@mdzip/editor 1.3.12 → 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
@@ -4,7 +4,7 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
4
4
  import { Compartment, EditorState } from '@codemirror/state';
5
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, CornerDownLeft, Moon, PackagePlus, 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
9
  import { MdzipAssetSession, mdzipArchiveSourceId, sniffImageSize } from './asset-cache.js';
10
10
  import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
@@ -52,6 +52,86 @@ const LINK_ICON_HTML = lucideIcon(Link, FORMAT_ICON_CLASS);
52
52
  const IMAGE_FORMAT_ICON_HTML = lucideIcon(ImagePlus, FORMAT_ICON_CLASS);
53
53
  const CHEVRON_ICON_HTML = lucideIcon(ChevronDown, 'format-chevron');
54
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
+ }
55
135
  const ALL_HEADINGS = [1, 2, 3, 4, 5, 6];
56
136
  const ALL_LAYOUT_CONTROLS = {
57
137
  source: true,
@@ -469,7 +549,12 @@ export class MdzipWorkspaceView {
469
549
  this.navPaneWidth = 280;
470
550
  this.splitRatio = 0.5;
471
551
  this.resizing = false;
472
- 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;
473
558
  this.nameDialogState = null;
474
559
  this.deleteDialogState = null;
475
560
  this.pendingNewFolders = new Set();
@@ -813,6 +898,15 @@ export class MdzipWorkspaceView {
813
898
  this.applyMarkdownFormat(command);
814
899
  return true;
815
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
+ }
816
910
  async convertToMdz() {
817
911
  if (!this.workspace || this.workspace.mode === 'read-only') {
818
912
  return false;
@@ -1486,6 +1580,11 @@ export class MdzipWorkspaceView {
1486
1580
  extensions: [
1487
1581
  this.lineNumbersCompartment.of(this.controlPolicy.lineNumbers ? lineNumbers() : []),
1488
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
+ ]),
1489
1588
  keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
1490
1589
  markdown(),
1491
1590
  syntaxHighlighting(mdzipMarkdownHighlight),
@@ -1741,27 +1840,25 @@ export class MdzipWorkspaceView {
1741
1840
  this.updateImageInsertOptionControls();
1742
1841
  }
1743
1842
  this.elMetadataDialog.hidden = !this.metadataDialogOpen;
1744
- if (this.navMenuState) {
1745
- 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);
1746
1847
  if (items.length === 0) {
1747
- this.navMenuState = null;
1848
+ this.contextMenuState = null;
1748
1849
  }
1749
1850
  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('');
1851
+ this.elNavMenu.innerHTML = renderContextMenuItems(items);
1755
1852
  this.elNavMenu.hidden = false;
1756
1853
  const rect = this.elNavMenu.getBoundingClientRect();
1757
1854
  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));
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));
1760
1857
  this.elNavMenu.style.left = `${x}px`;
1761
1858
  this.elNavMenu.style.top = `${y}px`;
1762
1859
  }
1763
1860
  }
1764
- if (!this.navMenuState) {
1861
+ if (!this.contextMenuState) {
1765
1862
  this.elNavMenu.hidden = true;
1766
1863
  }
1767
1864
  this.elNameDialog.hidden = this.nameDialogState === null;
@@ -1814,9 +1911,9 @@ export class MdzipWorkspaceView {
1814
1911
  const doc = this.elRoot.ownerDocument;
1815
1912
  doc.addEventListener('click', () => {
1816
1913
  this.closeFormatMenus();
1817
- if (this.zoomOpen || this.navMenuState) {
1914
+ if (this.zoomOpen || this.contextMenuState) {
1818
1915
  this.zoomOpen = false;
1819
- this.navMenuState = null;
1916
+ this.contextMenuState = null;
1820
1917
  this.render();
1821
1918
  }
1822
1919
  });
@@ -1824,8 +1921,8 @@ export class MdzipWorkspaceView {
1824
1921
  if (e.key !== 'Escape') {
1825
1922
  return;
1826
1923
  }
1827
- if (this.navMenuState || this.deleteDialogState || this.nameDialogState) {
1828
- this.navMenuState = null;
1924
+ if (this.contextMenuState || this.deleteDialogState || this.nameDialogState) {
1925
+ this.contextMenuState = null;
1829
1926
  this.deleteDialogState = null;
1830
1927
  this.nameDialogState = null;
1831
1928
  this.render();
@@ -2093,9 +2190,62 @@ export class MdzipWorkspaceView {
2093
2190
  this.elNavMenu.addEventListener('click', (e) => {
2094
2191
  e.stopPropagation();
2095
2192
  const item = e.target.closest('[data-menu-action]');
2096
- if (item) {
2097
- 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;
2098
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;
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();
2099
2249
  });
2100
2250
  this.elNameInput.addEventListener('input', () => {
2101
2251
  if (!this.nameDialogState) {
@@ -2676,6 +2826,296 @@ export class MdzipWorkspaceView {
2676
2826
  break;
2677
2827
  }
2678
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
+ }
2679
3119
  applyMarkdownFormat(format) {
2680
3120
  const editor = this.cmEditor;
2681
3121
  const snapshot = this.workspace?.snapshot();
@@ -2692,6 +3132,9 @@ export class MdzipWorkspaceView {
2692
3132
  case 'strikethrough':
2693
3133
  this.wrapSelection('~~', '~~', 'strikethrough text');
2694
3134
  break;
3135
+ case 'highlight':
3136
+ this.wrapSelection('<mark>', '</mark>', 'highlighted text');
3137
+ break;
2695
3138
  case 'paragraph':
2696
3139
  this.setSelectedLinePrefix('', /^(#{1,6})\s+/);
2697
3140
  break;
@@ -3030,7 +3473,7 @@ export class MdzipWorkspaceView {
3030
3473
  const bounds = event.target?.getBoundingClientRect();
3031
3474
  const clientX = event instanceof MouseEvent ? event.clientX : (bounds?.left ?? 0);
3032
3475
  const clientY = event instanceof MouseEvent ? event.clientY : (bounds?.bottom ?? 0);
3033
- this.navMenuState = { target, x: clientX, y: clientY };
3476
+ this.contextMenuState = { kind: 'nav', target, x: clientX, y: clientY };
3034
3477
  this.render();
3035
3478
  }
3036
3479
  // Items for the nav context menu; null entries render as separators.
@@ -3088,10 +3531,10 @@ export class MdzipWorkspaceView {
3088
3531
  return groups.flatMap((group, index) => index === 0 ? group : [null, ...group]);
3089
3532
  }
3090
3533
  async handleNavMenuAction(action) {
3091
- const state = this.navMenuState;
3092
- this.navMenuState = null;
3534
+ const state = this.contextMenuState;
3535
+ this.contextMenuState = null;
3093
3536
  this.render();
3094
- if (!state) {
3537
+ if (!state || state.kind !== 'nav') {
3095
3538
  return;
3096
3539
  }
3097
3540
  const target = state.target;