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.
420 lines
14 KiB
420 lines
14 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 {
|
|
EditorState,
|
|
NodeSelection,
|
|
Selection,
|
|
SelectionRange,
|
|
TextSelection,
|
|
Transaction,
|
|
} from 'prosemirror-state';
|
|
import { Decoration, DecorationSet, DecorationSource } from 'prosemirror-view';
|
|
import { Fragment, Node, ResolvedPos, Slice } from 'prosemirror-model';
|
|
|
|
import { inSameTable, pointsAtCell, removeColSpan, _setAttr } from './util';
|
|
import { TableMap } from './tablemap';
|
|
import { Mappable } from 'prosemirror-transform';
|
|
|
|
/**
|
|
* @public
|
|
*/
|
|
export interface CellSelectionJSON {
|
|
type: string;
|
|
anchor: number;
|
|
head: number;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @public
|
|
*/
|
|
export class CellSelection extends Selection {
|
|
// A resolved position pointing _in front of_ the anchor cell (the one
|
|
// that doesn't move when extending the selection).
|
|
public $anchorCell: ResolvedPos;
|
|
|
|
// A resolved position pointing in front of the head cell (the one
|
|
// moves when extending the selection).
|
|
public $headCell: 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: ResolvedPos, $headCell: ResolvedPos = $anchorCell) {
|
|
const table = $anchorCell.node(-1),
|
|
map = TableMap.get(table),
|
|
start = $anchorCell.start(-1);
|
|
const rect = map.rectBetween(
|
|
$anchorCell.pos - start,
|
|
$headCell.pos - start,
|
|
);
|
|
const doc = $anchorCell.node(0);
|
|
const 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);
|
|
const ranges = cells.map((pos) => {
|
|
const 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);
|
|
this.$anchorCell = $anchorCell;
|
|
this.$headCell = $headCell;
|
|
}
|
|
|
|
public map(doc: Node, mapping: Mappable): CellSelection | Selection {
|
|
const $anchorCell = doc.resolve(mapping.map(this.$anchorCell.pos));
|
|
const $headCell = doc.resolve(mapping.map(this.$headCell.pos));
|
|
if (
|
|
pointsAtCell($anchorCell) &&
|
|
pointsAtCell($headCell) &&
|
|
inSameTable($anchorCell, $headCell)
|
|
) {
|
|
const 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);
|
|
}
|
|
|
|
// Returns a rectangular slice of table rows containing the selected
|
|
// cells.
|
|
public content(): Slice {
|
|
const table = this.$anchorCell.node(-1),
|
|
map = TableMap.get(table),
|
|
start = this.$anchorCell.start(-1);
|
|
const rect = map.rectBetween(
|
|
this.$anchorCell.pos - start,
|
|
this.$headCell.pos - start,
|
|
);
|
|
const seen = {},
|
|
rows = [];
|
|
for (let row = rect.top; row < rect.bottom; row++) {
|
|
const rowContent = [];
|
|
for (
|
|
let index = row * map.width + rect.left, col = rect.left;
|
|
col < rect.right;
|
|
col++, index++
|
|
) {
|
|
const pos = map.map[index];
|
|
if (!seen[pos]) {
|
|
seen[pos] = true;
|
|
const cellRect = map.findCell(pos);
|
|
let cell = table.nodeAt(pos);
|
|
const 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) {
|
|
const 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);
|
|
}
|
|
|
|
public replace(tr: Transaction, content: Slice = Slice.empty): void {
|
|
const mapFrom = tr.steps.length,
|
|
ranges = this.ranges;
|
|
for (let i = 0; i < ranges.length; i++) {
|
|
const { $from, $to } = ranges[i],
|
|
mapping = tr.mapping.slice(mapFrom);
|
|
tr.replace(
|
|
mapping.map($from.pos),
|
|
mapping.map($to.pos),
|
|
i ? Slice.empty : content,
|
|
);
|
|
}
|
|
const sel = Selection.findFrom(
|
|
tr.doc.resolve(tr.mapping.slice(mapFrom).map(this.to)),
|
|
-1,
|
|
);
|
|
if (sel) tr.setSelection(sel);
|
|
}
|
|
|
|
public replaceWith(tr: Transaction, node: Node): void {
|
|
this.replace(tr, new Slice(Fragment.from(node), 0, 0));
|
|
}
|
|
|
|
public forEachCell(f: (node: Node, pos: number) => void): void {
|
|
const table = this.$anchorCell.node(-1),
|
|
map = TableMap.get(table),
|
|
start = this.$anchorCell.start(-1);
|
|
const 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]);
|
|
}
|
|
|
|
// True if this selection goes all the way from the top to the
|
|
// bottom of the table.
|
|
public isColSelection(): boolean {
|
|
const anchorTop = this.$anchorCell.index(-1),
|
|
headTop = this.$headCell.index(-1);
|
|
if (Math.min(anchorTop, headTop) > 0) return false;
|
|
const anchorBot = anchorTop + this.$anchorCell.nodeAfter.attrs.rowspan,
|
|
headBot = headTop + this.$headCell.nodeAfter.attrs.rowspan;
|
|
return Math.max(anchorBot, headBot) == this.$headCell.node(-1).childCount;
|
|
}
|
|
|
|
// Returns the smallest column selection that covers the given anchor
|
|
// and head cell.
|
|
public static colSelection(
|
|
$anchorCell: ResolvedPos,
|
|
$headCell: ResolvedPos | null = $anchorCell,
|
|
): CellSelection {
|
|
const map = TableMap.get($anchorCell.node(-1)),
|
|
start = $anchorCell.start(-1);
|
|
const anchorRect = map.findCell($anchorCell.pos - start),
|
|
headRect = map.findCell($headCell.pos - start);
|
|
const 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);
|
|
}
|
|
|
|
// True if this selection goes all the way from the left to the
|
|
// right of the table.
|
|
public isRowSelection(): boolean {
|
|
const map = TableMap.get(this.$anchorCell.node(-1)),
|
|
start = this.$anchorCell.start(-1);
|
|
const anchorLeft = map.colCount(this.$anchorCell.pos - start),
|
|
headLeft = map.colCount(this.$headCell.pos - start);
|
|
if (Math.min(anchorLeft, headLeft) > 0) return false;
|
|
const anchorRight = anchorLeft + this.$anchorCell.nodeAfter.attrs.colspan,
|
|
headRight = headLeft + this.$headCell.nodeAfter.attrs.colspan;
|
|
return Math.max(anchorRight, headRight) == map.width;
|
|
}
|
|
|
|
public eq(other: unknown): boolean {
|
|
return (
|
|
other instanceof CellSelection &&
|
|
other.$anchorCell.pos == this.$anchorCell.pos &&
|
|
other.$headCell.pos == this.$headCell.pos
|
|
);
|
|
}
|
|
|
|
// Returns the smallest row selection that covers the given anchor
|
|
// and head cell.
|
|
public static rowSelection(
|
|
$anchorCell: ResolvedPos,
|
|
$headCell: ResolvedPos | null = $anchorCell,
|
|
): CellSelection {
|
|
const map = TableMap.get($anchorCell.node(-1)),
|
|
start = $anchorCell.start(-1);
|
|
const anchorRect = map.findCell($anchorCell.pos - start),
|
|
headRect = map.findCell($headCell.pos - start);
|
|
const 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);
|
|
}
|
|
|
|
public toJSON(): CellSelectionJSON {
|
|
return {
|
|
type: 'cell',
|
|
anchor: this.$anchorCell.pos,
|
|
head: this.$headCell.pos,
|
|
};
|
|
}
|
|
|
|
static fromJSON(doc: Node, json: CellSelectionJSON): CellSelection {
|
|
return new CellSelection(doc.resolve(json.anchor), doc.resolve(json.head));
|
|
}
|
|
|
|
static create(
|
|
doc: Node,
|
|
anchorCell: number,
|
|
headCell: number | null = anchorCell,
|
|
): CellSelection {
|
|
return new CellSelection(doc.resolve(anchorCell), doc.resolve(headCell));
|
|
}
|
|
|
|
getBookmark(): CellBookmark {
|
|
return new CellBookmark(this.$anchorCell.pos, this.$headCell.pos);
|
|
}
|
|
}
|
|
|
|
CellSelection.prototype.visible = false;
|
|
|
|
Selection.jsonID('cell', CellSelection);
|
|
|
|
/**
|
|
* @public
|
|
*/
|
|
export class CellBookmark {
|
|
constructor(public anchor: number, public head: number) {}
|
|
|
|
map(mapping: Mappable): CellBookmark {
|
|
return new CellBookmark(mapping.map(this.anchor), mapping.map(this.head));
|
|
}
|
|
|
|
resolve(doc: Node): CellSelection | Selection {
|
|
const $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: EditorState): DecorationSource {
|
|
if (!(state.selection instanceof CellSelection)) return null;
|
|
const 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 }: TextSelection) {
|
|
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 }: TextSelection) {
|
|
let fromCellBoundaryNode;
|
|
let toCellBoundaryNode;
|
|
|
|
for (let i = $from.depth; i > 0; i--) {
|
|
const 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--) {
|
|
const 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: EditorState,
|
|
tr: Transaction | undefined,
|
|
allowTableNodeSelection: boolean,
|
|
): Transaction {
|
|
const sel = (tr || state).selection;
|
|
const doc = (tr || state).doc;
|
|
let normalize;
|
|
let 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') {
|
|
const $cell = doc.resolve(sel.from + 1);
|
|
normalize = CellSelection.rowSelection($cell, $cell);
|
|
} else if (!allowTableNodeSelection) {
|
|
const map = TableMap.get(sel.node),
|
|
start = sel.from + 1;
|
|
const 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;
|
|
}
|