1/* 2 * basekeys.ts 3 * 4 * Copyright (C) 2021 by RStudio, PBC 5 * 6 * Unless you have received this program directly from RStudio pursuant 7 * to the terms of a commercial license agreement with RStudio, then 8 * this program is licensed to you under the terms of version 3 of the 9 * GNU Affero General Public License. This program is distributed WITHOUT 10 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, 11 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the 12 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. 13 * 14 */ 15 16import { 17 splitBlock, 18 liftEmptyBlock, 19 createParagraphNear, 20 selectNodeBackward, 21 joinBackward, 22 deleteSelection, 23 selectNodeForward, 24 joinForward, 25 chainCommands, 26} from 'prosemirror-commands'; 27import { undoInputRule } from 'prosemirror-inputrules'; 28import { keymap } from 'prosemirror-keymap'; 29import { EditorState, Transaction, Plugin, Selection } from 'prosemirror-state'; 30import { EditorView } from 'prosemirror-view'; 31 32import { setTextSelection } from 'prosemirror-utils'; 33 34import { CommandFn } from './command'; 35import { editingRootNodeClosestToPos, editingRootNode } from './node'; 36import { selectionIsBodyTopLevel } from './selection'; 37import { kPlatformMac } from './platform'; 38 39export enum BaseKey { 40 Home = 'Home', 41 End = 'End', 42 Enter = 'Enter', 43 ModEnter = 'Mod-Enter', 44 ShiftEnter = 'Shift-Enter', 45 Backspace = 'Backspace', 46 Delete = 'Delete|Mod-Delete', // Use pipes to register multiple commands 47 Tab = 'Tab', 48 ShiftTab = 'Shift-Tab', 49 ArrowUp = 'Up|ArrowUp', 50 ArrowDown = 'Down|ArrowDown', 51 ArrowLeft = 'Left|ArrowLeft', 52 ArrowRight = 'Right|ArrowRight', 53 ModArrowUp = 'Mod-Up|Mod-ArrowUp', 54 ModArrowDown = 'Mod-Down|Mod-ArrowDown', 55 CtrlHome = 'Ctrl-Home', 56 CtrlEnd = 'Ctrl-End', 57 ShiftArrowLeft = 'Shift-Left|Shift-ArrowLeft', 58 ShiftArrowRight = 'Shift-Right|Shift-ArrowRight', 59 AltArrowLeft = 'Alt-Left|Alt-ArrowLeft', 60 AltArrowRight = 'Alt-Right|Alt-ArrowRight', 61 CtrlArrowLeft = 'Ctrl-Left|Ctrl-ArrowLeft', 62 CtrlArrowRight = 'Ctrl-Right|Ctrl-ArrowRight', 63 CtrlShiftArrowLeft = 'Ctrl-Shift-Left|Ctrl-Shift-ArrowLeft', 64 CtrlShiftArrowRight = 'Ctrl-Shift-Right|Ctrl-Shift-ArrowRight', 65} 66 67export interface BaseKeyBinding { 68 key: BaseKey; 69 command: CommandFn; 70} 71 72export function baseKeysPlugin(keys: readonly BaseKeyBinding[]): Plugin { 73 // collect all keys 74 const pluginKeys = [ 75 // base enter key behaviors 76 { key: BaseKey.Enter, command: splitBlock }, 77 { key: BaseKey.Enter, command: liftEmptyBlock }, 78 { key: BaseKey.Enter, command: createParagraphNear }, 79 80 // base backspace key behaviors 81 { key: BaseKey.Backspace, command: selectNodeBackward }, 82 { key: BaseKey.Backspace, command: joinBackward }, 83 { key: BaseKey.Backspace, command: deleteSelection }, 84 85 // base tab key behavior (ignore) 86 { key: BaseKey.Tab, command: ignoreKey }, 87 { key: BaseKey.ShiftTab, command: ignoreKey }, 88 89 // base delete key behaviors 90 { key: BaseKey.Delete, command: selectNodeForward }, 91 { key: BaseKey.Delete, command: joinForward }, 92 { key: BaseKey.Delete, command: deleteSelection }, 93 94 // base home/end key behaviors (Mac desktop default behavior advances to beginning/end of 95 // document, so we provide our own implementation rather than relying on contentEditable) 96 kPlatformMac ? { key: BaseKey.Home, command: homeKey } : null, 97 kPlatformMac ? { key: BaseKey.End, command: endKey } : null, 98 99 // base arrow key behavior (prevent traversing top-level body notes) 100 { key: BaseKey.ArrowLeft, command: arrowBodyNodeBoundary('left') }, 101 { key: BaseKey.ArrowUp, command: arrowBodyNodeBoundary('up') }, 102 { key: BaseKey.ArrowRight, command: arrowBodyNodeBoundary('right') }, 103 { key: BaseKey.ArrowDown, command: arrowBodyNodeBoundary('down') }, 104 { key: BaseKey.ModArrowDown, command: endTopLevelBodyNodeBoundary() }, 105 { key: BaseKey.CtrlEnd, command: endTopLevelBodyNodeBoundary() }, 106 107 // merge keys provided by extensions 108 ...keys, 109 110 // undoInputRule is always the highest priority backspace key 111 { key: BaseKey.Backspace, command: undoInputRule }, 112 ].filter(x => !!x); 113 114 // build arrays for each BaseKey type 115 const commandMap: { [key: string]: CommandFn[] } = {}; 116 for (const baseKey of Object.values(BaseKey)) { 117 commandMap[baseKey] = []; 118 } 119 pluginKeys.forEach(key => { 120 if (key) { 121 commandMap[key.key].unshift(key.command); 122 } 123 }); 124 125 const bindings: { [key: string]: CommandFn } = {}; 126 for (const baseKey of Object.values(BaseKey)) { 127 const commands = commandMap[baseKey]; 128 // baseKey may contain multiple keys, separated by | 129 for (const subkey of baseKey.split(/\|/)) { 130 bindings[subkey] = chainCommands(...commands); 131 } 132 } 133 134 // return keymap 135 return keymap(bindings); 136} 137 138export function verticalArrowCanAdvanceWithinTextBlock(selection: Selection, dir: 'up' | 'down') { 139 const $head = selection.$head; 140 const node = $head.node(); 141 if (node.isTextblock) { 142 const cursorOffset = $head.parentOffset; 143 const nodeText = node.textContent; 144 if (dir === 'down' && nodeText.substr(cursorOffset).includes('\n')) { 145 return true; 146 } 147 if (dir === 'up' && nodeText.substr(0, cursorOffset).includes('\n')) { 148 return true; 149 } 150 } 151 return false; 152} 153 154interface Coords { 155 left: number; 156 right: number; 157 top: number; 158 bottom: number; 159} 160 161function ignoreKey(state: EditorState, dispatch?: (tr: Transaction) => void) { 162 return true; 163} 164 165function homeKey(state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView) { 166 const selection = state.selection; 167 const editingNode = editingRootNode(selection); 168 if (editingNode && dispatch && view) { 169 const head = view.coordsAtPos(selection.head); 170 const beginDocPos = editingNode.start; 171 for (let pos = selection.from - 1; pos >= beginDocPos; pos--) { 172 const coords = view.coordsAtPos(pos); 173 if (isOnPreviousLine(head, coords) || pos === beginDocPos) { 174 const tr = state.tr; 175 setTextSelection(pos + 1)(tr); 176 dispatch(tr); 177 break; 178 } 179 } 180 } 181 return true; 182} 183 184function endKey(state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView) { 185 const selection = state.selection; 186 const editingNode = editingRootNode(selection); 187 if (editingNode && dispatch && view) { 188 const head = view.coordsAtPos(selection.head); 189 const endDocPos = editingNode.start + editingNode.node.nodeSize; 190 for (let pos = selection.from + 1; pos < endDocPos; pos++) { 191 const coords = view.coordsAtPos(pos); 192 if (isOnNextLine(head, coords) || pos === endDocPos) { 193 const tr = state.tr; 194 setTextSelection(pos - 1)(tr); 195 dispatch(tr); 196 break; 197 } 198 } 199 } 200 return true; 201} 202 203// helpers to check for a y coordinate on a diffent line that the selection 204 205// y coorinates are sometimes off by 1 or 2 due to margin/padding (e.g. for 206// inline code spans or spelling marks) so the comparision only succeeds if 207// the vertical extents of the two coords don't overlap. If this proves to 208// still have false positives, we could lookahead to the next a few dozen 209// positions to check if we ever "return to" the head's baseline--only a 210// permanent change would indicate that the line has truly changed. 211 212function isOnNextLine(head: Coords, pos: Coords) { 213 return head.bottom < pos.top; 214} 215 216function isOnPreviousLine(head: Coords, pos: Coords) { 217 return head.top > pos.bottom; 218} 219 220function arrowBodyNodeBoundary(dir: 'up' | 'down' | 'left' | 'right') { 221 return (state: EditorState, dispatch?: (tr: Transaction<any>) => void, view?: EditorView) => { 222 if (view && view.endOfTextblock(dir) && selectionIsBodyTopLevel(state.selection)) { 223 const side = dir === 'left' || dir === 'up' ? -1 : 1; 224 const $head = state.selection.$head; 225 const nextPos = Selection.near(state.doc.resolve(side > 0 ? $head.after() : $head.before()), side); 226 const currentRootNode = editingRootNodeClosestToPos($head); 227 const nextRootNode = editingRootNodeClosestToPos(nextPos.$head); 228 return currentRootNode?.node?.type !== nextRootNode?.node?.type; 229 } else { 230 return false; 231 } 232 }; 233} 234 235function endTopLevelBodyNodeBoundary() { 236 return (state: EditorState, dispatch?: (tr: Transaction<any>) => void, view?: EditorView) => { 237 const editingNode = editingRootNode(state.selection); 238 if (editingNode && selectionIsBodyTopLevel(state.selection)) { 239 if (dispatch) { 240 const tr = state.tr; 241 setTextSelection(editingNode.pos + editingNode.node.nodeSize - 2)(tr).scrollIntoView(); 242 dispatch(tr); 243 } 244 return true; 245 } else { 246 return false; 247 } 248 }; 249} 250