[WIKI-554] feat: delete multiple rows/columns from a table (#7426)

This commit is contained in:
Aaryan Khandelwal
2025-07-17 12:52:33 +05:30
committed by GitHub
parent 89983b06d2
commit d8f2c97810
9 changed files with 240 additions and 201 deletions

View File

@@ -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 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@@ -319,27 +321,6 @@ const removeLastColumn = (editor: Editor, tableInfo: TableInfo): boolean => {
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);

View File

@@ -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<TableCel
}
const { selection } = newState;
if (!(selection instanceof CellSelection)) return {};
if (!isCellSelection(selection)) return {};
const decorations: Decoration[] = [];
const tableMap = TableMap.get(table.node);

View File

@@ -1,58 +0,0 @@
import { findParentNode, type Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
// local imports
import { getCellBorderClasses } from "./utils";
type TableCellSelectionOutlinePluginState = {
decorations?: DecorationSet;
};
const TABLE_SELECTION_OUTLINE_PLUGIN_KEY = new PluginKey("table-cell-selection-outline");
export const TableCellSelectionOutlinePlugin = (editor: Editor): Plugin<TableCellSelectionOutlinePluginState> =>
new Plugin<TableCellSelectionOutlinePluginState>({
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;
},
},
});

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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<TableOptions>({
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,
};

View File

@@ -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,
});
};

View File

@@ -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;
};

View File

@@ -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;
};