Files
simple-react-app-kit/packages/lexical/src/bubble-toolbar.tsx
T

137 lines
3.8 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useRef, useState } from "react"
import type { ComponentProps } from "react"
import { createPortal } from "react-dom"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { cn } from "@workspace/ui/lib/utils"
import { LexicalActionTree } from "./actions-view"
import { useLexicalActions } from "./actions-context"
import { LexicalActionRuntimeProvider } from "./toolbar"
type BubblePosition = {
left: number
top: number
placement: "above" | "below"
}
export type LexicalBubbleToolbarProps = ComponentProps<"div">
export function LexicalBubbleToolbar({
className,
children,
...props
}: LexicalBubbleToolbarProps) {
const actions = useLexicalActions("bubble")
if (actions.length === 0 && children == null) return null
return (
<LexicalBubbleToolbarContent
actions={actions}
className={className}
{...props}
>
{children}
</LexicalBubbleToolbarContent>
)
}
function LexicalBubbleToolbarContent({
actions,
className,
children,
...props
}: LexicalBubbleToolbarProps & {
actions: ReturnType<typeof useLexicalActions>
}) {
const [editor] = useLexicalComposerContext()
const [position, setPosition] = useState<BubblePosition | null>(null)
const frameRef = useRef<number | null>(null)
const updatePosition = useCallback(() => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
}
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null
const rootElement = editor.getRootElement()
const selection = window.getSelection()
if (
!rootElement ||
!selection ||
selection.rangeCount === 0 ||
selection.isCollapsed ||
!selection.anchorNode ||
!rootElement.contains(selection.anchorNode)
) {
setPosition(null)
return
}
const range = selection.getRangeAt(0)
if (typeof range.getBoundingClientRect !== "function") {
setPosition(null)
return
}
const rect = range.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0) {
setPosition(null)
return
}
const placement = rect.top >= 72 ? "above" : "below"
setPosition({
left: Math.min(
Math.max(rect.left + rect.width / 2, 24),
window.innerWidth - 24
),
top: placement === "above" ? rect.top - 8 : rect.bottom + 8,
placement,
})
})
}, [editor])
useEffect(() => {
const unregisterUpdate = editor.registerUpdateListener(updatePosition)
document.addEventListener("selectionchange", updatePosition)
window.addEventListener("resize", updatePosition)
window.addEventListener("scroll", updatePosition, true)
return () => {
unregisterUpdate()
document.removeEventListener("selectionchange", updatePosition)
window.removeEventListener("resize", updatePosition)
window.removeEventListener("scroll", updatePosition, true)
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
}
}
}, [editor, updatePosition])
if (!position || typeof document === "undefined") return null
return createPortal(
<LexicalActionRuntimeProvider>
<div
role="toolbar"
className={cn(
"fixed z-50 flex min-h-11 -translate-x-1/2 items-center gap-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-xl ring-1 ring-foreground/5",
position.placement === "above"
? "-translate-y-full"
: "translate-y-0",
className
)}
style={{ left: position.left, top: position.top }}
{...props}
>
<LexicalActionTree items={actions} />
{children}
</div>
</LexicalActionRuntimeProvider>,
document.body
)
}