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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user