@canva/create-app
Version:
A command line tool for creating Canva Apps.
478 lines (441 loc) • 16.8 kB
text/typescript
import type { Cell, TableElement } from "@canva/design";
const MAX_CELL_COUNT = 225;
// Additional information in the wrapper that are not available in the table cell element.
// Currently, only merged cells, but it can later extend to other custom properties, like border, size,...
type MetaCell = {
// If a cell is merged into another cell, then this field should tell that other cell.
mergedInto?: { row: number; column: number };
};
export class TableWrapper {
// A shadow of the cell array, that highlight the relationship between merged cells.
private readonly metaCells: MetaCell[][];
private constructor(
private readonly rows: {
cells: (Cell | null | undefined)[];
}[],
) {
this.validateRowColumn();
this.metaCells = Array.from({ length: this.rows.length }, () =>
Array.from({ length: this.rows[0].cells.length }, () => ({})),
);
this.syncMergedCellsFromRows();
}
/**
* Creates an empty table wrapper.
* @param rowCount - The number of rows to create the table with.
* @param columnCount - The number of columns to create the table with.
*/
static create(rowCount: number, columnCount: number) {
const rows = Array.from({ length: rowCount }, () => ({
cells: Array.from({ length: columnCount }, () => null),
}));
return new TableWrapper(rows);
}
/**
* Converts a table element into a table wrapper.
* @param element - The table element to convert into a table wrapper.
* @throws TableValidationError if element is not a valid {@link TableElement}.
*/
static fromElement(element: TableElement) {
if (element.type !== "table") {
throw new TableValidationError(
`Cannot convert element of type ${element.type} to a table wrapper.`,
);
}
if (!Array.isArray(element.rows)) {
throw new TableValidationError(
`Invalid table element: expected an array of rows, got ${element.rows}`,
);
}
const rows = element.rows.map((row) => ({
cells: row.cells.map(
(cell) =>
cell && {
...cell,
attributes: cell.attributes ? { ...cell.attributes } : undefined,
},
),
}));
return new TableWrapper(rows);
}
/**
* Return a table element that can be passed into the `addElementAtPoint` or `addElementAtCursor` method.
* @returns A table element.
*/
toElement(): TableElement {
return {
type: "table",
rows: this.rows,
};
}
/**
* Adds a row to the table after the specified row.
* @param afterRowPos The position of the new row. A value of `0` adds a row before the first row, a
* value of `1` adds a row after the first row, etc.
* @remarks
* If the row above and below the new row both have the same properties, the properties will be
* copied to the new row. For example, if there are two rows with the same background color and a
* row is inserted between them, the new row will also have the same background color.
*/
addRow(afterRowPos: number) {
if (afterRowPos < 0 || afterRowPos > this.rows.length) {
throw new TableValidationError(
`New row position must be between 0 and ${this.rows.length}.`,
);
}
this.validateRowColumn(1, 0);
const newRow = {
cells: Array.from(
{ length: this.rows[0].cells.length },
() => ({}) as Cell,
),
};
this.rows.splice(afterRowPos, 0, newRow);
const newMergeCells: MetaCell[] = Array.from(
{ length: this.rows[0].cells.length },
() => ({}),
);
this.metaCells.splice(afterRowPos, 0, newMergeCells);
if (0 < afterRowPos && afterRowPos < this.rows.length) {
// Insert in between rows
for (let i = 0; i < newRow.cells.length; i++) {
this.mayCopyStyles({
frontRowIdx: afterRowPos - 1,
frontColumnIdx: i,
currentRowIdx: afterRowPos,
currentColumnIdx: i,
behindRowIdx: afterRowPos + 1,
behindColumnIdx: i,
});
}
this.syncCellSpansFromMetaCells();
}
}
/**
* Adds a column to the table after the specified column.
* @param afterColumnPos The position of the new column. A value of `0` adds a column before the first
* column, a value of `1` adds a column after the first column, etc.
* @remarks
* If the column before and after the new column both have the same properties, the properties will be
* copied to the new column. For example, if there are two columns with the same background color and a
* column is inserted between them, the new column will also have the same background color.
*/
addColumn(afterColumnPos: number) {
if (afterColumnPos < 0 || afterColumnPos > this.rows[0].cells.length) {
throw new TableValidationError(
`New column position must be between 0 and ${this.rows[0].cells.length}.`,
);
}
this.validateRowColumn(0, 1);
this.rows.forEach((row) => row.cells.splice(afterColumnPos, 0, null));
const newMergeCell: MetaCell = {};
this.metaCells.forEach((row) =>
row.splice(afterColumnPos, 0, newMergeCell),
);
if (0 < afterColumnPos && afterColumnPos < this.rows[0].cells.length) {
// Insert in between columns
for (let i = 0; i < this.rows.length; i++) {
this.mayCopyStyles({
frontRowIdx: i,
frontColumnIdx: afterColumnPos - 1,
currentRowIdx: i,
currentColumnIdx: afterColumnPos,
behindRowIdx: i,
behindColumnIdx: afterColumnPos + 1,
});
}
this.syncCellSpansFromMetaCells();
}
}
/**
* Checks if the specified cell is a *ghost cell*.
* @param rowPos The row number of the cell, starting from `1`.
* @param columnPos The column number of the cell, starting from `1`.
* @remarks
* A ghost cell is a cell that can not be interacted as it is hidden by a row or column spanning
* cell. For example, imagine a row where the first cell has a `colSpan` of `2`. In this case, the
* second cell in the row is hidden and is therefore a ghost cell.
*/
isGhostCell(rowPos: number, columnPos: number): boolean {
this.validateCellBoundaries(rowPos, columnPos);
const rowIndex = rowPos - 1;
const columnIndex = columnPos - 1;
const { mergedInto } = this.metaCells[rowIndex][columnIndex];
if (!mergedInto) {
// Not belongs to any merged cell
return false;
}
// Not a ghost cell if it's merged into itself
return mergedInto.row !== rowIndex || mergedInto.column !== columnIndex;
}
/**
* Returns information about the specified cell.
* @param rowPos The row number of the cell, starting from `1`.
* @param columnPos The column number of the cell, starting from `1`.
* @throws TableValidationError if the cell is a ghost cell. To learn more, see {@link isGhostCell}.
*/
getCellDetails(rowPos: number, columnPos: number) {
if (this.isGhostCell(rowPos, columnPos)) {
throw new TableValidationError(
`The cell at ${rowPos},${columnPos} is squashed into another cell`,
);
}
return this.rows[rowPos - 1].cells[columnPos - 1];
}
/**
* Sets the details of the specified cell, including its content and appearance.
* @param rowPos The row number of the cell, starting from `1`.
* @param columnPos The column number of the cell, starting from `1`.
* @param details The new details for the cell.
* @throws TableValidationError if the cell is a ghost cell. To learn more, see {@link isGhostCell}.
*/
setCellDetails(rowPos: number, columnPos: number, details: Cell) {
const rowSpan = details.rowSpan ?? 1;
const colSpan = details.colSpan ?? 1;
this.validateCellBoundaries(rowPos, columnPos, rowSpan, colSpan);
if (this.isGhostCell(rowPos, columnPos)) {
throw new TableValidationError(
`The cell at ${rowPos},${columnPos} is squashed into another cell`,
);
}
const rowIndex = rowPos - 1;
const columnIndex = columnPos - 1;
const { rowSpan: oldRowSpan, colSpan: oldColSpan } =
this.rows[rowIndex].cells[columnIndex] || {};
this.rows[rowIndex].cells[columnIndex] = details;
if (oldRowSpan !== rowSpan || oldColSpan !== colSpan) {
this.syncMergedCellsFromRows();
}
}
private validateRowColumn(toBeAddedRow = 0, toBeAddedColumn = 0) {
const rowCount = this.rows.length + toBeAddedRow;
const columnCount = this.rows[0].cells.length + toBeAddedColumn;
if (rowCount === 0) {
throw new TableValidationError("Table must have at least one row.");
}
if (columnCount === 0) {
throw new TableValidationError("Table must have at least one column.");
}
for (const row of this.rows) {
if (row.cells.length + toBeAddedColumn !== columnCount) {
throw new TableValidationError(
"All rows must have the same number of columns.",
);
}
}
const cellCount = rowCount * columnCount;
if (cellCount > MAX_CELL_COUNT) {
throw new TableValidationError(
`Table cannot have more than ${MAX_CELL_COUNT} cells. Actual: ${rowCount}x${columnCount} = ${cellCount}`,
);
}
}
/**
* Read all cell's colSpans and rowSpans and update the merged cells accordingly.
* This is opposite sync of {@link syncCellSpansFromMetaCells}.
*/
private syncMergedCellsFromRows(): void {
// First, reset metaCells to unmerged state
this.metaCells.forEach((cells) =>
cells.forEach((c) => (c.mergedInto = undefined)),
);
// Then loop through this.rows to find any merged cells
for (let rowIndex = 0; rowIndex < this.rows.length; rowIndex++) {
const row = this.rows[rowIndex];
for (let columnIndex = 0; columnIndex < row.cells.length; columnIndex++) {
const cell = row.cells[columnIndex] || { colSpan: 1, rowSpan: 1 };
const colSpan = cell.colSpan || 1;
const rowSpan = cell.rowSpan || 1;
if (colSpan !== 1 || rowSpan !== 1) {
this.validateCellBoundaries(
rowIndex + 1,
columnIndex + 1,
rowSpan,
colSpan,
);
this.setMergedCellsByBoundary(
rowIndex,
columnIndex,
rowIndex + rowSpan - 1,
columnIndex + colSpan - 1,
);
}
}
}
}
/**
* Update mergeCells array in accordance with the span boundary
* @param fromRow Top most row index
* @param fromColumn Left most column index
* @param toRow Bottom most row index
* @param toColumn Right most column index
*/
private setMergedCellsByBoundary(
fromRow: number,
fromColumn: number,
toRow: number,
toColumn: number,
) {
for (let row = fromRow; row <= toRow; row++) {
for (let column = fromColumn; column <= toColumn; column++) {
const metaCell = this.metaCells[row][column];
if (metaCell.mergedInto) {
// This cell may be squashed by another merge cell
const { row: originalRow, column: originalColumn } =
metaCell.mergedInto;
if (originalRow !== fromRow && originalColumn !== fromColumn) {
// And the old origin cell is mismatched with the new origin,
// this mean the current meta cell is merged into 2 different origin cells,
// which is forbidden.
throw new TableValidationError(
`Expanding the cell at ${fromRow},${fromColumn} collides with another merged cell from ${originalRow},${originalColumn}`,
);
}
}
metaCell.mergedInto = {
row: fromRow,
column: fromColumn,
};
}
}
}
private mayCopyStyles({
frontRowIdx,
frontColumnIdx,
behindRowIdx,
behindColumnIdx,
currentRowIdx,
currentColumnIdx,
}: {
frontRowIdx: number;
frontColumnIdx: number;
behindRowIdx: number;
behindColumnIdx: number;
currentRowIdx: number;
currentColumnIdx: number;
}) {
// Continue span if both front and behind cells belong to a same merged cell
const frontMergedCell =
this.metaCells[frontRowIdx][frontColumnIdx].mergedInto;
const behindMergedCell =
this.metaCells[behindRowIdx][behindColumnIdx].mergedInto;
if (
frontMergedCell &&
frontMergedCell.row === behindMergedCell?.row &&
frontMergedCell.column === behindMergedCell?.column
) {
this.metaCells[currentRowIdx][currentColumnIdx].mergedInto = {
...frontMergedCell,
};
}
// Copy attributes if both front and behind cells are the same
const frontCell = this.rows[frontRowIdx].cells[frontColumnIdx];
const behindCell = this.rows[behindRowIdx].cells[behindColumnIdx];
if (
frontCell != null &&
behindCell != null &&
frontCell.attributes &&
behindCell.attributes
) {
let currentCell = this.rows[currentRowIdx].cells[currentColumnIdx];
for (const key of Object.keys(frontCell.attributes)) {
if (frontCell.attributes[key] === behindCell.attributes[key]) {
currentCell = currentCell || { type: "empty" };
currentCell.attributes = currentCell.attributes || {};
currentCell.attributes[key] = frontCell.attributes[key];
}
}
this.rows[currentRowIdx].cells[currentColumnIdx] = currentCell;
}
}
/**
* Loop through meta cells and update rowSpan and colSpan of each cell accordingly.
* This is opposite sync of {@link syncMergedCellsFromRows}
*/
private syncCellSpansFromMetaCells() {
const groups = new Map<string, { row: number; column: number }[]>();
for (let row = 0; row < this.metaCells.length; row++) {
for (let column = 0; column < this.metaCells[row].length; column++) {
// Reset all rowSpans and colSpans
const currentCell = this.rows[row].cells[column];
currentCell && delete currentCell.rowSpan;
currentCell && delete currentCell.colSpan;
const mergedCell = this.metaCells[row][column];
if (!mergedCell.mergedInto) {
continue;
}
const { row: actualRow, column: actualColumn } = mergedCell.mergedInto;
const key = `${actualRow},${actualColumn}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)?.push({ row, column });
}
}
groups.forEach((cells, key) => {
const { minRow, maxRow, minColumn, maxColumn } = cells.reduce(
(prev, { row, column }) => {
return {
minRow: Math.min(prev.minRow, row),
maxRow: Math.max(prev.maxRow, row),
minColumn: Math.min(prev.minColumn, column),
maxColumn: Math.max(prev.maxColumn, column),
};
},
{ minRow: Infinity, maxRow: -1, minColumn: Infinity, maxColumn: -1 },
);
if (
!isFinite(minRow) ||
!isFinite(minColumn) ||
maxRow < 0 ||
maxColumn < 0
) {
throw new TableValidationError(`Invalid merged cell started at ${key}`);
}
const rowSpan = maxRow - minRow + 1;
const columnSpan = maxColumn - minColumn + 1;
if (rowSpan > 1 || columnSpan > 1) {
const currentCell = this.rows[minRow].cells[minColumn] || {
type: "empty",
};
currentCell.rowSpan = rowSpan;
currentCell.colSpan = columnSpan;
this.rows[minRow].cells[minColumn] = currentCell;
}
});
}
private validateCellBoundaries(
rowPos: number,
columnPos: number,
rowSpan = 1,
columnSpan = 1,
) {
if (rowPos < 1 || rowPos > this.rows.length) {
throw new TableValidationError(
`Row position must be between 1 and ${this.rows.length} (number of rows).`,
);
}
if (columnPos < 1 || columnPos > this.rows[0].cells.length) {
throw new TableValidationError(
`Column position must be between 1 and ${this.rows[0].cells.length} (number of columns).`,
);
}
if (rowSpan < 1) {
throw new TableValidationError(`Row span must be greater than 0.`);
}
if (columnSpan < 1) {
throw new TableValidationError(`Column span must be greater than 0.`);
}
if (rowPos + rowSpan - 1 > this.rows.length) {
throw new TableValidationError(
`Cannot expand ${rowSpan} rows from the cell at ${rowPos},${columnPos}. Table has ${this.rows.length} rows.`,
);
}
if (columnPos + columnSpan - 1 > this.rows[0].cells.length) {
throw new TableValidationError(
`Cannot expand ${columnSpan} columns from the cell at ${rowPos},${columnPos}. Table has ${this.rows[0].cells.length} columns.`,
);
}
}
}
class TableValidationError extends Error {}