You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
303 lines
9.0 KiB
303 lines
9.0 KiB
// This file defines a number of helpers for wiring up user input to
|
|
// table-related functionality.
|
|
|
|
import { Fragment, ResolvedPos, Slice } from 'prosemirror-model';
|
|
import {
|
|
Command,
|
|
EditorState,
|
|
Selection,
|
|
TextSelection,
|
|
Transaction,
|
|
} from 'prosemirror-state';
|
|
import { keydownHandler } from 'prosemirror-keymap';
|
|
|
|
import {
|
|
cellAround,
|
|
inSameTable,
|
|
isInTable,
|
|
tableEditingKey,
|
|
nextCell,
|
|
selectionCell,
|
|
} from './util';
|
|
import { CellSelection } from './cellselection';
|
|
import { TableMap } from './tablemap';
|
|
import { clipCells, fitSlice, insertCells, pastedCells } from './copypaste';
|
|
import { tableNodeTypes } from './schema';
|
|
import { EditorView } from 'prosemirror-view';
|
|
|
|
type Axis = 'horiz' | 'vert';
|
|
|
|
/**
|
|
* @public
|
|
*/
|
|
export type Direction = -1 | 1;
|
|
|
|
export const handleKeyDown = keydownHandler({
|
|
ArrowLeft: arrow('horiz', -1),
|
|
ArrowRight: arrow('horiz', 1),
|
|
ArrowUp: arrow('vert', -1),
|
|
ArrowDown: arrow('vert', 1),
|
|
|
|
'Shift-ArrowLeft': shiftArrow('horiz', -1),
|
|
'Shift-ArrowRight': shiftArrow('horiz', 1),
|
|
'Shift-ArrowUp': shiftArrow('vert', -1),
|
|
'Shift-ArrowDown': shiftArrow('vert', 1),
|
|
|
|
Backspace: deleteCellSelection,
|
|
'Mod-Backspace': deleteCellSelection,
|
|
Delete: deleteCellSelection,
|
|
'Mod-Delete': deleteCellSelection,
|
|
});
|
|
|
|
function maybeSetSelection(
|
|
state: EditorState,
|
|
dispatch: undefined | ((tr: Transaction) => void),
|
|
selection: Selection,
|
|
): boolean {
|
|
if (selection.eq(state.selection)) return false;
|
|
if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView());
|
|
return true;
|
|
}
|
|
|
|
function arrow(axis: Axis, dir: Direction): Command {
|
|
return (state, dispatch, view) => {
|
|
const sel = state.selection;
|
|
if (sel instanceof CellSelection) {
|
|
return maybeSetSelection(
|
|
state,
|
|
dispatch,
|
|
Selection.near(sel.$headCell, dir),
|
|
);
|
|
}
|
|
if (axis != 'horiz' && !sel.empty) return false;
|
|
const end = atEndOfCell(view, axis, dir);
|
|
if (end == null) return false;
|
|
if (axis == 'horiz') {
|
|
return maybeSetSelection(
|
|
state,
|
|
dispatch,
|
|
Selection.near(state.doc.resolve(sel.head + dir), dir),
|
|
);
|
|
} else {
|
|
const $cell = state.doc.resolve(end);
|
|
const $next = nextCell($cell, axis, dir);
|
|
let newSel;
|
|
if ($next) newSel = Selection.near($next, 1);
|
|
else if (dir < 0)
|
|
newSel = Selection.near(state.doc.resolve($cell.before(-1)), -1);
|
|
else newSel = Selection.near(state.doc.resolve($cell.after(-1)), 1);
|
|
return maybeSetSelection(state, dispatch, newSel);
|
|
}
|
|
};
|
|
}
|
|
|
|
function shiftArrow(axis: Axis, dir: Direction): Command {
|
|
return (state, dispatch, view) => {
|
|
const sel = state.selection;
|
|
let cellSel: CellSelection;
|
|
if (sel instanceof CellSelection) {
|
|
cellSel = sel;
|
|
} else {
|
|
const end = atEndOfCell(view, axis, dir);
|
|
if (end == null) return false;
|
|
cellSel = new CellSelection(state.doc.resolve(end));
|
|
}
|
|
|
|
const $head = nextCell(cellSel.$headCell, axis, dir);
|
|
if (!$head) return false;
|
|
return maybeSetSelection(
|
|
state,
|
|
dispatch,
|
|
new CellSelection(cellSel.$anchorCell, $head),
|
|
);
|
|
};
|
|
}
|
|
|
|
function deleteCellSelection(
|
|
state: EditorState,
|
|
dispatch?: (tr: Transaction) => void,
|
|
): boolean {
|
|
const sel = state.selection;
|
|
if (!(sel instanceof CellSelection)) return false;
|
|
if (dispatch) {
|
|
const tr = state.tr,
|
|
baseContent = tableNodeTypes(state.schema).cell.createAndFill().content;
|
|
sel.forEachCell((cell, pos) => {
|
|
if (!cell.content.eq(baseContent))
|
|
tr.replace(
|
|
tr.mapping.map(pos + 1),
|
|
tr.mapping.map(pos + cell.nodeSize - 1),
|
|
new Slice(baseContent, 0, 0),
|
|
);
|
|
});
|
|
if (tr.docChanged) dispatch(tr);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function handleTripleClick(view: EditorView, pos: number): boolean {
|
|
const doc = view.state.doc,
|
|
$cell = cellAround(doc.resolve(pos));
|
|
if (!$cell) return false;
|
|
view.dispatch(view.state.tr.setSelection(new CellSelection($cell)));
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @public
|
|
*/
|
|
export function handlePaste(
|
|
view: EditorView,
|
|
_: ClipboardEvent,
|
|
slice: Slice,
|
|
): boolean {
|
|
if (!isInTable(view.state)) return false;
|
|
let cells = pastedCells(slice);
|
|
const sel = view.state.selection;
|
|
if (sel instanceof CellSelection) {
|
|
if (!cells)
|
|
cells = {
|
|
width: 1,
|
|
height: 1,
|
|
rows: [
|
|
Fragment.from(
|
|
fitSlice(tableNodeTypes(view.state.schema).cell, slice),
|
|
),
|
|
],
|
|
};
|
|
const table = sel.$anchorCell.node(-1),
|
|
start = sel.$anchorCell.start(-1);
|
|
const rect = TableMap.get(table).rectBetween(
|
|
sel.$anchorCell.pos - start,
|
|
sel.$headCell.pos - start,
|
|
);
|
|
cells = clipCells(cells, rect.right - rect.left, rect.bottom - rect.top);
|
|
insertCells(view.state, view.dispatch, start, rect, cells);
|
|
return true;
|
|
} else if (cells) {
|
|
const $cell = selectionCell(view.state),
|
|
start = $cell.start(-1);
|
|
insertCells(
|
|
view.state,
|
|
view.dispatch,
|
|
start,
|
|
TableMap.get($cell.node(-1)).findCell($cell.pos - start),
|
|
cells,
|
|
);
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function handleMouseDown(
|
|
view: EditorView,
|
|
startEvent: MouseEvent,
|
|
): void {
|
|
if (startEvent.ctrlKey || startEvent.metaKey) return;
|
|
|
|
const startDOMCell = domInCell(view, startEvent.target as Node);
|
|
let $anchor;
|
|
if (startEvent.shiftKey && view.state.selection instanceof CellSelection) {
|
|
// Adding to an existing cell selection
|
|
setCellSelection(view.state.selection.$anchorCell, startEvent);
|
|
startEvent.preventDefault();
|
|
} else if (
|
|
startEvent.shiftKey &&
|
|
startDOMCell &&
|
|
($anchor = cellAround(view.state.selection.$anchor)) != null &&
|
|
cellUnderMouse(view, startEvent).pos != $anchor.pos
|
|
) {
|
|
// Adding to a selection that starts in another cell (causing a
|
|
// cell selection to be created).
|
|
setCellSelection($anchor, startEvent);
|
|
startEvent.preventDefault();
|
|
} else if (!startDOMCell) {
|
|
// Not in a cell, let the default behavior happen.
|
|
return;
|
|
}
|
|
|
|
// Create and dispatch a cell selection between the given anchor and
|
|
// the position under the mouse.
|
|
function setCellSelection($anchor: ResolvedPos, event: MouseEvent): void {
|
|
let $head = cellUnderMouse(view, event);
|
|
const starting = tableEditingKey.getState(view.state) == null;
|
|
if (!$head || !inSameTable($anchor, $head)) {
|
|
if (starting) $head = $anchor;
|
|
else return;
|
|
}
|
|
const selection = new CellSelection($anchor, $head);
|
|
if (starting || !view.state.selection.eq(selection)) {
|
|
const tr = view.state.tr.setSelection(selection);
|
|
if (starting) tr.setMeta(tableEditingKey, $anchor.pos);
|
|
view.dispatch(tr);
|
|
}
|
|
}
|
|
|
|
// Stop listening to mouse motion events.
|
|
function stop(): void {
|
|
view.root.removeEventListener('mouseup', stop);
|
|
view.root.removeEventListener('dragstart', stop);
|
|
view.root.removeEventListener('mousemove', move);
|
|
if (tableEditingKey.getState(view.state) != null)
|
|
view.dispatch(view.state.tr.setMeta(tableEditingKey, -1));
|
|
}
|
|
|
|
function move(event: MouseEvent): void {
|
|
const anchor = tableEditingKey.getState(view.state);
|
|
let $anchor;
|
|
if (anchor != null) {
|
|
// Continuing an existing cross-cell selection
|
|
$anchor = view.state.doc.resolve(anchor);
|
|
} else if (domInCell(view, event.target as Node) != startDOMCell) {
|
|
// Moving out of the initial cell -- start a new cell selection
|
|
$anchor = cellUnderMouse(view, startEvent);
|
|
if (!$anchor) return stop();
|
|
}
|
|
if ($anchor) setCellSelection($anchor, event);
|
|
}
|
|
|
|
view.root.addEventListener('mouseup', stop);
|
|
view.root.addEventListener('dragstart', stop);
|
|
view.root.addEventListener('mousemove', move);
|
|
}
|
|
|
|
// Check whether the cursor is at the end of a cell (so that further
|
|
// motion would move out of the cell)
|
|
function atEndOfCell(view: EditorView, axis: Axis, dir: number): null | number {
|
|
if (!(view.state.selection instanceof TextSelection)) return null;
|
|
const { $head } = view.state.selection;
|
|
for (let d = $head.depth - 1; d >= 0; d--) {
|
|
const parent = $head.node(d),
|
|
index = dir < 0 ? $head.index(d) : $head.indexAfter(d);
|
|
if (index != (dir < 0 ? 0 : parent.childCount)) return null;
|
|
if (
|
|
parent.type.spec.tableRole == 'cell' ||
|
|
parent.type.spec.tableRole == 'header_cell'
|
|
) {
|
|
const cellPos = $head.before(d);
|
|
const dirStr: 'up' | 'down' | 'left' | 'right' =
|
|
axis == 'vert' ? (dir > 0 ? 'down' : 'up') : dir > 0 ? 'right' : 'left';
|
|
return view.endOfTextblock(dirStr) ? cellPos : null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function domInCell(view: EditorView, dom: Node): Node {
|
|
for (; dom && dom != view.dom; dom = dom.parentNode)
|
|
if (dom.nodeName == 'TD' || dom.nodeName == 'TH') return dom;
|
|
}
|
|
|
|
function cellUnderMouse(
|
|
view: EditorView,
|
|
event: MouseEvent,
|
|
): ResolvedPos | null {
|
|
const mousePos = view.posAtCoords({
|
|
left: event.clientX,
|
|
top: event.clientY,
|
|
});
|
|
if (!mousePos) return null;
|
|
return mousePos ? cellAround(view.state.doc.resolve(mousePos.pos)) : null;
|
|
}
|