mirror of
https://github.com/makeplane/plane.git
synced 2026-01-30 18:29:30 -06:00
[WIKI-554] feat: delete multiple rows/columns from a table (#7426)
This commit is contained in:
committed by
GitHub
parent
89983b06d2
commit
d8f2c97810
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user