Skip to content
File

Blob: src/client/components/editor/controllers/table-menu.tsx

typescript247 lines
1import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2import {
3 FloatingPortal,
4 autoUpdate,
5 flip,
6 offset,
7 shift,
8 useDismiss,
9 useFloating,
10 useInteractions,
11 type VirtualElement,
12} from "@floating-ui/react";
13import { useTiptap, useTiptapState } from "@tiptap/react";
14import {
15 buildColumnMenuSections,
16 buildRowMenuSections,
17 buildTableMenuSections,
18 type TableMenuAction,
19 type TableMenuSection,
20} from "./table-menu-actions";
21import {
22 resolveOpenMenuState,
23 tableHandleSelector,
24 tableHandlesKey,
25 type OpenMenuState,
26} from "../extensions/table/state";
27import { hasExplicitColumnWidths } from "../extensions/table/widths";
28import { preserveEditorSelectionOnMouseDown } from "./menu/popover";
29 
30interface MenuDerivedState {
31 openMenu: OpenMenuState | null;
32 rowCount: number;
33 colCount: number;
34 canResetWidths: boolean;
35}
36 
37export 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 
191interface MenuItemProps {
192 icon: React.ReactNode;
193 label: string;
194 onSelect: () => void;
195 disabled?: boolean;
196 danger?: boolean;
197}
198 
199function 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 
221function MenuSeparator() {
222 return <div className="tiptap-menu-separator tiptap-table-menu-sep" role="separator" />;
223}
224 
225function 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 
236function 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}