diff --git a/packages/ui/src/RichEditor/FocusedCellExtension.ts b/packages/ui/src/RichEditor/FocusedCellExtension.ts new file mode 100644 index 000000000..0d5a3076d --- /dev/null +++ b/packages/ui/src/RichEditor/FocusedCellExtension.ts @@ -0,0 +1,40 @@ +// 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 { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { cellAround } from "@tiptap/pm/tables"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +const focusedCellKey = new PluginKey("focusedCell"); + +export const FocusedCellExtension = Extension.create({ + name: "focusedCell", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: focusedCellKey, + + props: { + decorations(state) { + const { selection, doc } = state; + const $pos = doc.resolve(selection.from); + const cell = cellAround($pos); + if (!cell) return DecorationSet.empty; + + const node = doc.nodeAt(cell.pos); + if (!node) return DecorationSet.empty; + + return DecorationSet.create(doc, [ + Decoration.node(cell.pos, cell.pos + node.nodeSize, { + class: "focusedCell", + }), + ]); + }, + }, + }), + ]; + }, +}); diff --git a/packages/ui/src/RichEditor/RichEditor.tsx b/packages/ui/src/RichEditor/RichEditor.tsx index d39ba6502..d942ab2a0 100644 --- a/packages/ui/src/RichEditor/RichEditor.tsx +++ b/packages/ui/src/RichEditor/RichEditor.tsx @@ -24,10 +24,12 @@ import { tv } from "tailwind-variants"; import { BlockMenu } from "./BlockMenu"; import { BubbleMenu } from "./BubbleMenu"; +import { FocusedCellExtension } from "./FocusedCellExtension"; import { LinkExtension } from "./LinkExtension"; import { OptionsMenu } from "./OptionsMenu"; import { PlaceholderExtension } from "./PlaceholderExtension"; import { SlashCommandExtension } from "./SlashCommandExtension"; +import { TableCellMenu } from "./TableCellMenu"; const extensions = [ Document, @@ -54,7 +56,10 @@ const extensions = [ Dropcursor, Gapcursor, UndoRedo, - TableKit, + TableKit.configure({ + table: { resizable: true }, + }), + FocusedCellExtension, ]; const richEditorVariants = tv({ @@ -102,6 +107,7 @@ export function RichEditor(props: RichEditorProps) { + ); diff --git a/packages/ui/src/RichEditor/TableCellMenu.tsx b/packages/ui/src/RichEditor/TableCellMenu.tsx new file mode 100644 index 000000000..14aaa4c47 --- /dev/null +++ b/packages/ui/src/RichEditor/TableCellMenu.tsx @@ -0,0 +1,357 @@ +// 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, + useDismiss, + useFloating, + useFloatingRootContext, + useInteractions, +} from "@floating-ui/react"; +import { BroomIcon, CircleIcon, DotsThreeCircleVerticalIcon, IntersectIcon } from "@phosphor-icons/react"; +import { TextSelection } from "@tiptap/pm/state"; +import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables"; +import { type useEditor, useEditorState } from "@tiptap/react"; +import { useLayoutEffect, useRef, useState } from "react"; +import { tv } from "tailwind-variants"; + +import { MenuButton } from "./MenuButton"; + +const DRAG_THRESHOLD = 4; + +const tableCellMenuVariants = tv({ + slots: { + trigger: [ + "z-30 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"], + }, +}); + +const { trigger, menu } = tableCellMenuVariants(); + +type TableCellMenuProps = { + 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 getActiveCellEl(editor: NonNullable>): HTMLElement | null { + const { selection } = editor.state; + + if (selection instanceof CellSelection) { + return cellDomElement(editor, selection.$headCell.pos); + } + + const $pos = editor.state.doc.resolve(selection.from); + const cell = cellAround($pos); + if (!cell) return null; + return cellDomElement(editor, cell.pos); +} + +export function TableCellMenu({ editor }: TableCellMenuProps) { + const [menuOpen, setMenuOpen] = useState(false); + const [handleHovered, setHandleHovered] = useState(false); + const [triggerEl, setTriggerEl] = useState(null); + const [dropdownEl, setDropdownEl] = useState(null); + const draggingRef = useRef(false); + const dragStartPos = useRef({ x: 0, y: 0 }); + const anchorCellPosRef = useRef(null); + const selectionBoundsRef = useRef<{ + bottomRow: number; + tableStart: number; + } | null>(null); + + const activeCellEl = useEditorState({ + editor, + selector: ({ editor: e }) => { + if (e.isDestroyed || !e.isEditable) return null; + return getActiveCellEl(e); + }, + }); + + const { + refs: handleRefs, + floatingStyles: handleStyles, + isPositioned, + } = useFloating({ + strategy: "fixed", + placement: "right", + middleware: [offset(-10)], + 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 || !activeCellEl) { + handleRefs.setReference(null); + return; + } + + const ed = editor; + const fallback = activeCellEl; + + handleRefs.setReference({ + getBoundingClientRect() { + const { selection } = ed.state; + if (selection instanceof CellSelection) { + let top = Infinity; + let left = Infinity; + let bottom = -Infinity; + let right = -Infinity; + selection.forEachCell((_node, pos) => { + const el = cellDomElement(ed, pos); + if (!el) return; + const rect = el.getBoundingClientRect(); + top = Math.min(top, rect.top); + left = Math.min(left, rect.left); + bottom = Math.max(bottom, rect.bottom); + right = Math.max(right, rect.right); + }); + if (top !== Infinity) { + return new DOMRect(left, top, right - left, bottom - top); + } + } + return fallback.getBoundingClientRect(); + }, + }); + }, [activeCellEl, editor, handleRefs]); + + if (!editor || !activeCellEl) return null; + + const getAnchorCellPos = (): number | null => { + try { + const { selection, doc } = editor.state; + const $pos = doc.resolve(selection.from); + const cell = cellAround($pos); + return cell ? cell.pos : null; + } catch { + return null; + } + }; + + const onHandleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (menuOpen) return; + + const { selection } = editor.state; + + if (selection instanceof CellSelection) { + try { + const table = selection.$anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = selection.$anchorCell.start(-1); + const anchorRect = map.findCell(selection.$anchorCell.pos - tableStart); + const headRect = map.findCell(selection.$headCell.pos - tableStart); + + const topRow = Math.min(anchorRect.top, headRect.top); + const bottomRow = Math.max(anchorRect.bottom, headRect.bottom) - 1; + const leftCol = Math.min(anchorRect.left, headRect.left); + + anchorCellPosRef.current = map.positionAt(topRow, leftCol, table) + tableStart; + + selectionBoundsRef.current = topRow !== bottomRow + ? { bottomRow, tableStart } + : null; + } catch { + anchorCellPosRef.current = getAnchorCellPos(); + selectionBoundsRef.current = null; + } + } else { + anchorCellPosRef.current = getAnchorCellPos(); + selectionBoundsRef.current = null; + } + + if (anchorCellPosRef.current == null) return; + + draggingRef.current = false; + dragStartPos.current = { x: e.clientX, y: e.clientY }; + + 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 && Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + + draggingRef.current = true; + + const coords = view.posAtCoords({ left: ev.clientX, top: ev.clientY }); + if (!coords) return; + + try { + const $head = view.state.doc.resolve(coords.pos); + const headCell = cellAround($head); + if (!headCell) return; + + let headPos = headCell.pos; + + if (selectionBoundsRef.current) { + const { bottomRow, tableStart } = selectionBoundsRef.current; + if (headCell.start(-1) === tableStart) { + const table = headCell.node(-1); + const map = TableMap.get(table); + const headRect = map.findCell(headCell.pos - tableStart); + headPos = map.positionAt(bottomRow, headRect.left, table) + tableStart; + } + } + + const sel = CellSelection.create( + view.state.doc, + anchorCellPosRef.current!, + headPos, + ); + const { tr } = view.state; + tr.setSelection(sel); + view.dispatch(tr); + } catch { + // position may be outside table + } + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + + if (!draggingRef.current) { + setMenuOpen(prev => !prev); + } + + draggingRef.current = false; + anchorCellPosRef.current = null; + selectionBoundsRef.current = null; + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }; + + const handleMergeCells = () => { + editor.chain().focus().mergeCells().run(); + setMenuOpen(false); + }; + + const handleClearContents = () => { + const { state, dispatch } = editor.view; + const { selection, schema } = state; + const { tr } = state; + let cursorTarget: number | null = null; + + if (selection instanceof CellSelection) { + selection.forEachCell((node, pos) => { + const start = pos + 1; + const end = pos + node.nodeSize - 1; + if (cursorTarget === null) { + cursorTarget = tr.mapping.map(start) + 1; + } + tr.replaceWith( + tr.mapping.map(start), + tr.mapping.map(end), + schema.nodes.paragraph.create(), + ); + }); + } else { + const $pos = state.doc.resolve(selection.from); + const cell = cellAround($pos); + if (cell) { + const cellNode = state.doc.nodeAt(cell.pos); + if (cellNode) { + const start = cell.pos + 1; + const end = cell.pos + cellNode.nodeSize - 1; + cursorTarget = start + 1; + tr.replaceWith(start, end, schema.nodes.paragraph.create()); + } + } + } + + if (cursorTarget !== null) { + tr.setSelection(TextSelection.create(tr.doc, cursorTarget)); + } + + dispatch(tr); + setMenuOpen(false); + }; + + return ( + <> + + {menuOpen && ( +
{ + setDropdownEl(node); + menuRefs.setFloating(node); + }} + style={menuStyles} + {...getFloatingProps()} + onMouseDown={e => e.preventDefault()} + className={menu()} + > + + + Merge cells + + + + Clear contents + +
+ )} + + ); +} diff --git a/packages/ui/src/rich-editor.css b/packages/ui/src/rich-editor.css index 5da5f6d8b..e0a8f906e 100644 --- a/packages/ui/src/rich-editor.css +++ b/packages/ui/src/rich-editor.css @@ -76,12 +76,43 @@ @apply border border-border-mid border-collapse; } th { - @apply px-2 py-1.5 border border-border-mid border-collapse bg-subtle text-left font-semibold; + @apply px-2 py-1.5 border border-border-mid border-collapse bg-subtle text-left font-semibold relative; p { @apply m-0; } } td { - @apply px-2 py-1.5 border border-border-mid border-collapse bg-level-0 text-left; + @apply px-2 py-1.5 border border-border-mid border-collapse bg-level-0 text-left relative; p { @apply m-0; } } } + + th.selectedCell, td.selectedCell, + th.focusedCell, td.focusedCell { + background-color: color-mix(in srgb, var(--color-info) 50%, transparent) !important; + border: 2px solid var(--color-border-info) !important; + } + + .selectedCell:has(+ .selectedCell) { + border-right: 1px solid var(--color-border-mid) !important; + } + .selectedCell + .selectedCell { + border-left: 1px solid var(--color-border-mid) !important; + } + tr:has(+ tr .selectedCell) > .selectedCell { + border-bottom: 1px solid var(--color-border-mid) !important; + } + tr:has(.selectedCell) + tr > .selectedCell { + border-top: 1px solid var(--color-border-mid) !important; + } + + .column-resize-handle { + @apply absolute top-0 -right-px bottom-0 w-0.5 bg-border-info pointer-events-none z-20; + } + + &.resize-cursor { + cursor: col-resize; + } + + .tableWrapper { + @apply overflow-x-auto; + } }