From 54c38844d254546a35afbe8d81be4f6380a54262 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Wed, 17 Feb 2021 22:00:48 +1300 Subject: [PATCH] Use key bindings in BasicMessageComposer --- src/KeyBindingsManager.ts | 164 +++++++++++++++- .../views/rooms/BasicMessageComposer.tsx | 175 +++++++++--------- 2 files changed, 245 insertions(+), 94 deletions(-) diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index e8f4126fbd..ef5084c16c 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -4,6 +4,8 @@ import SettingsStore from './settings/SettingsStore'; export enum KeyBindingContext { /** Key bindings for the chat message composer component */ MessageComposer = 'MessageComposer', + /** Key bindings for text editing autocompletion */ + AutoComplete = 'AutoComplete', } export enum KeyAction { @@ -21,9 +23,34 @@ export enum KeyAction { EditPrevMessage = 'EditPrevMessage', /** Start editing the user's next sent message */ EditNextMessage = 'EditNextMessage', - - /** Cancel editing a message or cancel replying to a message */ + /** Cancel editing a message or cancel replying to a message*/ CancelEditing = 'CancelEditing', + + /** Set bold format the current selection */ + FormatBold = 'FormatBold', + /** Set italics format the current selection */ + FormatItalics = 'FormatItalics', + /** Format the current selection as quote */ + FormatQuote = 'FormatQuote', + /** Undo the last editing */ + EditUndo = 'EditUndo', + /** Redo editing */ + EditRedo = 'EditRedo', + /** Insert new line */ + NewLine = 'NewLine', + MoveCursorToStart = 'MoveCursorToStart', + MoveCursorToEnd = 'MoveCursorToEnd', + + // Autocomplete + + /** Apply the current autocomplete selection */ + AutocompleteApply = 'AutocompleteApply', + /** Cancel autocompletion */ + AutocompleteCancel = 'AutocompleteCancel', + /** Move to the previous autocomplete selection */ + AutocompletePrevSelection = 'AutocompletePrevSelection', + /** Move to the next autocomplete selection */ + AutocompleteNextSelection = 'AutocompleteNextSelection', } /** @@ -84,7 +111,69 @@ const messageComposerBindings = (): KeyBinding[] => { key: Key.ESCAPE, }, }, + { + action: KeyAction.FormatBold, + keyCombo: { + key: Key.B, + ctrlOrCmd: true, + }, + }, + { + action: KeyAction.FormatItalics, + keyCombo: { + key: Key.I, + ctrlOrCmd: true, + }, + }, + { + action: KeyAction.FormatQuote, + keyCombo: { + key: Key.GREATER_THAN, + ctrlOrCmd: true, + shiftKey: true, + }, + }, + { + action: KeyAction.EditUndo, + keyCombo: { + key: Key.Z, + ctrlOrCmd: true, + }, + }, + // Note: the following two bindings also work with just HOME and END, add them here? + { + action: KeyAction.MoveCursorToStart, + keyCombo: { + key: Key.HOME, + ctrlOrCmd: true, + }, + }, + { + action: KeyAction.MoveCursorToEnd, + keyCombo: { + key: Key.END, + ctrlOrCmd: true, + }, + }, ]; + if (isMac) { + bindings.push({ + action: KeyAction.EditRedo, + keyCombo: { + key: Key.Z, + ctrlOrCmd: true, + shiftKey: true, + }, + }); + } else { + bindings.push({ + action: KeyAction.EditRedo, + keyCombo: { + key: Key.Y, + ctrlOrCmd: true, + }, + }); + } if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) { bindings.push({ action: KeyAction.Send, @@ -93,6 +182,12 @@ const messageComposerBindings = (): KeyBinding[] => { ctrlOrCmd: true, }, }); + bindings.push({ + action: KeyAction.NewLine, + keyCombo: { + key: Key.ENTER, + }, + }); } else { bindings.push({ action: KeyAction.Send, @@ -100,17 +195,75 @@ const messageComposerBindings = (): KeyBinding[] => { key: Key.ENTER, }, }); + bindings.push({ + action: KeyAction.NewLine, + keyCombo: { + key: Key.ENTER, + shiftKey: true, + }, + }); + if (isMac) { + bindings.push({ + action: KeyAction.NewLine, + keyCombo: { + key: Key.ENTER, + altKey: true, + }, + }); + } } - return bindings; } +const autocompleteBindings = (): KeyBinding[] => { + return [ + { + action: KeyAction.AutocompleteApply, + keyCombo: { + key: Key.TAB, + }, + }, + { + action: KeyAction.AutocompleteApply, + keyCombo: { + key: Key.TAB, + ctrlKey: true, + }, + }, + { + action: KeyAction.AutocompleteApply, + keyCombo: { + key: Key.TAB, + shiftKey: true, + }, + }, + { + action: KeyAction.AutocompleteCancel, + keyCombo: { + key: Key.ESCAPE, + }, + }, + { + action: KeyAction.AutocompletePrevSelection, + keyCombo: { + key: Key.ARROW_UP, + }, + }, + { + action: KeyAction.AutocompleteNextSelection, + keyCombo: { + key: Key.ARROW_DOWN, + }, + }, + ] +} + /** * Helper method to check if a KeyboardEvent matches a KeyCombo * * Note, this method is only exported for testing. */ -export function isKeyComboMatch(ev: KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean { +export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean { if (combo.key !== undefined && ev.key !== combo.key) { return false; } @@ -160,12 +313,13 @@ export class KeyBindingsManager { */ contextBindings: Record = { [KeyBindingContext.MessageComposer]: messageComposerBindings, + [KeyBindingContext.AutoComplete]: autocompleteBindings, }; /** * Finds a matching KeyAction for a given KeyboardEvent */ - getAction(context: KeyBindingContext, ev: KeyboardEvent): KeyAction { + getAction(context: KeyBindingContext, ev: KeyboardEvent | React.KeyboardEvent): KeyAction { const bindings = this.contextBindings[context]?.(); if (!bindings) { return KeyAction.None; diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 017ce77166..d0119ddc05 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -46,6 +46,7 @@ import {IDiff} from "../../../editor/diff"; import AutocompleteWrapperModel from "../../../editor/autocomplete"; import DocumentPosition from "../../../editor/position"; import {ICompletion} from "../../../autocomplete/Autocompleter"; +import { getKeyBindingsManager, KeyBindingContext, KeyAction } from '../../../KeyBindingsManager'; // matches emoticons which follow the start of a line or whitespace const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); @@ -419,98 +420,94 @@ export default class BasicMessageEditor extends React.Component private onKeyDown = (event: React.KeyboardEvent) => { const model = this.props.model; - const modKey = IS_MAC ? event.metaKey : event.ctrlKey; let handled = false; - // format bold - if (modKey && event.key === Key.B) { - this.onFormatAction(Formatting.Bold); - handled = true; - // format italics - } else if (modKey && event.key === Key.I) { - this.onFormatAction(Formatting.Italics); - handled = true; - // format quote - } else if (modKey && event.key === Key.GREATER_THAN) { - this.onFormatAction(Formatting.Quote); - handled = true; - // redo - } else if ((!IS_MAC && modKey && event.key === Key.Y) || - (IS_MAC && modKey && event.shiftKey && event.key === Key.Z)) { - if (this.historyManager.canRedo()) { - const {parts, caret} = this.historyManager.redo(); - // pass matching inputType so historyManager doesn't push echo - // when invoked from rerender callback. - model.reset(parts, caret, "historyRedo"); - } - handled = true; - // undo - } else if (modKey && event.key === Key.Z) { - if (this.historyManager.canUndo()) { - const {parts, caret} = this.historyManager.undo(this.props.model); - // pass matching inputType so historyManager doesn't push echo - // when invoked from rerender callback. - model.reset(parts, caret, "historyUndo"); - } - handled = true; - // insert newline on Shift+Enter - } else if (event.key === Key.ENTER && (event.shiftKey || (IS_MAC && event.altKey))) { - this.insertText("\n"); - handled = true; - // move selection to start of composer - } else if (modKey && event.key === Key.HOME && !event.shiftKey) { - setSelection(this.editorRef.current, model, { - index: 0, - offset: 0, - }); - handled = true; - // move selection to end of composer - } else if (modKey && event.key === Key.END && !event.shiftKey) { - setSelection(this.editorRef.current, model, { - index: model.parts.length - 1, - offset: model.parts[model.parts.length - 1].text.length, - }); - handled = true; - // autocomplete or enter to send below shouldn't have any modifier keys pressed. - } else { - const metaOrAltPressed = event.metaKey || event.altKey; - const modifierPressed = metaOrAltPressed || event.shiftKey; - if (model.autoComplete && model.autoComplete.hasCompletions()) { - const autoComplete = model.autoComplete; - switch (event.key) { - case Key.ARROW_UP: - if (!modifierPressed) { - autoComplete.onUpArrow(event); - handled = true; - } - break; - case Key.ARROW_DOWN: - if (!modifierPressed) { - autoComplete.onDownArrow(event); - handled = true; - } - break; - case Key.TAB: - if (!metaOrAltPressed) { - autoComplete.onTab(event); - handled = true; - } - break; - case Key.ESCAPE: - if (!modifierPressed) { - autoComplete.onEscape(event); - handled = true; - } - break; - default: - return; // don't preventDefault on anything else - } - } else if (event.key === Key.TAB) { - this.tabCompleteName(event); + const action = getKeyBindingsManager().getAction(KeyBindingContext.MessageComposer, event); + switch (action) { + case KeyAction.FormatBold: + this.onFormatAction(Formatting.Bold); handled = true; - } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) { - this.formatBarRef.current.hide(); - } + break; + case KeyAction.FormatItalics: + this.onFormatAction(Formatting.Italics); + handled = true; + break; + case KeyAction.FormatQuote: + this.onFormatAction(Formatting.Quote); + handled = true; + break; + case KeyAction.EditRedo: + if (this.historyManager.canRedo()) { + const {parts, caret} = this.historyManager.redo(); + // pass matching inputType so historyManager doesn't push echo + // when invoked from rerender callback. + model.reset(parts, caret, "historyRedo"); + } + handled = true; + break; + case KeyAction.EditUndo: + if (this.historyManager.canUndo()) { + const {parts, caret} = this.historyManager.undo(this.props.model); + // pass matching inputType so historyManager doesn't push echo + // when invoked from rerender callback. + model.reset(parts, caret, "historyUndo"); + } + handled = true; + break; + case KeyAction.NewLine: + this.insertText("\n"); + handled = true; + break; + case KeyAction.MoveCursorToStart: + setSelection(this.editorRef.current, model, { + index: 0, + offset: 0, + }); + handled = true; + break; + case KeyAction.MoveCursorToEnd: + setSelection(this.editorRef.current, model, { + index: model.parts.length - 1, + offset: model.parts[model.parts.length - 1].text.length, + }); + handled = true; + break; } + if (handled) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + const autocompleteAction = getKeyBindingsManager().getAction(KeyBindingContext.AutoComplete, event); + if (model.autoComplete && model.autoComplete.hasCompletions()) { + const autoComplete = model.autoComplete; + switch (autocompleteAction) { + case KeyAction.AutocompletePrevSelection: + autoComplete.onUpArrow(event); + handled = true; + break; + case KeyAction.AutocompleteNextSelection: + autoComplete.onDownArrow(event); + handled = true; + break; + case KeyAction.AutocompleteApply: + autoComplete.onTab(event); + handled = true; + break; + case KeyAction.AutocompleteCancel: + autoComplete.onEscape(event); + handled = true; + break; + default: + return; // don't preventDefault on anything else + } + } else if (autocompleteAction === KeyAction.AutocompleteApply) { + this.tabCompleteName(event); + handled = true; + } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) { + this.formatBarRef.current.hide(); + } + if (handled) { event.preventDefault(); event.stopPropagation();