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.
373 lines
11 KiB
373 lines
11 KiB
// Utilities used for copy/paste handling.
|
|
//
|
|
// This module handles pasting cell content into tables, or pasting
|
|
// anything into a cell selection, as replacing a block of cells with
|
|
// the content of the selection. When pasting cells into a cell, that
|
|
// involves placing the block of pasted content so that its top left
|
|
// aligns with the selection cell, optionally extending the table to
|
|
// the right or bottom to make sure it is large enough. Pasting into a
|
|
// cell selection is different, here the cells in the selection are
|
|
// clipped to the selection's rectangle, optionally repeating the
|
|
// pasted cells when they are smaller than the selection.
|
|
|
|
import { Fragment, Node, NodeType, Schema, Slice } from 'prosemirror-model';
|
|
import { Transform } from 'prosemirror-transform';
|
|
|
|
import { removeColSpan, _setAttr } from './util';
|
|
import { Rect, TableMap } from './tablemap';
|
|
import { CellSelection } from './cellselection';
|
|
import { tableNodeTypes } from './schema';
|
|
import { EditorState, Transaction } from 'prosemirror-state';
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
export type Area = { width: number; height: number; rows: Fragment[] };
|
|
|
|
// Utilities to help with copying and pasting table cells
|
|
|
|
/**
|
|
* Get a rectangular area of cells from a slice, or null if the outer
|
|
* nodes of the slice aren't table cells or rows.
|
|
*
|
|
* @internal
|
|
*/
|
|
export function pastedCells(slice: Slice): Area | undefined {
|
|
if (!slice.size) return null;
|
|
let { content, openStart, openEnd } = slice;
|
|
while (
|
|
content.childCount == 1 &&
|
|
((openStart > 0 && openEnd > 0) ||
|
|
content.firstChild.type.spec.tableRole == 'table')
|
|
) {
|
|
openStart--;
|
|
openEnd--;
|
|
content = content.firstChild.content;
|
|
}
|
|
const first = content.firstChild,
|
|
role = first.type.spec.tableRole;
|
|
const schema = first.type.schema,
|
|
rows = [];
|
|
if (role == 'row') {
|
|
for (let i = 0; i < content.childCount; i++) {
|
|
let cells = content.child(i).content;
|
|
const left = i ? 0 : Math.max(0, openStart - 1);
|
|
const right = i < content.childCount - 1 ? 0 : Math.max(0, openEnd - 1);
|
|
if (left || right)
|
|
cells = fitSlice(
|
|
tableNodeTypes(schema).row,
|
|
new Slice(cells, left, right),
|
|
).content;
|
|
rows.push(cells);
|
|
}
|
|
} else if (role == 'cell' || role == 'header_cell') {
|
|
rows.push(
|
|
openStart || openEnd
|
|
? fitSlice(
|
|
tableNodeTypes(schema).row,
|
|
new Slice(content, openStart, openEnd),
|
|
).content
|
|
: content,
|
|
);
|
|
} else {
|
|
return null;
|
|
}
|
|
return ensureRectangular(schema, rows);
|
|
}
|
|
|
|
// Compute the width and height of a set of cells, and make sure each
|
|
// row has the same number of cells.
|
|
function ensureRectangular(schema: Schema, rows: Fragment[]): Area {
|
|
const widths = [];
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = rows[i];
|
|
for (let j = row.childCount - 1; j >= 0; j--) {
|
|
const { rowspan, colspan } = row.child(j).attrs;
|
|
for (let r = i; r < i + rowspan; r++)
|
|
widths[r] = (widths[r] || 0) + colspan;
|
|
}
|
|
}
|
|
let width = 0;
|
|
for (let r = 0; r < widths.length; r++) width = Math.max(width, widths[r]);
|
|
for (let r = 0; r < widths.length; r++) {
|
|
if (r >= rows.length) rows.push(Fragment.empty);
|
|
if (widths[r] < width) {
|
|
const empty = tableNodeTypes(schema).cell.createAndFill(),
|
|
cells = [];
|
|
for (let i = widths[r]; i < width; i++) cells.push(empty);
|
|
rows[r] = rows[r].append(Fragment.from(cells));
|
|
}
|
|
}
|
|
return { height: rows.length, width, rows };
|
|
}
|
|
|
|
export function fitSlice(nodeType: NodeType, slice: Slice): Node {
|
|
const node = nodeType.createAndFill();
|
|
const tr = new Transform(node).replace(0, node.content.size, slice);
|
|
return tr.doc;
|
|
}
|
|
|
|
/**
|
|
* Clip or extend (repeat) the given set of cells to cover the given
|
|
* width and height. Will clip rowspan/colspan cells at the edges when
|
|
* they stick out.
|
|
*
|
|
* @internal
|
|
*/
|
|
export function clipCells(
|
|
{ width, height, rows }: Area,
|
|
newWidth: number,
|
|
newHeight: number,
|
|
): Area {
|
|
if (width != newWidth) {
|
|
const added = [],
|
|
newRows = [];
|
|
for (let row = 0; row < rows.length; row++) {
|
|
const frag = rows[row],
|
|
cells = [];
|
|
for (let col = added[row] || 0, i = 0; col < newWidth; i++) {
|
|
let cell = frag.child(i % frag.childCount);
|
|
if (col + cell.attrs.colspan > newWidth)
|
|
cell = cell.type.create(
|
|
removeColSpan(
|
|
cell.attrs,
|
|
cell.attrs.colspan,
|
|
col + cell.attrs.colspan - newWidth,
|
|
),
|
|
cell.content,
|
|
);
|
|
cells.push(cell);
|
|
col += cell.attrs.colspan;
|
|
for (let j = 1; j < cell.attrs.rowspan; j++)
|
|
added[row + j] = (added[row + j] || 0) + cell.attrs.colspan;
|
|
}
|
|
newRows.push(Fragment.from(cells));
|
|
}
|
|
rows = newRows;
|
|
width = newWidth;
|
|
}
|
|
|
|
if (height != newHeight) {
|
|
const newRows = [];
|
|
for (let row = 0, i = 0; row < newHeight; row++, i++) {
|
|
const cells = [],
|
|
source = rows[i % height];
|
|
for (let j = 0; j < source.childCount; j++) {
|
|
let cell = source.child(j);
|
|
if (row + cell.attrs.rowspan > newHeight)
|
|
cell = cell.type.create(
|
|
_setAttr(
|
|
cell.attrs,
|
|
'rowspan',
|
|
Math.max(1, newHeight - cell.attrs.rowspan),
|
|
),
|
|
cell.content,
|
|
);
|
|
cells.push(cell);
|
|
}
|
|
newRows.push(Fragment.from(cells));
|
|
}
|
|
rows = newRows;
|
|
height = newHeight;
|
|
}
|
|
|
|
return { width, height, rows };
|
|
}
|
|
|
|
// Make sure a table has at least the given width and height. Return
|
|
// true if something was changed.
|
|
function growTable(
|
|
tr: Transaction,
|
|
map: TableMap,
|
|
table: Node,
|
|
start: number,
|
|
width: number,
|
|
height: number,
|
|
mapFrom: number,
|
|
): boolean {
|
|
const schema = tr.doc.type.schema;
|
|
const types = tableNodeTypes(schema);
|
|
let empty;
|
|
let emptyHead;
|
|
if (width > map.width) {
|
|
for (let row = 0, rowEnd = 0; row < map.height; row++) {
|
|
const rowNode = table.child(row);
|
|
rowEnd += rowNode.nodeSize;
|
|
const cells = [];
|
|
let add;
|
|
if (rowNode.lastChild == null || rowNode.lastChild.type == types.cell)
|
|
add = empty || (empty = types.cell.createAndFill());
|
|
else add = emptyHead || (emptyHead = types.header_cell.createAndFill());
|
|
for (let i = map.width; i < width; i++) cells.push(add);
|
|
tr.insert(tr.mapping.slice(mapFrom).map(rowEnd - 1 + start), cells);
|
|
}
|
|
}
|
|
if (height > map.height) {
|
|
const cells = [];
|
|
for (
|
|
let i = 0, start = (map.height - 1) * map.width;
|
|
i < Math.max(map.width, width);
|
|
i++
|
|
) {
|
|
const header =
|
|
i >= map.width
|
|
? false
|
|
: table.nodeAt(map.map[start + i]).type == types.header_cell;
|
|
cells.push(
|
|
header
|
|
? emptyHead || (emptyHead = types.header_cell.createAndFill())
|
|
: empty || (empty = types.cell.createAndFill()),
|
|
);
|
|
}
|
|
|
|
const emptyRow = types.row.create(null, Fragment.from(cells)),
|
|
rows = [];
|
|
for (let i = map.height; i < height; i++) rows.push(emptyRow);
|
|
tr.insert(tr.mapping.slice(mapFrom).map(start + table.nodeSize - 2), rows);
|
|
}
|
|
return !!(empty || emptyHead);
|
|
}
|
|
|
|
// Make sure the given line (left, top) to (right, top) doesn't cross
|
|
// any rowspan cells by splitting cells that cross it. Return true if
|
|
// something changed.
|
|
function isolateHorizontal(
|
|
tr: Transaction,
|
|
map: TableMap,
|
|
table: Node,
|
|
start: number,
|
|
left: number,
|
|
right: number,
|
|
top: number,
|
|
mapFrom: number,
|
|
): boolean {
|
|
if (top == 0 || top == map.height) return false;
|
|
let found = false;
|
|
for (let col = left; col < right; col++) {
|
|
const index = top * map.width + col,
|
|
pos = map.map[index];
|
|
if (map.map[index - map.width] == pos) {
|
|
found = true;
|
|
const cell = table.nodeAt(pos);
|
|
const { top: cellTop, left: cellLeft } = map.findCell(pos);
|
|
tr.setNodeMarkup(
|
|
tr.mapping.slice(mapFrom).map(pos + start),
|
|
null,
|
|
_setAttr(cell.attrs, 'rowspan', top - cellTop),
|
|
);
|
|
tr.insert(
|
|
tr.mapping.slice(mapFrom).map(map.positionAt(top, cellLeft, table)),
|
|
cell.type.createAndFill(
|
|
_setAttr(cell.attrs, 'rowspan', cellTop + cell.attrs.rowspan - top),
|
|
),
|
|
);
|
|
col += cell.attrs.colspan - 1;
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
// Make sure the given line (left, top) to (left, bottom) doesn't
|
|
// cross any colspan cells by splitting cells that cross it. Return
|
|
// true if something changed.
|
|
function isolateVertical(
|
|
tr: Transaction,
|
|
map: TableMap,
|
|
table: Node,
|
|
start: number,
|
|
top: number,
|
|
bottom: number,
|
|
left: number,
|
|
mapFrom: number,
|
|
): boolean {
|
|
if (left == 0 || left == map.width) return false;
|
|
let found = false;
|
|
for (let row = top; row < bottom; row++) {
|
|
const index = row * map.width + left,
|
|
pos = map.map[index];
|
|
if (map.map[index - 1] == pos) {
|
|
found = true;
|
|
const cell = table.nodeAt(pos),
|
|
cellLeft = map.colCount(pos);
|
|
const updatePos = tr.mapping.slice(mapFrom).map(pos + start);
|
|
tr.setNodeMarkup(
|
|
updatePos,
|
|
null,
|
|
removeColSpan(
|
|
cell.attrs,
|
|
left - cellLeft,
|
|
cell.attrs.colspan - (left - cellLeft),
|
|
),
|
|
);
|
|
tr.insert(
|
|
updatePos + cell.nodeSize,
|
|
cell.type.createAndFill(removeColSpan(cell.attrs, 0, left - cellLeft)),
|
|
);
|
|
row += cell.attrs.rowspan - 1;
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Insert the given set of cells (as returned by `pastedCells`) into a
|
|
* table, at the position pointed at by rect.
|
|
*
|
|
* @internal
|
|
*/
|
|
export function insertCells(
|
|
state: EditorState,
|
|
dispatch: (tr: Transaction) => void,
|
|
tableStart: number,
|
|
rect: Rect,
|
|
cells: Area,
|
|
): void {
|
|
let table = tableStart ? state.doc.nodeAt(tableStart - 1) : state.doc,
|
|
map = TableMap.get(table);
|
|
const { top, left } = rect;
|
|
const right = left + cells.width,
|
|
bottom = top + cells.height;
|
|
const tr = state.tr;
|
|
let mapFrom = 0;
|
|
|
|
function recomp(): void {
|
|
table = tableStart ? tr.doc.nodeAt(tableStart - 1) : tr.doc;
|
|
map = TableMap.get(table);
|
|
mapFrom = tr.mapping.maps.length;
|
|
}
|
|
|
|
// Prepare the table to be large enough and not have any cells
|
|
// crossing the boundaries of the rectangle that we want to
|
|
// insert into. If anything about it changes, recompute the table
|
|
// map so that subsequent operations can see the current shape.
|
|
if (growTable(tr, map, table, tableStart, right, bottom, mapFrom)) recomp();
|
|
if (isolateHorizontal(tr, map, table, tableStart, left, right, top, mapFrom))
|
|
recomp();
|
|
if (
|
|
isolateHorizontal(tr, map, table, tableStart, left, right, bottom, mapFrom)
|
|
)
|
|
recomp();
|
|
if (isolateVertical(tr, map, table, tableStart, top, bottom, left, mapFrom))
|
|
recomp();
|
|
if (isolateVertical(tr, map, table, tableStart, top, bottom, right, mapFrom))
|
|
recomp();
|
|
|
|
for (let row = top; row < bottom; row++) {
|
|
const from = map.positionAt(row, left, table),
|
|
to = map.positionAt(row, right, table);
|
|
tr.replace(
|
|
tr.mapping.slice(mapFrom).map(from + tableStart),
|
|
tr.mapping.slice(mapFrom).map(to + tableStart),
|
|
new Slice(cells.rows[row - top], 0, 0),
|
|
);
|
|
}
|
|
recomp();
|
|
tr.setSelection(
|
|
new CellSelection(
|
|
tr.doc.resolve(tableStart + map.positionAt(top, left, table)),
|
|
tr.doc.resolve(tableStart + map.positionAt(bottom - 1, right - 1, table)),
|
|
),
|
|
);
|
|
dispatch(tr);
|
|
}
|