[WIKI-497] feat: table insert column and row handles (#7286)

* refactor: tables width and selection UI

* fix: drag handle position

* refactor: selection decorator logic

* refactor: adjacent cells logic

* refactor: folder structure

* chore: default column width for new columns

* refactor: plugin location

* feat: table insert handlers

* refactor: css rules

* refactor: plugins folder structure

* chore: add aria labels
This commit is contained in:
Aaryan Khandelwal 2025-07-09 17:59:04 +05:30 committed by GitHub
parent 853423608c
commit fcb6e269a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 724 additions and 3 deletions

View file

@ -0,0 +1,87 @@
import { type Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
// local imports
import { createColumnInsertButton, createRowInsertButton, findAllTables, TableInfo } from "./utils";
const TABLE_INSERT_PLUGIN_KEY = new PluginKey("table-insert");
export const TableInsertPlugin = (editor: Editor): Plugin => {
const tableMap = new Map<HTMLElement, TableInfo>();
const setupTable = (tableInfo: TableInfo) => {
const { tableElement } = tableInfo;
// Create and add column button if it doesn't exist
if (!tableInfo.columnButtonElement) {
const columnButton = createColumnInsertButton(editor, tableInfo);
tableElement.appendChild(columnButton);
tableInfo.columnButtonElement = columnButton;
}
// Create and add row button if it doesn't exist
if (!tableInfo.rowButtonElement) {
const rowButton = createRowInsertButton(editor, tableInfo);
tableElement.appendChild(rowButton);
tableInfo.rowButtonElement = rowButton;
}
tableMap.set(tableElement, tableInfo);
};
const cleanupTable = (tableElement: HTMLElement) => {
const tableInfo = tableMap.get(tableElement);
tableInfo?.columnButtonElement?.remove();
tableInfo?.rowButtonElement?.remove();
tableMap.delete(tableElement);
};
const updateAllTables = () => {
if (!editor.isEditable) {
// Clean up all tables if editor is not editable
tableMap.forEach((_, tableElement) => {
cleanupTable(tableElement);
});
return;
}
const currentTables = findAllTables(editor);
const currentTableElements = new Set(currentTables.map((t) => t.tableElement));
// Remove buttons from tables that no longer exist
tableMap.forEach((_, tableElement) => {
if (!currentTableElements.has(tableElement)) {
cleanupTable(tableElement);
}
});
// Add buttons to new tables
currentTables.forEach((tableInfo) => {
if (!tableMap.has(tableInfo.tableElement)) {
setupTable(tableInfo);
}
});
};
return new Plugin({
key: TABLE_INSERT_PLUGIN_KEY,
view() {
setTimeout(updateAllTables, 0);
return {
update(view, prevState) {
// Update when document changes
if (!prevState.doc.eq(view.state.doc)) {
updateAllTables();
}
},
destroy() {
// Clean up all tables
tableMap.forEach((_, tableElement) => {
cleanupTable(tableElement);
});
tableMap.clear();
},
};
},
});
};

View file

@ -0,0 +1,430 @@
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";
const addSvg = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8.5 7.49988V3.49988C8.5 3.22374 8.27614 2.99988 8 2.99988C7.72386 2.99988 7.5 3.22374 7.5 3.49988L7.5 7.49988L3.5 7.49988C3.22386 7.49988 3 7.72374 3 7.99988C3 8.27602 3.22386 8.49988 3.5 8.49988H7.5L7.5 12.4999C7.5 12.776 7.72386 12.9999 8 12.9999C8.27614 12.9999 8.5 12.776 8.5 12.4999L8.5 8.49988L12.5 8.49988C12.7761 8.49988 13 8.27602 13 7.99988C13 7.72374 12.7761 7.49988 12.5 7.49988L8.5 7.49988Z"
fill="currentColor"
/>
</svg>`;
export type TableInfo = {
tableElement: HTMLElement;
tableNode: ProseMirrorNode;
tablePos: number;
columnButtonElement?: HTMLElement;
rowButtonElement?: HTMLElement;
};
export const createColumnInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "table-column-insert-button";
button.title = "Insert columns";
button.ariaLabel = "Insert columns";
const icon = document.createElement("span");
icon.innerHTML = addSvg;
button.appendChild(icon);
let mouseDownX = 0;
let isDragging = false;
let dragStarted = false;
let lastActionX = 0;
const DRAG_THRESHOLD = 5; // pixels to start drag
const ACTION_THRESHOLD = 150; // pixels total distance to trigger action
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return; // Only left mouse button
e.preventDefault();
e.stopPropagation();
mouseDownX = e.clientX;
lastActionX = e.clientX;
isDragging = false;
dragStarted = false;
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - mouseDownX;
const distance = Math.abs(deltaX);
// Start dragging if moved more than threshold
if (!isDragging && distance > DRAG_THRESHOLD) {
isDragging = true;
dragStarted = true;
// Visual feedback
button.classList.add("dragging");
document.body.style.userSelect = "none";
}
if (isDragging) {
const totalDistance = Math.abs(e.clientX - lastActionX);
// Only trigger action when total distance reaches threshold
if (totalDistance >= ACTION_THRESHOLD) {
// Determine direction based on current movement relative to last action point
const directionFromLastAction = e.clientX - lastActionX;
// Right direction - add columns
if (directionFromLastAction > 0) {
insertColumnAfterLast(editor, tableInfo);
lastActionX = e.clientX; // Reset action point
}
// Left direction - delete empty columns
else if (directionFromLastAction < 0) {
const deleted = removeLastColumn(editor, tableInfo);
if (deleted) {
lastActionX = e.clientX; // Reset action point
}
}
}
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (isDragging) {
// Clean up drag state
button.classList.remove("dragging");
document.body.style.cursor = "";
document.body.style.userSelect = "";
} else if (!dragStarted) {
// Handle as click if no dragging occurred
insertColumnAfterLast(editor, tableInfo);
}
isDragging = false;
dragStarted = false;
};
button.addEventListener("mousedown", onMouseDown);
// Prevent context menu and text selection
button.addEventListener("contextmenu", (e) => e.preventDefault());
button.addEventListener("selectstart", (e) => e.preventDefault());
return button;
};
export const createRowInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "table-row-insert-button";
button.title = "Insert rows";
button.ariaLabel = "Insert rows";
const icon = document.createElement("span");
icon.innerHTML = addSvg;
button.appendChild(icon);
let mouseDownY = 0;
let isDragging = false;
let dragStarted = false;
let lastActionY = 0;
const DRAG_THRESHOLD = 5; // pixels to start drag
const ACTION_THRESHOLD = 40; // pixels total distance to trigger action
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return; // Only left mouse button
e.preventDefault();
e.stopPropagation();
mouseDownY = e.clientY;
lastActionY = e.clientY;
isDragging = false;
dragStarted = false;
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
const deltaY = e.clientY - mouseDownY;
const distance = Math.abs(deltaY);
// Start dragging if moved more than threshold
if (!isDragging && distance > DRAG_THRESHOLD) {
isDragging = true;
dragStarted = true;
// Visual feedback
button.classList.add("dragging");
document.body.style.userSelect = "none";
}
if (isDragging) {
const totalDistance = Math.abs(e.clientY - lastActionY);
// Only trigger action when total distance reaches threshold
if (totalDistance >= ACTION_THRESHOLD) {
// Determine direction based on current movement relative to last action point
const directionFromLastAction = e.clientY - lastActionY;
// Down direction - add rows
if (directionFromLastAction > 0) {
insertRowAfterLast(editor, tableInfo);
lastActionY = e.clientY; // Reset action point
}
// Up direction - delete empty rows
else if (directionFromLastAction < 0) {
const deleted = removeLastRow(editor, tableInfo);
if (deleted) {
lastActionY = e.clientY; // Reset action point
}
}
}
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (isDragging) {
// Clean up drag state
button.classList.remove("dragging");
document.body.style.cursor = "";
document.body.style.userSelect = "";
} else if (!dragStarted) {
// Handle as click if no dragging occurred
insertRowAfterLast(editor, tableInfo);
}
isDragging = false;
dragStarted = false;
};
button.addEventListener("mousedown", onMouseDown);
// Prevent context menu and text selection
button.addEventListener("contextmenu", (e) => e.preventDefault());
button.addEventListener("selectstart", (e) => e.preventDefault());
return button;
};
export const findAllTables = (editor: Editor): TableInfo[] => {
const tables: TableInfo[] = [];
const tableElements = editor.view.dom.querySelectorAll("table");
tableElements.forEach((tableElement) => {
// Find the table's ProseMirror position
let tablePos = -1;
let tableNode: ProseMirrorNode | null = null;
// Walk through the document to find matching table nodes
editor.state.doc.descendants((node, pos) => {
if (node.type.spec.tableRole === "table") {
const domAtPos = editor.view.domAtPos(pos + 1);
let domTable = domAtPos.node;
// Navigate to find the table element
while (domTable && domTable.parentNode && domTable.nodeType !== Node.ELEMENT_NODE) {
domTable = domTable.parentNode;
}
while (domTable && domTable.parentNode && (domTable as HTMLElement).tagName !== "TABLE") {
domTable = domTable.parentNode;
}
if (domTable === tableElement) {
tablePos = pos;
tableNode = node;
return false; // Stop iteration
}
}
});
if (tablePos !== -1 && tableNode) {
tables.push({
tableElement,
tableNode,
tablePos,
});
}
});
return tables;
};
const getCurrentTableInfo = (editor: Editor, tableInfo: TableInfo): TableInfo => {
// Refresh table info to get latest state
const tables = findAllTables(editor);
const updated = tables.find((t) => t.tableElement === tableInfo.tableElement);
return updated || tableInfo;
};
// Column functions
const insertColumnAfterLast = (editor: Editor, tableInfo: TableInfo) => {
const currentTableInfo = getCurrentTableInfo(editor, tableInfo);
const { tableNode, tablePos } = currentTableInfo;
const tableMapData = TableMap.get(tableNode);
const lastColumnIndex = tableMapData.width;
const tr = editor.state.tr;
const rect = {
map: tableMapData,
tableStart: tablePos,
table: tableNode,
top: 0,
left: 0,
bottom: tableMapData.height - 1,
right: tableMapData.width - 1,
};
const newTr = addColumn(tr, rect, lastColumnIndex);
editor.view.dispatch(newTr);
};
const removeLastColumn = (editor: Editor, tableInfo: TableInfo): boolean => {
const currentTableInfo = getCurrentTableInfo(editor, tableInfo);
const { tableNode, tablePos } = currentTableInfo;
const tableMapData = TableMap.get(tableNode);
// Don't delete if only one column left
if (tableMapData.width <= 1) {
return false;
}
const lastColumnIndex = tableMapData.width - 1;
// Check if last column is empty
if (!isColumnEmpty(currentTableInfo, lastColumnIndex)) {
return false;
}
const tr = editor.state.tr;
const rect = {
map: tableMapData,
tableStart: tablePos,
table: tableNode,
top: 0,
left: 0,
bottom: tableMapData.height - 1,
right: tableMapData.width - 1,
};
removeColumn(tr, rect, lastColumnIndex);
editor.view.dispatch(tr);
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);
// Check each cell in the column
for (let row = 0; row < tableMapData.height; row++) {
const cellIndex = row * tableMapData.width + columnIndex;
const cellPos = tableMapData.map[cellIndex];
const cell = tableNode.nodeAt(cellPos);
if (!isCellEmpty(cell)) {
return false;
}
}
return true;
};
// Row functions
const insertRowAfterLast = (editor: Editor, tableInfo: TableInfo) => {
const currentTableInfo = getCurrentTableInfo(editor, tableInfo);
const { tableNode, tablePos } = currentTableInfo;
const tableMapData = TableMap.get(tableNode);
const lastRowIndex = tableMapData.height;
const tr = editor.state.tr;
const rect = {
map: tableMapData,
tableStart: tablePos,
table: tableNode,
top: 0,
left: 0,
bottom: tableMapData.height - 1,
right: tableMapData.width - 1,
};
const newTr = addRow(tr, rect, lastRowIndex);
editor.view.dispatch(newTr);
};
const removeLastRow = (editor: Editor, tableInfo: TableInfo): boolean => {
const currentTableInfo = getCurrentTableInfo(editor, tableInfo);
const { tableNode, tablePos } = currentTableInfo;
const tableMapData = TableMap.get(tableNode);
// Don't delete if only one row left
if (tableMapData.height <= 1) {
return false;
}
const lastRowIndex = tableMapData.height - 1;
// Check if last row is empty
if (!isRowEmpty(currentTableInfo, lastRowIndex)) {
return false;
}
const tr = editor.state.tr;
const rect = {
map: tableMapData,
tableStart: tablePos,
table: tableNode,
top: 0,
left: 0,
bottom: tableMapData.height - 1,
right: tableMapData.width - 1,
};
removeRow(tr, rect, lastRowIndex);
editor.view.dispatch(tr);
return true;
};
const isRowEmpty = (tableInfo: TableInfo, rowIndex: number): boolean => {
const { tableNode } = tableInfo;
const tableMapData = TableMap.get(tableNode);
// Check each cell in the row
for (let col = 0; col < tableMapData.width; col++) {
const cellIndex = rowIndex * tableMapData.width + col;
const cellPos = tableMapData.map[cellIndex];
const cell = tableNode.nodeAt(cellPos);
if (!isCellEmpty(cell)) {
return false;
}
}
return true;
};

View file

@ -0,0 +1,58 @@
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

@ -0,0 +1,75 @@
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

@ -2,7 +2,7 @@ import { mergeAttributes, Node } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import { TableCellSelectionOutlinePlugin } from "./plugins/table-selection-outline/plugin";
import { TableCellSelectionOutlinePlugin } from "./plugins/selection-outline/plugin";
import { DEFAULT_COLUMN_WIDTH } from "./table";
export interface TableCellOptions {

View file

@ -23,6 +23,7 @@ import { Decoration } from "@tiptap/pm/view";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import { TableInsertPlugin } from "../plugins/insert-handlers/plugin";
import { tableControls } from "./table-controls";
import { TableView } from "./table-view";
import { createTable } from "./utilities/create-table";
@ -266,6 +267,7 @@ export const Table = Node.create<TableOptions>({
allowTableNodeSelection: this.options.allowTableNodeSelection,
}),
tableControls(),
TableInsertPlugin(this.editor),
];
if (isResizable) {

View file

@ -1,10 +1,12 @@
.table-wrapper {
overflow-x: auto;
padding-bottom: 30px;
table {
position: relative;
border-collapse: collapse;
table-layout: fixed;
margin: 0.5rem 0 1rem 0;
margin: 0.5rem 0 0 0;
border: 1px solid rgba(var(--color-border-200));
width: 100%;
@ -22,6 +24,7 @@
margin-bottom: 0;
}
/* Selected cell outline */
&.selectedCell {
user-select: none;
@ -50,6 +53,7 @@
border-right: 2px solid rgba(var(--color-primary-100));
}
}
/* End selected cell outline */
}
th {
@ -65,14 +69,16 @@
}
}
/* Selected status */
&.ProseMirror-selectednode {
table {
background-color: rgba(var(--color-primary-100), 0.2);
}
}
/* End selected status */
}
/* table dropdown */
/* Column resizer */
.table-wrapper table .column-resize-handle {
position: absolute;
right: -1px;
@ -83,6 +89,7 @@
background-color: rgba(var(--color-primary-100));
pointer-events: none;
}
/* End column resizer */
.table-wrapper .table-controls {
position: absolute;
@ -146,3 +153,65 @@
opacity: 0;
pointer-events: none;
}
/* Insert buttons */
.table-wrapper {
.table-column-insert-button,
.table-row-insert-button {
position: absolute;
background-color: rgba(var(--color-background-90));
color: rgba(var(--color-text-300));
border: 1px solid rgba(var(--color-border-200));
border-radius: 4px;
display: grid;
place-items: center;
opacity: 0;
pointer-events: none;
outline: none;
z-index: 1000;
transition: all 0.2s ease;
&:hover {
background-color: rgba(var(--color-background-80));
color: rgba(var(--color-text-100));
}
&.dragging {
opacity: 1;
pointer-events: auto;
background-color: rgba(var(--color-primary-100), 0.2);
color: rgba(var(--color-text-100));
}
svg {
width: 12px;
height: 12px;
}
}
.table-column-insert-button {
top: 0;
right: -20px;
width: 20px;
height: 100%;
transform: translateX(50%);
}
.table-row-insert-button {
bottom: -20px;
left: 0;
width: 100%;
height: 20px;
transform: translateY(50%);
}
/* Show buttons on table hover */
&:hover {
.table-column-insert-button,
.table-row-insert-button {
opacity: 1;
pointer-events: auto;
}
}
}
/* End insert buttons */