input.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. // This file defines a number of helpers for wiring up user input to
  2. // table-related functionality.
  3. import { Fragment, ResolvedPos, Slice } from 'prosemirror-model';
  4. import {
  5. Command,
  6. EditorState,
  7. Selection,
  8. TextSelection,
  9. Transaction,
  10. } from 'prosemirror-state';
  11. import { keydownHandler } from 'prosemirror-keymap';
  12. import {
  13. cellAround,
  14. inSameTable,
  15. isInTable,
  16. tableEditingKey,
  17. nextCell,
  18. selectionCell,
  19. } from './util';
  20. import { CellSelection } from './cellselection';
  21. import { TableMap } from './tablemap';
  22. import { clipCells, fitSlice, insertCells, pastedCells } from './copypaste';
  23. import { tableNodeTypes } from './schema';
  24. import { EditorView } from 'prosemirror-view';
  25. type Axis = 'horiz' | 'vert';
  26. /**
  27. * @public
  28. */
  29. export type Direction = -1 | 1;
  30. export const handleKeyDown = keydownHandler({
  31. ArrowLeft: arrow('horiz', -1),
  32. ArrowRight: arrow('horiz', 1),
  33. ArrowUp: arrow('vert', -1),
  34. ArrowDown: arrow('vert', 1),
  35. 'Shift-ArrowLeft': shiftArrow('horiz', -1),
  36. 'Shift-ArrowRight': shiftArrow('horiz', 1),
  37. 'Shift-ArrowUp': shiftArrow('vert', -1),
  38. 'Shift-ArrowDown': shiftArrow('vert', 1),
  39. Backspace: deleteCellSelection,
  40. 'Mod-Backspace': deleteCellSelection,
  41. Delete: deleteCellSelection,
  42. 'Mod-Delete': deleteCellSelection,
  43. });
  44. function maybeSetSelection(
  45. state: EditorState,
  46. dispatch: undefined | ((tr: Transaction) => void),
  47. selection: Selection,
  48. ): boolean {
  49. if (selection.eq(state.selection)) return false;
  50. if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView());
  51. return true;
  52. }
  53. /**
  54. * @internal
  55. */
  56. export function arrow(axis: Axis, dir: Direction): Command {
  57. return (state, dispatch, view) => {
  58. if (!view) return false;
  59. const sel = state.selection;
  60. if (sel instanceof CellSelection) {
  61. return maybeSetSelection(
  62. state,
  63. dispatch,
  64. Selection.near(sel.$headCell, dir),
  65. );
  66. }
  67. if (axis != 'horiz' && !sel.empty) return false;
  68. const end = atEndOfCell(view, axis, dir);
  69. if (end == null) return false;
  70. if (axis == 'horiz') {
  71. return maybeSetSelection(
  72. state,
  73. dispatch,
  74. Selection.near(state.doc.resolve(sel.head + dir), dir),
  75. );
  76. } else {
  77. const $cell = state.doc.resolve(end);
  78. const $next = nextCell($cell, axis, dir);
  79. let newSel;
  80. if ($next) newSel = Selection.near($next, 1);
  81. else if (dir < 0)
  82. newSel = Selection.near(state.doc.resolve($cell.before(-1)), -1);
  83. else newSel = Selection.near(state.doc.resolve($cell.after(-1)), 1);
  84. return maybeSetSelection(state, dispatch, newSel);
  85. }
  86. };
  87. }
  88. function shiftArrow(axis: Axis, dir: Direction): Command {
  89. return (state, dispatch, view) => {
  90. if (!view) return false;
  91. const sel = state.selection;
  92. let cellSel: CellSelection;
  93. if (sel instanceof CellSelection) {
  94. cellSel = sel;
  95. } else {
  96. const end = atEndOfCell(view, axis, dir);
  97. if (end == null) return false;
  98. cellSel = new CellSelection(state.doc.resolve(end));
  99. }
  100. const $head = nextCell(cellSel.$headCell, axis, dir);
  101. if (!$head) return false;
  102. return maybeSetSelection(
  103. state,
  104. dispatch,
  105. new CellSelection(cellSel.$anchorCell, $head),
  106. );
  107. };
  108. }
  109. function deleteCellSelection(
  110. state: EditorState,
  111. dispatch?: (tr: Transaction) => void,
  112. ): boolean {
  113. const sel = state.selection;
  114. if (!(sel instanceof CellSelection)) return false;
  115. if (dispatch) {
  116. const tr = state.tr;
  117. const baseContent = tableNodeTypes(state.schema).cell.createAndFill()!
  118. .content;
  119. sel.forEachCell((cell, pos) => {
  120. if (!cell.content.eq(baseContent))
  121. tr.replace(
  122. tr.mapping.map(pos + 1),
  123. tr.mapping.map(pos + cell.nodeSize - 1),
  124. new Slice(baseContent, 0, 0),
  125. );
  126. });
  127. if (tr.docChanged) dispatch(tr);
  128. }
  129. return true;
  130. }
  131. export function handleTripleClick(view: EditorView, pos: number): boolean {
  132. const doc = view.state.doc,
  133. $cell = cellAround(doc.resolve(pos));
  134. if (!$cell) return false;
  135. view.dispatch(view.state.tr.setSelection(new CellSelection($cell)));
  136. return true;
  137. }
  138. /**
  139. * @public
  140. */
  141. export function handlePaste(
  142. view: EditorView,
  143. _: ClipboardEvent,
  144. slice: Slice,
  145. ): boolean {
  146. if (!isInTable(view.state)) return false;
  147. let cells = pastedCells(slice);
  148. const sel = view.state.selection;
  149. if (sel instanceof CellSelection) {
  150. if (!cells)
  151. cells = {
  152. width: 1,
  153. height: 1,
  154. rows: [
  155. Fragment.from(
  156. fitSlice(tableNodeTypes(view.state.schema).cell, slice),
  157. ),
  158. ],
  159. };
  160. const table = sel.$anchorCell.node(-1);
  161. const start = sel.$anchorCell.start(-1);
  162. const rect = TableMap.get(table).rectBetween(
  163. sel.$anchorCell.pos - start,
  164. sel.$headCell.pos - start,
  165. );
  166. cells = clipCells(cells, rect.right - rect.left, rect.bottom - rect.top);
  167. insertCells(view.state, view.dispatch, start, rect, cells);
  168. return true;
  169. } else if (cells) {
  170. const $cell = selectionCell(view.state);
  171. const start = $cell.start(-1);
  172. insertCells(
  173. view.state,
  174. view.dispatch,
  175. start,
  176. TableMap.get($cell.node(-1)).findCell($cell.pos - start),
  177. cells,
  178. );
  179. return true;
  180. } else {
  181. return false;
  182. }
  183. }
  184. export function handleMouseDown(
  185. view: EditorView,
  186. startEvent: MouseEvent,
  187. ): void {
  188. if (startEvent.ctrlKey || startEvent.metaKey) return;
  189. const startDOMCell = domInCell(view, startEvent.target as Node);
  190. let $anchor;
  191. if (startEvent.shiftKey && view.state.selection instanceof CellSelection) {
  192. // Adding to an existing cell selection
  193. setCellSelection(view.state.selection.$anchorCell, startEvent);
  194. startEvent.preventDefault();
  195. } else if (
  196. startEvent.shiftKey &&
  197. startDOMCell &&
  198. ($anchor = cellAround(view.state.selection.$anchor)) != null &&
  199. cellUnderMouse(view, startEvent)?.pos != $anchor.pos
  200. ) {
  201. // Adding to a selection that starts in another cell (causing a
  202. // cell selection to be created).
  203. setCellSelection($anchor, startEvent);
  204. startEvent.preventDefault();
  205. } else if (!startDOMCell) {
  206. // Not in a cell, let the default behavior happen.
  207. return;
  208. }
  209. // Create and dispatch a cell selection between the given anchor and
  210. // the position under the mouse.
  211. function setCellSelection($anchor: ResolvedPos, event: MouseEvent): void {
  212. let $head = cellUnderMouse(view, event);
  213. const starting = tableEditingKey.getState(view.state) == null;
  214. if (!$head || !inSameTable($anchor, $head)) {
  215. if (starting) $head = $anchor;
  216. else return;
  217. }
  218. const selection = new CellSelection($anchor, $head);
  219. if (starting || !view.state.selection.eq(selection)) {
  220. const tr = view.state.tr.setSelection(selection);
  221. if (starting) tr.setMeta(tableEditingKey, $anchor.pos);
  222. view.dispatch(tr);
  223. }
  224. }
  225. // Stop listening to mouse motion events.
  226. function stop(): void {
  227. view.root.removeEventListener('mouseup', stop);
  228. view.root.removeEventListener('dragstart', stop);
  229. view.root.removeEventListener('mousemove', move);
  230. if (tableEditingKey.getState(view.state) != null)
  231. view.dispatch(view.state.tr.setMeta(tableEditingKey, -1));
  232. }
  233. function move(_event: Event): void {
  234. const event = _event as MouseEvent;
  235. const anchor = tableEditingKey.getState(view.state);
  236. let $anchor;
  237. if (anchor != null) {
  238. // Continuing an existing cross-cell selection
  239. $anchor = view.state.doc.resolve(anchor);
  240. } else if (domInCell(view, event.target as Node) != startDOMCell) {
  241. // Moving out of the initial cell -- start a new cell selection
  242. $anchor = cellUnderMouse(view, startEvent);
  243. if (!$anchor) return stop();
  244. }
  245. if ($anchor) setCellSelection($anchor, event);
  246. }
  247. view.root.addEventListener('mouseup', stop);
  248. view.root.addEventListener('dragstart', stop);
  249. view.root.addEventListener('mousemove', move);
  250. }
  251. // Check whether the cursor is at the end of a cell (so that further
  252. // motion would move out of the cell)
  253. function atEndOfCell(view: EditorView, axis: Axis, dir: number): null | number {
  254. if (!(view.state.selection instanceof TextSelection)) return null;
  255. const { $head } = view.state.selection;
  256. for (let d = $head.depth - 1; d >= 0; d--) {
  257. const parent = $head.node(d),
  258. index = dir < 0 ? $head.index(d) : $head.indexAfter(d);
  259. if (index != (dir < 0 ? 0 : parent.childCount)) return null;
  260. if (
  261. parent.type.spec.tableRole == 'cell' ||
  262. parent.type.spec.tableRole == 'header_cell'
  263. ) {
  264. const cellPos = $head.before(d);
  265. const dirStr: 'up' | 'down' | 'left' | 'right' =
  266. axis == 'vert' ? (dir > 0 ? 'down' : 'up') : dir > 0 ? 'right' : 'left';
  267. return view.endOfTextblock(dirStr) ? cellPos : null;
  268. }
  269. }
  270. return null;
  271. }
  272. function domInCell(view: EditorView, dom: Node | null): Node | null {
  273. for (; dom && dom != view.dom; dom = dom.parentNode) {
  274. if (dom.nodeName == 'TD' || dom.nodeName == 'TH') {
  275. return dom;
  276. }
  277. }
  278. return null;
  279. }
  280. function cellUnderMouse(
  281. view: EditorView,
  282. event: MouseEvent,
  283. ): ResolvedPos | null {
  284. const mousePos = view.posAtCoords({
  285. left: event.clientX,
  286. top: event.clientY,
  287. });
  288. if (!mousePos) return null;
  289. return mousePos ? cellAround(view.state.doc.resolve(mousePos.pos)) : null;
  290. }