@mdzip/editor 1.3.12 → 1.3.15

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
@@ -1,10 +1,11 @@
1
1
  import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
2
2
  import { markdown } from '@codemirror/lang-markdown';
3
3
  import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
4
+ import { closeSearchPanel, openSearchPanel, search, searchKeymap } from '@codemirror/search';
4
5
  import { Compartment, EditorState } from '@codemirror/state';
5
6
  import { Decoration, EditorView, MatchDecorator, ViewPlugin, dropCursor, keymap, lineNumbers } from '@codemirror/view';
6
7
  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, CornerDownLeft, Moon, PackagePlus, PanelLeft, Quote, Save, SquarePen, Strikethrough, Sun, ZoomIn } from 'lucide';
8
+ 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, Search, SquareCode, SquarePen, Strikethrough, Sun, TextSelect, ZoomIn } from 'lucide';
8
9
  import { browserClipboardHasImage, readBrowserClipboardImage } from './browser.js';
9
10
  import { MdzipAssetSession, mdzipArchiveSourceId, sniffImageSize } from './asset-cache.js';
10
11
  import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
@@ -35,6 +36,7 @@ const CONVERT_TO_MDZ_ICON_HTML = lucideIcon(PackagePlus, `${TOOLBAR_ICON_CLASS}
35
36
  const PREVIEW_ICON_HTML = lucideIcon(Eye, TOOLBAR_ICON_CLASS);
36
37
  const SPLIT_ICON_HTML = lucideIcon(Columns2, TOOLBAR_ICON_CLASS);
37
38
  const SAVE_ICON_HTML = lucideIcon(Save, TOOLBAR_ICON_CLASS);
39
+ const SEARCH_ICON_HTML = lucideIcon(Search, TOOLBAR_ICON_CLASS);
38
40
  const ZOOM_ICON_HTML = lucideIcon(ZoomIn, TOOLBAR_ICON_CLASS);
39
41
  const DARK_THEME_ICON_HTML = lucideIcon(Moon, TOOLBAR_ICON_CLASS);
40
42
  const LIGHT_THEME_ICON_HTML = lucideIcon(Sun, TOOLBAR_ICON_CLASS);
@@ -52,6 +54,86 @@ const LINK_ICON_HTML = lucideIcon(Link, FORMAT_ICON_CLASS);
52
54
  const IMAGE_FORMAT_ICON_HTML = lucideIcon(ImagePlus, FORMAT_ICON_CLASS);
53
55
  const CHEVRON_ICON_HTML = lucideIcon(ChevronDown, 'format-chevron');
54
56
  const INFO_ICON_HTML = lucideIcon(Info, 'document-info-icon');
57
+ // Leading icons for context-menu items (Obsidian-style icon column).
58
+ const MENU_ICON_CLASS = 'nav-menu-icon';
59
+ const MENU_CUT_ICON_HTML = lucideIcon(Scissors, MENU_ICON_CLASS);
60
+ const MENU_COPY_ICON_HTML = lucideIcon(Copy, MENU_ICON_CLASS);
61
+ const MENU_PASTE_ICON_HTML = lucideIcon(ClipboardPaste, MENU_ICON_CLASS);
62
+ const MENU_PASTE_PLAIN_ICON_HTML = lucideIcon(ClipboardType, MENU_ICON_CLASS);
63
+ const MENU_SELECT_ALL_ICON_HTML = lucideIcon(TextSelect, MENU_ICON_CLASS);
64
+ const MENU_BOLD_ICON_HTML = lucideIcon(Bold, MENU_ICON_CLASS);
65
+ const MENU_ITALIC_ICON_HTML = lucideIcon(Italic, MENU_ICON_CLASS);
66
+ const MENU_STRIKE_ICON_HTML = lucideIcon(Strikethrough, MENU_ICON_CLASS);
67
+ const MENU_CODE_ICON_HTML = lucideIcon(Code, MENU_ICON_CLASS);
68
+ const MENU_CODE_BLOCK_ICON_HTML = lucideIcon(SquareCode, MENU_ICON_CLASS);
69
+ const MENU_HIGHLIGHT_ICON_HTML = lucideIcon(Highlighter, MENU_ICON_CLASS);
70
+ const MENU_BULLET_LIST_ICON_HTML = lucideIcon(List, MENU_ICON_CLASS);
71
+ const MENU_ORDERED_LIST_ICON_HTML = lucideIcon(ListOrdered, MENU_ICON_CLASS);
72
+ const MENU_QUOTE_ICON_HTML = lucideIcon(Quote, MENU_ICON_CLASS);
73
+ const MENU_LINE_BREAK_ICON_HTML = lucideIcon(CornerDownLeft, MENU_ICON_CLASS);
74
+ const MENU_IMAGE_ICON_HTML = lucideIcon(ImagePlus, MENU_ICON_CLASS);
75
+ const MENU_LINK_ICON_HTML = lucideIcon(Link, MENU_ICON_CLASS);
76
+ const MENU_CLEAR_FORMAT_ICON_HTML = lucideIcon(Eraser, MENU_ICON_CLASS);
77
+ const MENU_HEADING_PARENT_ICON_HTML = lucideIcon(Heading1, MENU_ICON_CLASS);
78
+ const MENU_PARAGRAPH_ICON_HTML = lucideIcon(Pilcrow, MENU_ICON_CLASS);
79
+ const MENU_CHEVRON_ICON_HTML = lucideIcon(ChevronRight, 'nav-menu-chevron');
80
+ const MENU_HEADING_ICON_HTML = {
81
+ 1: lucideIcon(Heading1, MENU_ICON_CLASS),
82
+ 2: lucideIcon(Heading2, MENU_ICON_CLASS),
83
+ 3: lucideIcon(Heading3, MENU_ICON_CLASS),
84
+ 4: lucideIcon(Heading4, MENU_ICON_CLASS),
85
+ 5: lucideIcon(Heading5, MENU_ICON_CLASS),
86
+ 6: lucideIcon(Heading6, MENU_ICON_CLASS)
87
+ };
88
+ const MENU_SEPARATOR_HTML = '<div class="nav-menu-separator" role="separator"></div>';
89
+ // Matches the submenu `min-width` in the CSS; used to decide left/right flyout.
90
+ const SUBMENU_ESTIMATED_WIDTH = 190;
91
+ // Curated default for the Code Block submenu — a usable subset rather than the
92
+ // full highlight.js language set. Hosts override via `codeBlockLanguages`.
93
+ export const DEFAULT_CODE_BLOCK_LANGUAGES = [
94
+ { id: '', label: 'Plain Text' },
95
+ { id: 'typescript', label: 'TypeScript' },
96
+ { id: 'javascript', label: 'JavaScript' },
97
+ { id: 'tsx', label: 'TSX / JSX' },
98
+ { id: 'html', label: 'HTML' },
99
+ { id: 'css', label: 'CSS' },
100
+ { id: 'json', label: 'JSON' },
101
+ { id: 'python', label: 'Python' },
102
+ { id: 'csharp', label: 'C#' },
103
+ { id: 'java', label: 'Java' },
104
+ { id: 'cpp', label: 'C++' },
105
+ { id: 'go', label: 'Go' },
106
+ { id: 'rust', label: 'Rust' },
107
+ { id: 'sql', label: 'SQL' },
108
+ { id: 'bash', label: 'Shell' },
109
+ { id: 'yaml', label: 'YAML' },
110
+ { id: 'markdown', label: 'Markdown' }
111
+ ];
112
+ // Recursively renders a context-menu item. An item carrying `submenu` becomes a
113
+ // hover/focus flyout parent (no action of its own); everything else is an
114
+ // actionable `[data-menu-action]` button handled by the shared click delegate.
115
+ function renderContextMenuItem(item) {
116
+ const icon = item.icon ?? '';
117
+ const label = `<span class="nav-menu-label">${escapeHtml(item.label)}</span>`;
118
+ if (item.submenu) {
119
+ const children = item.submenu
120
+ .map((child) => (child === null ? MENU_SEPARATOR_HTML : renderContextMenuItem(child)))
121
+ .join('');
122
+ return '<div class="nav-menu-submenu-wrap">'
123
+ + '<button type="button" role="menuitem" aria-haspopup="true" class="nav-menu-parent">'
124
+ + `${icon}${label}${MENU_CHEVRON_ICON_HTML}</button>`
125
+ + `<div class="nav-context-submenu" role="menu">${children}</div></div>`;
126
+ }
127
+ const shortcut = item.shortcut
128
+ ? `<span class="nav-menu-shortcut">${escapeHtml(item.shortcut)}</span>`
129
+ : '';
130
+ return `<button type="button" role="menuitem" data-menu-action="${escapeHtml(item.action)}">${icon}${label}${shortcut}</button>`;
131
+ }
132
+ function renderContextMenuItems(items) {
133
+ return items
134
+ .map((item) => (item === null ? MENU_SEPARATOR_HTML : renderContextMenuItem(item)))
135
+ .join('');
136
+ }
55
137
  const ALL_HEADINGS = [1, 2, 3, 4, 5, 6];
56
138
  const ALL_LAYOUT_CONTROLS = {
57
139
  source: true,
@@ -99,7 +181,8 @@ const CONTROL_PRESETS = {
99
181
  zoom: false,
100
182
  colorScheme: false,
101
183
  orphanActions: false,
102
- fileActions: false
184
+ fileActions: false,
185
+ search: false
103
186
  },
104
187
  viewer: {
105
188
  preset: 'viewer',
@@ -113,7 +196,8 @@ const CONTROL_PRESETS = {
113
196
  zoom: true,
114
197
  colorScheme: true,
115
198
  orphanActions: false,
116
- fileActions: false
199
+ fileActions: false,
200
+ search: true
117
201
  },
118
202
  'standalone-editor': {
119
203
  preset: 'standalone-editor',
@@ -127,7 +211,8 @@ const CONTROL_PRESETS = {
127
211
  zoom: true,
128
212
  colorScheme: true,
129
213
  orphanActions: true,
130
- fileActions: true
214
+ fileActions: true,
215
+ search: true
131
216
  },
132
217
  'hosted-editor': {
133
218
  preset: 'hosted-editor',
@@ -141,7 +226,8 @@ const CONTROL_PRESETS = {
141
226
  zoom: true,
142
227
  colorScheme: true,
143
228
  orphanActions: true,
144
- fileActions: true
229
+ fileActions: true,
230
+ search: true
145
231
  }
146
232
  };
147
233
  export function resolveMdzipControlPolicy(controls) {
@@ -287,6 +373,73 @@ const mdzipEditorTheme = EditorView.theme({
287
373
  color: 'var(--mdzip-muted-foreground-color)',
288
374
  opacity: '0.65',
289
375
  },
376
+ '.cm-panels': {
377
+ background: 'var(--mdzip-widget-background-color)',
378
+ color: 'var(--mdzip-editor-foreground-color)',
379
+ zIndex: '2',
380
+ },
381
+ '.cm-panels.cm-panels-top': {
382
+ borderBottom: '1px solid var(--mdzip-border-color)',
383
+ },
384
+ '.cm-search': {
385
+ display: 'flex',
386
+ flexWrap: 'wrap',
387
+ alignItems: 'center',
388
+ gap: '8px',
389
+ padding: '10px 14px',
390
+ fontSize: '13px',
391
+ },
392
+ // CodeMirror separates the find and replace rows with a bare <br>; in a
393
+ // flex container that collapses to zero width instead of breaking the
394
+ // line, so force it to take the full row.
395
+ '.cm-search br': {
396
+ flexBasis: '100%',
397
+ height: '0',
398
+ },
399
+ '.cm-search label': {
400
+ display: 'inline-flex',
401
+ alignItems: 'center',
402
+ gap: '5px',
403
+ color: 'var(--mdzip-muted-foreground-color)',
404
+ },
405
+ '.cm-search input[type="checkbox"]': {
406
+ width: '14px',
407
+ height: '14px',
408
+ accentColor: 'var(--mdzip-accent-color)',
409
+ },
410
+ '.cm-textfield': {
411
+ background: 'var(--mdzip-editor-background-color)',
412
+ color: 'var(--mdzip-editor-foreground-color)',
413
+ border: '1px solid var(--mdzip-border-color)',
414
+ borderRadius: '5px',
415
+ padding: '5px 9px',
416
+ fontSize: 'inherit',
417
+ width: '220px',
418
+ },
419
+ '.cm-textfield:focus-visible': {
420
+ outline: '1px solid var(--mdzip-focus-outline-color)',
421
+ outlineOffset: '-1px',
422
+ },
423
+ '.cm-button': {
424
+ background: 'var(--mdzip-widget-background-color)',
425
+ backgroundImage: 'none',
426
+ color: 'var(--mdzip-control-foreground-color)',
427
+ border: '1px solid var(--mdzip-border-color)',
428
+ borderRadius: '5px',
429
+ padding: '5px 12px',
430
+ fontSize: 'inherit',
431
+ cursor: 'pointer',
432
+ },
433
+ '.cm-button:hover': {
434
+ background: 'var(--mdzip-control-hover-background-color)',
435
+ },
436
+ '.cm-searchMatch': {
437
+ backgroundColor: 'rgba(255, 214, 0, 0.35)',
438
+ },
439
+ '.cm-searchMatch-selected': {
440
+ backgroundColor: 'var(--mdzip-accent-color)',
441
+ color: 'var(--mdzip-accent-foreground-color)',
442
+ },
290
443
  });
291
444
  const mdzipMarkdownHighlight = HighlightStyle.define([
292
445
  { tag: [tags.heading1, tags.heading2, tags.heading3, tags.heading4, tags.heading5, tags.heading6],
@@ -318,6 +471,27 @@ const hardBreakMarkerHighlight = ViewPlugin.fromClass(class {
318
471
  }, {
319
472
  decorations: value => value.decorations
320
473
  });
474
+ // Matches raw HTML tags other than <br> (which hardBreakMarkerMatcher already
475
+ // covers) so authors can visually distinguish raw HTML from Markdown prose.
476
+ // Excludes autolinks like <https://example.com> — those have no space before
477
+ // '>' and no closing '/', so the tag-name-only branch requires the char after
478
+ // the name to be '>' directly, which a URL's ':' never satisfies.
479
+ const htmlTagMarkerMatcher = new MatchDecorator({
480
+ regexp: /<\/?(?!br\b)[a-zA-Z][a-zA-Z0-9-]*(?:\s[^<>]*)?\/?>/gi,
481
+ decoration: Decoration.mark({ class: 'mdzip-hard-break-marker' })
482
+ });
483
+ const htmlTagMarkerHighlight = ViewPlugin.fromClass(class {
484
+ constructor(view) {
485
+ this.decorations = htmlTagMarkerMatcher.createDeco(view);
486
+ }
487
+ update(update) {
488
+ if (update.docChanged || update.viewportChanged) {
489
+ this.decorations = htmlTagMarkerMatcher.updateDeco(update, this.decorations);
490
+ }
491
+ }
492
+ }, {
493
+ decorations: value => value.decorations
494
+ });
321
495
  function injectStyles(doc) {
322
496
  const existing = doc.querySelector(`style[${STYLE_ATTR}]`);
323
497
  if (existing) {
@@ -452,6 +626,35 @@ function navNodeOrder(a, b) {
452
626
  return 1;
453
627
  return a.name.localeCompare(b.name);
454
628
  }
629
+ // Browsers apply raw HTML width/height attributes as presentational sizing
630
+ // hints, but the preview's `img { height: auto }` rule (for responsive
631
+ // scaling) overrides that hint via the normal CSS cascade — so an author's
632
+ // `<img height="300">` was silently ignored. Re-applying numeric, unitless
633
+ // width/height attributes as inline pixel styles restores that sizing
634
+ // without touching an author-supplied inline style.
635
+ function applyRawHtmlImageSizeAttributes(image) {
636
+ const width = image.getAttribute('width');
637
+ if (width && /^\d+$/.test(width) && !image.style.width) {
638
+ image.style.width = `${width}px`;
639
+ }
640
+ const height = image.getAttribute('height');
641
+ if (height && /^\d+$/.test(height) && !image.style.height) {
642
+ image.style.height = `${height}px`;
643
+ }
644
+ }
645
+ // Maps the legacy raw HTML `align="left"|"right"` attribute (or the editor's
646
+ // own wrap classes) to a layout direction, for images that skip archive
647
+ // hydration — external, data, and fragment sources — and so never get a slot.
648
+ function rawHtmlImageAlignClass(image) {
649
+ const align = image.getAttribute('align')?.toLowerCase();
650
+ if (align === 'left' || image.classList.contains('mdzip-image-wrap-left')) {
651
+ return 'mdzip-image-left';
652
+ }
653
+ if (align === 'right' || image.classList.contains('mdzip-image-wrap-right')) {
654
+ return 'mdzip-image-right';
655
+ }
656
+ return null;
657
+ }
455
658
  export class MdzipWorkspaceView {
456
659
  constructor(container, options = {}) {
457
660
  this.workspace = null;
@@ -469,7 +672,12 @@ export class MdzipWorkspaceView {
469
672
  this.navPaneWidth = 280;
470
673
  this.splitRatio = 0.5;
471
674
  this.resizing = false;
472
- this.navMenuState = null;
675
+ // Shared overlay menu state. One menu is open at a time, so a single field
676
+ // drives both the nav-pane file menu and the editor selection menu; `kind`
677
+ // selects which item-builder and action-handler run. The editor variant
678
+ // captures the selection range at open time because clicking a menu item can
679
+ // move focus/selection before the action reads it.
680
+ this.contextMenuState = null;
473
681
  this.nameDialogState = null;
474
682
  this.deleteDialogState = null;
475
683
  this.pendingNewFolders = new Set();
@@ -489,6 +697,7 @@ export class MdzipWorkspaceView {
489
697
  this.cmEditor = null;
490
698
  this.readOnlyCompartment = new Compartment();
491
699
  this.lineNumbersCompartment = new Compartment();
700
+ this.searchCompartment = new Compartment();
492
701
  this.updatingCm = false;
493
702
  this.syncing = false;
494
703
  this.markdownExtensions = [];
@@ -545,6 +754,7 @@ export class MdzipWorkspaceView {
545
754
  this.elSourceBtn = q('[data-ref="source-btn"]');
546
755
  this.elSourceIcon = q('[data-ref="source-icon"]');
547
756
  this.elSaveBtn = q('[data-ref="save-btn"]');
757
+ this.elSearchBtn = q('[data-ref="search-btn"]');
548
758
  this.elZoomBtn = q('[data-ref="zoom-btn"]');
549
759
  this.elThemeControls = q('[data-ref="theme-controls"]');
550
760
  this.elDarkThemeBtn = q('[data-ref="dark-theme-btn"]');
@@ -813,6 +1023,49 @@ export class MdzipWorkspaceView {
813
1023
  this.applyMarkdownFormat(command);
814
1024
  return true;
815
1025
  }
1026
+ // Backs the editor's formatting keybindings (Mod-b/i/k/e). Returns false when
1027
+ // the command can't run (read-only, non-markdown) so the key passes through.
1028
+ runFormatShortcut(command) {
1029
+ if (!this.canExecuteCommand(command)) {
1030
+ return false;
1031
+ }
1032
+ this.applyMarkdownFormat(command);
1033
+ return true;
1034
+ }
1035
+ /**
1036
+ * Opens CodeMirror's find/replace panel for the current document. Unlike
1037
+ * {@link executeCommand}, this works in read-only (Viewer) hosts too —
1038
+ * searching doesn't require edit access, only a visible source pane. If
1039
+ * the current layout is preview-only, switches to split/source first so
1040
+ * the panel has somewhere to render.
1041
+ */
1042
+ async openSearch() {
1043
+ if (!this.controlPolicy.search) {
1044
+ return false;
1045
+ }
1046
+ const snapshot = this.workspace?.snapshot();
1047
+ if (!snapshot || snapshot.currentPathType !== 'markdown') {
1048
+ return false;
1049
+ }
1050
+ if (this.layout === 'preview') {
1051
+ const nextLayout = this.controlPolicy.layout.split
1052
+ ? 'split'
1053
+ : this.controlPolicy.layout.source ? 'source' : null;
1054
+ if (!nextLayout) {
1055
+ return false;
1056
+ }
1057
+ await this.setLayout(nextLayout);
1058
+ }
1059
+ const editor = await this.ensureCmEditor(true);
1060
+ if (!editor) {
1061
+ return false;
1062
+ }
1063
+ editor.focus();
1064
+ return openSearchPanel(editor);
1065
+ }
1066
+ closeSearch() {
1067
+ return this.cmEditor ? closeSearchPanel(this.cmEditor) : false;
1068
+ }
816
1069
  async convertToMdz() {
817
1070
  if (!this.workspace || this.workspace.mode === 'read-only') {
818
1071
  return false;
@@ -888,12 +1141,21 @@ export class MdzipWorkspaceView {
888
1141
  setControls(controls) {
889
1142
  const next = resolveMdzipControlPolicy(controls);
890
1143
  const lineNumbersChanged = next.lineNumbers !== this.controlPolicy.lineNumbers;
1144
+ const searchChanged = next.search !== this.controlPolicy.search;
891
1145
  this.controlPolicy = next;
892
1146
  if (lineNumbersChanged && this.cmEditor) {
893
1147
  this.cmEditor.dispatch({
894
1148
  effects: this.lineNumbersCompartment.reconfigure(next.lineNumbers ? lineNumbers() : [])
895
1149
  });
896
1150
  }
1151
+ if (searchChanged && this.cmEditor) {
1152
+ if (!next.search) {
1153
+ closeSearchPanel(this.cmEditor);
1154
+ }
1155
+ this.cmEditor.dispatch({
1156
+ effects: this.searchCompartment.reconfigure(next.search ? [search({ top: true }), keymap.of(searchKeymap)] : [])
1157
+ });
1158
+ }
897
1159
  const snapshot = this.workspace?.snapshot();
898
1160
  if (snapshot) {
899
1161
  this.layout = this.validLayoutForSnapshot(this.layout, snapshot);
@@ -1092,9 +1354,14 @@ export class MdzipWorkspaceView {
1092
1354
  const document = this.elPreviewContent.ownerDocument;
1093
1355
  const pending = [];
1094
1356
  for (const image of Array.from(this.elPreviewContent.querySelectorAll('img'))) {
1357
+ applyRawHtmlImageSizeAttributes(image);
1095
1358
  const source = image.getAttribute('src');
1096
1359
  // Leave external, protocol-relative, data, and fragment URLs untouched.
1097
1360
  if (!source || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(source)) {
1361
+ const alignClass = rawHtmlImageAlignClass(image);
1362
+ if (alignClass) {
1363
+ image.classList.add(alignClass);
1364
+ }
1098
1365
  continue;
1099
1366
  }
1100
1367
  // Drop the archive-relative src so the browser does not fetch the bad
@@ -1109,11 +1376,11 @@ export class MdzipWorkspaceView {
1109
1376
  slot.className = animateImageHydration
1110
1377
  ? 'mdzip-image-slot'
1111
1378
  : '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')) {
1379
+ const alignClass = rawHtmlImageAlignClass(image);
1380
+ if (alignClass === 'mdzip-image-left') {
1114
1381
  slot.classList.add('mdzip-image-align-left');
1115
1382
  }
1116
- else if (align === 'right' || image.classList.contains('mdzip-image-wrap-right')) {
1383
+ else if (alignClass === 'mdzip-image-right') {
1117
1384
  slot.classList.add('mdzip-image-align-right');
1118
1385
  }
1119
1386
  image.parentNode?.insertBefore(slot, image);
@@ -1485,11 +1752,18 @@ export class MdzipWorkspaceView {
1485
1752
  doc: initialText,
1486
1753
  extensions: [
1487
1754
  this.lineNumbersCompartment.of(this.controlPolicy.lineNumbers ? lineNumbers() : []),
1755
+ this.searchCompartment.of(this.controlPolicy.search ? [search({ top: true }), keymap.of(searchKeymap)] : []),
1488
1756
  history(),
1757
+ keymap.of([
1758
+ { key: 'Mod-b', run: () => self.runFormatShortcut('bold') },
1759
+ { key: 'Mod-i', run: () => self.runFormatShortcut('italic') },
1760
+ { key: 'Mod-k', run: () => self.runFormatShortcut('link') }
1761
+ ]),
1489
1762
  keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
1490
1763
  markdown(),
1491
1764
  syntaxHighlighting(mdzipMarkdownHighlight),
1492
1765
  hardBreakMarkerHighlight,
1766
+ htmlTagMarkerHighlight,
1493
1767
  EditorView.lineWrapping,
1494
1768
  dropCursor(),
1495
1769
  mdzipEditorTheme,
@@ -1583,24 +1857,28 @@ export class MdzipWorkspaceView {
1583
1857
  const showSaveControl = this.controlPolicy.save && snapshot.mode !== 'read-only';
1584
1858
  const showZoomControl = this.controlPolicy.zoom;
1585
1859
  const showColorSchemeControl = this.controlPolicy.colorScheme;
1860
+ const showSearchControl = this.controlPolicy.search
1861
+ && canShowSource && snapshot.currentPathType === 'markdown';
1586
1862
  const showEditControls = canEdit
1587
1863
  && snapshot.currentPathType === 'markdown'
1588
1864
  && this.layout !== 'preview'
1589
1865
  && hasFormattingControls(this.controlPolicy.formatting);
1590
1866
  const showToolbar = this.controlPolicy.toolbar
1591
- && (showNavigationControl || showLayoutControls
1592
- || showSaveControl || showZoomControl || showColorSchemeControl || showEditControls);
1867
+ && (showNavigationControl || showLayoutControls || showSaveControl
1868
+ || showZoomControl || showColorSchemeControl || showSearchControl || showEditControls);
1593
1869
  this.elDocumentStrip.hidden = !showTitleControl;
1594
1870
  this.elToolbar.hidden = !showToolbar;
1595
1871
  this.elToolbarLeft.hidden = !showNavigationControl;
1596
1872
  this.elEditToolbar.hidden = !showEditControls;
1597
1873
  this.elLayoutControls.hidden = !showLayoutControls;
1598
- this.elToolbarControls.hidden = !showSaveControl && !showZoomControl && !showColorSchemeControl;
1874
+ this.elToolbarControls.hidden = !showSaveControl && !showZoomControl
1875
+ && !showColorSchemeControl && !showSearchControl;
1599
1876
  this.elNavBtn.hidden = !showNavigationControl;
1600
1877
  this.elPreviewBtn.hidden = !this.controlPolicy.layout.preview;
1601
1878
  this.elSplitBtn.hidden = !this.controlPolicy.layout.split;
1602
1879
  this.elSourceBtn.hidden = !this.controlPolicy.layout.source;
1603
1880
  this.elSaveBtn.hidden = !showSaveControl;
1881
+ this.elSearchBtn.hidden = !showSearchControl;
1604
1882
  this.elZoomBtn.hidden = !showZoomControl;
1605
1883
  this.elThemeControls.hidden = !showColorSchemeControl;
1606
1884
  this.elRoot.style.setProperty('--mdz-zoom', String(this.zoom));
@@ -1741,27 +2019,25 @@ export class MdzipWorkspaceView {
1741
2019
  this.updateImageInsertOptionControls();
1742
2020
  }
1743
2021
  this.elMetadataDialog.hidden = !this.metadataDialogOpen;
1744
- if (this.navMenuState) {
1745
- const items = this.navMenuItems(this.navMenuState.target, snapshot);
2022
+ if (this.contextMenuState) {
2023
+ const items = this.contextMenuState.kind === 'nav'
2024
+ ? this.navMenuItems(this.contextMenuState.target, snapshot)
2025
+ : this.editorMenuItems(snapshot);
1746
2026
  if (items.length === 0) {
1747
- this.navMenuState = null;
2027
+ this.contextMenuState = null;
1748
2028
  }
1749
2029
  else {
1750
- this.elNavMenu.innerHTML = items
1751
- .map((item) => item === null
1752
- ? '<div class="nav-menu-separator" role="separator"></div>'
1753
- : `<button type="button" role="menuitem" data-menu-action="${escapeHtml(item.action)}">${escapeHtml(item.label)}</button>`)
1754
- .join('');
2030
+ this.elNavMenu.innerHTML = renderContextMenuItems(items);
1755
2031
  this.elNavMenu.hidden = false;
1756
2032
  const rect = this.elNavMenu.getBoundingClientRect();
1757
2033
  const win = this.elRoot.ownerDocument.defaultView ?? window;
1758
- const x = Math.max(4, Math.min(this.navMenuState.x, win.innerWidth - rect.width - 8));
1759
- const y = Math.max(4, Math.min(this.navMenuState.y, win.innerHeight - rect.height - 8));
2034
+ const x = Math.max(4, Math.min(this.contextMenuState.x, win.innerWidth - rect.width - 8));
2035
+ const y = Math.max(4, Math.min(this.contextMenuState.y, win.innerHeight - rect.height - 8));
1760
2036
  this.elNavMenu.style.left = `${x}px`;
1761
2037
  this.elNavMenu.style.top = `${y}px`;
1762
2038
  }
1763
2039
  }
1764
- if (!this.navMenuState) {
2040
+ if (!this.contextMenuState) {
1765
2041
  this.elNavMenu.hidden = true;
1766
2042
  }
1767
2043
  this.elNameDialog.hidden = this.nameDialogState === null;
@@ -1814,9 +2090,9 @@ export class MdzipWorkspaceView {
1814
2090
  const doc = this.elRoot.ownerDocument;
1815
2091
  doc.addEventListener('click', () => {
1816
2092
  this.closeFormatMenus();
1817
- if (this.zoomOpen || this.navMenuState) {
2093
+ if (this.zoomOpen || this.contextMenuState) {
1818
2094
  this.zoomOpen = false;
1819
- this.navMenuState = null;
2095
+ this.contextMenuState = null;
1820
2096
  this.render();
1821
2097
  }
1822
2098
  });
@@ -1824,8 +2100,8 @@ export class MdzipWorkspaceView {
1824
2100
  if (e.key !== 'Escape') {
1825
2101
  return;
1826
2102
  }
1827
- if (this.navMenuState || this.deleteDialogState || this.nameDialogState) {
1828
- this.navMenuState = null;
2103
+ if (this.contextMenuState || this.deleteDialogState || this.nameDialogState) {
2104
+ this.contextMenuState = null;
1829
2105
  this.deleteDialogState = null;
1830
2106
  this.nameDialogState = null;
1831
2107
  this.render();
@@ -1887,6 +2163,9 @@ export class MdzipWorkspaceView {
1887
2163
  void this.save();
1888
2164
  }
1889
2165
  });
2166
+ this.elSearchBtn.addEventListener('click', () => {
2167
+ void this.openSearch();
2168
+ });
1890
2169
  this.elZoomBtn.addEventListener('click', (e) => {
1891
2170
  if (!this.controlPolicy.zoom) {
1892
2171
  return;
@@ -2093,9 +2372,62 @@ export class MdzipWorkspaceView {
2093
2372
  this.elNavMenu.addEventListener('click', (e) => {
2094
2373
  e.stopPropagation();
2095
2374
  const item = e.target.closest('[data-menu-action]');
2096
- if (item) {
2097
- void this.handleNavMenuAction(item.dataset['menuAction'] ?? '');
2375
+ if (!item) {
2376
+ return;
2377
+ }
2378
+ const action = item.dataset['menuAction'] ?? '';
2379
+ if (this.contextMenuState?.kind === 'editor') {
2380
+ void this.handleEditorMenuAction(action);
2381
+ }
2382
+ else {
2383
+ void this.handleNavMenuAction(action);
2384
+ }
2385
+ });
2386
+ // Flyout submenus open to the right and aligned with their parent by
2387
+ // default; nudge them within the viewport. Runs on hover (when the submenu
2388
+ // is already displayed, so it can be measured): flips left when it would
2389
+ // overflow the right edge, and shifts up when a tall list (e.g. the code
2390
+ // languages) would run off the bottom.
2391
+ this.elNavMenu.addEventListener('pointerover', (e) => {
2392
+ const wrap = e.target.closest('.nav-menu-submenu-wrap');
2393
+ if (!wrap) {
2394
+ return;
2395
+ }
2396
+ const submenu = wrap.querySelector('.nav-context-submenu');
2397
+ if (!submenu) {
2398
+ return;
2399
+ }
2400
+ const win = this.elRoot.ownerDocument.defaultView ?? window;
2401
+ const margin = 8;
2402
+ const rect = wrap.getBoundingClientRect();
2403
+ const width = submenu.offsetWidth || SUBMENU_ESTIMATED_WIDTH;
2404
+ wrap.classList.toggle('open-left', rect.right + width > win.innerWidth - margin);
2405
+ // Vertical: clamp the flyout's top so its full height stays on-screen.
2406
+ // `top` is relative to the parent row (CSS default is -5px).
2407
+ const naturalTop = rect.top - 5;
2408
+ const clampedTop = Math.max(margin, Math.min(naturalTop, win.innerHeight - margin - submenu.offsetHeight));
2409
+ submenu.style.top = `${clampedTop - rect.top}px`;
2410
+ });
2411
+ this.elEditorHost.addEventListener('contextmenu', (e) => {
2412
+ const snapshot = this.workspace?.snapshot();
2413
+ if (!snapshot || !this.cmEditor) {
2414
+ return;
2415
+ }
2416
+ const selection = this.cmEditor.state.selection.main;
2417
+ this.contextMenuState = {
2418
+ kind: 'editor',
2419
+ from: selection.from,
2420
+ to: selection.to,
2421
+ x: e.clientX,
2422
+ y: e.clientY
2423
+ };
2424
+ if (this.editorMenuItems(snapshot).length === 0) {
2425
+ this.contextMenuState = null;
2426
+ return;
2098
2427
  }
2428
+ e.preventDefault();
2429
+ e.stopPropagation();
2430
+ this.render();
2099
2431
  });
2100
2432
  this.elNameInput.addEventListener('input', () => {
2101
2433
  if (!this.nameDialogState) {
@@ -2676,6 +3008,296 @@ export class MdzipWorkspaceView {
2676
3008
  break;
2677
3009
  }
2678
3010
  }
3011
+ // Items for the editor selection menu; null entries render as separators.
3012
+ // Reads the selection range captured when the menu opened (not the live
3013
+ // selection) so the displayed actions match what the handlers will act on.
3014
+ editorMenuItems(snapshot) {
3015
+ const state = this.contextMenuState;
3016
+ if (!this.cmEditor || state?.kind !== 'editor') {
3017
+ return [];
3018
+ }
3019
+ const hasSelection = state.to > state.from;
3020
+ const editable = snapshot.mode !== 'read-only' && snapshot.currentPathType === 'markdown';
3021
+ const formatting = this.controlPolicy.formatting;
3022
+ const groups = [];
3023
+ const clipboard = [];
3024
+ if (hasSelection) {
3025
+ if (editable) {
3026
+ clipboard.push({ action: 'editor-cut', label: 'Cut', icon: MENU_CUT_ICON_HTML, shortcut: this.editorShortcut('X') });
3027
+ }
3028
+ clipboard.push({ action: 'editor-copy', label: 'Copy', icon: MENU_COPY_ICON_HTML, shortcut: this.editorShortcut('C') });
3029
+ }
3030
+ if (editable) {
3031
+ clipboard.push({ action: 'editor-paste', label: 'Paste', icon: MENU_PASTE_ICON_HTML, shortcut: this.editorShortcut('V') });
3032
+ clipboard.push({ action: 'editor-paste-plain', label: 'Paste as Plain Text', icon: MENU_PASTE_PLAIN_ICON_HTML });
3033
+ }
3034
+ if (clipboard.length > 0) {
3035
+ groups.push(clipboard);
3036
+ }
3037
+ // Formatting mirrors the toolbar's capabilities so the menu can stand in
3038
+ // for it. Inline marks and block commands apply to the current line when
3039
+ // there's no selection (same as the toolbar), so only Clear Formatting —
3040
+ // which acts on a range — is gated on a selection.
3041
+ const anyInline = formatting.bold || formatting.italic || formatting.strikethrough || formatting.inlineCode;
3042
+ const anyBlock = formatting.headings.length > 0 || formatting.bulletList
3043
+ || formatting.orderedList || formatting.blockquote || formatting.codeBlock || formatting.lineBreak;
3044
+ if (editable && anyInline) {
3045
+ const inline = [];
3046
+ if (formatting.bold) {
3047
+ inline.push({ action: 'bold', label: 'Bold', icon: MENU_BOLD_ICON_HTML, shortcut: this.editorShortcut('B') });
3048
+ }
3049
+ if (formatting.italic) {
3050
+ inline.push({ action: 'italic', label: 'Italic', icon: MENU_ITALIC_ICON_HTML, shortcut: this.editorShortcut('I') });
3051
+ }
3052
+ if (formatting.strikethrough) {
3053
+ inline.push({ action: 'strikethrough', label: 'Strikethrough', icon: MENU_STRIKE_ICON_HTML });
3054
+ }
3055
+ inline.push({ action: 'highlight', label: 'Highlight', icon: MENU_HIGHLIGHT_ICON_HTML });
3056
+ if (formatting.inlineCode) {
3057
+ inline.push({ action: 'inline-code', label: 'Inline Code', icon: MENU_CODE_ICON_HTML });
3058
+ }
3059
+ groups.push(inline);
3060
+ }
3061
+ if (editable && anyBlock) {
3062
+ const block = [];
3063
+ if (formatting.headings.length > 0) {
3064
+ block.push({
3065
+ action: '',
3066
+ label: 'Heading',
3067
+ icon: MENU_HEADING_PARENT_ICON_HTML,
3068
+ submenu: [
3069
+ { action: 'paragraph', label: 'Paragraph', icon: MENU_PARAGRAPH_ICON_HTML },
3070
+ null,
3071
+ ...formatting.headings.map((level) => ({
3072
+ action: `heading-${level}`,
3073
+ label: `Heading ${level}`,
3074
+ icon: MENU_HEADING_ICON_HTML[level]
3075
+ }))
3076
+ ]
3077
+ });
3078
+ }
3079
+ if (formatting.bulletList) {
3080
+ block.push({ action: 'bullet-list', label: 'Bullet List', icon: MENU_BULLET_LIST_ICON_HTML });
3081
+ }
3082
+ if (formatting.orderedList) {
3083
+ block.push({ action: 'ordered-list', label: 'Numbered List', icon: MENU_ORDERED_LIST_ICON_HTML });
3084
+ }
3085
+ if (formatting.blockquote) {
3086
+ block.push({ action: 'blockquote', label: 'Blockquote', icon: MENU_QUOTE_ICON_HTML });
3087
+ }
3088
+ if (formatting.codeBlock) {
3089
+ const languages = this.options.codeBlockLanguages ?? DEFAULT_CODE_BLOCK_LANGUAGES;
3090
+ block.push({
3091
+ action: '',
3092
+ label: 'Code Block',
3093
+ icon: MENU_CODE_BLOCK_ICON_HTML,
3094
+ submenu: languages.map((lang) => ({
3095
+ action: `code-block:${lang.id}`,
3096
+ label: lang.label
3097
+ }))
3098
+ });
3099
+ }
3100
+ if (formatting.lineBreak) {
3101
+ block.push({ action: 'insert-line-break', label: 'Line Break', icon: MENU_LINE_BREAK_ICON_HTML });
3102
+ }
3103
+ groups.push(block);
3104
+ }
3105
+ if (editable && (formatting.link || formatting.image)) {
3106
+ const insert = [];
3107
+ if (formatting.link) {
3108
+ insert.push({ action: 'link', label: 'Link…', icon: MENU_LINK_ICON_HTML, shortcut: this.editorShortcut('K') });
3109
+ }
3110
+ if (formatting.image) {
3111
+ insert.push({ action: 'insert-image', label: 'Insert Image…', icon: MENU_IMAGE_ICON_HTML });
3112
+ }
3113
+ groups.push(insert);
3114
+ }
3115
+ if (editable && hasSelection && (anyInline || anyBlock)) {
3116
+ groups.push([{ action: 'editor-clear-format', label: 'Clear Formatting', icon: MENU_CLEAR_FORMAT_ICON_HTML }]);
3117
+ }
3118
+ groups.push([{ action: 'editor-select-all', label: 'Select All', icon: MENU_SELECT_ALL_ICON_HTML, shortcut: this.editorShortcut('A') }]);
3119
+ return groups.flatMap((group, index) => (index === 0 ? group : [null, ...group]));
3120
+ }
3121
+ async handleEditorMenuAction(action) {
3122
+ const state = this.contextMenuState;
3123
+ this.contextMenuState = null;
3124
+ this.render();
3125
+ const editor = this.cmEditor;
3126
+ if (!editor || state?.kind !== 'editor') {
3127
+ return;
3128
+ }
3129
+ // Clicking the menu may have moved focus and collapsed the selection, so
3130
+ // restore the range captured when the menu opened before acting on it.
3131
+ if (action !== 'editor-select-all') {
3132
+ editor.dispatch({ selection: { anchor: state.from, head: state.to } });
3133
+ }
3134
+ // Code Block submenu items carry the chosen language as a suffix.
3135
+ if (action.startsWith('code-block:')) {
3136
+ this.insertCodeBlock(action.slice('code-block:'.length));
3137
+ editor.focus();
3138
+ return;
3139
+ }
3140
+ switch (action) {
3141
+ case 'editor-cut':
3142
+ await this.cutEditorSelection();
3143
+ break;
3144
+ case 'editor-copy':
3145
+ await this.copyEditorSelection();
3146
+ break;
3147
+ case 'editor-paste':
3148
+ case 'editor-paste-plain':
3149
+ await this.pasteIntoEditor();
3150
+ break;
3151
+ case 'editor-clear-format':
3152
+ this.clearSelectionFormatting();
3153
+ editor.focus();
3154
+ break;
3155
+ case 'editor-select-all':
3156
+ editor.dispatch({ selection: { anchor: 0, head: editor.state.doc.length } });
3157
+ editor.focus();
3158
+ break;
3159
+ case 'insert-image':
3160
+ await this.executeCommand('insert-image');
3161
+ break;
3162
+ case 'bold':
3163
+ case 'italic':
3164
+ case 'strikethrough':
3165
+ case 'highlight':
3166
+ case 'inline-code':
3167
+ case 'blockquote':
3168
+ case 'bullet-list':
3169
+ case 'ordered-list':
3170
+ case 'insert-line-break':
3171
+ case 'link':
3172
+ case 'paragraph':
3173
+ case 'heading-1':
3174
+ case 'heading-2':
3175
+ case 'heading-3':
3176
+ case 'heading-4':
3177
+ case 'heading-5':
3178
+ case 'heading-6':
3179
+ this.applyMarkdownFormat(action);
3180
+ editor.focus();
3181
+ break;
3182
+ }
3183
+ }
3184
+ // Inserts a fenced code block, carrying the chosen language as the fence info
3185
+ // string. An empty language produces a plain ```` ``` ```` block.
3186
+ insertCodeBlock(language) {
3187
+ if (!this.canExecuteCommand('code-block')) {
3188
+ return;
3189
+ }
3190
+ this.wrapSelection(`\`\`\`${language}\n`, '\n```', 'code');
3191
+ }
3192
+ // Strips common Markdown formatting from the selection: leading block markers
3193
+ // (headings, blockquotes, list bullets) per line, then inline emphasis,
3194
+ // highlight, and code spans. Heuristic by nature — it targets the markers the
3195
+ // editor itself produces rather than parsing the full grammar.
3196
+ clearSelectionFormatting() {
3197
+ const editor = this.cmEditor;
3198
+ const snapshot = this.workspace?.snapshot();
3199
+ if (!editor || !snapshot || snapshot.mode === 'read-only') {
3200
+ return;
3201
+ }
3202
+ const selection = editor.state.selection.main;
3203
+ if (selection.empty) {
3204
+ return;
3205
+ }
3206
+ const cleared = editor.state.sliceDoc(selection.from, selection.to)
3207
+ .split('\n')
3208
+ .map((line) => line
3209
+ .replace(/^(\s*)#{1,6}\s+/, '$1')
3210
+ .replace(/^(\s*)>\s?/, '$1')
3211
+ .replace(/^(\s*)(?:[-*+]|\d+\.)\s+/, '$1'))
3212
+ .join('\n')
3213
+ .replace(/<\/?mark>/gi, '')
3214
+ .replace(/(\*\*|__)(.*?)\1/g, '$2')
3215
+ .replace(/(\*|_)(.*?)\1/g, '$2')
3216
+ .replace(/~~(.*?)~~/g, '$1')
3217
+ .replace(/==(.*?)==/g, '$1')
3218
+ .replace(/`([^`]*)`/g, '$1');
3219
+ editor.dispatch({
3220
+ changes: { from: selection.from, to: selection.to, insert: cleared },
3221
+ selection: { anchor: selection.from, head: selection.from + cleared.length }
3222
+ });
3223
+ }
3224
+ editorClipboard() {
3225
+ return this.elRoot.ownerDocument.defaultView?.navigator?.clipboard;
3226
+ }
3227
+ // Renders a single-letter shortcut for the host platform: ⌘X on macOS,
3228
+ // Ctrl+X elsewhere. Only used for bindings that genuinely fire (the native
3229
+ // clipboard keys and `defaultKeymap`'s select-all).
3230
+ editorShortcut(key) {
3231
+ const nav = this.elRoot.ownerDocument.defaultView?.navigator;
3232
+ const platform = nav?.userAgentData?.platform
3233
+ ?? nav?.platform
3234
+ ?? '';
3235
+ return /mac|iphone|ipad|ipod/i.test(platform) ? `⌘${key}` : `Ctrl+${key}`;
3236
+ }
3237
+ async copyEditorSelection() {
3238
+ const editor = this.cmEditor;
3239
+ if (!editor) {
3240
+ return;
3241
+ }
3242
+ const selection = editor.state.selection.main;
3243
+ if (selection.empty) {
3244
+ return;
3245
+ }
3246
+ try {
3247
+ await this.editorClipboard()?.writeText(editor.state.sliceDoc(selection.from, selection.to));
3248
+ }
3249
+ catch (error) {
3250
+ this.options.onFailed?.(error);
3251
+ }
3252
+ editor.focus();
3253
+ }
3254
+ async cutEditorSelection() {
3255
+ const editor = this.cmEditor;
3256
+ const snapshot = this.workspace?.snapshot();
3257
+ if (!editor || !snapshot || snapshot.mode === 'read-only') {
3258
+ return;
3259
+ }
3260
+ const selection = editor.state.selection.main;
3261
+ if (selection.empty) {
3262
+ return;
3263
+ }
3264
+ try {
3265
+ await this.editorClipboard()?.writeText(editor.state.sliceDoc(selection.from, selection.to));
3266
+ }
3267
+ catch (error) {
3268
+ this.options.onFailed?.(error);
3269
+ return;
3270
+ }
3271
+ editor.dispatch({
3272
+ changes: { from: selection.from, to: selection.to, insert: '' },
3273
+ selection: { anchor: selection.from }
3274
+ });
3275
+ editor.focus();
3276
+ }
3277
+ async pasteIntoEditor() {
3278
+ const editor = this.cmEditor;
3279
+ const snapshot = this.workspace?.snapshot();
3280
+ if (!editor || !snapshot || snapshot.mode === 'read-only') {
3281
+ return;
3282
+ }
3283
+ let text;
3284
+ try {
3285
+ text = await this.editorClipboard()?.readText();
3286
+ }
3287
+ catch (error) {
3288
+ this.options.onFailed?.(error);
3289
+ return;
3290
+ }
3291
+ if (!text) {
3292
+ return;
3293
+ }
3294
+ const selection = editor.state.selection.main;
3295
+ editor.dispatch({
3296
+ changes: { from: selection.from, to: selection.to, insert: text },
3297
+ selection: { anchor: selection.from + text.length }
3298
+ });
3299
+ editor.focus();
3300
+ }
2679
3301
  applyMarkdownFormat(format) {
2680
3302
  const editor = this.cmEditor;
2681
3303
  const snapshot = this.workspace?.snapshot();
@@ -2692,6 +3314,9 @@ export class MdzipWorkspaceView {
2692
3314
  case 'strikethrough':
2693
3315
  this.wrapSelection('~~', '~~', 'strikethrough text');
2694
3316
  break;
3317
+ case 'highlight':
3318
+ this.wrapSelection('<mark>', '</mark>', 'highlighted text');
3319
+ break;
2695
3320
  case 'paragraph':
2696
3321
  this.setSelectedLinePrefix('', /^(#{1,6})\s+/);
2697
3322
  break;
@@ -3030,7 +3655,7 @@ export class MdzipWorkspaceView {
3030
3655
  const bounds = event.target?.getBoundingClientRect();
3031
3656
  const clientX = event instanceof MouseEvent ? event.clientX : (bounds?.left ?? 0);
3032
3657
  const clientY = event instanceof MouseEvent ? event.clientY : (bounds?.bottom ?? 0);
3033
- this.navMenuState = { target, x: clientX, y: clientY };
3658
+ this.contextMenuState = { kind: 'nav', target, x: clientX, y: clientY };
3034
3659
  this.render();
3035
3660
  }
3036
3661
  // Items for the nav context menu; null entries render as separators.
@@ -3088,10 +3713,10 @@ export class MdzipWorkspaceView {
3088
3713
  return groups.flatMap((group, index) => index === 0 ? group : [null, ...group]);
3089
3714
  }
3090
3715
  async handleNavMenuAction(action) {
3091
- const state = this.navMenuState;
3092
- this.navMenuState = null;
3716
+ const state = this.contextMenuState;
3717
+ this.contextMenuState = null;
3093
3718
  this.render();
3094
- if (!state) {
3719
+ if (!state || state.kind !== 'nav') {
3095
3720
  return;
3096
3721
  }
3097
3722
  const target = state.target;
@@ -3756,6 +4381,9 @@ const SHELL_HTML = `
3756
4381
  </div>
3757
4382
 
3758
4383
  <div class="toolbar-controls" data-ref="toolbar-controls">
4384
+ <button type="button" class="icon-toggle" data-ref="search-btn" title="Find/replace (Mod-F)" aria-label="Find/replace">
4385
+ ${SEARCH_ICON_HTML}
4386
+ </button>
3759
4387
  <button type="button" class="icon-toggle" data-ref="save-btn" title="Save" aria-label="Save">
3760
4388
  ${SAVE_ICON_HTML}
3761
4389
  </button>