mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
260 lines
7.9 KiB
TypeScript
260 lines
7.9 KiB
TypeScript
import { EditorView, KeyBinding, keymap } from '@codemirror/view';
|
|
import { EditorCommandType, EditorControl, EditorSettings, LogMessageCallback, ContentScriptData, SearchState, UserEventSource } from '../types';
|
|
import CodeMirror5Emulation from './CodeMirror5Emulation/CodeMirror5Emulation';
|
|
import editorCommands from './editorCommands/editorCommands';
|
|
import { Compartment, EditorSelection, Extension, StateEffect } from '@codemirror/state';
|
|
import { updateLink } from './markdown/markdownCommands';
|
|
import { searchPanelOpen, SearchQuery, setSearchQuery } from '@codemirror/search';
|
|
import PluginLoader from './pluginApi/PluginLoader';
|
|
import customEditorCompletion, { editorCompletionSource, enableLanguageDataAutocomplete } from './pluginApi/customEditorCompletion';
|
|
import { CompletionSource } from '@codemirror/autocomplete';
|
|
import { RegionSpec } from './utils/formatting/RegionSpec';
|
|
import toggleInlineSelectionFormat from './utils/formatting/toggleInlineSelectionFormat';
|
|
import getSearchState from './utils/getSearchState';
|
|
|
|
interface Callbacks {
|
|
onUndoRedo(): void;
|
|
onSettingsChange(newSettings: EditorSettings): void;
|
|
onClearHistory(): void;
|
|
onRemove(): void;
|
|
onLogMessage: LogMessageCallback;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
type EditorUserCommand = (...args: any[])=> any;
|
|
|
|
// Copied from CodeMirror source code since type is not exported
|
|
export type ScrollStrategy = 'nearest' | 'start' | 'end' | 'center';
|
|
|
|
export default class CodeMirrorControl extends CodeMirror5Emulation implements EditorControl {
|
|
private _pluginControl: PluginLoader;
|
|
private _userCommands: Map<string, EditorUserCommand> = new Map();
|
|
|
|
public constructor(
|
|
editor: EditorView,
|
|
private _callbacks: Callbacks,
|
|
) {
|
|
super(editor, _callbacks.onLogMessage);
|
|
|
|
this._pluginControl = new PluginLoader(this, _callbacks.onLogMessage);
|
|
|
|
this.addExtension(customEditorCompletion());
|
|
}
|
|
|
|
public supportsCommand(name: string) {
|
|
return name in editorCommands || this._userCommands.has(name) || super.commandExists(name);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
public override execCommand(name: string, ...args: any[]) {
|
|
let commandOutput;
|
|
if (this._userCommands.has(name)) {
|
|
commandOutput = this._userCommands.get(name)(...args);
|
|
} else if (name in editorCommands) {
|
|
commandOutput = editorCommands[name as EditorCommandType](this.editor, ...args);
|
|
} else if (super.commandExists(name)) {
|
|
commandOutput = super.execCommand(name, ...args);
|
|
} else if (super.supportsJoplinCommand(name)) {
|
|
commandOutput = super.execJoplinCommand(name);
|
|
}
|
|
|
|
if (name === EditorCommandType.Undo || name === EditorCommandType.Redo) {
|
|
this._callbacks.onUndoRedo();
|
|
}
|
|
|
|
return commandOutput;
|
|
}
|
|
|
|
public registerCommand(name: string, command: EditorUserCommand) {
|
|
this._userCommands.set(name, command);
|
|
}
|
|
|
|
public undo() {
|
|
this.execCommand(EditorCommandType.Undo);
|
|
this._callbacks.onUndoRedo();
|
|
}
|
|
|
|
public redo() {
|
|
this.execCommand(EditorCommandType.Redo);
|
|
this._callbacks.onUndoRedo();
|
|
}
|
|
|
|
public select(anchor: number, head: number) {
|
|
this.editor.dispatch(this.editor.state.update({
|
|
selection: { anchor, head },
|
|
scrollIntoView: true,
|
|
}));
|
|
}
|
|
|
|
public clearHistory() {
|
|
this._callbacks.onClearHistory();
|
|
}
|
|
|
|
public setScrollPercent(fraction: number) {
|
|
const maxScroll = this.editor.scrollDOM.scrollHeight - this.editor.scrollDOM.clientHeight;
|
|
this.editor.scrollDOM.scrollTop = fraction * maxScroll;
|
|
}
|
|
|
|
public insertText(text: string, userEvent?: UserEventSource) {
|
|
this.editor.dispatch(this.editor.state.replaceSelection(text), { userEvent });
|
|
}
|
|
|
|
public wrapSelections(start: string, end: string) {
|
|
const regionSpec = RegionSpec.of({ template: { start, end } });
|
|
|
|
this.editor.dispatch(
|
|
this.editor.state.changeByRange(range => {
|
|
const update = toggleInlineSelectionFormat(this.editor.state, regionSpec, range);
|
|
if (!update.range.empty) {
|
|
// Deselect the start and end characters (roughly preserve the original
|
|
// selection).
|
|
update.range = EditorSelection.range(
|
|
update.range.from + start.length,
|
|
update.range.to - end.length,
|
|
);
|
|
}
|
|
return update;
|
|
}),
|
|
);
|
|
}
|
|
|
|
public updateBody(newBody: string) {
|
|
// TODO: doc.toString() can be slow for large documents.
|
|
const currentBody = this.editor.state.doc.toString();
|
|
|
|
if (newBody !== currentBody) {
|
|
// For now, collapse the selection to a single cursor
|
|
// to ensure that the selection stays within the document
|
|
// (and thus avoids an exception).
|
|
const mainCursorPosition = this.editor.state.selection.main.anchor;
|
|
|
|
// The maximum cursor position needs to be calculated using the EditorState,
|
|
// to correctly account for line endings.
|
|
const maxCursorPosition = this.editor.state.toText(newBody).length;
|
|
const newCursorPosition = Math.min(mainCursorPosition, maxCursorPosition);
|
|
|
|
this.editor.dispatch(this.editor.state.update({
|
|
changes: {
|
|
from: 0,
|
|
to: this.editor.state.doc.length,
|
|
insert: newBody,
|
|
},
|
|
selection: EditorSelection.cursor(newCursorPosition),
|
|
scrollIntoView: true,
|
|
}));
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public updateLink(newLabel: string, newUrl: string) {
|
|
updateLink(newLabel, newUrl)(this.editor);
|
|
}
|
|
|
|
public updateSettings(newSettings: EditorSettings) {
|
|
this._callbacks.onSettingsChange(newSettings);
|
|
}
|
|
|
|
public getSearchState(): SearchState {
|
|
return getSearchState(this.editor.state);
|
|
}
|
|
|
|
public setSearchState(newState: SearchState) {
|
|
if (newState.dialogVisible !== searchPanelOpen(this.editor.state)) {
|
|
this.execCommand(newState.dialogVisible ? EditorCommandType.ShowSearch : EditorCommandType.HideSearch);
|
|
}
|
|
|
|
const query = new SearchQuery({
|
|
search: newState.searchText,
|
|
caseSensitive: newState.caseSensitive,
|
|
regexp: newState.useRegex,
|
|
replace: newState.replaceText,
|
|
});
|
|
this.editor.dispatch({
|
|
effects: [
|
|
setSearchQuery.of(query),
|
|
],
|
|
});
|
|
|
|
}
|
|
|
|
public scrollToText(text: string, scrollStrategy: ScrollStrategy) {
|
|
const doc = this.editor.state.doc;
|
|
const index = doc.toString().indexOf(text);
|
|
const textFound = index >= 0;
|
|
|
|
if (textFound) {
|
|
this.editor.dispatch({
|
|
effects: EditorView.scrollIntoView(index, { y: scrollStrategy }),
|
|
});
|
|
}
|
|
|
|
return textFound;
|
|
}
|
|
|
|
public addStyles(...styles: Parameters<typeof EditorView.theme>) {
|
|
const compartment = new Compartment();
|
|
this.editor.dispatch({
|
|
effects: StateEffect.appendConfig.of(
|
|
compartment.of(EditorView.theme(...styles)),
|
|
),
|
|
});
|
|
|
|
return {
|
|
remove: () => {
|
|
this.editor.dispatch({
|
|
effects: compartment.reconfigure([]),
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
public setContentScripts(plugins: ContentScriptData[]) {
|
|
return this._pluginControl.setPlugins(plugins);
|
|
}
|
|
|
|
public remove() {
|
|
this._pluginControl.remove();
|
|
this._callbacks.onRemove();
|
|
}
|
|
|
|
//
|
|
// CodeMirror-specific methods
|
|
//
|
|
|
|
public prependKeymap(bindings: readonly KeyBinding[]) {
|
|
const compartment = new Compartment();
|
|
this.editor.dispatch({
|
|
effects: StateEffect.appendConfig.of([
|
|
compartment.of(keymap.of(bindings)),
|
|
]),
|
|
});
|
|
|
|
return {
|
|
remove: () => {
|
|
this.editor.dispatch({
|
|
effects: compartment.reconfigure([]),
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
public joplinExtensions = {
|
|
// Some plugins want to enable autocompletion from *just* that plugin, without also
|
|
// enabling autocompletion for text within code blocks (and other built-in completion
|
|
// sources).
|
|
// To support this, we need to provide extensions that wrap the built-in autocomplete.
|
|
// See https://discuss.codemirror.net/t/autocompletion-merging-override-in-config/7853
|
|
completionSource: (completionSource: CompletionSource) => editorCompletionSource.of(completionSource),
|
|
enableLanguageDataAutocomplete: enableLanguageDataAutocomplete,
|
|
};
|
|
|
|
public addExtension(extension: Extension) {
|
|
this.editor.dispatch({
|
|
effects: StateEffect.appendConfig.of([extension]),
|
|
});
|
|
}
|
|
}
|