@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/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
- function renderNavNode(node, state, options) {
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 children = node.children.map(c => renderNavNode(c, state, options)).join('');
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
- image.classList.add('mdzip-image-loading');
1105
+ if (animateImageHydration) {
1106
+ image.classList.add('mdzip-image-loading');
1107
+ }
949
1108
  const slot = document.createElement('span');
950
- slot.className = 'mdzip-image-slot';
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
- const clear = () => image.classList.remove('mdzip-image-loading');
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
- ...(this.controlPolicy.lineNumbers ? [lineNumbers()] : []),
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
- this.elNavBtn.classList.toggle('active', this.navVisible);
1431
- this.elNavBtn.setAttribute('aria-pressed', String(this.navVisible));
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.map(n => renderNavNode(n, snapshot, navRenderOptions)).join('');
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.wrapSelection('[', '](url)', 'link text');
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 `![${escapeMarkdownImageAlt(decision.altText)}](${markdownPath})`;
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 &lt;img&gt; 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">