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
+164
View File
@@ -0,0 +1,164 @@
/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */
import {
$applyNodeReplacement,
$getSelection,
$isRangeSelection,
TextNode,
} from "lexical"
import type {
DOMConversionMap,
DOMExportOutput,
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
SerializedTextNode,
Spread,
} from "lexical"
import { isISODate } from "../date-value"
export type SerializedDateNode = Spread<
{
date: string
},
SerializedTextNode
>
export class DateNode extends TextNode {
__date: string
static getType(): string {
return "date"
}
static clone(node: DateNode): DateNode {
return new DateNode(node.__date, node.__text, node.__key)
}
static importJSON(node: SerializedDateNode): DateNode {
return $createDateNode(node.date, node.text).updateFromJSON(node)
}
static importDOM(): DOMConversionMap | null {
return {
time: (element) => {
const date =
element.getAttribute("datetime") || element.dataset.lexicalDate
if (!date || !isISODate(date)) return null
return {
conversion: () => ({
node: $createDateNode(date, element.textContent || date),
forChild: () => null,
}),
priority: 4,
}
},
}
}
constructor(date: string, text?: string, key?: NodeKey) {
super(text ?? date, key)
this.__date = date
}
createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement {
const element = super.createDOM(config, editor)
element.dataset.lexicalDate = this.__date
return element
}
updateDOM(
previousNode: this,
element: HTMLElement,
config: EditorConfig
): boolean {
const didUpdate = super.updateDOM(previousNode, element, config)
if (previousNode.__date !== this.__date) {
element.dataset.lexicalDate = this.__date
}
return didUpdate
}
exportDOM(): DOMExportOutput {
const element = document.createElement("time")
element.dateTime = this.__date
element.dataset.lexicalDate = this.__date
element.textContent = this.getTextContent()
return { element }
}
exportJSON(): SerializedDateNode {
return {
...super.exportJSON(),
date: this.__date,
type: "date",
version: 1,
}
}
getDate(): string {
return this.getLatest().__date
}
setDate(date: string, text: string): this {
const writable = this.getWritable()
writable.__date = date
writable.__text = text
return writable
}
isTextEntity(): true {
return true
}
}
export function $createDateNode(date: string, text?: string): DateNode {
return $applyNodeReplacement(new DateNode(date, text))
.setMode("token")
.setDetail("unmergable")
}
export function $isDateNode(
node: LexicalNode | null | undefined
): node is DateNode {
return node instanceof DateNode
}
export function $getSelectedDateNode(): DateNode | null {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return null
const anchorNode = selection.anchor.getNode()
const focusNode = selection.focus.getNode()
if (anchorNode === focusNode && $isDateNode(anchorNode)) return anchorNode
const selectedNodes = selection.getNodes()
return selectedNodes.length === 1 && $isDateNode(selectedNodes[0])
? selectedNodes[0]
: null
}
export function $insertOrUpdateDate(
date: string,
text: string
): DateNode | null {
const selectedDateNode = $getSelectedDateNode()
if (selectedDateNode) {
selectedDateNode.setDate(date, text).select(0, text.length)
return selectedDateNode
}
const selection = $getSelection()
if (!$isRangeSelection(selection)) return null
const dateNode = $createDateNode(date, text)
selection.insertNodes([dateNode])
dateNode.select(0, text.length)
return dateNode
}
export { isISODate } from "../date-value"
+598
View File
@@ -0,0 +1,598 @@
/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */
import * as React from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection"
import {
$applyNodeReplacement,
$getNodeByKey,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
DecoratorNode,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
mergeRegister,
} from "lexical"
import type {
DOMConversionMap,
DOMExportOutput,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from "lexical"
import { cn } from "@workspace/ui/lib/utils"
import { AlignCenter, AlignLeft, AlignRight, Trash2 } from "lucide-react"
import { useTranslate } from "@workspace/i18n"
import { ImageResizer } from "../components/image-resizer"
import { useLexicalMessage } from "../i18n"
import { getImageUploadState, useImageUploadState } from "../image-upload-store"
import { lexicalMessages } from "../messages"
export type LexicalMediaKind = "image" | "video"
export type LexicalMediaAlignment = "center" | "end" | "start"
export interface LexicalMediaPayload {
alignment?: LexicalMediaAlignment
alt?: string
caption?: string
height?: number
kind: LexicalMediaKind
poster?: string
src: string
width?: number
}
export type SerializedLexicalMediaNode = Spread<
LexicalMediaPayload,
SerializedLexicalNode
>
export class LexicalMediaNode extends DecoratorNode<React.ReactNode> {
__alignment: LexicalMediaAlignment
__alt: string
__caption: string
__height: number
__kind: LexicalMediaKind
__poster: string
__src: string
__width: number
static getType(): string {
return "lexical-media"
}
static clone(node: LexicalMediaNode): LexicalMediaNode {
return new LexicalMediaNode(
{
alignment: node.__alignment,
alt: node.__alt || undefined,
caption: node.__caption || undefined,
height: node.__height || undefined,
kind: node.__kind,
poster: node.__poster || undefined,
src: node.__src,
width: node.__width || undefined,
},
node.__key
)
}
static importJSON(
node: SerializedLexicalNode & Record<string, unknown>
): LexicalMediaNode {
return $createLexicalMediaNode({
alignment: normalizeAlignment(node.alignment),
alt: typeof node.alt === "string" ? node.alt : undefined,
caption: typeof node.caption === "string" ? node.caption : undefined,
height: normalizeDimension(node.height),
kind: node.kind === "video" ? "video" : "image",
poster: typeof node.poster === "string" ? node.poster : undefined,
src: typeof node.src === "string" ? node.src : "",
width: normalizeDimension(node.width),
})
}
static importDOM(): DOMConversionMap | null {
return {
figure: (element) => {
const kind = element.dataset.lexicalMediaKind
if (kind !== "image" && kind !== "video") return null
const media = element.querySelector(kind === "image" ? "img" : "video")
const src = media?.getAttribute("src")?.trim()
if (!media || !src) return null
return {
conversion: () => ({
node: $createLexicalMediaNode({
alignment: normalizeAlignment(
element.dataset.lexicalMediaAlignment || element.style.textAlign
),
alt:
media instanceof HTMLImageElement
? media.getAttribute("alt") || undefined
: undefined,
caption:
element.querySelector("figcaption")?.textContent?.trim() ||
undefined,
height: normalizeDimension(media.getAttribute("height")),
kind,
poster:
media instanceof HTMLVideoElement
? media.getAttribute("poster") || undefined
: undefined,
src,
width: normalizeDimension(media.getAttribute("width")),
}),
forChild: () => null,
}),
priority: 4,
}
},
}
}
constructor(payload: LexicalMediaPayload, key?: NodeKey) {
super(key)
this.__alignment = normalizeAlignment(payload.alignment)
this.__alt = payload.alt ?? ""
this.__caption = payload.caption ?? ""
this.__height = normalizeDimension(payload.height) ?? 0
this.__kind = payload.kind
this.__poster = payload.poster ?? ""
this.__src = payload.src
this.__width = normalizeDimension(payload.width) ?? 0
}
createDOM(): HTMLElement {
return document.createElement("div")
}
updateDOM(): false {
return false
}
exportDOM(): DOMExportOutput {
const figure = document.createElement("figure")
figure.dataset.lexicalMediaKind = this.__kind
figure.dataset.lexicalMediaAlignment = this.__alignment
figure.style.textAlign = this.__alignment
if (getImageUploadState(this.__key)) {
figure.dataset.lexicalImageUploading = "true"
return { element: figure }
}
if (this.__kind === "image") {
const image = document.createElement("img")
image.setAttribute("src", this.__src)
image.setAttribute("alt", this.__alt)
if (this.__width) image.setAttribute("width", String(this.__width))
if (this.__height) image.setAttribute("height", String(this.__height))
figure.append(image)
} else {
const video = document.createElement("video")
video.setAttribute("src", this.__src)
video.setAttribute("controls", "")
if (this.__poster) video.setAttribute("poster", this.__poster)
figure.append(video)
}
if (this.__caption) {
const caption = document.createElement("figcaption")
caption.textContent = this.__caption
figure.append(caption)
}
return { element: figure }
}
exportJSON(): SerializedLexicalMediaNode {
return {
...super.exportJSON(),
...this.getPayload(),
type: "lexical-media",
version: 1,
}
}
getPayload(): LexicalMediaPayload {
const latest = this.getLatest()
return {
alignment: latest.__alignment,
alt: latest.__alt || undefined,
caption: latest.__caption || undefined,
height: latest.__height || undefined,
kind: latest.__kind,
poster: latest.__poster || undefined,
src: latest.__src,
width: latest.__width || undefined,
}
}
setCaption(caption: string): this {
const writable = this.getWritable()
writable.__caption = caption
return this
}
setAlignment(alignment: LexicalMediaAlignment): this {
this.getWritable().__alignment = alignment
return this
}
setPayload(payload: LexicalMediaPayload): this {
const writable = this.getWritable()
writable.__alignment = normalizeAlignment(payload.alignment)
writable.__alt = payload.alt ?? ""
writable.__caption = payload.caption ?? ""
writable.__height = normalizeDimension(payload.height) ?? 0
writable.__kind = payload.kind
writable.__poster = payload.poster ?? ""
writable.__src = payload.src
writable.__width = normalizeDimension(payload.width) ?? 0
return this
}
setWidthAndHeight(width: number, height: number): this {
const writable = this.getWritable()
writable.__width = normalizeDimension(width) ?? 0
writable.__height = normalizeDimension(height) ?? 0
return this
}
decorate(): React.ReactNode {
return <LexicalMedia payload={this.getPayload()} nodeKey={this.__key} />
}
isInline(): false {
return false
}
}
function LexicalMedia({
nodeKey,
payload,
}: {
nodeKey: NodeKey
payload: LexicalMediaPayload
}) {
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey)
const imageRef = React.useRef<HTMLImageElement>(null)
const captionRef = React.useRef<HTMLTextAreaElement>(null)
const [draftCaption, setDraftCaption] = React.useState(payload.caption ?? "")
const [isEditingCaption, setEditingCaption] = React.useState(false)
const [isResizing, setResizing] = React.useState(false)
const uploadState = useImageUploadState(nodeKey)
const translate = useTranslate()
const uploadingImageLabel = useLexicalMessage(lexicalMessages.uploadingImage)
const uploadProgressLabel = useLexicalMessage(lexicalMessages.uploadProgress)
const imageControlsLabel = useLexicalMessage(lexicalMessages.imageControls)
const deleteImageLabel = useLexicalMessage(lexicalMessages.deleteImage)
const captionLabel = useLexicalMessage(lexicalMessages.caption)
const addCaptionLabel = useLexicalMessage(lexicalMessages.addCaption)
const editCaptionLabel = useLexicalMessage(lexicalMessages.editCaption)
const imageAlignmentLabels = {
center: useLexicalMessage(lexicalMessages.imageAlignCenter),
end: useLexicalMessage(lexicalMessages.imageAlignRight),
start: useLexicalMessage(lexicalMessages.imageAlignLeft),
}
React.useEffect(() => {
if (!isEditingCaption) {
setDraftCaption(payload.caption ?? "")
}
}, [isEditingCaption, payload.caption])
React.useEffect(() => {
if (isEditingCaption) {
captionRef.current?.focus()
captionRef.current?.select()
}
}, [isEditingCaption])
React.useEffect(
() =>
mergeRegister(
editor.registerCommand(
CLICK_COMMAND,
(event) => {
const element = editor.getElementByKey(nodeKey)
const target = event.target
if (!(target instanceof Node) || !element?.contains(target)) {
return false
}
if (!event.shiftKey) clearSelection()
setSelected(event.shiftKey ? !isSelected : true)
return true
},
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
KEY_DELETE_COMMAND,
(event) => {
if (!isSelected) return false
event?.preventDefault()
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) node.remove()
})
return true
},
COMMAND_PRIORITY_LOW
),
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
(event) => {
if (!isSelected) return false
event?.preventDefault()
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) node.remove()
})
return true
},
COMMAND_PRIORITY_LOW
)
),
[clearSelection, editor, isSelected, nodeKey, setSelected]
)
return (
<figure
contentEditable={false}
data-lexical-media-kind={payload.kind}
className={cn(
"my-4 flex max-w-full flex-col items-center gap-2 rounded-xl",
payload.alignment === "start" && "items-start",
payload.alignment === "end" && "items-end",
payload.kind === "video" &&
isSelected &&
"ring-2 ring-primary ring-offset-2 ring-offset-background"
)}
>
{payload.kind === "image" ? (
<div
aria-busy={uploadState ? true : undefined}
className={cn(
"relative inline-flex max-w-full rounded-xl",
uploadState && "min-h-40 w-full bg-muted"
)}
data-lexical-image-selected={isSelected || undefined}
>
<img
ref={imageRef}
alt={payload.alt ?? ""}
className="max-h-128 max-w-full rounded-xl object-contain"
draggable={false}
src={payload.src}
style={{
height: payload.height || undefined,
width: payload.width || undefined,
}}
/>
{uploadState && (
<div
role="status"
aria-label={uploadingImageLabel}
className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 rounded-xl bg-black/55 px-8 text-sm font-medium text-white backdrop-blur-[2px]"
>
<span>
{uploadState.progress === undefined
? `${uploadingImageLabel}`
: translate(lexicalMessages.uploadingImageProgress, {
progress: Math.round(uploadState.progress * 100),
})}
</span>
<span
role="progressbar"
aria-label={uploadProgressLabel}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={
uploadState.progress === undefined
? undefined
: Math.round(uploadState.progress * 100)
}
className="h-1.5 w-full max-w-64 overflow-hidden rounded-full bg-white/25"
>
<span
className={cn(
"block h-full rounded-full bg-white transition-[width]",
uploadState.progress === undefined && "w-1/3 animate-pulse"
)}
style={{
width:
uploadState.progress === undefined
? undefined
: `${uploadState.progress * 100}%`,
}}
/>
</span>
</div>
)}
{isSelected && editor.isEditable() && !uploadState && (
<>
<ImageResizer
editor={editor}
imageRef={imageRef}
onResizeStart={() => setResizing(true)}
onResizeEnd={(width, height) => {
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) {
node.setWidthAndHeight(width, height)
}
})
setResizing(false)
}}
/>
{!isResizing && (
<div
className="absolute top-3 left-1/2 z-20 flex -translate-x-1/2 items-center gap-0.5 rounded-lg border border-white/20 bg-black/65 p-1 text-white shadow-lg backdrop-blur-sm"
role="toolbar"
aria-label={imageControlsLabel}
>
{IMAGE_ALIGNMENT_OPTIONS.map((option) => (
<button
key={option.alignment}
type="button"
aria-label={imageAlignmentLabels[option.alignment]}
aria-pressed={payload.alignment === option.alignment}
className="inline-flex size-7 items-center justify-center rounded-md hover:bg-white/15 aria-pressed:bg-white aria-pressed:text-black"
onClick={() => {
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) {
node.setAlignment(option.alignment)
}
})
}}
>
<option.icon className="size-4" />
</button>
))}
<span className="mx-0.5 h-4 w-px bg-white/25" />
<button
type="button"
aria-label={deleteImageLabel}
className="hover:text-destructive-foreground inline-flex size-7 items-center justify-center rounded-md hover:bg-destructive"
onClick={() => {
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) node.remove()
})
}}
>
<Trash2 className="size-4" />
</button>
</div>
)}
{!isResizing &&
(isEditingCaption ? (
<textarea
ref={captionRef}
aria-label={captionLabel}
className="absolute inset-x-4 bottom-4 z-20 min-h-10 resize-none rounded-lg border border-white/20 bg-black/65 px-3 py-2 text-center text-sm text-white backdrop-blur-sm outline-none focus:border-white/60"
contentEditable={false}
placeholder={addCaptionLabel}
rows={1}
value={draftCaption}
onBlur={() => setEditingCaption(false)}
onChange={(event) => {
const caption = event.target.value
setDraftCaption(caption)
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) {
node.setCaption(caption)
}
})
}}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
event.currentTarget.blur()
}
}}
/>
) : payload.caption ? (
<button
type="button"
aria-label={editCaptionLabel}
className="absolute inset-x-4 bottom-4 z-20 rounded-lg border border-white/20 bg-black/65 px-3 py-2 text-center text-sm text-white backdrop-blur-sm hover:bg-black/75"
onClick={(event) => {
event.stopPropagation()
setEditingCaption(true)
}}
>
{payload.caption}
</button>
) : (
<button
type="button"
className="absolute bottom-4 left-1/2 z-20 -translate-x-1/2 rounded-lg border border-white/20 bg-black/65 px-5 py-2 text-sm text-white backdrop-blur-sm hover:bg-black/75"
onClick={(event) => {
event.stopPropagation()
setEditingCaption(true)
}}
>
{addCaptionLabel}
</button>
))}
</>
)}
{!isSelected && payload.caption && (
<figcaption className="absolute inset-x-4 bottom-4 rounded-lg bg-black/65 px-3 py-2 text-center text-sm text-white backdrop-blur-sm">
{payload.caption}
</figcaption>
)}
</div>
) : (
<video
className="max-h-128 max-w-full rounded-xl bg-black"
controls
poster={payload.poster}
preload="metadata"
src={payload.src}
/>
)}
{payload.kind === "video" && payload.caption && (
<figcaption className="text-center text-sm text-muted-foreground">
{payload.caption}
</figcaption>
)}
</figure>
)
}
function normalizeDimension(value: unknown): number | undefined {
const dimension =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseFloat(value)
: Number.NaN
return Number.isFinite(dimension) && dimension > 0 ? dimension : undefined
}
function normalizeAlignment(value: unknown): LexicalMediaAlignment {
if (value === "start" || value === "left") return "start"
if (value === "end" || value === "right") return "end"
return "center"
}
const IMAGE_ALIGNMENT_OPTIONS = [
{
alignment: "start",
icon: AlignLeft,
},
{
alignment: "center",
icon: AlignCenter,
},
{
alignment: "end",
icon: AlignRight,
},
] as const
export function $createLexicalMediaNode(
payload: LexicalMediaPayload
): LexicalMediaNode {
return $applyNodeReplacement(new LexicalMediaNode(payload))
}
export function $isLexicalMediaNode(
node: LexicalNode | null | undefined
): node is LexicalMediaNode {
return node instanceof LexicalMediaNode
}
+82
View File
@@ -0,0 +1,82 @@
/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */
import { $isTextNode, TextNode } from "lexical"
import type {
DOMConversionMap,
DOMConversionOutput,
DOMConversionProp,
SerializedTextNode,
} from "lexical"
function patchTextStyleConversion(
originalDOMConverter?: DOMConversionProp<HTMLElement>
) {
return (node: HTMLElement): DOMConversionOutput | null => {
const original = originalDOMConverter?.(node)
const output = original?.conversion(node)
if (!output) return null
const color = node.style.color
const backgroundColor = node.style.backgroundColor
const textDecoration = node.style.textDecoration
const style = [
color ? `color: ${color}` : null,
backgroundColor ? `background-color: ${backgroundColor}` : null,
textDecoration ? `text-decoration: ${textDecoration}` : null,
]
.filter(Boolean)
.join("; ")
return {
...output,
forChild: (lexicalNode, parent) => {
const converted = output.forChild
? output.forChild(lexicalNode, parent)
: lexicalNode
if ($isTextNode(converted) && style) converted.setStyle(style)
return converted
},
}
}
}
export class LexicalTextNode extends TextNode {
static getType(): string {
return "lexical-text"
}
static clone(node: LexicalTextNode): LexicalTextNode {
return new LexicalTextNode(node.__text, node.__key)
}
static importJSON(node: SerializedTextNode): LexicalTextNode {
return new LexicalTextNode().updateFromJSON(node)
}
static importDOM(): DOMConversionMap | null {
const importers = TextNode.importDOM()
return {
...importers,
code: () => ({
conversion: patchTextStyleConversion(importers?.code),
priority: 1,
}),
em: () => ({
conversion: patchTextStyleConversion(importers?.em),
priority: 1,
}),
span: () => ({
conversion: patchTextStyleConversion(importers?.span),
priority: 1,
}),
strong: () => ({
conversion: patchTextStyleConversion(importers?.strong),
priority: 1,
}),
}
}
isSimpleText(): boolean {
return this.__type === "lexical-text" && this.__mode === 0
}
}