Skip to content
File

Blob: src/client/components/editor/controllers/menu/popover.tsx

typescript352 lines
1import {
2 useCallback,
3 useEffect,
4 useEffectEvent,
5 useLayoutEffect,
6 useMemo,
7 useRef,
8 useState,
9 type CSSProperties,
10} from "react";
11import {
12 autoUpdate as autoUpdateDom,
13 computePosition,
14 flip as flipDom,
15 offset as floatingOffsetDom,
16 shift as shiftDom,
17 size as sizeDom,
18} from "@floating-ui/dom";
19import {
20 autoUpdate,
21 flip,
22 offset as floatingOffset,
23 shift,
24 size,
25 useDismiss,
26 useFloating,
27 useInteractions,
28 type Middleware,
29 type Placement,
30 type Strategy,
31} from "@floating-ui/react";
32 
33// These hooks are intentionally split by anchor lifetime.
34// - useEditorPopover is only for real DOM references.
35// - useEditorRectPopover is for ephemeral caret/selection rect getters from Tiptap.
36// Re-unifying them reintroduces the exact regressions from the menu refactor:
37// useFloating reference churn loops in React, while late caret rects render menus at (0, 0).
38interface UseEditorPopoverOptions {
39 anchorRef: React.RefObject<HTMLElement | null>;
40 deferOutsidePress?: boolean;
41 maxHeight?: boolean;
42 middleware?: Middleware[];
43 offset?: number;
44 onClose?: () => void;
45 open: boolean;
46 padding?: number;
47 placement?: Placement;
48 strategy?: Strategy;
49}
50 
51interface UseEditorRectPopoverOptions {
52 contextElement?: Element | null | (() => Element | null);
53 deferOutsidePress?: boolean;
54 getAnchorRect: () => DOMRect | null;
55 maxHeight?: boolean;
56 offset?: number;
57 onClose?: () => void;
58 open: boolean;
59 padding?: number;
60 placement?: Placement;
61 strategy?: Strategy;
62}
63 
64function getContextElement(contextElement: UseEditorRectPopoverOptions["contextElement"]): Element | null {
65 if (typeof contextElement === "function") {
66 return contextElement();
67 }
68 return contextElement ?? null;
69}
70 
71function useDismissArmedRef(deferOutsidePress: boolean, open: boolean) {
72 const dismissArmedRef = useRef(!deferOutsidePress);
73 
74 useEffect(() => {
75 if (!deferOutsidePress) {
76 dismissArmedRef.current = true;
77 return;
78 }
79 if (!open) {
80 dismissArmedRef.current = false;
81 return;
82 }
83 
84 dismissArmedRef.current = false;
85 const frameId = window.requestAnimationFrame(() => {
86 dismissArmedRef.current = true;
87 });
88 return () => window.cancelAnimationFrame(frameId);
89 }, [deferOutsidePress, open]);
90 
91 return dismissArmedRef;
92}
93 
94function hiddenFloatingStyles(strategy: Strategy): CSSProperties {
95 return {
96 position: strategy,
97 left: 0,
98 top: 0,
99 visibility: "hidden",
100 };
101}
102 
103function applyFloatingStyles(element: HTMLElement, styles: CSSProperties) {
104 element.style.position = String(styles.position ?? "fixed");
105 element.style.left = typeof styles.left === "number" ? `${styles.left}px` : String(styles.left ?? 0);
106 element.style.top = typeof styles.top === "number" ? `${styles.top}px` : String(styles.top ?? 0);
107 if (styles.visibility) {
108 element.style.visibility = String(styles.visibility);
109 } else {
110 element.style.removeProperty("visibility");
111 }
112}
113 
114export function useEditorPopover({
115 anchorRef,
116 deferOutsidePress = false,
117 maxHeight = false,
118 middleware = [],
119 offset = 10,
120 onClose,
121 open,
122 padding = 10,
123 placement = "bottom-start",
124 strategy = "fixed",
125}: UseEditorPopoverOptions) {
126 const dismissArmedRef = useDismissArmedRef(deferOutsidePress, open);
127 const baseMiddleware = useMemo(() => {
128 const next: Middleware[] = [floatingOffset(offset), shift({ padding }), flip({ padding }), ...middleware];
129 if (maxHeight) {
130 next.push(
131 size({
132 apply({ availableHeight, elements }) {
133 elements.floating.style.maxHeight = `${Math.max(0, availableHeight)}px`;
134 },
135 padding,
136 }),
137 );
138 }
139 return next;
140 }, [maxHeight, middleware, offset, padding]);
141 
142 const { context, floatingStyles, refs } = useFloating({
143 open,
144 onOpenChange(nextOpen) {
145 if (!nextOpen) onClose?.();
146 },
147 placement,
148 strategy,
149 middleware: baseMiddleware,
150 whileElementsMounted: autoUpdate,
151 });
152 
153 useLayoutEffect(() => {
154 refs.setReference(anchorRef.current);
155 }, [anchorRef, refs]);
156 
157 const dismiss = useDismiss(context, {
158 enabled: !!onClose,
159 escapeKey: true,
160 outsidePress: () => dismissArmedRef.current,
161 });
162 
163 const { getFloatingProps } = useInteractions([dismiss]);
164 
165 return {
166 floatingStyles,
167 getFloatingProps,
168 refs,
169 };
170}
171 
172export function useEditorRectPopover({
173 contextElement,
174 deferOutsidePress = false,
175 getAnchorRect,
176 maxHeight = false,
177 offset = 10,
178 onClose,
179 open,
180 padding = 10,
181 placement = "bottom-start",
182 strategy = "fixed",
183}: UseEditorRectPopoverOptions) {
184 const dismissArmedRef = useDismissArmedRef(deferOutsidePress, open);
185 const [floatingElement, setFloatingElement] = useState<HTMLElement | null>(null);
186 const readAnchorRect = useEffectEvent(() => getAnchorRect());
187 const readContextElement = useEffectEvent(() => getContextElement(contextElement));
188 const lastAnchorRectRef = useRef<DOMRect | null>(null);
189 const lastStylesRef = useRef(hiddenFloatingStyles(strategy));
190 
191 const floatingStyles = useMemo<CSSProperties>(() => ({ position: strategy }), [strategy]);
192 const setFloating = useCallback(
193 (node: HTMLElement | null) => {
194 if (!node) {
195 lastAnchorRectRef.current = null;
196 lastStylesRef.current = hiddenFloatingStyles(strategy);
197 } else {
198 applyFloatingStyles(node, hiddenFloatingStyles(strategy));
199 }
200 setFloatingElement(node);
201 },
202 [strategy],
203 );
204 
205 useLayoutEffect(() => {
206 const nextHiddenStyles = hiddenFloatingStyles(strategy);
207 lastStylesRef.current = nextHiddenStyles;
208 lastAnchorRectRef.current = null;
209 if (floatingElement) {
210 applyFloatingStyles(floatingElement, nextHiddenStyles);
211 }
212 if (!open || !floatingElement) {
213 return;
214 }
215 
216 const controller = new AbortController();
217 const reference = {
218 getBoundingClientRect: () => lastAnchorRectRef.current ?? new DOMRect(),
219 get contextElement() {
220 return readContextElement() ?? undefined;
221 },
222 };
223 
224 // Keep caret- and selection-driven popovers on computePosition. Tiptap's
225 // clientRect()/coordsAtPos anchors can be null or late for a frame, and
226 // forcing them through useFloating's reference lifecycle caused two regressions:
227 // a React update loop in TableMenu and suggestion menus rendered at (0, 0).
228 const updatePosition = async () => {
229 const nextRect = readAnchorRect();
230 if (nextRect) {
231 lastAnchorRectRef.current = nextRect;
232 }
233 
234 if (!lastAnchorRectRef.current) {
235 const nextHiddenStyles = hiddenFloatingStyles(strategy);
236 if (
237 lastStylesRef.current.left !== nextHiddenStyles.left ||
238 lastStylesRef.current.top !== nextHiddenStyles.top ||
239 lastStylesRef.current.position !== nextHiddenStyles.position ||
240 lastStylesRef.current.visibility !== nextHiddenStyles.visibility
241 ) {
242 lastStylesRef.current = nextHiddenStyles;
243 applyFloatingStyles(floatingElement, nextHiddenStyles);
244 }
245 return;
246 }
247 
248 if (!maxHeight) {
249 floatingElement.style.removeProperty("max-height");
250 }
251 
252 const next = await computePosition(reference, floatingElement, {
253 placement,
254 strategy,
255 middleware: [
256 floatingOffsetDom(offset),
257 shiftDom({ padding }),
258 flipDom({ padding }),
259 ...(maxHeight
260 ? [
261 sizeDom({
262 apply({ availableHeight, elements }) {
263 elements.floating.style.maxHeight = `${Math.max(0, availableHeight)}px`;
264 },
265 padding,
266 }),
267 ]
268 : []),
269 ],
270 });
271 
272 if (controller.signal.aborted) return;
273 
274 const nextStyles: CSSProperties = {
275 position: strategy,
276 left: next.x,
277 top: next.y,
278 };
279 
280 if (
281 lastStylesRef.current.left === nextStyles.left &&
282 lastStylesRef.current.top === nextStyles.top &&
283 lastStylesRef.current.position === nextStyles.position &&
284 lastStylesRef.current.visibility === nextStyles.visibility
285 ) {
286 return;
287 }
288 
289 lastStylesRef.current = nextStyles;
290 applyFloatingStyles(floatingElement, nextStyles);
291 };
292 
293 const cleanup = autoUpdateDom(reference, floatingElement, updatePosition, {
294 animationFrame: true,
295 });
296 
297 void updatePosition();
298 
299 return () => {
300 controller.abort();
301 cleanup();
302 };
303 }, [floatingElement, maxHeight, offset, open, padding, placement, strategy]);
304 
305 useEffect(() => {
306 if (!open || !onClose) return;
307 const handleClose = onClose;
308 
309 function onPointerDown(event: PointerEvent) {
310 if (!dismissArmedRef.current) return;
311 const target = event.target as Node | null;
312 if (!target) return;
313 if (floatingElement?.contains(target)) return;
314 handleClose();
315 }
316 
317 function onKeyDown(event: KeyboardEvent) {
318 if (event.key !== "Escape") return;
319 event.preventDefault();
320 handleClose();
321 }
322 
323 document.addEventListener("pointerdown", onPointerDown, true);
324 document.addEventListener("keydown", onKeyDown, true);
325 return () => {
326 document.removeEventListener("pointerdown", onPointerDown, true);
327 document.removeEventListener("keydown", onKeyDown, true);
328 };
329 }, [dismissArmedRef, floatingElement, onClose, open]);
330 
331 return {
332 floatingStyles,
333 setFloating,
334 };
335}
336 
337export function preserveEditorSelectionOnMouseDown(
338 event: Pick<React.MouseEvent<HTMLElement>, "preventDefault" | "target">,
339 allowTarget?: (target: EventTarget | null) => boolean,
340) {
341 if (allowTarget?.(event.target)) return;
342 if (
343 event.target instanceof HTMLInputElement ||
344 event.target instanceof HTMLTextAreaElement ||
345 event.target instanceof HTMLSelectElement ||
346 (event.target instanceof HTMLElement && event.target.isContentEditable)
347 ) {
348 return;
349 }
350 event.preventDefault();
351}