137 lines
3.8 KiB
TypeScript
137 lines
3.8 KiB
TypeScript
|
|
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
|
||
|
|
)
|
||
|
|
}
|