From e8532ef4de7d641fabfe86ff313d379711f2209a Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Wed, 7 Aug 2024 20:32:54 +0100
Subject: [PATCH] Lexical: Added merge cell logic

---
 resources/js/wysiwyg/todo.md                  |  1 -
 .../js/wysiwyg/ui/defaults/buttons/tables.ts  |  8 +-
 .../helpers/table-selection-handler.ts        |  1 -
 resources/js/wysiwyg/utils/table-map.ts       | 96 +++++++++++++++++++
 resources/js/wysiwyg/utils/tables.ts          | 63 +++++++++++-
 5 files changed, 162 insertions(+), 7 deletions(-)
 create mode 100644 resources/js/wysiwyg/utils/table-map.ts

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<CustomTableCellNode>();
+
+        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);
+}
+
+
+
+
+
+
+
+
+
+
+