mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(files): pin editor bubble menus to the cursor during scroll (#6434)
The text and table toolbars were positioned `fixed` and portaled to `<body>`, so TipTap repositioned them on a debounced scroll listener — the toolbar visibly lagged and "chased" the cursor as the pane scrolled. Leave the menus at TipTap's default `absolute` strategy and append them into the editor's scroll container (now a positioning context) so they become absolutely-positioned children of the scrolled content and track the selection through native scrolling — no scroll listener, so no lag. TipTap's default `flip`/`shift` keep the toolbar above/below the selection at the pane edges, and the container's overflow clips it once the selection scrolls out of view. Verified in a harness: 0px menu-to-cell drift during scroll (was 100s of px on fast scroll), and no clipping at the top, bottom, or table edges.
This commit is contained in:
+1
-2
@@ -179,14 +179,13 @@ export function EditorBubbleMenu({
|
||||
setLinkValue(null)
|
||||
}
|
||||
|
||||
const { resolveAnchor, options, appendTo } = useBubbleMenuFloating(editor, scrollContainerRef)
|
||||
const { resolveAnchor, appendTo } = useBubbleMenuFloating(editor, scrollContainerRef)
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={bubbleMenuKey}
|
||||
getReferencedVirtualElement={resolveAnchor}
|
||||
options={options}
|
||||
appendTo={appendTo}
|
||||
role='toolbar'
|
||||
aria-label='Text formatting'
|
||||
|
||||
+1
-2
@@ -38,14 +38,13 @@ export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuP
|
||||
}),
|
||||
})
|
||||
|
||||
const { resolveAnchor, options, appendTo } = useBubbleMenuFloating(editor, scrollContainerRef)
|
||||
const { resolveAnchor, appendTo } = useBubbleMenuFloating(editor, scrollContainerRef)
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={menuKey}
|
||||
getReferencedVirtualElement={resolveAnchor}
|
||||
options={options}
|
||||
appendTo={appendTo}
|
||||
role='toolbar'
|
||||
aria-label='Table editing'
|
||||
|
||||
+16
-31
@@ -1,14 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback } 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.
|
||||
* the pane scrolls, so a cached rect would freeze the toolbar in place. Returns `null` before the
|
||||
* editor mounts so the caller skips positioning.
|
||||
*/
|
||||
function selectionVirtualElement(editor: Editor) {
|
||||
const { view, state } = editor
|
||||
@@ -23,37 +21,24 @@ function selectionVirtualElement(editor: Editor) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Wires a BubbleMenu's Floating UI concerns — the selection anchor and the portal target — so the
|
||||
* text and table toolbars share one source of truth and can't drift.
|
||||
*
|
||||
* The toolbar is appended into the editor's scroll container (which must be a positioning context)
|
||||
* and left at TipTap's default `absolute` strategy, so it becomes an absolutely-positioned child of
|
||||
* the scrolled content and tracks the selection through native scrolling — no scroll listener, so no
|
||||
* lag or "chase" as the pane scrolls. TipTap's default `flip` still parks it above or below the
|
||||
* selection at the pane edges, and the container's overflow clips it once the selection scrolls out.
|
||||
*/
|
||||
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])
|
||||
const appendTo = useCallback(
|
||||
() => scrollContainerRef.current ?? document.body,
|
||||
[scrollContainerRef]
|
||||
)
|
||||
|
||||
return { resolveAnchor, options, appendTo }
|
||||
return { resolveAnchor, appendTo }
|
||||
}
|
||||
|
||||
+1
-1
@@ -1204,7 +1204,7 @@ export function LoadedRichMarkdownEditor({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
|
||||
className={cn('relative flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
|
||||
>
|
||||
{editor && (
|
||||
<EditorBubbleMenu
|
||||
|
||||
Reference in New Issue
Block a user