BookStack/resources/js/wysiwyg/utils/tables.ts

318 lines
9.5 KiB
TypeScript

import {BaseSelection, LexicalEditor} from "lexical";
import {$isTableRowNode, $isTableSelection, TableRowNode, TableSelection, TableSelectionShape} from "@lexical/table";
import {$isCustomTableNode, CustomTableNode} from "../nodes/custom-table";
import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell";
import {$getParentOfType} from "./nodes";
import {$getNodeFromSelection} from "./selection";
import {formatSizeValue} from "./dom";
import {TableMap} from "./table-map";
import {$isCustomTableRowNode, CustomTableRowNode} from "../nodes/custom-table-row";
function $getTableFromCell(cell: CustomTableCellNode): CustomTableNode|null {
return $getParentOfType(cell, $isCustomTableNode) as CustomTableNode|null;
}
export function getTableColumnWidths(table: HTMLTableElement): string[] {
const maxColRow = getMaxColRowFromTable(table);
const colGroup = table.querySelector('colgroup');
let widths: string[] = [];
if (colGroup && (colGroup.childElementCount === maxColRow?.childElementCount || !maxColRow)) {
widths = extractWidthsFromRow(colGroup);
}
if (widths.filter(Boolean).length === 0 && maxColRow) {
widths = extractWidthsFromRow(maxColRow);
}
return widths;
}
function getMaxColRowFromTable(table: HTMLTableElement): HTMLTableRowElement | null {
const rows = table.querySelectorAll('tr');
let maxColCount: number = 0;
let maxColRow: HTMLTableRowElement | null = null;
for (const row of rows) {
if (row.childElementCount > maxColCount) {
maxColRow = row;
maxColCount = row.childElementCount;
}
}
return maxColRow;
}
function extractWidthsFromRow(row: HTMLTableRowElement | HTMLTableColElement) {
return [...row.children].map(child => extractWidthFromElement(child as HTMLElement))
}
function extractWidthFromElement(element: HTMLElement): string {
let width = element.style.width || element.getAttribute('width');
if (width && !Number.isNaN(Number(width))) {
width = width + 'px';
}
return width || '';
}
export function $setTableColumnWidth(node: CustomTableNode, columnIndex: number, width: number|string): void {
const rows = node.getChildren() as TableRowNode[];
let maxCols = 0;
for (const row of rows) {
const cellCount = row.getChildren().length;
if (cellCount > maxCols) {
maxCols = cellCount;
}
}
let colWidths = node.getColWidths();
if (colWidths.length === 0 || colWidths.length < maxCols) {
colWidths = Array(maxCols).fill('');
}
if (columnIndex + 1 > colWidths.length) {
console.error(`Attempted to set table column width for column [${columnIndex}] but only ${colWidths.length} columns found`);
}
colWidths[columnIndex] = formatSizeValue(width);
node.setColWidths(colWidths);
}
export function $getTableColumnWidth(editor: LexicalEditor, node: CustomTableNode, columnIndex: number): number {
const colWidths = node.getColWidths();
if (colWidths.length > columnIndex && colWidths[columnIndex].endsWith('px')) {
return Number(colWidths[columnIndex].replace('px', ''));
}
// Otherwise, get from table element
const table = editor.getElementByKey(node.__key) as HTMLTableElement | null;
if (table) {
const maxColRow = getMaxColRowFromTable(table);
if (maxColRow && maxColRow.children.length > columnIndex) {
const cell = maxColRow.children[columnIndex];
return cell.clientWidth;
}
}
return 0;
}
function $getCellColumnIndex(node: CustomTableCellNode): number {
const row = node.getParent();
if (!$isTableRowNode(row)) {
return -1;
}
let index = 0;
const cells = row.getChildren<CustomTableCellNode>();
for (const cell of cells) {
let colSpan = cell.getColSpan() || 1;
index += colSpan;
if (cell.getKey() === node.getKey()) {
break;
}
}
return index - 1;
}
export function $setTableCellColumnWidth(cell: CustomTableCellNode, width: string): void {
const table = $getTableFromCell(cell)
const index = $getCellColumnIndex(cell);
if (table && index >= 0) {
$setTableColumnWidth(table, index, width);
}
}
export function $getTableCellColumnWidth(editor: LexicalEditor, cell: CustomTableCellNode): string {
const table = $getTableFromCell(cell)
const index = $getCellColumnIndex(cell);
if (!table) {
return '';
}
const widths = table.getColWidths();
return (widths.length > index) ? widths[index] : '';
}
export function $getTableCellsFromSelection(selection: BaseSelection|null): CustomTableCellNode[] {
if ($isTableSelection(selection)) {
const nodes = selection.getNodes();
return nodes.filter(n => $isCustomTableCellNode(n));
}
const cell = $getNodeFromSelection(selection, $isCustomTableCellNode) as CustomTableCellNode;
return cell ? [cell] : [];
}
export function $mergeTableCellsInSelection(selection: TableSelection): void {
const selectionShape = selection.getShape();
const cells = $getTableCellsFromSelection(selection);
if (cells.length === 0) {
return;
}
const table = $getTableFromCell(cells[0]);
if (!table) {
return;
}
const tableMap = new TableMap(table);
const headCell = tableMap.getCellAtPosition(selectionShape.toX, selectionShape.toY);
if (!headCell) {
return;
}
// We have to adjust the shape since it won't take into account spans for the head corner position.
const fixedToX = selectionShape.toX + ((headCell.getColSpan() || 1) - 1);
const fixedToY = selectionShape.toY + ((headCell.getRowSpan() || 1) - 1);
const mergeCells = tableMap.getCellsInRange(
selectionShape.fromX,
selectionShape.fromY,
fixedToX,
fixedToY,
);
if (mergeCells.length === 0) {
return;
}
const firstCell = mergeCells[0];
const newWidth = Math.abs(selectionShape.fromX - fixedToX) + 1;
const newHeight = Math.abs(selectionShape.fromY - fixedToY) + 1;
for (let i = 1; i < mergeCells.length; i++) {
const mergeCell = mergeCells[i];
firstCell.append(...mergeCell.getChildren());
mergeCell.remove();
}
firstCell.setColSpan(newWidth);
firstCell.setRowSpan(newHeight);
}
export function $getTableRowsFromSelection(selection: BaseSelection|null): CustomTableRowNode[] {
const cells = $getTableCellsFromSelection(selection);
const rowsByKey: Record<string, CustomTableRowNode> = {};
for (const cell of cells) {
const row = cell.getParent();
if ($isCustomTableRowNode(row)) {
rowsByKey[row.getKey()] = row;
}
}
return Object.values(rowsByKey);
}
export function $getTableFromSelection(selection: BaseSelection|null): CustomTableNode|null {
const cells = $getTableCellsFromSelection(selection);
if (cells.length === 0) {
return null;
}
const table = $getParentOfType(cells[0], $isCustomTableNode);
if ($isCustomTableNode(table)) {
return table;
}
return null;
}
export function $clearTableSizes(table: CustomTableNode): void {
table.setColWidths([]);
// TODO - Extra form things once table properties and extra things
// are supported
for (const row of table.getChildren()) {
if (!$isCustomTableRowNode(row)) {
continue;
}
const rowStyles = row.getStyles();
rowStyles.delete('height');
rowStyles.delete('width');
row.setStyles(rowStyles);
const cells = row.getChildren().filter(c => $isCustomTableCellNode(c));
for (const cell of cells) {
const cellStyles = cell.getStyles();
cellStyles.delete('height');
cellStyles.delete('width');
cell.setStyles(cellStyles);
cell.clearWidth();
}
}
}
export function $clearTableFormatting(table: CustomTableNode): void {
table.setColWidths([]);
table.setStyles(new Map);
for (const row of table.getChildren()) {
if (!$isCustomTableRowNode(row)) {
continue;
}
row.setStyles(new Map);
row.setFormat('');
const cells = row.getChildren().filter(c => $isCustomTableCellNode(c));
for (const cell of cells) {
cell.setStyles(new Map);
cell.clearWidth();
cell.setFormat('');
}
}
}
/**
* Perform the given callback for each cell in the given table.
* Returning false from the callback stops the function early.
*/
export function $forEachTableCell(table: CustomTableNode, callback: (c: CustomTableCellNode) => void|false): void {
outer: for (const row of table.getChildren()) {
if (!$isCustomTableRowNode(row)) {
continue;
}
const cells = row.getChildren();
for (const cell of cells) {
if (!$isCustomTableCellNode(cell)) {
return;
}
const result = callback(cell);
if (result === false) {
break outer;
}
}
}
}
export function $getCellPaddingForTable(table: CustomTableNode): string {
let padding: string|null = null;
$forEachTableCell(table, (cell: CustomTableCellNode) => {
const cellPadding = cell.getStyles().get('padding') || ''
if (padding === null) {
padding = cellPadding;
}
if (cellPadding !== padding) {
padding = null;
return false;
}
});
return padding || '';
}