diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index ef86bfe53..2ca9b97dc 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -3,7 +3,6 @@ ## In progress - Table features - - Merge cell action - Row properties form logic - Table properties form logic - Caption text support diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 3b431141f..69d811ce2 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -21,6 +21,7 @@ import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/ import {$getParentOfType} from "../../../utils/nodes"; import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell-node"; import {showCellPropertiesForm} from "../forms/tables"; +import {$mergeTableCellsInSelection} from "../../../utils/tables"; const neverActive = (): boolean => false; const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); @@ -328,9 +329,10 @@ export const mergeCells: EditorButtonDefinition = { label: 'Merge cells', action(context: EditorUiContext) { context.editor.update(() => { - // Todo - Needs to be done manually - // Playground reference: - // https://github.com/facebook/lexical/blob/f373759a7849f473d34960a6bf4e34b2a011e762/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx#L299 + const selection = $getSelection(); + if ($isTableSelection(selection)) { + $mergeTableCellsInSelection(selection); + } }); }, isActive: neverActive, diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts index 0557b37e5..f631fb804 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts @@ -1,7 +1,6 @@ import {$getNodeByKey, LexicalEditor} from "lexical"; import {NodeKey} from "lexical/LexicalNode"; import { - $isTableNode, applyTableHandlers, HTMLTableElementWithWithTableSelectionState, TableNode, diff --git a/resources/js/wysiwyg/utils/table-map.ts b/resources/js/wysiwyg/utils/table-map.ts new file mode 100644 index 000000000..77c4eba45 --- /dev/null +++ b/resources/js/wysiwyg/utils/table-map.ts @@ -0,0 +1,96 @@ +import {CustomTableNode} from "../nodes/custom-table"; +import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell-node"; +import {$isTableRowNode} from "@lexical/table"; + +export class TableMap { + + rowCount: number = 0; + columnCount: number = 0; + + // Represents an array (rows*columns in length) of cell nodes from top-left to + // bottom right. Cells may repeat where merged and covering multiple spaces. + cells: CustomTableCellNode[] = []; + + constructor(table: CustomTableNode) { + this.buildCellMap(table); + } + + protected buildCellMap(table: CustomTableNode) { + const rowsAndCells: CustomTableCellNode[][] = []; + const setCell = (x: number, y: number, cell: CustomTableCellNode) => { + if (typeof rowsAndCells[y] === 'undefined') { + rowsAndCells[y] = []; + } + + rowsAndCells[y][x] = cell; + }; + const cellFilled = (x: number, y: number): boolean => !!(rowsAndCells[y] && rowsAndCells[y][x]); + + const rowNodes = table.getChildren().filter(r => $isTableRowNode(r)); + for (let rowIndex = 0; rowIndex < rowNodes.length; rowIndex++) { + const rowNode = rowNodes[rowIndex]; + const cellNodes = rowNode.getChildren().filter(c => $isCustomTableCellNode(c)); + let targetColIndex: number = 0; + for (let cellIndex = 0; cellIndex < cellNodes.length; cellIndex++) { + const cellNode = cellNodes[cellIndex]; + const colspan = cellNode.getColSpan() || 1; + const rowSpan = cellNode.getRowSpan() || 1; + for (let x = targetColIndex; x < targetColIndex + colspan; x++) { + for (let y = rowIndex; y < rowIndex + rowSpan; y++) { + while (cellFilled(x, y)) { + targetColIndex += 1; + x += 1; + } + + setCell(x, y, cellNode); + } + } + targetColIndex += colspan; + } + } + + this.rowCount = rowsAndCells.length; + this.columnCount = Math.max(...rowsAndCells.map(r => r.length)); + + const cells = []; + let lastCell: CustomTableCellNode = rowsAndCells[0][0]; + for (let y = 0; y < this.rowCount; y++) { + for (let x = 0; x < this.columnCount; x++) { + if (!rowsAndCells[y] || !rowsAndCells[y][x]) { + cells.push(lastCell); + } else { + cells.push(rowsAndCells[y][x]); + lastCell = rowsAndCells[y][x]; + } + } + } + + this.cells = cells; + } + + public getCellAtPosition(x: number, y: number): CustomTableCellNode { + const position = (y * this.columnCount) + x; + if (position >= this.cells.length) { + throw new Error(`TableMap Error: Attempted to get cell ${position+1} of ${this.cells.length}`); + } + + return this.cells[position]; + } + + public getCellsInRange(fromX: number, fromY: number, toX: number, toY: number): CustomTableCellNode[] { + const minX = Math.max(Math.min(fromX, toX), 0); + const maxX = Math.min(Math.max(fromX, toX), this.columnCount - 1); + const minY = Math.max(Math.min(fromY, toY), 0); + const maxY = Math.min(Math.max(fromY, toY), this.rowCount - 1); + + const cells = new Set(); + + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + cells.add(this.getCellAtPosition(x, y)); + } + } + + return [...cells.values()]; + } +} diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index 959c8a423..d4ef80f7f 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -1,10 +1,11 @@ import {BaseSelection, LexicalEditor} from "lexical"; -import {$isTableRowNode, $isTableSelection, TableRowNode} from "@lexical/table"; +import {$isTableRowNode, $isTableSelection, TableRowNode, TableSelection, TableSelectionShape} from "@lexical/table"; import {$isCustomTableNode, CustomTableNode} from "../nodes/custom-table"; import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell-node"; import {$getParentOfType} from "./nodes"; import {$getNodeFromSelection} from "./selection"; import {formatSizeValue} from "./dom"; +import {TableMap} from "./table-map"; function $getTableFromCell(cell: CustomTableCellNode): CustomTableNode|null { return $getParentOfType(cell, $isCustomTableNode) as CustomTableNode|null; @@ -131,4 +132,62 @@ export function $getTableCellsFromSelection(selection: BaseSelection|null): Cust const cell = $getNodeFromSelection(selection, $isCustomTableCellNode) as CustomTableCellNode; return cell ? [cell] : []; -} \ No newline at end of file +} + +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); +} + + + + + + + + + + +