Split menu components for better responsibility handling

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-26 10:48:16 +04:00
parent 0b02171e4a
commit cc93378996
24 changed files with 1706 additions and 1262 deletions

View File

@@ -0,0 +1,132 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import type { Node as PMNode } from "@tiptap/pm/model";
import { TableMap } from "@tiptap/pm/tables";
import { type Editor } from "@tiptap/react";
import { useState } from "react";
import { cellDomElement } from "../_lib/cellDomElement";
import { useTableDropdownMenu } from "../_lib/useTableDropdownMenu";
import { TableColumnMenuContent } from "./TableColumnMenuContent";
import { TableColumnMenuTrigger } from "./TableColumnMenuTrigger";
export type HoveredColumn = {
colIndex: number;
tableStart: number;
};
export function getColumnRect(
editor: Editor,
tableStart: number,
colIndex: number,
): DOMRect | null {
try {
const tableNodePos = tableStart - 1;
const table = editor.state.doc.nodeAt(tableNodePos);
if (!table) return null;
const map = TableMap.get(table);
if (colIndex < 0 || colIndex >= map.width) return null;
const cellPos = map.positionAt(0, colIndex, table) + tableStart;
const el = cellDomElement(editor, cellPos);
if (!el) return null;
const topRect = el.getBoundingClientRect();
let bottom = topRect.bottom;
if (map.height > 1) {
const lastCellPos
= map.positionAt(map.height - 1, colIndex, table) + tableStart;
const lastEl = cellDomElement(editor, lastCellPos);
if (lastEl) {
bottom = lastEl.getBoundingClientRect().bottom;
}
}
return new DOMRect(
topRect.left,
topRect.top,
topRect.width,
bottom - topRect.top,
);
} catch {
return null;
}
}
export function moveColumn(
editor: Editor,
tableStart: number,
fromCol: number,
toCol: number,
) {
if (fromCol === toCol) return;
const tableNodePos = tableStart - 1;
const table = editor.state.doc.nodeAt(tableNodePos);
if (!table) return;
const rows: PMNode[] = [];
table.forEach((row) => {
const cells: PMNode[] = [];
row.forEach(cell => cells.push(cell));
const [moved] = cells.splice(fromCol, 1);
cells.splice(toCol, 0, moved);
rows.push(row.type.create(row.attrs, cells));
});
const newTable = table.type.create(table.attrs, rows);
const { tr } = editor.state;
tr.replaceWith(tableNodePos, tableNodePos + table.nodeSize, newTable);
editor.view.dispatch(tr);
}
type TableColumnMenuProps = {
editor: Editor;
};
export function TableColumnMenu({ editor }: TableColumnMenuProps) {
const {
menuOpen,
setMenuOpen,
setTriggerEl,
setDropdownEl,
menuRefs,
menuStyles,
getFloatingProps,
} = useTableDropdownMenu();
const [hoveredCol, setHoveredCol] = useState<HoveredColumn | null>(null);
if (!hoveredCol && !menuOpen) return null;
return (
<>
<TableColumnMenuTrigger
editor={editor}
hoveredCol={hoveredCol}
setHoveredCol={setHoveredCol}
menuOpen={menuOpen}
setMenuOpen={setMenuOpen}
setTriggerEl={setTriggerEl}
menuRefs={menuRefs}
/>
{menuOpen && hoveredCol && (
<TableColumnMenuContent
editor={editor}
hoveredCol={hoveredCol}
setMenuOpen={setMenuOpen}
setHoveredCol={setHoveredCol}
setDropdownEl={setDropdownEl}
menuRefs={menuRefs}
menuStyles={menuStyles}
getFloatingProps={getFloatingProps}
/>
)}
</>
);
}

View File

@@ -0,0 +1,268 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import {
BroomIcon,
CopyIcon,
CrownSimpleIcon,
PlusIcon,
TrashIcon,
} from "@phosphor-icons/react";
import { TextSelection } from "@tiptap/pm/state";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
import { type Editor } from "@tiptap/react";
import type { DropdownMenu } from "../_lib/useTableDropdownMenu";
import { MenuButton } from "../MenuButton";
import { type HoveredColumn } from "./TableColumnMenu";
import { tableColumnMenuVariants } from "./variants";
const { menu } = tableColumnMenuVariants();
type TableColumnMenuContentProps = {
editor: Editor;
hoveredCol: HoveredColumn;
setMenuOpen: DropdownMenu["setMenuOpen"];
setHoveredCol: React.Dispatch<React.SetStateAction<HoveredColumn | null>>;
setDropdownEl: DropdownMenu["setDropdownEl"];
menuRefs: DropdownMenu["menuRefs"];
menuStyles: DropdownMenu["menuStyles"];
getFloatingProps: DropdownMenu["getFloatingProps"];
};
export function TableColumnMenuContent({
editor,
hoveredCol,
setMenuOpen,
setHoveredCol,
setDropdownEl,
menuRefs,
menuStyles,
getFloatingProps,
}: TableColumnMenuContentProps) {
const currentCol = hoveredCol;
const isFirstColumn = currentCol.colIndex === 0;
const isHeaderColumn = (): boolean => {
if (currentCol.colIndex !== 0) return false;
try {
const table = editor.state.doc.nodeAt(currentCol.tableStart - 1);
if (!table) return false;
const map = TableMap.get(table);
for (let row = 0; row < map.height; row++) {
const cellPos = map.map[row * map.width] + currentCol.tableStart;
const cellNode = editor.state.doc.nodeAt(cellPos);
if (!cellNode || cellNode.type.name !== "tableHeader") return false;
}
return true;
} catch {
return false;
}
};
const handleToggleHeaderColumn = () => {
if (currentCol.colIndex !== 0) return;
const { tableStart } = currentCol;
try {
const table = editor.state.doc.nodeAt(tableStart - 1);
if (!table) return;
const map = TableMap.get(table);
const cellPos = map.positionAt(0, 0, table) + tableStart;
editor
.chain()
.focus()
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, cellPos + 1));
return true;
})
.toggleHeaderColumn()
.run();
} catch {
// table may have changed
}
setMenuOpen(false);
};
const handleDeleteColumn = () => {
const { colIndex, tableStart } = currentCol;
try {
const table = editor.state.doc.nodeAt(tableStart - 1);
if (!table) return;
const map = TableMap.get(table);
const cellPos = map.positionAt(0, colIndex, table) + tableStart;
editor
.chain()
.focus()
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, cellPos + 1));
return true;
})
.deleteColumn()
.run();
} catch {
// table may have changed
}
setMenuOpen(false);
setHoveredCol(null);
};
const handleDuplicateColumn = () => {
const { colIndex, tableStart } = currentCol;
try {
const table = editor.state.doc.nodeAt(tableStart - 1);
if (!table) return;
const map = TableMap.get(table);
editor
.chain()
.focus()
.command(({ tr }) => {
for (let row = map.height - 1; row >= 0; row--) {
const cellOffset = map.map[row * map.width + colIndex];
const cell = table.nodeAt(cellOffset);
if (!cell) continue;
const insertPos = cellOffset + tableStart + cell.nodeSize;
tr.insert(insertPos, cell);
}
return true;
})
.run();
} catch {
// table may have changed
}
setMenuOpen(false);
};
const handleInsertLeft = () => {
const { colIndex, tableStart } = currentCol;
try {
const table = editor.state.doc.nodeAt(tableStart - 1);
if (!table) return;
const map = TableMap.get(table);
const cellPos = map.positionAt(0, colIndex, table) + tableStart;
editor
.chain()
.focus()
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, cellPos + 1));
return true;
})
.addColumnBefore()
.run();
} catch {
// table may have changed
}
setMenuOpen(false);
};
const handleInsertRight = () => {
const { colIndex, tableStart } = currentCol;
try {
const table = editor.state.doc.nodeAt(tableStart - 1);
if (!table) return;
const map = TableMap.get(table);
const cellPos = map.positionAt(0, colIndex, table) + tableStart;
editor
.chain()
.focus()
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, cellPos + 1));
return true;
})
.addColumnAfter()
.run();
} catch {
// table may have changed
}
setMenuOpen(false);
};
const handleClearContents = () => {
const { colIndex, tableStart } = currentCol;
try {
const table = editor.state.doc.nodeAt(tableStart - 1);
if (!table) return;
const map = TableMap.get(table);
const firstCellPos = map.map[colIndex] + tableStart;
const lastCellPos
= map.map[(map.height - 1) * map.width + colIndex] + tableStart;
const $anchor = editor.state.doc.resolve(firstCellPos);
const $head = editor.state.doc.resolve(lastCellPos);
editor.view.dispatch(
editor.state.tr.setSelection(new CellSelection($anchor, $head)),
);
editor.commands.deleteSelection();
} catch {
// table may have changed
}
setMenuOpen(false);
};
return (
<div
ref={(node) => {
setDropdownEl(node);
menuRefs.setFloating(node);
}}
data-column-menu
style={menuStyles}
{...getFloatingProps()}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
{isFirstColumn && (
<MenuButton active={isHeaderColumn()} onClick={handleToggleHeaderColumn}>
<CrownSimpleIcon size={16} weight="bold" />
Header column
</MenuButton>
)}
<MenuButton onClick={handleInsertLeft}>
<PlusIcon size={16} weight="bold" />
Insert column left
</MenuButton>
<MenuButton onClick={handleInsertRight}>
<PlusIcon size={16} weight="bold" />
Insert column right
</MenuButton>
<MenuButton onClick={handleDuplicateColumn}>
<CopyIcon size={16} weight="bold" />
Duplicate column
</MenuButton>
<MenuButton onClick={handleClearContents}>
<BroomIcon size={16} weight="bold" />
Clear contents
</MenuButton>
<MenuButton onClick={handleDeleteColumn}>
<TrashIcon size={16} weight="bold" />
Delete column
</MenuButton>
</div>
);
}

View File

@@ -0,0 +1,368 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import { autoUpdate, offset, size, useFloating } from "@floating-ui/react";
import { DotsThreeIcon } from "@phosphor-icons/react";
import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables";
import { type Editor } from "@tiptap/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { DRAG_THRESHOLD } from "../_lib/constants";
import type { DropdownMenu } from "../_lib/useTableDropdownMenu";
import { getColumnRect, type HoveredColumn, moveColumn } from "./TableColumnMenu";
import { tableColumnMenuVariants } from "./variants";
const { trigger } = tableColumnMenuVariants();
type TableColumnMenuTriggerProps = {
editor: Editor;
hoveredCol: HoveredColumn | null;
setHoveredCol: React.Dispatch<React.SetStateAction<HoveredColumn | null>>;
menuOpen: boolean;
setMenuOpen: DropdownMenu["setMenuOpen"];
setTriggerEl: DropdownMenu["setTriggerEl"];
menuRefs: DropdownMenu["menuRefs"];
};
export function TableColumnMenuTrigger({
editor,
hoveredCol,
setHoveredCol,
menuOpen,
setMenuOpen,
setTriggerEl,
menuRefs,
}: TableColumnMenuTriggerProps) {
const [dragIndicator, setDragIndicator] = useState<{
left: number;
top: number;
height: number;
} | null>(null);
const draggingRef = useRef(false);
const dragStartPos = useRef({ x: 0, y: 0 });
const rafId = useRef<number | null>(null);
const hoveredColRef = useRef<HoveredColumn | null>(null);
useEffect(() => {
hoveredColRef.current = hoveredCol;
}, [hoveredCol]);
useEffect(() => {
if (editor.isDestroyed || !editor.isEditable) return;
const editorDom = editor.view.dom;
const onMouseMove = (e: MouseEvent) => {
if (draggingRef.current || menuOpen) return;
if (rafId.current) return;
rafId.current = requestAnimationFrame(() => {
rafId.current = null;
const target = e.target as HTMLElement;
if (
target.closest("[data-column-handle]")
|| target.closest("[data-column-menu]")
) {
return;
}
const cell = target.closest("td, th");
if (cell && editorDom.contains(cell)) {
try {
const pos = editor.view.posAtDOM(cell, 0);
const $pos = editor.state.doc.resolve(pos);
const cellResolved = cellAround($pos);
if (cellResolved) {
const table = cellResolved.node(-1);
const ts = cellResolved.start(-1);
const map = TableMap.get(table);
const cellRect = map.findCell(cellResolved.pos - ts);
const ci = cellRect.left;
setHoveredCol((prev) => {
if (prev && prev.colIndex === ci && prev.tableStart === ts) {
return prev;
}
return { colIndex: ci, tableStart: ts };
});
return;
}
} catch {
// fall through to clear
}
}
const current = hoveredColRef.current;
if (current) {
const rect = getColumnRect(editor, current.tableStart, current.colIndex);
if (rect) {
const zoneTop = rect.top - 40;
if (
e.clientX >= rect.left
&& e.clientX <= rect.left + rect.width
&& e.clientY >= zoneTop
&& e.clientY <= rect.top
) {
return;
}
}
}
if (hoveredColRef.current) {
setHoveredCol(null);
}
});
};
document.addEventListener("mousemove", onMouseMove);
return () => {
document.removeEventListener("mousemove", onMouseMove);
if (rafId.current) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
};
}, [editor, menuOpen, setHoveredCol]);
const {
refs: handleRefs,
floatingStyles: handleStyles,
isPositioned,
} = useFloating({
strategy: "fixed",
placement: "top",
middleware: [
offset(6),
size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`,
});
},
}),
],
whileElementsMounted: (ref, floating, update) =>
autoUpdate(ref, floating, update, { animationFrame: true }),
});
useLayoutEffect(() => {
if (!hoveredCol) {
handleRefs.setReference(null);
return;
}
const { colIndex, tableStart } = hoveredCol;
const ed = editor;
handleRefs.setReference({
getBoundingClientRect() {
const r = getColumnRect(ed, tableStart, colIndex);
if (!r) return new DOMRect(0, 0, 0, 0);
return new DOMRect(r.left, r.top, r.width, 0);
},
});
}, [hoveredCol, editor, handleRefs]);
const computeTargetGap = (clientX: number, tableStart: number): number => {
const table = editor.state.doc.nodeAt(tableStart - 1);
if (!table) return 0;
const map = TableMap.get(table);
let targetGap = 0;
for (let col = 0; col < map.width; col++) {
const r = getColumnRect(editor, tableStart, col);
if (!r) continue;
const midX = r.left + r.width / 2;
if (clientX > midX) {
targetGap = col + 1;
} else {
targetGap = col;
break;
}
}
return targetGap;
};
const computeGapX = (
tableStart: number,
gap: number,
): number | null => {
const table = editor.state.doc.nodeAt(tableStart - 1);
if (!table) return null;
const map = TableMap.get(table);
if (gap <= 0) {
const r = getColumnRect(editor, tableStart, 0);
return r ? r.left : null;
}
if (gap >= map.width) {
const r = getColumnRect(editor, tableStart, map.width - 1);
return r ? r.left + r.width : null;
}
const rLeft = getColumnRect(editor, tableStart, gap - 1);
const rRight = getColumnRect(editor, tableStart, gap);
if (rLeft && rRight) {
return (rLeft.left + rLeft.width + rRight.left) / 2;
}
return null;
};
const onHandleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (menuOpen || !hoveredCol) return;
draggingRef.current = false;
dragStartPos.current = { x: e.clientX, y: e.clientY };
const { colIndex: fromCol, tableStart } = hoveredCol;
const view = editor.view;
const onMouseMove = (ev: MouseEvent) => {
const dx = ev.clientX - dragStartPos.current.x;
const dy = ev.clientY - dragStartPos.current.y;
if (!draggingRef.current) {
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
draggingRef.current = true;
try {
const table = editor.state.doc.nodeAt(tableStart - 1);
if (table) {
const map = TableMap.get(table);
const anchorPos
= map.positionAt(0, fromCol, table) + tableStart;
const headPos
= map.positionAt(map.height - 1, fromCol, table) + tableStart;
const sel = CellSelection.create(
view.state.doc,
anchorPos,
headPos,
);
view.dispatch(view.state.tr.setSelection(sel));
}
} catch {
// table may have changed
}
}
try {
const targetGap = computeTargetGap(ev.clientX, tableStart);
if (targetGap === fromCol || targetGap === fromCol + 1) {
setDragIndicator(null);
return;
}
const gapX = computeGapX(tableStart, targetGap);
if (gapX !== null) {
const colRect = getColumnRect(editor, tableStart, 0);
if (colRect) {
setDragIndicator({
left: gapX,
top: colRect.top,
height: colRect.height,
});
}
}
} catch {
// position may be outside table
}
};
const onMouseUp = (ev: MouseEvent) => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (draggingRef.current) {
setDragIndicator(null);
try {
const targetGap = computeTargetGap(ev.clientX, tableStart);
if (targetGap !== fromCol && targetGap !== fromCol + 1) {
const toCol
= fromCol < targetGap ? targetGap - 1 : targetGap;
moveColumn(editor, tableStart, fromCol, toCol);
}
} catch {
// table may have changed
}
} else {
try {
const table = editor.state.doc.nodeAt(tableStart - 1);
if (table) {
const map = TableMap.get(table);
const anchorPos
= map.positionAt(0, fromCol, table) + tableStart;
const headPos
= map.positionAt(map.height - 1, fromCol, table) + tableStart;
const sel = CellSelection.create(
view.state.doc,
anchorPos,
headPos,
);
view.dispatch(view.state.tr.setSelection(sel));
}
} catch {
// table may have changed
}
setMenuOpen(prev => !prev);
}
draggingRef.current = false;
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
return (
<>
<button
ref={(node) => {
handleRefs.setFloating(node);
setTriggerEl(node);
menuRefs.setReference(node);
}}
data-column-handle
onMouseDown={onHandleMouseDown}
type="button"
style={{
...handleStyles,
visibility:
isPositioned && (hoveredCol || menuOpen) ? "visible" : "hidden",
}}
className={trigger()}
>
<DotsThreeIcon size={16} weight="bold" />
</button>
{dragIndicator && (
<div
style={{
position: "fixed",
left: dragIndicator.left - 1,
top: dragIndicator.top,
width: 2,
height: dragIndicator.height,
backgroundColor: "var(--color-border-info)",
zIndex: 40,
pointerEvents: "none",
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import { tv } from "tailwind-variants";
export const tableColumnMenuVariants = tv({
slots: {
trigger: [
"z-10 flex items-center justify-center",
"rounded text-txt-tertiary bg-subtle hover:bg-border-solid cursor-grab",
"py-0.5 h-3",
],
menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"],
},
});