0d817537be
Introduce @workspace/lexical with composable actions, toolbars, embeds, rich-text nodes, media uploads, selection utilities, HTML serialization, and theme styling.\n\nLocalize built-in controls through MessageDescriptor catalogs for English and Simplified Chinese, while providing an English I18nProvider fallback for standalone editor use. Include focused unit coverage for actions, controls, serialization, plugins, public API, and catalogs.
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
|
|
)
|
|
}
|