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,227 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
import {
Date,
defineLexicalAction,
DraggableBlocks,
Image,
LexicalActions,
LexicalContent,
LexicalFixedToolbar,
LexicalRoot,
Link,
Video,
} from "."
afterEach(cleanup)
function RegisteredPlugin() {
return <span data-testid="registered-plugin">registered</span>
}
const RegisteredCapability = defineLexicalAction({
name: "registered-capability",
label: "Registered capability",
hidden: true,
plugins: [RegisteredPlugin],
execute: () => undefined,
})
describe("Lexical advanced actions", () => {
it("registers non-visual plugins through an action declaration", () => {
render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<RegisteredCapability />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
expect(screen.getByTestId("registered-plugin").textContent).toBe(
"registered"
)
})
it("registers draggable blocks declaratively", async () => {
const { container } = render(
<LexicalRoot value="<p>可拖动内容</p>" onChange={() => undefined}>
<LexicalActions>
<DraggableBlocks />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
await waitFor(() => {
expect(
container
.querySelector("[contenteditable=true]")
?.classList.contains("lexical-draggable-content")
).toBe(true)
})
})
it("registers link nodes and plugins through the Link action", async () => {
const { container } = render(
<LexicalRoot
value='<p><a href="https://lexical.dev/">Lexical</a></p>'
onChange={() => undefined}
>
<LexicalActions>
<Link />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const link = await waitFor(() => {
const element = container.querySelector("a")
expect(element).not.toBeNull()
return element!
})
Object.defineProperty(link, "getBoundingClientRect", {
value: () => ({
bottom: 80,
height: 20,
left: 20,
right: 120,
top: 60,
width: 100,
x: 20,
y: 60,
toJSON: () => undefined,
}),
})
fireEvent.click(link)
expect(
await screen.findByRole("button", { name: "Edit link" })
).not.toBeNull()
})
it("registers the Date node and popover through the Date action", async () => {
const { container } = render(
<LexicalRoot
value='<p><time datetime="2026-07-30" data-lexical-date="2026-07-30">July 30, 2026</time></p>'
onChange={() => undefined}
>
<LexicalActions>
<Date />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const date = await waitFor(() => {
const element = container.querySelector<HTMLElement>(
"[data-lexical-date]"
)
expect(element).not.toBeNull()
return element!
})
fireEvent.click(date)
expect(await screen.findByRole("grid")).not.toBeNull()
})
it("lets a custom Image control insert and operate on an image", async () => {
const { container } = render(
<LexicalRoot value="<p>before</p>" onChange={() => undefined}>
<LexicalActions>
<Image>
{({ execute }) => (
<button
type="button"
onClick={() =>
execute({
alt: "Custom image",
caption: "Uploaded externally",
src: "/custom-image.png",
})
}
>
Upload image
</button>
)}
</Image>
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
)
fireEvent.click(screen.getByRole("button", { name: "Upload image" }))
const image = await screen.findByRole("img", { name: "Custom image" })
expect(image.getAttribute("src")).toBe("/custom-image.png")
expect(screen.getByText("Uploaded externally")).not.toBeNull()
fireEvent.click(image)
await waitFor(() => {
expect(
container.querySelectorAll("[data-lexical-image-resize-handle]")
).toHaveLength(8)
})
fireEvent.click(screen.getByRole("button", { name: "Align image right" }))
await waitFor(() => {
expect(image.closest("figure")?.classList.contains("items-end")).toBe(
true
)
})
expect(screen.getByRole("button", { name: "Delete image" })).not.toBeNull()
fireEvent.click(screen.getByRole("button", { name: "Edit caption" }))
const caption = screen.getByRole("textbox", { name: "Caption" })
fireEvent.change(caption, { target: { value: "Updated caption" } })
expect((caption as HTMLTextAreaElement).value).toBe("Updated caption")
})
it("lets a custom Video control insert a video through execute", async () => {
const { container } = render(
<LexicalRoot value="<p>before</p>" onChange={() => undefined}>
<LexicalActions>
<Video>
{({ execute }) => (
<button
type="button"
onClick={() =>
execute({
caption: "Uploaded externally",
poster: "/custom-poster.png",
src: "/custom-video.mp4",
})
}
>
Upload video
</button>
)}
</Video>
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
)
fireEvent.click(screen.getByRole("button", { name: "Upload video" }))
await waitFor(() => {
const video = container.querySelector("video")
expect(video?.getAttribute("src")).toBe("/custom-video.mp4")
expect(video?.getAttribute("poster")).toBe("/custom-poster.png")
})
})
})