@mdzip/editor 1.3.8 → 1.3.12
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 +122 -0
- package/dist/asset-cache.d.ts +11 -0
- package/dist/asset-cache.d.ts.map +1 -1
- package/dist/asset-cache.js +27 -0
- package/dist/asset-cache.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/mermaid.d.ts +46 -0
- package/dist/mermaid.d.ts.map +1 -0
- package/dist/mermaid.js +107 -0
- package/dist/mermaid.js.map +1 -0
- package/dist/rendering.d.ts +38 -1
- package/dist/rendering.d.ts.map +1 -1
- package/dist/rendering.js +34 -11
- package/dist/rendering.js.map +1 -1
- package/dist/view-css.d.ts.map +1 -1
- package/dist/view-css.js +281 -53
- package/dist/view-css.js.map +1 -1
- package/dist/view.d.ts +103 -2
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +575 -38
- package/dist/view.js.map +1 -1
- package/dist/workspace-view.d.ts +1 -1
- package/dist/workspace-view.d.ts.map +1 -1
- package/dist/workspace-view.js +38 -3
- package/dist/workspace-view.js.map +1 -1
- package/dist/workspace.d.ts +1 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +17 -5
- package/dist/workspace.js.map +1 -1
- package/package.json +14 -2
package/dist/view.js
CHANGED
|
@@ -2,11 +2,11 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirro
|
|
|
2
2
|
import { markdown } from '@codemirror/lang-markdown';
|
|
3
3
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
|
4
4
|
import { Compartment, EditorState } from '@codemirror/state';
|
|
5
|
-
import { EditorView, dropCursor, keymap, lineNumbers } from '@codemirror/view';
|
|
5
|
+
import { Decoration, EditorView, MatchDecorator, ViewPlugin, dropCursor, keymap, lineNumbers } from '@codemirror/view';
|
|
6
6
|
import { tags } from '@lezer/highlight';
|
|
7
|
-
import { Bold, ChevronDown, Code, Columns2, Eye, File, FileBraces, FileImage, Folder, FolderOpen, Hash, Heading1, ImagePlus, Info, Italic, Link2Off, Link, List, ListOrdered, Moon, PanelLeft, Quote, Save, SquarePen, Strikethrough, Sun, ZoomIn } from 'lucide';
|
|
7
|
+
import { Bold, ChevronDown, Code, Columns2, Eye, File, FileBraces, FileImage, Folder, FolderOpen, Hash, Heading1, ImagePlus, Info, Italic, Link2Off, Link, List, ListOrdered, CornerDownLeft, Moon, PackagePlus, PanelLeft, Quote, Save, SquarePen, Strikethrough, Sun, ZoomIn } from 'lucide';
|
|
8
8
|
import { browserClipboardHasImage, readBrowserClipboardImage } from './browser.js';
|
|
9
|
-
import { MdzipAssetSession, mdzipArchiveSourceId } from './asset-cache.js';
|
|
9
|
+
import { MdzipAssetSession, mdzipArchiveSourceId, sniffImageSize } from './asset-cache.js';
|
|
10
10
|
import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
|
|
11
11
|
import { MDZIP_RUNTIME_LIBRARIES } from './library-info.js';
|
|
12
12
|
import { MdzipWorkspaceService, extensionForMime, normalizeArchivePath, relativeArchivePath } from './workspace.js';
|
|
@@ -31,6 +31,7 @@ const ORPHAN_ICON_HTML = lucideIcon(Link2Off, '');
|
|
|
31
31
|
const SOURCE_EDIT_ICON_HTML = lucideIcon(SquarePen, TOOLBAR_ICON_CLASS);
|
|
32
32
|
const SOURCE_MARKDOWN_ICON_HTML = lucideIcon(Hash, TOOLBAR_ICON_CLASS);
|
|
33
33
|
const NAV_TOGGLE_ICON_HTML = lucideIcon(PanelLeft, `${TOOLBAR_ICON_CLASS} nav-toggle-icon`);
|
|
34
|
+
const CONVERT_TO_MDZ_ICON_HTML = lucideIcon(PackagePlus, `${TOOLBAR_ICON_CLASS} convert-mdz-icon`);
|
|
34
35
|
const PREVIEW_ICON_HTML = lucideIcon(Eye, TOOLBAR_ICON_CLASS);
|
|
35
36
|
const SPLIT_ICON_HTML = lucideIcon(Columns2, TOOLBAR_ICON_CLASS);
|
|
36
37
|
const SAVE_ICON_HTML = lucideIcon(Save, TOOLBAR_ICON_CLASS);
|
|
@@ -46,6 +47,7 @@ const BULLET_LIST_ICON_HTML = lucideIcon(List, FORMAT_ICON_CLASS);
|
|
|
46
47
|
const ORDERED_LIST_ICON_HTML = lucideIcon(ListOrdered, FORMAT_ICON_CLASS);
|
|
47
48
|
const CODE_ICON_HTML = lucideIcon(Code, FORMAT_ICON_CLASS);
|
|
48
49
|
const QUOTE_ICON_HTML = lucideIcon(Quote, FORMAT_ICON_CLASS);
|
|
50
|
+
const LINE_BREAK_ICON_HTML = lucideIcon(CornerDownLeft, FORMAT_ICON_CLASS);
|
|
49
51
|
const LINK_ICON_HTML = lucideIcon(Link, FORMAT_ICON_CLASS);
|
|
50
52
|
const IMAGE_FORMAT_ICON_HTML = lucideIcon(ImagePlus, FORMAT_ICON_CLASS);
|
|
51
53
|
const CHEVRON_ICON_HTML = lucideIcon(ChevronDown, 'format-chevron');
|
|
@@ -66,6 +68,7 @@ const ALL_FORMATTING_CONTROLS = {
|
|
|
66
68
|
inlineCode: true,
|
|
67
69
|
codeBlock: true,
|
|
68
70
|
blockquote: true,
|
|
71
|
+
lineBreak: true,
|
|
69
72
|
link: true,
|
|
70
73
|
image: true
|
|
71
74
|
};
|
|
@@ -79,6 +82,7 @@ const NO_FORMATTING_CONTROLS = {
|
|
|
79
82
|
inlineCode: false,
|
|
80
83
|
codeBlock: false,
|
|
81
84
|
blockquote: false,
|
|
85
|
+
lineBreak: false,
|
|
82
86
|
link: false,
|
|
83
87
|
image: false
|
|
84
88
|
};
|
|
@@ -228,6 +232,12 @@ function resolveFormattingControls(base, override) {
|
|
|
228
232
|
: [...new Set(headings)].filter((level) => ALL_HEADINGS.includes(level)).sort()
|
|
229
233
|
};
|
|
230
234
|
}
|
|
235
|
+
function normalizeToolbarDensity(value) {
|
|
236
|
+
return value === 'compact' || value === 'dense' ? value : 'comfortable';
|
|
237
|
+
}
|
|
238
|
+
function normalizeContentDensity(value) {
|
|
239
|
+
return value === 'compact' ? value : 'comfortable';
|
|
240
|
+
}
|
|
231
241
|
const mdzipEditorTheme = EditorView.theme({
|
|
232
242
|
'&': {
|
|
233
243
|
height: '100%',
|
|
@@ -242,7 +252,7 @@ const mdzipEditorTheme = EditorView.theme({
|
|
|
242
252
|
overflow: 'auto',
|
|
243
253
|
},
|
|
244
254
|
'.cm-content': {
|
|
245
|
-
padding: '36px 48px',
|
|
255
|
+
padding: 'var(--mdzip-editor-content-padding, var(--mdzip-density-editor-content-padding, 36px 48px))',
|
|
246
256
|
caretColor: 'var(--mdzip-editor-cursor-color)',
|
|
247
257
|
overflowWrap: 'anywhere',
|
|
248
258
|
wordBreak: 'normal',
|
|
@@ -273,6 +283,10 @@ const mdzipEditorTheme = EditorView.theme({
|
|
|
273
283
|
'.cm-activeLineGutter': {
|
|
274
284
|
background: 'transparent',
|
|
275
285
|
},
|
|
286
|
+
'.mdzip-hard-break-marker': {
|
|
287
|
+
color: 'var(--mdzip-muted-foreground-color)',
|
|
288
|
+
opacity: '0.65',
|
|
289
|
+
},
|
|
276
290
|
});
|
|
277
291
|
const mdzipMarkdownHighlight = HighlightStyle.define([
|
|
278
292
|
{ tag: [tags.heading1, tags.heading2, tags.heading3, tags.heading4, tags.heading5, tags.heading6],
|
|
@@ -288,6 +302,22 @@ const mdzipMarkdownHighlight = HighlightStyle.define([
|
|
|
288
302
|
{ tag: tags.contentSeparator, color: '#6a9955' },
|
|
289
303
|
{ tag: tags.atom, color: '#d100d1' },
|
|
290
304
|
]);
|
|
305
|
+
const hardBreakMarkerMatcher = new MatchDecorator({
|
|
306
|
+
regexp: /<br\s*\/?>/gi,
|
|
307
|
+
decoration: Decoration.mark({ class: 'mdzip-hard-break-marker' })
|
|
308
|
+
});
|
|
309
|
+
const hardBreakMarkerHighlight = ViewPlugin.fromClass(class {
|
|
310
|
+
constructor(view) {
|
|
311
|
+
this.decorations = hardBreakMarkerMatcher.createDeco(view);
|
|
312
|
+
}
|
|
313
|
+
update(update) {
|
|
314
|
+
if (update.docChanged || update.viewportChanged) {
|
|
315
|
+
this.decorations = hardBreakMarkerMatcher.updateDeco(update, this.decorations);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}, {
|
|
319
|
+
decorations: value => value.decorations
|
|
320
|
+
});
|
|
291
321
|
function injectStyles(doc) {
|
|
292
322
|
const existing = doc.querySelector(`style[${STYLE_ATTR}]`);
|
|
293
323
|
if (existing) {
|
|
@@ -312,7 +342,27 @@ function attributesToHtml(attrs) {
|
|
|
312
342
|
.map(([key, value]) => ` ${key}="${escapeHtml(String(value))}"`)
|
|
313
343
|
.join('');
|
|
314
344
|
}
|
|
315
|
-
|
|
345
|
+
// Renders the per-row indent/guide cells: one fixed-width cell per ancestor
|
|
346
|
+
// depth. An ancestor column draws a continuous vertical rail when that folder
|
|
347
|
+
// still has siblings below it (so the spine keeps going), otherwise it is
|
|
348
|
+
// blank. The final cell connects to this row with an elbow (└, last child) or a
|
|
349
|
+
// tee (├). Painting per row at the row's own height means the guides never
|
|
350
|
+
// overshoot an expanded last subfolder the way a single container rail did.
|
|
351
|
+
function renderNavGuides(trail, isLast) {
|
|
352
|
+
const cells = trail.map((continues) => `<span class="nav-indent${continues ? ' nav-indent-rail' : ''}"></span>`);
|
|
353
|
+
cells.push(`<span class="nav-indent nav-indent-connector${isLast ? '' : ' nav-indent-continues'}"></span>`);
|
|
354
|
+
return cells.join('');
|
|
355
|
+
}
|
|
356
|
+
function renderNavNode(node, state, options,
|
|
357
|
+
// Per-ancestor flags: whether each ancestor column still has a sibling below
|
|
358
|
+
// it (so its rail continues through this row). One entry per guide column
|
|
359
|
+
// above the immediate connector.
|
|
360
|
+
trail = [],
|
|
361
|
+
// Whether this node is the last among its siblings (elbow vs tee connector).
|
|
362
|
+
isLast = true,
|
|
363
|
+
// Nesting depth; depth 0 (root entries) draws no guides.
|
|
364
|
+
depth = 0) {
|
|
365
|
+
const guides = depth === 0 ? '' : renderNavGuides(trail, isLast);
|
|
316
366
|
if (node.entry) {
|
|
317
367
|
const isCurrent = node.entry.path === state.currentPath;
|
|
318
368
|
const isOrphaned = isOrphanedMdzipAsset(node.entry, state);
|
|
@@ -348,17 +398,20 @@ function renderNavNode(node, state, options) {
|
|
|
348
398
|
const draggable = options.allowDrag && !isManifest;
|
|
349
399
|
return `<button type="button" class="${classes}" title="${title}"
|
|
350
400
|
data-nav-path="${safePath}" data-orphan="${isOrphaned ? 'true' : ''}"${draggable ? ' draggable="true"' : ''}>
|
|
351
|
-
<span class="nav-caret"></span>
|
|
401
|
+
${guides}<span class="nav-caret"></span>
|
|
352
402
|
<span class="nav-file-icon ${iconKind}">${iconHtml}</span>
|
|
353
403
|
${orphanBtnHtml}
|
|
354
404
|
<span class="nav-label">${safeName}</span>
|
|
355
405
|
</button>`;
|
|
356
406
|
}
|
|
357
|
-
const
|
|
407
|
+
const childTrail = depth === 0 ? [] : [...trail, !isLast];
|
|
408
|
+
const children = node.children
|
|
409
|
+
.map((child, index) => renderNavNode(child, state, options, childTrail, index === node.children.length - 1, depth + 1))
|
|
410
|
+
.join('');
|
|
358
411
|
const pending = options.pendingFolders.has(node.path.toLowerCase());
|
|
359
412
|
return `<details class="nav-directory${pending ? ' pending-folder' : ''}" open data-nav-dir="${escapeHtml(node.path)}">
|
|
360
413
|
<summary${pending ? ` title="${escapeHtml(node.path)} — not saved until it contains a file"` : ''}>
|
|
361
|
-
<span class="nav-caret" aria-hidden="true"></span>
|
|
414
|
+
${guides}<span class="nav-caret" aria-hidden="true"></span>
|
|
362
415
|
<span class="nav-folder-icon closed">${FOLDER_CLOSED_ICON_HTML}</span>
|
|
363
416
|
<span class="nav-folder-icon open">${FOLDER_OPEN_ICON_HTML}</span>
|
|
364
417
|
<span class="nav-label">${escapeHtml(node.name)}</span>
|
|
@@ -412,6 +465,7 @@ export class MdzipWorkspaceView {
|
|
|
412
465
|
this.metadataDialogOpen = false;
|
|
413
466
|
this.titleDraft = '';
|
|
414
467
|
this.conversionAction = null;
|
|
468
|
+
this.imageInsertDialogState = null;
|
|
415
469
|
this.navPaneWidth = 280;
|
|
416
470
|
this.splitRatio = 0.5;
|
|
417
471
|
this.resizing = false;
|
|
@@ -422,6 +476,11 @@ export class MdzipWorkspaceView {
|
|
|
422
476
|
this.pendingReplacePath = null;
|
|
423
477
|
this.conversionHookPending = false;
|
|
424
478
|
this.conversionDocumentGeneration = 0;
|
|
479
|
+
// Monotonic token bumped on every openArchive/openWorkspace call. The async
|
|
480
|
+
// parse for a superseded call must not win the race and overwrite the latest
|
|
481
|
+
// input, so each call captures its token and discards its result once a newer
|
|
482
|
+
// open has started.
|
|
483
|
+
this.openGeneration = 0;
|
|
425
484
|
this.dragSourcePath = null;
|
|
426
485
|
this.dragOverElement = null;
|
|
427
486
|
this.tooltipState = null;
|
|
@@ -429,6 +488,7 @@ export class MdzipWorkspaceView {
|
|
|
429
488
|
this.tooltipHideTimer = null;
|
|
430
489
|
this.cmEditor = null;
|
|
431
490
|
this.readOnlyCompartment = new Compartment();
|
|
491
|
+
this.lineNumbersCompartment = new Compartment();
|
|
432
492
|
this.updatingCm = false;
|
|
433
493
|
this.syncing = false;
|
|
434
494
|
this.markdownExtensions = [];
|
|
@@ -453,6 +513,9 @@ export class MdzipWorkspaceView {
|
|
|
453
513
|
this.options = options;
|
|
454
514
|
this.controlPolicy = resolveMdzipControlPolicy(options.controls);
|
|
455
515
|
this.navigationMode = options.navigationMode ?? 'editor';
|
|
516
|
+
this.imageHydrationAnimation = options.imageHydrationAnimation ?? 'auto';
|
|
517
|
+
this.toolbarDensity = options.toolbarDensity ?? 'comfortable';
|
|
518
|
+
this.contentDensity = options.contentDensity ?? 'comfortable';
|
|
456
519
|
this.layout = options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy);
|
|
457
520
|
this.colorScheme = options.initialColorScheme
|
|
458
521
|
?? (container.ownerDocument.defaultView?.matchMedia('(prefers-color-scheme: dark)').matches
|
|
@@ -468,6 +531,7 @@ export class MdzipWorkspaceView {
|
|
|
468
531
|
container.innerHTML = SHELL_HTML;
|
|
469
532
|
const q = (sel) => container.querySelector(sel);
|
|
470
533
|
this.elRoot = q('.mdzip-root');
|
|
534
|
+
this.applyDensityClasses();
|
|
471
535
|
this.elDocumentStrip = q('[data-ref="document-strip"]');
|
|
472
536
|
this.elToolbar = q('[data-ref="toolbar"]');
|
|
473
537
|
this.elToolbarLeft = q('.toolbar-left');
|
|
@@ -510,6 +574,14 @@ export class MdzipWorkspaceView {
|
|
|
510
574
|
this.elLibraryList = q('[data-ref="library-list"]');
|
|
511
575
|
this.elConversionDialog = q('[data-ref="conversion-dialog"]');
|
|
512
576
|
this.elConversionConfirmBtn = q('[data-ref="conversion-confirm-btn"]');
|
|
577
|
+
this.elImageInsertDialog = q('[data-ref="image-insert-dialog"]');
|
|
578
|
+
this.elImageInsertModeMarkdown = q('[data-ref="image-insert-mode-markdown"]');
|
|
579
|
+
this.elImageInsertModeHtml = q('[data-ref="image-insert-mode-html"]');
|
|
580
|
+
this.elImageInsertAltInput = q('[data-ref="image-insert-alt"]');
|
|
581
|
+
this.elImageInsertSizeModeSelect = q('[data-ref="image-insert-size-mode"]');
|
|
582
|
+
this.elImageInsertSizeValueInput = q('[data-ref="image-insert-size-value"]');
|
|
583
|
+
this.elImageInsertPositionSelect = q('[data-ref="image-insert-position"]');
|
|
584
|
+
this.elImageInsertConfirmBtn = q('[data-ref="image-insert-confirm-btn"]');
|
|
513
585
|
this.elNavMenu = q('[data-ref="nav-menu"]');
|
|
514
586
|
this.elNameDialog = q('[data-ref="name-dialog"]');
|
|
515
587
|
this.elNameDialogHeading = q('[data-ref="name-dialog-heading"]');
|
|
@@ -537,6 +609,7 @@ export class MdzipWorkspaceView {
|
|
|
537
609
|
await this.openArchive(bytes, options);
|
|
538
610
|
}
|
|
539
611
|
async openArchive(bytes, options = {}) {
|
|
612
|
+
const generation = ++this.openGeneration;
|
|
540
613
|
this.unsub?.();
|
|
541
614
|
this.resetRenderingState();
|
|
542
615
|
this.cmEditor?.destroy();
|
|
@@ -546,6 +619,11 @@ export class MdzipWorkspaceView {
|
|
|
546
619
|
this.conversionDocumentGeneration += 1;
|
|
547
620
|
try {
|
|
548
621
|
const ws = await MdzipWorkspaceService.open(bytes, options);
|
|
622
|
+
if (generation !== this.openGeneration) {
|
|
623
|
+
// A newer open() started while this parse was in flight; discard this
|
|
624
|
+
// stale result so the latest input remains authoritative.
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
549
627
|
this.workspace = ws;
|
|
550
628
|
this.replaceAssetSession(new MdzipAssetSession(ws, ws.snapshot().workspace.assets, this.elRoot.ownerDocument, {
|
|
551
629
|
cache: this.options.assetCache,
|
|
@@ -558,6 +636,9 @@ export class MdzipWorkspaceView {
|
|
|
558
636
|
}
|
|
559
637
|
this.layout = this.validLayoutForSnapshot(this.options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy), snap);
|
|
560
638
|
await this.ensureCmEditor();
|
|
639
|
+
if (generation !== this.openGeneration) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
561
642
|
this.unsub = ws.subscribe((event) => {
|
|
562
643
|
if (event.changes.includes('asset')) {
|
|
563
644
|
this.replaceAssetSession(new MdzipAssetSession(ws, event.snapshot.workspace.assets, this.elRoot.ownerDocument, {
|
|
@@ -576,6 +657,9 @@ export class MdzipWorkspaceView {
|
|
|
576
657
|
void this.notifyChanged(this.initialWorkspaceEvent(ws.snapshot()));
|
|
577
658
|
}
|
|
578
659
|
catch (error) {
|
|
660
|
+
if (generation !== this.openGeneration) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
579
663
|
this.options.onFailed?.(error);
|
|
580
664
|
}
|
|
581
665
|
}
|
|
@@ -606,6 +690,7 @@ export class MdzipWorkspaceView {
|
|
|
606
690
|
* `ERR_LAZY_TEXT_UNAVAILABLE` instead of silently producing an empty file.
|
|
607
691
|
*/
|
|
608
692
|
async openWorkspace(workspace, options = {}) {
|
|
693
|
+
const generation = ++this.openGeneration;
|
|
609
694
|
this.unsub?.();
|
|
610
695
|
this.resetRenderingState();
|
|
611
696
|
this.cmEditor?.destroy();
|
|
@@ -615,6 +700,10 @@ export class MdzipWorkspaceView {
|
|
|
615
700
|
this.conversionDocumentGeneration += 1;
|
|
616
701
|
try {
|
|
617
702
|
const ws = await MdzipWorkspaceService.openWorkspace(workspace, options);
|
|
703
|
+
if (generation !== this.openGeneration) {
|
|
704
|
+
// A newer open started while this parse was in flight; discard it.
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
618
707
|
this.workspace = ws;
|
|
619
708
|
this.replaceAssetSession(new MdzipAssetSession(ws, ws.snapshot().workspace.assets, this.elRoot.ownerDocument, {
|
|
620
709
|
cache: this.options.assetCache,
|
|
@@ -625,6 +714,9 @@ export class MdzipWorkspaceView {
|
|
|
625
714
|
const snap = ws.snapshot();
|
|
626
715
|
this.layout = this.validLayoutForSnapshot(this.options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy), snap);
|
|
627
716
|
await this.ensureCmEditor();
|
|
717
|
+
if (generation !== this.openGeneration) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
628
720
|
this.unsub = ws.subscribe((event) => {
|
|
629
721
|
if (event.changes.includes('asset')) {
|
|
630
722
|
this.replaceAssetSession(new MdzipAssetSession(ws, event.snapshot.workspace.assets, this.elRoot.ownerDocument, {
|
|
@@ -645,6 +737,9 @@ export class MdzipWorkspaceView {
|
|
|
645
737
|
void this.notifyChanged(this.initialWorkspaceEvent(ws.snapshot()));
|
|
646
738
|
}
|
|
647
739
|
catch (error) {
|
|
740
|
+
if (generation !== this.openGeneration) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
648
743
|
this.options.onFailed?.(error);
|
|
649
744
|
}
|
|
650
745
|
}
|
|
@@ -703,12 +798,12 @@ export class MdzipWorkspaceView {
|
|
|
703
798
|
if (command === 'insert-image') {
|
|
704
799
|
if (this.workspace?.sourceFormat === 'markdown') {
|
|
705
800
|
this.requestMdzConversion(file
|
|
706
|
-
? { kind: 'image-file', file }
|
|
801
|
+
? { kind: 'image-file', file, source: 'picker' }
|
|
707
802
|
: { kind: 'image-picker' });
|
|
708
803
|
return true;
|
|
709
804
|
}
|
|
710
805
|
if (file) {
|
|
711
|
-
await this.insertImageFile(file);
|
|
806
|
+
await this.insertImageFile(file, 'picker');
|
|
712
807
|
}
|
|
713
808
|
else {
|
|
714
809
|
this.elImageInput.click();
|
|
@@ -737,6 +832,7 @@ export class MdzipWorkspaceView {
|
|
|
737
832
|
}
|
|
738
833
|
destroy() {
|
|
739
834
|
this.conversionDocumentGeneration += 1;
|
|
835
|
+
this.resolveImageInsertDialog(null);
|
|
740
836
|
try {
|
|
741
837
|
this.unsub?.();
|
|
742
838
|
}
|
|
@@ -783,6 +879,55 @@ export class MdzipWorkspaceView {
|
|
|
783
879
|
this.entryMatchMissKey = null;
|
|
784
880
|
this.render();
|
|
785
881
|
}
|
|
882
|
+
/**
|
|
883
|
+
* Replaces the view control policy without recreating the workspace or
|
|
884
|
+
* CodeMirror editor. In particular, `lineNumbers` is reconfigured through a
|
|
885
|
+
* CodeMirror compartment so text, selection, focus, scroll, and undo history
|
|
886
|
+
* stay intact.
|
|
887
|
+
*/
|
|
888
|
+
setControls(controls) {
|
|
889
|
+
const next = resolveMdzipControlPolicy(controls);
|
|
890
|
+
const lineNumbersChanged = next.lineNumbers !== this.controlPolicy.lineNumbers;
|
|
891
|
+
this.controlPolicy = next;
|
|
892
|
+
if (lineNumbersChanged && this.cmEditor) {
|
|
893
|
+
this.cmEditor.dispatch({
|
|
894
|
+
effects: this.lineNumbersCompartment.reconfigure(next.lineNumbers ? lineNumbers() : [])
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
const snapshot = this.workspace?.snapshot();
|
|
898
|
+
if (snapshot) {
|
|
899
|
+
this.layout = this.validLayoutForSnapshot(this.layout, snapshot);
|
|
900
|
+
}
|
|
901
|
+
this.render();
|
|
902
|
+
}
|
|
903
|
+
setImageHydrationAnimation(animation) {
|
|
904
|
+
const next = animation ?? 'auto';
|
|
905
|
+
if (next === this.imageHydrationAnimation) {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
this.imageHydrationAnimation = next;
|
|
909
|
+
this.resetPreviewState();
|
|
910
|
+
this.render();
|
|
911
|
+
}
|
|
912
|
+
setDensityOptions(options) {
|
|
913
|
+
const nextToolbarDensity = normalizeToolbarDensity(options.toolbarDensity);
|
|
914
|
+
const nextContentDensity = normalizeContentDensity(options.contentDensity);
|
|
915
|
+
if (nextToolbarDensity === this.toolbarDensity && nextContentDensity === this.contentDensity) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
this.toolbarDensity = nextToolbarDensity;
|
|
919
|
+
this.contentDensity = nextContentDensity;
|
|
920
|
+
this.applyDensityClasses();
|
|
921
|
+
}
|
|
922
|
+
setImageInsertOptions(options) {
|
|
923
|
+
this.options.imageInsertMode = options.imageInsertMode;
|
|
924
|
+
this.options.imageInsertHandler = options.imageInsertHandler;
|
|
925
|
+
}
|
|
926
|
+
applyDensityClasses() {
|
|
927
|
+
this.elRoot.classList.remove('toolbar-density-comfortable', 'toolbar-density-compact', 'toolbar-density-dense', 'content-density-comfortable', 'content-density-compact');
|
|
928
|
+
this.elRoot.classList.add(`toolbar-density-${this.toolbarDensity}`);
|
|
929
|
+
this.elRoot.classList.add(`content-density-${this.contentDensity}`);
|
|
930
|
+
}
|
|
786
931
|
/**
|
|
787
932
|
* Tears down all custom rendering state: aborts in-flight renders, destroys
|
|
788
933
|
* mounted extension and entry renderer handles, and clears memo caches.
|
|
@@ -840,6 +985,7 @@ export class MdzipWorkspaceView {
|
|
|
840
985
|
// mounted extension handles.
|
|
841
986
|
return;
|
|
842
987
|
}
|
|
988
|
+
const animateImageHydration = this.shouldAnimateImageHydration(memo, snapshot);
|
|
843
989
|
this.previewAbort?.abort();
|
|
844
990
|
this.previewAbort = null;
|
|
845
991
|
this.destroyPreviewHandles();
|
|
@@ -888,14 +1034,14 @@ export class MdzipWorkspaceView {
|
|
|
888
1034
|
}
|
|
889
1035
|
if (typeof result === 'string') {
|
|
890
1036
|
// Sync fast path: no microtask hop when every pipeline stage is sync.
|
|
891
|
-
this.applyPreviewHtml(result, snapshot, context, generation);
|
|
1037
|
+
this.applyPreviewHtml(result, snapshot, context, generation, animateImageHydration);
|
|
892
1038
|
return;
|
|
893
1039
|
}
|
|
894
1040
|
void result.then((html) => {
|
|
895
1041
|
if (generation !== this.previewGeneration || abort.signal.aborted) {
|
|
896
1042
|
return; // Stale: the selection or content moved on while rendering.
|
|
897
1043
|
}
|
|
898
|
-
this.applyPreviewHtml(html, snapshot, context, generation);
|
|
1044
|
+
this.applyPreviewHtml(html, snapshot, context, generation, animateImageHydration);
|
|
899
1045
|
}).catch((error) => {
|
|
900
1046
|
if (generation !== this.previewGeneration || abort.signal.aborted) {
|
|
901
1047
|
return;
|
|
@@ -905,17 +1051,28 @@ export class MdzipWorkspaceView {
|
|
|
905
1051
|
}
|
|
906
1052
|
});
|
|
907
1053
|
}
|
|
908
|
-
applyPreviewHtml(html, snapshot, context, generation) {
|
|
1054
|
+
applyPreviewHtml(html, snapshot, context, generation, animateImageHydration) {
|
|
909
1055
|
// When the preview references archive images, mount the text immediately
|
|
910
1056
|
// and hydrate each image progressively (reserving layout space from its
|
|
911
1057
|
// sniffed intrinsic size), rather than blocking the whole preview on image
|
|
912
1058
|
// resolution. Other markdown mounts synchronously.
|
|
913
1059
|
if (this.assetSession && /<img\b/i.test(html)) {
|
|
914
|
-
this.mountProgressivePreview(html, snapshot, context, generation);
|
|
1060
|
+
this.mountProgressivePreview(html, snapshot, context, generation, animateImageHydration);
|
|
915
1061
|
return;
|
|
916
1062
|
}
|
|
917
1063
|
this.mountPreviewHtml(html, snapshot, context, generation);
|
|
918
1064
|
}
|
|
1065
|
+
shouldAnimateImageHydration(previousMemo, snapshot) {
|
|
1066
|
+
if (this.imageHydrationAnimation === 'off') {
|
|
1067
|
+
return false;
|
|
1068
|
+
}
|
|
1069
|
+
if (this.imageHydrationAnimation === 'auto') {
|
|
1070
|
+
return true;
|
|
1071
|
+
}
|
|
1072
|
+
return !previousMemo
|
|
1073
|
+
|| previousMemo.path !== snapshot.currentPath
|
|
1074
|
+
|| previousMemo.pathType !== snapshot.currentPathType;
|
|
1075
|
+
}
|
|
919
1076
|
mountPreviewHtml(html, snapshot, context, generation) {
|
|
920
1077
|
this.elPreviewContent.innerHTML = html;
|
|
921
1078
|
this.mountPreviewExtensions(context, generation);
|
|
@@ -929,7 +1086,7 @@ export class MdzipWorkspaceView {
|
|
|
929
1086
|
* text is in the DOM; `onAssetsHydrated` fires once every referenced image
|
|
930
1087
|
* has resolved and had its final `src` assigned (or there are none).
|
|
931
1088
|
*/
|
|
932
|
-
mountProgressivePreview(html, snapshot, context, generation) {
|
|
1089
|
+
mountProgressivePreview(html, snapshot, context, generation, animateImageHydration) {
|
|
933
1090
|
this.elPreviewContent.innerHTML = html;
|
|
934
1091
|
const session = this.assetSession;
|
|
935
1092
|
const document = this.elPreviewContent.ownerDocument;
|
|
@@ -945,9 +1102,20 @@ export class MdzipWorkspaceView {
|
|
|
945
1102
|
// immediately readable; the slot eases open to the reserved height once
|
|
946
1103
|
// the image resolves.
|
|
947
1104
|
image.removeAttribute('src');
|
|
948
|
-
|
|
1105
|
+
if (animateImageHydration) {
|
|
1106
|
+
image.classList.add('mdzip-image-loading');
|
|
1107
|
+
}
|
|
949
1108
|
const slot = document.createElement('span');
|
|
950
|
-
slot.className =
|
|
1109
|
+
slot.className = animateImageHydration
|
|
1110
|
+
? 'mdzip-image-slot'
|
|
1111
|
+
: 'mdzip-image-slot mdzip-image-open mdzip-image-animation-off';
|
|
1112
|
+
const align = image.getAttribute('align')?.toLowerCase();
|
|
1113
|
+
if (align === 'left' || image.classList.contains('mdzip-image-wrap-left')) {
|
|
1114
|
+
slot.classList.add('mdzip-image-align-left');
|
|
1115
|
+
}
|
|
1116
|
+
else if (align === 'right' || image.classList.contains('mdzip-image-wrap-right')) {
|
|
1117
|
+
slot.classList.add('mdzip-image-align-right');
|
|
1118
|
+
}
|
|
951
1119
|
image.parentNode?.insertBefore(slot, image);
|
|
952
1120
|
slot.appendChild(image);
|
|
953
1121
|
pending.push({ image, slot, source });
|
|
@@ -980,13 +1148,12 @@ export class MdzipWorkspaceView {
|
|
|
980
1148
|
// Size the reserved box from the sniffed dimensions so the slot eases
|
|
981
1149
|
// open to the image's exact height in a single slide — and the pixels
|
|
982
1150
|
// drop into an already-correct box with no further reflow.
|
|
983
|
-
if (resolved.width && resolved.height
|
|
1151
|
+
if (resolved.width && resolved.height
|
|
1152
|
+
&& !image.hasAttribute('width') && !image.hasAttribute('height')) {
|
|
984
1153
|
image.setAttribute('width', String(resolved.width));
|
|
985
1154
|
image.setAttribute('height', String(resolved.height));
|
|
986
1155
|
}
|
|
987
|
-
|
|
988
|
-
image.addEventListener('load', clear, { once: true });
|
|
989
|
-
image.addEventListener('error', clear, { once: true });
|
|
1156
|
+
this.attachImageLoadHandlers(image, source, resolved.url, context, generation);
|
|
990
1157
|
image.setAttribute('src', resolved.url);
|
|
991
1158
|
this.openImageSlot(slot);
|
|
992
1159
|
settle();
|
|
@@ -1007,9 +1174,59 @@ export class MdzipWorkspaceView {
|
|
|
1007
1174
|
* under `prefers-reduced-motion`.
|
|
1008
1175
|
*/
|
|
1009
1176
|
openImageSlot(slot) {
|
|
1177
|
+
if (this.imageHydrationAnimation === 'off' || slot.classList.contains('mdzip-image-animation-off')) {
|
|
1178
|
+
slot.classList.add('mdzip-image-open');
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1010
1181
|
void slot.offsetHeight;
|
|
1011
1182
|
slot.classList.add('mdzip-image-open');
|
|
1012
1183
|
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Wires `<img>` load/error handling for a resolved archive image.
|
|
1186
|
+
*
|
|
1187
|
+
* `resolveImage()` already succeeded, so an `error` here is environmental
|
|
1188
|
+
* rather than a missing asset — most often a host whose CSP `img-src` blocks
|
|
1189
|
+
* the `blob:` object URL. When that happens we retry once with a `data:` URL
|
|
1190
|
+
* (which many such hosts still permit), and only if that also fails do we
|
|
1191
|
+
* surface the failure via `onFailed` instead of leaving a silent blank box.
|
|
1192
|
+
*/
|
|
1193
|
+
attachImageLoadHandlers(image, source, resolvedUrl, context, generation) {
|
|
1194
|
+
const clear = () => image.classList.remove('mdzip-image-loading');
|
|
1195
|
+
image.addEventListener('load', clear, { once: true });
|
|
1196
|
+
image.addEventListener('error', () => {
|
|
1197
|
+
const session = this.assetSession;
|
|
1198
|
+
if (resolvedUrl.startsWith('blob:') && session) {
|
|
1199
|
+
void session.resolveDataUrl(source, context.currentPath).then((dataUrl) => {
|
|
1200
|
+
if (generation !== this.previewGeneration || context.signal.aborted) {
|
|
1201
|
+
clear();
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
if (dataUrl && dataUrl !== resolvedUrl) {
|
|
1205
|
+
image.addEventListener('load', clear, { once: true });
|
|
1206
|
+
image.addEventListener('error', () => {
|
|
1207
|
+
clear();
|
|
1208
|
+
this.reportImageLoadFailure(source);
|
|
1209
|
+
}, { once: true });
|
|
1210
|
+
image.setAttribute('src', dataUrl);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
clear();
|
|
1214
|
+
this.reportImageLoadFailure(source);
|
|
1215
|
+
}).catch((error) => {
|
|
1216
|
+
clear();
|
|
1217
|
+
this.options.onFailed?.(error);
|
|
1218
|
+
});
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
clear();
|
|
1222
|
+
this.reportImageLoadFailure(source);
|
|
1223
|
+
}, { once: true });
|
|
1224
|
+
}
|
|
1225
|
+
reportImageLoadFailure(source) {
|
|
1226
|
+
this.options.onFailed?.(new Error(`Failed to load archive image "${source}". When embedding the editor in a `
|
|
1227
|
+
+ 'CSP-restricted host (e.g. a VS Code webview), ensure img-src permits '
|
|
1228
|
+
+ 'blob: and data:.'));
|
|
1229
|
+
}
|
|
1013
1230
|
mountPreviewExtensions(context, generation) {
|
|
1014
1231
|
for (const extension of this.markdownExtensions) {
|
|
1015
1232
|
if (!extension.mount) {
|
|
@@ -1267,11 +1484,12 @@ export class MdzipWorkspaceView {
|
|
|
1267
1484
|
const state = EditorState.create({
|
|
1268
1485
|
doc: initialText,
|
|
1269
1486
|
extensions: [
|
|
1270
|
-
|
|
1487
|
+
this.lineNumbersCompartment.of(this.controlPolicy.lineNumbers ? lineNumbers() : []),
|
|
1271
1488
|
history(),
|
|
1272
1489
|
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
|
|
1273
1490
|
markdown(),
|
|
1274
1491
|
syntaxHighlighting(mdzipMarkdownHighlight),
|
|
1492
|
+
hardBreakMarkerHighlight,
|
|
1275
1493
|
EditorView.lineWrapping,
|
|
1276
1494
|
dropCursor(),
|
|
1277
1495
|
mdzipEditorTheme,
|
|
@@ -1427,8 +1645,24 @@ export class MdzipWorkspaceView {
|
|
|
1427
1645
|
this.elPreviewBtn.setAttribute('aria-pressed', String(this.layout === 'preview'));
|
|
1428
1646
|
this.elSplitBtn.setAttribute('aria-pressed', String(this.layout === 'split'));
|
|
1429
1647
|
this.elSourceBtn.setAttribute('aria-pressed', String(this.layout === 'source'));
|
|
1430
|
-
|
|
1431
|
-
|
|
1648
|
+
if (snapshot.sourceFormat === 'markdown') {
|
|
1649
|
+
this.elNavBtn.innerHTML = CONVERT_TO_MDZ_ICON_HTML;
|
|
1650
|
+
this.elNavBtn.classList.remove('active');
|
|
1651
|
+
this.elNavBtn.classList.add('convert-mdz-toggle');
|
|
1652
|
+
this.elNavBtn.setAttribute('title', 'Convert to MDZ');
|
|
1653
|
+
this.elNavBtn.setAttribute('aria-label', 'Convert to MDZ');
|
|
1654
|
+
this.elNavBtn.dataset['tooltip'] = 'Convert to MDZ';
|
|
1655
|
+
this.elNavBtn.removeAttribute('aria-pressed');
|
|
1656
|
+
}
|
|
1657
|
+
else {
|
|
1658
|
+
this.elNavBtn.innerHTML = NAV_TOGGLE_ICON_HTML;
|
|
1659
|
+
this.elNavBtn.classList.remove('convert-mdz-toggle');
|
|
1660
|
+
this.elNavBtn.classList.toggle('active', this.navVisible);
|
|
1661
|
+
this.elNavBtn.setAttribute('title', 'Toggle contents');
|
|
1662
|
+
this.elNavBtn.setAttribute('aria-label', 'Toggle contents');
|
|
1663
|
+
this.elNavBtn.dataset['tooltip'] = 'Toggle contents';
|
|
1664
|
+
this.elNavBtn.setAttribute('aria-pressed', String(this.navVisible));
|
|
1665
|
+
}
|
|
1432
1666
|
this.elZoomBtn.classList.toggle('active', this.zoomOpen);
|
|
1433
1667
|
this.elDarkThemeBtn.classList.toggle('active', this.colorScheme === 'dark');
|
|
1434
1668
|
this.elLightThemeBtn.classList.toggle('active', this.colorScheme === 'light');
|
|
@@ -1445,7 +1679,7 @@ export class MdzipWorkspaceView {
|
|
|
1445
1679
|
this.elNavResizer.classList.toggle('hidden', !showNavigationPane);
|
|
1446
1680
|
this.prunePendingFolders(snapshot);
|
|
1447
1681
|
const navTree = snapshot.sourceFormat === 'mdz'
|
|
1448
|
-
? mergePendingFolders(buildMdzipNavTree(snapshot.content.paths), this.pendingNewFolders)
|
|
1682
|
+
? mergePendingFolders(buildMdzipNavTree(snapshot.content.paths, snapshot.content.entryPoint), this.pendingNewFolders)
|
|
1449
1683
|
: [];
|
|
1450
1684
|
const allowOrphanActions = this.controlPolicy.orphanActions && snapshot.mode !== 'read-only';
|
|
1451
1685
|
const allowFileActions = this.allowFileActions(snapshot);
|
|
@@ -1455,7 +1689,9 @@ export class MdzipWorkspaceView {
|
|
|
1455
1689
|
allowDrag: snapshot.mode !== 'read-only' && snapshot.sourceFormat === 'mdz',
|
|
1456
1690
|
pendingFolders: new Set([...this.pendingNewFolders].map((path) => path.toLowerCase()))
|
|
1457
1691
|
};
|
|
1458
|
-
this.elNavTree.innerHTML = navTree
|
|
1692
|
+
this.elNavTree.innerHTML = navTree
|
|
1693
|
+
.map((n, i) => renderNavNode(n, snapshot, navRenderOptions, [], i === navTree.length - 1, 0))
|
|
1694
|
+
.join('');
|
|
1459
1695
|
this.prepareTooltips();
|
|
1460
1696
|
if (this.cmEditor) {
|
|
1461
1697
|
this.updatingCm = true;
|
|
@@ -1492,6 +1728,18 @@ export class MdzipWorkspaceView {
|
|
|
1492
1728
|
this.elTitleSaveBtn.disabled = !valid;
|
|
1493
1729
|
}
|
|
1494
1730
|
this.elConversionDialog.hidden = this.conversionAction === null;
|
|
1731
|
+
this.elImageInsertDialog.hidden = this.imageInsertDialogState === null;
|
|
1732
|
+
if (this.imageInsertDialogState) {
|
|
1733
|
+
this.elImageInsertAltInput.value = this.imageInsertDialogState.request.defaultAltText;
|
|
1734
|
+
this.elImageInsertModeMarkdown.checked = true;
|
|
1735
|
+
this.elImageInsertModeHtml.checked = false;
|
|
1736
|
+
this.elImageInsertSizeModeSelect.value = 'width';
|
|
1737
|
+
this.elImageInsertSizeValueInput.value = this.imageInsertDialogState.request.intrinsicWidth
|
|
1738
|
+
? String(this.imageInsertDialogState.request.intrinsicWidth)
|
|
1739
|
+
: '';
|
|
1740
|
+
this.elImageInsertPositionSelect.value = 'inline';
|
|
1741
|
+
this.updateImageInsertOptionControls();
|
|
1742
|
+
}
|
|
1495
1743
|
this.elMetadataDialog.hidden = !this.metadataDialogOpen;
|
|
1496
1744
|
if (this.navMenuState) {
|
|
1497
1745
|
const items = this.navMenuItems(this.navMenuState.target, snapshot);
|
|
@@ -1686,7 +1934,7 @@ export class MdzipWorkspaceView {
|
|
|
1686
1934
|
const file = this.elImageInput.files?.[0];
|
|
1687
1935
|
this.elImageInput.value = '';
|
|
1688
1936
|
if (file) {
|
|
1689
|
-
void this.insertImageFile(file);
|
|
1937
|
+
void this.insertImageFile(file, 'picker');
|
|
1690
1938
|
}
|
|
1691
1939
|
});
|
|
1692
1940
|
this.elEditToolbar.addEventListener('keydown', (event) => {
|
|
@@ -1829,6 +2077,19 @@ export class MdzipWorkspaceView {
|
|
|
1829
2077
|
this.elConversionConfirmBtn.addEventListener('click', () => {
|
|
1830
2078
|
void this.confirmMdzConversion();
|
|
1831
2079
|
});
|
|
2080
|
+
this.elImageInsertDialog.querySelector('[data-action="cancel-image-insert"]')
|
|
2081
|
+
.addEventListener('click', () => {
|
|
2082
|
+
this.resolveImageInsertDialog(null);
|
|
2083
|
+
});
|
|
2084
|
+
this.elImageInsertConfirmBtn.addEventListener('click', () => {
|
|
2085
|
+
this.resolveImageInsertDialog(this.readImageInsertDialogDecision());
|
|
2086
|
+
});
|
|
2087
|
+
this.elImageInsertModeMarkdown.addEventListener('change', () => this.updateImageInsertOptionControls());
|
|
2088
|
+
this.elImageInsertModeHtml.addEventListener('change', () => this.updateImageInsertOptionControls());
|
|
2089
|
+
this.elImageInsertSizeModeSelect.addEventListener('change', () => {
|
|
2090
|
+
this.resetImageInsertSizeValue();
|
|
2091
|
+
this.updateImageInsertOptionControls();
|
|
2092
|
+
});
|
|
1832
2093
|
this.elNavMenu.addEventListener('click', (e) => {
|
|
1833
2094
|
e.stopPropagation();
|
|
1834
2095
|
const item = e.target.closest('[data-menu-action]');
|
|
@@ -2214,7 +2475,7 @@ export class MdzipWorkspaceView {
|
|
|
2214
2475
|
editor.dispatch({ selection: { anchor: target.selectionStart } });
|
|
2215
2476
|
}
|
|
2216
2477
|
if (action.kind === 'image-file') {
|
|
2217
|
-
await this.insertImageFile(action.file);
|
|
2478
|
+
await this.insertImageFile(action.file, action.source ?? 'picker');
|
|
2218
2479
|
return true;
|
|
2219
2480
|
}
|
|
2220
2481
|
this.elImageInput.click();
|
|
@@ -2240,7 +2501,7 @@ export class MdzipWorkspaceView {
|
|
|
2240
2501
|
return;
|
|
2241
2502
|
}
|
|
2242
2503
|
if (action.kind === 'image-file') {
|
|
2243
|
-
await this.insertImageFile(action.file);
|
|
2504
|
+
await this.insertImageFile(action.file, action.source ?? 'picker');
|
|
2244
2505
|
return;
|
|
2245
2506
|
}
|
|
2246
2507
|
this.elImageInput.click();
|
|
@@ -2260,37 +2521,45 @@ export class MdzipWorkspaceView {
|
|
|
2260
2521
|
if (this.workspace?.sourceFormat === 'markdown') {
|
|
2261
2522
|
const extension = extensionForMime(image.mimeType);
|
|
2262
2523
|
const pastedFile = new window.File([new Blob([image.bytes], { type: image.mimeType })], `pasted.${extension}`, { type: image.mimeType });
|
|
2263
|
-
this.requestMdzConversion({ kind: 'image-file', file: pastedFile });
|
|
2524
|
+
this.requestMdzConversion({ kind: 'image-file', file: pastedFile, source: 'paste' });
|
|
2264
2525
|
return;
|
|
2265
2526
|
}
|
|
2266
|
-
await this.insertImageBytes(image.bytes, image.mimeType
|
|
2527
|
+
await this.insertImageBytes(image.bytes, image.mimeType, {
|
|
2528
|
+
fileName: `pasted.${extensionForMime(image.mimeType)}`,
|
|
2529
|
+
source: 'paste'
|
|
2530
|
+
});
|
|
2267
2531
|
}
|
|
2268
2532
|
catch (error) {
|
|
2269
2533
|
this.options.onFailed?.(error);
|
|
2270
2534
|
}
|
|
2271
2535
|
}
|
|
2272
|
-
async insertImageFile(file) {
|
|
2536
|
+
async insertImageFile(file, source = 'picker') {
|
|
2273
2537
|
if (!file.type.startsWith('image/') && !/\.(png|jpe?g|gif|webp|svg)$/i.test(file.name)) {
|
|
2274
2538
|
return;
|
|
2275
2539
|
}
|
|
2276
2540
|
try {
|
|
2277
|
-
await this.insertImageBytes(new Uint8Array(await file.arrayBuffer()), file.type || imageMimeTypeFromFileName(file.name));
|
|
2541
|
+
await this.insertImageBytes(new Uint8Array(await file.arrayBuffer()), file.type || imageMimeTypeFromFileName(file.name), { fileName: file.name, source });
|
|
2278
2542
|
}
|
|
2279
2543
|
catch (error) {
|
|
2280
2544
|
this.options.onFailed?.(error);
|
|
2281
2545
|
}
|
|
2282
2546
|
}
|
|
2283
|
-
async insertImageBytes(bytes, mimeType) {
|
|
2547
|
+
async insertImageBytes(bytes, mimeType, options) {
|
|
2284
2548
|
const editor = this.cmEditor;
|
|
2285
2549
|
if (!editor) {
|
|
2286
2550
|
return;
|
|
2287
2551
|
}
|
|
2288
2552
|
const selection = editor.state.selection.main;
|
|
2553
|
+
const decision = await this.resolveImageInsertDecision(bytes, mimeType, options);
|
|
2554
|
+
if (!decision) {
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2289
2557
|
const result = await this.workspace?.pasteImage({
|
|
2290
2558
|
bytes,
|
|
2291
2559
|
mimeType,
|
|
2292
2560
|
selectionStart: selection.from,
|
|
2293
|
-
selectionEnd: selection.to
|
|
2561
|
+
selectionEnd: selection.to,
|
|
2562
|
+
markdownImage: (markdownPath) => formatImageInsertMarkdown(markdownPath, decision, editor.state.doc.toString(), selection.from, selection.to)
|
|
2294
2563
|
});
|
|
2295
2564
|
if (result && this.cmEditor) {
|
|
2296
2565
|
this.render();
|
|
@@ -2298,6 +2567,115 @@ export class MdzipWorkspaceView {
|
|
|
2298
2567
|
this.cmEditor.focus();
|
|
2299
2568
|
}
|
|
2300
2569
|
}
|
|
2570
|
+
async resolveImageInsertDecision(bytes, mimeType, options) {
|
|
2571
|
+
const size = sniffImageSize(bytes, mimeType);
|
|
2572
|
+
const request = {
|
|
2573
|
+
fileName: options.fileName || `pasted.${extensionForMime(mimeType)}`,
|
|
2574
|
+
mimeType,
|
|
2575
|
+
intrinsicWidth: size?.width,
|
|
2576
|
+
intrinsicHeight: size?.height,
|
|
2577
|
+
defaultAltText: 'Pasted image',
|
|
2578
|
+
source: options.source
|
|
2579
|
+
};
|
|
2580
|
+
const handler = this.options.imageInsertHandler;
|
|
2581
|
+
if (handler) {
|
|
2582
|
+
try {
|
|
2583
|
+
const handled = await handler(request);
|
|
2584
|
+
if (handled !== undefined) {
|
|
2585
|
+
return normalizeImageInsertDecision(handled);
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
catch (error) {
|
|
2589
|
+
this.options.onFailed?.(error);
|
|
2590
|
+
return null;
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
const mode = this.options.imageInsertMode ?? 'markdown';
|
|
2594
|
+
if (mode === 'ask') {
|
|
2595
|
+
return this.openImageInsertDialog(request);
|
|
2596
|
+
}
|
|
2597
|
+
return normalizeImageInsertDecision({
|
|
2598
|
+
mode: mode === 'html' ? 'html' : 'markdown',
|
|
2599
|
+
altText: request.defaultAltText,
|
|
2600
|
+
width: mode === 'html' ? request.intrinsicWidth : undefined,
|
|
2601
|
+
height: mode === 'html' ? request.intrinsicHeight : undefined,
|
|
2602
|
+
position: 'inline'
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2605
|
+
openImageInsertDialog(request) {
|
|
2606
|
+
return new Promise((resolve) => {
|
|
2607
|
+
this.resolveImageInsertDialog(null);
|
|
2608
|
+
this.imageInsertDialogState = { request, resolve };
|
|
2609
|
+
this.render();
|
|
2610
|
+
requestAnimationFrame(() => this.elImageInsertAltInput.focus());
|
|
2611
|
+
});
|
|
2612
|
+
}
|
|
2613
|
+
resolveImageInsertDialog(decision) {
|
|
2614
|
+
const state = this.imageInsertDialogState;
|
|
2615
|
+
if (!state) {
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
this.imageInsertDialogState = null;
|
|
2619
|
+
this.render();
|
|
2620
|
+
state.resolve(normalizeImageInsertDecision(decision));
|
|
2621
|
+
}
|
|
2622
|
+
readImageInsertDialogDecision() {
|
|
2623
|
+
const parseDimension = (value) => {
|
|
2624
|
+
const parsed = Number(value);
|
|
2625
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
|
|
2626
|
+
};
|
|
2627
|
+
const request = this.imageInsertDialogState?.request;
|
|
2628
|
+
const sizeMode = this.elImageInsertSizeModeSelect.value;
|
|
2629
|
+
const sizeValue = parseDimension(this.elImageInsertSizeValueInput.value);
|
|
2630
|
+
let width;
|
|
2631
|
+
let height;
|
|
2632
|
+
if (this.elImageInsertModeHtml.checked && sizeValue) {
|
|
2633
|
+
if (sizeMode === 'width') {
|
|
2634
|
+
width = sizeValue;
|
|
2635
|
+
}
|
|
2636
|
+
else if (sizeMode === 'height') {
|
|
2637
|
+
height = sizeValue;
|
|
2638
|
+
}
|
|
2639
|
+
else if (sizeMode === 'percent' && request?.intrinsicWidth && request.intrinsicHeight) {
|
|
2640
|
+
width = Math.max(1, Math.round(request.intrinsicWidth * sizeValue / 100));
|
|
2641
|
+
height = Math.max(1, Math.round(request.intrinsicHeight * sizeValue / 100));
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
return {
|
|
2645
|
+
mode: this.elImageInsertModeHtml.checked ? 'html' : 'markdown',
|
|
2646
|
+
altText: this.elImageInsertAltInput.value,
|
|
2647
|
+
width,
|
|
2648
|
+
height,
|
|
2649
|
+
position: this.elImageInsertPositionSelect.value
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2652
|
+
updateImageInsertOptionControls() {
|
|
2653
|
+
const htmlMode = this.elImageInsertModeHtml.checked;
|
|
2654
|
+
const sizeDisabled = !htmlMode || this.elImageInsertSizeModeSelect.value === 'original';
|
|
2655
|
+
this.elImageInsertSizeModeSelect.disabled = !htmlMode;
|
|
2656
|
+
this.elImageInsertSizeValueInput.disabled = sizeDisabled;
|
|
2657
|
+
this.elImageInsertPositionSelect.disabled = !htmlMode;
|
|
2658
|
+
this.elImageInsertSizeModeSelect.closest('.image-insert-field')?.classList.toggle('field-disabled', !htmlMode);
|
|
2659
|
+
this.elImageInsertSizeValueInput.closest('.image-insert-field')?.classList.toggle('field-disabled', sizeDisabled);
|
|
2660
|
+
this.elImageInsertPositionSelect.closest('.image-insert-field')?.classList.toggle('field-disabled', !htmlMode);
|
|
2661
|
+
}
|
|
2662
|
+
resetImageInsertSizeValue() {
|
|
2663
|
+
const request = this.imageInsertDialogState?.request;
|
|
2664
|
+
switch (this.elImageInsertSizeModeSelect.value) {
|
|
2665
|
+
case 'width':
|
|
2666
|
+
this.elImageInsertSizeValueInput.value = request?.intrinsicWidth ? String(request.intrinsicWidth) : '';
|
|
2667
|
+
break;
|
|
2668
|
+
case 'height':
|
|
2669
|
+
this.elImageInsertSizeValueInput.value = request?.intrinsicHeight ? String(request.intrinsicHeight) : '';
|
|
2670
|
+
break;
|
|
2671
|
+
case 'percent':
|
|
2672
|
+
this.elImageInsertSizeValueInput.value = '100';
|
|
2673
|
+
break;
|
|
2674
|
+
default:
|
|
2675
|
+
this.elImageInsertSizeValueInput.value = '';
|
|
2676
|
+
break;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2301
2679
|
applyMarkdownFormat(format) {
|
|
2302
2680
|
const editor = this.cmEditor;
|
|
2303
2681
|
const snapshot = this.workspace?.snapshot();
|
|
@@ -2340,8 +2718,11 @@ export class MdzipWorkspaceView {
|
|
|
2340
2718
|
case 'blockquote':
|
|
2341
2719
|
this.prefixSelectedLines('> ', /^(\s*)>\s?/);
|
|
2342
2720
|
break;
|
|
2721
|
+
case 'insert-line-break':
|
|
2722
|
+
this.insertLineBreak();
|
|
2723
|
+
break;
|
|
2343
2724
|
case 'link':
|
|
2344
|
-
this.
|
|
2725
|
+
this.insertMarkdownLink();
|
|
2345
2726
|
break;
|
|
2346
2727
|
default:
|
|
2347
2728
|
return;
|
|
@@ -2358,6 +2739,7 @@ export class MdzipWorkspaceView {
|
|
|
2358
2739
|
orderedList: formatting.orderedList,
|
|
2359
2740
|
code: formatting.inlineCode || formatting.codeBlock,
|
|
2360
2741
|
blockquote: formatting.blockquote,
|
|
2742
|
+
lineBreak: formatting.lineBreak,
|
|
2361
2743
|
link: formatting.link,
|
|
2362
2744
|
image: formatting.image
|
|
2363
2745
|
};
|
|
@@ -2418,6 +2800,38 @@ export class MdzipWorkspaceView {
|
|
|
2418
2800
|
});
|
|
2419
2801
|
editor.focus();
|
|
2420
2802
|
}
|
|
2803
|
+
insertMarkdownLink() {
|
|
2804
|
+
const editor = this.cmEditor;
|
|
2805
|
+
if (!editor) {
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
const selection = editor.state.selection.main;
|
|
2809
|
+
const selectedText = editor.state.sliceDoc(selection.from, selection.to);
|
|
2810
|
+
const linkText = selectedText || 'link text';
|
|
2811
|
+
const urlPlaceholder = 'url';
|
|
2812
|
+
const insert = `[${linkText}](${urlPlaceholder})`;
|
|
2813
|
+
const anchor = selection.from + linkText.length + 3;
|
|
2814
|
+
editor.dispatch({
|
|
2815
|
+
changes: { from: selection.from, to: selection.to, insert },
|
|
2816
|
+
selection: { anchor, head: anchor + urlPlaceholder.length },
|
|
2817
|
+
scrollIntoView: true
|
|
2818
|
+
});
|
|
2819
|
+
editor.focus();
|
|
2820
|
+
}
|
|
2821
|
+
insertLineBreak() {
|
|
2822
|
+
const editor = this.cmEditor;
|
|
2823
|
+
if (!editor) {
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
const selection = editor.state.selection.main;
|
|
2827
|
+
const insert = '<br>\n';
|
|
2828
|
+
editor.dispatch({
|
|
2829
|
+
changes: { from: selection.from, to: selection.to, insert },
|
|
2830
|
+
selection: { anchor: selection.from + insert.length },
|
|
2831
|
+
scrollIntoView: true
|
|
2832
|
+
});
|
|
2833
|
+
editor.focus();
|
|
2834
|
+
}
|
|
2421
2835
|
prefixSelectedLines(prefix, existingPrefix) {
|
|
2422
2836
|
const editor = this.cmEditor;
|
|
2423
2837
|
if (!editor) {
|
|
@@ -2913,7 +3327,7 @@ export class MdzipWorkspaceView {
|
|
|
2913
3327
|
async handleEditorImageDrop(file, x, y) {
|
|
2914
3328
|
try {
|
|
2915
3329
|
if (this.workspace?.sourceFormat === 'markdown') {
|
|
2916
|
-
this.requestMdzConversion({ kind: 'image-file', file });
|
|
3330
|
+
this.requestMdzConversion({ kind: 'image-file', file, source: 'drop' });
|
|
2917
3331
|
return;
|
|
2918
3332
|
}
|
|
2919
3333
|
const editor = this.cmEditor;
|
|
@@ -2923,7 +3337,7 @@ export class MdzipWorkspaceView {
|
|
|
2923
3337
|
editor.dispatch({ selection: { anchor: pos } });
|
|
2924
3338
|
}
|
|
2925
3339
|
}
|
|
2926
|
-
await this.insertImageFile(file);
|
|
3340
|
+
await this.insertImageFile(file, 'drop');
|
|
2927
3341
|
}
|
|
2928
3342
|
catch (error) {
|
|
2929
3343
|
this.options.onFailed?.(error);
|
|
@@ -3137,6 +3551,7 @@ function hasFormattingControls(policy) {
|
|
|
3137
3551
|
|| policy.inlineCode
|
|
3138
3552
|
|| policy.codeBlock
|
|
3139
3553
|
|| policy.blockquote
|
|
3554
|
+
|| policy.lineBreak
|
|
3140
3555
|
|| policy.link
|
|
3141
3556
|
|| policy.image;
|
|
3142
3557
|
}
|
|
@@ -3156,6 +3571,8 @@ function markdownCommandFromToolbarFormat(format) {
|
|
|
3156
3571
|
case 'code-block':
|
|
3157
3572
|
case 'link':
|
|
3158
3573
|
return format;
|
|
3574
|
+
case 'line-break':
|
|
3575
|
+
return 'insert-line-break';
|
|
3159
3576
|
case 'strike':
|
|
3160
3577
|
return 'strikethrough';
|
|
3161
3578
|
case 'code':
|
|
@@ -3183,6 +3600,70 @@ function imageMimeTypeFromFileName(fileName) {
|
|
|
3183
3600
|
return 'image/png';
|
|
3184
3601
|
}
|
|
3185
3602
|
}
|
|
3603
|
+
function normalizeImageInsertDecision(decision) {
|
|
3604
|
+
if (!decision) {
|
|
3605
|
+
return null;
|
|
3606
|
+
}
|
|
3607
|
+
const normalizeDimension = (value) => value !== undefined && Number.isFinite(value) && value > 0
|
|
3608
|
+
? Math.round(value)
|
|
3609
|
+
: undefined;
|
|
3610
|
+
return {
|
|
3611
|
+
mode: decision.mode === 'html' ? 'html' : 'markdown',
|
|
3612
|
+
altText: decision.altText ?? '',
|
|
3613
|
+
width: normalizeDimension(decision.width),
|
|
3614
|
+
height: normalizeDimension(decision.height),
|
|
3615
|
+
position: normalizeImagePosition(decision.position)
|
|
3616
|
+
};
|
|
3617
|
+
}
|
|
3618
|
+
function normalizeImagePosition(position) {
|
|
3619
|
+
switch (position) {
|
|
3620
|
+
case 'left':
|
|
3621
|
+
case 'center':
|
|
3622
|
+
case 'right':
|
|
3623
|
+
case 'wrap-left':
|
|
3624
|
+
case 'wrap-right':
|
|
3625
|
+
return position;
|
|
3626
|
+
default:
|
|
3627
|
+
return 'inline';
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
function formatImageInsertMarkdown(markdownPath, decision, currentText = '', selectionStart = 0, selectionEnd = selectionStart) {
|
|
3631
|
+
if (decision.mode === 'html') {
|
|
3632
|
+
const attrs = [
|
|
3633
|
+
`src="${escapeHtml(markdownPath)}"`,
|
|
3634
|
+
`alt="${escapeHtml(decision.altText)}"`
|
|
3635
|
+
];
|
|
3636
|
+
if (decision.width) {
|
|
3637
|
+
attrs.push(`width="${decision.width}"`);
|
|
3638
|
+
}
|
|
3639
|
+
if (decision.height) {
|
|
3640
|
+
attrs.push(`height="${decision.height}"`);
|
|
3641
|
+
}
|
|
3642
|
+
if (decision.position === 'wrap-left' || decision.position === 'wrap-right') {
|
|
3643
|
+
attrs.push(`align="${decision.position === 'wrap-left' ? 'left' : 'right'}"`);
|
|
3644
|
+
}
|
|
3645
|
+
const image = `<img ${attrs.join(' ')}>`;
|
|
3646
|
+
if (decision.position === 'left' || decision.position === 'center' || decision.position === 'right') {
|
|
3647
|
+
return padHtmlImageBlock(`<p align="${decision.position}">${image}</p>`, currentText, selectionStart, selectionEnd);
|
|
3648
|
+
}
|
|
3649
|
+
return padHtmlImageBlock(image, currentText, selectionStart, selectionEnd);
|
|
3650
|
+
}
|
|
3651
|
+
return ``;
|
|
3652
|
+
}
|
|
3653
|
+
function padHtmlImageBlock(html, currentText, selectionStart, selectionEnd) {
|
|
3654
|
+
const before = currentText.slice(0, Math.max(0, selectionStart));
|
|
3655
|
+
const after = currentText.slice(Math.max(selectionStart, selectionEnd));
|
|
3656
|
+
const prefix = before.length === 0 || before.endsWith('\n\n')
|
|
3657
|
+
? ''
|
|
3658
|
+
: before.endsWith('\n') ? '\n' : '\n\n';
|
|
3659
|
+
const suffix = after.startsWith('\n\n')
|
|
3660
|
+
? ''
|
|
3661
|
+
: after.startsWith('\n') ? '\n' : '\n\n';
|
|
3662
|
+
return `${prefix}${html}${suffix}`;
|
|
3663
|
+
}
|
|
3664
|
+
function escapeMarkdownImageAlt(value) {
|
|
3665
|
+
return value.replace(/\\/g, '\\\\').replace(/]/g, '\\]');
|
|
3666
|
+
}
|
|
3186
3667
|
function formatMetadataValue(value) {
|
|
3187
3668
|
if (typeof value === 'string' && value.trim()) {
|
|
3188
3669
|
return value;
|
|
@@ -3247,6 +3728,7 @@ const SHELL_HTML = `
|
|
|
3247
3728
|
</div>
|
|
3248
3729
|
</div>
|
|
3249
3730
|
<button type="button" data-format="quote" data-format-control="blockquote" title="Blockquote" aria-label="Blockquote">${QUOTE_ICON_HTML}</button>
|
|
3731
|
+
<button type="button" data-format="line-break" data-format-control="lineBreak" title="Line break" aria-label="Line break">${LINE_BREAK_ICON_HTML}</button>
|
|
3250
3732
|
</div>
|
|
3251
3733
|
<span class="edit-toolbar-divider" aria-hidden="true"></span>
|
|
3252
3734
|
<div class="edit-toolbar-group">
|
|
@@ -3353,6 +3835,61 @@ const SHELL_HTML = `
|
|
|
3353
3835
|
</div>
|
|
3354
3836
|
</div>
|
|
3355
3837
|
|
|
3838
|
+
<div class="title-dialog-backdrop" data-ref="image-insert-dialog" hidden
|
|
3839
|
+
role="dialog" aria-modal="true" aria-labelledby="mdzip-image-insert-dialog-heading">
|
|
3840
|
+
<div class="title-dialog image-insert-dialog">
|
|
3841
|
+
<h3 id="mdzip-image-insert-dialog-heading">Insert Image</h3>
|
|
3842
|
+
<p>Choose the Markdown inserted for this image.</p>
|
|
3843
|
+
<fieldset class="image-insert-options">
|
|
3844
|
+
<legend>Output</legend>
|
|
3845
|
+
<label>
|
|
3846
|
+
<input type="radio" name="mdzip-image-insert-mode" value="markdown"
|
|
3847
|
+
data-ref="image-insert-mode-markdown" checked />
|
|
3848
|
+
Markdown image
|
|
3849
|
+
</label>
|
|
3850
|
+
<label>
|
|
3851
|
+
<input type="radio" name="mdzip-image-insert-mode" value="html"
|
|
3852
|
+
data-ref="image-insert-mode-html" />
|
|
3853
|
+
HTML <img> with sizing
|
|
3854
|
+
</label>
|
|
3855
|
+
</fieldset>
|
|
3856
|
+
<label class="image-insert-field">
|
|
3857
|
+
<span>Alt text</span>
|
|
3858
|
+
<input type="text" data-ref="image-insert-alt" />
|
|
3859
|
+
</label>
|
|
3860
|
+
<div class="image-insert-grid">
|
|
3861
|
+
<label class="image-insert-field">
|
|
3862
|
+
<span>Size by</span>
|
|
3863
|
+
<select data-ref="image-insert-size-mode">
|
|
3864
|
+
<option value="original">Original size</option>
|
|
3865
|
+
<option value="width" selected>Width</option>
|
|
3866
|
+
<option value="height">Height</option>
|
|
3867
|
+
<option value="percent">Percent</option>
|
|
3868
|
+
</select>
|
|
3869
|
+
</label>
|
|
3870
|
+
<label class="image-insert-field">
|
|
3871
|
+
<span>Value</span>
|
|
3872
|
+
<input type="number" min="1" step="1" data-ref="image-insert-size-value" />
|
|
3873
|
+
</label>
|
|
3874
|
+
</div>
|
|
3875
|
+
<label class="image-insert-field">
|
|
3876
|
+
<span>Position</span>
|
|
3877
|
+
<select data-ref="image-insert-position">
|
|
3878
|
+
<option value="inline">Inline/default</option>
|
|
3879
|
+
<option value="left">Left</option>
|
|
3880
|
+
<option value="center">Center</option>
|
|
3881
|
+
<option value="right">Right</option>
|
|
3882
|
+
<option value="wrap-left">Wrap left</option>
|
|
3883
|
+
<option value="wrap-right">Wrap right</option>
|
|
3884
|
+
</select>
|
|
3885
|
+
</label>
|
|
3886
|
+
<div class="title-dialog-actions">
|
|
3887
|
+
<button type="button" data-action="cancel-image-insert">Cancel</button>
|
|
3888
|
+
<button type="button" class="save-title" data-ref="image-insert-confirm-btn">Insert</button>
|
|
3889
|
+
</div>
|
|
3890
|
+
</div>
|
|
3891
|
+
</div>
|
|
3892
|
+
|
|
3356
3893
|
<div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
|
|
3357
3894
|
role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
|
|
3358
3895
|
<div class="title-dialog metadata-dialog">
|