[WIKI-498] [WIKI-567] feat: ability to rearrange columns and rows in table (#7624)

* feat: ability to rearrange columns and rows

* chore: update delete icon

* refactor: table utilities and plugins

* chore: handle edge cases

* chore: safe pseudo element inserts

---------

Co-authored-by: Sriram Veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2025-08-23 00:54:03 +05:30 committed by GitHub
parent 4ad88c969c
commit 9ecea15d74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1858 additions and 150 deletions

View file

@ -1,3 +1,5 @@
export enum CORE_EDITOR_META {
SKIP_FILE_DELETION = "skipFileDeletion",
INTENTIONAL_DELETION = "intentionalDeletion",
ADD_TO_HISTORY = "addToHistory",
}

View file

@ -0,0 +1,211 @@
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 (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);
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 (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);
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);
// 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];
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);
// 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++) {
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<number>();
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);
};

View file

@ -0,0 +1,117 @@
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";
// TODO: implement text color selector
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> = (props) => {
const { editor, onSelect } = props;
return (
<Disclosure defaultOpen>
<Disclosure.Button
as="button"
type="button"
className="flex items-center justify-between gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200 hover:bg-custom-background-80"
>
{({ open }) => (
<>
<span className="flex items-center gap-2">
<Palette className="shrink-0 size-3" />
Color
</span>
<ChevronRight
className={cn("shrink-0 size-3 transition-transform duration-200", {
"rotate-90": open,
})}
/>
</>
)}
</Disclosure.Button>
<Disclosure.Panel className="p-1 space-y-2 mb-1.5">
{/* <div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
<div className="flex items-center flex-wrap gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.textColor,
}}
onClick={() => handleTextColorChange(editor, color.textColor)}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => handleTextColorChange(editor, null)}
>
<Ban className="size-4" />
</button>
</div>
</div> */}
<div className="space-y-1">
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
<div className="flex items-center flex-wrap gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => {
handleBackgroundColorChange(editor, color.backgroundColor);
onSelect(color.backgroundColor);
}}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => {
handleBackgroundColorChange(editor, null);
onSelect(null);
}}
>
<Ban className="size-4" />
</button>
</div>
</div>
</Disclosure.Panel>
</Disclosure>
);
};

View file

@ -0,0 +1,204 @@
import {
shift,
flip,
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<ColumnDragHandleProps> = (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<HTMLButtonElement>) => {
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,
});
};
try {
window.addEventListener("mouseup", handleFinish);
window.addEventListener("mousemove", handleMove);
} catch (error) {
console.error("Error in ColumnDragHandle:", error);
handleFinish();
}
},
[col, editor]
);
return (
<>
<div className="table-col-handle-container absolute z-20 top-0 left-0 flex justify-center items-center w-full -translate-y-1/2">
<button
ref={refs.setReference}
{...getReferenceProps()}
type="button"
onMouseDown={handleMouseDown}
className={cn(
"px-1 bg-custom-background-90 border border-custom-border-400 rounded outline-none transition-all duration-200",
{
"!opacity-100 bg-custom-primary-100 border-custom-primary-100": isDropdownOpen,
"hover:bg-custom-background-80": !isDropdownOpen,
}
)}
>
<Ellipsis className="size-4 text-custom-text-100" />
</button>
</div>
{isDropdownOpen && (
<FloatingPortal>
{/* Backdrop */}
<FloatingOverlay
style={{
zIndex: 99,
}}
lockScroll
/>
<div
className="max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
ref={refs.setFloating}
{...getFloatingProps()}
style={{
...floatingStyles,
zIndex: 100,
}}
>
<ColumnOptionsDropdown editor={editor} onClose={() => setIsDropdownOpen(false)} />
</div>
</FloatingPortal>
)}
</>
);
};

View file

@ -0,0 +1,100 @@
import type { Editor } from "@tiptap/core";
import { TableMap } from "@tiptap/pm/tables";
import { ArrowLeft, ArrowRight, Copy, ToggleRight, Trash2, 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: Trash2,
action: (editor) => editor.chain().focus().deleteColumn().run(),
},
];
type Props = {
editor: Editor;
onClose: () => void;
};
export const ColumnOptionsDropdown: React.FC<Props> = (props) => {
const { editor, onClose } = props;
return (
<>
<button
type="button"
className="flex items-center justify-between gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
editor.chain().focus().toggleHeaderColumn().run();
onClose();
}}
>
<div className="flex-grow truncate">Header column</div>
<ToggleRight className="shrink-0 size-3" />
</button>
<hr className="my-2 border-custom-border-200" />
<TableDragHandleDropdownColorSelector editor={editor} onSelect={onClose} />
{DROPDOWN_ITEMS.map((item) => (
<button
key={item.key}
type="button"
className="flex items-center gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action(editor);
onClose();
}}
>
<item.icon className="shrink-0 size-3" />
<div className="flex-grow truncate">{item.label}</div>
</button>
))}
</>
);
};

View file

@ -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<TableColumnDragHandlePluginState> =>
new Plugin<TableColumnDragHandlePluginState>({
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;
},
},
});

View file

@ -0,0 +1,150 @@
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 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;
}
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;
};

View file

@ -0,0 +1,106 @@
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) {
/// clear existing content
while (element.firstChild) {
element.removeChild(element.firstChild);
}
// clone and append the pseudo column
element.appendChild(pseudoColumn.cloneNode(true));
}
};
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) {
/// clear existing content
while (element.firstChild) {
element.removeChild(element.firstChild);
}
// clone and append the pseudo row
element.appendChild(pseudoRow.cloneNode(true));
}
};

View file

@ -0,0 +1,203 @@
import {
autoUpdate,
flip,
FloatingOverlay,
FloatingPortal,
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<RowDragHandleProps> = (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<HTMLButtonElement>) => {
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,
});
};
try {
window.addEventListener("mouseup", handleFinish);
window.addEventListener("mousemove", handleMove);
} catch (error) {
console.error("Error in RowDragHandle:", error);
handleFinish();
}
},
[editor, row]
);
return (
<>
<div className="table-row-handle-container absolute z-20 top-0 left-0 flex justify-center items-center h-full -translate-x-1/2">
<button
ref={refs.setReference}
{...getReferenceProps()}
type="button"
onMouseDown={handleMouseDown}
className={cn(
"py-1 bg-custom-background-90 border border-custom-border-400 rounded outline-none transition-all duration-200",
{
"!opacity-100 bg-custom-primary-100 border-custom-primary-100": isDropdownOpen,
"hover:bg-custom-background-80": !isDropdownOpen,
}
)}
>
<Ellipsis className="size-4 text-custom-text-100 rotate-90" />
</button>
</div>
{isDropdownOpen && (
<FloatingPortal>
{/* Backdrop */}
<FloatingOverlay
style={{
zIndex: 99,
}}
lockScroll
/>
<div
className="max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
ref={refs.setFloating}
{...getFloatingProps()}
style={{
...floatingStyles,
zIndex: 100,
}}
>
<RowOptionsDropdown editor={editor} onClose={() => setIsDropdownOpen(false)} />
</div>
</FloatingPortal>
)}
</>
);
};

View file

@ -0,0 +1,100 @@
import type { Editor } from "@tiptap/core";
import { TableMap } from "@tiptap/pm/tables";
import { ArrowDown, ArrowUp, Copy, ToggleRight, Trash2, 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: Trash2,
action: (editor) => editor.chain().focus().deleteRow().run(),
},
];
type Props = {
editor: Editor;
onClose: () => void;
};
export const RowOptionsDropdown: React.FC<Props> = (props) => {
const { editor, onClose } = props;
return (
<>
<button
type="button"
className="flex items-center justify-between gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
editor.chain().focus().toggleHeaderRow().run();
onClose();
}}
>
<div className="flex-grow truncate">Header row</div>
<ToggleRight className="shrink-0 size-3" />
</button>
<hr className="my-2 border-custom-border-200" />
<TableDragHandleDropdownColorSelector editor={editor} onSelect={onClose} />
{DROPDOWN_ITEMS.map((item) => (
<button
key={item.key}
type="button"
className="flex items-center gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action(editor);
onClose();
}}
>
<item.icon className="shrink-0 size-3" />
<div className="flex-grow truncate">{item.label}</div>
</button>
))}
</>
);
};

View file

@ -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<TableRowDragHandlePluginState> =>
new Plugin<TableRowDragHandlePluginState>({
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;
},
},
});

View file

@ -0,0 +1,149 @@
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);
if (!tableMap || tableMap.height === 0 || tableMap.width === 0) {
return result;
}
for (let row = 0; row < tableMap.height; row++) {
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({
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;
};

View file

@ -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.setProperty("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();

View file

@ -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.className = "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.className = DROP_MARKER_CLASS;
return el;
};
const createColDragMarker = (): HTMLElement => {
const el = document.createElement("div");
el.className = `${COL_DRAG_MARKER_CLASS} hidden`;
return el;
};
const createRowDragMarker = (): HTMLElement => {
const el = document.createElement("div");
el.className = `${ROW_DRAG_MARKER_CLASS} hidden`;
return el;
};

View file

@ -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,

View file

@ -47,6 +47,9 @@ export const TableCell = Node.create<TableCellOptions>({
textColor: {
default: null,
},
hideContent: {
default: false,
},
};
},
@ -107,7 +110,8 @@ export const TableCell = Node.create<TableCellOptions>({
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,
];

View file

@ -17,7 +17,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
};
},
content: "paragraph+",
content: "block+",
addAttributes() {
return {
@ -39,6 +39,9 @@ export const TableHeader = Node.create<TableHeaderOptions>({
background: {
default: "none",
},
hideContent: {
default: false,
},
};
},
@ -54,7 +57,8 @@ export const TableHeader = Node.create<TableHeaderOptions>({
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,
];

View file

@ -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;
}
}

View file

@ -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<TableOptions>({
() =>
({ state, dispatch }) =>
toggleHeaderCell(state, dispatch),
clearSelectedCells:
() =>
({ state, dispatch }) =>
deleteCellSelection(state, dispatch),
mergeOrSplit:
() =>
({ state, dispatch }) => {
@ -254,10 +261,10 @@ export const Table = Node.create<TableOptions>({
},
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<TableOptions>({
tableEditing({
allowTableNodeSelection: this.options.allowTableNodeSelection,
}),
tableControls(),
TableInsertPlugin(this.editor),
TableColumnDragHandlePlugin(this.editor),
TableRowDragHandlePlugin(this.editor),
];
if (isResizable) {

View file

@ -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,184 @@ 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[] => {
if (isCellSelection(selection) && selection.isColSelection()) {
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[] => {
if (isCellSelection(selection) && selection.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;
};

View file

@ -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;

View file

@ -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 */