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,215 @@
import * as React from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import {
$getSelection,
$insertNodes,
$isRangeSelection,
$setSelection,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from "lexical"
import type { RangeSelection } from "lexical"
import { Button } from "@workspace/ui/components/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@workspace/ui/components/field"
import { Input } from "@workspace/ui/components/input"
import { useLexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
import {
$createLexicalMediaNode,
type LexicalMediaKind,
type LexicalMediaPayload,
} from "../nodes/media-node"
interface PendingMedia {
kind: LexicalMediaKind
selection: RangeSelection
}
export const OPEN_MEDIA_DIALOG_COMMAND = createCommand<LexicalMediaKind>(
"OPEN_MEDIA_DIALOG_COMMAND"
)
export function LexicalMediaDialogPlugin() {
const [editor] = useLexicalComposerContext()
const [pendingMedia, setPendingMedia] = React.useState<PendingMedia | null>(
null
)
const [src, setSrc] = React.useState("")
const [alt, setAlt] = React.useState("")
const [caption, setCaption] = React.useState("")
const [poster, setPoster] = React.useState("")
const imageDescription = useLexicalMessage(lexicalMessages.imageDescription)
const videoDescription = useLexicalMessage(lexicalMessages.videoDescription)
const imageUrlLabel = useLexicalMessage(lexicalMessages.imageUrl)
const videoUrlLabel = useLexicalMessage(lexicalMessages.videoUrl)
const urlDescription = useLexicalMessage(lexicalMessages.urlDescription)
const imageAltLabel = useLexicalMessage(lexicalMessages.imageAlt)
const imageAltDescription = useLexicalMessage(
lexicalMessages.imageAltDescription
)
const videoPosterLabel = useLexicalMessage(lexicalMessages.videoPoster)
const captionLabel = useLexicalMessage(lexicalMessages.caption)
const cancelLabel = useLexicalMessage(lexicalMessages.cancel)
const optionalLabel = useLexicalMessage(lexicalMessages.optional)
React.useEffect(
() =>
editor.registerCommand(
OPEN_MEDIA_DIALOG_COMMAND,
(kind) => {
const selection = editor.getEditorState().read(() => {
const currentSelection = $getSelection()
return $isRangeSelection(currentSelection)
? currentSelection.clone()
: null
})
if (!selection) return false
setSrc("")
setAlt("")
setCaption("")
setPoster("")
setPendingMedia({ kind, selection })
return true
},
COMMAND_PRIORITY_EDITOR
),
[editor]
)
const closeDialog = () => {
setPendingMedia(null)
editor.focus()
}
const insertMedia = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!pendingMedia) return
const normalizedSrc = src.trim()
if (!normalizedSrc) return
const payload: LexicalMediaPayload = {
alt: pendingMedia.kind === "image" ? alt.trim() || undefined : undefined,
caption: caption.trim() || undefined,
kind: pendingMedia.kind,
poster:
pendingMedia.kind === "video" ? poster.trim() || undefined : undefined,
src: normalizedSrc,
}
editor.update(() => {
$setSelection(pendingMedia.selection.clone())
$insertNodes([$createLexicalMediaNode(payload)])
})
closeDialog()
}
const isImage = pendingMedia?.kind === "image"
const title = useLexicalMessage(
isImage ? lexicalMessages.insertImage : lexicalMessages.insertVideo
)
return (
<Dialog
open={pendingMedia !== null}
onOpenChange={(open) => {
if (!open && pendingMedia) closeDialog()
}}
>
<DialogContent
finalFocus={() => editor.getRootElement()}
showCloseButton={false}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{isImage ? imageDescription : videoDescription}
</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={insertMedia}>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="lexical-media-src">
{isImage ? imageUrlLabel : videoUrlLabel}
</FieldLabel>
<Input
autoFocus
id="lexical-media-src"
inputMode="url"
placeholder={
isImage
? "https://example.com/image.jpg"
: "https://example.com/video.mp4"
}
required
value={src}
onChange={(event) => setSrc(event.target.value)}
/>
<FieldDescription>{urlDescription}</FieldDescription>
</Field>
{isImage ? (
<Field>
<FieldLabel htmlFor="lexical-media-alt">
{imageAltLabel}
</FieldLabel>
<Input
id="lexical-media-alt"
placeholder={imageAltLabel}
value={alt}
onChange={(event) => setAlt(event.target.value)}
/>
<FieldDescription>{imageAltDescription}</FieldDescription>
</Field>
) : (
<Field>
<FieldLabel htmlFor="lexical-media-poster">
{videoPosterLabel}
</FieldLabel>
<Input
id="lexical-media-poster"
inputMode="url"
placeholder="https://example.com/poster.jpg"
value={poster}
onChange={(event) => setPoster(event.target.value)}
/>
</Field>
)}
<Field>
<FieldLabel htmlFor="lexical-media-caption">
{captionLabel}
</FieldLabel>
<Input
id="lexical-media-caption"
placeholder={optionalLabel}
value={caption}
onChange={(event) => setCaption(event.target.value)}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={closeDialog}>
{cancelLabel}
</Button>
<Button type="submit">{title}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}