diff --git a/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts b/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts new file mode 100644 index 00000000000..97cd2d09f7a --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts @@ -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(); + + 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(); + }, + }; + }, + }); +}; 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 new file mode 100644 index 00000000000..1306e7919a1 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts @@ -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 = ` + +`; + +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; +}; 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 new file mode 100644 index 00000000000..0e88d8c7797 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts @@ -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 => + 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/selection-outline/utils.ts b/packages/editor/src/core/extensions/table/plugins/selection-outline/utils.ts new file mode 100644 index 00000000000..f4c43e77ee6 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/selection-outline/utils.ts @@ -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; +}; diff --git a/packages/editor/src/core/extensions/table/table-cell.ts b/packages/editor/src/core/extensions/table/table-cell.ts index 988fcbb14d2..a9c98717bef 100644 --- a/packages/editor/src/core/extensions/table/table-cell.ts +++ b/packages/editor/src/core/extensions/table/table-cell.ts @@ -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 { diff --git a/packages/editor/src/core/extensions/table/table/table.ts b/packages/editor/src/core/extensions/table/table/table.ts index aa2c79aaf3d..66e54adcdaf 100644 --- a/packages/editor/src/core/extensions/table/table/table.ts +++ b/packages/editor/src/core/extensions/table/table/table.ts @@ -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({ allowTableNodeSelection: this.options.allowTableNodeSelection, }), tableControls(), + TableInsertPlugin(this.editor), ]; if (isResizable) { diff --git a/packages/editor/src/styles/table.css b/packages/editor/src/styles/table.css index 8c0b1786af7..e9556c9fdba 100644 --- a/packages/editor/src/styles/table.css +++ b/packages/editor/src/styles/table.css @@ -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 */