2026-07-31 15:07:09 +08:00
|
|
|
// @vitest-environment jsdom
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
cleanup,
|
|
|
|
|
fireEvent,
|
|
|
|
|
render,
|
|
|
|
|
screen,
|
|
|
|
|
waitFor,
|
2026-07-31 16:03:54 +08:00
|
|
|
within,
|
2026-07-31 15:07:09 +08:00
|
|
|
} 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")
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-31 16:03:54 +08:00
|
|
|
it("reads an image selected from the insert dialog as a Base64 data URL", async () => {
|
|
|
|
|
const { container } = render(
|
|
|
|
|
<LexicalRoot value="<p>before</p>" onChange={() => undefined}>
|
|
|
|
|
<LexicalActions>
|
|
|
|
|
<Image />
|
|
|
|
|
</LexicalActions>
|
|
|
|
|
<LexicalFixedToolbar />
|
|
|
|
|
<LexicalContent />
|
|
|
|
|
</LexicalRoot>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Insert image" }))
|
|
|
|
|
const dialog = await screen.findByRole("dialog")
|
|
|
|
|
|
|
|
|
|
const file = new File([new Uint8Array([137, 80, 78, 71])], "local.png", {
|
|
|
|
|
type: "image/png",
|
|
|
|
|
})
|
|
|
|
|
fireEvent.change(within(dialog).getByLabelText("Image file"), {
|
|
|
|
|
target: { files: [file] },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const urlInput = within(dialog).getByRole("textbox", {
|
|
|
|
|
name: "Image URL",
|
|
|
|
|
})
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect((urlInput as HTMLInputElement).value).toMatch(
|
|
|
|
|
/^data:image\/png;base64,/
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
fireEvent.change(
|
|
|
|
|
within(dialog).getByRole("textbox", { name: "Alternative text" }),
|
|
|
|
|
{
|
|
|
|
|
target: { value: "Local image" },
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
fireEvent.submit(dialog.querySelector("form")!)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByRole("img", { name: "Local image" }).getAttribute("src")
|
|
|
|
|
).toMatch(/^data:image\/png;base64,/)
|
|
|
|
|
})
|
|
|
|
|
expect(container.querySelector("figure")).not.toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-31 15:07:09 +08:00
|
|
|
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")
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|