425 lines
11 KiB
TypeScript
425 lines
11 KiB
TypeScript
import { Fragment, Node, NodeType } from "prosemirror-model";
|
|
import { Command, EditorState, TextSelection } from "prosemirror-state";
|
|
import {
|
|
CellSelection,
|
|
addRow,
|
|
isInTable,
|
|
selectedRect,
|
|
tableNodeTypes,
|
|
toggleHeader,
|
|
addColumn,
|
|
deleteRow,
|
|
deleteColumn,
|
|
} from "prosemirror-tables";
|
|
import { chainTransactions } from "../lib/chainTransactions";
|
|
import { getCellsInColumn, isHeaderEnabled } from "../queries/table";
|
|
import { TableLayout } from "../types";
|
|
import { collapseSelection } from "./collapseSelection";
|
|
|
|
export function createTable({
|
|
rowsCount,
|
|
colsCount,
|
|
}: {
|
|
rowsCount: number;
|
|
colsCount: number;
|
|
}): Command {
|
|
return (state, dispatch) => {
|
|
if (dispatch) {
|
|
const offset = state.tr.selection.anchor + 1;
|
|
const nodes = createTableInner(state, rowsCount, colsCount);
|
|
const tr = state.tr.replaceSelectionWith(nodes).scrollIntoView();
|
|
const resolvedPos = tr.doc.resolve(offset);
|
|
tr.setSelection(TextSelection.near(resolvedPos));
|
|
dispatch(tr);
|
|
}
|
|
return true;
|
|
};
|
|
}
|
|
|
|
function createTableInner(
|
|
state: EditorState,
|
|
rowsCount: number,
|
|
colsCount: number,
|
|
withHeaderRow = true,
|
|
cellContent?: Node
|
|
) {
|
|
const types = tableNodeTypes(state.schema);
|
|
const headerCells: Node[] = [];
|
|
const cells: Node[] = [];
|
|
const rows: Node[] = [];
|
|
|
|
const createCell = (
|
|
cellType: NodeType,
|
|
cellContent: Fragment | Node | readonly Node[] | null | undefined
|
|
) =>
|
|
cellContent
|
|
? cellType.createChecked(null, cellContent)
|
|
: cellType.createAndFill();
|
|
|
|
for (let index = 0; index < colsCount; index += 1) {
|
|
const cell = createCell(types.cell, cellContent);
|
|
|
|
if (cell) {
|
|
cells.push(cell);
|
|
}
|
|
|
|
if (withHeaderRow) {
|
|
const headerCell = createCell(types.header_cell, cellContent);
|
|
|
|
if (headerCell) {
|
|
headerCells.push(headerCell);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let index = 0; index < rowsCount; index += 1) {
|
|
rows.push(
|
|
types.row.createChecked(
|
|
null,
|
|
withHeaderRow && index === 0 ? headerCells : cells
|
|
)
|
|
);
|
|
}
|
|
|
|
return types.table.createChecked(null, rows);
|
|
}
|
|
|
|
export function sortTable({
|
|
index,
|
|
direction,
|
|
}: {
|
|
index: number;
|
|
direction: "asc" | "desc";
|
|
}): Command {
|
|
return (state, dispatch) => {
|
|
if (!isInTable(state)) {
|
|
return false;
|
|
}
|
|
|
|
if (dispatch) {
|
|
const rect = selectedRect(state);
|
|
const table: Node[][] = [];
|
|
|
|
for (let r = 0; r < rect.map.height; r++) {
|
|
const cells = [];
|
|
for (let c = 0; c < rect.map.width; c++) {
|
|
const cell = state.doc.nodeAt(
|
|
rect.tableStart + rect.map.map[r * rect.map.width + c]
|
|
);
|
|
if (cell) {
|
|
cells.push(cell);
|
|
}
|
|
}
|
|
table.push(cells);
|
|
}
|
|
|
|
// check if all the cells in the column are a number
|
|
const compareAsText = table.some((row) => {
|
|
const cell = row[index]?.textContent;
|
|
return cell === "" ? false : isNaN(parseFloat(cell));
|
|
});
|
|
|
|
// remove the header row
|
|
const header = table.shift();
|
|
|
|
// column data before sort
|
|
const columnData = table.map((row) => row[index]?.textContent ?? "");
|
|
|
|
// sort table data based on column at index
|
|
table.sort((a, b) => {
|
|
if (compareAsText) {
|
|
return (a[index]?.textContent ?? "").localeCompare(
|
|
b[index]?.textContent ?? ""
|
|
);
|
|
} else {
|
|
return (
|
|
parseFloat(a[index]?.textContent ?? "") -
|
|
parseFloat(b[index]?.textContent ?? "")
|
|
);
|
|
}
|
|
});
|
|
|
|
if (direction === "desc") {
|
|
table.reverse();
|
|
}
|
|
|
|
// check if column data changed, if not then do not replace table
|
|
if (
|
|
columnData.join() === table.map((row) => row[index]?.textContent).join()
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// add the header row back
|
|
if (header) {
|
|
table.unshift(header);
|
|
}
|
|
|
|
// create the new table
|
|
const rows = [];
|
|
for (let i = 0; i < table.length; i += 1) {
|
|
rows.push(state.schema.nodes.tr.createChecked(null, table[i]));
|
|
}
|
|
|
|
// replace the original table with this sorted one
|
|
const nodes = state.schema.nodes.table.createChecked(
|
|
rect.table.attrs,
|
|
rows
|
|
);
|
|
const { tr } = state;
|
|
|
|
tr.replaceRangeWith(
|
|
rect.tableStart - 1,
|
|
rect.tableStart - 1 + rect.table.nodeSize,
|
|
nodes
|
|
);
|
|
|
|
dispatch(tr.scrollIntoView());
|
|
}
|
|
return true;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A command that safely adds a row taking into account any existing heading column at the top of
|
|
* the table, and preventing it moving "into" the table.
|
|
*
|
|
* @param index The index to add the row at, if undefined the current selection is used
|
|
* @returns The command
|
|
*/
|
|
export function addRowBefore({ index }: { index?: number }): Command {
|
|
return (state, dispatch) => {
|
|
if (!isInTable(state)) {
|
|
return false;
|
|
}
|
|
|
|
const rect = selectedRect(state);
|
|
const isHeaderRowEnabled = isHeaderEnabled(state, "row", rect);
|
|
const position = index !== undefined ? index : rect.left;
|
|
|
|
// Special case when adding row to the beginning of the table to ensure the header does not
|
|
// move inwards.
|
|
const headerSpecialCase = position === 0 && isHeaderRowEnabled;
|
|
|
|
chainTransactions(
|
|
headerSpecialCase ? toggleHeader("row") : undefined,
|
|
(s, d) => !!d?.(addRow(s.tr, rect, position)),
|
|
headerSpecialCase ? toggleHeader("row") : undefined,
|
|
collapseSelection()
|
|
)(state, dispatch);
|
|
|
|
return true;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A command that deletes the current selected row, if any.
|
|
*
|
|
* @returns The command
|
|
*/
|
|
export function deleteRowSelection(): Command {
|
|
return (state, dispatch) => {
|
|
if (
|
|
state.selection instanceof CellSelection &&
|
|
state.selection.isRowSelection()
|
|
) {
|
|
return deleteRow(state, dispatch);
|
|
}
|
|
return false;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A command that deletes the current selected column, if any.
|
|
*
|
|
* @returns The command
|
|
*/
|
|
export function deleteColSelection(): Command {
|
|
return (state, dispatch) => {
|
|
if (
|
|
state.selection instanceof CellSelection &&
|
|
state.selection.isColSelection()
|
|
) {
|
|
return deleteColumn(state, dispatch);
|
|
}
|
|
return false;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A command that safely adds a column taking into account any existing heading column on the far
|
|
* left of the table, and preventing it moving "into" the table.
|
|
*
|
|
* @param index The index to add the column at, if undefined the current selection is used
|
|
* @returns The command
|
|
*/
|
|
export function addColumnBefore({ index }: { index?: number }): Command {
|
|
return (state, dispatch) => {
|
|
if (!isInTable(state)) {
|
|
return false;
|
|
}
|
|
|
|
const rect = selectedRect(state);
|
|
const isHeaderColumnEnabled = isHeaderEnabled(state, "column", rect);
|
|
const position = index !== undefined ? index : rect.left;
|
|
|
|
// Special case when adding column to the beginning of the table to ensure the header does not
|
|
// move inwards.
|
|
const headerSpecialCase = position === 0 && isHeaderColumnEnabled;
|
|
|
|
chainTransactions(
|
|
headerSpecialCase ? toggleHeader("column") : undefined,
|
|
(s, d) => !!d?.(addColumn(s.tr, rect, position)),
|
|
headerSpecialCase ? toggleHeader("column") : undefined,
|
|
collapseSelection()
|
|
)(state, dispatch);
|
|
|
|
return true;
|
|
};
|
|
}
|
|
|
|
export function addRowAndMoveSelection({
|
|
index,
|
|
}: {
|
|
index?: number;
|
|
} = {}): Command {
|
|
return (state, dispatch, view) => {
|
|
if (!isInTable(state)) {
|
|
return false;
|
|
}
|
|
|
|
const rect = selectedRect(state);
|
|
const cells = getCellsInColumn(0)(state);
|
|
|
|
// If the cursor is at the beginning of the first column then insert row
|
|
// above instead of below.
|
|
if (rect.left === 0 && view?.endOfTextblock("backward", state)) {
|
|
const indexBefore = index !== undefined ? index - 1 : rect.top;
|
|
dispatch?.(addRow(state.tr, rect, indexBefore));
|
|
return true;
|
|
}
|
|
|
|
const indexAfter = index !== undefined ? index + 1 : rect.bottom;
|
|
const tr = addRow(state.tr, rect, indexAfter);
|
|
|
|
// Special case when adding row to the end of the table as the calculated
|
|
// rect does not include the row that we just added.
|
|
if (indexAfter !== rect.map.height) {
|
|
const pos = cells[Math.min(cells.length - 1, indexAfter)];
|
|
const $pos = tr.doc.resolve(pos);
|
|
dispatch?.(tr.setSelection(TextSelection.near($pos)));
|
|
} else {
|
|
const $pos = tr.doc.resolve(rect.tableStart + rect.table.nodeSize);
|
|
dispatch?.(tr.setSelection(TextSelection.near($pos)));
|
|
}
|
|
|
|
return true;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set column attributes. Passed attributes will be merged with existing.
|
|
*
|
|
* @param attrs The attributes to set
|
|
* @returns The command
|
|
*/
|
|
export function setColumnAttr({
|
|
index,
|
|
alignment,
|
|
}: {
|
|
index: number;
|
|
alignment: string;
|
|
}): Command {
|
|
return (state, dispatch) => {
|
|
if (dispatch) {
|
|
const cells = getCellsInColumn(index)(state) || [];
|
|
let transaction = state.tr;
|
|
cells.forEach((pos) => {
|
|
const node = state.doc.nodeAt(pos);
|
|
transaction = transaction.setNodeMarkup(pos, undefined, {
|
|
...node?.attrs,
|
|
alignment,
|
|
});
|
|
});
|
|
dispatch(transaction);
|
|
}
|
|
return true;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set table attributes. Passed attributes will be merged with existing.
|
|
*
|
|
* @param attrs The attributes to set
|
|
* @returns The command
|
|
*/
|
|
export function setTableAttr(attrs: { layout: TableLayout | null }): Command {
|
|
return (state, dispatch) => {
|
|
if (!isInTable(state)) {
|
|
return false;
|
|
}
|
|
|
|
if (dispatch) {
|
|
const { tr } = state;
|
|
const rect = selectedRect(state);
|
|
|
|
tr.setNodeMarkup(rect.tableStart - 1, undefined, {
|
|
...rect.table.attrs,
|
|
...attrs,
|
|
}).setSelection(TextSelection.near(tr.doc.resolve(rect.tableStart)));
|
|
dispatch(tr);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
}
|
|
|
|
export function selectRow(index: number, expand = false): Command {
|
|
return (state: EditorState, dispatch): boolean => {
|
|
if (dispatch) {
|
|
const rect = selectedRect(state);
|
|
const pos = rect.map.positionAt(index, 0, rect.table);
|
|
const $pos = state.doc.resolve(rect.tableStart + pos);
|
|
const rowSelection =
|
|
expand && state.selection instanceof CellSelection
|
|
? CellSelection.rowSelection(state.selection.$anchorCell, $pos)
|
|
: CellSelection.rowSelection($pos);
|
|
dispatch(state.tr.setSelection(rowSelection));
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
}
|
|
|
|
export function selectColumn(index: number, expand = false): Command {
|
|
return (state, dispatch): boolean => {
|
|
if (dispatch) {
|
|
const rect = selectedRect(state);
|
|
const pos = rect.map.positionAt(0, index, rect.table);
|
|
const $pos = state.doc.resolve(rect.tableStart + pos);
|
|
const colSelection =
|
|
expand && state.selection instanceof CellSelection
|
|
? CellSelection.colSelection(state.selection.$anchorCell, $pos)
|
|
: CellSelection.colSelection($pos);
|
|
dispatch(state.tr.setSelection(colSelection));
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
}
|
|
|
|
export function selectTable(): Command {
|
|
return (state, dispatch): boolean => {
|
|
if (dispatch) {
|
|
const rect = selectedRect(state);
|
|
const map = rect.map.map;
|
|
const $anchor = state.doc.resolve(rect.tableStart + map[0]);
|
|
const $head = state.doc.resolve(rect.tableStart + map[map.length - 1]);
|
|
const tableSelection = new CellSelection($anchor, $head);
|
|
dispatch(state.tr.setSelection(tableSelection));
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
}
|