mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-15 09:04:04 +02:00
454 lines
13 KiB
TypeScript
454 lines
13 KiB
TypeScript
/* eslint-disable import/prefer-default-export */
|
|
|
|
// This contains the CodeMirror instance, which needs to be built into a bundle
|
|
// using `yarn run buildInjectedJs`. This bundle is then loaded from
|
|
// NoteEditor.tsx into the webview.
|
|
//
|
|
// In general, since this file is harder to debug due to the intermediate built
|
|
// step, it's better to keep it as light as possible - it shoud just be a light
|
|
// wrapper to access CodeMirror functionalities. Anything else should be done
|
|
// from NoteEditor.tsx.
|
|
|
|
import { MarkdownMathExtension } from './markdownMathParser';
|
|
import createTheme from './theme';
|
|
import decoratorExtension from './decoratorExtension';
|
|
|
|
import syntaxHighlightingLanguages from './syntaxHighlightingLanguages';
|
|
|
|
import { EditorState } from '@codemirror/state';
|
|
import { markdown } from '@codemirror/lang-markdown';
|
|
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
|
|
import { indentOnInput, indentUnit, syntaxTree } from '@codemirror/language';
|
|
import {
|
|
openSearchPanel, closeSearchPanel, SearchQuery, setSearchQuery, getSearchQuery,
|
|
/* highlightSelectionMatches, */ search, findNext, findPrevious, replaceAll, replaceNext,
|
|
} from '@codemirror/search';
|
|
|
|
import {
|
|
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command,
|
|
} from '@codemirror/view';
|
|
import { undo, redo, history, undoDepth, redoDepth, indentWithTab } from '@codemirror/commands';
|
|
|
|
import { keymap, KeyBinding } from '@codemirror/view';
|
|
import { searchKeymap } from '@codemirror/search';
|
|
import { historyKeymap, defaultKeymap } from '@codemirror/commands';
|
|
|
|
import { CodeMirrorControl } from './types';
|
|
import { EditorSettings, ListType, SearchState } from '../types';
|
|
import { ChangeEvent, SelectionChangeEvent, Selection } from '../types';
|
|
import SelectionFormatting from '../SelectionFormatting';
|
|
import { logMessage, postMessage } from './webviewLogger';
|
|
import {
|
|
decreaseIndent, increaseIndent,
|
|
toggleBolded, toggleCode,
|
|
toggleHeaderLevel, toggleItalicized,
|
|
toggleList, toggleMath, updateLink,
|
|
} from './markdownCommands';
|
|
|
|
|
|
interface CodeMirrorResult extends CodeMirrorControl {
|
|
editor: EditorView;
|
|
}
|
|
|
|
export function initCodeMirror(
|
|
parentElement: any, initialText: string, settings: EditorSettings
|
|
): CodeMirrorResult {
|
|
logMessage('Initializing CodeMirror...');
|
|
const theme = settings.themeData;
|
|
|
|
let searchVisible = false;
|
|
|
|
let schedulePostUndoRedoDepthChangeId_: any = 0;
|
|
const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow: boolean = false) => {
|
|
if (schedulePostUndoRedoDepthChangeId_) {
|
|
if (doItNow) {
|
|
clearTimeout(schedulePostUndoRedoDepthChangeId_);
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
schedulePostUndoRedoDepthChangeId_ = setTimeout(() => {
|
|
schedulePostUndoRedoDepthChangeId_ = null;
|
|
postMessage('onUndoRedoDepthChange', {
|
|
undoDepth: undoDepth(editor.state),
|
|
redoDepth: redoDepth(editor.state),
|
|
});
|
|
}, doItNow ? 0 : 1000);
|
|
};
|
|
|
|
const notifyDocChanged = (viewUpdate: ViewUpdate) => {
|
|
if (viewUpdate.docChanged) {
|
|
const event: ChangeEvent = {
|
|
value: editor.state.doc.toString(),
|
|
};
|
|
|
|
postMessage('onChange', event);
|
|
schedulePostUndoRedoDepthChange(editor);
|
|
}
|
|
};
|
|
|
|
const notifyLinkEditRequest = () => {
|
|
postMessage('onRequestLinkEdit', null);
|
|
};
|
|
|
|
const showSearchDialog = () => {
|
|
const query = getSearchQuery(editor.state);
|
|
const searchState: SearchState = {
|
|
searchText: query.search,
|
|
replaceText: query.replace,
|
|
useRegex: query.regexp,
|
|
caseSensitive: query.caseSensitive,
|
|
dialogVisible: true,
|
|
};
|
|
|
|
postMessage('onRequestShowSearch', searchState);
|
|
searchVisible = true;
|
|
};
|
|
|
|
const hideSearchDialog = () => {
|
|
postMessage('onRequestHideSearch', null);
|
|
searchVisible = false;
|
|
};
|
|
|
|
const notifySelectionChange = (viewUpdate: ViewUpdate) => {
|
|
if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
|
|
const mainRange = viewUpdate.state.selection.main;
|
|
const selection: Selection = {
|
|
start: mainRange.from,
|
|
end: mainRange.to,
|
|
};
|
|
const event: SelectionChangeEvent = {
|
|
selection,
|
|
};
|
|
postMessage('onSelectionChange', event);
|
|
}
|
|
};
|
|
|
|
const notifySelectionFormattingChange = (viewUpdate?: ViewUpdate) => {
|
|
// If we can't determine the previous formatting, post the update regardless
|
|
if (!viewUpdate) {
|
|
const formatting = computeSelectionFormatting(editor.state);
|
|
postMessage('onSelectionFormattingChange', formatting.toJSON());
|
|
} else if (viewUpdate.docChanged || !viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
|
|
// Only post the update if something changed
|
|
const oldFormatting = computeSelectionFormatting(viewUpdate.startState);
|
|
const newFormatting = computeSelectionFormatting(viewUpdate.state);
|
|
|
|
if (!oldFormatting.eq(newFormatting)) {
|
|
postMessage('onSelectionFormattingChange', newFormatting.toJSON());
|
|
}
|
|
}
|
|
};
|
|
|
|
const computeSelectionFormatting = (state: EditorState): SelectionFormatting => {
|
|
const range = state.selection.main;
|
|
const formatting: SelectionFormatting = new SelectionFormatting();
|
|
formatting.selectedText = state.doc.sliceString(range.from, range.to);
|
|
formatting.spellChecking = editor.contentDOM.spellcheck;
|
|
|
|
const parseLinkData = (nodeText: string) => {
|
|
const linkMatch = nodeText.match(/\[([^\]]*)\]\(([^)]*)\)/);
|
|
|
|
if (linkMatch) {
|
|
return {
|
|
linkText: linkMatch[1],
|
|
linkURL: linkMatch[2],
|
|
};
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Find nodes that overlap/are within the selected region
|
|
syntaxTree(state).iterate({
|
|
from: range.from, to: range.to,
|
|
enter: node => {
|
|
// Checklists don't have a specific containing node. As such,
|
|
// we're in a checklist if we've selected a 'Task' node.
|
|
if (node.name === 'Task') {
|
|
formatting.inChecklist = true;
|
|
}
|
|
|
|
// Only handle notes that contain the entire range.
|
|
if (node.from > range.from || node.to < range.to) {
|
|
return;
|
|
}
|
|
// Lazily compute the node's text
|
|
const nodeText = () => state.doc.sliceString(node.from, node.to);
|
|
|
|
switch (node.name) {
|
|
case 'StrongEmphasis':
|
|
formatting.bolded = true;
|
|
break;
|
|
case 'Emphasis':
|
|
formatting.italicized = true;
|
|
break;
|
|
case 'ListItem':
|
|
formatting.listLevel += 1;
|
|
break;
|
|
case 'BulletList':
|
|
formatting.inUnorderedList = true;
|
|
break;
|
|
case 'OrderedList':
|
|
formatting.inOrderedList = true;
|
|
break;
|
|
case 'TaskList':
|
|
formatting.inChecklist = true;
|
|
break;
|
|
case 'InlineCode':
|
|
case 'FencedCode':
|
|
formatting.inCode = true;
|
|
formatting.unspellCheckableRegion = true;
|
|
break;
|
|
case 'InlineMath':
|
|
case 'BlockMath':
|
|
formatting.inMath = true;
|
|
formatting.unspellCheckableRegion = true;
|
|
break;
|
|
case 'ATXHeading1':
|
|
formatting.headerLevel = 1;
|
|
break;
|
|
case 'ATXHeading2':
|
|
formatting.headerLevel = 2;
|
|
break;
|
|
case 'ATXHeading3':
|
|
formatting.headerLevel = 3;
|
|
break;
|
|
case 'ATXHeading4':
|
|
formatting.headerLevel = 4;
|
|
break;
|
|
case 'ATXHeading5':
|
|
formatting.headerLevel = 5;
|
|
break;
|
|
case 'URL':
|
|
formatting.inLink = true;
|
|
formatting.linkData.linkURL = nodeText();
|
|
formatting.unspellCheckableRegion = true;
|
|
break;
|
|
case 'Link':
|
|
formatting.inLink = true;
|
|
formatting.linkData = parseLinkData(nodeText());
|
|
break;
|
|
}
|
|
},
|
|
});
|
|
|
|
// The markdown parser marks checklists as unordered lists. Ensure
|
|
// that they aren't marked as such.
|
|
if (formatting.inChecklist) {
|
|
if (!formatting.inUnorderedList) {
|
|
// Even if the selection contains a Task, because an unordered list node
|
|
// must contain a valid Task node, we're only in a checklist if we're also in
|
|
// an unordered list.
|
|
formatting.inChecklist = false;
|
|
} else {
|
|
formatting.inUnorderedList = false;
|
|
}
|
|
}
|
|
|
|
if (formatting.unspellCheckableRegion) {
|
|
formatting.spellChecking = false;
|
|
}
|
|
|
|
return formatting;
|
|
};
|
|
|
|
// Returns a keyboard command that returns true (so accepts the keybind)
|
|
const keyCommand = (key: string, run: Command): KeyBinding => {
|
|
return {
|
|
key,
|
|
run,
|
|
preventDefault: true,
|
|
};
|
|
};
|
|
|
|
const editor = new EditorView({
|
|
state: EditorState.create({
|
|
// See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
|
|
// for a sample configuration.
|
|
extensions: [
|
|
markdown({
|
|
extensions: [
|
|
GitHubFlavoredMarkdownExtension,
|
|
|
|
// Don't highlight KaTeX if the user disabled it
|
|
settings.katexEnabled ? MarkdownMathExtension : [],
|
|
],
|
|
codeLanguages: syntaxHighlightingLanguages,
|
|
}),
|
|
...createTheme(theme),
|
|
history(),
|
|
search({
|
|
createPanel(_: EditorView) {
|
|
return {
|
|
// The actual search dialog is implemented with react native,
|
|
// use a dummy element.
|
|
dom: document.createElement('div'),
|
|
mount() {
|
|
showSearchDialog();
|
|
},
|
|
destroy() {
|
|
hideSearchDialog();
|
|
},
|
|
};
|
|
},
|
|
}),
|
|
drawSelection(),
|
|
highlightSpecialChars(),
|
|
// highlightSelectionMatches(),
|
|
indentOnInput(),
|
|
|
|
// By default, indent with four spaces
|
|
indentUnit.of(' '),
|
|
EditorState.tabSize.of(4),
|
|
|
|
// Apply styles to entire lines (block-display decorations)
|
|
decoratorExtension,
|
|
|
|
EditorView.lineWrapping,
|
|
EditorView.contentAttributes.of({
|
|
autocapitalize: 'sentence',
|
|
spellcheck: settings.spellcheckEnabled ? 'true' : 'false',
|
|
}),
|
|
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
|
notifyDocChanged(viewUpdate);
|
|
notifySelectionChange(viewUpdate);
|
|
notifySelectionFormattingChange(viewUpdate);
|
|
}),
|
|
keymap.of([
|
|
// Custom mod-f binding: Toggle the external dialog implementation
|
|
// (don't show/hide the Panel dialog).
|
|
keyCommand('Mod-f', (_: EditorView) => {
|
|
if (searchVisible) {
|
|
hideSearchDialog();
|
|
} else {
|
|
showSearchDialog();
|
|
}
|
|
return true;
|
|
}),
|
|
// Markdown formatting keyboard shortcuts
|
|
keyCommand('Mod-b', toggleBolded),
|
|
keyCommand('Mod-i', toggleItalicized),
|
|
keyCommand('Mod-$', toggleMath),
|
|
keyCommand('Mod-`', toggleCode),
|
|
keyCommand('Mod-[', decreaseIndent),
|
|
keyCommand('Mod-]', increaseIndent),
|
|
keyCommand('Mod-k', (_: EditorView) => {
|
|
notifyLinkEditRequest();
|
|
return true;
|
|
}),
|
|
|
|
...defaultKeymap, ...historyKeymap, indentWithTab, ...searchKeymap,
|
|
]),
|
|
],
|
|
doc: initialText,
|
|
}),
|
|
parent: parentElement,
|
|
});
|
|
|
|
// HACK: 09/02/22: Work around https://github.com/laurent22/joplin/issues/6802 by creating a copy mousedown
|
|
// event to prevent the Editor's .preventDefault from making the context menu not appear.
|
|
// TODO: Track the upstream issue at https://github.com/codemirror/dev/issues/935 and remove this workaround
|
|
// when the upstream bug is fixed.
|
|
document.body.addEventListener('mousedown', (evt) => {
|
|
if (!evt.isTrusted) {
|
|
return;
|
|
}
|
|
|
|
// Walk up the tree -- is evt.target or any of its parent nodes the editor's input region?
|
|
for (let current: Record<string, any> = evt.target; current; current = current.parentElement) {
|
|
if (current === editor.contentDOM) {
|
|
evt.stopPropagation();
|
|
|
|
const copyEvent = new Event('mousedown', evt);
|
|
editor.contentDOM.dispatchEvent(copyEvent);
|
|
return;
|
|
}
|
|
}
|
|
}, true);
|
|
|
|
const updateSearchQuery = (newState: SearchState) => {
|
|
const query = new SearchQuery({
|
|
search: newState.searchText,
|
|
caseSensitive: newState.caseSensitive,
|
|
regexp: newState.useRegex,
|
|
replace: newState.replaceText,
|
|
});
|
|
editor.dispatch({
|
|
effects: setSearchQuery.of(query),
|
|
});
|
|
};
|
|
|
|
const editorControls = {
|
|
editor,
|
|
undo: () => {
|
|
undo(editor);
|
|
schedulePostUndoRedoDepthChange(editor, true);
|
|
},
|
|
redo: () => {
|
|
redo(editor);
|
|
schedulePostUndoRedoDepthChange(editor, true);
|
|
},
|
|
select: (anchor: number, head: number) => {
|
|
editor.dispatch(editor.state.update({
|
|
selection: { anchor, head },
|
|
scrollIntoView: true,
|
|
}));
|
|
},
|
|
scrollSelectionIntoView: () => {
|
|
editor.dispatch(editor.state.update({
|
|
scrollIntoView: true,
|
|
}));
|
|
},
|
|
insertText: (text: string) => {
|
|
editor.dispatch(editor.state.replaceSelection(text));
|
|
},
|
|
toggleFindDialog: () => {
|
|
const opened = openSearchPanel(editor);
|
|
if (!opened) {
|
|
closeSearchPanel(editor);
|
|
}
|
|
},
|
|
|
|
// Formatting
|
|
toggleBolded: () => { toggleBolded(editor); },
|
|
toggleItalicized: () => { toggleItalicized(editor); },
|
|
toggleCode: () => { toggleCode(editor); },
|
|
toggleMath: () => { toggleMath(editor); },
|
|
increaseIndent: () => { increaseIndent(editor); },
|
|
decreaseIndent: () => { decreaseIndent(editor); },
|
|
toggleList: (kind: ListType) => { toggleList(kind)(editor); },
|
|
toggleHeaderLevel: (level: number) => { toggleHeaderLevel(level)(editor); },
|
|
updateLink: (label: string, url: string) => { updateLink(label, url)(editor); },
|
|
|
|
// Search
|
|
searchControl: {
|
|
findNext: () => {
|
|
findNext(editor);
|
|
},
|
|
findPrevious: () => {
|
|
findPrevious(editor);
|
|
},
|
|
replaceCurrent: () => {
|
|
replaceNext(editor);
|
|
},
|
|
replaceAll: () => {
|
|
replaceAll(editor);
|
|
},
|
|
setSearchState: (state: SearchState) => {
|
|
updateSearchQuery(state);
|
|
},
|
|
showSearch: () => {
|
|
showSearchDialog();
|
|
},
|
|
hideSearch: () => {
|
|
hideSearchDialog();
|
|
},
|
|
},
|
|
};
|
|
|
|
return editorControls;
|
|
}
|
|
|