feat(lexical): add declarative rich text editor

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.
This commit is contained in:
Maofeng
2026-07-31 15:07:09 +08:00
parent 1fe9c1021a
commit 0d817537be
65 changed files with 7453 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
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
)
}