diff --git a/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts b/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts index 1306e7919a..c8dc5f4794 100644 --- a/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts +++ b/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts @@ -1,6 +1,8 @@ import type { Editor } from "@tiptap/core"; import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { addColumn, removeColumn, addRow, removeRow, TableMap } from "@tiptap/pm/tables"; +// local imports +import { isCellEmpty } from "../../table/utilities/helpers"; const addSvg = ` { return true; }; -// Helper function to check if a single cell is empty -const isCellEmpty = (cell: ProseMirrorNode | null | undefined): boolean => { - if (!cell || cell.content.size === 0) { - return true; - } - - // Check if cell has any non-empty content - let hasContent = false; - cell.content.forEach((node) => { - if (node.type.name === "paragraph") { - if (node.content.size > 0) { - hasContent = true; - } - } else if (node.content.size > 0 || node.isText) { - hasContent = true; - } - }); - - return !hasContent; -}; - const isColumnEmpty = (tableInfo: TableInfo, columnIndex: number): boolean => { const { tableNode } = tableInfo; const tableMapData = TableMap.get(tableNode); diff --git a/packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts b/packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts index 0e88d8c779..834ea3e448 100644 --- a/packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts +++ b/packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts @@ -1,8 +1,9 @@ import { findParentNode, type Editor } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; -import { CellSelection, TableMap } from "@tiptap/pm/tables"; +import { TableMap } from "@tiptap/pm/tables"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; // local imports +import { isCellSelection } from "../../table/utilities/helpers"; import { getCellBorderClasses } from "./utils"; type TableCellSelectionOutlinePluginState = { @@ -25,7 +26,7 @@ export const TableCellSelectionOutlinePlugin = (editor: Editor): Plugin => - new Plugin({ - key: TABLE_SELECTION_OUTLINE_PLUGIN_KEY, - state: { - init: () => ({}), - apply(tr, prev, oldState, newState) { - if (!editor.isEditable) return {}; - const table = findParentNode((node) => node.type.spec.tableRole === "table")(newState.selection); - const hasDocChanged = tr.docChanged || !newState.selection.eq(oldState.selection); - if (!table || !hasDocChanged) { - return table === undefined ? {} : prev; - } - - const { selection } = newState; - if (!(selection instanceof CellSelection)) return {}; - - const decorations: Decoration[] = []; - const tableMap = TableMap.get(table.node); - const selectedCells: number[] = []; - - // First, collect all selected cell positions - selection.forEachCell((_node, pos) => { - const start = pos - table.pos - 1; - selectedCells.push(start); - }); - - // Then, add decorations with appropriate border classes - selection.forEachCell((node, pos) => { - const start = pos - table.pos - 1; - const classes = getCellBorderClasses(start, selectedCells, tableMap); - - decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: classes.join(" ") })); - }); - - return { - decorations: DecorationSet.create(newState.doc, decorations), - }; - }, - }, - props: { - decorations(state) { - return TABLE_SELECTION_OUTLINE_PLUGIN_KEY.getState(state).decorations; - }, - }, - }); diff --git a/packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts b/packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts deleted file mode 100644 index f4c43e77ee..0000000000 --- a/packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { TableMap } from "@tiptap/pm/tables"; - -/** - * Calculates the positions of cells adjacent to a given cell in a table - * @param cellStart - The start position of the current cell in the document - * @param tableMap - ProseMirror's table mapping structure containing cell positions and dimensions - * @returns Object with positions of adjacent cells (undefined if cell doesn't exist at table edge) - */ -const getAdjacentCellPositions = ( - cellStart: number, - tableMap: TableMap -): { top?: number; bottom?: number; left?: number; right?: number } => { - // Extract table dimensions - // width -> number of columns in the table - // height -> number of rows in the table - const { width, height } = tableMap; - - // Find the index of our cell in the flat tableMap.map array - // tableMap.map contains start positions of all cells in row-by-row order - const cellIndex = tableMap.map.indexOf(cellStart); - - // Safety check: if cell position not found in table map, return empty object - if (cellIndex === -1) return {}; - - // Convert flat array index to 2D grid coordinates - // row = which row the cell is in (0-based from top) - // col = which column the cell is in (0-based from left) - const row = Math.floor(cellIndex / width); // Integer division gives row number - const col = cellIndex % width; // Remainder gives column number - - return { - // Top cell: same column, one row up - // Check if we're not in the first row (row > 0) before calculating - top: row > 0 ? tableMap.map[(row - 1) * width + col] : undefined, - - // Bottom cell: same column, one row down - // Check if we're not in the last row (row < height - 1) before calculating - bottom: row < height - 1 ? tableMap.map[(row + 1) * width + col] : undefined, - - // Left cell: same row, one column left - // Check if we're not in the first column (col > 0) before calculating - left: col > 0 ? tableMap.map[row * width + (col - 1)] : undefined, - - // Right cell: same row, one column right - // Check if we're not in the last column (col < width - 1) before calculating - right: col < width - 1 ? tableMap.map[row * width + (col + 1)] : undefined, - }; -}; - -export const getCellBorderClasses = (cellStart: number, selectedCells: number[], tableMap: TableMap): string[] => { - const adjacent = getAdjacentCellPositions(cellStart, tableMap); - const classes: string[] = []; - - // Add border-right if right cell is not selected or doesn't exist - if (adjacent.right === undefined || !selectedCells.includes(adjacent.right)) { - classes.push("selectedCell-border-right"); - } - - // Add border-left if left cell is not selected or doesn't exist - if (adjacent.left === undefined || !selectedCells.includes(adjacent.left)) { - classes.push("selectedCell-border-left"); - } - - // Add border-top if top cell is not selected or doesn't exist - if (adjacent.top === undefined || !selectedCells.includes(adjacent.top)) { - classes.push("selectedCell-border-top"); - } - - // Add border-bottom if bottom cell is not selected or doesn't exist - if (adjacent.bottom === undefined || !selectedCells.includes(adjacent.bottom)) { - classes.push("selectedCell-border-bottom"); - } - - return classes; -}; diff --git a/packages/editor/src/core/extensions/table/table/table-view.tsx b/packages/editor/src/core/extensions/table/table/table-view.tsx index c3466ba59a..2ccdc3c6df 100644 --- a/packages/editor/src/core/extensions/table/table/table-view.tsx +++ b/packages/editor/src/core/extensions/table/table/table-view.tsx @@ -7,6 +7,8 @@ import { icons } from "src/core/extensions/table/table/icons"; import tippy, { Instance, Props } from "tippy.js"; // constants import { CORE_EXTENSIONS } from "@/constants/extension"; +// local imports +import { isCellSelection } from "./utilities/helpers"; type ToolboxItem = { label: string; @@ -95,7 +97,7 @@ function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: strin function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) { const { state, dispatch } = editor.view; const { selection } = state; - if (!(selection instanceof CellSelection)) { + if (!isCellSelection(selection)) { return false; } diff --git a/packages/editor/src/core/extensions/table/table/table.ts b/packages/editor/src/core/extensions/table/table/table.ts index e20f42cfba..42ff67177c 100644 --- a/packages/editor/src/core/extensions/table/table/table.ts +++ b/packages/editor/src/core/extensions/table/table/table.ts @@ -26,8 +26,8 @@ import { tableControls } from "./table-controls"; import { TableView } from "./table-view"; import { createTable } from "./utilities/create-table"; import { deleteColumnOrTable } from "./utilities/delete-column"; +import { handleDeleteKeyOnTable } from "./utilities/delete-key-shortcut"; import { deleteRowOrTable } from "./utilities/delete-row"; -import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected"; import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action"; import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action"; import { DEFAULT_COLUMN_WIDTH } from "."; @@ -236,10 +236,10 @@ export const Table = Node.create({ return false; }, "Shift-Tab": () => this.editor.commands.goToPreviousCell(), - Backspace: deleteTableWhenAllCellsSelected, - "Mod-Backspace": deleteTableWhenAllCellsSelected, - Delete: deleteTableWhenAllCellsSelected, - "Mod-Delete": deleteTableWhenAllCellsSelected, + Backspace: handleDeleteKeyOnTable, + "Mod-Backspace": handleDeleteKeyOnTable, + Delete: handleDeleteKeyOnTable, + "Mod-Delete": handleDeleteKeyOnTable, ArrowDown: insertLineBelowTableAction, ArrowUp: insertLineAboveTableAction, }; diff --git a/packages/editor/src/core/extensions/table/table/utilities/delete-key-shortcut.ts b/packages/editor/src/core/extensions/table/table/utilities/delete-key-shortcut.ts new file mode 100644 index 0000000000..99f343651e --- /dev/null +++ b/packages/editor/src/core/extensions/table/table/utilities/delete-key-shortcut.ts @@ -0,0 +1,201 @@ +import { Editor, findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { CellSelection, TableMap } from "@tiptap/pm/tables"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// extensions +import { isCellEmpty, isCellSelection } from "@/extensions/table/table/utilities/helpers"; + +interface CellCoord { + row: number; + col: number; +} + +interface TableInfo { + node: ProseMirrorNode; + pos: number; + map: TableMap; + totalColumns: number; + totalRows: number; +} + +export const handleDeleteKeyOnTable: KeyboardShortcutCommand = (props) => { + const { editor } = props; + const { selection } = editor.state; + + try { + if (!isCellSelection(selection)) return false; + + const tableInfo = getTableInfo(editor); + if (!tableInfo) return false; + + const selectedCellCoords = getSelectedCellCoords(selection, tableInfo); + if (selectedCellCoords.length === 0) return false; + + const hasContent = checkCellsHaveContent(selection); + if (hasContent) return false; + + const selectionBounds = calculateSelectionBounds(selectedCellCoords); + const { totalColumnsInSelection, totalRowsInSelection, minRow, minCol } = selectionBounds; + + // Check if entire rows are selected + if (totalColumnsInSelection === tableInfo.totalColumns) { + return deleteMultipleRows(editor, totalRowsInSelection, minRow, tableInfo); + } + + // Check if entire columns are selected + if (totalRowsInSelection === tableInfo.totalRows) { + return deleteMultipleColumns(editor, totalColumnsInSelection, minCol, tableInfo); + } + + return false; + } catch (error) { + console.error("Error in handleDeleteKeyOnTable", error); + return false; + } +}; + +const getTableInfo = (editor: Editor): TableInfo | null => { + const table = findParentNodeClosestToPos( + editor.state.selection.ranges[0].$from, + (node) => node.type.name === CORE_EXTENSIONS.TABLE + ); + + if (!table) return null; + + const tableMap = TableMap.get(table.node); + return { + node: table.node, + pos: table.pos, + map: tableMap, + totalColumns: tableMap.width, + totalRows: tableMap.height, + }; +}; + +const getSelectedCellCoords = (selection: CellSelection, tableInfo: TableInfo): CellCoord[] => { + const selectedCellCoords: CellCoord[] = []; + + selection.forEachCell((_node, pos) => { + const cellStart = pos - tableInfo.pos - 1; + const coord = findCellCoordinate(cellStart, tableInfo); + + if (coord) { + selectedCellCoords.push(coord); + } + }); + + return selectedCellCoords; +}; + +const findCellCoordinate = (cellStart: number, tableInfo: TableInfo): CellCoord | null => { + // Primary method: use indexOf + const cellIndex = tableInfo.map.map.indexOf(cellStart); + + if (cellIndex !== -1) { + return { + row: Math.floor(cellIndex / tableInfo.totalColumns), + col: cellIndex % tableInfo.totalColumns, + }; + } + + // Fallback: manual search + for (let i = 0; i < tableInfo.map.map.length; i++) { + if (tableInfo.map.map[i] === cellStart) { + return { + row: Math.floor(i / tableInfo.totalColumns), + col: i % tableInfo.totalColumns, + }; + } + } + + return null; +}; + +const checkCellsHaveContent = (selection: CellSelection): boolean => { + let hasContent = false; + + selection.forEachCell((node) => { + if (node && !isCellEmpty(node)) { + hasContent = true; + } + }); + + return hasContent; +}; + +const calculateSelectionBounds = (selectedCellCoords: CellCoord[]) => { + const minRow = Math.min(...selectedCellCoords.map((c) => c.row)); + const maxRow = Math.max(...selectedCellCoords.map((c) => c.row)); + const minCol = Math.min(...selectedCellCoords.map((c) => c.col)); + const maxCol = Math.max(...selectedCellCoords.map((c) => c.col)); + + return { + minRow, + maxRow, + minCol, + maxCol, + totalColumnsInSelection: maxCol - minCol + 1, + totalRowsInSelection: maxRow - minRow + 1, + }; +}; + +const deleteMultipleRows = ( + editor: Editor, + totalRowsInSelection: number, + minRow: number, + initialTableInfo: TableInfo +): boolean => { + // Position cursor at the first selected row + setCursorAtPosition(editor, initialTableInfo, minRow, 0); + + // Delete rows one by one + for (let i = 0; i < totalRowsInSelection; i++) { + editor.commands.deleteRow(); + + // Reposition cursor if there are more rows to delete + if (i < totalRowsInSelection - 1) { + const updatedTableInfo = getTableInfo(editor); + if (updatedTableInfo) { + setCursorAtPosition(editor, updatedTableInfo, minRow, 0); + } + } + } + + return true; +}; + +const deleteMultipleColumns = ( + editor: Editor, + totalColumnsInSelection: number, + minCol: number, + initialTableInfo: TableInfo +): boolean => { + // Position cursor at the first selected column + setCursorAtPosition(editor, initialTableInfo, 0, minCol); + + // Delete columns one by one + for (let i = 0; i < totalColumnsInSelection; i++) { + editor.commands.deleteColumn(); + + // Reposition cursor if there are more columns to delete + if (i < totalColumnsInSelection - 1) { + const updatedTableInfo = getTableInfo(editor); + if (updatedTableInfo) { + setCursorAtPosition(editor, updatedTableInfo, 0, minCol); + } + } + } + + return true; +}; + +const setCursorAtPosition = (editor: Editor, tableInfo: TableInfo, row: number, col: number): void => { + const cellIndex = row * tableInfo.totalColumns + col; + const cellPos = tableInfo.pos + tableInfo.map.map[cellIndex] + 1; + + editor.commands.setCellSelection({ + anchorCell: cellPos, + headCell: cellPos, + }); +}; diff --git a/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts b/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts deleted file mode 100644 index 31afd53a82..0000000000 --- a/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"; -// constants -import { CORE_EXTENSIONS } from "@/constants/extension"; -// extensions -import { isCellSelection } from "@/extensions/table/table/utilities/helpers"; - -export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => { - const { selection } = editor.state; - - if (!isCellSelection(selection)) { - return false; - } - - let cellCount = 0; - const table = findParentNodeClosestToPos( - selection.ranges[0].$from, - (node) => node.type.name === CORE_EXTENSIONS.TABLE - ); - - table?.node.descendants((node) => { - if (node.type.name === CORE_EXTENSIONS.TABLE) { - return false; - } - - if ([CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER].includes(node.type.name as CORE_EXTENSIONS)) { - cellCount += 1; - } - }); - - const allCellsSelected = cellCount === selection.ranges.length; - - if (!allCellsSelected) { - return false; - } - - editor.commands.deleteTable(); - - return true; -}; diff --git a/packages/editor/src/core/extensions/table/table/utilities/helpers.ts b/packages/editor/src/core/extensions/table/table/utilities/helpers.ts index d5540a27b6..4d72180609 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/helpers.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/helpers.ts @@ -1,3 +1,4 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import type { Selection } from "@tiptap/pm/state"; import { CellSelection } from "@tiptap/pm/tables"; @@ -7,3 +8,28 @@ import { CellSelection } from "@tiptap/pm/tables"; * @returns {boolean} True if the selection is a cell selection, false otherwise */ export const isCellSelection = (selection: Selection): selection is CellSelection => selection instanceof CellSelection; + +/** + * @description Check if a cell is empty + * @param {ProseMirrorNode | null} cell - The cell to check + * @returns {boolean} True if the cell is empty, false otherwise + */ +export const isCellEmpty = (cell: ProseMirrorNode | null): boolean => { + if (!cell || cell.content.size === 0) { + return true; + } + + // Check if cell has any non-empty content + let hasContent = false; + cell.content.forEach((node) => { + if (node.type.name === "paragraph") { + if (node.content.size > 0) { + hasContent = true; + } + } else if (node.content.size > 0 || node.isText) { + hasContent = true; + } + }); + + return !hasContent; +};