[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:
parent
4ad88c969c
commit
9ecea15d74
22 changed files with 1858 additions and 150 deletions
|
|
@ -1,3 +1,5 @@
|
|||
export enum CORE_EDITOR_META {
|
||||
SKIP_FILE_DELETION = "skipFileDeletion",
|
||||
INTENTIONAL_DELETION = "intentionalDeletion",
|
||||
ADD_TO_HISTORY = "addToHistory",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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));
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue