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
+323
View File
@@ -0,0 +1,323 @@
/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */
import React from "react"
import type { ComponentType, ReactNode } from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection"
import {
$applyNodeReplacement,
$getNodeByKey,
$insertNodes,
COMMAND_PRIORITY_LOW,
DecoratorNode,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
mergeRegister,
} from "lexical"
import type {
DOMConversionMap,
DOMExportOutput,
LexicalEditor,
NodeKey,
SerializedLexicalNode,
Spread,
} from "lexical"
import { cn } from "@workspace/ui/lib/utils"
import type { LexicalActionContext, LexicalActionDefinition } from "./types"
export interface LexicalEmbedPayload {
id: string
title: string
description?: string
imageUrl?: string
metadata?: Record<string, unknown>
}
export interface LexicalEmbedRenderProps {
payload: LexicalEmbedPayload
}
export interface LexicalEmbedDefinition {
type: string
render: ComponentType<LexicalEmbedRenderProps>
}
export interface CreateLexicalEmbedActionOptions {
embed?: LexicalEmbedDefinition
name: string
label: LexicalActionDefinition["label"]
icon?: LexicalActionDefinition["icon"]
onRequest: (context: LexicalActionContext) => void
}
type SerializedLexicalEmbedNode = Spread<
{
embedType: string
payload: LexicalEmbedPayload
},
SerializedLexicalNode
>
const LexicalEmbedContext = React.createContext<
ReadonlyMap<string, LexicalEmbedDefinition>
>(new Map())
export function LexicalEmbedProvider({
definitions,
children,
}: {
definitions: readonly LexicalEmbedDefinition[]
children: ReactNode
}) {
const definitionsByType = React.useMemo(
() =>
new Map(
definitions.map((definition) => [definition.type, definition] as const)
),
[definitions]
)
return (
<LexicalEmbedContext.Provider value={definitionsByType}>
{children}
</LexicalEmbedContext.Provider>
)
}
export function createLexicalEmbedAction({
name,
label,
icon,
embed,
onRequest,
}: CreateLexicalEmbedActionOptions): LexicalActionDefinition {
return {
name,
label,
icon,
group: "insert",
nodes: [LexicalEmbedNode],
embeds: embed ? [embed] : undefined,
execute: onRequest,
}
}
export function insertLexicalEmbed(
editor: LexicalEditor,
embedType: string,
payload: LexicalEmbedPayload
) {
editor.update(() => {
$insertNodes([$createLexicalEmbedNode(embedType, payload)])
})
}
export class LexicalEmbedNode extends DecoratorNode<ReactNode> {
__embedType: string
__payload: LexicalEmbedPayload
static getType(): string {
return "lexical-embed"
}
static clone(node: LexicalEmbedNode): LexicalEmbedNode {
return new LexicalEmbedNode(node.__embedType, node.__payload, node.__key)
}
static importJSON(node: SerializedLexicalEmbedNode): LexicalEmbedNode {
return $createLexicalEmbedNode(node.embedType, node.payload)
}
static importDOM(): DOMConversionMap | null {
const conversion = (element: HTMLElement) => {
const embedType = element.dataset.lexicalEmbedType
const serializedPayload = element.dataset.lexicalEmbedPayload
if (!embedType || !serializedPayload) return null
try {
const payload = JSON.parse(serializedPayload) as LexicalEmbedPayload
if (!isLexicalEmbedPayload(payload)) return null
return {
conversion: () => ({
node: $createLexicalEmbedNode(embedType, payload),
forChild: () => null,
}),
priority: 4 as const,
}
} catch {
return null
}
}
return {
article: conversion,
div: conversion,
}
}
constructor(embedType: string, payload: LexicalEmbedPayload, key?: NodeKey) {
super(key)
this.__embedType = embedType
this.__payload = payload
}
createDOM(): HTMLElement {
return document.createElement("div")
}
updateDOM(): false {
return false
}
exportDOM(): DOMExportOutput {
const element = document.createElement("article")
element.dataset.lexicalEmbedType = this.__embedType
element.dataset.lexicalEmbedPayload = JSON.stringify(this.__payload)
if (this.__payload.imageUrl) {
const image = document.createElement("img")
image.setAttribute("src", this.__payload.imageUrl)
image.setAttribute("alt", "")
element.append(image)
}
const title = document.createElement("strong")
title.textContent = this.__payload.title
element.append(title)
if (this.__payload.description) {
const description = document.createElement("p")
description.textContent = this.__payload.description
element.append(description)
}
return { element }
}
exportJSON(): SerializedLexicalEmbedNode {
return {
...super.exportJSON(),
embedType: this.__embedType,
payload: this.__payload,
type: "lexical-embed",
version: 1,
}
}
decorate(): ReactNode {
return (
<LexicalEmbed
embedType={this.__embedType}
payload={this.__payload}
nodeKey={this.__key}
/>
)
}
}
function LexicalEmbed({
embedType,
payload,
nodeKey,
}: {
embedType: string
payload: LexicalEmbedPayload
nodeKey: NodeKey
}) {
const definitions = React.useContext(LexicalEmbedContext)
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey)
const definition = definitions.get(embedType)
const Render = definition?.render
React.useEffect(
() =>
mergeRegister(
editor.registerCommand(
KEY_DELETE_COMMAND,
(event) => {
if (!isSelected) return false
event.preventDefault()
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalEmbedNode(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 ($isLexicalEmbedNode(node)) node.remove()
})
return true
},
COMMAND_PRIORITY_LOW
)
),
[editor, isSelected, nodeKey]
)
return (
<div
contentEditable={false}
data-lexical-embed-type={embedType}
className={cn(
"my-3 rounded-xl",
isSelected && "ring-2 ring-primary ring-offset-2 ring-offset-background"
)}
onClick={(event) => {
if (!event.shiftKey) clearSelection()
setSelected(event.shiftKey ? !isSelected : true)
}}
>
{Render ? <Render payload={payload} /> : <EmbedFallback {...payload} />}
</div>
)
}
function EmbedFallback({ title, description, imageUrl }: LexicalEmbedPayload) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-muted/30 p-3">
{imageUrl && (
<img
src={imageUrl}
alt=""
className="size-12 shrink-0 rounded-lg object-cover"
/>
)}
<div className="min-w-0">
<p className="truncate font-medium">{title}</p>
{description && (
<p className="truncate text-sm text-muted-foreground">
{description}
</p>
)}
</div>
</div>
)
}
function isLexicalEmbedPayload(value: unknown): value is LexicalEmbedPayload {
if (!value || typeof value !== "object") return false
const payload = value as Partial<LexicalEmbedPayload>
return typeof payload.id === "string" && typeof payload.title === "string"
}
export function $createLexicalEmbedNode(
embedType: string,
payload: LexicalEmbedPayload
) {
return $applyNodeReplacement(new LexicalEmbedNode(embedType, payload))
}
export function $isLexicalEmbedNode(node: unknown): node is LexicalEmbedNode {
return node instanceof LexicalEmbedNode
}