import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { FloatingPortal, autoUpdate, flip, offset, shift, useDismiss, useFloating, useInteractions, type VirtualElement, } from "@floating-ui/react"; import { useTiptap, useTiptapState } from "@tiptap/react"; import { buildColumnMenuSections, buildRowMenuSections, buildTableMenuSections, type TableMenuAction, type TableMenuSection, } from "./table-menu-actions"; import { resolveOpenMenuState, tableHandleSelector, tableHandlesKey, type OpenMenuState, } from "../extensions/table/state"; import { hasExplicitColumnWidths } from "../extensions/table/widths"; import { preserveEditorSelectionOnMouseDown } from "./menu/popover"; interface MenuDerivedState { openMenu: OpenMenuState | null; rowCount: number; colCount: number; canResetWidths: boolean; } export function TableMenu() { const { editor } = useTiptap(); const state = useTiptapState((ctx) => { const e = ctx.editor; const pluginState = tableHandlesKey.getState(e.state); const openMenu = pluginState?.openMenu ?? null; let rowCount = 0; let colCount = 0; let canResetWidths = false; const resolved = openMenu ? resolveOpenMenuState(e.state.doc, openMenu) : null; if (resolved) { rowCount = resolved.rowCount; colCount = resolved.colCount; canResetWidths = hasExplicitColumnWidths(resolved.table); } return { openMenu, rowCount, colCount, canResetWidths, }; }); const { openMenu } = state; const menuRef = useRef(null); const [triggerState, setTriggerState] = useState<{ selector: string | null; hasTrigger: boolean }>({ selector: null, hasTrigger: false, }); const selector = useMemo(() => tableHandleSelector(openMenu), [openMenu]); const hasTrigger = selector !== null && triggerState.selector === selector && triggerState.hasTrigger; const resolveTriggerEl = useCallback(() => { if (!selector) return null; return document.querySelector(selector); }, [selector]); const close = useCallback( (focusTrigger = true) => { editor.view.dispatch(editor.view.state.tr.setMeta(tableHandlesKey, { openMenu: "close" })); if (focusTrigger) { resolveTriggerEl()?.focus(); } }, [editor, resolveTriggerEl], ); useEffect(() => { if (!selector) return; let raf = 0; const find = () => { if (resolveTriggerEl()) { setTriggerState({ selector, hasTrigger: true }); } else { raf = requestAnimationFrame(find); } }; raf = requestAnimationFrame(find); return () => { if (raf) cancelAnimationFrame(raf); }; }, [resolveTriggerEl, selector]); let sections: TableMenuSection[] = []; if (openMenu?.kind === "row") { sections = buildRowMenuSections({ editor, openMenu, onDone: close }); } else if (openMenu?.kind === "col") { sections = buildColumnMenuSections({ editor, openMenu, onDone: close }); } else if (openMenu) { sections = buildTableMenuSections({ editor, openMenu, canResetWidths: state.canResetWidths, onDone: close, }); } const virtualReference = useMemo(() => { if (!selector) return null; return { getBoundingClientRect: () => resolveTriggerEl()?.getBoundingClientRect() ?? new DOMRect(), get contextElement() { return resolveTriggerEl() ?? undefined; }, }; }, [resolveTriggerEl, selector]); const { context, refs, floatingStyles } = useFloating({ open: !!openMenu && hasTrigger, onOpenChange(nextOpen) { if (!nextOpen) close(); }, placement: openMenu?.kind === "row" ? "right-start" : openMenu?.kind === "col" ? "bottom-start" : "bottom-end", strategy: "fixed", middleware: [offset(6), flip({ padding: 8 }), shift({ padding: 8 })], whileElementsMounted: autoUpdate, }); useLayoutEffect(() => { // Keep the table menu's virtual reference stable and local to this component. // Table handles are real DOM affordances, so useFloating works well here, but // feeding it a freshly created virtual element every render causes internal // React state churn and can recurse into a maximum update depth error. refs.setReference(virtualReference); }, [refs, virtualReference]); const dismiss = useDismiss(context, { enabled: !!openMenu && hasTrigger, escapeKey: true, outsidePress: (event) => { const target = event.target as Node | null; if (!target) return false; if (menuRef.current?.contains(target)) return false; if (resolveTriggerEl()?.contains(target)) return false; return true; }, }); const { getFloatingProps } = useInteractions([dismiss]); useEffect(() => { if (!openMenu) return; const raf = requestAnimationFrame(() => { const first = menuRef.current?.querySelector("button[role='menuitem']:not(:disabled)"); first?.focus(); }); return () => cancelAnimationFrame(raf); }, [openMenu]); if (!editor || !openMenu || !hasTrigger) return null; return (
{ refs.setFloating(node); menuRef.current = node; }} role="menu" aria-orientation="vertical" className="tiptap-menu-surface tiptap-table-menu" style={{ ...floatingStyles, zIndex: 60, animation: "var(--animate-menu)" }} {...getFloatingProps({ onMouseDownCapture: (e) => preserveEditorSelectionOnMouseDown(e), })} > {sections.map((section, index) => ( 0} /> ))}
); } interface MenuItemProps { icon: React.ReactNode; label: string; onSelect: () => void; disabled?: boolean; danger?: boolean; } function MenuItem({ icon, label, onSelect, disabled, danger }: MenuItemProps) { return ( ); } function MenuSeparator() { return
; } function SectionRenderer({ section, showSeparator }: { section: TableMenuSection; showSeparator: boolean }) { return ( <> {showSeparator && } {section.map((action) => ( ))} ); } function ActionMenuItem({ action }: { action: TableMenuAction }) { return ( ); }