@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/library-info.d.ts +2 -2
- package/dist/library-info.js +2 -2
- package/dist/mermaid.d.ts.map +1 -1
- package/dist/mermaid.js +26 -2
- package/dist/mermaid.js.map +1 -1
- package/dist/view-css.d.ts.map +1 -1
- package/dist/view-css.js +86 -4
- package/dist/view-css.js.map +1 -1
- package/dist/view.d.ts +40 -2
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +661 -33
- package/dist/view.js.map +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +19 -6
- package/dist/workspace.js.map +1 -1
- package/package.json +76 -75
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
|
-
|
|
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
|
|
1113
|
-
if (
|
|
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 (
|
|
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
|
-
||
|
|
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
|
|
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.
|
|
1745
|
-
const items = this.
|
|
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.
|
|
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.
|
|
1759
|
-
const y = Math.max(4, Math.min(this.
|
|
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.
|
|
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.
|
|
2093
|
+
if (this.zoomOpen || this.contextMenuState) {
|
|
1818
2094
|
this.zoomOpen = false;
|
|
1819
|
-
this.
|
|
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.
|
|
1828
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
3092
|
-
this.
|
|
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>
|