@mdzip/editor 1.3.20 → 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 +49 -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 +46 -1
- package/dist/view-css.js.map +1 -1
- package/dist/view.d.ts +342 -1
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +1425 -99
- 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`);
|
|
@@ -147,6 +149,16 @@ function renderContextMenuItems(items) {
|
|
|
147
149
|
.map((item) => (item === null ? MENU_SEPARATOR_HTML : renderContextMenuItem(item)))
|
|
148
150
|
.join('');
|
|
149
151
|
}
|
|
152
|
+
const PREVIEW_MAX_WIDTH_ALIASES = {
|
|
153
|
+
narrow: 650,
|
|
154
|
+
default: 900,
|
|
155
|
+
wide: 1200,
|
|
156
|
+
};
|
|
157
|
+
function resolvePreviewMaxWidthPx(value) {
|
|
158
|
+
if (value === undefined)
|
|
159
|
+
return undefined;
|
|
160
|
+
return typeof value === 'number' ? value : PREVIEW_MAX_WIDTH_ALIASES[value];
|
|
161
|
+
}
|
|
150
162
|
const ALL_HEADINGS = [1, 2, 3, 4, 5, 6];
|
|
151
163
|
const ALL_LAYOUT_CONTROLS = {
|
|
152
164
|
source: true,
|
|
@@ -380,21 +392,29 @@ const mdzipEditorTheme = EditorView.theme({
|
|
|
380
392
|
overflow: 'auto',
|
|
381
393
|
},
|
|
382
394
|
'.cm-content': {
|
|
383
|
-
padding: 'var(--mdzip-editor-content-padding, var(--mdzip-density-editor-content-padding, 36px 48px))',
|
|
395
|
+
padding: 'var(--mdzip-editor-content-padding, var(--mdzip-density-editor-content-padding, 36px 48px 36px 16px))',
|
|
384
396
|
caretColor: 'var(--mdzip-editor-cursor-color)',
|
|
385
397
|
overflowWrap: 'anywhere',
|
|
386
398
|
wordBreak: 'normal',
|
|
387
399
|
},
|
|
388
400
|
'.cm-gutters': {
|
|
389
|
-
background: '
|
|
390
|
-
|
|
401
|
+
background: 'transparent',
|
|
402
|
+
border: 'none',
|
|
391
403
|
color: 'var(--mdzip-line-number-foreground-color)',
|
|
392
404
|
fontFamily: '"Cascadia Code", Consolas, monospace',
|
|
393
|
-
|
|
405
|
+
fontSize: '0.85em',
|
|
406
|
+
// CodeMirror force-sets each gutter element's height to match its
|
|
407
|
+
// content line's rendered height, but a unitless/inherited line-height
|
|
408
|
+
// resolves against the gutter's own (smaller) font-size — leaving the
|
|
409
|
+
// number shorter than its box and rendering top-aligned instead of
|
|
410
|
+
// matching the content line's baseline. Pin it to the same absolute
|
|
411
|
+
// line-height as the content (fontSize '&' * the '.cm-scroller' 1.5
|
|
412
|
+
// multiplier) so both sit centered in equal-height boxes.
|
|
413
|
+
lineHeight: 'calc(16px * var(--mdz-zoom, 1) * 1.5)',
|
|
414
|
+
opacity: '0.65',
|
|
394
415
|
},
|
|
395
416
|
'.cm-lineNumbers .cm-gutterElement': {
|
|
396
417
|
padding: '0 8px 0 4px',
|
|
397
|
-
minWidth: '44px',
|
|
398
418
|
},
|
|
399
419
|
'&.cm-focused .cm-cursor': {
|
|
400
420
|
borderLeftColor: 'var(--mdzip-editor-cursor-color)',
|
|
@@ -415,6 +435,29 @@ const mdzipEditorTheme = EditorView.theme({
|
|
|
415
435
|
color: 'var(--mdzip-muted-foreground-color)',
|
|
416
436
|
opacity: '0.65',
|
|
417
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
|
+
},
|
|
418
461
|
'.cm-panels': {
|
|
419
462
|
background: 'var(--mdzip-widget-background-color)',
|
|
420
463
|
color: 'var(--mdzip-editor-foreground-color)',
|
|
@@ -537,6 +580,100 @@ const htmlTagMarkerHighlight = ViewPlugin.fromClass(class {
|
|
|
537
580
|
}, {
|
|
538
581
|
decorations: value => value.decorations
|
|
539
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
|
+
});
|
|
540
677
|
function injectStyles(doc) {
|
|
541
678
|
const existing = doc.querySelector(`style[${STYLE_ATTR}]`);
|
|
542
679
|
if (existing) {
|
|
@@ -680,11 +817,11 @@ function navNodeOrder(a, b) {
|
|
|
680
817
|
function applyRawHtmlImageSizeAttributes(image) {
|
|
681
818
|
const width = image.getAttribute('width');
|
|
682
819
|
if (width && /^\d+$/.test(width) && !image.style.width) {
|
|
683
|
-
image.style.width =
|
|
820
|
+
image.style.width = `calc(${width}px * var(--mdz-zoom, 1))`;
|
|
684
821
|
}
|
|
685
822
|
const height = image.getAttribute('height');
|
|
686
823
|
if (height && /^\d+$/.test(height) && !image.style.height) {
|
|
687
|
-
image.style.height =
|
|
824
|
+
image.style.height = `calc(${height}px * var(--mdz-zoom, 1))`;
|
|
688
825
|
}
|
|
689
826
|
}
|
|
690
827
|
// Maps the legacy raw HTML `align="left"|"right"` attribute (or the editor's
|
|
@@ -720,6 +857,53 @@ export class MdzipWorkspaceView {
|
|
|
720
857
|
// trigger) there's no "currently open document" to race against and no
|
|
721
858
|
// de-dupe/staleness guard is needed.
|
|
722
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;
|
|
723
907
|
this.navPaneWidth = 280;
|
|
724
908
|
this.splitRatio = 0.5;
|
|
725
909
|
this.resizing = false;
|
|
@@ -745,6 +929,12 @@ export class MdzipWorkspaceView {
|
|
|
745
929
|
this.tooltipState = null;
|
|
746
930
|
this.tooltipShowTimer = null;
|
|
747
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;
|
|
748
938
|
this.cmEditor = null;
|
|
749
939
|
this.readOnlyCompartment = new Compartment();
|
|
750
940
|
this.lineNumbersCompartment = new Compartment();
|
|
@@ -756,6 +946,16 @@ export class MdzipWorkspaceView {
|
|
|
756
946
|
// syncScrollFromPreview/syncScrollToPreview).
|
|
757
947
|
this.lastSyncedEditorScrollTop = null;
|
|
758
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;
|
|
759
959
|
this.markdownExtensions = [];
|
|
760
960
|
this.entryRenderers = [];
|
|
761
961
|
this.renderingService = new MdzipRenderingService();
|
|
@@ -779,8 +979,10 @@ export class MdzipWorkspaceView {
|
|
|
779
979
|
this.controlPolicy = resolveMdzipControlPolicy(options.controls);
|
|
780
980
|
this.navigationMode = options.navigationMode ?? 'editor';
|
|
781
981
|
this.imageHydrationAnimation = options.imageHydrationAnimation ?? 'auto';
|
|
982
|
+
this.progressiveTextRendering = options.progressiveTextRendering ?? false;
|
|
782
983
|
this.toolbarDensity = options.toolbarDensity ?? 'comfortable';
|
|
783
984
|
this.contentDensity = options.contentDensity ?? 'comfortable';
|
|
985
|
+
this.previewMaxWidth = options.previewMaxWidth;
|
|
784
986
|
this.layout = options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy);
|
|
785
987
|
this.colorScheme = options.initialColorScheme
|
|
786
988
|
?? (container.ownerDocument.defaultView?.matchMedia('(prefers-color-scheme: dark)').matches
|
|
@@ -853,6 +1055,17 @@ export class MdzipWorkspaceView {
|
|
|
853
1055
|
this.elPackFilesModeProject = q('[data-ref="pack-files-mode-project"]');
|
|
854
1056
|
this.elPackFilesEntrySelect = q('[data-ref="pack-files-entry"]');
|
|
855
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"]');
|
|
856
1069
|
this.elNavMenu = q('[data-ref="nav-menu"]');
|
|
857
1070
|
this.elNameDialog = q('[data-ref="name-dialog"]');
|
|
858
1071
|
this.elNameDialogHeading = q('[data-ref="name-dialog-heading"]');
|
|
@@ -864,6 +1077,7 @@ export class MdzipWorkspaceView {
|
|
|
864
1077
|
this.elDeleteConfirmBtn = q('[data-ref="delete-confirm-btn"]');
|
|
865
1078
|
this.elReplaceInput = q('[data-ref="replace-input"]');
|
|
866
1079
|
this.elTooltip = q('[data-ref="tooltip"]');
|
|
1080
|
+
this.elCopyToast = q('[data-ref="copy-toast"]');
|
|
867
1081
|
this.elEmptyState = q('[data-ref="empty-state"]');
|
|
868
1082
|
this.prepareTooltips();
|
|
869
1083
|
this.attachEvents();
|
|
@@ -885,6 +1099,7 @@ export class MdzipWorkspaceView {
|
|
|
885
1099
|
this.resetRenderingState();
|
|
886
1100
|
this.cmEditor?.destroy();
|
|
887
1101
|
this.cmEditor = null;
|
|
1102
|
+
this.workspace?.dispose();
|
|
888
1103
|
this.workspace = null;
|
|
889
1104
|
this.replaceAssetSession(null);
|
|
890
1105
|
this.conversionDocumentGeneration += 1;
|
|
@@ -966,6 +1181,7 @@ export class MdzipWorkspaceView {
|
|
|
966
1181
|
this.resetRenderingState();
|
|
967
1182
|
this.cmEditor?.destroy();
|
|
968
1183
|
this.cmEditor = null;
|
|
1184
|
+
this.workspace?.dispose();
|
|
969
1185
|
this.workspace = null;
|
|
970
1186
|
this.replaceAssetSession(null);
|
|
971
1187
|
this.conversionDocumentGeneration += 1;
|
|
@@ -1159,6 +1375,12 @@ export class MdzipWorkspaceView {
|
|
|
1159
1375
|
this.teardownEntryRenderer();
|
|
1160
1376
|
// Release any whenRendered() waiters so their promises do not hang.
|
|
1161
1377
|
this.flushRenderedWaiters();
|
|
1378
|
+
try {
|
|
1379
|
+
this.workspace?.dispose();
|
|
1380
|
+
}
|
|
1381
|
+
catch {
|
|
1382
|
+
// Ignore worker teardown errors
|
|
1383
|
+
}
|
|
1162
1384
|
try {
|
|
1163
1385
|
this.cmEditor?.destroy();
|
|
1164
1386
|
}
|
|
@@ -1254,10 +1476,26 @@ export class MdzipWorkspaceView {
|
|
|
1254
1476
|
this.contentDensity = nextContentDensity;
|
|
1255
1477
|
this.applyDensityClasses();
|
|
1256
1478
|
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Sets the preview reading-column width. Developer-facing only — there is
|
|
1481
|
+
* no toolbar UI for this. Pass `undefined` to return to the built-in
|
|
1482
|
+
* default (which scales with zoom); any other value is used exactly as
|
|
1483
|
+
* given and does not scale with zoom (see {@link MdzipPreviewMaxWidth}).
|
|
1484
|
+
*/
|
|
1485
|
+
setPreviewMaxWidth(value) {
|
|
1486
|
+
if (value === this.previewMaxWidth) {
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
this.previewMaxWidth = value;
|
|
1490
|
+
this.render();
|
|
1491
|
+
}
|
|
1257
1492
|
setImageInsertOptions(options) {
|
|
1258
1493
|
this.options.imageInsertMode = options.imageInsertMode;
|
|
1259
1494
|
this.options.imageInsertHandler = options.imageInsertHandler;
|
|
1260
1495
|
}
|
|
1496
|
+
setImageEditOptions(options) {
|
|
1497
|
+
this.options.imageEditHandler = options.imageEditHandler;
|
|
1498
|
+
}
|
|
1261
1499
|
applyDensityClasses() {
|
|
1262
1500
|
this.elRoot.classList.remove('toolbar-density-comfortable', 'toolbar-density-compact', 'toolbar-density-dense', 'content-density-comfortable', 'content-density-compact');
|
|
1263
1501
|
this.elRoot.classList.add(`toolbar-density-${this.toolbarDensity}`);
|
|
@@ -1296,6 +1534,26 @@ export class MdzipWorkspaceView {
|
|
|
1296
1534
|
this.options.onFailed?.(error);
|
|
1297
1535
|
}
|
|
1298
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;
|
|
1299
1557
|
}
|
|
1300
1558
|
updatePreview(snapshot, entryClaimed) {
|
|
1301
1559
|
if (entryClaimed) {
|
|
@@ -1311,11 +1569,7 @@ export class MdzipWorkspaceView {
|
|
|
1311
1569
|
return;
|
|
1312
1570
|
}
|
|
1313
1571
|
const memo = this.previewMemo;
|
|
1314
|
-
if (
|
|
1315
|
-
&& memo.path === snapshot.currentPath
|
|
1316
|
-
&& memo.pathType === snapshot.currentPathType
|
|
1317
|
-
&& memo.text === snapshot.currentText
|
|
1318
|
-
&& memo.colorScheme === this.colorScheme) {
|
|
1572
|
+
if (this.previewMemoMatchesSnapshot(snapshot)) {
|
|
1319
1573
|
// Nothing that feeds the preview changed; keep the existing DOM and any
|
|
1320
1574
|
// mounted extension handles.
|
|
1321
1575
|
return;
|
|
@@ -1356,6 +1610,10 @@ export class MdzipWorkspaceView {
|
|
|
1356
1610
|
const abort = new AbortController();
|
|
1357
1611
|
this.previewAbort = abort;
|
|
1358
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
|
+
}
|
|
1359
1617
|
let result;
|
|
1360
1618
|
try {
|
|
1361
1619
|
result = this.renderingService.renderMarkdown(snapshot.currentText, context);
|
|
@@ -1427,28 +1685,86 @@ export class MdzipWorkspaceView {
|
|
|
1427
1685
|
*/
|
|
1428
1686
|
mountProgressivePreview(html, snapshot, context, generation, animateImageHydration) {
|
|
1429
1687
|
this.elPreviewContent.innerHTML = html;
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
+
}
|
|
1432
1717
|
const pending = [];
|
|
1433
|
-
for (const
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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;
|
|
1441
1729
|
}
|
|
1442
|
-
|
|
1730
|
+
image.removeAttribute('src');
|
|
1731
|
+
if (animateImageHydration) {
|
|
1732
|
+
image.classList.add('mdzip-image-loading');
|
|
1733
|
+
}
|
|
1734
|
+
pending.push({ image, source });
|
|
1443
1735
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
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();
|
|
1451
1765
|
}
|
|
1766
|
+
};
|
|
1767
|
+
const createImageSlot = (image) => {
|
|
1452
1768
|
const slot = document.createElement('span');
|
|
1453
1769
|
slot.className = animateImageHydration
|
|
1454
1770
|
? 'mdzip-image-slot'
|
|
@@ -1462,26 +1778,9 @@ export class MdzipWorkspaceView {
|
|
|
1462
1778
|
}
|
|
1463
1779
|
image.parentNode?.insertBefore(slot, image);
|
|
1464
1780
|
slot.appendChild(image);
|
|
1465
|
-
|
|
1466
|
-
}
|
|
1467
|
-
this.mountPreviewExtensions(context, generation);
|
|
1468
|
-
const codeBlockHandle = this.mountCodeBlockControls();
|
|
1469
|
-
if (codeBlockHandle) {
|
|
1470
|
-
this.previewHandles.push(codeBlockHandle);
|
|
1471
|
-
}
|
|
1472
|
-
this.firePreviewRendered(snapshot, generation);
|
|
1473
|
-
if (!session || pending.length === 0) {
|
|
1474
|
-
this.fireAssetsHydrated(snapshot, generation);
|
|
1475
|
-
return;
|
|
1476
|
-
}
|
|
1477
|
-
let remaining = pending.length;
|
|
1478
|
-
const settle = () => {
|
|
1479
|
-
remaining -= 1;
|
|
1480
|
-
if (remaining === 0) {
|
|
1481
|
-
this.fireAssetsHydrated(snapshot, generation);
|
|
1482
|
-
}
|
|
1781
|
+
return slot;
|
|
1483
1782
|
};
|
|
1484
|
-
|
|
1783
|
+
const hydrate = (image, slot, source) => {
|
|
1485
1784
|
void session.resolveImage(source, context.currentPath).then((resolved) => {
|
|
1486
1785
|
if (generation !== this.previewGeneration || context.signal.aborted) {
|
|
1487
1786
|
settle();
|
|
@@ -1513,6 +1812,294 @@ export class MdzipWorkspaceView {
|
|
|
1513
1812
|
this.openImageSlot(slot);
|
|
1514
1813
|
settle();
|
|
1515
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;
|
|
1516
2103
|
}
|
|
1517
2104
|
}
|
|
1518
2105
|
/**
|
|
@@ -1575,13 +2162,20 @@ export class MdzipWorkspaceView {
|
|
|
1575
2162
|
+ 'CSP-restricted host (e.g. a VS Code webview), ensure img-src permits '
|
|
1576
2163
|
+ 'blob: and data:.'));
|
|
1577
2164
|
}
|
|
1578
|
-
|
|
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) {
|
|
1579
2173
|
for (const extension of this.markdownExtensions) {
|
|
1580
2174
|
if (!extension.mount) {
|
|
1581
2175
|
continue;
|
|
1582
2176
|
}
|
|
1583
2177
|
try {
|
|
1584
|
-
const mounted = extension.mount(
|
|
2178
|
+
const mounted = extension.mount(root, context);
|
|
1585
2179
|
if (isThenable(mounted)) {
|
|
1586
2180
|
void Promise.resolve(mounted).then((handle) => {
|
|
1587
2181
|
if (!handle) {
|
|
@@ -1621,12 +2215,15 @@ export class MdzipWorkspaceView {
|
|
|
1621
2215
|
* extension) — every consumer gets this automatically, gated by policy
|
|
1622
2216
|
* rather than opt-in wiring. Runs after `mountPreviewExtensions` so any
|
|
1623
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.
|
|
1624
2221
|
*/
|
|
1625
|
-
mountCodeBlockControls() {
|
|
2222
|
+
mountCodeBlockControls(root = this.elPreviewContent) {
|
|
1626
2223
|
if (!this.controlPolicy.codeBlockTools) {
|
|
1627
2224
|
return null;
|
|
1628
2225
|
}
|
|
1629
|
-
const codeEls = Array.from(
|
|
2226
|
+
const codeEls = Array.from(root.querySelectorAll('pre > code'));
|
|
1630
2227
|
if (codeEls.length === 0) {
|
|
1631
2228
|
return null;
|
|
1632
2229
|
}
|
|
@@ -1969,6 +2566,8 @@ export class MdzipWorkspaceView {
|
|
|
1969
2566
|
syntaxHighlighting(mdzipMarkdownHighlight),
|
|
1970
2567
|
hardBreakMarkerHighlight,
|
|
1971
2568
|
htmlTagMarkerHighlight,
|
|
2569
|
+
noSpellcheckHighlight,
|
|
2570
|
+
imageEditAffordanceField,
|
|
1972
2571
|
EditorView.lineWrapping,
|
|
1973
2572
|
dropCursor(),
|
|
1974
2573
|
// Content is contenteditable, but browsers don't agree on a default
|
|
@@ -2024,6 +2623,23 @@ export class MdzipWorkspaceView {
|
|
|
2024
2623
|
void self.handleEditorImageDrop(file, event.clientX, event.clientY);
|
|
2025
2624
|
return true;
|
|
2026
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) });
|
|
2027
2643
|
}
|
|
2028
2644
|
}),
|
|
2029
2645
|
],
|
|
@@ -2034,6 +2650,10 @@ export class MdzipWorkspaceView {
|
|
|
2034
2650
|
if (scroller) {
|
|
2035
2651
|
scroller.addEventListener('scroll', () => self.syncScrollToPreview());
|
|
2036
2652
|
}
|
|
2653
|
+
editor.dom.addEventListener(IMAGE_EDIT_AFFORDANCE_CLICK_EVENT, (event) => {
|
|
2654
|
+
const { from, to } = event.detail;
|
|
2655
|
+
void self.openImageEditFlow(from, to);
|
|
2656
|
+
});
|
|
2037
2657
|
return editor;
|
|
2038
2658
|
}
|
|
2039
2659
|
async ensureCmEditor(force = false) {
|
|
@@ -2049,6 +2669,23 @@ export class MdzipWorkspaceView {
|
|
|
2049
2669
|
const snapshot = this.workspace?.snapshot() ?? null;
|
|
2050
2670
|
this.elEmptyState.hidden = snapshot !== null;
|
|
2051
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
|
+
}
|
|
2052
2689
|
if (!snapshot) {
|
|
2053
2690
|
this.elDocumentStrip.hidden = true;
|
|
2054
2691
|
this.elToolbar.hidden = true;
|
|
@@ -2092,6 +2729,13 @@ export class MdzipWorkspaceView {
|
|
|
2092
2729
|
this.elRoot.style.setProperty('--mdz-zoom', String(this.zoom));
|
|
2093
2730
|
this.elRoot.style.setProperty('--nav-pane-width', `${this.navPaneWidth}px`);
|
|
2094
2731
|
this.elRoot.style.setProperty('--split-edit-ratio', String(this.splitRatio));
|
|
2732
|
+
const previewMaxWidthPx = resolvePreviewMaxWidthPx(this.previewMaxWidth);
|
|
2733
|
+
if (previewMaxWidthPx === undefined) {
|
|
2734
|
+
this.elRoot.style.removeProperty('--mdzip-preview-content-max-width');
|
|
2735
|
+
}
|
|
2736
|
+
else {
|
|
2737
|
+
this.elRoot.style.setProperty('--mdzip-preview-content-max-width', `${previewMaxWidthPx}px`);
|
|
2738
|
+
}
|
|
2095
2739
|
this.elRoot.classList.toggle('resizing', this.resizing);
|
|
2096
2740
|
this.elRoot.classList.toggle('mdzip-theme-dark', this.colorScheme === 'dark');
|
|
2097
2741
|
this.elRoot.classList.toggle('mdzip-theme-light', this.colorScheme === 'light');
|
|
@@ -2226,15 +2870,26 @@ export class MdzipWorkspaceView {
|
|
|
2226
2870
|
this.elImageInsertPositionSelect.value = 'inline';
|
|
2227
2871
|
this.updateImageInsertOptionControls();
|
|
2228
2872
|
}
|
|
2229
|
-
this.
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
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;
|
|
2238
2893
|
}
|
|
2239
2894
|
this.elMetadataDialog.hidden = !this.metadataDialogOpen;
|
|
2240
2895
|
if (this.contextMenuState) {
|
|
@@ -2328,10 +2983,11 @@ export class MdzipWorkspaceView {
|
|
|
2328
2983
|
}
|
|
2329
2984
|
});
|
|
2330
2985
|
// Ctrl/Cmd+A normally selects the whole page, which in split layout grabs
|
|
2331
|
-
// both panes' text at once. Scope it to
|
|
2332
|
-
// focus is inside the preview pane (see the
|
|
2333
|
-
// how it gets there); the source editor
|
|
2334
|
-
// 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.
|
|
2335
2991
|
doc.addEventListener('keydown', (e) => {
|
|
2336
2992
|
if (e.key.toLowerCase() !== 'a' || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) {
|
|
2337
2993
|
return;
|
|
@@ -2340,7 +2996,7 @@ export class MdzipWorkspaceView {
|
|
|
2340
2996
|
return;
|
|
2341
2997
|
}
|
|
2342
2998
|
e.preventDefault();
|
|
2343
|
-
this.
|
|
2999
|
+
void this.copyAllPreviewContent();
|
|
2344
3000
|
});
|
|
2345
3001
|
this.elNavBtn.addEventListener('click', () => {
|
|
2346
3002
|
if (!this.controlPolicy.navigation) {
|
|
@@ -2611,6 +3267,28 @@ export class MdzipWorkspaceView {
|
|
|
2611
3267
|
this.elPackFilesConfirmBtn.addEventListener('click', () => {
|
|
2612
3268
|
this.resolvePackFilesDialog(this.readPackFilesDialogDecision());
|
|
2613
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
|
+
});
|
|
2614
3292
|
this.elNavMenu.addEventListener('click', (e) => {
|
|
2615
3293
|
e.stopPropagation();
|
|
2616
3294
|
const item = e.target.closest('[data-menu-action]');
|
|
@@ -2876,6 +3554,18 @@ export class MdzipWorkspaceView {
|
|
|
2876
3554
|
this.tooltipHideTimer = null;
|
|
2877
3555
|
}, 40);
|
|
2878
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
|
+
}
|
|
2879
3569
|
async save() {
|
|
2880
3570
|
try {
|
|
2881
3571
|
const workspace = this.workspace;
|
|
@@ -2930,15 +3620,35 @@ export class MdzipWorkspaceView {
|
|
|
2930
3620
|
}
|
|
2931
3621
|
renderMetadata(snapshot) {
|
|
2932
3622
|
const manifest = snapshot.content.manifest;
|
|
3623
|
+
const isMdz = snapshot.sourceFormat === 'mdz';
|
|
2933
3624
|
const fields = [
|
|
2934
3625
|
['Filename', snapshot.fileName],
|
|
2935
|
-
['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)],
|
|
2936
3634
|
['Document title', snapshot.displayTitle],
|
|
2937
3635
|
['First heading', snapshot.headingFallback ?? 'Not found'],
|
|
2938
3636
|
['Created', formatMetadataValue(manifest?.created)],
|
|
2939
3637
|
['Modified', formatMetadataValue(manifest?.modified)],
|
|
2940
|
-
['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']
|
|
2941
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
|
+
}
|
|
2942
3652
|
this.elMetadataList.replaceChildren(...fields.map(([label, value]) => {
|
|
2943
3653
|
return this.createMetadataRow(label, value);
|
|
2944
3654
|
}));
|
|
@@ -3254,6 +3964,61 @@ export class MdzipWorkspaceView {
|
|
|
3254
3964
|
this.cmEditor.focus();
|
|
3255
3965
|
}
|
|
3256
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
|
+
}
|
|
3257
4022
|
async resolveImageInsertDecision(bytes, mimeType, options) {
|
|
3258
4023
|
const size = sniffImageSize(bytes, mimeType);
|
|
3259
4024
|
const request = {
|
|
@@ -3550,7 +4315,7 @@ export class MdzipWorkspaceView {
|
|
|
3550
4315
|
}
|
|
3551
4316
|
}
|
|
3552
4317
|
// Items for the rendered-preview selection menu. Taking over `contextmenu`
|
|
3553
|
-
// to offer
|
|
4318
|
+
// to offer Copy All also suppresses the browser's native menu — which is
|
|
3554
4319
|
// the only thing that was otherwise offering Copy on a right-click — so
|
|
3555
4320
|
// Copy has to be reinstated explicitly whenever there's a selection.
|
|
3556
4321
|
previewMenuItems() {
|
|
@@ -3564,11 +4329,16 @@ export class MdzipWorkspaceView {
|
|
|
3564
4329
|
items.push(null);
|
|
3565
4330
|
}
|
|
3566
4331
|
items.push({
|
|
3567
|
-
action: 'preview-
|
|
3568
|
-
label: '
|
|
3569
|
-
icon:
|
|
4332
|
+
action: 'preview-copy-all',
|
|
4333
|
+
label: 'Copy All',
|
|
4334
|
+
icon: MENU_COPY_ICON_HTML,
|
|
3570
4335
|
shortcut: this.editorShortcut('A')
|
|
3571
4336
|
});
|
|
4337
|
+
items.push({
|
|
4338
|
+
action: 'preview-copy-all-images',
|
|
4339
|
+
label: 'Copy All with Images',
|
|
4340
|
+
icon: MENU_COPY_ICON_HTML
|
|
4341
|
+
});
|
|
3572
4342
|
return items;
|
|
3573
4343
|
}
|
|
3574
4344
|
handlePreviewMenuAction(action) {
|
|
@@ -3579,41 +4349,448 @@ export class MdzipWorkspaceView {
|
|
|
3579
4349
|
return;
|
|
3580
4350
|
}
|
|
3581
4351
|
switch (action) {
|
|
3582
|
-
case 'preview-
|
|
3583
|
-
this.
|
|
4352
|
+
case 'preview-copy-all':
|
|
4353
|
+
void this.copyAllPreviewContent();
|
|
4354
|
+
break;
|
|
4355
|
+
case 'preview-copy-all-images':
|
|
4356
|
+
void this.copyAllWithImagesPreviewContent();
|
|
3584
4357
|
break;
|
|
3585
4358
|
case 'preview-copy':
|
|
3586
|
-
|
|
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));
|
|
3587
4362
|
break;
|
|
3588
4363
|
}
|
|
3589
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). */
|
|
3590
4436
|
async copyPreviewSelection(text) {
|
|
3591
4437
|
if (!text) {
|
|
3592
|
-
return;
|
|
4438
|
+
return null;
|
|
3593
4439
|
}
|
|
3594
4440
|
try {
|
|
3595
|
-
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' };
|
|
3596
4443
|
}
|
|
3597
4444
|
catch (error) {
|
|
3598
4445
|
this.options.onFailed?.(error);
|
|
4446
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
3599
4447
|
}
|
|
3600
4448
|
}
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
selectAllPreviewContent() {
|
|
3605
|
-
const doc = this.elPreviewContent.ownerDocument;
|
|
3606
|
-
const selection = doc.defaultView?.getSelection();
|
|
3607
|
-
if (!selection) {
|
|
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) {
|
|
3608
4452
|
return;
|
|
3609
4453
|
}
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
this.
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
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 ?? ''));
|
|
4518
|
+
return;
|
|
4519
|
+
}
|
|
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
|
+
}
|
|
3617
4794
|
}
|
|
3618
4795
|
// Inserts a fenced code block, carrying the chosen language as the fence info
|
|
3619
4796
|
// string. An empty language produces a plain ```` ``` ```` block.
|
|
@@ -4012,6 +5189,14 @@ export class MdzipWorkspaceView {
|
|
|
4012
5189
|
setZoom(value) {
|
|
4013
5190
|
this.zoom = Math.max(0.5, Math.min(2.5, Math.round(value * 100) / 100));
|
|
4014
5191
|
this.render();
|
|
5192
|
+
// render() only updates the --mdz-zoom CSS variable; CodeMirror caches
|
|
5193
|
+
// gutter/line-block heights from its own measure pass and has no way to
|
|
5194
|
+
// observe a custom-property change (font-size grows but the scroller's
|
|
5195
|
+
// own outer box doesn't, so no ResizeObserver fires either). Without
|
|
5196
|
+
// this, gutter row heights stay pinned to their pre-zoom pixel values
|
|
5197
|
+
// while .cm-line rows reflow normally, drifting the two further apart
|
|
5198
|
+
// with every line.
|
|
5199
|
+
this.cmEditor?.requestMeasure();
|
|
4015
5200
|
}
|
|
4016
5201
|
/**
|
|
4017
5202
|
* Sets the active color scheme after construction.
|
|
@@ -4611,8 +5796,29 @@ export class MdzipWorkspaceView {
|
|
|
4611
5796
|
if (this.lastSyncedEditorScrollTop !== null && Math.abs(currentTop - this.lastSyncedEditorScrollTop) < 2) {
|
|
4612
5797
|
return;
|
|
4613
5798
|
}
|
|
4614
|
-
this.syncing = true;
|
|
4615
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;
|
|
4616
5822
|
const scrollRatio = editorHeight > 0 ? currentTop / editorHeight : 0;
|
|
4617
5823
|
const previewHeight = this.elPreviewPane.scrollHeight - this.elPreviewPane.clientHeight;
|
|
4618
5824
|
const target = scrollRatio * previewHeight;
|
|
@@ -4620,6 +5826,84 @@ export class MdzipWorkspaceView {
|
|
|
4620
5826
|
this.elPreviewPane.scrollTop = target;
|
|
4621
5827
|
this.syncing = false;
|
|
4622
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
|
+
}
|
|
4623
5907
|
}
|
|
4624
5908
|
function canShowSourceLayout(snapshot) {
|
|
4625
5909
|
return canEditMdzipPath(snapshot.currentPathType, snapshot.currentPath, 'editable');
|
|
@@ -4744,8 +6028,24 @@ function padHtmlImageBlock(html, currentText, selectionStart, selectionEnd) {
|
|
|
4744
6028
|
: after.startsWith('\n') ? '\n' : '\n\n';
|
|
4745
6029
|
return `${prefix}${html}${suffix}`;
|
|
4746
6030
|
}
|
|
4747
|
-
function
|
|
4748
|
-
|
|
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]}`;
|
|
4749
6049
|
}
|
|
4750
6050
|
function formatMetadataValue(value) {
|
|
4751
6051
|
if (typeof value === 'string' && value.trim()) {
|
|
@@ -5005,6 +6305,30 @@ const SHELL_HTML = `
|
|
|
5005
6305
|
</div>
|
|
5006
6306
|
</div>
|
|
5007
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
|
+
|
|
5008
6332
|
<div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
|
|
5009
6333
|
role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
|
|
5010
6334
|
<div class="title-dialog metadata-dialog">
|
|
@@ -5049,6 +6373,8 @@ const SHELL_HTML = `
|
|
|
5049
6373
|
|
|
5050
6374
|
<div class="mdzip-tooltip" data-ref="tooltip" role="tooltip" hidden></div>
|
|
5051
6375
|
|
|
6376
|
+
<div class="mdzip-copy-toast" data-ref="copy-toast" role="status" aria-live="polite" hidden></div>
|
|
6377
|
+
|
|
5052
6378
|
<p class="mdzip-empty" data-ref="empty-state">No MDZip workspace loaded.</p>
|
|
5053
6379
|
</section>
|
|
5054
6380
|
`;
|