Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Blimp,
Bold,
Expand All @@ -16,13 +16,13 @@ import {
TextQuote,
Unlink,
} from '@sim/emcn/icons'
import { posToDOMRect } from '@tiptap/core'
import { PluginKey } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/react'
import { useEditorState } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import { applyLink, LinkUrlInput } from './link-editing'
import { ToolbarButton, ToolbarDivider } from './toolbar-button'
import { useBubbleMenuFloating } from './use-bubble-menu-floating'

/**
* Whether the formatting toolbar may show for the given range: the editor is editable, the range
Expand All @@ -45,15 +45,9 @@ function revealBubbleMenu(editor: Editor, key: PluginKey): void {
editor.commands.setMeta(key, 'updatePosition')
}

/** Pins the toolbar to the viewport so it stays put while the document scrolls instead of tracking the text. */
const FLOATING_OPTIONS = { strategy: 'fixed' } as const

/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */
const APPEND_TO_BODY = () => document.body

interface EditorBubbleMenuProps {
editor: Editor
/** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */
/** The editor's scrollable viewport, so the toolbar repositions with the selection as the pane scrolls. */
scrollContainerRef: React.RefObject<HTMLDivElement | null>
/** Adds the current selection to Chat as a reference. Omit to hide the action. */
onAddToChat?: () => void
Expand Down Expand Up @@ -185,37 +179,15 @@ export function EditorBubbleMenu({
setLinkValue(null)
}

const anchorCacheRef = useRef<{ key: string; rect: DOMRect } | null>(null)
const resolveAnchor = useCallback(() => {
const { view, state } = editor
if (!view.dom.isConnected) return null
const { from, to } = state.selection
const key = `${from}:${to}`
if (anchorCacheRef.current?.key !== key) {
const selection = posToDOMRect(view, from, to)
const viewport = scrollContainerRef.current?.getBoundingClientRect()
const rect =
viewport && selection.height > viewport.height
? new DOMRect(
selection.left,
Math.min(Math.max(selection.top, viewport.top), viewport.bottom),
selection.width,
0
)
: selection
anchorCacheRef.current = { key, rect }
}
const { rect } = anchorCacheRef.current
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
}, [editor, scrollContainerRef])
const { resolveAnchor, options, appendTo } = useBubbleMenuFloating(editor, scrollContainerRef)

return (
<BubbleMenu
editor={editor}
pluginKey={bubbleMenuKey}
getReferencedVirtualElement={resolveAnchor}
options={FLOATING_OPTIONS}
appendTo={APPEND_TO_BODY}
options={options}
appendTo={appendTo}
role='toolbar'
aria-label='Text formatting'
updateDelay={0}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'
import { useState } from 'react'
import {
ArrowDown,
ArrowLeft,
Expand All @@ -9,22 +9,16 @@ import {
Table as TableIcon,
Trash,
} from '@sim/emcn/icons'
import { posToDOMRect } from '@tiptap/core'
import { PluginKey } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/react'
import { useEditorState } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import { ToolbarButton, ToolbarDivider } from './toolbar-button'

/** Pins the toolbar to the viewport instead of tracking the (often wide) table as it scrolls horizontally. */
const FLOATING_OPTIONS = { strategy: 'fixed' } as const

/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */
const APPEND_TO_BODY = () => document.body
import { useBubbleMenuFloating } from './use-bubble-menu-floating'

interface TableBubbleMenuProps {
editor: Editor
/** The editor's scrollable viewport, used to keep the toolbar on-screen for a table taller than it. */
/** The editor's scrollable viewport, so the toolbar repositions with the cell as the pane scrolls. */
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}

Expand All @@ -44,29 +38,15 @@ export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuP
}),
})

// Recomputed on every call (not cached by selection key) — the same table cell can land at a
// different screen position purely from scrolling with no selection change, and Floating UI's
// `autoUpdate` re-invokes this on scroll/resize expecting a fresh rect each time.
const resolveAnchor = useCallback(() => {
const { view, state } = editor
if (!view.dom.isConnected) return null
const { from, to } = state.selection
const selection = posToDOMRect(view, from, to)
const viewport = scrollContainerRef.current?.getBoundingClientRect()
const rect =
viewport && selection.top < viewport.top
? new DOMRect(selection.left, viewport.top, selection.width, 0)
: selection
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
}, [editor, scrollContainerRef])
const { resolveAnchor, options, appendTo } = useBubbleMenuFloating(editor, scrollContainerRef)

return (
<BubbleMenu
editor={editor}
pluginKey={menuKey}
getReferencedVirtualElement={resolveAnchor}
options={FLOATING_OPTIONS}
appendTo={APPEND_TO_BODY}
options={options}
appendTo={appendTo}
role='toolbar'
aria-label='Table editing'
updateDelay={0}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { posToDOMRect } from '@tiptap/core'
import type { Editor } from '@tiptap/react'

/**
* A Floating UI virtual element anchored to the current selection. The rect is recomputed on every
* call rather than cached by selection: the same `from`/`to` maps to a different screen position as
* the pane scrolls, so a cached rect would freeze the toolbar in place. `contextElement` is the
* editor DOM so Floating UI resolves clipping (and the `hide` middleware) against the editor's
* scroll container, hiding the toolbar once the selection leaves the pane — not just the viewport.
* Returns `null` before the editor mounts so the caller skips positioning.
*/
function selectionVirtualElement(editor: Editor) {
const { view, state } = editor
if (!view.dom.isConnected) return null
const { from, to } = state.selection
const rect = posToDOMRect(view, from, to)
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
contextElement: view.dom,
}
}

/**
* BubbleMenu Floating UI options. `scrollTarget` is load-bearing: TipTap's reposition listener
* defaults to `window`, but the editor scrolls inside an inner overflow container that never fires a
* window scroll — passing the container makes the toolbar track the selection as the pane scrolls.
* `hide` removes the toolbar once the selection scrolls out of view; `fixed` positions it relative
* to the viewport so an overflow ancestor can't clip it.
*/
function bubbleMenuFloatingOptions(scrollTarget: HTMLElement | null) {
return { strategy: 'fixed' as const, scrollTarget: scrollTarget ?? undefined, hide: true }
}

/** Renders the toolbar into `<body>` so a clipping or transformed ancestor can't reparent or shift it. */
const appendTo = () => document.body

/**
* Wires a BubbleMenu's Floating UI concerns — the selection anchor, positioning options, and the
* body portal — so the text and table toolbars share one source of truth and can't drift. Captures
* the parent-owned scroll container into state so `options` gains a new identity once the element
* resolves, which is what makes the BubbleMenu bind its scroll listener to the pane.
*/
export function useBubbleMenuFloating(
editor: Editor,
scrollContainerRef: React.RefObject<HTMLDivElement | null>
) {
const [scrollTarget, setScrollTarget] = useState<HTMLElement | null>(null)

useEffect(() => {
setScrollTarget(scrollContainerRef.current)
}, [scrollContainerRef])

const resolveAnchor = useCallback(() => selectionVirtualElement(editor), [editor])
const options = useMemo(() => bubbleMenuFloatingOptions(scrollTarget), [scrollTarget])

return { resolveAnchor, options, appendTo }
}
Loading