// 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 { EditorState, Transaction } from "prosemirror-state"; import { Transform } from "prosemirror-transform"; import { CellAttrs, EditorSchema, NodeTypeName } from "../schema"; import { Dispatch, DocNode, TableStart } from "../types"; import { CellSelection } from "./CellSelection"; import { EdgeRect } from "./EdgeRect"; import { TableMap } from "./TableMap"; import { findTable, shrinkColSpan } from "./util"; // 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. */ export function pastedCells(slice: Slice): Cells | undefined | null { if (slice.size === 0) { return null; } let { content, openStart, openEnd } = slice; while ( content.childCount == 1 && ((openStart > 0 && openEnd > 0) || content.firstChild!.type.name === ("tbl" as NodeTypeName)) ) { openStart--; openEnd--; content = content.firstChild!.content; } const first = content.firstChild!; const schema = first.type.schema; const rows = []; if (first.type.name === ("tr" as NodeTypeName)) { for (let i = 0; i < content.childCount; i++) { let cells = content.child(i).content; const left = i > 0 ? 0 : Math.max(0, openStart - 1); const right = i < content.childCount - 1 ? 0 : Math.max(0, openEnd - 1); if (left > 0 || right > 0) { cells = fitSlice(schema.nodes.tr, new Slice(cells, left, right)).content; } rows.push(cells); } } else if (first.type.name === ("td" as NodeTypeName) || first.type.name === ("th" as NodeTypeName)) { rows.push( openStart > 0 || openEnd > 0 ? fitSlice(schema.nodes.tr, 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: Array): { width: number; height: number; rows: Array } { const widths: Array = []; 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++) { const currentWidth = widths[r] as number | undefined; widths[r] = (currentWidth !== undefined ? currentWidth : 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 = schema.nodes.td.createAndFill()!; const cells: Node[] = []; 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; } export interface Cells { width: number; height: number; rows: Fragment[]; } /** * 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. */ export function clipCells({ width, height, rows }: Cells, newWidth: number, newHeight: number): Cells { if (width != newWidth) { const added: number[] = []; const newRows: Fragment[] = []; for (let row = 0; row < rows.length; row++) { const frag = rows[row]; const cells = []; const currentValue = added[row] as number | undefined; for (let col = currentValue !== undefined ? currentValue : 0, i = 0; col < newWidth; i++) { let cell = frag.child(i % frag.childCount); if (col + cell.attrs.colspan > newWidth) { cell = cell.type.create( shrinkColSpan(cell.attrs as CellAttrs, 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++) { const currentValue = added[row + j] as number | undefined; added[row + j] = (currentValue !== undefined ? currentValue : 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: Node[] = []; const 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({ ...(cell.attrs as CellAttrs), rowspan: 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; 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 == schema.nodes.td) { add = empty !== undefined ? empty : (empty = schema.nodes.td.createAndFill()!); } else { add = emptyHead !== undefined ? emptyHead : (emptyHead = schema.nodes.th.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 == schema.nodes.th; cells.push( header ? emptyHead !== undefined ? emptyHead : (emptyHead = schema.nodes.th.createAndFill()!) : empty !== undefined ? empty : (empty = schema.nodes.td.createAndFill()!) ); } const emptyRow = schema.nodes.tr.create(undefined, Fragment.from(cells)); const 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 !== undefined || emptyHead !== undefined; } /** * 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 ) { if (top == 0 || top == map.height) { return false; } let found = false; for (let col = left; col < right; col++) { const index = top * map.width + col; const 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), undefined, { ...(cell.attrs as CellAttrs), rowspan: top - cellTop }); tr.insert( tr.mapping.slice(mapFrom).map(map.positionAt(top, cellLeft, table)), cell.type.createAndFill({ ...(cell.attrs as CellAttrs), 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 ) { if (left == 0 || left == map.width) { return false; } let found = false; for (let row = top; row < bottom; row++) { const index = row * map.width + left; const pos = map.map[index]; if (map.map[index - 1] == pos) { found = true; const cell = table.nodeAt(pos)!; const cellLeft = map.columnIndex(pos); const updatePos = tr.mapping.slice(mapFrom).map(pos + start); tr.setNodeMarkup( updatePos, undefined, shrinkColSpan(cell.attrs as CellAttrs, left - cellLeft, cell.attrs.colspan - (left - cellLeft)) ); tr.insert( updatePos + cell.nodeSize, cell.type.createAndFill(shrinkColSpan(cell.attrs as CellAttrs, 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. */ export function insertCells(state: EditorState, dispatch: Dispatch, tableStart: TableStart, rect: EdgeRect, cells: Cells) { let table = findTable(state.doc as DocNode, tableStart)!; let map = TableMap.get(table); const { top, left } = rect; const right = left + cells.width; const bottom = top + cells.height; const tr = state.tr; let mapFrom = 0; function recomp() { table = findTable(tr.doc as DocNode, tableStart)!; 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); const to = map.positionAt(row, right, table); tr.replace( tr.mapping.slice(mapFrom).map(tableStart + from), tr.mapping.slice(mapFrom).map(tableStart + to), 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); }