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