From 071b5763e90f2ac809273cf5dd918cde644f585e Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Fri, 11 Jul 2025 17:35:18 +0530 Subject: [PATCH 1/2] fix: delete last cell of a table --- .../components/menus/bubble-menu/root.tsx | 7 +- .../table/plugins/selection-outline/plugin.ts | 5 +- .../plugins/table-selection-outline/plugin.ts | 58 -------------- .../plugins/table-selection-outline/utils.ts | 75 ------------------- .../src/core/extensions/table/table-cell.ts | 58 ++++++++++++++ .../extensions/table/table/table-view.tsx | 4 +- .../delete-table-when-all-cells-selected.ts | 2 +- .../table/table/utilities/helpers.ts | 9 +++ .../table/utilities/is-cell-selection.ts | 5 -- packages/editor/src/index.ts | 2 +- 10 files changed, 79 insertions(+), 146 deletions(-) delete mode 100644 packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts delete mode 100644 packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts create mode 100644 packages/editor/src/core/extensions/table/table/utilities/helpers.ts delete mode 100644 packages/editor/src/core/extensions/table/table/utilities/is-cell-selection.ts diff --git a/packages/editor/src/core/components/menus/bubble-menu/root.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx index fa5427c3b44..05e8911491c 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -21,10 +21,11 @@ import { import { COLORS_LIST } from "@/constants/common"; import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions -import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; -// local components -import { TextAlignmentSelector } from "./alignment-selector"; +import { isCellSelection } from "@/extensions/table/table/utilities/helpers"; +// types import { TEditorCommands } from "@/types"; +// local imports +import { TextAlignmentSelector } from "./alignment-selector"; type EditorBubbleMenuProps = Omit; 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 0e88d8c7797..834ea3e448e 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 f4c43e77ee6..00000000000 --- 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-cell.ts b/packages/editor/src/core/extensions/table/table-cell.ts index a9c98717bef..26be9e16d0c 100644 --- a/packages/editor/src/core/extensions/table/table-cell.ts +++ b/packages/editor/src/core/extensions/table/table-cell.ts @@ -1,9 +1,11 @@ import { mergeAttributes, Node } from "@tiptap/core"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; // constants import { CORE_EXTENSIONS } from "@/constants/extension"; // local imports import { TableCellSelectionOutlinePlugin } from "./plugins/selection-outline/plugin"; import { DEFAULT_COLUMN_WIDTH } from "./table"; +import { isCellSelection } from "./table/utilities/helpers"; export interface TableCellOptions { HTMLAttributes: Record; @@ -54,6 +56,62 @@ export const TableCell = Node.create({ return [TableCellSelectionOutlinePlugin(this.editor)]; }, + addKeyboardShortcuts() { + return { + Backspace: ({ editor }) => { + const { state } = editor.view; + const { selection } = state; + + // Check if we're at the start of the cell + if (selection.from !== selection.to || selection.$head.parentOffset !== 0) { + return false; + } + + // Find table and current cell + let tableNode: ProseMirrorNode | null = null; + let currentCellNode: ProseMirrorNode | null = null; + let cellPos: number | null = null; + let cellDepth: number | null = null; + + for (let depth = selection.$head.depth; depth > 0; depth--) { + const node = selection.$head.node(depth); + if (node.type.name === CORE_EXTENSIONS.TABLE) { + tableNode = node; + } + if (node.type.name === CORE_EXTENSIONS.TABLE_CELL || node.type.name === CORE_EXTENSIONS.TABLE_HEADER) { + currentCellNode = node; + cellPos = selection.$head.start(depth); + cellDepth = depth; + } + } + + if (!tableNode || !currentCellNode || cellPos === null || cellDepth === null) return false; + + // Check if this is the only cell in the TableMap (1 row, 1 column) + const isOnlyCell = tableNode.childCount === 1 && tableNode.firstChild?.childCount === 1; + if (!isOnlyCell) return false; + // Check if cell is selected (CellSelection) + const isCellSelected = isCellSelection(selection); + + if (isCellSelected) { + // Cell is already selected, delete the TableNode + editor.chain().focus().deleteTable().run(); + return true; + } else { + // Cell has content, select the entire cell + // Use the position that points to the cell node itself, not its content + const cellNodePos = selection.$head.before(cellDepth); + + editor.commands.setCellSelection({ + anchorCell: cellNodePos, + headCell: cellNodePos, + }); + return true; + } + }, + }; + }, + parseHTML() { return [{ tag: "td" }]; }, 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 c3466ba59a1..2ccdc3c6df5 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/utilities/delete-table-when-all-cells-selected.ts b/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts index 5c84b8617da..31afd53a82a 100644 --- 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 @@ -2,7 +2,7 @@ import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/cor // constants import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions -import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; +import { isCellSelection } from "@/extensions/table/table/utilities/helpers"; export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => { const { selection } = editor.state; diff --git a/packages/editor/src/core/extensions/table/table/utilities/helpers.ts b/packages/editor/src/core/extensions/table/table/utilities/helpers.ts new file mode 100644 index 00000000000..d5540a27b68 --- /dev/null +++ b/packages/editor/src/core/extensions/table/table/utilities/helpers.ts @@ -0,0 +1,9 @@ +import type { Selection } from "@tiptap/pm/state"; +import { CellSelection } from "@tiptap/pm/tables"; + +/** + * @description Check if the selection is a cell selection + * @param {Selection} selection - The selection to check + * @returns {boolean} True if the selection is a cell selection, false otherwise + */ +export const isCellSelection = (selection: Selection): selection is CellSelection => selection instanceof CellSelection; diff --git a/packages/editor/src/core/extensions/table/table/utilities/is-cell-selection.ts b/packages/editor/src/core/extensions/table/table/utilities/is-cell-selection.ts deleted file mode 100644 index 42ea5759c9b..00000000000 --- a/packages/editor/src/core/extensions/table/table/utilities/is-cell-selection.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CellSelection } from "@tiptap/pm/tables"; - -export function isCellSelection(value: unknown): value is CellSelection { - return value instanceof CellSelection; -} diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 5484d0affaf..9cb374dce24 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -15,7 +15,7 @@ export { RichTextEditorWithRef, } from "@/components/editors"; -export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; +export { isCellSelection } from "@/extensions/table/table/utilities/helpers"; // constants export * from "@/constants/common"; From 6acffb55a47c82fa16d6b818e9e5c2323dcbbbea Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 17 Jul 2025 17:37:22 +0530 Subject: [PATCH 2/2] refactor: last cell selection logic --- .../src/core/extensions/table/table-cell.ts | 65 ++++++++----------- .../insert-line-above-table-action.ts | 2 +- .../insert-line-below-table-action.ts | 2 +- packages/editor/src/core/helpers/common.ts | 20 ++++-- 4 files changed, 44 insertions(+), 45 deletions(-) diff --git a/packages/editor/src/core/extensions/table/table-cell.ts b/packages/editor/src/core/extensions/table/table-cell.ts index 26be9e16d0c..42fd3c7df5b 100644 --- a/packages/editor/src/core/extensions/table/table-cell.ts +++ b/packages/editor/src/core/extensions/table/table-cell.ts @@ -1,7 +1,9 @@ import { mergeAttributes, Node } from "@tiptap/core"; -import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { TableMap } from "@tiptap/pm/tables"; // constants import { CORE_EXTENSIONS } from "@/constants/extension"; +// helpers +import { findParentNodeOfType } from "@/helpers/common"; // local imports import { TableCellSelectionOutlinePlugin } from "./plugins/selection-outline/plugin"; import { DEFAULT_COLUMN_WIDTH } from "./table"; @@ -62,52 +64,37 @@ export const TableCell = Node.create({ const { state } = editor.view; const { selection } = state; + if (isCellSelection(selection)) return false; + // Check if we're at the start of the cell - if (selection.from !== selection.to || selection.$head.parentOffset !== 0) { - return false; - } + if (selection.from !== selection.to || selection.$head.parentOffset !== 0) return false; // Find table and current cell - let tableNode: ProseMirrorNode | null = null; - let currentCellNode: ProseMirrorNode | null = null; - let cellPos: number | null = null; - let cellDepth: number | null = null; - - for (let depth = selection.$head.depth; depth > 0; depth--) { - const node = selection.$head.node(depth); - if (node.type.name === CORE_EXTENSIONS.TABLE) { - tableNode = node; - } - if (node.type.name === CORE_EXTENSIONS.TABLE_CELL || node.type.name === CORE_EXTENSIONS.TABLE_HEADER) { - currentCellNode = node; - cellPos = selection.$head.start(depth); - cellDepth = depth; - } - } + const tableNode = findParentNodeOfType(selection, [CORE_EXTENSIONS.TABLE])?.node; + const currentCellInfo = findParentNodeOfType(selection, [ + CORE_EXTENSIONS.TABLE_CELL, + CORE_EXTENSIONS.TABLE_HEADER, + ]); + const currentCellNode = currentCellInfo?.node; + const cellPos = currentCellInfo?.pos; + const cellDepth = currentCellInfo?.depth; if (!tableNode || !currentCellNode || cellPos === null || cellDepth === null) return false; // Check if this is the only cell in the TableMap (1 row, 1 column) - const isOnlyCell = tableNode.childCount === 1 && tableNode.firstChild?.childCount === 1; + const tableMap = TableMap.get(tableNode); + const isOnlyCell = tableMap.width === 1 && tableMap.height === 1; if (!isOnlyCell) return false; - // Check if cell is selected (CellSelection) - const isCellSelected = isCellSelection(selection); - - if (isCellSelected) { - // Cell is already selected, delete the TableNode - editor.chain().focus().deleteTable().run(); - return true; - } else { - // Cell has content, select the entire cell - // Use the position that points to the cell node itself, not its content - const cellNodePos = selection.$head.before(cellDepth); - - editor.commands.setCellSelection({ - anchorCell: cellNodePos, - headCell: cellNodePos, - }); - return true; - } + + // Cell has content, select the entire cell + // Use the position that points to the cell node itself, not its content + const cellNodePos = selection.$head.before(cellDepth); + + editor.commands.setCellSelection({ + anchorCell: cellNodePos, + headCell: cellNodePos, + }); + return true; }, }; }, diff --git a/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts b/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts index 35c2ee3c713..9a50a839c46 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts @@ -13,7 +13,7 @@ export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) const { selection } = editor.state; // Find the table node and its position - const tableNode = findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE); + const tableNode = findParentNodeOfType(selection, [CORE_EXTENSIONS.TABLE]); if (!tableNode) return false; const tablePos = tableNode.pos; diff --git a/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts b/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts index 6c26e22a2f6..b30b8ae4dd6 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts @@ -13,7 +13,7 @@ export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) const { selection } = editor.state; // Find the table node and its position - const tableNode = findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE); + const tableNode = findParentNodeOfType(selection, [CORE_EXTENSIONS.TABLE]); if (!tableNode) return false; const tablePos = tableNode.pos; diff --git a/packages/editor/src/core/helpers/common.ts b/packages/editor/src/core/helpers/common.ts index e694e1e8539..301d81917a3 100644 --- a/packages/editor/src/core/helpers/common.ts +++ b/packages/editor/src/core/helpers/common.ts @@ -1,3 +1,4 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { EditorState, Selection } from "@tiptap/pm/state"; // plane imports import { cn } from "@plane/utils"; @@ -21,17 +22,28 @@ export const getEditorClassNames = ({ noBorder, borderOnFocus, containerClassNam ); // Helper function to find the parent node of a specific type -export function findParentNodeOfType(selection: Selection, typeName: string) { +export const findParentNodeOfType = ( + selection: Selection, + typeName: string[] +): { + node: ProseMirrorNode; + pos: number; + depth: number; +} | null => { let depth = selection.$anchor.depth; while (depth > 0) { const node = selection.$anchor.node(depth); - if (node.type.name === typeName) { - return { node, pos: selection.$anchor.start(depth) - 1 }; + if (typeName.includes(node.type.name)) { + return { + node, + pos: selection.$anchor.start(depth) - 1, + depth, + }; } depth--; } return null; -} +}; export const findTableAncestor = (node: Node | null): HTMLTableElement | null => { while (node !== null && node.nodeName !== "TABLE") {