diff --git a/packages/ui/src/RichEditor/BlockMenu.tsx b/packages/ui/src/RichEditor/BlockMenu.tsx index be6850206..142ff664a 100644 --- a/packages/ui/src/RichEditor/BlockMenu.tsx +++ b/packages/ui/src/RichEditor/BlockMenu.tsx @@ -211,7 +211,7 @@ export function BlockMenu({ editor }: BlockMenuProps) { } = useFloating({ strategy: "fixed", placement: triggerPlacement, - middleware: [offset(32)], + middleware: [offset(40)], whileElementsMounted: autoUpdate, }); diff --git a/packages/ui/src/RichEditor/OptionsMenu.tsx b/packages/ui/src/RichEditor/OptionsMenu.tsx index 4ca709db6..8d11ee387 100644 --- a/packages/ui/src/RichEditor/OptionsMenu.tsx +++ b/packages/ui/src/RichEditor/OptionsMenu.tsx @@ -71,7 +71,7 @@ export function OptionsMenu({ editor }: OptionsMenuProps) { } = useFloating({ strategy: "fixed", placement: triggerPlacement, - middleware: [offset(8)], + middleware: [offset(16)], whileElementsMounted: autoUpdate, }); diff --git a/packages/ui/src/RichEditor/RichEditor.tsx b/packages/ui/src/RichEditor/RichEditor.tsx index 4805477f9..e6b1d2ddf 100644 --- a/packages/ui/src/RichEditor/RichEditor.tsx +++ b/packages/ui/src/RichEditor/RichEditor.tsx @@ -31,6 +31,7 @@ import { PlaceholderExtension } from "./PlaceholderExtension"; import { SlashCommandExtension } from "./SlashCommandExtension"; import { TableCellMenu } from "./TableCellMenu"; import { TableColumnMenu } from "./TableColumnMenu"; +import { TableRowMenu } from "./TableRowMenu"; const extensions = [ Document, @@ -110,6 +111,7 @@ export function RichEditor(props: RichEditorProps) { + ); diff --git a/packages/ui/src/RichEditor/TableCellMenu.tsx b/packages/ui/src/RichEditor/TableCellMenu.tsx index 14aaa4c47..6c4a19884 100644 --- a/packages/ui/src/RichEditor/TableCellMenu.tsx +++ b/packages/ui/src/RichEditor/TableCellMenu.tsx @@ -26,7 +26,7 @@ const DRAG_THRESHOLD = 4; const tableCellMenuVariants = tv({ slots: { trigger: [ - "z-30 flex size-5 items-center justify-center", + "z-10 flex size-5 items-center justify-center", "rounded text-border-info cursor-pointer", ], menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"], diff --git a/packages/ui/src/RichEditor/TableColumnMenu.tsx b/packages/ui/src/RichEditor/TableColumnMenu.tsx index 50e570d2d..4b1dc58c8 100644 --- a/packages/ui/src/RichEditor/TableColumnMenu.tsx +++ b/packages/ui/src/RichEditor/TableColumnMenu.tsx @@ -34,7 +34,7 @@ const DRAG_THRESHOLD = 4; const tableColumnMenuVariants = tv({ slots: { trigger: [ - "z-30 flex items-center justify-center", + "z-10 flex items-center justify-center", "rounded text-txt-tertiary bg-subtle hover:bg-border-solid cursor-grab", "py-0.5 h-3", ], diff --git a/packages/ui/src/RichEditor/TableRowMenu.tsx b/packages/ui/src/RichEditor/TableRowMenu.tsx new file mode 100644 index 000000000..7659c33b5 --- /dev/null +++ b/packages/ui/src/RichEditor/TableRowMenu.tsx @@ -0,0 +1,649 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { + autoUpdate, + flip, + offset, + shift, + size, + useDismiss, + useFloating, + useFloatingRootContext, + useInteractions, +} from "@floating-ui/react"; +import { + BroomIcon, + CopyIcon, + DotsThreeVerticalIcon, + PlusIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import type { Node as PMNode } from "@tiptap/pm/model"; +import { TextSelection } from "@tiptap/pm/state"; +import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables"; +import { type useEditor } from "@tiptap/react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { tv } from "tailwind-variants"; + +import { MenuButton } from "./MenuButton"; + +const DRAG_THRESHOLD = 4; + +const tableRowMenuVariants = tv({ + slots: { + trigger: [ + "z-10 flex flex-col items-center justify-center", + "rounded text-txt-tertiary bg-subtle hover:bg-border-solid cursor-grab", + "px-0.5 w-3", + ], + menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"], + }, +}); + +const { trigger, menu } = tableRowMenuVariants(); + +type HoveredRow = { + rowIndex: number; + tableStart: number; +}; + +type TableRowMenuProps = { + editor: ReturnType; +}; + +function cellDomElement( + editor: NonNullable>, + cellPos: number, +): HTMLElement | null { + const dom = editor.view.domAtPos(cellPos + 1); + let el: Node | null = dom.node; + if (el.nodeType === Node.TEXT_NODE) el = el.parentElement; + while (el && !(el instanceof HTMLTableCellElement)) { + el = (el as HTMLElement).parentElement; + } + return el as HTMLElement | null; +} + +function getRowRect( + editor: NonNullable>, + tableStart: number, + rowIndex: 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 (rowIndex < 0 || rowIndex >= map.height) return null; + + const cellPos = map.positionAt(rowIndex, 0, table) + tableStart; + const el = cellDomElement(editor, cellPos); + if (!el) return null; + + const leftRect = el.getBoundingClientRect(); + let right = leftRect.right; + + if (map.width > 1) { + const lastCellPos + = map.positionAt(rowIndex, map.width - 1, table) + tableStart; + const lastEl = cellDomElement(editor, lastCellPos); + if (lastEl) { + right = lastEl.getBoundingClientRect().right; + } + } + + return new DOMRect( + leftRect.left, + leftRect.top, + right - leftRect.left, + leftRect.height, + ); + } catch { + return null; + } +} + +function moveRow( + editor: NonNullable>, + tableStart: number, + fromRow: number, + toRow: number, +) { + if (fromRow === toRow) return; + + const tableNodePos = tableStart - 1; + const table = editor.state.doc.nodeAt(tableNodePos); + if (!table) return; + + const rows: PMNode[] = []; + table.forEach(row => rows.push(row)); + const [moved] = rows.splice(fromRow, 1); + rows.splice(toRow, 0, moved); + + const newTable = table.type.create(table.attrs, rows); + const { tr } = editor.state; + tr.replaceWith(tableNodePos, tableNodePos + table.nodeSize, newTable); + editor.view.dispatch(tr); +} + +export function TableRowMenu({ editor }: TableRowMenuProps) { + const [menuOpen, setMenuOpen] = useState(false); + const [hoveredRow, setHoveredRow] = useState(null); + const [triggerEl, setTriggerEl] = useState(null); + const [dropdownEl, setDropdownEl] = useState(null); + const [dragIndicator, setDragIndicator] = useState<{ + left: number; + top: number; + width: number; + } | null>(null); + + const draggingRef = useRef(false); + const dragStartPos = useRef({ x: 0, y: 0 }); + const rafId = useRef(null); + const hoveredRowRef = useRef(null); + + useEffect(() => { + hoveredRowRef.current = hoveredRow; + }, [hoveredRow]); + + useEffect(() => { + if (!editor || 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-row-handle]") + || target.closest("[data-row-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 ri = cellRect.top; + + setHoveredRow((prev) => { + if (prev && prev.rowIndex === ri && prev.tableStart === ts) { + return prev; + } + return { rowIndex: ri, tableStart: ts }; + }); + return; + } + } catch { + // fall through to clear + } + } + + const current = hoveredRowRef.current; + if (current) { + const rect = getRowRect(editor, current.tableStart, current.rowIndex); + if (rect) { + const zoneLeft = rect.left - 40; + if ( + e.clientY >= rect.top + && e.clientY <= rect.top + rect.height + && e.clientX >= zoneLeft + && e.clientX <= rect.left + ) { + return; + } + } + } + + if (hoveredRowRef.current) { + setHoveredRow(null); + } + }); + }; + + document.addEventListener("mousemove", onMouseMove); + + return () => { + document.removeEventListener("mousemove", onMouseMove); + if (rafId.current) { + cancelAnimationFrame(rafId.current); + rafId.current = null; + } + }; + }, [editor, menuOpen]); + + const { + refs: handleRefs, + floatingStyles: handleStyles, + isPositioned, + } = useFloating({ + strategy: "fixed", + placement: "left", + middleware: [ + offset(6), + size({ + apply({ rects, elements }) { + Object.assign(elements.floating.style, { + height: `${rects.reference.height}px`, + }); + }, + }), + ], + whileElementsMounted: (ref, floating, update) => + autoUpdate(ref, floating, update, { animationFrame: true }), + }); + + const menuRootContext = useFloatingRootContext({ + open: menuOpen, + onOpenChange: setMenuOpen, + elements: { reference: triggerEl, floating: dropdownEl }, + }); + + const { refs: menuRefs, floatingStyles: menuStyles } = useFloating({ + rootContext: menuRootContext, + strategy: "fixed", + placement: "bottom-start", + middleware: [offset(4), flip(), shift()], + whileElementsMounted: autoUpdate, + }); + + const dismiss = useDismiss(menuRootContext); + const { getFloatingProps } = useInteractions([dismiss]); + + useLayoutEffect(() => { + if (!editor || !hoveredRow) { + handleRefs.setReference(null); + return; + } + + const { rowIndex, tableStart } = hoveredRow; + const ed = editor; + + handleRefs.setReference({ + getBoundingClientRect() { + const r = getRowRect(ed, tableStart, rowIndex); + if (!r) return new DOMRect(0, 0, 0, 0); + return new DOMRect(r.left, r.top, 0, r.height); + }, + }); + }, [hoveredRow, editor, handleRefs]); + + if (!editor || (!hoveredRow && !menuOpen)) return null; + + const computeTargetGap = (clientY: 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 row = 0; row < map.height; row++) { + const r = getRowRect(editor, tableStart, row); + if (!r) continue; + const midY = r.top + r.height / 2; + if (clientY > midY) { + targetGap = row + 1; + } else { + targetGap = row; + break; + } + } + + return targetGap; + }; + + const computeGapY = ( + 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 = getRowRect(editor, tableStart, 0); + return r ? r.top : null; + } + + if (gap >= map.height) { + const r = getRowRect(editor, tableStart, map.height - 1); + return r ? r.top + r.height : null; + } + + const rAbove = getRowRect(editor, tableStart, gap - 1); + const rBelow = getRowRect(editor, tableStart, gap); + if (rAbove && rBelow) { + return (rAbove.top + rAbove.height + rBelow.top) / 2; + } + return null; + }; + + const onHandleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (menuOpen || !hoveredRow) return; + + draggingRef.current = false; + dragStartPos.current = { x: e.clientX, y: e.clientY }; + + const { rowIndex: fromRow, tableStart } = hoveredRow; + 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(fromRow, 0, table) + tableStart; + const headPos + = map.positionAt(fromRow, map.width - 1, 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.clientY, tableStart); + + if (targetGap === fromRow || targetGap === fromRow + 1) { + setDragIndicator(null); + return; + } + + const gapY = computeGapY(tableStart, targetGap); + if (gapY !== null) { + const rowRect = getRowRect(editor, tableStart, 0); + if (rowRect) { + setDragIndicator({ + left: rowRect.left, + top: gapY, + width: rowRect.width, + }); + } + } + } 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.clientY, tableStart); + + if (targetGap !== fromRow && targetGap !== fromRow + 1) { + const toRow + = fromRow < targetGap ? targetGap - 1 : targetGap; + moveRow(editor, tableStart, fromRow, toRow); + } + } catch { + // table may have changed + } + } else { + setMenuOpen(prev => !prev); + } + + draggingRef.current = false; + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }; + + const currentRow = hoveredRow; + + const handleDeleteRow = () => { + if (!currentRow) return; + const { rowIndex, tableStart } = currentRow; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const cellPos = map.positionAt(rowIndex, 0, table) + tableStart; + + editor + .chain() + .focus() + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); + return true; + }) + .deleteRow() + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + setHoveredRow(null); + }; + + const handleDuplicateRow = () => { + if (!currentRow) return; + const { rowIndex, tableStart } = currentRow; + + try { + const tableNodePos = tableStart - 1; + const table = editor.state.doc.nodeAt(tableNodePos); + if (!table) return; + + const rows: PMNode[] = []; + table.forEach((row, _offset, i) => { + rows.push(row); + if (i === rowIndex) { + rows.push(row.copy(row.content)); + } + }); + + const newTable = table.type.create(table.attrs, rows); + const { tr } = editor.state; + tr.replaceWith(tableNodePos, tableNodePos + table.nodeSize, newTable); + editor.view.dispatch(tr); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + const handleInsertAbove = () => { + if (!currentRow) return; + const { rowIndex, tableStart } = currentRow; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const cellPos = map.positionAt(rowIndex, 0, table) + tableStart; + + editor + .chain() + .focus() + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); + return true; + }) + .addRowBefore() + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + const handleInsertBelow = () => { + if (!currentRow) return; + const { rowIndex, tableStart } = currentRow; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const cellPos = map.positionAt(rowIndex, 0, table) + tableStart; + + editor + .chain() + .focus() + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); + return true; + }) + .addRowAfter() + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + const handleClearContents = () => { + if (!currentRow) return; + const { rowIndex, tableStart } = currentRow; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const { tr } = editor.state; + const { schema } = editor.state; + + for (let col = 0; col < map.width; col++) { + const cellPos = map.map[rowIndex * map.width + col] + tableStart; + const cellNode = editor.state.doc.nodeAt(cellPos); + if (!cellNode) continue; + + const start = cellPos + 1; + const end = cellPos + cellNode.nodeSize - 1; + + tr.replaceWith( + tr.mapping.map(start), + tr.mapping.map(end), + schema.nodes.paragraph.create(), + ); + } + + editor.view.dispatch(tr); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + return ( + <> + + {menuOpen && ( +
{ + setDropdownEl(node); + menuRefs.setFloating(node); + }} + data-row-menu + style={menuStyles} + {...getFloatingProps()} + onMouseDown={e => e.preventDefault()} + className={menu()} + > + + + Insert row above + + + + Insert row below + + + + Duplicate row + + + + Clear contents + + + + Delete row + +
+ )} + {dragIndicator && ( +
+ )} + + ); +} diff --git a/packages/ui/src/rich-editor.css b/packages/ui/src/rich-editor.css index e0a8f906e..5a2536fe0 100644 --- a/packages/ui/src/rich-editor.css +++ b/packages/ui/src/rich-editor.css @@ -113,6 +113,6 @@ } .tableWrapper { - @apply overflow-x-auto; + @apply overflow-x-auto my-4; } }