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