@mdzip/editor 1.3.11 → 1.3.13

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, ChevronRight, ClipboardPaste, ClipboardType, Code, Columns2, Copy, Eraser, Eye, File, FileBraces, FileImage, Folder, FolderOpen, Hash, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Highlighter, ImagePlus, Info, Italic, Link2Off, Link, List, ListOrdered, CornerDownLeft, Moon, PackagePlus, PanelLeft, Pilcrow, Quote, Save, Scissors, SquareCode, SquarePen, Strikethrough, Sun, TextSelect, 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,10 +47,91 @@ 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');
52
54
  const INFO_ICON_HTML = lucideIcon(Info, 'document-info-icon');
55
+ // Leading icons for context-menu items (Obsidian-style icon column).
56
+ const MENU_ICON_CLASS = 'nav-menu-icon';
57
+ const MENU_CUT_ICON_HTML = lucideIcon(Scissors, MENU_ICON_CLASS);
58
+ const MENU_COPY_ICON_HTML = lucideIcon(Copy, MENU_ICON_CLASS);
59
+ const MENU_PASTE_ICON_HTML = lucideIcon(ClipboardPaste, MENU_ICON_CLASS);
60
+ const MENU_PASTE_PLAIN_ICON_HTML = lucideIcon(ClipboardType, MENU_ICON_CLASS);
61
+ const MENU_SELECT_ALL_ICON_HTML = lucideIcon(TextSelect, MENU_ICON_CLASS);
62
+ const MENU_BOLD_ICON_HTML = lucideIcon(Bold, MENU_ICON_CLASS);
63
+ const MENU_ITALIC_ICON_HTML = lucideIcon(Italic, MENU_ICON_CLASS);
64
+ const MENU_STRIKE_ICON_HTML = lucideIcon(Strikethrough, MENU_ICON_CLASS);
65
+ const MENU_CODE_ICON_HTML = lucideIcon(Code, MENU_ICON_CLASS);
66
+ const MENU_CODE_BLOCK_ICON_HTML = lucideIcon(SquareCode, MENU_ICON_CLASS);
67
+ const MENU_HIGHLIGHT_ICON_HTML = lucideIcon(Highlighter, MENU_ICON_CLASS);
68
+ const MENU_BULLET_LIST_ICON_HTML = lucideIcon(List, MENU_ICON_CLASS);
69
+ const MENU_ORDERED_LIST_ICON_HTML = lucideIcon(ListOrdered, MENU_ICON_CLASS);
70
+ const MENU_QUOTE_ICON_HTML = lucideIcon(Quote, MENU_ICON_CLASS);
71
+ const MENU_LINE_BREAK_ICON_HTML = lucideIcon(CornerDownLeft, MENU_ICON_CLASS);
72
+ const MENU_IMAGE_ICON_HTML = lucideIcon(ImagePlus, MENU_ICON_CLASS);
73
+ const MENU_LINK_ICON_HTML = lucideIcon(Link, MENU_ICON_CLASS);
74
+ const MENU_CLEAR_FORMAT_ICON_HTML = lucideIcon(Eraser, MENU_ICON_CLASS);
75
+ const MENU_HEADING_PARENT_ICON_HTML = lucideIcon(Heading1, MENU_ICON_CLASS);
76
+ const MENU_PARAGRAPH_ICON_HTML = lucideIcon(Pilcrow, MENU_ICON_CLASS);
77
+ const MENU_CHEVRON_ICON_HTML = lucideIcon(ChevronRight, 'nav-menu-chevron');
78
+ const MENU_HEADING_ICON_HTML = {
79
+ 1: lucideIcon(Heading1, MENU_ICON_CLASS),
80
+ 2: lucideIcon(Heading2, MENU_ICON_CLASS),
81
+ 3: lucideIcon(Heading3, MENU_ICON_CLASS),
82
+ 4: lucideIcon(Heading4, MENU_ICON_CLASS),
83
+ 5: lucideIcon(Heading5, MENU_ICON_CLASS),
84
+ 6: lucideIcon(Heading6, MENU_ICON_CLASS)
85
+ };
86
+ const MENU_SEPARATOR_HTML = '<div class="nav-menu-separator" role="separator"></div>';
87
+ // Matches the submenu `min-width` in the CSS; used to decide left/right flyout.
88
+ const SUBMENU_ESTIMATED_WIDTH = 190;
89
+ // Curated default for the Code Block submenu — a usable subset rather than the
90
+ // full highlight.js language set. Hosts override via `codeBlockLanguages`.
91
+ export const DEFAULT_CODE_BLOCK_LANGUAGES = [
92
+ { id: '', label: 'Plain Text' },
93
+ { id: 'typescript', label: 'TypeScript' },
94
+ { id: 'javascript', label: 'JavaScript' },
95
+ { id: 'tsx', label: 'TSX / JSX' },
96
+ { id: 'html', label: 'HTML' },
97
+ { id: 'css', label: 'CSS' },
98
+ { id: 'json', label: 'JSON' },
99
+ { id: 'python', label: 'Python' },
100
+ { id: 'csharp', label: 'C#' },
101
+ { id: 'java', label: 'Java' },
102
+ { id: 'cpp', label: 'C++' },
103
+ { id: 'go', label: 'Go' },
104
+ { id: 'rust', label: 'Rust' },
105
+ { id: 'sql', label: 'SQL' },
106
+ { id: 'bash', label: 'Shell' },
107
+ { id: 'yaml', label: 'YAML' },
108
+ { id: 'markdown', label: 'Markdown' }
109
+ ];
110
+ // Recursively renders a context-menu item. An item carrying `submenu` becomes a
111
+ // hover/focus flyout parent (no action of its own); everything else is an
112
+ // actionable `[data-menu-action]` button handled by the shared click delegate.
113
+ function renderContextMenuItem(item) {
114
+ const icon = item.icon ?? '';
115
+ const label = `<span class="nav-menu-label">${escapeHtml(item.label)}</span>`;
116
+ if (item.submenu) {
117
+ const children = item.submenu
118
+ .map((child) => (child === null ? MENU_SEPARATOR_HTML : renderContextMenuItem(child)))
119
+ .join('');
120
+ return '<div class="nav-menu-submenu-wrap">'
121
+ + '<button type="button" role="menuitem" aria-haspopup="true" class="nav-menu-parent">'
122
+ + `${icon}${label}${MENU_CHEVRON_ICON_HTML}</button>`
123
+ + `<div class="nav-context-submenu" role="menu">${children}</div></div>`;
124
+ }
125
+ const shortcut = item.shortcut
126
+ ? `<span class="nav-menu-shortcut">${escapeHtml(item.shortcut)}</span>`
127
+ : '';
128
+ return `<button type="button" role="menuitem" data-menu-action="${escapeHtml(item.action)}">${icon}${label}${shortcut}</button>`;
129
+ }
130
+ function renderContextMenuItems(items) {
131
+ return items
132
+ .map((item) => (item === null ? MENU_SEPARATOR_HTML : renderContextMenuItem(item)))
133
+ .join('');
134
+ }
53
135
  const ALL_HEADINGS = [1, 2, 3, 4, 5, 6];
54
136
  const ALL_LAYOUT_CONTROLS = {
55
137
  source: true,
@@ -66,6 +148,7 @@ const ALL_FORMATTING_CONTROLS = {
66
148
  inlineCode: true,
67
149
  codeBlock: true,
68
150
  blockquote: true,
151
+ lineBreak: true,
69
152
  link: true,
70
153
  image: true
71
154
  };
@@ -79,6 +162,7 @@ const NO_FORMATTING_CONTROLS = {
79
162
  inlineCode: false,
80
163
  codeBlock: false,
81
164
  blockquote: false,
165
+ lineBreak: false,
82
166
  link: false,
83
167
  image: false
84
168
  };
@@ -228,6 +312,12 @@ function resolveFormattingControls(base, override) {
228
312
  : [...new Set(headings)].filter((level) => ALL_HEADINGS.includes(level)).sort()
229
313
  };
230
314
  }
315
+ function normalizeToolbarDensity(value) {
316
+ return value === 'compact' || value === 'dense' ? value : 'comfortable';
317
+ }
318
+ function normalizeContentDensity(value) {
319
+ return value === 'compact' ? value : 'comfortable';
320
+ }
231
321
  const mdzipEditorTheme = EditorView.theme({
232
322
  '&': {
233
323
  height: '100%',
@@ -242,7 +332,7 @@ const mdzipEditorTheme = EditorView.theme({
242
332
  overflow: 'auto',
243
333
  },
244
334
  '.cm-content': {
245
- padding: '36px 48px',
335
+ padding: 'var(--mdzip-editor-content-padding, var(--mdzip-density-editor-content-padding, 36px 48px))',
246
336
  caretColor: 'var(--mdzip-editor-cursor-color)',
247
337
  overflowWrap: 'anywhere',
248
338
  wordBreak: 'normal',
@@ -273,6 +363,10 @@ const mdzipEditorTheme = EditorView.theme({
273
363
  '.cm-activeLineGutter': {
274
364
  background: 'transparent',
275
365
  },
366
+ '.mdzip-hard-break-marker': {
367
+ color: 'var(--mdzip-muted-foreground-color)',
368
+ opacity: '0.65',
369
+ },
276
370
  });
277
371
  const mdzipMarkdownHighlight = HighlightStyle.define([
278
372
  { tag: [tags.heading1, tags.heading2, tags.heading3, tags.heading4, tags.heading5, tags.heading6],
@@ -288,6 +382,22 @@ const mdzipMarkdownHighlight = HighlightStyle.define([
288
382
  { tag: tags.contentSeparator, color: '#6a9955' },
289
383
  { tag: tags.atom, color: '#d100d1' },
290
384
  ]);
385
+ const hardBreakMarkerMatcher = new MatchDecorator({
386
+ regexp: /<br\s*\/?>/gi,
387
+ decoration: Decoration.mark({ class: 'mdzip-hard-break-marker' })
388
+ });
389
+ const hardBreakMarkerHighlight = ViewPlugin.fromClass(class {
390
+ constructor(view) {
391
+ this.decorations = hardBreakMarkerMatcher.createDeco(view);
392
+ }
393
+ update(update) {
394
+ if (update.docChanged || update.viewportChanged) {
395
+ this.decorations = hardBreakMarkerMatcher.updateDeco(update, this.decorations);
396
+ }
397
+ }
398
+ }, {
399
+ decorations: value => value.decorations
400
+ });
291
401
  function injectStyles(doc) {
292
402
  const existing = doc.querySelector(`style[${STYLE_ATTR}]`);
293
403
  if (existing) {
@@ -312,7 +422,27 @@ function attributesToHtml(attrs) {
312
422
  .map(([key, value]) => ` ${key}="${escapeHtml(String(value))}"`)
313
423
  .join('');
314
424
  }
315
- function renderNavNode(node, state, options) {
425
+ // Renders the per-row indent/guide cells: one fixed-width cell per ancestor
426
+ // depth. An ancestor column draws a continuous vertical rail when that folder
427
+ // still has siblings below it (so the spine keeps going), otherwise it is
428
+ // blank. The final cell connects to this row with an elbow (└, last child) or a
429
+ // tee (├). Painting per row at the row's own height means the guides never
430
+ // overshoot an expanded last subfolder the way a single container rail did.
431
+ function renderNavGuides(trail, isLast) {
432
+ const cells = trail.map((continues) => `<span class="nav-indent${continues ? ' nav-indent-rail' : ''}"></span>`);
433
+ cells.push(`<span class="nav-indent nav-indent-connector${isLast ? '' : ' nav-indent-continues'}"></span>`);
434
+ return cells.join('');
435
+ }
436
+ function renderNavNode(node, state, options,
437
+ // Per-ancestor flags: whether each ancestor column still has a sibling below
438
+ // it (so its rail continues through this row). One entry per guide column
439
+ // above the immediate connector.
440
+ trail = [],
441
+ // Whether this node is the last among its siblings (elbow vs tee connector).
442
+ isLast = true,
443
+ // Nesting depth; depth 0 (root entries) draws no guides.
444
+ depth = 0) {
445
+ const guides = depth === 0 ? '' : renderNavGuides(trail, isLast);
316
446
  if (node.entry) {
317
447
  const isCurrent = node.entry.path === state.currentPath;
318
448
  const isOrphaned = isOrphanedMdzipAsset(node.entry, state);
@@ -348,17 +478,20 @@ function renderNavNode(node, state, options) {
348
478
  const draggable = options.allowDrag && !isManifest;
349
479
  return `<button type="button" class="${classes}" title="${title}"
350
480
  data-nav-path="${safePath}" data-orphan="${isOrphaned ? 'true' : ''}"${draggable ? ' draggable="true"' : ''}>
351
- <span class="nav-caret"></span>
481
+ ${guides}<span class="nav-caret"></span>
352
482
  <span class="nav-file-icon ${iconKind}">${iconHtml}</span>
353
483
  ${orphanBtnHtml}
354
484
  <span class="nav-label">${safeName}</span>
355
485
  </button>`;
356
486
  }
357
- const children = node.children.map(c => renderNavNode(c, state, options)).join('');
487
+ const childTrail = depth === 0 ? [] : [...trail, !isLast];
488
+ const children = node.children
489
+ .map((child, index) => renderNavNode(child, state, options, childTrail, index === node.children.length - 1, depth + 1))
490
+ .join('');
358
491
  const pending = options.pendingFolders.has(node.path.toLowerCase());
359
492
  return `<details class="nav-directory${pending ? ' pending-folder' : ''}" open data-nav-dir="${escapeHtml(node.path)}">
360
493
  <summary${pending ? ` title="${escapeHtml(node.path)} — not saved until it contains a file"` : ''}>
361
- <span class="nav-caret" aria-hidden="true"></span>
494
+ ${guides}<span class="nav-caret" aria-hidden="true"></span>
362
495
  <span class="nav-folder-icon closed">${FOLDER_CLOSED_ICON_HTML}</span>
363
496
  <span class="nav-folder-icon open">${FOLDER_OPEN_ICON_HTML}</span>
364
497
  <span class="nav-label">${escapeHtml(node.name)}</span>
@@ -412,10 +545,16 @@ export class MdzipWorkspaceView {
412
545
  this.metadataDialogOpen = false;
413
546
  this.titleDraft = '';
414
547
  this.conversionAction = null;
548
+ this.imageInsertDialogState = null;
415
549
  this.navPaneWidth = 280;
416
550
  this.splitRatio = 0.5;
417
551
  this.resizing = false;
418
- this.navMenuState = null;
552
+ // Shared overlay menu state. One menu is open at a time, so a single field
553
+ // drives both the nav-pane file menu and the editor selection menu; `kind`
554
+ // selects which item-builder and action-handler run. The editor variant
555
+ // captures the selection range at open time because clicking a menu item can
556
+ // move focus/selection before the action reads it.
557
+ this.contextMenuState = null;
419
558
  this.nameDialogState = null;
420
559
  this.deleteDialogState = null;
421
560
  this.pendingNewFolders = new Set();
@@ -434,6 +573,7 @@ export class MdzipWorkspaceView {
434
573
  this.tooltipHideTimer = null;
435
574
  this.cmEditor = null;
436
575
  this.readOnlyCompartment = new Compartment();
576
+ this.lineNumbersCompartment = new Compartment();
437
577
  this.updatingCm = false;
438
578
  this.syncing = false;
439
579
  this.markdownExtensions = [];
@@ -458,6 +598,9 @@ export class MdzipWorkspaceView {
458
598
  this.options = options;
459
599
  this.controlPolicy = resolveMdzipControlPolicy(options.controls);
460
600
  this.navigationMode = options.navigationMode ?? 'editor';
601
+ this.imageHydrationAnimation = options.imageHydrationAnimation ?? 'auto';
602
+ this.toolbarDensity = options.toolbarDensity ?? 'comfortable';
603
+ this.contentDensity = options.contentDensity ?? 'comfortable';
461
604
  this.layout = options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy);
462
605
  this.colorScheme = options.initialColorScheme
463
606
  ?? (container.ownerDocument.defaultView?.matchMedia('(prefers-color-scheme: dark)').matches
@@ -473,6 +616,7 @@ export class MdzipWorkspaceView {
473
616
  container.innerHTML = SHELL_HTML;
474
617
  const q = (sel) => container.querySelector(sel);
475
618
  this.elRoot = q('.mdzip-root');
619
+ this.applyDensityClasses();
476
620
  this.elDocumentStrip = q('[data-ref="document-strip"]');
477
621
  this.elToolbar = q('[data-ref="toolbar"]');
478
622
  this.elToolbarLeft = q('.toolbar-left');
@@ -515,6 +659,14 @@ export class MdzipWorkspaceView {
515
659
  this.elLibraryList = q('[data-ref="library-list"]');
516
660
  this.elConversionDialog = q('[data-ref="conversion-dialog"]');
517
661
  this.elConversionConfirmBtn = q('[data-ref="conversion-confirm-btn"]');
662
+ this.elImageInsertDialog = q('[data-ref="image-insert-dialog"]');
663
+ this.elImageInsertModeMarkdown = q('[data-ref="image-insert-mode-markdown"]');
664
+ this.elImageInsertModeHtml = q('[data-ref="image-insert-mode-html"]');
665
+ this.elImageInsertAltInput = q('[data-ref="image-insert-alt"]');
666
+ this.elImageInsertSizeModeSelect = q('[data-ref="image-insert-size-mode"]');
667
+ this.elImageInsertSizeValueInput = q('[data-ref="image-insert-size-value"]');
668
+ this.elImageInsertPositionSelect = q('[data-ref="image-insert-position"]');
669
+ this.elImageInsertConfirmBtn = q('[data-ref="image-insert-confirm-btn"]');
518
670
  this.elNavMenu = q('[data-ref="nav-menu"]');
519
671
  this.elNameDialog = q('[data-ref="name-dialog"]');
520
672
  this.elNameDialogHeading = q('[data-ref="name-dialog-heading"]');
@@ -731,12 +883,12 @@ export class MdzipWorkspaceView {
731
883
  if (command === 'insert-image') {
732
884
  if (this.workspace?.sourceFormat === 'markdown') {
733
885
  this.requestMdzConversion(file
734
- ? { kind: 'image-file', file }
886
+ ? { kind: 'image-file', file, source: 'picker' }
735
887
  : { kind: 'image-picker' });
736
888
  return true;
737
889
  }
738
890
  if (file) {
739
- await this.insertImageFile(file);
891
+ await this.insertImageFile(file, 'picker');
740
892
  }
741
893
  else {
742
894
  this.elImageInput.click();
@@ -746,6 +898,15 @@ export class MdzipWorkspaceView {
746
898
  this.applyMarkdownFormat(command);
747
899
  return true;
748
900
  }
901
+ // Backs the editor's formatting keybindings (Mod-b/i/k/e). Returns false when
902
+ // the command can't run (read-only, non-markdown) so the key passes through.
903
+ runFormatShortcut(command) {
904
+ if (!this.canExecuteCommand(command)) {
905
+ return false;
906
+ }
907
+ this.applyMarkdownFormat(command);
908
+ return true;
909
+ }
749
910
  async convertToMdz() {
750
911
  if (!this.workspace || this.workspace.mode === 'read-only') {
751
912
  return false;
@@ -765,6 +926,7 @@ export class MdzipWorkspaceView {
765
926
  }
766
927
  destroy() {
767
928
  this.conversionDocumentGeneration += 1;
929
+ this.resolveImageInsertDialog(null);
768
930
  try {
769
931
  this.unsub?.();
770
932
  }
@@ -811,6 +973,55 @@ export class MdzipWorkspaceView {
811
973
  this.entryMatchMissKey = null;
812
974
  this.render();
813
975
  }
976
+ /**
977
+ * Replaces the view control policy without recreating the workspace or
978
+ * CodeMirror editor. In particular, `lineNumbers` is reconfigured through a
979
+ * CodeMirror compartment so text, selection, focus, scroll, and undo history
980
+ * stay intact.
981
+ */
982
+ setControls(controls) {
983
+ const next = resolveMdzipControlPolicy(controls);
984
+ const lineNumbersChanged = next.lineNumbers !== this.controlPolicy.lineNumbers;
985
+ this.controlPolicy = next;
986
+ if (lineNumbersChanged && this.cmEditor) {
987
+ this.cmEditor.dispatch({
988
+ effects: this.lineNumbersCompartment.reconfigure(next.lineNumbers ? lineNumbers() : [])
989
+ });
990
+ }
991
+ const snapshot = this.workspace?.snapshot();
992
+ if (snapshot) {
993
+ this.layout = this.validLayoutForSnapshot(this.layout, snapshot);
994
+ }
995
+ this.render();
996
+ }
997
+ setImageHydrationAnimation(animation) {
998
+ const next = animation ?? 'auto';
999
+ if (next === this.imageHydrationAnimation) {
1000
+ return;
1001
+ }
1002
+ this.imageHydrationAnimation = next;
1003
+ this.resetPreviewState();
1004
+ this.render();
1005
+ }
1006
+ setDensityOptions(options) {
1007
+ const nextToolbarDensity = normalizeToolbarDensity(options.toolbarDensity);
1008
+ const nextContentDensity = normalizeContentDensity(options.contentDensity);
1009
+ if (nextToolbarDensity === this.toolbarDensity && nextContentDensity === this.contentDensity) {
1010
+ return;
1011
+ }
1012
+ this.toolbarDensity = nextToolbarDensity;
1013
+ this.contentDensity = nextContentDensity;
1014
+ this.applyDensityClasses();
1015
+ }
1016
+ setImageInsertOptions(options) {
1017
+ this.options.imageInsertMode = options.imageInsertMode;
1018
+ this.options.imageInsertHandler = options.imageInsertHandler;
1019
+ }
1020
+ applyDensityClasses() {
1021
+ this.elRoot.classList.remove('toolbar-density-comfortable', 'toolbar-density-compact', 'toolbar-density-dense', 'content-density-comfortable', 'content-density-compact');
1022
+ this.elRoot.classList.add(`toolbar-density-${this.toolbarDensity}`);
1023
+ this.elRoot.classList.add(`content-density-${this.contentDensity}`);
1024
+ }
814
1025
  /**
815
1026
  * Tears down all custom rendering state: aborts in-flight renders, destroys
816
1027
  * mounted extension and entry renderer handles, and clears memo caches.
@@ -868,6 +1079,7 @@ export class MdzipWorkspaceView {
868
1079
  // mounted extension handles.
869
1080
  return;
870
1081
  }
1082
+ const animateImageHydration = this.shouldAnimateImageHydration(memo, snapshot);
871
1083
  this.previewAbort?.abort();
872
1084
  this.previewAbort = null;
873
1085
  this.destroyPreviewHandles();
@@ -916,14 +1128,14 @@ export class MdzipWorkspaceView {
916
1128
  }
917
1129
  if (typeof result === 'string') {
918
1130
  // Sync fast path: no microtask hop when every pipeline stage is sync.
919
- this.applyPreviewHtml(result, snapshot, context, generation);
1131
+ this.applyPreviewHtml(result, snapshot, context, generation, animateImageHydration);
920
1132
  return;
921
1133
  }
922
1134
  void result.then((html) => {
923
1135
  if (generation !== this.previewGeneration || abort.signal.aborted) {
924
1136
  return; // Stale: the selection or content moved on while rendering.
925
1137
  }
926
- this.applyPreviewHtml(html, snapshot, context, generation);
1138
+ this.applyPreviewHtml(html, snapshot, context, generation, animateImageHydration);
927
1139
  }).catch((error) => {
928
1140
  if (generation !== this.previewGeneration || abort.signal.aborted) {
929
1141
  return;
@@ -933,17 +1145,28 @@ export class MdzipWorkspaceView {
933
1145
  }
934
1146
  });
935
1147
  }
936
- applyPreviewHtml(html, snapshot, context, generation) {
1148
+ applyPreviewHtml(html, snapshot, context, generation, animateImageHydration) {
937
1149
  // When the preview references archive images, mount the text immediately
938
1150
  // and hydrate each image progressively (reserving layout space from its
939
1151
  // sniffed intrinsic size), rather than blocking the whole preview on image
940
1152
  // resolution. Other markdown mounts synchronously.
941
1153
  if (this.assetSession && /<img\b/i.test(html)) {
942
- this.mountProgressivePreview(html, snapshot, context, generation);
1154
+ this.mountProgressivePreview(html, snapshot, context, generation, animateImageHydration);
943
1155
  return;
944
1156
  }
945
1157
  this.mountPreviewHtml(html, snapshot, context, generation);
946
1158
  }
1159
+ shouldAnimateImageHydration(previousMemo, snapshot) {
1160
+ if (this.imageHydrationAnimation === 'off') {
1161
+ return false;
1162
+ }
1163
+ if (this.imageHydrationAnimation === 'auto') {
1164
+ return true;
1165
+ }
1166
+ return !previousMemo
1167
+ || previousMemo.path !== snapshot.currentPath
1168
+ || previousMemo.pathType !== snapshot.currentPathType;
1169
+ }
947
1170
  mountPreviewHtml(html, snapshot, context, generation) {
948
1171
  this.elPreviewContent.innerHTML = html;
949
1172
  this.mountPreviewExtensions(context, generation);
@@ -957,7 +1180,7 @@ export class MdzipWorkspaceView {
957
1180
  * text is in the DOM; `onAssetsHydrated` fires once every referenced image
958
1181
  * has resolved and had its final `src` assigned (or there are none).
959
1182
  */
960
- mountProgressivePreview(html, snapshot, context, generation) {
1183
+ mountProgressivePreview(html, snapshot, context, generation, animateImageHydration) {
961
1184
  this.elPreviewContent.innerHTML = html;
962
1185
  const session = this.assetSession;
963
1186
  const document = this.elPreviewContent.ownerDocument;
@@ -973,9 +1196,20 @@ export class MdzipWorkspaceView {
973
1196
  // immediately readable; the slot eases open to the reserved height once
974
1197
  // the image resolves.
975
1198
  image.removeAttribute('src');
976
- image.classList.add('mdzip-image-loading');
1199
+ if (animateImageHydration) {
1200
+ image.classList.add('mdzip-image-loading');
1201
+ }
977
1202
  const slot = document.createElement('span');
978
- slot.className = 'mdzip-image-slot';
1203
+ slot.className = animateImageHydration
1204
+ ? 'mdzip-image-slot'
1205
+ : 'mdzip-image-slot mdzip-image-open mdzip-image-animation-off';
1206
+ const align = image.getAttribute('align')?.toLowerCase();
1207
+ if (align === 'left' || image.classList.contains('mdzip-image-wrap-left')) {
1208
+ slot.classList.add('mdzip-image-align-left');
1209
+ }
1210
+ else if (align === 'right' || image.classList.contains('mdzip-image-wrap-right')) {
1211
+ slot.classList.add('mdzip-image-align-right');
1212
+ }
979
1213
  image.parentNode?.insertBefore(slot, image);
980
1214
  slot.appendChild(image);
981
1215
  pending.push({ image, slot, source });
@@ -1008,7 +1242,8 @@ export class MdzipWorkspaceView {
1008
1242
  // Size the reserved box from the sniffed dimensions so the slot eases
1009
1243
  // open to the image's exact height in a single slide — and the pixels
1010
1244
  // drop into an already-correct box with no further reflow.
1011
- if (resolved.width && resolved.height) {
1245
+ if (resolved.width && resolved.height
1246
+ && !image.hasAttribute('width') && !image.hasAttribute('height')) {
1012
1247
  image.setAttribute('width', String(resolved.width));
1013
1248
  image.setAttribute('height', String(resolved.height));
1014
1249
  }
@@ -1033,6 +1268,10 @@ export class MdzipWorkspaceView {
1033
1268
  * under `prefers-reduced-motion`.
1034
1269
  */
1035
1270
  openImageSlot(slot) {
1271
+ if (this.imageHydrationAnimation === 'off' || slot.classList.contains('mdzip-image-animation-off')) {
1272
+ slot.classList.add('mdzip-image-open');
1273
+ return;
1274
+ }
1036
1275
  void slot.offsetHeight;
1037
1276
  slot.classList.add('mdzip-image-open');
1038
1277
  }
@@ -1339,11 +1578,17 @@ export class MdzipWorkspaceView {
1339
1578
  const state = EditorState.create({
1340
1579
  doc: initialText,
1341
1580
  extensions: [
1342
- ...(this.controlPolicy.lineNumbers ? [lineNumbers()] : []),
1581
+ this.lineNumbersCompartment.of(this.controlPolicy.lineNumbers ? lineNumbers() : []),
1343
1582
  history(),
1583
+ keymap.of([
1584
+ { key: 'Mod-b', run: () => self.runFormatShortcut('bold') },
1585
+ { key: 'Mod-i', run: () => self.runFormatShortcut('italic') },
1586
+ { key: 'Mod-k', run: () => self.runFormatShortcut('link') }
1587
+ ]),
1344
1588
  keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
1345
1589
  markdown(),
1346
1590
  syntaxHighlighting(mdzipMarkdownHighlight),
1591
+ hardBreakMarkerHighlight,
1347
1592
  EditorView.lineWrapping,
1348
1593
  dropCursor(),
1349
1594
  mdzipEditorTheme,
@@ -1499,8 +1744,24 @@ export class MdzipWorkspaceView {
1499
1744
  this.elPreviewBtn.setAttribute('aria-pressed', String(this.layout === 'preview'));
1500
1745
  this.elSplitBtn.setAttribute('aria-pressed', String(this.layout === 'split'));
1501
1746
  this.elSourceBtn.setAttribute('aria-pressed', String(this.layout === 'source'));
1502
- this.elNavBtn.classList.toggle('active', this.navVisible);
1503
- this.elNavBtn.setAttribute('aria-pressed', String(this.navVisible));
1747
+ if (snapshot.sourceFormat === 'markdown') {
1748
+ this.elNavBtn.innerHTML = CONVERT_TO_MDZ_ICON_HTML;
1749
+ this.elNavBtn.classList.remove('active');
1750
+ this.elNavBtn.classList.add('convert-mdz-toggle');
1751
+ this.elNavBtn.setAttribute('title', 'Convert to MDZ');
1752
+ this.elNavBtn.setAttribute('aria-label', 'Convert to MDZ');
1753
+ this.elNavBtn.dataset['tooltip'] = 'Convert to MDZ';
1754
+ this.elNavBtn.removeAttribute('aria-pressed');
1755
+ }
1756
+ else {
1757
+ this.elNavBtn.innerHTML = NAV_TOGGLE_ICON_HTML;
1758
+ this.elNavBtn.classList.remove('convert-mdz-toggle');
1759
+ this.elNavBtn.classList.toggle('active', this.navVisible);
1760
+ this.elNavBtn.setAttribute('title', 'Toggle contents');
1761
+ this.elNavBtn.setAttribute('aria-label', 'Toggle contents');
1762
+ this.elNavBtn.dataset['tooltip'] = 'Toggle contents';
1763
+ this.elNavBtn.setAttribute('aria-pressed', String(this.navVisible));
1764
+ }
1504
1765
  this.elZoomBtn.classList.toggle('active', this.zoomOpen);
1505
1766
  this.elDarkThemeBtn.classList.toggle('active', this.colorScheme === 'dark');
1506
1767
  this.elLightThemeBtn.classList.toggle('active', this.colorScheme === 'light');
@@ -1517,7 +1778,7 @@ export class MdzipWorkspaceView {
1517
1778
  this.elNavResizer.classList.toggle('hidden', !showNavigationPane);
1518
1779
  this.prunePendingFolders(snapshot);
1519
1780
  const navTree = snapshot.sourceFormat === 'mdz'
1520
- ? mergePendingFolders(buildMdzipNavTree(snapshot.content.paths), this.pendingNewFolders)
1781
+ ? mergePendingFolders(buildMdzipNavTree(snapshot.content.paths, snapshot.content.entryPoint), this.pendingNewFolders)
1521
1782
  : [];
1522
1783
  const allowOrphanActions = this.controlPolicy.orphanActions && snapshot.mode !== 'read-only';
1523
1784
  const allowFileActions = this.allowFileActions(snapshot);
@@ -1527,7 +1788,9 @@ export class MdzipWorkspaceView {
1527
1788
  allowDrag: snapshot.mode !== 'read-only' && snapshot.sourceFormat === 'mdz',
1528
1789
  pendingFolders: new Set([...this.pendingNewFolders].map((path) => path.toLowerCase()))
1529
1790
  };
1530
- this.elNavTree.innerHTML = navTree.map(n => renderNavNode(n, snapshot, navRenderOptions)).join('');
1791
+ this.elNavTree.innerHTML = navTree
1792
+ .map((n, i) => renderNavNode(n, snapshot, navRenderOptions, [], i === navTree.length - 1, 0))
1793
+ .join('');
1531
1794
  this.prepareTooltips();
1532
1795
  if (this.cmEditor) {
1533
1796
  this.updatingCm = true;
@@ -1564,28 +1827,38 @@ export class MdzipWorkspaceView {
1564
1827
  this.elTitleSaveBtn.disabled = !valid;
1565
1828
  }
1566
1829
  this.elConversionDialog.hidden = this.conversionAction === null;
1830
+ this.elImageInsertDialog.hidden = this.imageInsertDialogState === null;
1831
+ if (this.imageInsertDialogState) {
1832
+ this.elImageInsertAltInput.value = this.imageInsertDialogState.request.defaultAltText;
1833
+ this.elImageInsertModeMarkdown.checked = true;
1834
+ this.elImageInsertModeHtml.checked = false;
1835
+ this.elImageInsertSizeModeSelect.value = 'width';
1836
+ this.elImageInsertSizeValueInput.value = this.imageInsertDialogState.request.intrinsicWidth
1837
+ ? String(this.imageInsertDialogState.request.intrinsicWidth)
1838
+ : '';
1839
+ this.elImageInsertPositionSelect.value = 'inline';
1840
+ this.updateImageInsertOptionControls();
1841
+ }
1567
1842
  this.elMetadataDialog.hidden = !this.metadataDialogOpen;
1568
- if (this.navMenuState) {
1569
- const items = this.navMenuItems(this.navMenuState.target, snapshot);
1843
+ if (this.contextMenuState) {
1844
+ const items = this.contextMenuState.kind === 'nav'
1845
+ ? this.navMenuItems(this.contextMenuState.target, snapshot)
1846
+ : this.editorMenuItems(snapshot);
1570
1847
  if (items.length === 0) {
1571
- this.navMenuState = null;
1848
+ this.contextMenuState = null;
1572
1849
  }
1573
1850
  else {
1574
- this.elNavMenu.innerHTML = items
1575
- .map((item) => item === null
1576
- ? '<div class="nav-menu-separator" role="separator"></div>'
1577
- : `<button type="button" role="menuitem" data-menu-action="${escapeHtml(item.action)}">${escapeHtml(item.label)}</button>`)
1578
- .join('');
1851
+ this.elNavMenu.innerHTML = renderContextMenuItems(items);
1579
1852
  this.elNavMenu.hidden = false;
1580
1853
  const rect = this.elNavMenu.getBoundingClientRect();
1581
1854
  const win = this.elRoot.ownerDocument.defaultView ?? window;
1582
- const x = Math.max(4, Math.min(this.navMenuState.x, win.innerWidth - rect.width - 8));
1583
- const y = Math.max(4, Math.min(this.navMenuState.y, win.innerHeight - rect.height - 8));
1855
+ const x = Math.max(4, Math.min(this.contextMenuState.x, win.innerWidth - rect.width - 8));
1856
+ const y = Math.max(4, Math.min(this.contextMenuState.y, win.innerHeight - rect.height - 8));
1584
1857
  this.elNavMenu.style.left = `${x}px`;
1585
1858
  this.elNavMenu.style.top = `${y}px`;
1586
1859
  }
1587
1860
  }
1588
- if (!this.navMenuState) {
1861
+ if (!this.contextMenuState) {
1589
1862
  this.elNavMenu.hidden = true;
1590
1863
  }
1591
1864
  this.elNameDialog.hidden = this.nameDialogState === null;
@@ -1638,9 +1911,9 @@ export class MdzipWorkspaceView {
1638
1911
  const doc = this.elRoot.ownerDocument;
1639
1912
  doc.addEventListener('click', () => {
1640
1913
  this.closeFormatMenus();
1641
- if (this.zoomOpen || this.navMenuState) {
1914
+ if (this.zoomOpen || this.contextMenuState) {
1642
1915
  this.zoomOpen = false;
1643
- this.navMenuState = null;
1916
+ this.contextMenuState = null;
1644
1917
  this.render();
1645
1918
  }
1646
1919
  });
@@ -1648,8 +1921,8 @@ export class MdzipWorkspaceView {
1648
1921
  if (e.key !== 'Escape') {
1649
1922
  return;
1650
1923
  }
1651
- if (this.navMenuState || this.deleteDialogState || this.nameDialogState) {
1652
- this.navMenuState = null;
1924
+ if (this.contextMenuState || this.deleteDialogState || this.nameDialogState) {
1925
+ this.contextMenuState = null;
1653
1926
  this.deleteDialogState = null;
1654
1927
  this.nameDialogState = null;
1655
1928
  this.render();
@@ -1758,7 +2031,7 @@ export class MdzipWorkspaceView {
1758
2031
  const file = this.elImageInput.files?.[0];
1759
2032
  this.elImageInput.value = '';
1760
2033
  if (file) {
1761
- void this.insertImageFile(file);
2034
+ void this.insertImageFile(file, 'picker');
1762
2035
  }
1763
2036
  });
1764
2037
  this.elEditToolbar.addEventListener('keydown', (event) => {
@@ -1901,12 +2174,78 @@ export class MdzipWorkspaceView {
1901
2174
  this.elConversionConfirmBtn.addEventListener('click', () => {
1902
2175
  void this.confirmMdzConversion();
1903
2176
  });
2177
+ this.elImageInsertDialog.querySelector('[data-action="cancel-image-insert"]')
2178
+ .addEventListener('click', () => {
2179
+ this.resolveImageInsertDialog(null);
2180
+ });
2181
+ this.elImageInsertConfirmBtn.addEventListener('click', () => {
2182
+ this.resolveImageInsertDialog(this.readImageInsertDialogDecision());
2183
+ });
2184
+ this.elImageInsertModeMarkdown.addEventListener('change', () => this.updateImageInsertOptionControls());
2185
+ this.elImageInsertModeHtml.addEventListener('change', () => this.updateImageInsertOptionControls());
2186
+ this.elImageInsertSizeModeSelect.addEventListener('change', () => {
2187
+ this.resetImageInsertSizeValue();
2188
+ this.updateImageInsertOptionControls();
2189
+ });
1904
2190
  this.elNavMenu.addEventListener('click', (e) => {
1905
2191
  e.stopPropagation();
1906
2192
  const item = e.target.closest('[data-menu-action]');
1907
- if (item) {
1908
- void this.handleNavMenuAction(item.dataset['menuAction'] ?? '');
2193
+ if (!item) {
2194
+ return;
2195
+ }
2196
+ const action = item.dataset['menuAction'] ?? '';
2197
+ if (this.contextMenuState?.kind === 'editor') {
2198
+ void this.handleEditorMenuAction(action);
2199
+ }
2200
+ else {
2201
+ void this.handleNavMenuAction(action);
2202
+ }
2203
+ });
2204
+ // Flyout submenus open to the right and aligned with their parent by
2205
+ // default; nudge them within the viewport. Runs on hover (when the submenu
2206
+ // is already displayed, so it can be measured): flips left when it would
2207
+ // overflow the right edge, and shifts up when a tall list (e.g. the code
2208
+ // languages) would run off the bottom.
2209
+ this.elNavMenu.addEventListener('pointerover', (e) => {
2210
+ const wrap = e.target.closest('.nav-menu-submenu-wrap');
2211
+ if (!wrap) {
2212
+ return;
2213
+ }
2214
+ const submenu = wrap.querySelector('.nav-context-submenu');
2215
+ if (!submenu) {
2216
+ return;
2217
+ }
2218
+ const win = this.elRoot.ownerDocument.defaultView ?? window;
2219
+ const margin = 8;
2220
+ const rect = wrap.getBoundingClientRect();
2221
+ const width = submenu.offsetWidth || SUBMENU_ESTIMATED_WIDTH;
2222
+ wrap.classList.toggle('open-left', rect.right + width > win.innerWidth - margin);
2223
+ // Vertical: clamp the flyout's top so its full height stays on-screen.
2224
+ // `top` is relative to the parent row (CSS default is -5px).
2225
+ const naturalTop = rect.top - 5;
2226
+ const clampedTop = Math.max(margin, Math.min(naturalTop, win.innerHeight - margin - submenu.offsetHeight));
2227
+ submenu.style.top = `${clampedTop - rect.top}px`;
2228
+ });
2229
+ this.elEditorHost.addEventListener('contextmenu', (e) => {
2230
+ const snapshot = this.workspace?.snapshot();
2231
+ if (!snapshot || !this.cmEditor) {
2232
+ return;
1909
2233
  }
2234
+ const selection = this.cmEditor.state.selection.main;
2235
+ this.contextMenuState = {
2236
+ kind: 'editor',
2237
+ from: selection.from,
2238
+ to: selection.to,
2239
+ x: e.clientX,
2240
+ y: e.clientY
2241
+ };
2242
+ if (this.editorMenuItems(snapshot).length === 0) {
2243
+ this.contextMenuState = null;
2244
+ return;
2245
+ }
2246
+ e.preventDefault();
2247
+ e.stopPropagation();
2248
+ this.render();
1910
2249
  });
1911
2250
  this.elNameInput.addEventListener('input', () => {
1912
2251
  if (!this.nameDialogState) {
@@ -2286,7 +2625,7 @@ export class MdzipWorkspaceView {
2286
2625
  editor.dispatch({ selection: { anchor: target.selectionStart } });
2287
2626
  }
2288
2627
  if (action.kind === 'image-file') {
2289
- await this.insertImageFile(action.file);
2628
+ await this.insertImageFile(action.file, action.source ?? 'picker');
2290
2629
  return true;
2291
2630
  }
2292
2631
  this.elImageInput.click();
@@ -2312,7 +2651,7 @@ export class MdzipWorkspaceView {
2312
2651
  return;
2313
2652
  }
2314
2653
  if (action.kind === 'image-file') {
2315
- await this.insertImageFile(action.file);
2654
+ await this.insertImageFile(action.file, action.source ?? 'picker');
2316
2655
  return;
2317
2656
  }
2318
2657
  this.elImageInput.click();
@@ -2332,37 +2671,45 @@ export class MdzipWorkspaceView {
2332
2671
  if (this.workspace?.sourceFormat === 'markdown') {
2333
2672
  const extension = extensionForMime(image.mimeType);
2334
2673
  const pastedFile = new window.File([new Blob([image.bytes], { type: image.mimeType })], `pasted.${extension}`, { type: image.mimeType });
2335
- this.requestMdzConversion({ kind: 'image-file', file: pastedFile });
2674
+ this.requestMdzConversion({ kind: 'image-file', file: pastedFile, source: 'paste' });
2336
2675
  return;
2337
2676
  }
2338
- await this.insertImageBytes(image.bytes, image.mimeType);
2677
+ await this.insertImageBytes(image.bytes, image.mimeType, {
2678
+ fileName: `pasted.${extensionForMime(image.mimeType)}`,
2679
+ source: 'paste'
2680
+ });
2339
2681
  }
2340
2682
  catch (error) {
2341
2683
  this.options.onFailed?.(error);
2342
2684
  }
2343
2685
  }
2344
- async insertImageFile(file) {
2686
+ async insertImageFile(file, source = 'picker') {
2345
2687
  if (!file.type.startsWith('image/') && !/\.(png|jpe?g|gif|webp|svg)$/i.test(file.name)) {
2346
2688
  return;
2347
2689
  }
2348
2690
  try {
2349
- await this.insertImageBytes(new Uint8Array(await file.arrayBuffer()), file.type || imageMimeTypeFromFileName(file.name));
2691
+ await this.insertImageBytes(new Uint8Array(await file.arrayBuffer()), file.type || imageMimeTypeFromFileName(file.name), { fileName: file.name, source });
2350
2692
  }
2351
2693
  catch (error) {
2352
2694
  this.options.onFailed?.(error);
2353
2695
  }
2354
2696
  }
2355
- async insertImageBytes(bytes, mimeType) {
2697
+ async insertImageBytes(bytes, mimeType, options) {
2356
2698
  const editor = this.cmEditor;
2357
2699
  if (!editor) {
2358
2700
  return;
2359
2701
  }
2360
2702
  const selection = editor.state.selection.main;
2703
+ const decision = await this.resolveImageInsertDecision(bytes, mimeType, options);
2704
+ if (!decision) {
2705
+ return;
2706
+ }
2361
2707
  const result = await this.workspace?.pasteImage({
2362
2708
  bytes,
2363
2709
  mimeType,
2364
2710
  selectionStart: selection.from,
2365
- selectionEnd: selection.to
2711
+ selectionEnd: selection.to,
2712
+ markdownImage: (markdownPath) => formatImageInsertMarkdown(markdownPath, decision, editor.state.doc.toString(), selection.from, selection.to)
2366
2713
  });
2367
2714
  if (result && this.cmEditor) {
2368
2715
  this.render();
@@ -2370,6 +2717,405 @@ export class MdzipWorkspaceView {
2370
2717
  this.cmEditor.focus();
2371
2718
  }
2372
2719
  }
2720
+ async resolveImageInsertDecision(bytes, mimeType, options) {
2721
+ const size = sniffImageSize(bytes, mimeType);
2722
+ const request = {
2723
+ fileName: options.fileName || `pasted.${extensionForMime(mimeType)}`,
2724
+ mimeType,
2725
+ intrinsicWidth: size?.width,
2726
+ intrinsicHeight: size?.height,
2727
+ defaultAltText: 'Pasted image',
2728
+ source: options.source
2729
+ };
2730
+ const handler = this.options.imageInsertHandler;
2731
+ if (handler) {
2732
+ try {
2733
+ const handled = await handler(request);
2734
+ if (handled !== undefined) {
2735
+ return normalizeImageInsertDecision(handled);
2736
+ }
2737
+ }
2738
+ catch (error) {
2739
+ this.options.onFailed?.(error);
2740
+ return null;
2741
+ }
2742
+ }
2743
+ const mode = this.options.imageInsertMode ?? 'markdown';
2744
+ if (mode === 'ask') {
2745
+ return this.openImageInsertDialog(request);
2746
+ }
2747
+ return normalizeImageInsertDecision({
2748
+ mode: mode === 'html' ? 'html' : 'markdown',
2749
+ altText: request.defaultAltText,
2750
+ width: mode === 'html' ? request.intrinsicWidth : undefined,
2751
+ height: mode === 'html' ? request.intrinsicHeight : undefined,
2752
+ position: 'inline'
2753
+ });
2754
+ }
2755
+ openImageInsertDialog(request) {
2756
+ return new Promise((resolve) => {
2757
+ this.resolveImageInsertDialog(null);
2758
+ this.imageInsertDialogState = { request, resolve };
2759
+ this.render();
2760
+ requestAnimationFrame(() => this.elImageInsertAltInput.focus());
2761
+ });
2762
+ }
2763
+ resolveImageInsertDialog(decision) {
2764
+ const state = this.imageInsertDialogState;
2765
+ if (!state) {
2766
+ return;
2767
+ }
2768
+ this.imageInsertDialogState = null;
2769
+ this.render();
2770
+ state.resolve(normalizeImageInsertDecision(decision));
2771
+ }
2772
+ readImageInsertDialogDecision() {
2773
+ const parseDimension = (value) => {
2774
+ const parsed = Number(value);
2775
+ return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
2776
+ };
2777
+ const request = this.imageInsertDialogState?.request;
2778
+ const sizeMode = this.elImageInsertSizeModeSelect.value;
2779
+ const sizeValue = parseDimension(this.elImageInsertSizeValueInput.value);
2780
+ let width;
2781
+ let height;
2782
+ if (this.elImageInsertModeHtml.checked && sizeValue) {
2783
+ if (sizeMode === 'width') {
2784
+ width = sizeValue;
2785
+ }
2786
+ else if (sizeMode === 'height') {
2787
+ height = sizeValue;
2788
+ }
2789
+ else if (sizeMode === 'percent' && request?.intrinsicWidth && request.intrinsicHeight) {
2790
+ width = Math.max(1, Math.round(request.intrinsicWidth * sizeValue / 100));
2791
+ height = Math.max(1, Math.round(request.intrinsicHeight * sizeValue / 100));
2792
+ }
2793
+ }
2794
+ return {
2795
+ mode: this.elImageInsertModeHtml.checked ? 'html' : 'markdown',
2796
+ altText: this.elImageInsertAltInput.value,
2797
+ width,
2798
+ height,
2799
+ position: this.elImageInsertPositionSelect.value
2800
+ };
2801
+ }
2802
+ updateImageInsertOptionControls() {
2803
+ const htmlMode = this.elImageInsertModeHtml.checked;
2804
+ const sizeDisabled = !htmlMode || this.elImageInsertSizeModeSelect.value === 'original';
2805
+ this.elImageInsertSizeModeSelect.disabled = !htmlMode;
2806
+ this.elImageInsertSizeValueInput.disabled = sizeDisabled;
2807
+ this.elImageInsertPositionSelect.disabled = !htmlMode;
2808
+ this.elImageInsertSizeModeSelect.closest('.image-insert-field')?.classList.toggle('field-disabled', !htmlMode);
2809
+ this.elImageInsertSizeValueInput.closest('.image-insert-field')?.classList.toggle('field-disabled', sizeDisabled);
2810
+ this.elImageInsertPositionSelect.closest('.image-insert-field')?.classList.toggle('field-disabled', !htmlMode);
2811
+ }
2812
+ resetImageInsertSizeValue() {
2813
+ const request = this.imageInsertDialogState?.request;
2814
+ switch (this.elImageInsertSizeModeSelect.value) {
2815
+ case 'width':
2816
+ this.elImageInsertSizeValueInput.value = request?.intrinsicWidth ? String(request.intrinsicWidth) : '';
2817
+ break;
2818
+ case 'height':
2819
+ this.elImageInsertSizeValueInput.value = request?.intrinsicHeight ? String(request.intrinsicHeight) : '';
2820
+ break;
2821
+ case 'percent':
2822
+ this.elImageInsertSizeValueInput.value = '100';
2823
+ break;
2824
+ default:
2825
+ this.elImageInsertSizeValueInput.value = '';
2826
+ break;
2827
+ }
2828
+ }
2829
+ // Items for the editor selection menu; null entries render as separators.
2830
+ // Reads the selection range captured when the menu opened (not the live
2831
+ // selection) so the displayed actions match what the handlers will act on.
2832
+ editorMenuItems(snapshot) {
2833
+ const state = this.contextMenuState;
2834
+ if (!this.cmEditor || state?.kind !== 'editor') {
2835
+ return [];
2836
+ }
2837
+ const hasSelection = state.to > state.from;
2838
+ const editable = snapshot.mode !== 'read-only' && snapshot.currentPathType === 'markdown';
2839
+ const formatting = this.controlPolicy.formatting;
2840
+ const groups = [];
2841
+ const clipboard = [];
2842
+ if (hasSelection) {
2843
+ if (editable) {
2844
+ clipboard.push({ action: 'editor-cut', label: 'Cut', icon: MENU_CUT_ICON_HTML, shortcut: this.editorShortcut('X') });
2845
+ }
2846
+ clipboard.push({ action: 'editor-copy', label: 'Copy', icon: MENU_COPY_ICON_HTML, shortcut: this.editorShortcut('C') });
2847
+ }
2848
+ if (editable) {
2849
+ clipboard.push({ action: 'editor-paste', label: 'Paste', icon: MENU_PASTE_ICON_HTML, shortcut: this.editorShortcut('V') });
2850
+ clipboard.push({ action: 'editor-paste-plain', label: 'Paste as Plain Text', icon: MENU_PASTE_PLAIN_ICON_HTML });
2851
+ }
2852
+ if (clipboard.length > 0) {
2853
+ groups.push(clipboard);
2854
+ }
2855
+ // Formatting mirrors the toolbar's capabilities so the menu can stand in
2856
+ // for it. Inline marks and block commands apply to the current line when
2857
+ // there's no selection (same as the toolbar), so only Clear Formatting —
2858
+ // which acts on a range — is gated on a selection.
2859
+ const anyInline = formatting.bold || formatting.italic || formatting.strikethrough || formatting.inlineCode;
2860
+ const anyBlock = formatting.headings.length > 0 || formatting.bulletList
2861
+ || formatting.orderedList || formatting.blockquote || formatting.codeBlock || formatting.lineBreak;
2862
+ if (editable && anyInline) {
2863
+ const inline = [];
2864
+ if (formatting.bold) {
2865
+ inline.push({ action: 'bold', label: 'Bold', icon: MENU_BOLD_ICON_HTML, shortcut: this.editorShortcut('B') });
2866
+ }
2867
+ if (formatting.italic) {
2868
+ inline.push({ action: 'italic', label: 'Italic', icon: MENU_ITALIC_ICON_HTML, shortcut: this.editorShortcut('I') });
2869
+ }
2870
+ if (formatting.strikethrough) {
2871
+ inline.push({ action: 'strikethrough', label: 'Strikethrough', icon: MENU_STRIKE_ICON_HTML });
2872
+ }
2873
+ inline.push({ action: 'highlight', label: 'Highlight', icon: MENU_HIGHLIGHT_ICON_HTML });
2874
+ if (formatting.inlineCode) {
2875
+ inline.push({ action: 'inline-code', label: 'Inline Code', icon: MENU_CODE_ICON_HTML });
2876
+ }
2877
+ groups.push(inline);
2878
+ }
2879
+ if (editable && anyBlock) {
2880
+ const block = [];
2881
+ if (formatting.headings.length > 0) {
2882
+ block.push({
2883
+ action: '',
2884
+ label: 'Heading',
2885
+ icon: MENU_HEADING_PARENT_ICON_HTML,
2886
+ submenu: [
2887
+ { action: 'paragraph', label: 'Paragraph', icon: MENU_PARAGRAPH_ICON_HTML },
2888
+ null,
2889
+ ...formatting.headings.map((level) => ({
2890
+ action: `heading-${level}`,
2891
+ label: `Heading ${level}`,
2892
+ icon: MENU_HEADING_ICON_HTML[level]
2893
+ }))
2894
+ ]
2895
+ });
2896
+ }
2897
+ if (formatting.bulletList) {
2898
+ block.push({ action: 'bullet-list', label: 'Bullet List', icon: MENU_BULLET_LIST_ICON_HTML });
2899
+ }
2900
+ if (formatting.orderedList) {
2901
+ block.push({ action: 'ordered-list', label: 'Numbered List', icon: MENU_ORDERED_LIST_ICON_HTML });
2902
+ }
2903
+ if (formatting.blockquote) {
2904
+ block.push({ action: 'blockquote', label: 'Blockquote', icon: MENU_QUOTE_ICON_HTML });
2905
+ }
2906
+ if (formatting.codeBlock) {
2907
+ const languages = this.options.codeBlockLanguages ?? DEFAULT_CODE_BLOCK_LANGUAGES;
2908
+ block.push({
2909
+ action: '',
2910
+ label: 'Code Block',
2911
+ icon: MENU_CODE_BLOCK_ICON_HTML,
2912
+ submenu: languages.map((lang) => ({
2913
+ action: `code-block:${lang.id}`,
2914
+ label: lang.label
2915
+ }))
2916
+ });
2917
+ }
2918
+ if (formatting.lineBreak) {
2919
+ block.push({ action: 'insert-line-break', label: 'Line Break', icon: MENU_LINE_BREAK_ICON_HTML });
2920
+ }
2921
+ groups.push(block);
2922
+ }
2923
+ if (editable && (formatting.link || formatting.image)) {
2924
+ const insert = [];
2925
+ if (formatting.link) {
2926
+ insert.push({ action: 'link', label: 'Link…', icon: MENU_LINK_ICON_HTML, shortcut: this.editorShortcut('K') });
2927
+ }
2928
+ if (formatting.image) {
2929
+ insert.push({ action: 'insert-image', label: 'Insert Image…', icon: MENU_IMAGE_ICON_HTML });
2930
+ }
2931
+ groups.push(insert);
2932
+ }
2933
+ if (editable && hasSelection && (anyInline || anyBlock)) {
2934
+ groups.push([{ action: 'editor-clear-format', label: 'Clear Formatting', icon: MENU_CLEAR_FORMAT_ICON_HTML }]);
2935
+ }
2936
+ groups.push([{ action: 'editor-select-all', label: 'Select All', icon: MENU_SELECT_ALL_ICON_HTML, shortcut: this.editorShortcut('A') }]);
2937
+ return groups.flatMap((group, index) => (index === 0 ? group : [null, ...group]));
2938
+ }
2939
+ async handleEditorMenuAction(action) {
2940
+ const state = this.contextMenuState;
2941
+ this.contextMenuState = null;
2942
+ this.render();
2943
+ const editor = this.cmEditor;
2944
+ if (!editor || state?.kind !== 'editor') {
2945
+ return;
2946
+ }
2947
+ // Clicking the menu may have moved focus and collapsed the selection, so
2948
+ // restore the range captured when the menu opened before acting on it.
2949
+ if (action !== 'editor-select-all') {
2950
+ editor.dispatch({ selection: { anchor: state.from, head: state.to } });
2951
+ }
2952
+ // Code Block submenu items carry the chosen language as a suffix.
2953
+ if (action.startsWith('code-block:')) {
2954
+ this.insertCodeBlock(action.slice('code-block:'.length));
2955
+ editor.focus();
2956
+ return;
2957
+ }
2958
+ switch (action) {
2959
+ case 'editor-cut':
2960
+ await this.cutEditorSelection();
2961
+ break;
2962
+ case 'editor-copy':
2963
+ await this.copyEditorSelection();
2964
+ break;
2965
+ case 'editor-paste':
2966
+ case 'editor-paste-plain':
2967
+ await this.pasteIntoEditor();
2968
+ break;
2969
+ case 'editor-clear-format':
2970
+ this.clearSelectionFormatting();
2971
+ editor.focus();
2972
+ break;
2973
+ case 'editor-select-all':
2974
+ editor.dispatch({ selection: { anchor: 0, head: editor.state.doc.length } });
2975
+ editor.focus();
2976
+ break;
2977
+ case 'insert-image':
2978
+ await this.executeCommand('insert-image');
2979
+ break;
2980
+ case 'bold':
2981
+ case 'italic':
2982
+ case 'strikethrough':
2983
+ case 'highlight':
2984
+ case 'inline-code':
2985
+ case 'blockquote':
2986
+ case 'bullet-list':
2987
+ case 'ordered-list':
2988
+ case 'insert-line-break':
2989
+ case 'link':
2990
+ case 'paragraph':
2991
+ case 'heading-1':
2992
+ case 'heading-2':
2993
+ case 'heading-3':
2994
+ case 'heading-4':
2995
+ case 'heading-5':
2996
+ case 'heading-6':
2997
+ this.applyMarkdownFormat(action);
2998
+ editor.focus();
2999
+ break;
3000
+ }
3001
+ }
3002
+ // Inserts a fenced code block, carrying the chosen language as the fence info
3003
+ // string. An empty language produces a plain ```` ``` ```` block.
3004
+ insertCodeBlock(language) {
3005
+ if (!this.canExecuteCommand('code-block')) {
3006
+ return;
3007
+ }
3008
+ this.wrapSelection(`\`\`\`${language}\n`, '\n```', 'code');
3009
+ }
3010
+ // Strips common Markdown formatting from the selection: leading block markers
3011
+ // (headings, blockquotes, list bullets) per line, then inline emphasis,
3012
+ // highlight, and code spans. Heuristic by nature — it targets the markers the
3013
+ // editor itself produces rather than parsing the full grammar.
3014
+ clearSelectionFormatting() {
3015
+ const editor = this.cmEditor;
3016
+ const snapshot = this.workspace?.snapshot();
3017
+ if (!editor || !snapshot || snapshot.mode === 'read-only') {
3018
+ return;
3019
+ }
3020
+ const selection = editor.state.selection.main;
3021
+ if (selection.empty) {
3022
+ return;
3023
+ }
3024
+ const cleared = editor.state.sliceDoc(selection.from, selection.to)
3025
+ .split('\n')
3026
+ .map((line) => line
3027
+ .replace(/^(\s*)#{1,6}\s+/, '$1')
3028
+ .replace(/^(\s*)>\s?/, '$1')
3029
+ .replace(/^(\s*)(?:[-*+]|\d+\.)\s+/, '$1'))
3030
+ .join('\n')
3031
+ .replace(/<\/?mark>/gi, '')
3032
+ .replace(/(\*\*|__)(.*?)\1/g, '$2')
3033
+ .replace(/(\*|_)(.*?)\1/g, '$2')
3034
+ .replace(/~~(.*?)~~/g, '$1')
3035
+ .replace(/==(.*?)==/g, '$1')
3036
+ .replace(/`([^`]*)`/g, '$1');
3037
+ editor.dispatch({
3038
+ changes: { from: selection.from, to: selection.to, insert: cleared },
3039
+ selection: { anchor: selection.from, head: selection.from + cleared.length }
3040
+ });
3041
+ }
3042
+ editorClipboard() {
3043
+ return this.elRoot.ownerDocument.defaultView?.navigator?.clipboard;
3044
+ }
3045
+ // Renders a single-letter shortcut for the host platform: ⌘X on macOS,
3046
+ // Ctrl+X elsewhere. Only used for bindings that genuinely fire (the native
3047
+ // clipboard keys and `defaultKeymap`'s select-all).
3048
+ editorShortcut(key) {
3049
+ const nav = this.elRoot.ownerDocument.defaultView?.navigator;
3050
+ const platform = nav?.userAgentData?.platform
3051
+ ?? nav?.platform
3052
+ ?? '';
3053
+ return /mac|iphone|ipad|ipod/i.test(platform) ? `⌘${key}` : `Ctrl+${key}`;
3054
+ }
3055
+ async copyEditorSelection() {
3056
+ const editor = this.cmEditor;
3057
+ if (!editor) {
3058
+ return;
3059
+ }
3060
+ const selection = editor.state.selection.main;
3061
+ if (selection.empty) {
3062
+ return;
3063
+ }
3064
+ try {
3065
+ await this.editorClipboard()?.writeText(editor.state.sliceDoc(selection.from, selection.to));
3066
+ }
3067
+ catch (error) {
3068
+ this.options.onFailed?.(error);
3069
+ }
3070
+ editor.focus();
3071
+ }
3072
+ async cutEditorSelection() {
3073
+ const editor = this.cmEditor;
3074
+ const snapshot = this.workspace?.snapshot();
3075
+ if (!editor || !snapshot || snapshot.mode === 'read-only') {
3076
+ return;
3077
+ }
3078
+ const selection = editor.state.selection.main;
3079
+ if (selection.empty) {
3080
+ return;
3081
+ }
3082
+ try {
3083
+ await this.editorClipboard()?.writeText(editor.state.sliceDoc(selection.from, selection.to));
3084
+ }
3085
+ catch (error) {
3086
+ this.options.onFailed?.(error);
3087
+ return;
3088
+ }
3089
+ editor.dispatch({
3090
+ changes: { from: selection.from, to: selection.to, insert: '' },
3091
+ selection: { anchor: selection.from }
3092
+ });
3093
+ editor.focus();
3094
+ }
3095
+ async pasteIntoEditor() {
3096
+ const editor = this.cmEditor;
3097
+ const snapshot = this.workspace?.snapshot();
3098
+ if (!editor || !snapshot || snapshot.mode === 'read-only') {
3099
+ return;
3100
+ }
3101
+ let text;
3102
+ try {
3103
+ text = await this.editorClipboard()?.readText();
3104
+ }
3105
+ catch (error) {
3106
+ this.options.onFailed?.(error);
3107
+ return;
3108
+ }
3109
+ if (!text) {
3110
+ return;
3111
+ }
3112
+ const selection = editor.state.selection.main;
3113
+ editor.dispatch({
3114
+ changes: { from: selection.from, to: selection.to, insert: text },
3115
+ selection: { anchor: selection.from + text.length }
3116
+ });
3117
+ editor.focus();
3118
+ }
2373
3119
  applyMarkdownFormat(format) {
2374
3120
  const editor = this.cmEditor;
2375
3121
  const snapshot = this.workspace?.snapshot();
@@ -2386,6 +3132,9 @@ export class MdzipWorkspaceView {
2386
3132
  case 'strikethrough':
2387
3133
  this.wrapSelection('~~', '~~', 'strikethrough text');
2388
3134
  break;
3135
+ case 'highlight':
3136
+ this.wrapSelection('<mark>', '</mark>', 'highlighted text');
3137
+ break;
2389
3138
  case 'paragraph':
2390
3139
  this.setSelectedLinePrefix('', /^(#{1,6})\s+/);
2391
3140
  break;
@@ -2412,8 +3161,11 @@ export class MdzipWorkspaceView {
2412
3161
  case 'blockquote':
2413
3162
  this.prefixSelectedLines('> ', /^(\s*)>\s?/);
2414
3163
  break;
3164
+ case 'insert-line-break':
3165
+ this.insertLineBreak();
3166
+ break;
2415
3167
  case 'link':
2416
- this.wrapSelection('[', '](url)', 'link text');
3168
+ this.insertMarkdownLink();
2417
3169
  break;
2418
3170
  default:
2419
3171
  return;
@@ -2430,6 +3182,7 @@ export class MdzipWorkspaceView {
2430
3182
  orderedList: formatting.orderedList,
2431
3183
  code: formatting.inlineCode || formatting.codeBlock,
2432
3184
  blockquote: formatting.blockquote,
3185
+ lineBreak: formatting.lineBreak,
2433
3186
  link: formatting.link,
2434
3187
  image: formatting.image
2435
3188
  };
@@ -2490,6 +3243,38 @@ export class MdzipWorkspaceView {
2490
3243
  });
2491
3244
  editor.focus();
2492
3245
  }
3246
+ insertMarkdownLink() {
3247
+ const editor = this.cmEditor;
3248
+ if (!editor) {
3249
+ return;
3250
+ }
3251
+ const selection = editor.state.selection.main;
3252
+ const selectedText = editor.state.sliceDoc(selection.from, selection.to);
3253
+ const linkText = selectedText || 'link text';
3254
+ const urlPlaceholder = 'url';
3255
+ const insert = `[${linkText}](${urlPlaceholder})`;
3256
+ const anchor = selection.from + linkText.length + 3;
3257
+ editor.dispatch({
3258
+ changes: { from: selection.from, to: selection.to, insert },
3259
+ selection: { anchor, head: anchor + urlPlaceholder.length },
3260
+ scrollIntoView: true
3261
+ });
3262
+ editor.focus();
3263
+ }
3264
+ insertLineBreak() {
3265
+ const editor = this.cmEditor;
3266
+ if (!editor) {
3267
+ return;
3268
+ }
3269
+ const selection = editor.state.selection.main;
3270
+ const insert = '<br>\n';
3271
+ editor.dispatch({
3272
+ changes: { from: selection.from, to: selection.to, insert },
3273
+ selection: { anchor: selection.from + insert.length },
3274
+ scrollIntoView: true
3275
+ });
3276
+ editor.focus();
3277
+ }
2493
3278
  prefixSelectedLines(prefix, existingPrefix) {
2494
3279
  const editor = this.cmEditor;
2495
3280
  if (!editor) {
@@ -2688,7 +3473,7 @@ export class MdzipWorkspaceView {
2688
3473
  const bounds = event.target?.getBoundingClientRect();
2689
3474
  const clientX = event instanceof MouseEvent ? event.clientX : (bounds?.left ?? 0);
2690
3475
  const clientY = event instanceof MouseEvent ? event.clientY : (bounds?.bottom ?? 0);
2691
- this.navMenuState = { target, x: clientX, y: clientY };
3476
+ this.contextMenuState = { kind: 'nav', target, x: clientX, y: clientY };
2692
3477
  this.render();
2693
3478
  }
2694
3479
  // Items for the nav context menu; null entries render as separators.
@@ -2746,10 +3531,10 @@ export class MdzipWorkspaceView {
2746
3531
  return groups.flatMap((group, index) => index === 0 ? group : [null, ...group]);
2747
3532
  }
2748
3533
  async handleNavMenuAction(action) {
2749
- const state = this.navMenuState;
2750
- this.navMenuState = null;
3534
+ const state = this.contextMenuState;
3535
+ this.contextMenuState = null;
2751
3536
  this.render();
2752
- if (!state) {
3537
+ if (!state || state.kind !== 'nav') {
2753
3538
  return;
2754
3539
  }
2755
3540
  const target = state.target;
@@ -2985,7 +3770,7 @@ export class MdzipWorkspaceView {
2985
3770
  async handleEditorImageDrop(file, x, y) {
2986
3771
  try {
2987
3772
  if (this.workspace?.sourceFormat === 'markdown') {
2988
- this.requestMdzConversion({ kind: 'image-file', file });
3773
+ this.requestMdzConversion({ kind: 'image-file', file, source: 'drop' });
2989
3774
  return;
2990
3775
  }
2991
3776
  const editor = this.cmEditor;
@@ -2995,7 +3780,7 @@ export class MdzipWorkspaceView {
2995
3780
  editor.dispatch({ selection: { anchor: pos } });
2996
3781
  }
2997
3782
  }
2998
- await this.insertImageFile(file);
3783
+ await this.insertImageFile(file, 'drop');
2999
3784
  }
3000
3785
  catch (error) {
3001
3786
  this.options.onFailed?.(error);
@@ -3209,6 +3994,7 @@ function hasFormattingControls(policy) {
3209
3994
  || policy.inlineCode
3210
3995
  || policy.codeBlock
3211
3996
  || policy.blockquote
3997
+ || policy.lineBreak
3212
3998
  || policy.link
3213
3999
  || policy.image;
3214
4000
  }
@@ -3228,6 +4014,8 @@ function markdownCommandFromToolbarFormat(format) {
3228
4014
  case 'code-block':
3229
4015
  case 'link':
3230
4016
  return format;
4017
+ case 'line-break':
4018
+ return 'insert-line-break';
3231
4019
  case 'strike':
3232
4020
  return 'strikethrough';
3233
4021
  case 'code':
@@ -3255,6 +4043,70 @@ function imageMimeTypeFromFileName(fileName) {
3255
4043
  return 'image/png';
3256
4044
  }
3257
4045
  }
4046
+ function normalizeImageInsertDecision(decision) {
4047
+ if (!decision) {
4048
+ return null;
4049
+ }
4050
+ const normalizeDimension = (value) => value !== undefined && Number.isFinite(value) && value > 0
4051
+ ? Math.round(value)
4052
+ : undefined;
4053
+ return {
4054
+ mode: decision.mode === 'html' ? 'html' : 'markdown',
4055
+ altText: decision.altText ?? '',
4056
+ width: normalizeDimension(decision.width),
4057
+ height: normalizeDimension(decision.height),
4058
+ position: normalizeImagePosition(decision.position)
4059
+ };
4060
+ }
4061
+ function normalizeImagePosition(position) {
4062
+ switch (position) {
4063
+ case 'left':
4064
+ case 'center':
4065
+ case 'right':
4066
+ case 'wrap-left':
4067
+ case 'wrap-right':
4068
+ return position;
4069
+ default:
4070
+ return 'inline';
4071
+ }
4072
+ }
4073
+ function formatImageInsertMarkdown(markdownPath, decision, currentText = '', selectionStart = 0, selectionEnd = selectionStart) {
4074
+ if (decision.mode === 'html') {
4075
+ const attrs = [
4076
+ `src="${escapeHtml(markdownPath)}"`,
4077
+ `alt="${escapeHtml(decision.altText)}"`
4078
+ ];
4079
+ if (decision.width) {
4080
+ attrs.push(`width="${decision.width}"`);
4081
+ }
4082
+ if (decision.height) {
4083
+ attrs.push(`height="${decision.height}"`);
4084
+ }
4085
+ if (decision.position === 'wrap-left' || decision.position === 'wrap-right') {
4086
+ attrs.push(`align="${decision.position === 'wrap-left' ? 'left' : 'right'}"`);
4087
+ }
4088
+ const image = `<img ${attrs.join(' ')}>`;
4089
+ if (decision.position === 'left' || decision.position === 'center' || decision.position === 'right') {
4090
+ return padHtmlImageBlock(`<p align="${decision.position}">${image}</p>`, currentText, selectionStart, selectionEnd);
4091
+ }
4092
+ return padHtmlImageBlock(image, currentText, selectionStart, selectionEnd);
4093
+ }
4094
+ return `![${escapeMarkdownImageAlt(decision.altText)}](${markdownPath})`;
4095
+ }
4096
+ function padHtmlImageBlock(html, currentText, selectionStart, selectionEnd) {
4097
+ const before = currentText.slice(0, Math.max(0, selectionStart));
4098
+ const after = currentText.slice(Math.max(selectionStart, selectionEnd));
4099
+ const prefix = before.length === 0 || before.endsWith('\n\n')
4100
+ ? ''
4101
+ : before.endsWith('\n') ? '\n' : '\n\n';
4102
+ const suffix = after.startsWith('\n\n')
4103
+ ? ''
4104
+ : after.startsWith('\n') ? '\n' : '\n\n';
4105
+ return `${prefix}${html}${suffix}`;
4106
+ }
4107
+ function escapeMarkdownImageAlt(value) {
4108
+ return value.replace(/\\/g, '\\\\').replace(/]/g, '\\]');
4109
+ }
3258
4110
  function formatMetadataValue(value) {
3259
4111
  if (typeof value === 'string' && value.trim()) {
3260
4112
  return value;
@@ -3319,6 +4171,7 @@ const SHELL_HTML = `
3319
4171
  </div>
3320
4172
  </div>
3321
4173
  <button type="button" data-format="quote" data-format-control="blockquote" title="Blockquote" aria-label="Blockquote">${QUOTE_ICON_HTML}</button>
4174
+ <button type="button" data-format="line-break" data-format-control="lineBreak" title="Line break" aria-label="Line break">${LINE_BREAK_ICON_HTML}</button>
3322
4175
  </div>
3323
4176
  <span class="edit-toolbar-divider" aria-hidden="true"></span>
3324
4177
  <div class="edit-toolbar-group">
@@ -3425,6 +4278,61 @@ const SHELL_HTML = `
3425
4278
  </div>
3426
4279
  </div>
3427
4280
 
4281
+ <div class="title-dialog-backdrop" data-ref="image-insert-dialog" hidden
4282
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-image-insert-dialog-heading">
4283
+ <div class="title-dialog image-insert-dialog">
4284
+ <h3 id="mdzip-image-insert-dialog-heading">Insert Image</h3>
4285
+ <p>Choose the Markdown inserted for this image.</p>
4286
+ <fieldset class="image-insert-options">
4287
+ <legend>Output</legend>
4288
+ <label>
4289
+ <input type="radio" name="mdzip-image-insert-mode" value="markdown"
4290
+ data-ref="image-insert-mode-markdown" checked />
4291
+ Markdown image
4292
+ </label>
4293
+ <label>
4294
+ <input type="radio" name="mdzip-image-insert-mode" value="html"
4295
+ data-ref="image-insert-mode-html" />
4296
+ HTML &lt;img&gt; with sizing
4297
+ </label>
4298
+ </fieldset>
4299
+ <label class="image-insert-field">
4300
+ <span>Alt text</span>
4301
+ <input type="text" data-ref="image-insert-alt" />
4302
+ </label>
4303
+ <div class="image-insert-grid">
4304
+ <label class="image-insert-field">
4305
+ <span>Size by</span>
4306
+ <select data-ref="image-insert-size-mode">
4307
+ <option value="original">Original size</option>
4308
+ <option value="width" selected>Width</option>
4309
+ <option value="height">Height</option>
4310
+ <option value="percent">Percent</option>
4311
+ </select>
4312
+ </label>
4313
+ <label class="image-insert-field">
4314
+ <span>Value</span>
4315
+ <input type="number" min="1" step="1" data-ref="image-insert-size-value" />
4316
+ </label>
4317
+ </div>
4318
+ <label class="image-insert-field">
4319
+ <span>Position</span>
4320
+ <select data-ref="image-insert-position">
4321
+ <option value="inline">Inline/default</option>
4322
+ <option value="left">Left</option>
4323
+ <option value="center">Center</option>
4324
+ <option value="right">Right</option>
4325
+ <option value="wrap-left">Wrap left</option>
4326
+ <option value="wrap-right">Wrap right</option>
4327
+ </select>
4328
+ </label>
4329
+ <div class="title-dialog-actions">
4330
+ <button type="button" data-action="cancel-image-insert">Cancel</button>
4331
+ <button type="button" class="save-title" data-ref="image-insert-confirm-btn">Insert</button>
4332
+ </div>
4333
+ </div>
4334
+ </div>
4335
+
3428
4336
  <div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
3429
4337
  role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
3430
4338
  <div class="title-dialog metadata-dialog">