|
1 | | -import { useCallback } from 'react' |
| 1 | +import { useCallback, useEffect, useRef } from 'react' |
| 2 | +import { autoUpdate } from '@floating-ui/dom' |
2 | 3 | import { posToDOMRect } from '@tiptap/core' |
3 | | -import { AllSelection, type Selection } from '@tiptap/pm/state' |
| 4 | +import { AllSelection, type PluginKey, type Selection } from '@tiptap/pm/state' |
4 | 5 | import type { Editor } from '@tiptap/react' |
5 | 6 |
|
6 | 7 | /** |
@@ -35,21 +36,42 @@ function selectionVirtualElement(editor: Editor) { |
35 | 36 | * Wires a BubbleMenu's Floating UI concerns — the selection anchor and the portal target — so the |
36 | 37 | * text and table toolbars share one source of truth and can't drift. |
37 | 38 | * |
38 | | - * The toolbar is appended into the editor's scroll container (which must be a positioning context) |
39 | | - * and left at TipTap's default `absolute` strategy, so it becomes an absolutely-positioned child of |
40 | | - * the scrolled content and tracks the selection through native scrolling — no scroll listener, so no |
41 | | - * lag or "chase" as the pane scrolls. TipTap's default `flip` still parks it above or below the |
42 | | - * selection at the pane edges, and the container's overflow clips it once the selection scrolls out. |
| 39 | + * The toolbar stays inside the editor's scroll container. Its controls can resize after a selection |
| 40 | + * changes, and a table can scroll independently of the document, so track both through Floating UI. |
43 | 41 | */ |
44 | 42 | export function useBubbleMenuFloating( |
45 | 43 | editor: Editor, |
46 | | - scrollContainerRef: React.RefObject<HTMLDivElement | null> |
| 44 | + scrollContainerRef: React.RefObject<HTMLDivElement | null>, |
| 45 | + pluginKey: PluginKey |
47 | 46 | ) { |
| 47 | + const menuRef = useRef<HTMLDivElement>(null) |
48 | 48 | const resolveAnchor = useCallback(() => selectionVirtualElement(editor), [editor]) |
49 | 49 | const appendTo = useCallback( |
50 | 50 | () => scrollContainerRef.current ?? document.body, |
51 | 51 | [scrollContainerRef] |
52 | 52 | ) |
53 | 53 |
|
54 | | - return { resolveAnchor, appendTo } |
| 54 | + useEffect(() => { |
| 55 | + const menu = menuRef.current |
| 56 | + if (!menu) return |
| 57 | + let stop: (() => void) | undefined |
| 58 | + const trackSelection = () => { |
| 59 | + stop?.() |
| 60 | + const { node } = editor.view.domAtPos(editor.state.selection.from) |
| 61 | + const reference = node instanceof Element ? node : (node.parentElement ?? editor.view.dom) |
| 62 | + stop = autoUpdate(reference, menu, () => { |
| 63 | + if (!editor.isDestroyed && menu.isConnected && menu.style.visibility !== 'hidden') { |
| 64 | + editor.commands.setMeta(pluginKey, 'updatePosition') |
| 65 | + } |
| 66 | + }) |
| 67 | + } |
| 68 | + trackSelection() |
| 69 | + editor.on('selectionUpdate', trackSelection) |
| 70 | + return () => { |
| 71 | + stop?.() |
| 72 | + editor.off('selectionUpdate', trackSelection) |
| 73 | + } |
| 74 | + }, [editor, pluginKey]) |
| 75 | + |
| 76 | + return { resolveAnchor, appendTo, menuRef } |
55 | 77 | } |
0 commit comments