diff --git a/.eslintignore b/.eslintignore index 25c41fd03..a0291f831 100644 --- a/.eslintignore +++ b/.eslintignore @@ -873,6 +873,8 @@ packages/editor/CodeMirror/utils/growSelectionToNode.js packages/editor/CodeMirror/utils/handlePasteEvent.js packages/editor/CodeMirror/utils/isCursorAtBeginning.js packages/editor/CodeMirror/utils/isInSyntaxNode.js +packages/editor/CodeMirror/utils/overwriteModeExtension.test.js +packages/editor/CodeMirror/utils/overwriteModeExtension.js packages/editor/CodeMirror/utils/searchExtension.js packages/editor/CodeMirror/utils/setupVim.js packages/editor/SelectionFormatting.js diff --git a/.gitignore b/.gitignore index 0cd35145c..d077aadaf 100644 --- a/.gitignore +++ b/.gitignore @@ -850,6 +850,8 @@ packages/editor/CodeMirror/utils/growSelectionToNode.js packages/editor/CodeMirror/utils/handlePasteEvent.js packages/editor/CodeMirror/utils/isCursorAtBeginning.js packages/editor/CodeMirror/utils/isInSyntaxNode.js +packages/editor/CodeMirror/utils/overwriteModeExtension.test.js +packages/editor/CodeMirror/utils/overwriteModeExtension.js packages/editor/CodeMirror/utils/searchExtension.js packages/editor/CodeMirror/utils/setupVim.js packages/editor/SelectionFormatting.js diff --git a/packages/editor/CodeMirror/createEditor.ts b/packages/editor/CodeMirror/createEditor.ts index 7c0b2590a..a5bed37fd 100644 --- a/packages/editor/CodeMirror/createEditor.ts +++ b/packages/editor/CodeMirror/createEditor.ts @@ -32,6 +32,7 @@ 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 @@ -269,7 +270,9 @@ const createEditor = ( // Apply styles to entire lines (block-display decorations) decoratorExtension, + biDirectionalTextExtension, + overwriteModeExtension, props.localisations ? EditorState.phrases.of(props.localisations) : [], diff --git a/packages/editor/CodeMirror/testUtil/typeText.ts b/packages/editor/CodeMirror/testUtil/typeText.ts index 41d10e903..4e9f666e7 100644 --- a/packages/editor/CodeMirror/testUtil/typeText.ts +++ b/packages/editor/CodeMirror/testUtil/typeText.ts @@ -1,16 +1,26 @@ import { EditorView } from '@codemirror/view'; const typeText = (editor: EditorView, text: string) => { - // How CodeMirror does this in their tests: + const selection = editor.state.selection; + const inputHandlers = editor.state.facet(EditorView.inputHandler); + + // See how the upstream CodeMirror tests simulate user input: // https://github.com/codemirror/autocomplete/blob/fb1c899464df4d36528331412cdd316548134cb2/test/webtest-autocomplete.ts#L116 // The important part is the userEvent: input.type. - - const selection = editor.state.selection; - editor.dispatch({ + const defaultTransaction = editor.state.update({ changes: [{ from: selection.main.head, insert: text }], selection: { anchor: selection.main.head + text.length }, userEvent: 'input.type', }); + + // Allows code that uses input handlers to be tested + for (const handler of inputHandlers) { + if (handler(editor, selection.main.from, selection.main.to, text, () => defaultTransaction)) { + return; + } + } + + editor.dispatch(defaultTransaction); }; export default typeText; diff --git a/packages/editor/CodeMirror/utils/overwriteModeExtension.test.ts b/packages/editor/CodeMirror/utils/overwriteModeExtension.test.ts new file mode 100644 index 000000000..2be4212a5 --- /dev/null +++ b/packages/editor/CodeMirror/utils/overwriteModeExtension.test.ts @@ -0,0 +1,57 @@ +import { EditorSelection } from '@codemirror/state'; +import createTestEditor from '../testUtil/createTestEditor'; +import overwriteModeExtension, { toggleOverwrite } from './overwriteModeExtension'; +import typeText from '../testUtil/typeText'; +import pressReleaseKey from '../testUtil/pressReleaseKey'; + +const createEditor = async (initialText: string, defaultEnabled = false) => { + const editor = await createTestEditor(initialText, EditorSelection.cursor(0), [], [ + overwriteModeExtension, + ]); + + if (defaultEnabled) { + editor.dispatch({ effects: [toggleOverwrite.of(true)] }); + } + + return editor; +}; + +describe('overwriteModeExtension', () => { + test('should be disabled by default', async () => { + const editor = await createEditor('Test!'); + + typeText(editor, 'This should be inserted. '); + expect(editor.state.doc.toString()).toBe('This should be inserted. Test!'); + }); + + test('should overwrite characters while typing', async () => { + const editor = await createEditor('Test!', true); + + pressReleaseKey(editor, { key: 'A', code: 'KeyA' }); + typeText(editor, 'New'); + expect(editor.state.doc.toString()).toBe('Newt!'); + }); + + test('should be toggled by pressing ', async () => { + const editor = await createEditor('Test!'); + + pressReleaseKey(editor, { key: 'Insert', code: 'Insert' }); + typeText(editor, 'Exam'); + expect(editor.state.doc.toString()).toBe('Exam!'); + + pressReleaseKey(editor, { key: 'Insert', code: 'Insert' }); + typeText(editor, 'ple'); + expect(editor.state.doc.toString()).toBe('Example!'); + }); + + test('should insert text if the cursor is at the end of a line', async () => { + const editor = await createEditor('\nTest', true); + typeText(editor, 'Test! This is a test! '); + typeText(editor, 't'); + typeText(editor, 'e'); + typeText(editor, 's'); + typeText(editor, 't'); + + expect(editor.state.doc.toString()).toBe('Test! This is a test! test\nTest'); + }); +}); diff --git a/packages/editor/CodeMirror/utils/overwriteModeExtension.ts b/packages/editor/CodeMirror/utils/overwriteModeExtension.ts new file mode 100644 index 000000000..762bd0e00 --- /dev/null +++ b/packages/editor/CodeMirror/utils/overwriteModeExtension.ts @@ -0,0 +1,78 @@ +import { keymap, EditorView } from '@codemirror/view'; +import { StateField, Facet, StateEffect } from '@codemirror/state'; + +const overwriteModeFacet = Facet.define({ + combine: values => values[0] ?? false, + enables: facet => [ + EditorView.inputHandler.of(( + view, _from, _to, text, insert, + ) => { + if (view.composing || view.compositionStarted || view.state.readOnly) { + return false; + } + + if (view.state.facet(facet) && text) { + const originalTransaction = insert(); + const newState1 = originalTransaction.state; + const emptySelections1 = newState1.selection.ranges.filter( + range => range.empty, + ); + + view.dispatch([ + originalTransaction, + newState1.update({ + changes: emptySelections1.map(range => { + const line = newState1.doc.lineAt(range.to); + return { + from: range.to, + to: Math.min(line.to, range.to + text.length), + insert: '', + }; + }).filter(change => change.from !== change.to || change.insert), + }), + ]); + return true; + } + return false; + }), + EditorView.theme({ + '&.-overwrite .cm-cursor': { + borderLeftWidth: '0.5em', + }, + }), + ], +}); + +export const toggleOverwrite = StateEffect.define(); +const overwriteModeState = StateField.define({ + create: () => false, + update: (oldValue, tr) => { + for (const e of tr.effects) { + if (e.is(toggleOverwrite)) { + return e.value; + } + } + return oldValue; + }, + provide: (field) => [ + overwriteModeFacet.from(field), + EditorView.editorAttributes.from(field, on => ({ + class: on ? '-overwrite' : '', + })), + ], +}); + +const overwriteModeExtension = [ + overwriteModeState, + keymap.of([{ + key: 'Insert', + run: (view) => { + view.dispatch({ + effects: toggleOverwrite.of(!view.state.field(overwriteModeState)), + }); + return false; + }, + }]), +]; + +export default overwriteModeExtension;