File
Blob: src/client/components/editor/controllers/table-menu.tsx
| 1 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { |
| 3 | FloatingPortal, |
| 4 | autoUpdate, |
| 5 | flip, |
| 6 | offset, |
| 7 | shift, |
| 8 | useDismiss, |
| 9 | useFloating, |
| 10 | useInteractions, |
| 11 | type VirtualElement, |
| 12 | } from "@floating-ui/react"; |
| 13 | import { useTiptap, useTiptapState } from "@tiptap/react"; |
| 14 | import { |
| 15 | buildColumnMenuSections, |
| 16 | buildRowMenuSections, |
| 17 | buildTableMenuSections, |
| 18 | type TableMenuAction, |
| 19 | type TableMenuSection, |
| 20 | } from "./table-menu-actions"; |
| 21 | import { |
| 22 | resolveOpenMenuState, |
| 23 | tableHandleSelector, |
| 24 | tableHandlesKey, |
| 25 | type OpenMenuState, |
| 26 | } from "../extensions/table/state"; |
| 27 | import { hasExplicitColumnWidths } from "../extensions/table/widths"; |
| 28 | import { preserveEditorSelectionOnMouseDown } from "./menu/popover"; |
| 29 | |
| 30 | interface MenuDerivedState { |
| 31 | openMenu: OpenMenuState | null; |
| 32 | rowCount: number; |
| 33 | colCount: number; |
| 34 | canResetWidths: boolean; |
| 35 | } |
| 36 | |
| 37 | export function TableMenu() { |
| 38 | const { editor } = useTiptap(); |
| 39 | const state = useTiptapState<MenuDerivedState>((ctx) => { |
| 40 | const e = ctx.editor; |
| 41 | const pluginState = tableHandlesKey.getState(e.state); |
| 42 | const openMenu = pluginState?.openMenu ?? null; |
| 43 | let rowCount = 0; |
| 44 | let colCount = 0; |
| 45 | let canResetWidths = false; |
| 46 | const resolved = openMenu ? resolveOpenMenuState(e.state.doc, openMenu) : null; |
| 47 | if (resolved) { |
| 48 | rowCount = resolved.rowCount; |
| 49 | colCount = resolved.colCount; |
| 50 | canResetWidths = hasExplicitColumnWidths(resolved.table); |
| 51 | } |
| 52 | return { |
| 53 | openMenu, |
| 54 | rowCount, |
| 55 | colCount, |
| 56 | canResetWidths, |
| 57 | }; |
| 58 | }); |
| 59 | |
| 60 | const { openMenu } = state; |
| 61 | const menuRef = useRef<HTMLDivElement | null>(null); |
| 62 | const [triggerState, setTriggerState] = useState<{ selector: string | null; hasTrigger: boolean }>({ |
| 63 | selector: null, |
| 64 | hasTrigger: false, |
| 65 | }); |
| 66 | const selector = useMemo(() => tableHandleSelector(openMenu), [openMenu]); |
| 67 | const hasTrigger = selector !== null && triggerState.selector === selector && triggerState.hasTrigger; |
| 68 | |
| 69 | const resolveTriggerEl = useCallback(() => { |
| 70 | if (!selector) return null; |
| 71 | return document.querySelector<HTMLElement>(selector); |
| 72 | }, [selector]); |
| 73 | |
| 74 | const close = useCallback( |
| 75 | (focusTrigger = true) => { |
| 76 | editor.view.dispatch(editor.view.state.tr.setMeta(tableHandlesKey, { openMenu: "close" })); |
| 77 | if (focusTrigger) { |
| 78 | resolveTriggerEl()?.focus(); |
| 79 | } |
| 80 | }, |
| 81 | [editor, resolveTriggerEl], |
| 82 | ); |
| 83 | |
| 84 | useEffect(() => { |
| 85 | if (!selector) return; |
| 86 | let raf = 0; |
| 87 | const find = () => { |
| 88 | if (resolveTriggerEl()) { |
| 89 | setTriggerState({ selector, hasTrigger: true }); |
| 90 | } else { |
| 91 | raf = requestAnimationFrame(find); |
| 92 | } |
| 93 | }; |
| 94 | raf = requestAnimationFrame(find); |
| 95 | return () => { |
| 96 | if (raf) cancelAnimationFrame(raf); |
| 97 | }; |
| 98 | }, [resolveTriggerEl, selector]); |
| 99 | |
| 100 | let sections: TableMenuSection[] = []; |
| 101 | if (openMenu?.kind === "row") { |
| 102 | sections = buildRowMenuSections({ editor, openMenu, onDone: close }); |
| 103 | } else if (openMenu?.kind === "col") { |
| 104 | sections = buildColumnMenuSections({ editor, openMenu, onDone: close }); |
| 105 | } else if (openMenu) { |
| 106 | sections = buildTableMenuSections({ |
| 107 | editor, |
| 108 | openMenu, |
| 109 | canResetWidths: state.canResetWidths, |
| 110 | onDone: close, |
| 111 | }); |
| 112 | } |
| 113 | |
| 114 | const virtualReference = useMemo<VirtualElement | null>(() => { |
| 115 | if (!selector) return null; |
| 116 | return { |
| 117 | getBoundingClientRect: () => resolveTriggerEl()?.getBoundingClientRect() ?? new DOMRect(), |
| 118 | get contextElement() { |
| 119 | return resolveTriggerEl() ?? undefined; |
| 120 | }, |
| 121 | }; |
| 122 | }, [resolveTriggerEl, selector]); |
| 123 | |
| 124 | const { context, refs, floatingStyles } = useFloating({ |
| 125 | open: !!openMenu && hasTrigger, |
| 126 | onOpenChange(nextOpen) { |
| 127 | if (!nextOpen) close(); |
| 128 | }, |
| 129 | placement: openMenu?.kind === "row" ? "right-start" : openMenu?.kind === "col" ? "bottom-start" : "bottom-end", |
| 130 | strategy: "fixed", |
| 131 | middleware: [offset(6), flip({ padding: 8 }), shift({ padding: 8 })], |
| 132 | whileElementsMounted: autoUpdate, |
| 133 | }); |
| 134 | |
| 135 | useLayoutEffect(() => { |
| 136 | // Keep the table menu's virtual reference stable and local to this component. |
| 137 | // Table handles are real DOM affordances, so useFloating works well here, but |
| 138 | // feeding it a freshly created virtual element every render causes internal |
| 139 | // React state churn and can recurse into a maximum update depth error. |
| 140 | refs.setReference(virtualReference); |
| 141 | }, [refs, virtualReference]); |
| 142 | |
| 143 | const dismiss = useDismiss(context, { |
| 144 | enabled: !!openMenu && hasTrigger, |
| 145 | escapeKey: true, |
| 146 | outsidePress: (event) => { |
| 147 | const target = event.target as Node | null; |
| 148 | if (!target) return false; |
| 149 | if (menuRef.current?.contains(target)) return false; |
| 150 | if (resolveTriggerEl()?.contains(target)) return false; |
| 151 | return true; |
| 152 | }, |
| 153 | }); |
| 154 | const { getFloatingProps } = useInteractions([dismiss]); |
| 155 | |
| 156 | useEffect(() => { |
| 157 | if (!openMenu) return; |
| 158 | const raf = requestAnimationFrame(() => { |
| 159 | const first = menuRef.current?.querySelector<HTMLButtonElement>("button[role='menuitem']:not(:disabled)"); |
| 160 | first?.focus(); |
| 161 | }); |
| 162 | return () => cancelAnimationFrame(raf); |
| 163 | }, [openMenu]); |
| 164 | |
| 165 | if (!editor || !openMenu || !hasTrigger) return null; |
| 166 | |
| 167 | return ( |
| 168 | <FloatingPortal> |
| 169 | <div |
| 170 | key={`${openMenu.kind}:${openMenu.tableKey}:${openMenu.index ?? "corner"}`} |
| 171 | ref={(node) => { |
| 172 | refs.setFloating(node); |
| 173 | menuRef.current = node; |
| 174 | }} |
| 175 | role="menu" |
| 176 | aria-orientation="vertical" |
| 177 | className="tiptap-menu-surface tiptap-table-menu" |
| 178 | style={{ ...floatingStyles, zIndex: 60, animation: "var(--animate-menu)" }} |
| 179 | {...getFloatingProps({ |
| 180 | onMouseDownCapture: (e) => preserveEditorSelectionOnMouseDown(e), |
| 181 | })} |
| 182 | > |
| 183 | {sections.map((section, index) => ( |
| 184 | <SectionRenderer key={index} section={section} showSeparator={index > 0} /> |
| 185 | ))} |
| 186 | </div> |
| 187 | </FloatingPortal> |
| 188 | ); |
| 189 | } |
| 190 | |
| 191 | interface MenuItemProps { |
| 192 | icon: React.ReactNode; |
| 193 | label: string; |
| 194 | onSelect: () => void; |
| 195 | disabled?: boolean; |
| 196 | danger?: boolean; |
| 197 | } |
| 198 | |
| 199 | function MenuItem({ icon, label, onSelect, disabled, danger }: MenuItemProps) { |
| 200 | return ( |
| 201 | <button |
| 202 | type="button" |
| 203 | role="menuitem" |
| 204 | disabled={disabled} |
| 205 | className={`tiptap-menu-item tiptap-table-menu-item${danger ? " is-danger" : ""}`} |
| 206 | onMouseDown={(e) => { |
| 207 | e.preventDefault(); |
| 208 | }} |
| 209 | onClick={(e) => { |
| 210 | e.preventDefault(); |
| 211 | if (disabled) return; |
| 212 | onSelect(); |
| 213 | }} |
| 214 | > |
| 215 | <span className="tiptap-menu-item-icon tiptap-table-menu-item-icon">{icon}</span> |
| 216 | <span className="tiptap-menu-item-label tiptap-table-menu-item-label">{label}</span> |
| 217 | </button> |
| 218 | ); |
| 219 | } |
| 220 | |
| 221 | function MenuSeparator() { |
| 222 | return <div className="tiptap-menu-separator tiptap-table-menu-sep" role="separator" />; |
| 223 | } |
| 224 | |
| 225 | function SectionRenderer({ section, showSeparator }: { section: TableMenuSection; showSeparator: boolean }) { |
| 226 | return ( |
| 227 | <> |
| 228 | {showSeparator && <MenuSeparator />} |
| 229 | {section.map((action) => ( |
| 230 | <ActionMenuItem key={action.key} action={action} /> |
| 231 | ))} |
| 232 | </> |
| 233 | ); |
| 234 | } |
| 235 | |
| 236 | function ActionMenuItem({ action }: { action: TableMenuAction }) { |
| 237 | return ( |
| 238 | <MenuItem |
| 239 | icon={action.icon} |
| 240 | label={action.label} |
| 241 | onSelect={action.onSelect} |
| 242 | disabled={action.disabled} |
| 243 | danger={action.danger} |
| 244 | /> |
| 245 | ); |
| 246 | } |