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
@@ -0,0 +1,343 @@
import { useEffect, useId, useRef, useState } from "react"
import { $isLinkNode, $toggleLink } from "@lexical/link"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { Check, Pencil, Trash2 } from "lucide-react"
import {
$findMatchingParent,
$getNearestNodeFromDOMNode,
$getNodeByKey,
$getSelection,
$isRangeSelection,
$setSelection,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from "lexical"
import type { NodeKey, RangeSelection } from "lexical"
import { Button } from "@workspace/ui/components/button"
import {
InputGroup,
InputGroupButton,
InputGroupTextarea,
} from "@workspace/ui/components/input-group"
import {
Popover,
PopoverContent,
PopoverTitle,
} from "@workspace/ui/components/popover"
import { useLexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
import { createSelectionAnchor, type SelectionAnchor } from "./selection-anchor"
interface SelectedTextLink {
anchor: HTMLElement | SelectionAnchor
kind: "selection"
selection: RangeSelection
url: string
}
interface ExistingLink {
anchor: HTMLAnchorElement
key: NodeKey
kind: "existing"
url: string
}
type ActiveLink = ExistingLink | SelectedTextLink
export const OPEN_LINK_POPOVER_COMMAND = createCommand<void>(
"OPEN_LINK_POPOVER_COMMAND"
)
export function LexicalLinkPopoverPlugin() {
const [editor] = useLexicalComposerContext()
const [activeLink, setActiveLink] = useState<ActiveLink | null>(null)
const [draftUrl, setDraftUrl] = useState("")
const [isEditing, setEditing] = useState(false)
const inputId = useId()
const inputRef = useRef<HTMLTextAreaElement>(null)
const insertLinkLabel = useLexicalMessage(lexicalMessages.insertLink)
const linkDetailsLabel = useLexicalMessage(lexicalMessages.linkDetails)
const linkAddressLabel = useLexicalMessage(lexicalMessages.linkAddress)
const applyLinkLabel = useLexicalMessage(lexicalMessages.applyLink)
const removeLinkLabel = useLexicalMessage(lexicalMessages.removeLink)
const editLinkLabel = useLexicalMessage(lexicalMessages.editLink)
useEffect(() => {
const rootElement = editor.getRootElement()
const unregisterOpenCommand = editor.registerCommand(
OPEN_LINK_POPOVER_COMMAND,
() => {
if (!rootElement) return false
const selectionLink = editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return null
const linkNode = $findMatchingParent(
selection.anchor.getNode(),
$isLinkNode
)
return {
anchor: createSelectionAnchor(rootElement),
kind: "selection" as const,
selection: selection.clone(),
url: linkNode?.getURL() ?? "https://",
}
})
if (!selectionLink) return false
setActiveLink(selectionLink)
setDraftUrl(selectionLink.url)
setEditing(true)
return true
},
COMMAND_PRIORITY_EDITOR
)
if (!rootElement) return unregisterOpenCommand
const handleEditorClick = (event: MouseEvent) => {
const target = event.target instanceof Element ? event.target : null
const anchor = target?.closest("a")
if (
!(anchor instanceof HTMLAnchorElement) ||
!rootElement.contains(anchor)
) {
setActiveLink(null)
setEditing(false)
return
}
event.preventDefault()
const editorState = editor.getEditorState()
const link = editorState.read(
() => {
const node = $getNearestNodeFromDOMNode(anchor, editorState)
return $isLinkNode(node)
? { key: node.getKey(), url: node.getURL() }
: null
},
{ editor }
)
if (!link) return
setActiveLink({
anchor,
key: link.key,
kind: "existing",
url: link.url,
})
setDraftUrl(link.url)
setEditing(false)
}
rootElement.addEventListener("click", handleEditorClick)
return () => {
unregisterOpenCommand()
rootElement.removeEventListener("click", handleEditorClick)
}
}, [editor])
useEffect(() => {
if (isEditing) inputRef.current?.focus()
}, [isEditing])
if (!activeLink) return null
const closePopover = () => {
setActiveLink(null)
setEditing(false)
}
const startEditing = () => {
setDraftUrl(activeLink.url)
setEditing(true)
}
const saveLink = () => {
const normalizedUrl = draftUrl.trim().replace(/[\r\n]+/g, "")
if (activeLink.kind === "selection") {
if (normalizedUrl) {
editor.update(() => {
$setSelection(activeLink.selection.clone())
$toggleLink(normalizedUrl)
})
}
closePopover()
editor.focus()
return
}
editor.update(() => {
const node = $getNodeByKey(activeLink.key)
if (!$isLinkNode(node)) return
if (normalizedUrl) {
node.setURL(normalizedUrl)
} else {
const children = node.getChildren()
children.forEach((child) => node.insertBefore(child))
node.remove()
}
})
if (normalizedUrl) {
setActiveLink((current) =>
current ? { ...current, url: normalizedUrl } : null
)
setEditing(false)
} else {
closePopover()
}
}
const removeLink = () => {
if (activeLink.kind !== "existing") return
editor.update(() => {
const node = $getNodeByKey(activeLink.key)
if (!$isLinkNode(node)) return
const children = node.getChildren()
children.forEach((child) => node.insertBefore(child))
node.remove()
})
closePopover()
}
const showEditor = activeLink.kind === "selection" || isEditing
return (
<Popover
open
onOpenChange={(open) => {
if (!open) closePopover()
}}
>
<PopoverContent
anchor={activeLink.anchor}
align="center"
positionMethod="fixed"
className="w-80 gap-1.5 p-2"
finalFocus={() => editor.getRootElement()}
initialFocus={showEditor ? inputRef : undefined}
showArrow
>
<PopoverTitle className="sr-only">
{activeLink.kind === "selection" ? insertLinkLabel : linkDetailsLabel}
</PopoverTitle>
{showEditor ? (
<form
className="min-w-0"
onSubmit={(event) => {
event.preventDefault()
saveLink()
}}
>
<div className="mb-1.5 flex items-center gap-1 ps-1">
<label
htmlFor={inputId}
className="text-xs font-medium text-muted-foreground"
>
{linkAddressLabel}
</label>
<div className="-my-1 ms-auto flex items-center gap-1">
<InputGroupButton
type="submit"
size="icon-xs"
aria-label={applyLinkLabel}
>
<Check className="size-3" />
</InputGroupButton>
{activeLink.kind === "existing" && (
<InputGroupButton
size="icon-xs"
aria-label={removeLinkLabel}
onClick={removeLink}
>
<Trash2 className="size-3" />
</InputGroupButton>
)}
</div>
</div>
<InputGroup>
<InputGroupTextarea
id={inputId}
ref={inputRef}
aria-label={linkAddressLabel}
className="min-h-16 resize-y break-all"
inputMode="url"
placeholder="https://example.com"
rows={2}
value={draftUrl}
onChange={(event) => setDraftUrl(event.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
saveLink()
return
}
if (
event.key === "Escape" &&
activeLink.kind === "existing"
) {
event.preventDefault()
setEditing(false)
}
}}
/>
</InputGroup>
</form>
) : (
<>
<div className="flex items-center gap-1 ps-1">
<span className="text-xs font-medium text-muted-foreground">
{linkAddressLabel}
</span>
<div className="-my-1 ms-auto flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={editLinkLabel}
onClick={startEditing}
>
<Pencil />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={removeLinkLabel}
onClick={removeLink}
>
<Trash2 />
</Button>
</div>
</div>
<a
href={activeLink.url}
target="_blank"
rel="noreferrer"
className="block max-h-24 overflow-y-auto px-1 break-all text-primary underline underline-offset-3"
>
{activeLink.url}
</a>
</>
)}
</PopoverContent>
</Popover>
)
}