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.
388 lines
13 KiB
388 lines
13 KiB
// This file defines a ProseMirror selection subclass that models
|
|
// table cell selections. The table plugin needs to be active to wire
|
|
// in the user interaction part of table selections (so that you
|
|
// actually get such selections when you select across cells).
|
|
|
|
import {
|
|
Selection,
|
|
TextSelection,
|
|
NodeSelection,
|
|
SelectionRange,
|
|
} from 'prosemirror-state';
|
|
import { Decoration, DecorationSet } from 'prosemirror-view';
|
|
import { Fragment, Slice } from 'prosemirror-model';
|
|
|
|
import { inSameTable, pointsAtCell, setAttr, removeColSpan } from './util';
|
|
import { TableMap } from './tablemap';
|
|
|
|
// ::- A [`Selection`](http://prosemirror.net/docs/ref/#state.Selection)
|
|
// subclass that represents a cell selection spanning part of a table.
|
|
// With the plugin enabled, these will be created when the user
|
|
// selects across cells, and will be drawn by giving selected cells a
|
|
// `selectedCell` CSS class.
|
|
export class CellSelection extends Selection {
|
|
// :: (ResolvedPos, ?ResolvedPos)
|
|
// A table selection is identified by its anchor and head cells. The
|
|
// positions given to this constructor should point _before_ two
|
|
// cells in the same table. They may be the same, to select a single
|
|
// cell.
|
|
constructor($anchorCell, $headCell = $anchorCell) {
|
|
let table = $anchorCell.node(-1),
|
|
map = TableMap.get(table),
|
|
start = $anchorCell.start(-1);
|
|
let rect = map.rectBetween($anchorCell.pos - start, $headCell.pos - start);
|
|
let doc = $anchorCell.node(0);
|
|
let cells = map.cellsInRect(rect).filter((p) => p != $headCell.pos - start);
|
|
// Make the head cell the first range, so that it counts as the
|
|
// primary part of the selection
|
|
cells.unshift($headCell.pos - start);
|
|
let ranges = cells.map((pos) => {
|
|
let cell = table.nodeAt(pos),
|
|
from = pos + start + 1;
|
|
return new SelectionRange(
|
|
doc.resolve(from),
|
|
doc.resolve(from + cell.content.size),
|
|
);
|
|
});
|
|
super(ranges[0].$from, ranges[0].$to, ranges);
|
|
// :: ResolvedPos
|
|
// A resolved position pointing _in front of_ the anchor cell (the one
|
|
// that doesn't move when extending the selection).
|
|
this.$anchorCell = $anchorCell;
|
|
// :: ResolvedPos
|
|
// A resolved position pointing in front of the head cell (the one
|
|
// moves when extending the selection).
|
|
this.$headCell = $headCell;
|
|
}
|
|
|
|
map(doc, mapping) {
|
|
let $anchorCell = doc.resolve(mapping.map(this.$anchorCell.pos));
|
|
let $headCell = doc.resolve(mapping.map(this.$headCell.pos));
|
|
if (
|
|
pointsAtCell($anchorCell) &&
|
|
pointsAtCell($headCell) &&
|
|
inSameTable($anchorCell, $headCell)
|
|
) {
|
|
let tableChanged = this.$anchorCell.node(-1) != $anchorCell.node(-1);
|
|
if (tableChanged && this.isRowSelection())
|
|
return CellSelection.rowSelection($anchorCell, $headCell);
|
|
else if (tableChanged && this.isColSelection())
|
|
return CellSelection.colSelection($anchorCell, $headCell);
|
|
else return new CellSelection($anchorCell, $headCell);
|
|
}
|
|
return TextSelection.between($anchorCell, $headCell);
|
|
}
|
|
|
|
// :: () → Slice
|
|
// Returns a rectangular slice of table rows containing the selected
|
|
// cells.
|
|
content() {
|
|
let table = this.$anchorCell.node(-1),
|
|
map = TableMap.get(table),
|
|
start = this.$anchorCell.start(-1);
|
|
let rect = map.rectBetween(
|
|
this.$anchorCell.pos - start,
|
|
this.$headCell.pos - start,
|
|
);
|
|
let seen = {},
|
|
rows = [];
|
|
for (let row = rect.top; row < rect.bottom; row++) {
|
|
let rowContent = [];
|
|
for (
|
|
let index = row * map.width + rect.left, col = rect.left;
|
|
col < rect.right;
|
|
col++, index++
|
|
) {
|
|
let pos = map.map[index];
|
|
if (!seen[pos]) {
|
|
seen[pos] = true;
|
|
let cellRect = map.findCell(pos),
|
|
cell = table.nodeAt(pos);
|
|
let extraLeft = rect.left - cellRect.left,
|
|
extraRight = cellRect.right - rect.right;
|
|
if (extraLeft > 0 || extraRight > 0) {
|
|
let attrs = cell.attrs;
|
|
if (extraLeft > 0) attrs = removeColSpan(attrs, 0, extraLeft);
|
|
if (extraRight > 0)
|
|
attrs = removeColSpan(
|
|
attrs,
|
|
attrs.colspan - extraRight,
|
|
extraRight,
|
|
);
|
|
if (cellRect.left < rect.left)
|
|
cell = cell.type.createAndFill(attrs);
|
|
else cell = cell.type.create(attrs, cell.content);
|
|
}
|
|
if (cellRect.top < rect.top || cellRect.bottom > rect.bottom) {
|
|
let attrs = setAttr(
|
|
cell.attrs,
|
|
'rowspan',
|
|
Math.min(cellRect.bottom, rect.bottom) -
|
|
Math.max(cellRect.top, rect.top),
|
|
);
|
|
if (cellRect.top < rect.top) cell = cell.type.createAndFill(attrs);
|
|
else cell = cell.type.create(attrs, cell.content);
|
|
}
|
|
rowContent.push(cell);
|
|
}
|
|
}
|
|
rows.push(table.child(row).copy(Fragment.from(rowContent)));
|
|
}
|
|
|
|
const fragment =
|
|
this.isColSelection() && this.isRowSelection() ? table : rows;
|
|
return new Slice(Fragment.from(fragment), 1, 1);
|
|
}
|
|
|
|
replace(tr, content = Slice.empty) {
|
|
let mapFrom = tr.steps.length,
|
|
ranges = this.ranges;
|
|
for (let i = 0; i < ranges.length; i++) {
|
|
let { $from, $to } = ranges[i],
|
|
mapping = tr.mapping.slice(mapFrom);
|
|
tr.replace(
|
|
mapping.map($from.pos),
|
|
mapping.map($to.pos),
|
|
i ? Slice.empty : content,
|
|
);
|
|
}
|
|
let sel = Selection.findFrom(
|
|
tr.doc.resolve(tr.mapping.slice(mapFrom).map(this.to)),
|
|
-1,
|
|
);
|
|
if (sel) tr.setSelection(sel);
|
|
}
|
|
|
|
replaceWith(tr, node) {
|
|
this.replace(tr, new Slice(Fragment.from(node), 0, 0));
|
|
}
|
|
|
|
forEachCell(f) {
|
|
let table = this.$anchorCell.node(-1),
|
|
map = TableMap.get(table),
|
|
start = this.$anchorCell.start(-1);
|
|
let cells = map.cellsInRect(
|
|
map.rectBetween(this.$anchorCell.pos - start, this.$headCell.pos - start),
|
|
);
|
|
for (let i = 0; i < cells.length; i++)
|
|
f(table.nodeAt(cells[i]), start + cells[i]);
|
|
}
|
|
|
|
// :: () → bool
|
|
// True if this selection goes all the way from the top to the
|
|
// bottom of the table.
|
|
isColSelection() {
|
|
let anchorTop = this.$anchorCell.index(-1),
|
|
headTop = this.$headCell.index(-1);
|
|
if (Math.min(anchorTop, headTop) > 0) return false;
|
|
let anchorBot = anchorTop + this.$anchorCell.nodeAfter.attrs.rowspan,
|
|
headBot = headTop + this.$headCell.nodeAfter.attrs.rowspan;
|
|
return Math.max(anchorBot, headBot) == this.$headCell.node(-1).childCount;
|
|
}
|
|
|
|
// :: (ResolvedPos, ?ResolvedPos) → CellSelection
|
|
// Returns the smallest column selection that covers the given anchor
|
|
// and head cell.
|
|
static colSelection($anchorCell, $headCell = $anchorCell) {
|
|
let map = TableMap.get($anchorCell.node(-1)),
|
|
start = $anchorCell.start(-1);
|
|
let anchorRect = map.findCell($anchorCell.pos - start),
|
|
headRect = map.findCell($headCell.pos - start);
|
|
let doc = $anchorCell.node(0);
|
|
if (anchorRect.top <= headRect.top) {
|
|
if (anchorRect.top > 0)
|
|
$anchorCell = doc.resolve(start + map.map[anchorRect.left]);
|
|
if (headRect.bottom < map.height)
|
|
$headCell = doc.resolve(
|
|
start + map.map[map.width * (map.height - 1) + headRect.right - 1],
|
|
);
|
|
} else {
|
|
if (headRect.top > 0)
|
|
$headCell = doc.resolve(start + map.map[headRect.left]);
|
|
if (anchorRect.bottom < map.height)
|
|
$anchorCell = doc.resolve(
|
|
start + map.map[map.width * (map.height - 1) + anchorRect.right - 1],
|
|
);
|
|
}
|
|
return new CellSelection($anchorCell, $headCell);
|
|
}
|
|
|
|
// :: () → bool
|
|
// True if this selection goes all the way from the left to the
|
|
// right of the table.
|
|
isRowSelection() {
|
|
let map = TableMap.get(this.$anchorCell.node(-1)),
|
|
start = this.$anchorCell.start(-1);
|
|
let anchorLeft = map.colCount(this.$anchorCell.pos - start),
|
|
headLeft = map.colCount(this.$headCell.pos - start);
|
|
if (Math.min(anchorLeft, headLeft) > 0) return false;
|
|
let anchorRight = anchorLeft + this.$anchorCell.nodeAfter.attrs.colspan,
|
|
headRight = headLeft + this.$headCell.nodeAfter.attrs.colspan;
|
|
return Math.max(anchorRight, headRight) == map.width;
|
|
}
|
|
|
|
eq(other) {
|
|
return (
|
|
other instanceof CellSelection &&
|
|
other.$anchorCell.pos == this.$anchorCell.pos &&
|
|
other.$headCell.pos == this.$headCell.pos
|
|
);
|
|
}
|
|
|
|
// :: (ResolvedPos, ?ResolvedPos) → CellSelection
|
|
// Returns the smallest row selection that covers the given anchor
|
|
// and head cell.
|
|
static rowSelection($anchorCell, $headCell = $anchorCell) {
|
|
let map = TableMap.get($anchorCell.node(-1)),
|
|
start = $anchorCell.start(-1);
|
|
let anchorRect = map.findCell($anchorCell.pos - start),
|
|
headRect = map.findCell($headCell.pos - start);
|
|
let doc = $anchorCell.node(0);
|
|
if (anchorRect.left <= headRect.left) {
|
|
if (anchorRect.left > 0)
|
|
$anchorCell = doc.resolve(start + map.map[anchorRect.top * map.width]);
|
|
if (headRect.right < map.width)
|
|
$headCell = doc.resolve(
|
|
start + map.map[map.width * (headRect.top + 1) - 1],
|
|
);
|
|
} else {
|
|
if (headRect.left > 0)
|
|
$headCell = doc.resolve(start + map.map[headRect.top * map.width]);
|
|
if (anchorRect.right < map.width)
|
|
$anchorCell = doc.resolve(
|
|
start + map.map[map.width * (anchorRect.top + 1) - 1],
|
|
);
|
|
}
|
|
return new CellSelection($anchorCell, $headCell);
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
type: 'cell',
|
|
anchor: this.$anchorCell.pos,
|
|
head: this.$headCell.pos,
|
|
};
|
|
}
|
|
|
|
static fromJSON(doc, json) {
|
|
return new CellSelection(doc.resolve(json.anchor), doc.resolve(json.head));
|
|
}
|
|
|
|
// :: (Node, number, ?number) → CellSelection
|
|
static create(doc, anchorCell, headCell = anchorCell) {
|
|
return new CellSelection(doc.resolve(anchorCell), doc.resolve(headCell));
|
|
}
|
|
|
|
getBookmark() {
|
|
return new CellBookmark(this.$anchorCell.pos, this.$headCell.pos);
|
|
}
|
|
}
|
|
|
|
CellSelection.prototype.visible = false;
|
|
|
|
Selection.jsonID('cell', CellSelection);
|
|
|
|
class CellBookmark {
|
|
constructor(anchor, head) {
|
|
this.anchor = anchor;
|
|
this.head = head;
|
|
}
|
|
map(mapping) {
|
|
return new CellBookmark(mapping.map(this.anchor), mapping.map(this.head));
|
|
}
|
|
resolve(doc) {
|
|
let $anchorCell = doc.resolve(this.anchor),
|
|
$headCell = doc.resolve(this.head);
|
|
if (
|
|
$anchorCell.parent.type.spec.tableRole == 'row' &&
|
|
$headCell.parent.type.spec.tableRole == 'row' &&
|
|
$anchorCell.index() < $anchorCell.parent.childCount &&
|
|
$headCell.index() < $headCell.parent.childCount &&
|
|
inSameTable($anchorCell, $headCell)
|
|
)
|
|
return new CellSelection($anchorCell, $headCell);
|
|
else return Selection.near($headCell, 1);
|
|
}
|
|
}
|
|
|
|
export function drawCellSelection(state) {
|
|
if (!(state.selection instanceof CellSelection)) return null;
|
|
let cells = [];
|
|
state.selection.forEachCell((node, pos) => {
|
|
cells.push(
|
|
Decoration.node(pos, pos + node.nodeSize, { class: 'selectedCell' }),
|
|
);
|
|
});
|
|
return DecorationSet.create(state.doc, cells);
|
|
}
|
|
|
|
function isCellBoundarySelection({ $from, $to }) {
|
|
if ($from.pos == $to.pos || $from.pos < $from.pos - 6) return false; // Cheap elimination
|
|
let afterFrom = $from.pos,
|
|
beforeTo = $to.pos,
|
|
depth = $from.depth;
|
|
for (; depth >= 0; depth--, afterFrom++)
|
|
if ($from.after(depth + 1) < $from.end(depth)) break;
|
|
for (let d = $to.depth; d >= 0; d--, beforeTo--)
|
|
if ($to.before(d + 1) > $to.start(d)) break;
|
|
return (
|
|
afterFrom == beforeTo &&
|
|
/row|table/.test($from.node(depth).type.spec.tableRole)
|
|
);
|
|
}
|
|
|
|
function isTextSelectionAcrossCells({ $from, $to }) {
|
|
let fromCellBoundaryNode;
|
|
let toCellBoundaryNode;
|
|
|
|
for (let i = $from.depth; i > 0; i--) {
|
|
let node = $from.node(i);
|
|
if (
|
|
node.type.spec.tableRole === 'cell' ||
|
|
node.type.spec.tableRole === 'header_cell'
|
|
) {
|
|
fromCellBoundaryNode = node;
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (let i = $to.depth; i > 0; i--) {
|
|
let node = $to.node(i);
|
|
if (
|
|
node.type.spec.tableRole === 'cell' ||
|
|
node.type.spec.tableRole === 'header_cell'
|
|
) {
|
|
toCellBoundaryNode = node;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return fromCellBoundaryNode !== toCellBoundaryNode && $to.parentOffset === 0;
|
|
}
|
|
|
|
export function normalizeSelection(state, tr, allowTableNodeSelection) {
|
|
let sel = (tr || state).selection,
|
|
doc = (tr || state).doc,
|
|
normalize,
|
|
role;
|
|
if (sel instanceof NodeSelection && (role = sel.node.type.spec.tableRole)) {
|
|
if (role == 'cell' || role == 'header_cell') {
|
|
normalize = CellSelection.create(doc, sel.from);
|
|
} else if (role == 'row') {
|
|
let $cell = doc.resolve(sel.from + 1);
|
|
normalize = CellSelection.rowSelection($cell, $cell);
|
|
} else if (!allowTableNodeSelection) {
|
|
let map = TableMap.get(sel.node),
|
|
start = sel.from + 1;
|
|
let lastCell = start + map.map[map.width * map.height - 1];
|
|
normalize = CellSelection.create(doc, start + 1, lastCell);
|
|
}
|
|
} else if (sel instanceof TextSelection && isCellBoundarySelection(sel)) {
|
|
normalize = TextSelection.create(doc, sel.from);
|
|
} else if (sel instanceof TextSelection && isTextSelectionAcrossCells(sel)) {
|
|
normalize = TextSelection.create(doc, sel.$from.start(), sel.$from.end());
|
|
}
|
|
if (normalize) (tr || (tr = state.tr)).setSelection(normalize);
|
|
return tr;
|
|
}
|