@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/library-info.d.ts +1 -1
- package/dist/library-info.js +1 -1
- package/dist/view-css.d.ts.map +1 -1
- package/dist/view-css.js +60 -1
- package/dist/view-css.js.map +1 -1
- package/dist/view.d.ts +26 -2
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +466 -23
- package/dist/view.js.map +1 -1
- package/package.json +75 -75
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
|
-
|
|
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.
|
|
1745
|
-
const items = this.
|
|
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.
|
|
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.
|
|
1759
|
-
const y = Math.max(4, Math.min(this.
|
|
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.
|
|
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.
|
|
1914
|
+
if (this.zoomOpen || this.contextMenuState) {
|
|
1818
1915
|
this.zoomOpen = false;
|
|
1819
|
-
this.
|
|
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.
|
|
1828
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
3092
|
-
this.
|
|
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;
|