1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-27 10:32:58 +02:00
joplin/packages/editor/CodeMirror/createEditor.ts
2024-11-04 20:33:15 +00:00

334 lines
10 KiB
TypeScript

import { Compartment, EditorState, Prec } from '@codemirror/state';
import { indentOnInput, syntaxHighlighting } from '@codemirror/language';
import { openSearchPanel, closeSearchPanel, searchPanelOpen } from '@codemirror/search';
import { classHighlighter } from '@lezer/highlight';
import {
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, rectangularSelection,
dropCursor,
} from '@codemirror/view';
import { history, undoDepth, redoDepth, standardKeymap, insertTab } from '@codemirror/commands';
import { keymap, KeyBinding } from '@codemirror/view';
import { searchKeymap } from '@codemirror/search';
import { historyKeymap } from '@codemirror/commands';
import { EditorProps, EditorSettings } from '../types';
import { EditorEventType, SelectionRangeChangeEvent } from '../events';
import {
decreaseIndent, increaseIndent,
insertOrIncreaseIndent,
toggleBolded, toggleCode,
toggleItalicized, toggleMath,
} from './markdown/markdownCommands';
import decoratorExtension from './markdown/decoratorExtension';
import computeSelectionFormatting from './markdown/computeSelectionFormatting';
import { selectionFormattingEqual } from '../SelectionFormatting';
import configFromSettings from './configFromSettings';
import getScrollFraction from './getScrollFraction';
import CodeMirrorControl from './CodeMirrorControl';
import insertLineAfter from './editorCommands/insertLineAfter';
import handlePasteEvent from './utils/handlePasteEvent';
import biDirectionalTextExtension from './utils/biDirectionalTextExtension';
import searchExtension from './utils/searchExtension';
import isCursorAtBeginning from './utils/isCursorAtBeginning';
import overwriteModeExtension from './utils/overwriteModeExtension';
// Newer versions of CodeMirror by default use Chrome's EditContext API.
// While this might be stable enough for desktop use, it causes significant
// problems on Android:
// - https://github.com/codemirror/dev/issues/1450
// - https://github.com/codemirror/dev/issues/1451
// For now, CodeMirror allows disabling EditContext to work around these issues:
// https://discuss.codemirror.net/t/experimental-support-for-editcontext/8144/3
type ExtendedEditorView = typeof EditorView & { EDIT_CONTEXT: boolean };
(EditorView as ExtendedEditorView).EDIT_CONTEXT = false;
const createEditor = (
parentElement: HTMLElement, props: EditorProps,
): CodeMirrorControl => {
const initialText = props.initialText;
let settings = props.settings;
props.onLogMessage('Initializing CodeMirror...');
// Handles firing an event when the undo/redo stack changes
let schedulePostUndoRedoDepthChangeId_: ReturnType<typeof setTimeout>|null = null;
let lastUndoDepth = 0;
let lastRedoDepth = 0;
const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow = false) => {
if (schedulePostUndoRedoDepthChangeId_ !== null) {
if (doItNow) {
clearTimeout(schedulePostUndoRedoDepthChangeId_);
} else {
return;
}
}
schedulePostUndoRedoDepthChangeId_ = setTimeout(() => {
schedulePostUndoRedoDepthChangeId_ = null;
const newUndoDepth = undoDepth(editor.state);
const newRedoDepth = redoDepth(editor.state);
if (newUndoDepth !== lastUndoDepth || newRedoDepth !== lastRedoDepth) {
props.onEvent({
kind: EditorEventType.UndoRedoDepthChange,
undoDepth: newUndoDepth,
redoDepth: newRedoDepth,
});
lastUndoDepth = newUndoDepth;
lastRedoDepth = newRedoDepth;
}
}, doItNow ? 0 : 1000);
};
let currentDocText = props.initialText;
const notifyDocChanged = (viewUpdate: ViewUpdate) => {
if (viewUpdate.docChanged) {
currentDocText = editor.state.doc.toString();
props.onEvent({
kind: EditorEventType.Change,
value: currentDocText,
});
schedulePostUndoRedoDepthChange(editor);
}
};
const notifyLinkEditRequest = () => {
props.onEvent({
kind: EditorEventType.EditLink,
});
};
const globalSpellcheckEnabled = () => {
return editor.contentDOM.spellcheck;
};
const notifySelectionChange = (viewUpdate: ViewUpdate) => {
if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
const mainRange = viewUpdate.state.selection.main;
const event: SelectionRangeChangeEvent = {
kind: EditorEventType.SelectionRangeChange,
anchor: mainRange.anchor,
head: mainRange.head,
from: mainRange.from,
to: mainRange.to,
};
props.onEvent(event);
}
};
const notifySelectionFormattingChange = (viewUpdate?: ViewUpdate) => {
const spellcheck = globalSpellcheckEnabled();
// If we can't determine the previous formatting, post the update regardless
if (!viewUpdate) {
const formatting = computeSelectionFormatting(editor.state, spellcheck);
props.onEvent({
kind: EditorEventType.SelectionFormattingChange,
formatting,
});
} else if (viewUpdate.docChanged || !viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
// Only post the update if something changed
const oldFormatting = computeSelectionFormatting(viewUpdate.startState, spellcheck);
const newFormatting = computeSelectionFormatting(viewUpdate.state, spellcheck);
if (!selectionFormattingEqual(oldFormatting, newFormatting)) {
props.onEvent({
kind: EditorEventType.SelectionFormattingChange,
formatting: newFormatting,
});
}
}
};
// Returns a keyboard command that returns true (so accepts the keybind)
// alwaysActive: true if this command should be registered even if ignoreModifiers is given.
const keyCommand = (key: string, run: Command, alwaysActive?: boolean): KeyBinding => {
return {
key,
run: editor => {
if (settings.ignoreModifiers && !alwaysActive) return false;
return run(editor);
},
};
};
const historyCompartment = new Compartment();
const dynamicConfig = new Compartment();
// Give the default keymap low precedence so that it is overridden
// by extensions with default precedence.
const keymapConfig = Prec.low(keymap.of([
// Custom mod-f binding: Toggle the external dialog implementation
// (don't show/hide the Panel dialog).
keyCommand('Mod-f', (editor: EditorView) => {
if (searchPanelOpen(editor.state)) {
closeSearchPanel(editor);
} else {
openSearchPanel(editor);
}
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;
}),
keyCommand('Tab', (view: EditorView) => {
if (settings.autocompleteMarkup) {
return insertOrIncreaseIndent(view);
}
// Use the default indent behavior (which doesn't adjust markup)
return insertTab(view);
}, true),
keyCommand('Shift-Tab', (view) => {
// When at the beginning of the editor, allow shift-tab to act
// normally.
if (isCursorAtBeginning(view.state)) {
return false;
}
return decreaseIndent(view);
}, true),
keyCommand('Mod-Enter', (_: EditorView) => {
insertLineAfter(_);
return true;
}, true),
keyCommand('ArrowUp', (view: EditorView) => {
if (isCursorAtBeginning(view.state) && props.onSelectPastBeginning) {
props.onSelectPastBeginning();
return true;
}
return false;
}, true),
...standardKeymap, ...historyKeymap, ...searchKeymap,
]));
const editor = new EditorView({
state: EditorState.create({
// See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
// for a sample configuration.
extensions: [
keymapConfig,
dynamicConfig.of(configFromSettings(props.settings)),
historyCompartment.of(history()),
searchExtension(props.onEvent, props.settings),
// Allows multiple selections and allows selecting a rectangle
// with ctrl (as in CodeMirror 5)
EditorState.allowMultipleSelections.of(true),
rectangularSelection(),
drawSelection(),
highlightSpecialChars(),
indentOnInput(),
EditorView.domEventHandlers({
scroll: (_event, view) => {
props.onEvent({
kind: EditorEventType.Scroll,
fraction: getScrollFraction(view),
});
},
paste: (event, view) => {
if (props.onPasteFile) {
handlePasteEvent(event, view, props.onPasteFile);
}
},
dragover: (event, _view) => {
if (props.onPasteFile && event.dataTransfer.files.length) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
return true;
}
return false;
},
drop: (event, view) => {
if (props.onPasteFile) {
handlePasteEvent(event, view, props.onPasteFile);
}
},
}),
EditorState.tabSize.of(4),
// Apply styles to entire lines (block-display decorations)
decoratorExtension,
dropCursor(),
biDirectionalTextExtension,
overwriteModeExtension,
props.localisations ? EditorState.phrases.of(props.localisations) : [],
// Adds additional CSS classes to tokens (the default CSS classes are
// auto-generated and thus unstable).
syntaxHighlighting(classHighlighter),
EditorView.lineWrapping,
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
notifyDocChanged(viewUpdate);
notifySelectionChange(viewUpdate);
notifySelectionFormattingChange(viewUpdate);
}),
],
doc: initialText,
}),
parent: parentElement,
});
const editorControls = new CodeMirrorControl(editor, {
onClearHistory: () => {
// Clear history by removing then re-add the history extension.
// Just re-adding the history extension isn't enough.
editor.dispatch({
effects: historyCompartment.reconfigure([]),
});
editor.dispatch({
effects: historyCompartment.reconfigure(history()),
});
},
onSettingsChange: (newSettings: EditorSettings) => {
settings = newSettings;
editor.dispatch({
effects: dynamicConfig.reconfigure(
configFromSettings(newSettings),
),
});
},
onUndoRedo: () => {
// This callback is triggered when undo/redo is called
// directly. Show visual feedback immediately.
schedulePostUndoRedoDepthChange(editor, true);
},
onLogMessage: props.onLogMessage,
onRemove: () => {
editor.destroy();
},
});
return editorControls;
};
export default createEditor;