2024-03-09 12:49:28 +02:00
|
|
|
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
2023-09-21 10:12:40 +02:00
|
|
|
import { indentOnInput, syntaxHighlighting } from '@codemirror/language';
|
2024-08-15 17:01:52 +02:00
|
|
|
import { openSearchPanel, closeSearchPanel, searchPanelOpen } from '@codemirror/search';
|
2023-09-21 10:12:40 +02:00
|
|
|
|
|
|
|
import { classHighlighter } from '@lezer/highlight';
|
|
|
|
|
|
|
|
import {
|
2023-12-13 21:48:06 +02:00
|
|
|
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, rectangularSelection,
|
2023-09-21 10:12:40 +02:00
|
|
|
} from '@codemirror/view';
|
|
|
|
import { history, undoDepth, redoDepth, standardKeymap } from '@codemirror/commands';
|
|
|
|
|
|
|
|
import { keymap, KeyBinding } from '@codemirror/view';
|
|
|
|
import { searchKeymap } from '@codemirror/search';
|
|
|
|
import { historyKeymap } from '@codemirror/commands';
|
|
|
|
|
2024-08-15 17:01:52 +02:00
|
|
|
import { EditorProps, EditorSettings } from '../types';
|
2023-09-21 10:12:40 +02:00
|
|
|
import { EditorEventType, SelectionRangeChangeEvent } from '../events';
|
|
|
|
import {
|
|
|
|
decreaseIndent, increaseIndent,
|
2023-10-22 12:51:54 +02:00
|
|
|
insertOrIncreaseIndent,
|
2023-09-21 10:12:40 +02:00
|
|
|
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';
|
2024-03-02 17:52:55 +02:00
|
|
|
import insertLineAfter from './editorCommands/insertLineAfter';
|
2024-07-16 20:28:05 +02:00
|
|
|
import handlePasteEvent from './utils/handlePasteEvent';
|
2024-08-02 15:47:56 +02:00
|
|
|
import biDirectionalTextExtension from './utils/biDirectionalTextExtension';
|
2024-08-15 17:01:52 +02:00
|
|
|
import searchExtension from './utils/searchExtension';
|
2024-08-27 19:05:48 +02:00
|
|
|
import isCursorAtBeginning from './utils/isCursorAtBeginning';
|
2023-09-21 10:12:40 +02:00
|
|
|
|
2024-10-26 22:03:18 +02:00
|
|
|
// 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;
|
|
|
|
|
2023-09-21 10:12:40 +02:00
|
|
|
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();
|
|
|
|
|
2024-06-18 11:02:01 +02:00
|
|
|
// 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).
|
2024-08-15 17:01:52 +02:00
|
|
|
keyCommand('Mod-f', (editor: EditorView) => {
|
|
|
|
if (searchPanelOpen(editor.state)) {
|
|
|
|
closeSearchPanel(editor);
|
2024-06-18 11:02:01 +02:00
|
|
|
} else {
|
2024-08-15 17:01:52 +02:00
|
|
|
openSearchPanel(editor);
|
2024-06-18 11:02:01 +02:00
|
|
|
}
|
|
|
|
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', insertOrIncreaseIndent, true),
|
2024-08-27 19:05:48 +02:00
|
|
|
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),
|
2024-06-18 11:02:01 +02:00
|
|
|
keyCommand('Mod-Enter', (_: EditorView) => {
|
|
|
|
insertLineAfter(_);
|
|
|
|
return true;
|
|
|
|
}, true),
|
|
|
|
|
2024-08-27 19:05:48 +02:00
|
|
|
keyCommand('ArrowUp', (view: EditorView) => {
|
|
|
|
if (isCursorAtBeginning(view.state) && props.onSelectPastBeginning) {
|
|
|
|
props.onSelectPastBeginning();
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}, true),
|
|
|
|
|
2024-06-18 11:02:01 +02:00
|
|
|
...standardKeymap, ...historyKeymap, ...searchKeymap,
|
|
|
|
]));
|
|
|
|
|
2023-09-21 10:12:40 +02:00
|
|
|
const editor = new EditorView({
|
|
|
|
state: EditorState.create({
|
|
|
|
// See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
|
|
|
|
// for a sample configuration.
|
|
|
|
extensions: [
|
2024-06-18 11:02:01 +02:00
|
|
|
keymapConfig,
|
|
|
|
|
2023-09-21 10:12:40 +02:00
|
|
|
dynamicConfig.of(configFromSettings(props.settings)),
|
|
|
|
historyCompartment.of(history()),
|
2024-08-15 17:01:52 +02:00
|
|
|
searchExtension(props.onEvent, props.settings),
|
2023-12-13 21:48:06 +02:00
|
|
|
|
|
|
|
// Allows multiple selections and allows selecting a rectangle
|
|
|
|
// with ctrl (as in CodeMirror 5)
|
|
|
|
EditorState.allowMultipleSelections.of(true),
|
|
|
|
rectangularSelection(),
|
2023-09-21 10:12:40 +02:00
|
|
|
drawSelection(),
|
2023-12-13 21:48:06 +02:00
|
|
|
|
2023-09-21 10:12:40 +02:00
|
|
|
highlightSpecialChars(),
|
|
|
|
indentOnInput(),
|
|
|
|
|
|
|
|
EditorView.domEventHandlers({
|
|
|
|
scroll: (_event, view) => {
|
|
|
|
props.onEvent({
|
|
|
|
kind: EditorEventType.Scroll,
|
|
|
|
fraction: getScrollFraction(view),
|
|
|
|
});
|
|
|
|
},
|
2024-07-16 20:28:05 +02:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
},
|
2023-09-21 10:12:40 +02:00
|
|
|
}),
|
|
|
|
|
|
|
|
EditorState.tabSize.of(4),
|
|
|
|
|
|
|
|
// Apply styles to entire lines (block-display decorations)
|
|
|
|
decoratorExtension,
|
2024-08-02 15:47:56 +02:00
|
|
|
biDirectionalTextExtension,
|
2023-09-21 10:12:40 +02:00
|
|
|
|
2024-10-26 22:02:54 +02:00
|
|
|
props.localisations ? EditorState.phrases.of(props.localisations) : [],
|
|
|
|
|
2023-09-21 10:12:40 +02:00
|
|
|
// 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);
|
|
|
|
}),
|
2024-08-15 17:01:52 +02:00
|
|
|
|
2023-09-21 10:12:40 +02:00
|
|
|
],
|
|
|
|
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;
|
|
|
|
|
|
|
|
|