From 4b2cc907e218a8bdf986b6bb351f3d5fa5c62b13 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Fri, 14 Apr 2023 09:58:19 +0100 Subject: [PATCH] [cm6] Change Emacs commands to visual-line-mode (#12523) * [cm6] Change Emacs commands to visual-line-mode * [cm6] Change line deletion commands to visual line mode GitOrigin-RevId: 7a4f3d66bec611de410b6c1fbafbfe33b974e37b --- package-lock.json | 14 +- .../source-editor/extensions/index.ts | 16 +- .../source-editor/extensions/keybindings.ts | 19 +++ .../source-editor/extensions/shortcuts.ts | 12 ++ .../extensions/visual-line-selection.ts | 160 ++++++++++++++++++ services/web/package.json | 2 +- 6 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 services/web/frontend/js/features/source-editor/extensions/visual-line-selection.ts diff --git a/package-lock.json b/package-lock.json index f36c77d33a..4a04cd367a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8189,8 +8189,8 @@ }, "node_modules/@replit/codemirror-emacs": { "version": "6.0.0", - "resolved": "git+ssh://git@github.com/overleaf/codemirror-emacs.git#3f235870d4fa83069df855d6588569865c31e220", - "integrity": "sha512-Fqnj4HO25LRqlvgS2g/FEP+w2LpmjAN+y+nED2eLk6ezaE8VpFc7DJyd0Or7FqwqonJv1ARMJUDOKQVTkKcEjA==", + "resolved": "git+ssh://git@github.com/overleaf/codemirror-emacs.git#cea6eaefe2301bf07e7dec54f028537c3fdc4982", + "integrity": "sha512-1dW1RZX6yaZ31N2KqQ7XgYAy44yhXOf3LBZjpoODoVnJzEX5b003mejygoVCrHr6GpjBeInAx7ggx2wRWXiLXA==", "license": "MIT", "peerDependencies": { "@codemirror/autocomplete": "^6.0.2", @@ -35131,7 +35131,7 @@ "@pollyjs/core": "^4.2.1", "@pollyjs/persister-fs": "^4.2.1", "@reach/tabs": "^0.15.0", - "@replit/codemirror-emacs": "overleaf/codemirror-emacs#3f235870d4fa83069df855d6588569865c31e220", + "@replit/codemirror-emacs": "overleaf/codemirror-emacs#cea6eaefe2301bf07e7dec54f028537c3fdc4982", "@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#1b1f93c0bcd04293aea6986aa2275185b2c56803", "@replit/codemirror-vim": "overleaf/codemirror-vim#77876ac3390f5a6642e04348c0afbe9f0878ec25", "@sentry/browser": "^7.8.1", @@ -44810,7 +44810,7 @@ "@pollyjs/core": "^4.2.1", "@pollyjs/persister-fs": "^4.2.1", "@reach/tabs": "^0.15.0", - "@replit/codemirror-emacs": "overleaf/codemirror-emacs#3f235870d4fa83069df855d6588569865c31e220", + "@replit/codemirror-emacs": "overleaf/codemirror-emacs#cea6eaefe2301bf07e7dec54f028537c3fdc4982", "@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#1b1f93c0bcd04293aea6986aa2275185b2c56803", "@replit/codemirror-vim": "overleaf/codemirror-vim#77876ac3390f5a6642e04348c0afbe9f0878ec25", "@sentry/browser": "^7.8.1", @@ -46990,9 +46990,9 @@ "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" }, "@replit/codemirror-emacs": { - "version": "git+ssh://git@github.com/overleaf/codemirror-emacs.git#3f235870d4fa83069df855d6588569865c31e220", - "integrity": "sha512-Fqnj4HO25LRqlvgS2g/FEP+w2LpmjAN+y+nED2eLk6ezaE8VpFc7DJyd0Or7FqwqonJv1ARMJUDOKQVTkKcEjA==", - "from": "@replit/codemirror-emacs@overleaf/codemirror-emacs#3f235870d4fa83069df855d6588569865c31e220", + "version": "git+ssh://git@github.com/overleaf/codemirror-emacs.git#cea6eaefe2301bf07e7dec54f028537c3fdc4982", + "integrity": "sha512-1dW1RZX6yaZ31N2KqQ7XgYAy44yhXOf3LBZjpoODoVnJzEX5b003mejygoVCrHr6GpjBeInAx7ggx2wRWXiLXA==", + "from": "@replit/codemirror-emacs@overleaf/codemirror-emacs#cea6eaefe2301bf07e7dec54f028537c3fdc4982", "requires": {} }, "@replit/codemirror-indentation-markers": { diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts index 6d7462644e..7de27b307f 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -62,6 +62,12 @@ const ignoredDefaultKeybindings = new Set([ 'Mod-Alt-\\', ]) +const ignoredDefaultMacKeybindings = new Set([ + // We replace these with our custom visual-line versions + 'Mod-Backspace', + 'Mod-Delete', +]) + const moduleExtensions: Array<() => Extension> = importOverleafModules( 'sourceEditorExtensions' ).map((item: { import: { extension: Extension } }) => item.import.extension) @@ -92,7 +98,15 @@ export const createExtensions = (options: Record): Extension[] => [ ...defaultKeymap.filter( // We only filter on keys, so if the keybinding doesn't have a key, // allow it - item => !item.key || !ignoredDefaultKeybindings.has(item.key) + item => { + if (item.key && ignoredDefaultKeybindings.has(item.key)) { + return false + } + if (item.mac && ignoredDefaultMacKeybindings.has(item.mac)) { + return false + } + return true + } ), ...historyKeymap, ...lintKeymap, diff --git a/services/web/frontend/js/features/source-editor/extensions/keybindings.ts b/services/web/frontend/js/features/source-editor/extensions/keybindings.ts index 30abad9e3d..d4e3e772d8 100644 --- a/services/web/frontend/js/features/source-editor/extensions/keybindings.ts +++ b/services/web/frontend/js/features/source-editor/extensions/keybindings.ts @@ -8,6 +8,13 @@ import { import { EmacsHandler } from '@replit/codemirror-emacs' import { CodeMirror } from '@replit/codemirror-vim' import { foldCode, toggleFold, unfoldCode } from '@codemirror/language' +import { + cursorToBeginningOfVisualLine, + cursorToEndOfVisualLine, + selectRestOfVisualLine, + selectToBeginningOfVisualLine, + selectToEndOfVisualLine, +} from './visual-line-selection' const hasNonEmptySelection = (cm: CodeMirror): boolean => { const selections = cm.getSelections() @@ -132,6 +139,18 @@ const customiseEmacsOnce = () => { }) EmacsHandler.bindKey('C-s', 'openSearch') EmacsHandler.bindKey('C-r', 'openSearch') + EmacsHandler.bindKey('C-a', { + command: 'goOrSelect', + args: [cursorToBeginningOfVisualLine, selectToBeginningOfVisualLine], + }) + EmacsHandler.bindKey('C-e', { + command: 'goOrSelect', + args: [cursorToEndOfVisualLine, selectToEndOfVisualLine], + }) + EmacsHandler.bindKey('C-k', { + command: 'killLine', + args: selectRestOfVisualLine, + }) } const options = [ diff --git a/services/web/frontend/js/features/source-editor/extensions/shortcuts.ts b/services/web/frontend/js/features/source-editor/extensions/shortcuts.ts index 8ab5d81a03..14a0b1e2b1 100644 --- a/services/web/frontend/js/features/source-editor/extensions/shortcuts.ts +++ b/services/web/frontend/js/features/source-editor/extensions/shortcuts.ts @@ -19,6 +19,10 @@ import { changeCase, duplicateSelection } from '../commands/ranges' import { selectOccurrence } from '../commands/select' import { cloneSelectionVertically } from '../commands/cursor' import { dispatchEditorEvent } from './changes/change-manager' +import { + deleteToVisualLineEnd, + deleteToVisualLineStart, +} from './visual-line-selection' export const shortcuts = () => { const toggleReviewPanel = () => { @@ -168,6 +172,14 @@ export const shortcuts = () => { run: cursorSyntaxRight, shift: selectSyntaxRight, }, + { + mac: 'Mod-Backspace', + run: deleteToVisualLineStart, + }, + { + mac: 'Mod-Delete', + run: deleteToVisualLineEnd, + }, ] return Prec.high(keymap.of(keyBindings)) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual-line-selection.ts b/services/web/frontend/js/features/source-editor/extensions/visual-line-selection.ts new file mode 100644 index 0000000000..60cfe4cb08 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual-line-selection.ts @@ -0,0 +1,160 @@ +import { + SelectionRange, + EditorSelection, + EditorState, + Transaction, +} from '@codemirror/state' +import { Command, EditorView } from '@codemirror/view' + +const getNextLineBoundary = ( + selection: SelectionRange, + forward: boolean, + view: EditorView, + includeWrappingCharacter = false +) => { + const newSelection = view.moveToLineBoundary( + EditorSelection.cursor( + selection.head, + 1, + selection.bidiLevel || undefined, + selection.goalColumn + ), + forward + ) + // Adjust to be "before" the simulated line break + let offset = 0 + if ( + forward && + !includeWrappingCharacter && + view.lineBlockAt(selection.head).to !== newSelection.head + ) { + offset = 1 + } + return EditorSelection.cursor( + newSelection.head - offset, + selection.assoc, + selection.bidiLevel || undefined, + newSelection.goalColumn + ) +} + +const changeSelection = ( + view: EditorView, + how: (selection: SelectionRange) => SelectionRange, + extend = false +) => { + view.dispatch({ + selection: EditorSelection.create( + view.state.selection.ranges.map(start => { + const newSelection = how(start) + const anchor = extend ? start.anchor : newSelection.head + return EditorSelection.range( + anchor, + newSelection.head, + newSelection.goalColumn, + newSelection.bidiLevel || undefined + ) + }), + view.state.selection.mainIndex + ), + scrollIntoView: true, + userEvent: 'select', + }) +} + +export const cursorToEndOfVisualLine = (view: EditorView) => + changeSelection(view, range => getNextLineBoundary(range, true, view), false) + +export const selectToEndOfVisualLine = (view: EditorView) => + changeSelection(view, range => getNextLineBoundary(range, true, view), true) + +export const selectRestOfVisualLine = (view: EditorView) => + changeSelection( + view, + range => getNextLineBoundary(range, true, view, true), + true + ) + +export const cursorToBeginningOfVisualLine = (view: EditorView) => + changeSelection(view, range => getNextLineBoundary(range, false, view), false) + +export const selectToBeginningOfVisualLine = (view: EditorView) => + changeSelection(view, range => getNextLineBoundary(range, false, view), true) + +export const deleteToVisualLineEnd: Command = view => + deleteBy(view, pos => { + const lineEnd = getNextLineBoundary( + EditorSelection.cursor(pos), + true, + view, + true + ).to + return pos < lineEnd ? lineEnd : Math.min(view.state.doc.length, pos + 1) + }) + +export const deleteToVisualLineStart: Command = view => + deleteBy(view, pos => { + const lineStart = getNextLineBoundary( + EditorSelection.cursor(pos), + false, + view + ).to + return pos > lineStart ? lineStart : Math.max(0, pos - 1) + }) + +/* eslint-disable */ +/** + * The following definitions are from CodeMirror 6, licensed under the MIT license: + * https://github.com/codemirror/commands/blob/main/src/commands.ts + */ +type CommandTarget = { state: EditorState; dispatch: (tr: Transaction) => void } + +function deleteBy(target: CommandTarget, by: (start: number) => number) { + if (target.state.readOnly) return false + let event = 'delete.selection', + { state } = target + let changes = state.changeByRange(range => { + let { from, to } = range + if (from == to) { + let towards = by(from) + if (towards < from) { + event = 'delete.backward' + towards = skipAtomic(target, towards, false) + } else if (towards > from) { + event = 'delete.forward' + towards = skipAtomic(target, towards, true) + } + from = Math.min(from, towards) + to = Math.max(to, towards) + } else { + from = skipAtomic(target, from, false) + to = skipAtomic(target, to, true) + } + return from == to + ? { range } + : { changes: { from, to }, range: EditorSelection.cursor(from) } + }) + if (changes.changes.empty) return false + target.dispatch( + state.update(changes, { + scrollIntoView: true, + userEvent: event, + effects: + event == 'delete.selection' + ? EditorView.announce.of(state.phrase('Selection deleted')) + : undefined, + }) + ) + return true +} + +function skipAtomic(target: CommandTarget, pos: number, forward: boolean) { + if (target instanceof EditorView) + for (let ranges of target.state + .facet(EditorView.atomicRanges) + .map(f => f(target))) + ranges.between(pos, pos, (from, to) => { + if (from < pos && to > pos) pos = forward ? to : from + }) + return pos +} diff --git a/services/web/package.json b/services/web/package.json index f349365800..ca52f71b1e 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -111,7 +111,7 @@ "@pollyjs/core": "^4.2.1", "@pollyjs/persister-fs": "^4.2.1", "@reach/tabs": "^0.15.0", - "@replit/codemirror-emacs": "overleaf/codemirror-emacs#3f235870d4fa83069df855d6588569865c31e220", + "@replit/codemirror-emacs": "overleaf/codemirror-emacs#cea6eaefe2301bf07e7dec54f028537c3fdc4982", "@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#1b1f93c0bcd04293aea6986aa2275185b2c56803", "@replit/codemirror-vim": "overleaf/codemirror-vim#77876ac3390f5a6642e04348c0afbe9f0878ec25", "@sentry/browser": "^7.8.1",