From 91879d1e7ff61e31d38f090fd0652e654532645b Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Fri, 22 Aug 2025 14:00:29 +0530 Subject: [PATCH 1/5] feat: ability to rearrange columns and rows --- packages/editor/src/core/constants/meta.ts | 2 + .../table/plugins/drag-handles/actions.ts | 186 ++++++++++++++++ .../plugins/drag-handles/color-selector.tsx | 115 ++++++++++ .../drag-handles/column/drag-handle.tsx | 200 ++++++++++++++++++ .../plugins/drag-handles/column/dropdown.tsx | 100 +++++++++ .../plugins/drag-handles/column/plugin.ts | 74 +++++++ .../plugins/drag-handles/column/utils.ts | 143 +++++++++++++ .../plugins/drag-handles/marker-utils.ts | 96 +++++++++ .../plugins/drag-handles/row/drag-handle.tsx | 199 +++++++++++++++++ .../plugins/drag-handles/row/dropdown.tsx | 100 +++++++++ .../table/plugins/drag-handles/row/plugin.ts | 72 +++++++ .../table/plugins/drag-handles/row/utils.ts | 143 +++++++++++++ .../table/plugins/drag-handles/utils.ts | 60 ++++++ .../table/plugins/insert-handlers/plugin.ts | 42 +++- .../table/plugins/insert-handlers/utils.ts | 7 +- .../src/core/extensions/table/table-cell.ts | 6 +- .../src/core/extensions/table/table-header.ts | 8 +- .../extensions/table/table/table-controls.ts | 128 ----------- .../src/core/extensions/table/table/table.ts | 18 +- .../table/table/utilities/helpers.ts | 191 ++++++++++++++++- .../editor/src/core/plugins/drag-handle.ts | 11 +- packages/editor/src/styles/table.css | 54 ++++- 22 files changed, 1805 insertions(+), 150 deletions(-) create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/actions.ts create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/color-selector.tsx create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/column/dropdown.tsx create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/column/utils.ts create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/marker-utils.ts create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/row/dropdown.tsx create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/row/utils.ts create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/utils.ts delete mode 100644 packages/editor/src/core/extensions/table/table/table-controls.ts diff --git a/packages/editor/src/core/constants/meta.ts b/packages/editor/src/core/constants/meta.ts index 66769bb82c9..1a01ecaa500 100644 --- a/packages/editor/src/core/constants/meta.ts +++ b/packages/editor/src/core/constants/meta.ts @@ -1,3 +1,5 @@ export enum CORE_EDITOR_META { SKIP_FILE_DELETION = "skipFileDeletion", + INTENTIONAL_DELETION = "intentionalDeletion", + ADD_TO_HISTORY = "addToHistory", } diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/actions.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/actions.ts new file mode 100644 index 00000000000..fb1c5ce7a48 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/actions.ts @@ -0,0 +1,186 @@ +import type { Editor } from "@tiptap/core"; +import { Fragment, type Node, type Node as ProseMirrorNode } from "@tiptap/pm/model"; +import type { Transaction } from "@tiptap/pm/state"; +import { type CellSelection, TableMap } from "@tiptap/pm/tables"; +// extensions +import { TableNodeLocation } from "@/extensions/table/table/utilities/helpers"; + +type TableRow = (ProseMirrorNode | null)[]; +type TableRows = TableRow[]; + +/** + * Move the selected columns to the specified index. + * @param {Editor} editor - The editor instance. + * @param {TableNodeLocation} table - The table node location. + * @param {CellSelection} selection - The cell selection. + * @param {number} to - The index to move the columns to. + * @param {Transaction} tr - The transaction. + * @returns {Transaction} The updated transaction. + */ +export const moveSelectedColumns = ( + editor: Editor, + table: TableNodeLocation, + selection: CellSelection, + to: number, + tr: Transaction +): Transaction => { + const tableMap = TableMap.get(table.node); + + let columnStart = -1; + let columnEnd = -1; + + selection.forEachCell((_node, pos) => { + const cell = tableMap.findCell(pos - table.pos - 1); + for (let i = cell.left; i < cell.right; i++) { + columnStart = columnStart >= 0 ? Math.min(cell.left, columnStart) : cell.left; + columnEnd = columnEnd >= 0 ? Math.max(cell.right, columnEnd) : cell.right; + } + }); + + if (to < 0 || to > tableMap.width || (to >= columnStart && to < columnEnd)) return tr; + + const rows = tableToCells(table); + for (const row of rows) { + const range = row.splice(columnStart, columnEnd - columnStart); + const offset = to > columnStart ? to - (columnEnd - columnStart - 1) : to; + row.splice(offset, 0, ...range); + } + + tableFromCells(editor, table, rows, tr); + return tr; +}; + +/** + * Move the selected rows to the specified index. + * @param {Editor} editor - The editor instance. + * @param {TableNodeLocation} table - The table node location. + * @param {CellSelection} selection - The cell selection. + * @param {number} to - The index to move the rows to. + * @param {Transaction} tr - The transaction. + * @returns {Transaction} The updated transaction. + */ +export const moveSelectedRows = ( + editor: Editor, + table: TableNodeLocation, + selection: CellSelection, + to: number, + tr: Transaction +): Transaction => { + const tableMap = TableMap.get(table.node); + + let rowStart = -1; + let rowEnd = -1; + + selection.forEachCell((_node, pos) => { + const cell = tableMap.findCell(pos - table.pos - 1); + for (let i = cell.top; i < cell.bottom; i++) { + rowStart = rowStart >= 0 ? Math.min(cell.top, rowStart) : cell.top; + rowEnd = rowEnd >= 0 ? Math.max(cell.bottom, rowEnd) : cell.bottom; + } + }); + + if (to < 0 || to > tableMap.height || (to >= rowStart && to < rowEnd)) return tr; + + const rows = tableToCells(table); + const range = rows.splice(rowStart, rowEnd - rowStart); + const offset = to > rowStart ? to - (rowEnd - rowStart - 1) : to; + rows.splice(offset, 0, ...range); + + tableFromCells(editor, table, rows, tr); + return tr; +}; + +/** + * @description Duplicate the selected rows. + * @param {TableNodeLocation} table - The table node location. + * @param {number[]} rowIndices - The indices of the rows to duplicate. + * @param {Transaction} tr - The transaction. + * @returns {Transaction} The updated transaction. + */ +export const duplicateRows = (table: TableNodeLocation, rowIndices: number[], tr: Transaction): Transaction => { + const rows = tableToCells(table); + + const { map, width } = TableMap.get(table.node); + const mapStart = tr.mapping.maps.length; + + const lastRowPos = map[rowIndices[rowIndices.length - 1] * width + width - 1]; + const nextRowStart = lastRowPos + (table.node.nodeAt(lastRowPos)?.nodeSize ?? 0) + 1; + const insertPos = tr.mapping.slice(mapStart).map(table.start + nextRowStart); + + for (let i = rowIndices.length - 1; i >= 0; i--) { + tr.insert( + insertPos, + rows[rowIndices[i]].filter((r) => r !== null) + ); + } + + return tr; +}; + +/** + * @description Duplicate the selected columns. + * @param {TableNodeLocation} table - The table node location. + * @param {number[]} columnIndices - The indices of the columns to duplicate. + * @param {Transaction} tr - The transaction. + * @returns {Transaction} The updated transaction. + */ +export const duplicateColumns = (table: TableNodeLocation, columnIndices: number[], tr: Transaction): Transaction => { + const rows = tableToCells(table); + + const { map, width, height } = TableMap.get(table.node); + const mapStart = tr.mapping.maps.length; + + for (let row = 0; row < height; row++) { + const lastColumnPos = map[row * width + columnIndices[columnIndices.length - 1]]; + const nextColumnStart = lastColumnPos + (table.node.nodeAt(lastColumnPos)?.nodeSize ?? 0); + const insertPos = tr.mapping.slice(mapStart).map(table.start + nextColumnStart); + + for (let i = columnIndices.length - 1; i >= 0; i--) { + const copiedNode = rows[row][columnIndices[i]]; + if (copiedNode !== null) { + tr.insert(insertPos, copiedNode); + } + } + } + + return tr; +}; + +/** + * @description Convert the table to cells. + * @param {TableNodeLocation} table - The table node location. + * @returns {TableRows} The table rows. + */ +const tableToCells = (table: TableNodeLocation): TableRows => { + const { map, width, height } = TableMap.get(table.node); + + const visitedCells = new Set(); + const rows: TableRows = []; + for (let row = 0; row < height; row++) { + const cells: (ProseMirrorNode | null)[] = []; + for (let col = 0; col < width; col++) { + const pos = map[row * width + col]; + cells.push(!visitedCells.has(pos) ? table.node.nodeAt(pos) : null); + visitedCells.add(pos); + } + rows.push(cells); + } + + return rows; +}; + +/** + * @description Convert the cells to a table. + * @param {Editor} editor - The editor instance. + * @param {TableNodeLocation} table - The table node location. + * @param {TableRows} rows - The table rows. + * @param {Transaction} tr - The transaction. + */ +const tableFromCells = (editor: Editor, table: TableNodeLocation, rows: TableRows, tr: Transaction): void => { + const schema = editor.schema.nodes; + const newRowNodes = rows.map((row) => + schema.tableRow.create(null, row.filter((cell) => cell !== null) as readonly Node[]) + ); + const newTableNode = table.node.copy(Fragment.from(newRowNodes)); + tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTableNode); +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/color-selector.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/color-selector.tsx new file mode 100644 index 00000000000..34213ccaee4 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/color-selector.tsx @@ -0,0 +1,115 @@ +import { Disclosure } from "@headlessui/react"; +import type { Editor } from "@tiptap/core"; +import { Ban, ChevronRight, Palette } from "lucide-react"; +// plane imports +import { cn } from "@plane/utils"; +// constants +import { COLORS_LIST } from "@/constants/common"; +import { CORE_EXTENSIONS } from "@/constants/extension"; + +type Props = { + editor: Editor; + onSelect: (color: string | null) => void; +}; + +const handleBackgroundColorChange = (editor: Editor, color: string | null) => { + editor + .chain() + .focus() + .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, { + background: color, + }) + .run(); +}; + +// const handleTextColorChange = (editor: Editor, color: string | null) => { +// editor +// .chain() +// .focus() +// .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, { +// textColor: color, +// }) +// .run(); +// }; + +export const TableDragHandleDropdownColorSelector: React.FC = (props) => { + const { editor, onSelect } = props; + + return ( + + + {({ open }) => ( + <> + + + Color + + + + )} + + + {/*
+

Text colors

+
+ {COLORS_LIST.map((color) => ( + +
+
*/} +
+

Background colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+
+ ); +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx new file mode 100644 index 00000000000..8ba3fe4c310 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx @@ -0,0 +1,200 @@ +import { + shift, + flip, + offset, + useDismiss, + useFloating, + useInteractions, + autoUpdate, + useClick, + useRole, + FloatingOverlay, + FloatingPortal, +} from "@floating-ui/react"; +import type { Editor } from "@tiptap/core"; +import { Ellipsis } from "lucide-react"; +import { useCallback, useState } from "react"; +// plane imports +import { cn } from "@plane/utils"; +// extensions +import { + findTable, + getTableHeightPx, + getTableWidthPx, + isCellSelection, + selectColumn, +} from "@/extensions/table/table/utilities/helpers"; +// local imports +import { moveSelectedColumns } from "../actions"; +import { + DROP_MARKER_THICKNESS, + getColDragMarker, + getDropMarker, + hideDragMarker, + hideDropMarker, + updateColDragMarker, + updateColDropMarker, +} from "../marker-utils"; +import { updateCellContentVisibility } from "../utils"; +import { ColumnOptionsDropdown } from "./dropdown"; +import { calculateColumnDropIndex, constructColumnDragPreview, getTableColumnNodesInfo } from "./utils"; + +export type ColumnDragHandleProps = { + col: number; + editor: Editor; +}; + +export const ColumnDragHandle: React.FC = (props) => { + const { col, editor } = props; + // states + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + // floating ui + const { refs, floatingStyles, context } = useFloating({ + placement: "bottom-start", + middleware: [ + flip({ + fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"], + }), + shift({ + padding: 8, + }), + ], + open: isDropdownOpen, + onOpenChange: setIsDropdownOpen, + whileElementsMounted: autoUpdate, + }); + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context); + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + const table = findTable(editor.state.selection); + if (!table) return; + + editor.view.dispatch(selectColumn(table, col, editor.state.tr)); + + // drag column + const tableWidthPx = getTableWidthPx(table, editor); + const columns = getTableColumnNodesInfo(table, editor); + + let dropIndex = col; + const startLeft = columns[col].left ?? 0; + const startX = e.clientX; + const tableElement = editor.view.nodeDOM(table.pos); + + const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; + const dragMarker = tableElement instanceof HTMLElement ? getColDragMarker(tableElement) : null; + + const handleFinish = () => { + if (!dropMarker || !dragMarker) return; + hideDropMarker(dropMarker); + hideDragMarker(dragMarker); + + if (isCellSelection(editor.state.selection)) { + updateCellContentVisibility(editor, false); + } + + if (col !== dropIndex) { + let tr = editor.state.tr; + const selection = editor.state.selection; + if (isCellSelection(selection)) { + const table = findTable(selection); + if (table) { + tr = moveSelectedColumns(editor, table, selection, dropIndex, tr); + } + } + editor.view.dispatch(tr); + } + window.removeEventListener("mouseup", handleFinish); + window.removeEventListener("mousemove", handleMove); + }; + + let pseudoColumn: HTMLElement | undefined; + + const handleMove = (moveEvent: MouseEvent) => { + if (!dropMarker || !dragMarker) return; + const currentLeft = startLeft + moveEvent.clientX - startX; + dropIndex = calculateColumnDropIndex(col, columns, currentLeft); + + if (!pseudoColumn) { + pseudoColumn = constructColumnDragPreview(editor, editor.state.selection, table); + const tableHeightPx = getTableHeightPx(table, editor); + if (pseudoColumn) { + pseudoColumn.style.height = `${tableHeightPx}px`; + } + } + + const dragMarkerWidthPx = columns[col].width; + const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx)); + const dropMarkerLeftPx = + dropIndex <= col ? columns[dropIndex].left : columns[dropIndex].left + columns[dropIndex].width; + + updateColDropMarker({ + element: dropMarker, + left: dropMarkerLeftPx - Math.floor(DROP_MARKER_THICKNESS / 2) - 1, + width: DROP_MARKER_THICKNESS, + }); + updateColDragMarker({ + element: dragMarker, + left: dragMarkerLeftPx, + width: dragMarkerWidthPx, + pseudoColumn, + }); + }; + + window.addEventListener("mouseup", handleFinish); + window.addEventListener("mousemove", handleMove); + }, + [col, editor] + ); + + return ( + <> +
+ +
+ {isDropdownOpen && ( + + {/* Backdrop */} + + +
+ setIsDropdownOpen(false)} /> +
+
+ )} + + ); +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/dropdown.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/dropdown.tsx new file mode 100644 index 00000000000..23f22f281de --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/dropdown.tsx @@ -0,0 +1,100 @@ +import type { Editor } from "@tiptap/core"; +import { TableMap } from "@tiptap/pm/tables"; +import { ArrowLeft, ArrowRight, Copy, ToggleRight, Trash, X, type LucideIcon } from "lucide-react"; +// extensions +import { findTable, getSelectedColumns } from "@/extensions/table/table/utilities/helpers"; +// local imports +import { duplicateColumns } from "../actions"; +import { TableDragHandleDropdownColorSelector } from "../color-selector"; + +const DROPDOWN_ITEMS: { + key: string; + label: string; + icon: LucideIcon; + action: (editor: Editor) => void; +}[] = [ + { + key: "insert-left", + label: "Insert left", + icon: ArrowLeft, + action: (editor) => editor.chain().focus().addColumnBefore().run(), + }, + { + key: "insert-right", + label: "Insert right", + icon: ArrowRight, + action: (editor) => editor.chain().focus().addColumnAfter().run(), + }, + { + key: "duplicate", + label: "Duplicate", + icon: Copy, + action: (editor) => { + const table = findTable(editor.state.selection); + if (!table) return; + + const tableMap = TableMap.get(table.node); + let tr = editor.state.tr; + const selectedColumns = getSelectedColumns(editor.state.selection, tableMap); + tr = duplicateColumns(table, selectedColumns, tr); + editor.view.dispatch(tr); + }, + }, + { + key: "clear-contents", + label: "Clear contents", + icon: X, + action: (editor) => editor.chain().focus().clearSelectedCells().run(), + }, + { + key: "delete", + label: "Delete", + icon: Trash, + action: (editor) => editor.chain().focus().deleteColumn().run(), + }, +]; + +type Props = { + editor: Editor; + onClose: () => void; +}; + +export const ColumnOptionsDropdown: React.FC = (props) => { + const { editor, onClose } = props; + + return ( + <> + +
+ + {DROPDOWN_ITEMS.map((item) => ( + + ))} + + ); +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts new file mode 100644 index 00000000000..6cefa24b6f6 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts @@ -0,0 +1,74 @@ +import type { Editor } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { TableMap } from "@tiptap/pm/tables"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { ReactRenderer } from "@tiptap/react"; +// extensions +import { + findTable, + getTableCellWidgetDecorationPos, + haveTableRelatedChanges, +} from "@/extensions/table/table/utilities/helpers"; +// local imports +import { ColumnDragHandle, ColumnDragHandleProps } from "./drag-handle"; + +type TableColumnDragHandlePluginState = { + decorations?: DecorationSet; +}; + +const TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableColumnHandlerDecorationPlugin"); + +export const TableColumnDragHandlePlugin = (editor: Editor): Plugin => + new Plugin({ + key: TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY, + state: { + init: () => ({}), + apply(tr, prev, oldState, newState) { + const table = findTable(newState.selection); + if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) { + return table !== undefined ? prev : {}; + } + + const tableMap = TableMap.get(table.node); + + let isStale = false; + const mapped = prev.decorations?.map(tr.mapping, tr.doc); + for (let col = 0; col < tableMap.width; col++) { + const pos = getTableCellWidgetDecorationPos(table, tableMap, col); + if (mapped?.find(pos, pos + 1)?.length !== 1) { + isStale = true; + break; + } + } + + if (!isStale) { + return { decorations: mapped }; + } + + const decorations: Decoration[] = []; + + for (let col = 0; col < tableMap.width; col++) { + const pos = getTableCellWidgetDecorationPos(table, tableMap, col); + + const dragHandleComponent = new ReactRenderer(ColumnDragHandle, { + props: { + col, + editor, + } satisfies ColumnDragHandleProps, + editor, + }); + + decorations.push(Decoration.widget(pos, () => dragHandleComponent.element)); + } + + return { + decorations: DecorationSet.create(newState.doc, decorations), + }; + }, + }, + props: { + decorations(state) { + return TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY.getState(state).decorations; + }, + }, + }); diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/utils.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/utils.ts new file mode 100644 index 00000000000..1fa13aca3ee --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/utils.ts @@ -0,0 +1,143 @@ +import type { Editor } from "@tiptap/core"; +import type { Selection } from "@tiptap/pm/state"; +import { TableMap } from "@tiptap/pm/tables"; +// extensions +import { getSelectedRect, isCellSelection, type TableNodeLocation } from "@/extensions/table/table/utilities/helpers"; +// local imports +import { cloneTableCell, constructDragPreviewTable, updateCellContentVisibility } from "../utils"; + +type TableColumn = { + left: number; + width: number; +}; + +/** + * @description Calculate the index where the dragged column should be dropped. + * @param {number} col - The column index. + * @param {TableColumn[]} columns - The columns. + * @param {number} left - The left position of the dragged column. + * @returns {number} The index where the dragged column should be dropped. + */ +export const calculateColumnDropIndex = (col: number, columns: TableColumn[], left: number): number => { + const currentColumnLeft = columns[col].left; + const currentColumnRight = currentColumnLeft + columns[col].width; + + const draggedColumnLeft = left; + const draggedColumnRight = draggedColumnLeft + columns[col].width; + + const isDraggingToLeft = draggedColumnLeft < currentColumnLeft; + const isDraggingToRight = draggedColumnRight > currentColumnRight; + + const isFirstColumn = col === 0; + const isLastColumn = col === columns.length - 1; + + if ((isFirstColumn && isDraggingToLeft) || (isLastColumn && isDraggingToRight)) { + return col; + } + + const firstColumn = columns[0]; + if (isDraggingToLeft && draggedColumnLeft <= firstColumn.left) { + return 0; + } + + const lastColumn = columns[columns.length - 1]; + if (isDraggingToRight && draggedColumnRight >= lastColumn.left + lastColumn.width) { + return columns.length - 1; + } + + let dropColumnIndex = col; + if (isDraggingToRight) { + const findHoveredColumn = columns.find((p, index) => { + if (index === col) return false; + const currentColumnCenter = p.left + p.width / 2; + const currentColumnEdge = p.left + p.width; + const nextColumn = columns[index + 1] as TableColumn | undefined; + const nextColumnCenter = nextColumn ? nextColumn.width / 2 : 0; + + return draggedColumnRight >= currentColumnCenter && draggedColumnRight < currentColumnEdge + nextColumnCenter; + }); + if (findHoveredColumn) { + dropColumnIndex = columns.indexOf(findHoveredColumn); + } + } + + if (isDraggingToLeft) { + const findHoveredColumn = columns.find((p, index) => { + if (index === col) return false; + const currentColumnCenter = p.left + p.width / 2; + const prevColumn = columns[index - 1] as TableColumn | undefined; + const prevColumnLeft = prevColumn ? prevColumn.left : 0; + const prevColumnCenter = prevColumn ? prevColumn.width / 2 : 0; + + return draggedColumnLeft <= currentColumnCenter && draggedColumnLeft > prevColumnLeft + prevColumnCenter; + }); + if (findHoveredColumn) { + dropColumnIndex = columns.indexOf(findHoveredColumn); + } + } + + return dropColumnIndex; +}; + +/** + * @description Get the node information of the columns in the table- their offset left and width. + * @param {TableNodeLocation} table - The table node location. + * @param {Editor} editor - The editor instance. + * @returns {TableColumn[]} The information of the columns in the table. + */ +export const getTableColumnNodesInfo = (table: TableNodeLocation, editor: Editor): TableColumn[] => { + const result: TableColumn[] = []; + let leftPx = 0; + + const { map, width } = TableMap.get(table.node); + for (let col = 0; col < width; col++) { + const dom = editor.view.domAtPos(table.start + map[col] + 1); + if (dom.node instanceof HTMLElement) { + if (col === 0) { + leftPx = dom.node.offsetLeft; + } + result.push({ + left: dom.node.offsetLeft - leftPx, + width: dom.node.offsetWidth, + }); + } + } + return result; +}; + +/** + * @description Construct a pseudo column from the selected cells for drag preview. + * @param {Editor} editor - The editor instance. + * @param {Selection} selection - The selection. + * @param {TableNodeLocation} table - The table node location. + * @returns {HTMLElement | undefined} The pseudo column. + */ +export const constructColumnDragPreview = ( + editor: Editor, + selection: Selection, + table: TableNodeLocation +): HTMLElement | undefined => { + if (!isCellSelection(selection)) return; + + const tableMap = TableMap.get(table.node); + const selectedColRect = getSelectedRect(selection, tableMap); + const activeColCells = tableMap.cellsInRect(selectedColRect); + + const { tableElement, tableBodyElement } = constructDragPreviewTable(); + + activeColCells.forEach((cellPos) => { + const resolvedCellPos = table.start + cellPos + 1; + const cellElement = editor.view.domAtPos(resolvedCellPos).node; + if (cellElement instanceof HTMLElement) { + const { clonedCellElement } = cloneTableCell(cellElement); + clonedCellElement.style.height = cellElement.getBoundingClientRect().height + "px"; + const tableRowElement = document.createElement("tr"); + tableRowElement.appendChild(clonedCellElement); + tableBodyElement.appendChild(tableRowElement); + } + }); + + updateCellContentVisibility(editor, true); + + return tableElement; +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/marker-utils.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/marker-utils.ts new file mode 100644 index 00000000000..784a046bf08 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/marker-utils.ts @@ -0,0 +1,96 @@ +export const DROP_MARKER_CLASS = "table-drop-marker"; +export const COL_DRAG_MARKER_CLASS = "table-col-drag-marker"; +export const ROW_DRAG_MARKER_CLASS = "table-row-drag-marker"; + +export const DROP_MARKER_THICKNESS = 2; + +export const getDropMarker = (tableElement: HTMLElement): HTMLElement | null => + tableElement.querySelector(`.${DROP_MARKER_CLASS}`); + +export const hideDropMarker = (element: HTMLElement): void => { + if (!element.classList.contains("hidden")) { + element.classList.add("hidden"); + } +}; + +export const updateColDropMarker = ({ + element, + left, + width, +}: { + element: HTMLElement; + left: number; + width: number; +}) => { + element.style.height = "100%"; + element.style.width = `${width}px`; + element.style.top = "0"; + element.style.left = `${left}px`; + element.classList.remove("hidden"); +}; + +export const updateRowDropMarker = ({ + element, + top, + height, +}: { + element: HTMLElement; + top: number; + height: number; +}) => { + element.style.width = "100%"; + element.style.height = `${height}px`; + element.style.left = "0"; + element.style.top = `${top}px`; + element.classList.remove("hidden"); +}; + +export const getColDragMarker = (tableElement: HTMLElement): HTMLElement | null => + tableElement.querySelector(`.${COL_DRAG_MARKER_CLASS}`); + +export const getRowDragMarker = (tableElement: HTMLElement): HTMLElement | null => + tableElement.querySelector(`.${ROW_DRAG_MARKER_CLASS}`); + +export const hideDragMarker = (element: HTMLElement): void => { + if (!element.classList.contains("hidden")) { + element.classList.add("hidden"); + } +}; + +export const updateColDragMarker = ({ + element, + left, + width, + pseudoColumn, +}: { + element: HTMLElement; + left: number; + width: number; + pseudoColumn: HTMLElement | undefined; +}) => { + element.style.left = `${left}px`; + element.style.width = `${width}px`; + element.classList.remove("hidden"); + if (pseudoColumn) { + element.innerHTML = pseudoColumn.outerHTML; + } +}; + +export const updateRowDragMarker = ({ + element, + top, + height, + pseudoRow, +}: { + element: HTMLElement; + top: number; + height: number; + pseudoRow: HTMLElement | undefined; +}) => { + element.style.top = `${top}px`; + element.style.height = `${height}px`; + element.classList.remove("hidden"); + if (pseudoRow) { + element.innerHTML = pseudoRow.outerHTML; + } +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx new file mode 100644 index 00000000000..589b5530643 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx @@ -0,0 +1,199 @@ +import { + autoUpdate, + flip, + FloatingOverlay, + FloatingPortal, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, +} from "@floating-ui/react"; +import type { Editor } from "@tiptap/core"; +import { Ellipsis } from "lucide-react"; +import { useCallback, useState } from "react"; +// plane imports +import { cn } from "@plane/utils"; +// extensions +import { + findTable, + getTableHeightPx, + getTableWidthPx, + isCellSelection, + selectRow, +} from "@/extensions/table/table/utilities/helpers"; +// local imports +import { moveSelectedRows } from "../actions"; +import { + DROP_MARKER_THICKNESS, + getDropMarker, + getRowDragMarker, + hideDragMarker, + hideDropMarker, + updateRowDragMarker, + updateRowDropMarker, +} from "../marker-utils"; +import { updateCellContentVisibility } from "../utils"; +import { RowOptionsDropdown } from "./dropdown"; +import { calculateRowDropIndex, constructRowDragPreview, getTableRowNodesInfo } from "./utils"; + +export type RowDragHandleProps = { + editor: Editor; + row: number; +}; + +export const RowDragHandle: React.FC = (props) => { + const { editor, row } = props; + // states + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + // floating ui + const { refs, floatingStyles, context } = useFloating({ + placement: "bottom-start", + middleware: [ + flip({ + fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"], + }), + shift({ + padding: 8, + }), + ], + open: isDropdownOpen, + onOpenChange: setIsDropdownOpen, + whileElementsMounted: autoUpdate, + }); + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context); + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + const table = findTable(editor.state.selection); + if (!table) return; + + editor.view.dispatch(selectRow(table, row, editor.state.tr)); + + // drag row + const tableHeightPx = getTableHeightPx(table, editor); + const rows = getTableRowNodesInfo(table, editor); + + let dropIndex = row; + const startTop = rows[row].top ?? 0; + const startY = e.clientY; + const tableElement = editor.view.nodeDOM(table.pos); + + const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; + const dragMarker = tableElement instanceof HTMLElement ? getRowDragMarker(tableElement) : null; + + const handleFinish = (): void => { + if (!dropMarker || !dragMarker) return; + hideDropMarker(dropMarker); + hideDragMarker(dragMarker); + + if (isCellSelection(editor.state.selection)) { + updateCellContentVisibility(editor, false); + } + + if (row !== dropIndex) { + let tr = editor.state.tr; + const selection = editor.state.selection; + if (isCellSelection(selection)) { + const table = findTable(selection); + if (table) { + tr = moveSelectedRows(editor, table, selection, dropIndex, tr); + } + } + editor.view.dispatch(tr); + } + window.removeEventListener("mouseup", handleFinish); + window.removeEventListener("mousemove", handleMove); + }; + + let pseudoRow: HTMLElement | undefined; + + const handleMove = (moveEvent: MouseEvent): void => { + if (!dropMarker || !dragMarker) return; + const cursorTop = startTop + moveEvent.clientY - startY; + dropIndex = calculateRowDropIndex(row, rows, cursorTop); + + if (!pseudoRow) { + pseudoRow = constructRowDragPreview(editor, editor.state.selection, table); + const tableWidthPx = getTableWidthPx(table, editor); + if (pseudoRow) { + pseudoRow.style.width = `${tableWidthPx}px`; + } + } + + const dragMarkerHeightPx = rows[row].height; + const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx)); + const dropMarkerTopPx = dropIndex <= row ? rows[dropIndex].top : rows[dropIndex].top + rows[dropIndex].height; + + updateRowDropMarker({ + element: dropMarker, + top: dropMarkerTopPx - DROP_MARKER_THICKNESS / 2, + height: DROP_MARKER_THICKNESS, + }); + updateRowDragMarker({ + element: dragMarker, + top: dragMarkerTopPx, + height: dragMarkerHeightPx, + pseudoRow, + }); + }; + + window.addEventListener("mouseup", handleFinish); + window.addEventListener("mousemove", handleMove); + }, + [editor, row] + ); + + return ( + <> +
+ +
+ {isDropdownOpen && ( + + {/* Backdrop */} + + +
+ setIsDropdownOpen(false)} /> +
+
+ )} + + ); +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/dropdown.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/dropdown.tsx new file mode 100644 index 00000000000..77712261add --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/dropdown.tsx @@ -0,0 +1,100 @@ +import type { Editor } from "@tiptap/core"; +import { TableMap } from "@tiptap/pm/tables"; +import { ArrowDown, ArrowUp, Copy, ToggleRight, Trash, X, type LucideIcon } from "lucide-react"; +// extensions +import { findTable, getSelectedRows } from "@/extensions/table/table/utilities/helpers"; +// local imports +import { duplicateRows } from "../actions"; +import { TableDragHandleDropdownColorSelector } from "../color-selector"; + +const DROPDOWN_ITEMS: { + key: string; + label: string; + icon: LucideIcon; + action: (editor: Editor) => void; +}[] = [ + { + key: "insert-above", + label: "Insert above", + icon: ArrowUp, + action: (editor) => editor.chain().focus().addRowBefore().run(), + }, + { + key: "insert-below", + label: "Insert below", + icon: ArrowDown, + action: (editor) => editor.chain().focus().addRowAfter().run(), + }, + { + key: "duplicate", + label: "Duplicate", + icon: Copy, + action: (editor) => { + const table = findTable(editor.state.selection); + if (!table) return; + + const tableMap = TableMap.get(table.node); + let tr = editor.state.tr; + const selectedRows = getSelectedRows(editor.state.selection, tableMap); + tr = duplicateRows(table, selectedRows, tr); + editor.view.dispatch(tr); + }, + }, + { + key: "clear-contents", + label: "Clear contents", + icon: X, + action: (editor) => editor.chain().focus().clearSelectedCells().run(), + }, + { + key: "delete", + label: "Delete", + icon: Trash, + action: (editor) => editor.chain().focus().deleteRow().run(), + }, +]; + +type Props = { + editor: Editor; + onClose: () => void; +}; + +export const RowOptionsDropdown: React.FC = (props) => { + const { editor, onClose } = props; + + return ( + <> + +
+ + {DROPDOWN_ITEMS.map((item) => ( + + ))} + + ); +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts new file mode 100644 index 00000000000..d84f942ba45 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts @@ -0,0 +1,72 @@ +import { type Editor } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { TableMap } from "@tiptap/pm/tables"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { ReactRenderer } from "@tiptap/react"; +// extensions +import { + findTable, + getTableCellWidgetDecorationPos, + haveTableRelatedChanges, +} from "@/extensions/table/table/utilities/helpers"; +// local imports +import { RowDragHandle, RowDragHandleProps } from "./drag-handle"; + +type TableRowDragHandlePluginState = { + decorations?: DecorationSet; +}; + +const TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableRowDragHandlePlugin"); + +export const TableRowDragHandlePlugin = (editor: Editor): Plugin => + new Plugin({ + key: TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY, + state: { + init: () => ({}), + apply(tr, prev, oldState, newState) { + const table = findTable(newState.selection); + if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) { + return table !== undefined ? prev : {}; + } + + const tableMap = TableMap.get(table.node); + + let isStale = false; + const mapped = prev.decorations?.map(tr.mapping, tr.doc); + for (let row = 0; row < tableMap.height; row++) { + const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width); + if (mapped?.find(pos, pos + 1)?.length !== 1) { + isStale = true; + break; + } + } + + if (!isStale) { + return { decorations: mapped }; + } + + const decorations: Decoration[] = []; + + for (let row = 0; row < tableMap.height; row++) { + const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width); + + const dragHandleComponent = new ReactRenderer(RowDragHandle, { + props: { + editor, + row, + } satisfies RowDragHandleProps, + editor, + }); + + decorations.push(Decoration.widget(pos, () => dragHandleComponent.element)); + } + + return { decorations: DecorationSet.create(newState.doc, decorations) }; + }, + }, + props: { + decorations(state) { + return TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY.getState(state).decorations; + }, + }, + }); diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/utils.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/utils.ts new file mode 100644 index 00000000000..d913736fef5 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/utils.ts @@ -0,0 +1,143 @@ +import type { Editor } from "@tiptap/core"; +import type { Selection } from "@tiptap/pm/state"; +import { TableMap } from "@tiptap/pm/tables"; +// extensions +import { getSelectedRect, isCellSelection, type TableNodeLocation } from "@/extensions/table/table/utilities/helpers"; +// local imports +import { cloneTableCell, constructDragPreviewTable, updateCellContentVisibility } from "../utils"; + +type TableRow = { + top: number; + height: number; +}; + +/** + * @description Calculate the index where the dragged row should be dropped. + * @param {number} row - The row index. + * @param {TableRow[]} rows - The rows. + * @param {number} top - The top position of the dragged row. + * @returns {number} The index where the dragged row should be dropped. + */ +export const calculateRowDropIndex = (row: number, rows: TableRow[], top: number): number => { + const currentRowTop = rows[row].top; + const currentRowBottom = currentRowTop + rows[row].height; + + const draggedRowTop = top; + const draggedRowBottom = draggedRowTop + rows[row].height; + + const isDraggingUp = draggedRowTop < currentRowTop; + const isDraggingDown = draggedRowBottom > currentRowBottom; + + const isFirstRow = row === 0; + const isLastRow = row === rows.length - 1; + + if ((isFirstRow && isDraggingUp) || (isLastRow && isDraggingDown)) { + return row; + } + + const firstRow = rows[0]; + if (isDraggingUp && draggedRowTop <= firstRow.top) { + return 0; + } + + const lastRow = rows[rows.length - 1]; + if (isDraggingDown && draggedRowBottom >= lastRow.top + lastRow.height) { + return rows.length - 1; + } + + let dropRowIndex = row; + if (isDraggingDown) { + const findHoveredRow = rows.find((p, index) => { + if (index === row) return false; + const currentRowCenter = p.top + p.height / 2; + const currentRowEdge = p.top + p.height; + const nextRow = rows[index + 1] as TableRow | undefined; + const nextRowCenter = nextRow ? nextRow.height / 2 : 0; + + return draggedRowBottom >= currentRowCenter && draggedRowBottom < currentRowEdge + nextRowCenter; + }); + if (findHoveredRow) { + dropRowIndex = rows.indexOf(findHoveredRow); + } + } + + if (isDraggingUp) { + const findHoveredRow = rows.find((p, index) => { + if (index === row) return false; + const currentRowCenter = p.top + p.height / 2; + const prevRow = rows[index - 1] as TableRow | undefined; + const prevRowTop = prevRow ? prevRow.top : 0; + const prevRowCenter = prevRow ? prevRow.height / 2 : 0; + + return draggedRowTop <= currentRowCenter && draggedRowTop > prevRowTop + prevRowCenter; + }); + if (findHoveredRow) { + dropRowIndex = rows.indexOf(findHoveredRow); + } + } + + return dropRowIndex; +}; + +/** + * @description Get the node information of the rows in the table- their offset top and height. + * @param {TableNodeLocation} table - The table node location. + * @param {Editor} editor - The editor instance. + * @returns {TableRow[]} The information of the rows in the table. + */ +export const getTableRowNodesInfo = (table: TableNodeLocation, editor: Editor): TableRow[] => { + const result: TableRow[] = []; + let topPx = 0; + + const tableMap = TableMap.get(table.node); + for (let row = 0; row < tableMap.height; row++) { + const dom = editor.view.domAtPos(table.start + tableMap.map[row * tableMap.width]); + if (dom.node instanceof HTMLElement) { + const heightPx = dom.node.offsetHeight; + result.push({ + top: topPx, + height: heightPx, + }); + topPx += heightPx; + } + } + return result; +}; + +/** + * @description Construct a pseudo column from the selected cells for drag preview. + * @param {Editor} editor - The editor instance. + * @param {Selection} selection - The selection. + * @param {TableNodeLocation} table - The table node location. + * @returns {HTMLElement | undefined} The pseudo column. + */ +export const constructRowDragPreview = ( + editor: Editor, + selection: Selection, + table: TableNodeLocation +): HTMLElement | undefined => { + if (!isCellSelection(selection)) return; + + const tableMap = TableMap.get(table.node); + const selectedRowRect = getSelectedRect(selection, tableMap); + const activeRowCells = tableMap.cellsInRect(selectedRowRect); + + const { tableElement, tableBodyElement } = constructDragPreviewTable(); + + const tableRowElement = document.createElement("tr"); + tableBodyElement.appendChild(tableRowElement); + + activeRowCells.forEach((cellPos) => { + const resolvedCellPos = table.start + cellPos + 1; + const cellElement = editor.view.domAtPos(resolvedCellPos).node; + if (cellElement instanceof HTMLElement) { + const { clonedCellElement } = cloneTableCell(cellElement); + clonedCellElement.style.width = cellElement.getBoundingClientRect().width + "px"; + tableRowElement.appendChild(clonedCellElement); + } + }); + + updateCellContentVisibility(editor, true); + + return tableElement; +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/utils.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/utils.ts new file mode 100644 index 00000000000..256192e952d --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/utils.ts @@ -0,0 +1,60 @@ +import type { Editor } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +import { CORE_EDITOR_META } from "@/constants/meta"; + +/** + * @description Construct a pseudo table element which will act as a parent for column and row drag previews. + * @returns {HTMLTableElement} The pseudo table. + */ +export const constructDragPreviewTable = (): { + tableElement: HTMLTableElement; + tableBodyElement: HTMLTableSectionElement; +} => { + const tableElement = document.createElement("table"); + tableElement.classList.add("table-drag-preview"); + tableElement.classList.add("bg-custom-background-100"); + tableElement.style.opacity = "0.9"; + const tableBodyElement = document.createElement("tbody"); + tableElement.appendChild(tableBodyElement); + + return { tableElement, tableBodyElement }; +}; + +/** + * @description Clone a table cell element. + * @param {HTMLElement} cellElement - The cell element to clone. + * @returns {HTMLElement} The cloned cell element. + */ +export const cloneTableCell = ( + cellElement: HTMLElement +): { + clonedCellElement: HTMLElement; +} => { + const clonedCellElement = cellElement.cloneNode(true) as HTMLElement; + clonedCellElement.style.visibility = "visible !important"; + + const widgetElement = clonedCellElement.querySelectorAll(".ProseMirror-widget"); + widgetElement.forEach((widget) => widget.remove()); + + return { clonedCellElement }; +}; + +/** + * @description This function updates the `hideContent` attribute of the table cells and headers. + * @param {Editor} editor - The editor instance. + * @param {boolean} hideContent - Whether to hide the content. + * @returns {boolean} Whether the content visibility was updated. + */ +export const updateCellContentVisibility = (editor: Editor, hideContent: boolean): boolean => + editor + .chain() + .focus() + .setMeta(CORE_EDITOR_META.ADD_TO_HISTORY, false) + .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, { + hideContent, + }) + .updateAttributes(CORE_EXTENSIONS.TABLE_HEADER, { + hideContent, + }) + .run(); 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 index 97cd2d09f7a..50c9b489f86 100644 --- a/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts +++ b/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts @@ -1,6 +1,7 @@ -import { type Editor } from "@tiptap/core"; +import type { Editor } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; // local imports +import { COL_DRAG_MARKER_CLASS, DROP_MARKER_CLASS, ROW_DRAG_MARKER_CLASS } from "../drag-handles/marker-utils"; import { createColumnInsertButton, createRowInsertButton, findAllTables, TableInfo } from "./utils"; const TABLE_INSERT_PLUGIN_KEY = new PluginKey("table-insert"); @@ -25,6 +26,13 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { tableInfo.rowButtonElement = rowButton; } + // Create and add drag marker if it doesn't exist + if (!tableInfo.dragMarkerContainerElement) { + const dragMarker = createMarkerContainer(); + tableElement.appendChild(dragMarker); + tableInfo.dragMarkerContainerElement = dragMarker; + } + tableMap.set(tableElement, tableInfo); }; @@ -32,6 +40,7 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { const tableInfo = tableMap.get(tableElement); tableInfo?.columnButtonElement?.remove(); tableInfo?.rowButtonElement?.remove(); + tableInfo?.dragMarkerContainerElement?.remove(); tableMap.delete(tableElement); }; @@ -64,6 +73,7 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { return new Plugin({ key: TABLE_INSERT_PLUGIN_KEY, + view() { setTimeout(updateAllTables, 0); @@ -85,3 +95,33 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { }, }); }; + +const createMarkerContainer = (): HTMLElement => { + const el = document.createElement("div"); + el.classList.add("table-drag-marker-container"); + el.contentEditable = "false"; + el.appendChild(createDropMarker()); + el.appendChild(createColDragMarker()); + el.appendChild(createRowDragMarker()); + return el; +}; + +const createDropMarker = (): HTMLElement => { + const el = document.createElement("div"); + el.classList.add(DROP_MARKER_CLASS); + return el; +}; + +const createColDragMarker = (): HTMLElement => { + const el = document.createElement("div"); + el.classList.value = `${COL_DRAG_MARKER_CLASS} hidden`; + + return el; +}; + +const createRowDragMarker = (): HTMLElement => { + const el = document.createElement("div"); + el.classList.value = `${ROW_DRAG_MARKER_CLASS} hidden`; + + return el; +}; 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 index c8dc5f4794c..8e6526c4e1a 100644 --- a/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts +++ b/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts @@ -1,6 +1,6 @@ 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"; +import { addColumn, removeColumn, addRow, removeRow, TableMap, type TableRect } from "@tiptap/pm/tables"; // local imports import { isCellEmpty } from "../../table/utilities/helpers"; @@ -17,6 +17,7 @@ export type TableInfo = { tablePos: number; columnButtonElement?: HTMLElement; rowButtonElement?: HTMLElement; + dragMarkerContainerElement?: HTMLElement; }; export const createColumnInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => { @@ -274,7 +275,7 @@ const insertColumnAfterLast = (editor: Editor, tableInfo: TableInfo) => { const lastColumnIndex = tableMapData.width; const tr = editor.state.tr; - const rect = { + const rect: TableRect = { map: tableMapData, tableStart: tablePos, table: tableNode, @@ -346,7 +347,7 @@ const insertRowAfterLast = (editor: Editor, tableInfo: TableInfo) => { const lastRowIndex = tableMapData.height; const tr = editor.state.tr; - const rect = { + const rect: TableRect = { map: tableMapData, tableStart: tablePos, table: tableNode, diff --git a/packages/editor/src/core/extensions/table/table-cell.ts b/packages/editor/src/core/extensions/table/table-cell.ts index fd6cc6bb04f..9aa284cd17f 100644 --- a/packages/editor/src/core/extensions/table/table-cell.ts +++ b/packages/editor/src/core/extensions/table/table-cell.ts @@ -47,6 +47,9 @@ export const TableCell = Node.create({ textColor: { default: null, }, + hideContent: { + default: false, + }, }; }, @@ -107,7 +110,8 @@ export const TableCell = Node.create({ return [ "td", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { - style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`, + class: node.attrs.hideContent ? "content-hidden" : "", + style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor};`, }), 0, ]; diff --git a/packages/editor/src/core/extensions/table/table-header.ts b/packages/editor/src/core/extensions/table/table-header.ts index 9f7f90d02da..635fb7ee7a2 100644 --- a/packages/editor/src/core/extensions/table/table-header.ts +++ b/packages/editor/src/core/extensions/table/table-header.ts @@ -17,7 +17,7 @@ export const TableHeader = Node.create({ }; }, - content: "paragraph+", + content: "block+", addAttributes() { return { @@ -39,6 +39,9 @@ export const TableHeader = Node.create({ background: { default: "none", }, + hideContent: { + default: false, + }, }; }, @@ -54,7 +57,8 @@ export const TableHeader = Node.create({ return [ "th", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { - style: `background-color: ${node.attrs.background}`, + class: node.attrs.hideContent ? "content-hidden" : "", + style: `background-color: ${node.attrs.background};`, }), 0, ]; diff --git a/packages/editor/src/core/extensions/table/table/table-controls.ts b/packages/editor/src/core/extensions/table/table/table-controls.ts deleted file mode 100644 index 5cd3506d31c..00000000000 --- a/packages/editor/src/core/extensions/table/table/table-controls.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { findParentNode } from "@tiptap/core"; -import { Plugin, PluginKey, TextSelection, Transaction } from "@tiptap/pm/state"; -import { DecorationSet, Decoration } from "@tiptap/pm/view"; -// constants -import { CORE_EXTENSIONS } from "@/constants/extension"; - -const key = new PluginKey("tableControls"); - -export function tableControls() { - return new Plugin({ - key, - state: { - init() { - return new TableControlsState(); - }, - apply(tr, prev) { - return prev.apply(tr); - }, - }, - props: { - handleTripleClickOn(view, pos, node, nodePos, event) { - if (node.type.name === CORE_EXTENSIONS.TABLE_CELL) { - event.preventDefault(); - const $pos = view.state.doc.resolve(pos); - const line = $pos.parent; - const linePos = $pos.start(); - const start = linePos; - const end = linePos + line.nodeSize - 1; - const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, start, end)); - view.dispatch(tr); - return true; - } - return false; - }, - handleDOMEvents: { - mousemove: (view, event) => { - const pluginState = key.getState(view.state); - - if (!(event.target as HTMLElement).closest(".table-wrapper") && pluginState.values.hoveredTable) { - return view.dispatch( - view.state.tr.setMeta(key, { - setHoveredTable: null, - setHoveredCell: null, - }) - ); - } - - const pos = view.posAtCoords({ - left: event.clientX, - top: event.clientY, - }); - - if (!pos || pos.pos < 0 || pos.pos > view.state.doc.content.size) return; - - const table = findParentNode((node) => node.type.name === CORE_EXTENSIONS.TABLE)( - TextSelection.create(view.state.doc, pos.pos) - ); - const cell = findParentNode((node) => - [CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER].includes(node.type.name as CORE_EXTENSIONS) - )(TextSelection.create(view.state.doc, pos.pos)); - - if (!table || !cell) return; - - if (pluginState.values.hoveredCell?.pos !== cell.pos) { - return view.dispatch( - view.state.tr.setMeta(key, { - setHoveredTable: table, - setHoveredCell: cell, - }) - ); - } - }, - }, - decorations: (state) => { - const pluginState = key.getState(state); - if (!pluginState) { - return null; - } - - const { hoveredTable, hoveredCell } = pluginState.values; - const docSize = state.doc.content.size; - if (hoveredTable && hoveredCell && hoveredTable.pos < docSize && hoveredCell.pos < docSize) { - const decorations = [ - Decoration.node( - hoveredTable.pos, - hoveredTable.pos + hoveredTable.node.nodeSize, - {}, - { - hoveredTable, - hoveredCell, - } - ), - ]; - - return DecorationSet.create(state.doc, decorations); - } - - return null; - }, - }, - }); -} - -class TableControlsState { - values; - - constructor(props = {}) { - this.values = { - hoveredTable: null, - hoveredCell: null, - ...props, - }; - } - - apply(tr: Transaction) { - const actions = tr.getMeta(key); - - if (actions?.setHoveredTable !== undefined) { - this.values.hoveredTable = actions.setHoveredTable; - } - - if (actions?.setHoveredCell !== undefined) { - this.values.hoveredCell = actions.setHoveredCell; - } - - return this; - } -} diff --git a/packages/editor/src/core/extensions/table/table/table.ts b/packages/editor/src/core/extensions/table/table/table.ts index bab16f7e723..2462fa817db 100644 --- a/packages/editor/src/core/extensions/table/table/table.ts +++ b/packages/editor/src/core/extensions/table/table/table.ts @@ -7,6 +7,7 @@ import { addRowBefore, CellSelection, columnResizing, + deleteCellSelection, deleteTable, fixTables, goToNextCell, @@ -17,12 +18,13 @@ import { toggleHeader, toggleHeaderCell, } from "@tiptap/pm/tables"; -import { Decoration } from "@tiptap/pm/view"; +import type { Decoration } from "@tiptap/pm/view"; // constants import { CORE_EXTENSIONS } from "@/constants/extension"; // local imports +import { TableColumnDragHandlePlugin } from "../plugins/drag-handles/column/plugin"; +import { TableRowDragHandlePlugin } from "../plugins/drag-handles/row/plugin"; import { TableInsertPlugin } from "../plugins/insert-handlers/plugin"; -import { tableControls } from "./table-controls"; import { TableView } from "./table-view"; import { createTable } from "./utilities/create-table"; import { deleteColumnOrTable } from "./utilities/delete-column"; @@ -57,6 +59,7 @@ declare module "@tiptap/core" { toggleHeaderColumn: () => ReturnType; toggleHeaderRow: () => ReturnType; toggleHeaderCell: () => ReturnType; + clearSelectedCells: () => ReturnType; mergeOrSplit: () => ReturnType; setCellAttribute: (name: string, value: any) => ReturnType; goToNextCell: () => ReturnType; @@ -174,6 +177,10 @@ export const Table = Node.create({ () => ({ state, dispatch }) => toggleHeaderCell(state, dispatch), + clearSelectedCells: + () => + ({ state, dispatch }) => + deleteCellSelection(state, dispatch), mergeOrSplit: () => ({ state, dispatch }) => { @@ -254,10 +261,10 @@ export const Table = Node.create({ }, addNodeView() { - return ({ editor, getPos, node, decorations }) => { + return ({ editor, node, decorations, getPos }) => { const { cellMinWidth } = this.options; - return new TableView(node, cellMinWidth, decorations as Decoration[], editor, getPos as () => number); + return new TableView(node, cellMinWidth, decorations as Decoration[], editor, getPos); }; }, @@ -268,8 +275,9 @@ export const Table = Node.create({ tableEditing({ allowTableNodeSelection: this.options.allowTableNodeSelection, }), - tableControls(), TableInsertPlugin(this.editor), + TableColumnDragHandlePlugin(this.editor), + TableRowDragHandlePlugin(this.editor), ]; if (isResizable) { diff --git a/packages/editor/src/core/extensions/table/table/utilities/helpers.ts b/packages/editor/src/core/extensions/table/table/utilities/helpers.ts index f90f1a294c5..b42001c0565 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/helpers.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/helpers.ts @@ -1,6 +1,7 @@ +import { type Editor, findParentNode } from "@tiptap/core"; import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import type { Selection } from "@tiptap/pm/state"; -import { CellSelection } from "@tiptap/pm/tables"; +import type { EditorState, Selection, Transaction } from "@tiptap/pm/state"; +import { CellSelection, type Rect, TableMap } from "@tiptap/pm/tables"; // constants import { CORE_EXTENSIONS } from "@/constants/extension"; @@ -35,3 +36,189 @@ export const isCellEmpty = (cell: ProseMirrorNode | null): boolean => { return !hasContent; }; + +export type TableNodeLocation = { + pos: number; + start: number; + node: ProseMirrorNode; +}; + +/** + * @description Find the table node location from the selection. + * @param {Selection} selection - The selection. + * @returns {TableNodeLocation | undefined} The table node location. + */ +export const findTable = (selection: Selection): TableNodeLocation | undefined => + findParentNode((node) => node.type.spec.tableRole === "table")(selection); + +/** + * @description Check if the selection has table related changes. + * @param {Editor} editor - The editor instance. + * @param {TableNodeLocation | undefined} table - The table node location. + * @param {EditorState} oldState - The old editor state. + * @param {EditorState} newState - The new editor state. + * @param {Transaction} tr - The transaction. + * @returns {boolean} True if the selection has table related changes, false otherwise. + */ +export const haveTableRelatedChanges = ( + editor: Editor, + table: TableNodeLocation | undefined, + oldState: EditorState, + newState: EditorState, + tr: Transaction +): table is TableNodeLocation => + editor.isEditable && table !== undefined && (tr.docChanged || !newState.selection.eq(oldState.selection)); + +/** + * @description Get the selected rect from the cell selection. + * @param {CellSelection} selection - The cell selection. + * @param {TableMap} map - The table map. + * @returns {Rect} The selected rect. + */ +export const getSelectedRect = (selection: CellSelection, map: TableMap): Rect => { + const start = selection.$anchorCell.start(-1); + return map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start); +}; + +/** + * @description Get the selected columns from the cell selection. + * @param {Selection} selection - The selection. + * @param {TableMap} map - The table map. + * @returns {number[]} The selected columns. + */ +export const getSelectedColumns = (selection: Selection, map: TableMap): number[] => { + const isColumnSelection = isCellSelection(selection) && selection.isColSelection(); + console.log("isColumnSelection", isColumnSelection); + if (isColumnSelection) { + const selectedRect = getSelectedRect(selection, map); + return [...Array(selectedRect.right - selectedRect.left).keys()].map((idx) => idx + selectedRect.left); + } + + return []; +}; + +/** + * @description Get the selected rows from the cell selection. + * @param {Selection} selection - The selection. + * @param {TableMap} map - The table map. + * @returns {number[]} The selected rows. + */ +export const getSelectedRows = (selection: Selection, map: TableMap): number[] => { + console.log("helper isCellSelection", isCellSelection(selection)); + const isRowSelection = isCellSelection(selection) && selection.isRowSelection(); + console.log("isRowSelection", isRowSelection); + if (isRowSelection) { + const selectedRect = getSelectedRect(selection, map); + return [...Array(selectedRect.bottom - selectedRect.top).keys()].map((idx) => idx + selectedRect.top); + } + + return []; +}; + +/** + * @description Check if the rect is selected. + * @param {Rect} rect - The rect. + * @param {CellSelection} selection - The cell selection. + * @returns {boolean} True if the rect is selected, false otherwise. + */ +export const isRectSelected = (rect: Rect, selection: CellSelection): boolean => { + const map = TableMap.get(selection.$anchorCell.node(-1)); + const cells = map.cellsInRect(rect); + const selectedCells = map.cellsInRect(getSelectedRect(selection, map)); + + return cells.every((cell) => selectedCells.includes(cell)); +}; + +/** + * @description Check if the column is selected. + * @param {number} columnIndex - The column index. + * @param {Selection} selection - The selection. + * @returns {boolean} True if the column is selected, false otherwise. + */ +export const isColumnSelected = (columnIndex: number, selection: Selection): boolean => { + if (!isCellSelection(selection)) return false; + + const { height } = TableMap.get(selection.$anchorCell.node(-1)); + const rect = { left: columnIndex, right: columnIndex + 1, top: 0, bottom: height }; + return isRectSelected(rect, selection); +}; + +/** + * @description Check if the row is selected. + * @param {number} rowIndex - The row index. + * @param {Selection} selection - The selection. + * @returns {boolean} True if the row is selected, false otherwise. + */ +export const isRowSelected = (rowIndex: number, selection: Selection): boolean => { + if (isCellSelection(selection)) { + const { width } = TableMap.get(selection.$anchorCell.node(-1)); + const rect = { left: 0, right: width, top: rowIndex, bottom: rowIndex + 1 }; + return isRectSelected(rect, selection); + } + + return false; +}; + +/** + * @description Select the column. + * @param {TableNodeLocation} table - The table node location. + * @param {number} index - The column index. + * @param {Transaction} tr - The transaction. + * @returns {Transaction} The updated transaction. + */ +export const selectColumn = (table: TableNodeLocation, index: number, tr: Transaction): Transaction => { + const { map } = TableMap.get(table.node); + + const anchorCell = table.start + map[index]; + const $anchor = tr.doc.resolve(anchorCell); + + return tr.setSelection(CellSelection.colSelection($anchor)); +}; + +/** + * @description Select the row. + * @param {TableNodeLocation} table - The table node location. + * @param {number} index - The row index. + * @param {Transaction} tr - The transaction. + * @returns {Transaction} The updated transaction. + */ +export const selectRow = (table: TableNodeLocation, index: number, tr: Transaction): Transaction => { + const { map, width } = TableMap.get(table.node); + + const anchorCell = table.start + map[index * width]; + const $anchor = tr.doc.resolve(anchorCell); + + return tr.setSelection(CellSelection.rowSelection($anchor)); +}; + +/** + * @description Get the position of the cell widget decoration. + * @param {TableNodeLocation} table - The table node location. + * @param {TableMap} map - The table map. + * @param {number} index - The index. + * @returns {number} The position of the cell widget decoration. + */ +export const getTableCellWidgetDecorationPos = (table: TableNodeLocation, map: TableMap, index: number): number => + table.start + map.map[index] + 1; + +/** + * @description Get the height of the table in pixels. + * @param {TableNodeLocation} table - The table node location. + * @param {Editor} editor - The editor instance. + * @returns {number} The height of the table in pixels. + */ +export const getTableHeightPx = (table: TableNodeLocation, editor: Editor): number => { + const dom = editor.view.domAtPos(table.start); + return dom.node.parentElement?.offsetHeight ?? 0; +}; + +/** + * @description Get the width of the table in pixels. + * @param {TableNodeLocation} table - The table node location. + * @param {Editor} editor - The editor instance. + * @returns {number} The width of the table in pixels. + */ +export const getTableWidthPx = (table: TableNodeLocation, editor: Editor): number => { + const dom = editor.view.domAtPos(table.start); + return dom.node.parentElement?.offsetWidth ?? 0; +}; diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 607837e4e54..5d05f80fc85 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -16,7 +16,7 @@ const generalSelectors = [ "blockquote", "h1.editor-heading-block, h2.editor-heading-block, h3.editor-heading-block, h4.editor-heading-block, h5.editor-heading-block, h6.editor-heading-block", "[data-type=horizontalRule]", - "table", + "table:not(.table-drag-preview)", ".issue-embed", ".image-component", ".image-upload-component", @@ -65,9 +65,7 @@ const isScrollable = (node: HTMLElement | SVGElement) => { }); }; -const getScrollParent = (node: HTMLElement | SVGElement | null): Element | null => { - if (!node) return null; - +export const getScrollParent = (node: HTMLElement | SVGElement) => { if (scrollParentCache.has(node)) { return scrollParentCache.get(node); } @@ -92,7 +90,7 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => { for (const elem of elements) { // Check for table wrapper first - if (elem.matches("table")) { + if (elem.matches("table:not(.table-drag-preview)")) { return elem; } @@ -173,7 +171,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp scrollableParent.scrollBy({ top: currentScrollSpeed }); } - scrollAnimationFrame = requestAnimationFrame(scroll); + scrollAnimationFrame = requestAnimationFrame(scroll) as unknown as null; } const handleClick = (event: MouseEvent, view: EditorView) => { @@ -381,7 +379,6 @@ const handleNodeSelection = ( let draggedNodePos = nodePosAtDOM(node, view, options); if (draggedNodePos == null || draggedNodePos < 0) return; - // Handle blockquote separately if (node.matches("blockquote")) { draggedNodePos = nodePosAtDOMForBlockQuotes(node, view); if (draggedNodePos === null || draggedNodePos === undefined) return; diff --git a/packages/editor/src/styles/table.css b/packages/editor/src/styles/table.css index ba5834abb9d..c2c013d77bc 100644 --- a/packages/editor/src/styles/table.css +++ b/packages/editor/src/styles/table.css @@ -25,7 +25,7 @@ } /* Selected cell outline */ - &.selectedCell { + &.selectedCell:not(.content-hidden) { user-select: none; &::after { @@ -54,6 +54,30 @@ } } /* End selected cell outline */ + + .table-col-handle-container, + .table-row-handle-container { + & > button { + opacity: 0; + } + } + + &:hover { + .table-col-handle-container, + .table-row-handle-container { + & > button { + opacity: 1; + } + } + } + + .ProseMirror-widget + * { + padding-top: 0 !important; + } + + &.content-hidden > * { + visibility: hidden; + } } th { @@ -67,6 +91,34 @@ background-color: rgba(var(--color-background-90)); } } + + .table-drop-marker { + background-color: rgba(var(--color-primary-100)); + position: absolute; + z-index: 10; + + &.hidden { + display: none; + } + } + + .table-col-drag-marker, + .table-row-drag-marker { + position: absolute; + z-index: 10; + + &.hidden { + display: none; + } + } + + .table-col-drag-marker { + top: -9px; + } + + .table-row-drag-marker { + left: 0; + } } /* Selected status */ From 1669e98fd15985f92b821fa419abe8bddbb32a5e Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Fri, 22 Aug 2025 14:04:48 +0530 Subject: [PATCH 2/5] chore: update delete icon --- .../extensions/table/plugins/drag-handles/column/dropdown.tsx | 4 ++-- .../extensions/table/plugins/drag-handles/row/dropdown.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/dropdown.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/dropdown.tsx index 23f22f281de..562f918cd5b 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/dropdown.tsx +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/dropdown.tsx @@ -1,6 +1,6 @@ import type { Editor } from "@tiptap/core"; import { TableMap } from "@tiptap/pm/tables"; -import { ArrowLeft, ArrowRight, Copy, ToggleRight, Trash, X, type LucideIcon } from "lucide-react"; +import { ArrowLeft, ArrowRight, Copy, ToggleRight, Trash2, X, type LucideIcon } from "lucide-react"; // extensions import { findTable, getSelectedColumns } from "@/extensions/table/table/utilities/helpers"; // local imports @@ -49,7 +49,7 @@ const DROPDOWN_ITEMS: { { key: "delete", label: "Delete", - icon: Trash, + icon: Trash2, action: (editor) => editor.chain().focus().deleteColumn().run(), }, ]; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/dropdown.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/dropdown.tsx index 77712261add..09be7415b7b 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/dropdown.tsx +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/dropdown.tsx @@ -1,6 +1,6 @@ import type { Editor } from "@tiptap/core"; import { TableMap } from "@tiptap/pm/tables"; -import { ArrowDown, ArrowUp, Copy, ToggleRight, Trash, X, type LucideIcon } from "lucide-react"; +import { ArrowDown, ArrowUp, Copy, ToggleRight, Trash2, X, type LucideIcon } from "lucide-react"; // extensions import { findTable, getSelectedRows } from "@/extensions/table/table/utilities/helpers"; // local imports @@ -49,7 +49,7 @@ const DROPDOWN_ITEMS: { { key: "delete", label: "Delete", - icon: Trash, + icon: Trash2, action: (editor) => editor.chain().focus().deleteRow().run(), }, ]; From fe071536472cf5bed00497c2a7c5bbdf4e5ff501 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Fri, 22 Aug 2025 19:52:21 +0530 Subject: [PATCH 3/5] refactor: table utilities and plugins --- .../table/plugins/drag-handles/actions.ts | 15 +++++++++++++++ .../table/plugins/drag-handles/color-selector.tsx | 2 ++ .../plugins/drag-handles/column/drag-handle.tsx | 10 +++++++--- .../plugins/drag-handles/row/drag-handle.tsx | 10 +++++++--- .../table/plugins/drag-handles/utils.ts | 2 +- .../table/plugins/insert-handlers/plugin.ts | 8 ++++---- .../extensions/table/table/utilities/helpers.ts | 9 ++------- 7 files changed, 38 insertions(+), 18 deletions(-) diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/actions.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/actions.ts index fb1c5ce7a48..4136ba1ed74 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/actions.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/actions.ts @@ -101,6 +101,14 @@ export const duplicateRows = (table: TableNodeLocation, rowIndices: number[], tr const rows = tableToCells(table); const { map, width } = TableMap.get(table.node); + + // Validate row indices + const maxRow = rows.length - 1; + if (rowIndices.some((idx) => idx < 0 || idx > maxRow)) { + console.warn("Invalid row indices for duplication"); + return tr; + } + const mapStart = tr.mapping.maps.length; const lastRowPos = map[rowIndices[rowIndices.length - 1] * width + width - 1]; @@ -128,6 +136,13 @@ export const duplicateColumns = (table: TableNodeLocation, columnIndices: number const rows = tableToCells(table); const { map, width, height } = TableMap.get(table.node); + + // Validate column indices + if (columnIndices.some((idx) => idx < 0 || idx >= width)) { + console.warn("Invalid column indices for duplication"); + return tr; + } + const mapStart = tr.mapping.maps.length; for (let row = 0; row < height; row++) { diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/color-selector.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/color-selector.tsx index 34213ccaee4..e6036c3b5e6 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/color-selector.tsx +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/color-selector.tsx @@ -7,6 +7,8 @@ import { cn } from "@plane/utils"; import { COLORS_LIST } from "@/constants/common"; import { CORE_EXTENSIONS } from "@/constants/extension"; +// TODO: implement text color selector + type Props = { editor: Editor; onSelect: (color: string | null) => void; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx index 8ba3fe4c310..425bc7572fa 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx @@ -1,7 +1,6 @@ import { shift, flip, - offset, useDismiss, useFloating, useInteractions, @@ -147,8 +146,13 @@ export const ColumnDragHandle: React.FC = (props) => { }); }; - window.addEventListener("mouseup", handleFinish); - window.addEventListener("mousemove", handleMove); + try { + window.addEventListener("mouseup", handleFinish); + window.addEventListener("mousemove", handleMove); + } catch (error) { + console.error("Error in ColumnDragHandle:", error); + handleFinish(); + } }, [col, editor] ); diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx index 589b5530643..7c0f1449a1d 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx @@ -3,7 +3,6 @@ import { flip, FloatingOverlay, FloatingPortal, - offset, shift, useClick, useDismiss, @@ -146,8 +145,13 @@ export const RowDragHandle: React.FC = (props) => { }); }; - window.addEventListener("mouseup", handleFinish); - window.addEventListener("mousemove", handleMove); + try { + window.addEventListener("mouseup", handleFinish); + window.addEventListener("mousemove", handleMove); + } catch (error) { + console.error("Error in RowDragHandle:", error); + handleFinish(); + } }, [editor, row] ); diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/utils.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/utils.ts index 256192e952d..591d4357603 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/utils.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/utils.ts @@ -32,7 +32,7 @@ export const cloneTableCell = ( clonedCellElement: HTMLElement; } => { const clonedCellElement = cellElement.cloneNode(true) as HTMLElement; - clonedCellElement.style.visibility = "visible !important"; + clonedCellElement.style.setProperty("visibility", "visible", "important"); const widgetElement = clonedCellElement.querySelectorAll(".ProseMirror-widget"); widgetElement.forEach((widget) => widget.remove()); 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 index 50c9b489f86..75f39641661 100644 --- a/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts +++ b/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts @@ -98,7 +98,7 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { const createMarkerContainer = (): HTMLElement => { const el = document.createElement("div"); - el.classList.add("table-drag-marker-container"); + el.className = "table-drag-marker-container"; el.contentEditable = "false"; el.appendChild(createDropMarker()); el.appendChild(createColDragMarker()); @@ -108,20 +108,20 @@ const createMarkerContainer = (): HTMLElement => { const createDropMarker = (): HTMLElement => { const el = document.createElement("div"); - el.classList.add(DROP_MARKER_CLASS); + el.className = DROP_MARKER_CLASS; return el; }; const createColDragMarker = (): HTMLElement => { const el = document.createElement("div"); - el.classList.value = `${COL_DRAG_MARKER_CLASS} hidden`; + el.className = `${COL_DRAG_MARKER_CLASS} hidden`; return el; }; const createRowDragMarker = (): HTMLElement => { const el = document.createElement("div"); - el.classList.value = `${ROW_DRAG_MARKER_CLASS} hidden`; + el.className = `${ROW_DRAG_MARKER_CLASS} hidden`; return el; }; diff --git a/packages/editor/src/core/extensions/table/table/utilities/helpers.ts b/packages/editor/src/core/extensions/table/table/utilities/helpers.ts index b42001c0565..211dc2c42a3 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/helpers.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/helpers.ts @@ -87,9 +87,7 @@ export const getSelectedRect = (selection: CellSelection, map: TableMap): Rect = * @returns {number[]} The selected columns. */ export const getSelectedColumns = (selection: Selection, map: TableMap): number[] => { - const isColumnSelection = isCellSelection(selection) && selection.isColSelection(); - console.log("isColumnSelection", isColumnSelection); - if (isColumnSelection) { + if (isCellSelection(selection) && selection.isColSelection()) { const selectedRect = getSelectedRect(selection, map); return [...Array(selectedRect.right - selectedRect.left).keys()].map((idx) => idx + selectedRect.left); } @@ -104,10 +102,7 @@ export const getSelectedColumns = (selection: Selection, map: TableMap): number[ * @returns {number[]} The selected rows. */ export const getSelectedRows = (selection: Selection, map: TableMap): number[] => { - console.log("helper isCellSelection", isCellSelection(selection)); - const isRowSelection = isCellSelection(selection) && selection.isRowSelection(); - console.log("isRowSelection", isRowSelection); - if (isRowSelection) { + if (isCellSelection(selection) && selection.isRowSelection()) { const selectedRect = getSelectedRect(selection, map); return [...Array(selectedRect.bottom - selectedRect.top).keys()].map((idx) => idx + selectedRect.top); } From e6f2e52ca03ca8d4b65a7fe485bfb39238a1d56a Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Fri, 22 Aug 2025 20:03:01 +0530 Subject: [PATCH 4/5] chore: handle edge cases --- .../table/plugins/drag-handles/actions.ts | 10 ++++++++++ .../table/plugins/drag-handles/column/utils.ts | 13 ++++++++++--- .../table/plugins/drag-handles/row/utils.ts | 8 +++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/actions.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/actions.ts index 4136ba1ed74..a2ebc26bb7c 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/actions.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/actions.ts @@ -37,6 +37,11 @@ export const moveSelectedColumns = ( } }); + if (columnStart === -1 || columnEnd === -1) { + console.warn("Invalid column selection"); + return tr; + } + if (to < 0 || to > tableMap.width || (to >= columnStart && to < columnEnd)) return tr; const rows = tableToCells(table); @@ -79,6 +84,11 @@ export const moveSelectedRows = ( } }); + if (rowStart === -1 || rowEnd === -1) { + console.warn("Invalid row selection"); + return tr; + } + if (to < 0 || to > tableMap.height || (to >= rowStart && to < rowEnd)) return tr; const rows = tableToCells(table); diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/utils.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/utils.ts index 1fa13aca3ee..f88f90fdcd2 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/utils.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/utils.ts @@ -89,9 +89,16 @@ export const getTableColumnNodesInfo = (table: TableNodeLocation, editor: Editor const result: TableColumn[] = []; let leftPx = 0; - const { map, width } = TableMap.get(table.node); - for (let col = 0; col < width; col++) { - const dom = editor.view.domAtPos(table.start + map[col] + 1); + const tableMap = TableMap.get(table.node); + if (!tableMap || tableMap.height === 0 || tableMap.width === 0) { + return result; + } + + for (let col = 0; col < tableMap.width; col++) { + const cellPos = tableMap.map[col]; + if (cellPos === undefined) continue; + + const dom = editor.view.domAtPos(table.start + cellPos + 1); if (dom.node instanceof HTMLElement) { if (col === 0) { leftPx = dom.node.offsetLeft; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/utils.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/utils.ts index d913736fef5..d43d9ae73e9 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/utils.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/utils.ts @@ -90,8 +90,14 @@ export const getTableRowNodesInfo = (table: TableNodeLocation, editor: Editor): let topPx = 0; const tableMap = TableMap.get(table.node); + if (!tableMap || tableMap.height === 0 || tableMap.width === 0) { + return result; + } + for (let row = 0; row < tableMap.height; row++) { - const dom = editor.view.domAtPos(table.start + tableMap.map[row * tableMap.width]); + const cellPos = tableMap.map[row * tableMap.width]; + if (cellPos === undefined) continue; + const dom = editor.view.domAtPos(table.start + cellPos); if (dom.node instanceof HTMLElement) { const heightPx = dom.node.offsetHeight; result.push({ From b60d8935fbc598500e18cf2ba9591b51517ad28c Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Fri, 22 Aug 2025 20:10:14 +0530 Subject: [PATCH 5/5] chore: safe pseudo element inserts --- .../table/plugins/drag-handles/marker-utils.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/marker-utils.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/marker-utils.ts index 784a046bf08..db2095e1128 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/marker-utils.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/marker-utils.ts @@ -72,7 +72,12 @@ export const updateColDragMarker = ({ element.style.width = `${width}px`; element.classList.remove("hidden"); if (pseudoColumn) { - element.innerHTML = pseudoColumn.outerHTML; + /// clear existing content + while (element.firstChild) { + element.removeChild(element.firstChild); + } + // clone and append the pseudo column + element.appendChild(pseudoColumn.cloneNode(true)); } }; @@ -91,6 +96,11 @@ export const updateRowDragMarker = ({ element.style.height = `${height}px`; element.classList.remove("hidden"); if (pseudoRow) { - element.innerHTML = pseudoRow.outerHTML; + /// clear existing content + while (element.firstChild) { + element.removeChild(element.firstChild); + } + // clone and append the pseudo row + element.appendChild(pseudoRow.cloneNode(true)); } };