Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
import { describe, it, expect } from "vitest";
import {
extractHeadings,
findCurrentHeading,
type HeadingItem,
} from "../../src/components/editor/DocumentOutline";
/**
* Create a mock editor with a document containing the specified headings
*/
function createMockEditor(headings: Array<{ level: number; text: string; pos: number }>) {
const mockDoc = {
descendants: (
callback: (
node: { type: { name: string }; attrs: { level: number }; textContent: string },
pos: number,
) => void,
) => {
for (const heading of headings) {
callback(
{
type: { name: "heading" },
attrs: { level: heading.level },
textContent: heading.text,
},
heading.pos,
);
}
},
};
return {
state: {
doc: mockDoc,
},
} as unknown as Parameters<typeof extractHeadings>[0];
}
describe("DocumentOutline", () => {
describe("extractHeadings", () => {
it("returns empty array when editor is null", () => {
const result = extractHeadings(null);
expect(result).toEqual([]);
});
it("extracts headings from editor document", () => {
const editor = createMockEditor([
{ level: 1, text: "Main Title", pos: 0 },
{ level: 2, text: "Section One", pos: 50 },
{ level: 3, text: "Subsection", pos: 100 },
]);
const result = extractHeadings(editor);
expect(result).toHaveLength(3);
expect(result[0]).toMatchObject({
level: 1,
text: "Main Title",
pos: 0,
});
expect(result[1]).toMatchObject({
level: 2,
text: "Section One",
pos: 50,
});
expect(result[2]).toMatchObject({
level: 3,
text: "Subsection",
pos: 100,
});
});
it("skips headings with empty text", () => {
const editor = createMockEditor([
{ level: 1, text: "Title", pos: 0 },
{ level: 2, text: "", pos: 50 },
{ level: 2, text: " ", pos: 100 },
{ level: 2, text: "Valid", pos: 150 },
]);
const result = extractHeadings(editor);
expect(result).toHaveLength(2);
expect(result[0]?.text).toBe("Title");
expect(result[1]?.text).toBe("Valid");
});
it("assigns unique keys to headings", () => {
const editor = createMockEditor([
{ level: 1, text: "Title", pos: 0 },
{ level: 2, text: "Section", pos: 50 },
]);
const result = extractHeadings(editor);
expect(result[0]?.key).toBeDefined();
expect(result[1]?.key).toBeDefined();
expect(result[0]?.key).not.toBe(result[1]?.key);
});
});
describe("findCurrentHeading", () => {
const headings: HeadingItem[] = [
{ level: 1, text: "Title", pos: 0, key: "h1" },
{ level: 2, text: "Section One", pos: 100, key: "h2" },
{ level: 2, text: "Section Two", pos: 200, key: "h3" },
{ level: 3, text: "Subsection", pos: 300, key: "h4" },
];
it("returns null for empty headings array", () => {
const result = findCurrentHeading([], 50);
expect(result).toBeNull();
});
it("returns null when cursor is before first heading", () => {
const headingsWithOffset: HeadingItem[] = [{ level: 1, text: "Title", pos: 100, key: "h1" }];
const result = findCurrentHeading(headingsWithOffset, 50);
expect(result).toBeNull();
});
it("returns first heading when cursor is at its position", () => {
const result = findCurrentHeading(headings, 0);
expect(result?.key).toBe("h1");
});
it("returns heading that contains cursor position", () => {
const result = findCurrentHeading(headings, 150);
expect(result?.key).toBe("h2");
});
it("returns last heading when cursor is past all headings", () => {
const result = findCurrentHeading(headings, 500);
expect(result?.key).toBe("h4");
});
it("returns heading when cursor is exactly at heading position", () => {
const result = findCurrentHeading(headings, 200);
expect(result?.key).toBe("h3");
});
});
describe("indentation", () => {
it("correctly structures heading levels", () => {
const editor = createMockEditor([
{ level: 1, text: "H1", pos: 0 },
{ level: 2, text: "H2", pos: 50 },
{ level: 3, text: "H3", pos: 100 },
]);
const result = extractHeadings(editor);
// H1 should be at root level
expect(result[0]?.level).toBe(1);
// H2 should be indented (level 2)
expect(result[1]?.level).toBe(2);
// H3 should be further indented (level 3)
expect(result[2]?.level).toBe(3);
});
});
});

View File

@@ -0,0 +1,956 @@
/**
* PortableTextEditor component tests.
*
* Tests the TipTap-based rich text editor in vitest browser mode,
* covering Portable Text ↔ ProseMirror round-trip conversion,
* toolbar behaviour, focus modes, and editor lifecycle.
*/
import type { Editor } from "@tiptap/react";
import * as React from "react";
import { describe, it, expect, vi } from "vitest";
import type { PluginBlockDef } from "../../src/components/PortableTextEditor";
import {
_buildPluginBlockFormValues,
_hasPluginBlockFormData,
PortableTextEditor,
} from "../../src/components/PortableTextEditor";
import { render } from "../utils/render";
// ---------------------------------------------------------------------------
// Mocks — heavy components that need network / Astro context
// ---------------------------------------------------------------------------
vi.mock("../../src/components/MediaPickerModal", () => ({
MediaPickerModal: () => null,
}));
vi.mock("../../src/components/SectionPickerModal", () => ({
SectionPickerModal: () => null,
}));
vi.mock("../../src/components/editor/DragHandleWrapper", () => ({
DragHandleWrapper: () => null,
}));
vi.mock("../../src/components/editor/ImageNode", async () => {
const { Node } = await import("@tiptap/core");
const ImageExtension = Node.create({
name: "image",
group: "block",
atom: true,
addAttributes() {
return {
src: { default: null },
alt: { default: "" },
title: { default: "" },
caption: { default: "" },
mediaId: { default: null },
provider: { default: "local" },
width: { default: null },
height: { default: null },
displayWidth: { default: null },
displayHeight: { default: null },
};
},
parseHTML() {
return [{ tag: "img[src]" }];
},
renderHTML({ HTMLAttributes }) {
return ["img", HTMLAttributes];
},
});
return { ImageExtension };
});
vi.mock("../../src/components/editor/PluginBlockNode", async () => {
const { Node } = await import("@tiptap/core");
const PluginBlockExtension = Node.create({
name: "pluginBlock",
group: "block",
atom: true,
addAttributes() {
return {
blockType: { default: "embed" },
id: { default: "" },
data: { default: {} },
};
},
parseHTML() {
return [{ tag: "div[data-plugin-block]" }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { ...HTMLAttributes, "data-plugin-block": "" }];
},
});
return {
PluginBlockExtension,
getEmbedMeta: () => ({ label: "Embed", Icon: () => null }),
registerPluginBlocks: () => {},
resolveIcon: () => () => null,
};
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const SPOTLIGHT_MODE_PATTERN = /Spotlight Mode/i;
/** Wait for the ProseMirror editor to mount inside the container */
async function waitForEditor(): Promise<HTMLElement> {
let pm: HTMLElement | null = null;
await vi.waitFor(
() => {
pm = document.querySelector(".ProseMirror") as HTMLElement | null;
expect(pm).toBeTruthy();
},
{ timeout: 3000 },
);
return pm!;
}
/** Focus the ProseMirror contenteditable and wait for it to be focused */
async function focusEditor(pm: HTMLElement) {
pm.focus();
await vi.waitFor(() => expect(document.activeElement).toBe(pm), { timeout: 1000 });
}
/**
* Render the editor, wait for it to initialize, and return the Editor instance.
* Useful for tests that need to type or manipulate content programmatically.
*/
async function renderAndGetEditor(props: Partial<Parameters<typeof PortableTextEditor>[0]> = {}) {
let capturedEditor: Editor | null = null;
const screen = await render(
<PortableTextEditor
onEditorReady={(editor) => {
capturedEditor = editor;
}}
{...props}
/>,
);
const pm = await waitForEditor();
await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 });
return { screen, editor: capturedEditor!, pm };
}
/**
* Simulate typing text into the editor via TipTap's API.
* This avoids browser keyboard API issues and is more reliable in tests.
*/
function typeIntoEditor(editor: Editor, text: string) {
editor.chain().focus().insertContent(text).run();
}
// Shorthand block builders
function textBlock(
text: string,
opts: {
style?: "normal" | "h1" | "h2" | "h3" | "blockquote";
marks?: string[];
listItem?: "bullet" | "number";
level?: number;
markDefs?: Array<{ _type: string; _key: string; [k: string]: unknown }>;
} = {},
) {
return {
_type: "block" as const,
_key: Math.random().toString(36).slice(2, 9),
style: opts.style ?? "normal",
...(opts.listItem ? { listItem: opts.listItem, level: opts.level ?? 1 } : {}),
children: [
{
_type: "span" as const,
_key: Math.random().toString(36).slice(2, 9),
text,
marks: opts.marks,
},
],
markDefs: opts.markDefs,
};
}
// =============================================================================
// 1. Plugin block helpers
// =============================================================================
describe("plugin block helpers", () => {
it("builds form state from field initial_value defaults", () => {
const block: PluginBlockDef = {
type: "readingTime",
pluginId: "reading-time",
label: "Reading Time",
fields: [
{
type: "select",
action_id: "variant",
label: "Style",
options: [
{ label: "Inline", value: "inline" },
{ label: "Compact", value: "compact" },
],
initial_value: "inline",
},
{
type: "toggle",
action_id: "includeHeadings",
label: "Include headings",
initial_value: true,
},
],
};
expect(_buildPluginBlockFormValues(block)).toEqual({
variant: "inline",
includeHeadings: true,
});
expect(_hasPluginBlockFormData(_buildPluginBlockFormValues(block))).toBe(true);
});
it("merges existing block data over defaults when editing", () => {
const block: PluginBlockDef = {
type: "readingTime",
pluginId: "reading-time",
label: "Reading Time",
fields: [
{
type: "select",
action_id: "variant",
label: "Style",
options: [
{ label: "Inline", value: "inline" },
{ label: "Compact", value: "compact" },
],
initial_value: "inline",
},
{
type: "toggle",
action_id: "includeHeadings",
label: "Include headings",
initial_value: true,
},
],
};
expect(
_buildPluginBlockFormValues(block, {
variant: "compact",
customLabel: "Custom label",
includeHeadings: false,
}),
).toEqual({
variant: "compact",
customLabel: "Custom label",
includeHeadings: false,
});
});
it("keeps explicit existing values over defaults", () => {
const block: PluginBlockDef = {
type: "readingTime",
pluginId: "reading-time",
label: "Reading Time",
fields: [
{
type: "number_input",
action_id: "minutes",
label: "Minutes",
initial_value: 5,
},
{
type: "text_input",
action_id: "label",
label: "Label",
initial_value: "Default label",
},
{
type: "toggle",
action_id: "includeHeadings",
label: "Include headings",
initial_value: true,
},
],
};
expect(
_buildPluginBlockFormValues(block, {
minutes: 0,
label: "",
includeHeadings: false,
}),
).toEqual({
minutes: 0,
label: "",
includeHeadings: false,
});
});
});
// =============================================================================
// 2. Portable Text ↔ ProseMirror Conversion (via component)
// =============================================================================
describe("Portable Text ↔ ProseMirror conversion", () => {
it("renders a paragraph from PT value", async () => {
await render(<PortableTextEditor value={[textBlock("Hello world")]} />);
const pm = await waitForEditor();
const p = pm.querySelector("p");
expect(p).toBeTruthy();
expect(p!.textContent).toBe("Hello world");
});
it("renders an h1 heading", async () => {
await render(<PortableTextEditor value={[textBlock("Title", { style: "h1" })]} />);
const pm = await waitForEditor();
const h1 = pm.querySelector("h1");
expect(h1).toBeTruthy();
expect(h1!.textContent).toBe("Title");
});
it("renders bold text", async () => {
await render(<PortableTextEditor value={[textBlock("Bold text", { marks: ["strong"] })]} />);
const pm = await waitForEditor();
const strong = pm.querySelector("strong");
expect(strong).toBeTruthy();
expect(strong!.textContent).toBe("Bold text");
});
it("renders a link from markDef", async () => {
const linkKey = "lnk1";
await render(
<PortableTextEditor
value={[
textBlock("Click me", {
marks: [linkKey],
markDefs: [{ _type: "link", _key: linkKey, href: "https://example.com" }],
}),
]}
/>,
);
const pm = await waitForEditor();
const anchor = pm.querySelector("a");
expect(anchor).toBeTruthy();
expect(anchor!.textContent).toBe("Click me");
expect(anchor!.getAttribute("href")).toBe("https://example.com");
});
it("renders a bullet list", async () => {
await render(
<PortableTextEditor
value={[
textBlock("Item one", { listItem: "bullet" }),
textBlock("Item two", { listItem: "bullet" }),
]}
/>,
);
const pm = await waitForEditor();
const ul = pm.querySelector("ul");
expect(ul).toBeTruthy();
const items = ul!.querySelectorAll("li");
expect(items.length).toBe(2);
expect(items[0]!.textContent).toBe("Item one");
expect(items[1]!.textContent).toBe("Item two");
});
it("renders an ordered list", async () => {
await render(
<PortableTextEditor
value={[
textBlock("First", { listItem: "number" }),
textBlock("Second", { listItem: "number" }),
]}
/>,
);
const pm = await waitForEditor();
const ol = pm.querySelector("ol");
expect(ol).toBeTruthy();
const items = ol!.querySelectorAll("li");
expect(items.length).toBe(2);
});
it("renders a blockquote", async () => {
await render(
<PortableTextEditor value={[textBlock("A wise quote", { style: "blockquote" })]} />,
);
const pm = await waitForEditor();
const bq = pm.querySelector("blockquote");
expect(bq).toBeTruthy();
expect(bq!.textContent).toBe("A wise quote");
});
it("renders a code block", async () => {
await render(
<PortableTextEditor
value={[{ _type: "code", _key: "c1", code: "const x = 1", language: "js" }]}
/>,
);
const pm = await waitForEditor();
const pre = pm.querySelector("pre");
expect(pre).toBeTruthy();
expect(pre!.textContent).toContain("const x = 1");
});
it("renders an image block", async () => {
await render(
<PortableTextEditor
value={[
{
_type: "image",
_key: "img1",
asset: { _ref: "img-1", url: "/test.jpg" },
alt: "Test image",
},
]}
/>,
);
const pm = await waitForEditor();
// The mock ImageExtension renders as <img>
const img = pm.querySelector("img");
expect(img).toBeTruthy();
expect(img!.getAttribute("src")).toBe("/test.jpg");
});
it("renders a horizontal rule", async () => {
await render(
<PortableTextEditor
value={[
textBlock("Above"),
{ _type: "break", _key: "hr1", style: "lineBreak" },
textBlock("Below"),
]}
/>,
);
const pm = await waitForEditor();
const hr = pm.querySelector("hr");
expect(hr).toBeTruthy();
});
it("renders empty editor when value is empty array", async () => {
await render(<PortableTextEditor value={[]} placeholder="Write here..." />);
const pm = await waitForEditor();
// Empty editor should have a single empty paragraph
const paragraphs = pm.querySelectorAll("p");
expect(paragraphs.length).toBeGreaterThanOrEqual(1);
// Placeholder should appear
expect(pm.textContent).toBe("");
});
it("renders empty editor when value is undefined", async () => {
await render(<PortableTextEditor placeholder="Start..." />);
const pm = await waitForEditor();
expect(pm).toBeTruthy();
// Empty editor — no meaningful text
const textContent = pm.textContent ?? "";
expect(textContent.trim()).toBe("");
});
it("renders bold+italic text with multiple marks", async () => {
await render(
<PortableTextEditor value={[textBlock("Bold italic", { marks: ["strong", "em"] })]} />,
);
const pm = await waitForEditor();
const strong = pm.querySelector("strong");
const em = pm.querySelector("em");
expect(strong).toBeTruthy();
expect(em).toBeTruthy();
// The text is wrapped in both marks
expect(pm.textContent).toContain("Bold italic");
});
it("fires onChange with valid PT blocks when typing", async () => {
const onChange = vi.fn();
const { editor } = await renderAndGetEditor({ onChange });
typeIntoEditor(editor, "Hello");
await vi.waitFor(
() => {
expect(onChange).toHaveBeenCalled();
},
{ timeout: 2000 },
);
const lastCall = onChange.mock.calls.at(-1)!;
const blocks = lastCall[0] as Array<{ _type: string }>;
expect(blocks.length).toBeGreaterThan(0);
expect(blocks[0]!._type).toBe("block");
});
});
// =============================================================================
// 2. Editor Component Behaviour
// =============================================================================
describe("Editor component behaviour", () => {
it("shows placeholder text in empty editor", async () => {
await render(<PortableTextEditor placeholder="Write something..." />);
const pm = await waitForEditor();
// TipTap sets placeholder via data-placeholder or a .is-empty class
// Check for the placeholder content in a before pseudo-element or attribute
const placeholderEl = pm.querySelector("[data-placeholder]");
if (placeholderEl) {
expect(placeholderEl.getAttribute("data-placeholder")).toBe("Write something...");
} else {
// Fallback: check the class-based placeholder
const emptyNode = pm.querySelector(".is-empty, .is-editor-empty");
expect(emptyNode).toBeTruthy();
}
});
it("sets contenteditable=false when editable is false", async () => {
await render(<PortableTextEditor editable={false} value={[textBlock("Read only")]} />);
const pm = await waitForEditor();
expect(pm.getAttribute("contenteditable")).toBe("false");
});
it("sets contenteditable=true by default", async () => {
await render(<PortableTextEditor value={[textBlock("Editable")]} />);
const pm = await waitForEditor();
expect(pm.getAttribute("contenteditable")).toBe("true");
});
it("applies spotlight-mode class when focusMode is spotlight", async () => {
await render(<PortableTextEditor focusMode="spotlight" value={[textBlock("Focused")]} />);
await waitForEditor();
const wrapper = document.querySelector(".spotlight-mode");
expect(wrapper).toBeTruthy();
});
it("does not apply spotlight-mode class when focusMode is normal", async () => {
await render(<PortableTextEditor focusMode="normal" value={[textBlock("Normal")]} />);
await waitForEditor();
const wrapper = document.querySelector(".spotlight-mode");
expect(wrapper).toBeNull();
});
it("calls onFocusModeChange when spotlight button is clicked", async () => {
const onFocusModeChange = vi.fn();
const screen = await render(
<PortableTextEditor
focusMode="normal"
onFocusModeChange={onFocusModeChange}
value={[textBlock("Test")]}
/>,
);
await waitForEditor();
// The spotlight button has aria-label containing "Spotlight Mode"
const spotlightBtn = screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN });
await spotlightBtn.click();
expect(onFocusModeChange).toHaveBeenCalledWith("spotlight");
});
it("hides toolbar and footer in minimal mode", async () => {
await render(<PortableTextEditor minimal={true} value={[textBlock("Minimal")]} />);
await waitForEditor();
// Toolbar has role="toolbar" — should not exist
const toolbar = document.querySelector('[role="toolbar"]');
expect(toolbar).toBeNull();
// Footer shows word count — should not exist
const footer = document.querySelector(".border-t");
expect(footer).toBeNull();
});
it("calls onEditorReady with Editor instance", async () => {
const onEditorReady = vi.fn();
await render(<PortableTextEditor onEditorReady={onEditorReady} value={[textBlock("Ready")]} />);
await waitForEditor();
await vi.waitFor(() => expect(onEditorReady).toHaveBeenCalledTimes(1), { timeout: 2000 });
const editorArg = onEditorReady.mock.calls[0]![0] as Editor;
expect(editorArg).toBeTruthy();
expect(typeof editorArg.getJSON).toBe("function");
expect(typeof editorArg.chain).toBe("function");
});
it("calls onEditorReady with null on unmount so consumers can clear stale references", async () => {
// Without this cleanup, ContentEditor's `portableTextEditor` slot keeps
// pointing at a destroyed TipTap instance during the brief remount window
// when switching translations (FieldRenderer is re-keyed by item.id),
// causing DocumentOutline to render against a destroyed editor.
const onEditorReady = vi.fn();
const screen = await render(
<PortableTextEditor onEditorReady={onEditorReady} value={[textBlock("Mount/unmount")]} />,
);
await waitForEditor();
await vi.waitFor(() => expect(onEditorReady).toHaveBeenCalledTimes(1), { timeout: 2000 });
expect(onEditorReady.mock.calls[0]![0]).toBeTruthy();
// Unmount and verify the cleanup fires onEditorReady(null).
await screen.unmount();
await vi.waitFor(() => expect(onEditorReady).toHaveBeenCalledTimes(2), { timeout: 2000 });
expect(onEditorReady.mock.calls[1]![0]).toBeNull();
});
it("shows word count and character count in footer", async () => {
await render(<PortableTextEditor value={[textBlock("One two three")]} />);
await waitForEditor();
await vi.waitFor(
() => {
const text = document.body.textContent ?? "";
expect(text).toContain("words");
expect(text).toContain("characters");
expect(text).toContain("min read");
},
{ timeout: 2000 },
);
});
});
// =============================================================================
// 3. Toolbar
// =============================================================================
describe("Toolbar", () => {
async function renderWithToolbar() {
const screen = await render(<PortableTextEditor value={[textBlock("Toolbar test")]} />);
await waitForEditor();
return screen;
}
it("renders a toolbar with text formatting aria-label", async () => {
const screen = await renderWithToolbar();
const toolbar = screen.getByRole("toolbar");
await expect.element(toolbar).toHaveAttribute("aria-label", "Text formatting");
});
it("has inline formatting buttons", async () => {
const screen = await renderWithToolbar();
await expect.element(screen.getByRole("button", { name: "Bold" })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Italic" })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Underline" })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Strikethrough" })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Inline Code" })).toBeInTheDocument();
});
it("has heading buttons", async () => {
const screen = await renderWithToolbar();
await expect.element(screen.getByRole("button", { name: "Heading 1" })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Heading 2" })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Heading 3" })).toBeInTheDocument();
});
it("has list buttons", async () => {
const screen = await renderWithToolbar();
await expect.element(screen.getByRole("button", { name: "Bullet List" })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Numbered List" })).toBeInTheDocument();
});
it("has block buttons", async () => {
const screen = await renderWithToolbar();
await expect.element(screen.getByRole("button", { name: "Quote" })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Code Block" })).toBeInTheDocument();
});
it("has alignment buttons", async () => {
const screen = await renderWithToolbar();
await expect.element(screen.getByRole("button", { name: "Align Left" })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Align Center" })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Align Right" })).toBeInTheDocument();
});
it("has insert buttons", async () => {
const screen = await renderWithToolbar();
await expect.element(screen.getByRole("button", { name: "Insert Link" })).toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Insert Image" })).toBeInTheDocument();
await expect
.element(screen.getByRole("button", { name: "Insert Horizontal Rule" }))
.toBeInTheDocument();
});
it("has history buttons (initially disabled)", async () => {
const screen = await renderWithToolbar();
const undoBtn = screen.getByRole("button", { name: "Undo" });
const redoBtn = screen.getByRole("button", { name: "Redo" });
await expect.element(undoBtn).toBeInTheDocument();
await expect.element(redoBtn).toBeInTheDocument();
await expect.element(undoBtn).toBeDisabled();
await expect.element(redoBtn).toBeDisabled();
});
it("has spotlight mode button", async () => {
const screen = await renderWithToolbar();
await expect
.element(screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN }))
.toBeInTheDocument();
});
it("toggles bold aria-pressed when clicked", async () => {
const screen = await renderWithToolbar();
const pm = document.querySelector(".ProseMirror") as HTMLElement;
await focusEditor(pm);
const boldBtn = screen.getByRole("button", { name: "Bold" });
await expect.element(boldBtn).toHaveAttribute("aria-pressed", "false");
await boldBtn.click();
await vi.waitFor(
async () => {
await expect.element(boldBtn).toHaveAttribute("aria-pressed", "true");
},
{ timeout: 2000 },
);
});
it("toggles italic aria-pressed when clicked", async () => {
const screen = await renderWithToolbar();
const pm = document.querySelector(".ProseMirror") as HTMLElement;
await focusEditor(pm);
const italicBtn = screen.getByRole("button", { name: "Italic" });
await expect.element(italicBtn).toHaveAttribute("aria-pressed", "false");
await italicBtn.click();
await vi.waitFor(
async () => {
await expect.element(italicBtn).toHaveAttribute("aria-pressed", "true");
},
{ timeout: 2000 },
);
});
it("toggles Heading 1 aria-pressed when clicked", async () => {
const screen = await renderWithToolbar();
const pm = document.querySelector(".ProseMirror") as HTMLElement;
await focusEditor(pm);
const h1Btn = screen.getByRole("button", { name: "Heading 1" });
await expect.element(h1Btn).toHaveAttribute("aria-pressed", "false");
await h1Btn.click();
await vi.waitFor(
async () => {
await expect.element(h1Btn).toHaveAttribute("aria-pressed", "true");
},
{ timeout: 2000 },
);
});
it("enables Undo after typing and Redo after undoing", async () => {
let editorRef: Editor | null = null;
const screen = await render(
<PortableTextEditor
value={[textBlock("Toolbar test")]}
onEditorReady={(editor) => {
editorRef = editor;
}}
/>,
);
await waitForEditor();
await vi.waitFor(() => expect(editorRef).toBeTruthy(), { timeout: 2000 });
const undoBtn = screen.getByRole("button", { name: "Undo" });
const redoBtn = screen.getByRole("button", { name: "Redo" });
// Initially both disabled
await expect.element(undoBtn).toBeDisabled();
await expect.element(redoBtn).toBeDisabled();
// Type something via editor API
typeIntoEditor(editorRef!, "Some text");
// Undo should become enabled
await vi.waitFor(
async () => {
await expect.element(undoBtn).toBeEnabled();
},
{ timeout: 2000 },
);
// Click undo
await undoBtn.click();
// Redo should become enabled
await vi.waitFor(
async () => {
await expect.element(redoBtn).toBeEnabled();
},
{ timeout: 2000 },
);
});
it("toggles spotlight mode button aria-pressed", async () => {
const onFocusModeChange = vi.fn();
const screen = await render(
<PortableTextEditor
focusMode="normal"
onFocusModeChange={onFocusModeChange}
value={[textBlock("Test")]}
/>,
);
await waitForEditor();
const btn = screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN });
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
});
it("spotlight button shows pressed when focusMode is spotlight", async () => {
const screen = await render(
<PortableTextEditor
focusMode="spotlight"
onFocusModeChange={() => {}}
value={[textBlock("Focused")]}
/>,
);
await waitForEditor();
const btn = screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN });
await expect.element(btn).toHaveAttribute("aria-pressed", "true");
});
it("toolbar not present in minimal mode", async () => {
await render(<PortableTextEditor minimal={true} value={[textBlock("Minimal")]} />);
await waitForEditor();
const toolbar = document.querySelector('[role="toolbar"]');
expect(toolbar).toBeNull();
});
});
// =============================================================================
// 4. Slash Commands
// =============================================================================
describe("Slash commands", () => {
it("renders without errors with default commands", async () => {
await render(<PortableTextEditor value={[textBlock("Slash test")]} />);
const pm = await waitForEditor();
expect(pm).toBeTruthy();
});
it("renders without errors with pluginBlocks prop", async () => {
const pluginBlocks: PluginBlockDef[] = [
{ type: "youtube", pluginId: "embeds", label: "YouTube Video" },
{ type: "tweet", pluginId: "social", label: "Tweet" },
];
await render(
<PortableTextEditor value={[textBlock("Plugin test")]} pluginBlocks={pluginBlocks} />,
);
const pm = await waitForEditor();
expect(pm).toBeTruthy();
});
it("editor accepts pluginBlocks without crashing when typing", async () => {
const pluginBlocks: PluginBlockDef[] = [
{ type: "youtube", pluginId: "embeds", label: "YouTube Video" },
];
const onChange = vi.fn();
const { editor } = await renderAndGetEditor({
pluginBlocks,
onChange,
});
typeIntoEditor(editor, "Hello");
await vi.waitFor(() => expect(onChange).toHaveBeenCalled(), { timeout: 2000 });
});
});
// =============================================================================
// 5. Round-trip: onChange output shape
// =============================================================================
describe("onChange output shape", () => {
it("onChange returns blocks with _type and _key", async () => {
const onChange = vi.fn();
const { editor } = await renderAndGetEditor({ onChange });
typeIntoEditor(editor, "Test");
await vi.waitFor(() => expect(onChange).toHaveBeenCalled(), { timeout: 2000 });
const blocks = onChange.mock.calls.at(-1)![0] as Array<{
_type: string;
_key: string;
children?: Array<{ _type: string; text: string }>;
}>;
expect(blocks.length).toBeGreaterThan(0);
const block = blocks[0]!;
expect(block._type).toBe("block");
expect(typeof block._key).toBe("string");
expect(block.children).toBeDefined();
expect(block.children!.length).toBeGreaterThan(0);
expect(block.children![0]!._type).toBe("span");
expect(block.children![0]!.text).toContain("Test");
});
it("heading value roundtrips through onEditorReady", async () => {
let capturedEditor: Editor | null = null;
const value = [textBlock("My Heading", { style: "h1" })];
await render(
<PortableTextEditor
value={value}
onEditorReady={(editor) => {
capturedEditor = editor;
}}
/>,
);
await waitForEditor();
await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 });
// Verify the editor has a heading node
const json = capturedEditor!.getJSON();
const headingNode = json.content?.find((n: { type: string }) => n.type === "heading");
expect(headingNode).toBeTruthy();
expect((headingNode as { attrs?: { level?: number } }).attrs?.level).toBe(1);
});
it("code block value roundtrips through onEditorReady", async () => {
let capturedEditor: Editor | null = null;
const value = [{ _type: "code" as const, _key: "c1", code: "let a = 1;", language: "js" }];
await render(
<PortableTextEditor
value={value}
onEditorReady={(editor) => {
capturedEditor = editor;
}}
/>,
);
await waitForEditor();
await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 });
const json = capturedEditor!.getJSON();
const codeNode = json.content?.find((n: { type: string }) => n.type === "codeBlock");
expect(codeNode).toBeTruthy();
});
it("list value roundtrips through onEditorReady", async () => {
let capturedEditor: Editor | null = null;
const value = [
textBlock("Alpha", { listItem: "bullet" }),
textBlock("Beta", { listItem: "bullet" }),
];
await render(
<PortableTextEditor
value={value}
onEditorReady={(editor) => {
capturedEditor = editor;
}}
/>,
);
await waitForEditor();
await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 });
const json = capturedEditor!.getJSON();
const listNode = json.content?.find((n: { type: string }) => n.type === "bulletList");
expect(listNode).toBeTruthy();
});
});

View File

@@ -0,0 +1,484 @@
/**
* BlockMenu component tests.
*
* Tests the floating block-level context menu that appears when clicking
* a drag handle. Covers the main menu (Turn into, Duplicate, Delete),
* the "Turn into" submenu with block transforms, Escape to close,
* and click-outside dismissal.
*
* BlockMenu is a standalone component that takes an editor instance,
* an anchor element, and open/close callbacks. It renders via
* createPortal to document.body.
*/
import type { Editor } from "@tiptap/react";
import { userEvent } from "@vitest/browser/context";
import * as React from "react";
import { describe, it, expect, vi } from "vitest";
import { BlockMenu } from "../../src/components/editor/BlockMenu";
import { PortableTextEditor } from "../../src/components/PortableTextEditor";
import { render } from "../utils/render";
// ---------------------------------------------------------------------------
// Mocks — same as other editor tests
// ---------------------------------------------------------------------------
vi.mock("../../src/components/MediaPickerModal", () => ({
MediaPickerModal: () => null,
}));
vi.mock("../../src/components/SectionPickerModal", () => ({
SectionPickerModal: () => null,
}));
vi.mock("../../src/components/editor/DragHandleWrapper", () => ({
DragHandleWrapper: () => null,
}));
vi.mock("../../src/components/editor/ImageNode", async () => {
const { Node } = await import("@tiptap/core");
const ImageExtension = Node.create({
name: "image",
group: "block",
atom: true,
addAttributes() {
return {
src: { default: null },
alt: { default: "" },
title: { default: "" },
caption: { default: "" },
mediaId: { default: null },
provider: { default: "local" },
width: { default: null },
height: { default: null },
displayWidth: { default: null },
displayHeight: { default: null },
};
},
parseHTML() {
return [{ tag: "img[src]" }];
},
renderHTML({ HTMLAttributes }) {
return ["img", HTMLAttributes];
},
});
return { ImageExtension };
});
vi.mock("../../src/components/editor/PluginBlockNode", async () => {
const { Node } = await import("@tiptap/core");
const PluginBlockExtension = Node.create({
name: "pluginBlock",
group: "block",
atom: true,
addAttributes() {
return {
blockType: { default: "embed" },
id: { default: "" },
data: { default: {} },
};
},
parseHTML() {
return [{ tag: "div[data-plugin-block]" }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { ...HTMLAttributes, "data-plugin-block": "" }];
},
});
return {
PluginBlockExtension,
getEmbedMeta: () => ({ label: "Embed", Icon: () => null }),
registerPluginBlocks: () => {},
resolveIcon: () => () => null,
};
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const defaultValue = [
{
_type: "block" as const,
_key: "1",
style: "normal" as const,
children: [{ _type: "span" as const, _key: "s1", text: "First paragraph" }],
},
{
_type: "block" as const,
_key: "2",
style: "normal" as const,
children: [{ _type: "span" as const, _key: "s2", text: "Second paragraph" }],
},
];
/** Render the full editor to get a real TipTap Editor instance */
async function getEditor() {
let editorInstance: Editor | null = null;
await render(
<PortableTextEditor
value={defaultValue}
onEditorReady={(editor) => {
editorInstance = editor;
}}
/>,
);
await vi.waitFor(
() => {
expect(document.querySelector(".ProseMirror")).toBeTruthy();
expect(editorInstance).toBeTruthy();
},
{ timeout: 3000 },
);
const pm = document.querySelector(".ProseMirror") as HTMLElement;
return { editor: editorInstance!, pm };
}
/**
* Wrapper component that renders BlockMenu with an anchor element.
* This is needed because BlockMenu uses useFloating which needs a real DOM element.
*/
function BlockMenuTestWrapper({
editor,
isOpen,
onClose,
}: {
editor: Editor;
isOpen: boolean;
onClose: () => void;
}) {
const anchorRef = React.useRef<HTMLDivElement>(null);
return (
<>
<div ref={anchorRef} data-testid="anchor" style={{ width: 100, height: 20 }}>
Anchor
</div>
<BlockMenu
editor={editor}
anchorElement={anchorRef.current}
isOpen={isOpen}
onClose={onClose}
/>
</>
);
}
/** Get the block menu portal element */
function getBlockMenu(): HTMLElement | null {
const portals = document.querySelectorAll("body > div");
for (const el of portals) {
// The block menu has "Turn into", "Duplicate", "Delete" buttons
if (el.textContent?.includes("Turn into") || el.textContent?.includes("Back")) {
return el as HTMLElement;
}
}
return null;
}
/** Get all text buttons in the menu */
function getMenuButtons(menu: HTMLElement): HTMLButtonElement[] {
return [...menu.querySelectorAll("button")];
}
/** Find a button by its text content */
function findButtonByText(menu: HTMLElement, text: string): HTMLButtonElement | null {
const buttons = getMenuButtons(menu);
return buttons.find((btn) => btn.textContent?.includes(text)) ?? null;
}
// =============================================================================
// BlockMenu — Main Menu
// =============================================================================
describe("BlockMenu", () => {
it("renders nothing when isOpen is false", async () => {
const { editor } = await getEditor();
const onClose = vi.fn();
await render(<BlockMenuTestWrapper editor={editor} isOpen={false} onClose={onClose} />);
expect(getBlockMenu()).toBeNull();
});
it("renders main menu with Turn into, Duplicate, Delete when open", async () => {
const { editor } = await getEditor();
const onClose = vi.fn();
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
await vi.waitFor(() => {
const menu = getBlockMenu();
expect(menu).toBeTruthy();
});
const menu = getBlockMenu()!;
expect(findButtonByText(menu, "Turn into")).toBeTruthy();
expect(findButtonByText(menu, "Duplicate")).toBeTruthy();
expect(findButtonByText(menu, "Delete")).toBeTruthy();
});
it("shows Turn into submenu when Turn into is clicked", async () => {
const { editor } = await getEditor();
const onClose = vi.fn();
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
await vi.waitFor(() => {
expect(getBlockMenu()).toBeTruthy();
});
const menu = getBlockMenu()!;
findButtonByText(menu, "Turn into")!.click();
// Should show transform options
await vi.waitFor(() => {
const updatedMenu = getBlockMenu()!;
expect(findButtonByText(updatedMenu, "Back")).toBeTruthy();
expect(findButtonByText(updatedMenu, "Paragraph")).toBeTruthy();
expect(findButtonByText(updatedMenu, "Heading 1")).toBeTruthy();
expect(findButtonByText(updatedMenu, "Heading 2")).toBeTruthy();
expect(findButtonByText(updatedMenu, "Heading 3")).toBeTruthy();
expect(findButtonByText(updatedMenu, "Quote")).toBeTruthy();
expect(findButtonByText(updatedMenu, "Code Block")).toBeTruthy();
expect(findButtonByText(updatedMenu, "Bullet List")).toBeTruthy();
expect(findButtonByText(updatedMenu, "Numbered List")).toBeTruthy();
});
});
it("returns to main menu when Back is clicked in transform submenu", async () => {
const { editor } = await getEditor();
const onClose = vi.fn();
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
await vi.waitFor(() => {
expect(getBlockMenu()).toBeTruthy();
});
const menu = getBlockMenu()!;
findButtonByText(menu, "Turn into")!.click();
await vi.waitFor(() => {
expect(findButtonByText(getBlockMenu()!, "Back")).toBeTruthy();
});
findButtonByText(getBlockMenu()!, "Back")!.click();
await vi.waitFor(() => {
const mainMenu = getBlockMenu()!;
expect(findButtonByText(mainMenu, "Turn into")).toBeTruthy();
expect(findButtonByText(mainMenu, "Duplicate")).toBeTruthy();
});
});
it("transforms block to heading when Heading 1 is selected", async () => {
const { editor, pm } = await getEditor();
const onClose = vi.fn();
// Focus editor on first paragraph
editor.commands.focus("start");
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
await vi.waitFor(() => {
expect(getBlockMenu()).toBeTruthy();
});
// Open transforms
findButtonByText(getBlockMenu()!, "Turn into")!.click();
await vi.waitFor(() => {
expect(findButtonByText(getBlockMenu()!, "Heading 1")).toBeTruthy();
});
findButtonByText(getBlockMenu()!, "Heading 1")!.click();
// Should close menu and transform block
expect(onClose).toHaveBeenCalled();
await vi.waitFor(() => {
expect(pm.querySelector("h1")).toBeTruthy();
});
});
it("transforms block to blockquote", async () => {
const { editor, pm } = await getEditor();
const onClose = vi.fn();
editor.commands.focus("start");
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
await vi.waitFor(() => {
expect(getBlockMenu()).toBeTruthy();
});
findButtonByText(getBlockMenu()!, "Turn into")!.click();
await vi.waitFor(() => {
expect(findButtonByText(getBlockMenu()!, "Quote")).toBeTruthy();
});
findButtonByText(getBlockMenu()!, "Quote")!.click();
expect(onClose).toHaveBeenCalled();
await vi.waitFor(() => {
expect(pm.querySelector("blockquote")).toBeTruthy();
});
});
it("transforms block to code block", async () => {
const { editor, pm } = await getEditor();
const onClose = vi.fn();
editor.commands.focus("start");
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
await vi.waitFor(() => {
expect(getBlockMenu()).toBeTruthy();
});
findButtonByText(getBlockMenu()!, "Turn into")!.click();
await vi.waitFor(() => {
expect(findButtonByText(getBlockMenu()!, "Code Block")).toBeTruthy();
});
findButtonByText(getBlockMenu()!, "Code Block")!.click();
expect(onClose).toHaveBeenCalled();
await vi.waitFor(() => {
expect(pm.querySelector("pre")).toBeTruthy();
});
});
it("transforms block to bullet list", async () => {
const { editor, pm } = await getEditor();
const onClose = vi.fn();
editor.commands.focus("start");
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
await vi.waitFor(() => {
expect(getBlockMenu()).toBeTruthy();
});
findButtonByText(getBlockMenu()!, "Turn into")!.click();
await vi.waitFor(() => {
expect(findButtonByText(getBlockMenu()!, "Bullet List")).toBeTruthy();
});
findButtonByText(getBlockMenu()!, "Bullet List")!.click();
expect(onClose).toHaveBeenCalled();
await vi.waitFor(() => {
expect(pm.querySelector("ul")).toBeTruthy();
});
});
it("deletes the current block when Delete is clicked", async () => {
const { editor, pm } = await getEditor();
const onClose = vi.fn();
// Focus on first paragraph
editor.commands.focus("start");
// Count initial paragraphs
const initialParagraphs = pm.querySelectorAll("p").length;
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
await vi.waitFor(() => {
expect(getBlockMenu()).toBeTruthy();
});
findButtonByText(getBlockMenu()!, "Delete")!.click();
expect(onClose).toHaveBeenCalled();
// Should have one fewer paragraph
await vi.waitFor(() => {
const newParagraphs = pm.querySelectorAll("p").length;
expect(newParagraphs).toBeLessThan(initialParagraphs);
});
});
it("duplicates the current block when Duplicate is clicked", async () => {
const { editor, pm } = await getEditor();
const onClose = vi.fn();
editor.commands.focus("start");
const initialParagraphs = pm.querySelectorAll("p").length;
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
await vi.waitFor(() => {
expect(getBlockMenu()).toBeTruthy();
});
findButtonByText(getBlockMenu()!, "Duplicate")!.click();
expect(onClose).toHaveBeenCalled();
await vi.waitFor(() => {
const newParagraphs = pm.querySelectorAll("p").length;
expect(newParagraphs).toBe(initialParagraphs + 1);
});
});
it("closes on Escape key", async () => {
const { editor } = await getEditor();
const onClose = vi.fn();
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
await vi.waitFor(() => {
expect(getBlockMenu()).toBeTruthy();
});
await userEvent.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
it("closes transform submenu on Escape (returns to main, not full close)", async () => {
const { editor } = await getEditor();
const onClose = vi.fn();
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
await vi.waitFor(() => {
expect(getBlockMenu()).toBeTruthy();
});
// Open transforms
findButtonByText(getBlockMenu()!, "Turn into")!.click();
await vi.waitFor(() => {
expect(findButtonByText(getBlockMenu()!, "Back")).toBeTruthy();
});
// Escape should close submenu, not the whole menu
await userEvent.keyboard("{Escape}");
// onClose should NOT have been called — submenu should just close
// (The component resets showTransforms on Escape in submenu)
await vi.waitFor(() => {
const menu = getBlockMenu()!;
// Should be back to main menu
expect(findButtonByText(menu, "Turn into")).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,566 @@
/**
* Bubble menu tests.
*
* Tests the inline formatting bubble menu that appears when text is selected.
* Covers formatting buttons (bold, italic, underline, strikethrough, code),
* link insertion/editing, and menu visibility.
*
* The bubble menu uses TipTap's BubbleMenu component and only appears
* when there's a text selection in the editor.
*/
import type { Editor } from "@tiptap/react";
import { userEvent } from "@vitest/browser/context";
import { describe, it, expect, vi } from "vitest";
import type { PortableTextEditorProps } from "../../src/components/PortableTextEditor";
import { PortableTextEditor } from "../../src/components/PortableTextEditor";
import { render } from "../utils/render.tsx";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../src/components/MediaPickerModal", () => ({
MediaPickerModal: () => null,
}));
vi.mock("../../src/components/SectionPickerModal", () => ({
SectionPickerModal: () => null,
}));
vi.mock("../../src/components/editor/DragHandleWrapper", () => ({
DragHandleWrapper: () => null,
}));
vi.mock("../../src/components/editor/ImageNode", async () => {
const { Node } = await import("@tiptap/core");
const ImageExtension = Node.create({
name: "image",
group: "block",
atom: true,
addAttributes() {
return {
src: { default: null },
alt: { default: "" },
title: { default: "" },
caption: { default: "" },
mediaId: { default: null },
provider: { default: "local" },
width: { default: null },
height: { default: null },
displayWidth: { default: null },
displayHeight: { default: null },
};
},
parseHTML() {
return [{ tag: "img[src]" }];
},
renderHTML({ HTMLAttributes }) {
return ["img", HTMLAttributes];
},
});
return { ImageExtension };
});
vi.mock("../../src/components/editor/PluginBlockNode", async () => {
const { Node } = await import("@tiptap/core");
const PluginBlockExtension = Node.create({
name: "pluginBlock",
group: "block",
atom: true,
addAttributes() {
return {
blockType: { default: "embed" },
id: { default: "" },
data: { default: {} },
};
},
parseHTML() {
return [{ tag: "div[data-plugin-block]" }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { ...HTMLAttributes, "data-plugin-block": "" }];
},
});
return {
PluginBlockExtension,
getEmbedMeta: () => ({ label: "Embed", Icon: () => null }),
registerPluginBlocks: () => {},
resolveIcon: () => () => null,
};
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const defaultValue = [
{
_type: "block" as const,
_key: "1",
style: "normal" as const,
children: [{ _type: "span" as const, _key: "s1", text: "Hello world" }],
},
];
async function renderEditor(props: Partial<PortableTextEditorProps> = {}) {
let editorInstance: Editor | null = null;
const screen = await render(
<PortableTextEditor
value={defaultValue}
onEditorReady={(editor) => {
editorInstance = editor;
}}
{...props}
/>,
);
await vi.waitFor(
() => {
expect(document.querySelector(".ProseMirror")).toBeTruthy();
expect(editorInstance).toBeTruthy();
},
{ timeout: 3000 },
);
const pm = document.querySelector(".ProseMirror") as HTMLElement;
return { screen, editor: editorInstance!, pm };
}
/** Focus the editor and select all text using TipTap commands */
async function focusAndSelectAll(editor: Editor, pm: HTMLElement) {
pm.focus();
await vi.waitFor(() => expect(document.activeElement).toBe(pm), { timeout: 1000 });
editor.commands.focus();
editor.commands.selectAll();
}
/**
* Find the bubble menu element.
* TipTap's BubbleMenu renders as a div with role=presentation (tippy.js).
* Our menu has the class "bg-kumo-base" and contains aria-label buttons.
*/
function getBubbleMenu(): HTMLElement | null {
// The BubbleMenu from @tiptap/react/menus renders inline.
// Look for the container with our known class pattern.
const candidates = document.querySelectorAll('[class*="bg-kumo-base"]');
for (const el of candidates) {
// Bubble menu has formatting buttons with specific aria-labels
if (el.querySelector('[aria-label="Bold"]') && el.querySelector('[aria-label="Italic"]')) {
return el as HTMLElement;
}
}
// Also check for link input mode (has Apply link button)
for (const el of candidates) {
if (el.querySelector('[aria-label="Apply link"]')) {
return el as HTMLElement;
}
}
return null;
}
/** Wait for bubble menu to appear */
async function waitForBubbleMenu(): Promise<HTMLElement> {
let menu: HTMLElement | null = null;
await vi.waitFor(
() => {
menu = getBubbleMenu();
expect(menu).toBeTruthy();
},
{ timeout: 3000 },
);
return menu!;
}
/** Get a bubble menu button by aria-label */
function getBubbleButton(menu: HTMLElement, label: string): HTMLButtonElement | null {
return menu.querySelector(`[aria-label="${label}"]`);
}
// =============================================================================
// Bubble Menu
// =============================================================================
describe("Bubble Menu", () => {
it("appears when text is selected", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
expect(menu).toBeTruthy();
});
it("shows formatting buttons: Bold, Italic, Underline, Strikethrough, Code", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
expect(getBubbleButton(menu, "Bold")).toBeTruthy();
expect(getBubbleButton(menu, "Italic")).toBeTruthy();
expect(getBubbleButton(menu, "Underline")).toBeTruthy();
expect(getBubbleButton(menu, "Strikethrough")).toBeTruthy();
expect(getBubbleButton(menu, "Code")).toBeTruthy();
});
it("shows Add link button", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
expect(getBubbleButton(menu, "Add link")).toBeTruthy();
});
it("toggles bold when Bold button is clicked", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
const boldBtn = getBubbleButton(menu, "Bold")!;
boldBtn.click();
await vi.waitFor(() => {
expect(editor.isActive("bold")).toBe(true);
});
// Verify the text is wrapped in <strong>
expect(pm.querySelector("strong")).toBeTruthy();
});
it("toggles italic when Italic button is clicked", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
const italicBtn = getBubbleButton(menu, "Italic")!;
italicBtn.click();
await vi.waitFor(() => {
expect(editor.isActive("italic")).toBe(true);
});
expect(pm.querySelector("em")).toBeTruthy();
});
it("toggles underline when Underline button is clicked", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
const underlineBtn = getBubbleButton(menu, "Underline")!;
underlineBtn.click();
await vi.waitFor(() => {
expect(editor.isActive("underline")).toBe(true);
});
expect(pm.querySelector("u")).toBeTruthy();
});
it("toggles strikethrough when Strikethrough button is clicked", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
const strikeBtn = getBubbleButton(menu, "Strikethrough")!;
strikeBtn.click();
await vi.waitFor(() => {
expect(editor.isActive("strike")).toBe(true);
});
expect(pm.querySelector("s")).toBeTruthy();
});
it("toggles inline code when Code button is clicked", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
const codeBtn = getBubbleButton(menu, "Code")!;
codeBtn.click();
await vi.waitFor(() => {
expect(editor.isActive("code")).toBe(true);
});
expect(pm.querySelector("code")).toBeTruthy();
});
it("shows link input when Add link button is clicked", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
const linkBtn = getBubbleButton(menu, "Add link")!;
linkBtn.click();
// The bubble menu should now show the link input
await vi.waitFor(() => {
const applyBtn = getBubbleButton(menu, "Apply link");
expect(applyBtn).toBeTruthy();
});
// Should have a URL input with placeholder
const input = menu.querySelector('input[type="url"]');
expect(input).toBeTruthy();
});
it("applies link URL when Apply button is clicked", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
const linkBtn = getBubbleButton(menu, "Add link")!;
linkBtn.click();
await vi.waitFor(() => {
expect(menu.querySelector('input[type="url"]')).toBeTruthy();
});
// Type a URL into the input
const input = menu.querySelector('input[type="url"]') as HTMLInputElement;
input.focus();
// Use native value setter + input event for React controlled input
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!;
nativeInputValueSetter.call(input, "https://example.com");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
// Click Apply
const applyBtn = getBubbleButton(menu, "Apply link")!;
applyBtn.click();
// The editor should now have a link
await vi.waitFor(() => {
const link = pm.querySelector("a");
expect(link).toBeTruthy();
expect(link!.getAttribute("href")).toBe("https://example.com");
});
});
it("applies link on Enter key in URL input", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
getBubbleButton(menu, "Add link")!.click();
await vi.waitFor(() => {
expect(menu.querySelector('input[type="url"]')).toBeTruthy();
});
const input = menu.querySelector('input[type="url"]') as HTMLInputElement;
input.focus();
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!;
nativeInputValueSetter.call(input, "https://test.org");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
// Press Enter
await userEvent.keyboard("{Enter}");
await vi.waitFor(() => {
const link = pm.querySelector("a");
expect(link).toBeTruthy();
expect(link!.getAttribute("href")).toBe("https://test.org");
});
});
it("shows Edit link and Remove link buttons when cursor is on a link", async () => {
const linkValue = [
{
_type: "block" as const,
_key: "1",
style: "normal" as const,
children: [
{
_type: "span" as const,
_key: "s1",
text: "Click here",
marks: ["link1"],
},
],
markDefs: [{ _type: "link", _key: "link1", href: "https://example.com" }],
},
];
const { editor, pm } = await renderEditor({ value: linkValue });
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
// Should show "Edit link" instead of "Add link"
expect(getBubbleButton(menu, "Edit link")).toBeTruthy();
// Click Edit link to show input
getBubbleButton(menu, "Edit link")!.click();
await vi.waitFor(() => {
expect(getBubbleButton(menu, "Remove link")).toBeTruthy();
});
});
it("removes link when Remove link button is clicked", async () => {
const linkValue = [
{
_type: "block" as const,
_key: "1",
style: "normal" as const,
children: [
{
_type: "span" as const,
_key: "s1",
text: "Click here",
marks: ["link1"],
},
],
markDefs: [{ _type: "link", _key: "link1", href: "https://example.com" }],
},
];
const { editor, pm } = await renderEditor({ value: linkValue });
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
// Click Edit link to open link input mode
getBubbleButton(menu, "Edit link")!.click();
await vi.waitFor(() => {
expect(getBubbleButton(menu, "Remove link")).toBeTruthy();
});
// Click Remove link
getBubbleButton(menu, "Remove link")!.click();
await vi.waitFor(() => {
expect(pm.querySelector("a")).toBeNull();
});
});
it("closes link input on Escape and returns focus to editor", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
getBubbleButton(menu, "Add link")!.click();
await vi.waitFor(() => {
expect(menu.querySelector('input[type="url"]')).toBeTruthy();
});
const input = menu.querySelector('input[type="url"]') as HTMLInputElement;
input.focus();
// Press Escape
await userEvent.keyboard("{Escape}");
// Should return to the formatting buttons view
await vi.waitFor(() => {
expect(getBubbleButton(menu, "Bold")).toBeTruthy();
});
});
it("unsets link when Apply is clicked with empty URL", async () => {
const linkValue = [
{
_type: "block" as const,
_key: "1",
style: "normal" as const,
children: [
{
_type: "span" as const,
_key: "s1",
text: "Click here",
marks: ["link1"],
},
],
markDefs: [{ _type: "link", _key: "link1", href: "https://example.com" }],
},
];
const { editor, pm } = await renderEditor({ value: linkValue });
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
getBubbleButton(menu, "Edit link")!.click();
await vi.waitFor(() => {
expect(menu.querySelector('input[type="url"]')).toBeTruthy();
});
// Clear the input
const input = menu.querySelector('input[type="url"]') as HTMLInputElement;
input.focus();
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!;
nativeInputValueSetter.call(input, "");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
// Click Apply
getBubbleButton(menu, "Apply link")!.click();
// Link should be removed
await vi.waitFor(() => {
expect(pm.querySelector("a")).toBeNull();
});
});
it("can apply multiple formatting marks", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
// Apply bold + italic
getBubbleButton(menu, "Bold")!.click();
getBubbleButton(menu, "Italic")!.click();
await vi.waitFor(() => {
expect(editor.isActive("bold")).toBe(true);
expect(editor.isActive("italic")).toBe(true);
});
expect(pm.querySelector("strong")).toBeTruthy();
expect(pm.querySelector("em")).toBeTruthy();
});
it("toggles off formatting when clicked twice", async () => {
const { editor, pm } = await renderEditor();
await focusAndSelectAll(editor, pm);
const menu = await waitForBubbleMenu();
const boldBtn = getBubbleButton(menu, "Bold")!;
// Apply bold
boldBtn.click();
await vi.waitFor(() => expect(editor.isActive("bold")).toBe(true));
// Re-select (bold toggle may deselect)
editor.commands.selectAll();
// Remove bold
boldBtn.click();
await vi.waitFor(() => expect(editor.isActive("bold")).toBe(false));
expect(pm.querySelector("strong")).toBeNull();
});
});

View File

@@ -0,0 +1,226 @@
/**
* Input Rules Tests for TipTap Editor
*
* Tests that Markdown-style shortcuts work correctly in the editor.
*
* TipTap input rules are triggered by actual text input, not insertContent().
* In headless tests, we simulate this by using the inputRules extension's
* run function or by testing the resulting transformations.
*
* For integration testing, we verify the editor has the correct extensions
* configured and that the expected node/mark types exist.
*/
import { Editor } from "@tiptap/core";
import Typography from "@tiptap/extension-typography";
import StarterKit from "@tiptap/starter-kit";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
describe("Editor Input Rules", () => {
let editor: Editor;
beforeEach(() => {
editor = new Editor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Typography,
],
content: "",
});
});
afterEach(() => {
editor.destroy();
});
describe("Editor extension configuration", () => {
it("has heading extension with levels 1-3", () => {
const headingExtension = editor.extensionManager.extensions.find(
(ext) => ext.name === "heading",
);
expect(headingExtension).toBeDefined();
});
it("has bulletList extension", () => {
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "bulletList");
expect(extension).toBeDefined();
});
it("has orderedList extension", () => {
const extension = editor.extensionManager.extensions.find(
(ext) => ext.name === "orderedList",
);
expect(extension).toBeDefined();
});
it("has blockquote extension", () => {
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "blockquote");
expect(extension).toBeDefined();
});
it("has codeBlock extension", () => {
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "codeBlock");
expect(extension).toBeDefined();
});
it("has horizontalRule extension", () => {
const extension = editor.extensionManager.extensions.find(
(ext) => ext.name === "horizontalRule",
);
expect(extension).toBeDefined();
});
it("has bold extension", () => {
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "bold");
expect(extension).toBeDefined();
});
it("has italic extension", () => {
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "italic");
expect(extension).toBeDefined();
});
it("has code extension", () => {
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "code");
expect(extension).toBeDefined();
});
it("has strike extension", () => {
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "strike");
expect(extension).toBeDefined();
});
it("has typography extension", () => {
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "typography");
expect(extension).toBeDefined();
});
});
describe("Commands work correctly", () => {
it("can toggle heading 1", () => {
editor.commands.setHeading({ level: 1 });
expect(editor.isActive("heading", { level: 1 })).toBe(true);
});
it("can toggle heading 2", () => {
editor.commands.setHeading({ level: 2 });
expect(editor.isActive("heading", { level: 2 })).toBe(true);
});
it("can toggle heading 3", () => {
editor.commands.setHeading({ level: 3 });
expect(editor.isActive("heading", { level: 3 })).toBe(true);
});
it("can toggle bullet list", () => {
editor.commands.toggleBulletList();
expect(editor.isActive("bulletList")).toBe(true);
});
it("can toggle ordered list", () => {
editor.commands.toggleOrderedList();
expect(editor.isActive("orderedList")).toBe(true);
});
it("can toggle blockquote", () => {
editor.commands.toggleBlockquote();
expect(editor.isActive("blockquote")).toBe(true);
});
it("can toggle code block", () => {
editor.commands.toggleCodeBlock();
expect(editor.isActive("codeBlock")).toBe(true);
});
it("can insert horizontal rule", () => {
editor.commands.setHorizontalRule();
const json = editor.getJSON();
const hasHR = json.content?.some((node) => node.type === "horizontalRule");
expect(hasHR).toBe(true);
});
it("can toggle bold", () => {
editor.commands.insertContent("test");
editor.commands.selectAll();
editor.commands.toggleBold();
expect(editor.isActive("bold")).toBe(true);
});
it("can toggle italic", () => {
editor.commands.insertContent("test");
editor.commands.selectAll();
editor.commands.toggleItalic();
expect(editor.isActive("italic")).toBe(true);
});
it("can toggle code", () => {
editor.commands.insertContent("test");
editor.commands.selectAll();
editor.commands.toggleCode();
expect(editor.isActive("code")).toBe(true);
});
it("can toggle strike", () => {
editor.commands.insertContent("test");
editor.commands.selectAll();
editor.commands.toggleStrike();
expect(editor.isActive("strike")).toBe(true);
});
});
describe("Schema has correct node types", () => {
it("has heading node type", () => {
expect(editor.schema.nodes.heading).toBeDefined();
});
it("has bulletList node type", () => {
expect(editor.schema.nodes.bulletList).toBeDefined();
});
it("has orderedList node type", () => {
expect(editor.schema.nodes.orderedList).toBeDefined();
});
it("has blockquote node type", () => {
expect(editor.schema.nodes.blockquote).toBeDefined();
});
it("has codeBlock node type", () => {
expect(editor.schema.nodes.codeBlock).toBeDefined();
});
it("has horizontalRule node type", () => {
expect(editor.schema.nodes.horizontalRule).toBeDefined();
});
});
describe("Schema has correct mark types", () => {
it("has bold mark type", () => {
expect(editor.schema.marks.bold).toBeDefined();
});
it("has italic mark type", () => {
expect(editor.schema.marks.italic).toBeDefined();
});
it("has code mark type", () => {
expect(editor.schema.marks.code).toBeDefined();
});
it("has strike mark type", () => {
expect(editor.schema.marks.strike).toBeDefined();
});
});
describe("Input rules are registered", () => {
it("has input rules plugin", () => {
// StarterKit registers input rules through individual extensions
// We verify by checking extensions have inputRules defined
const extensions = editor.extensionManager.extensions;
const headingExt = extensions.find((e) => e.name === "heading");
expect(headingExt).toBeDefined();
});
});
});

View File

@@ -0,0 +1,303 @@
/**
* Markdown Link Extension Tests
*
* Tests the MarkdownLinkExtension which converts [text](url) syntax
* into proper link marks:
* - Extension registration and input/paste rule presence
* - Input rule converts typed [text](url) to a linked text node
* - Paste rule converts pasted markdown links inline
* - Disallowed protocols (javascript:) are rejected
* - Edge cases: empty text, empty href, whitespace trimming
* - No conflict with StarterKit's Link extension commands
*/
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { MarkdownLinkExtension } from "../../src/components/editor/MarkdownLinkExtension";
/**
* Simulate typing text character-by-character into the editor.
*
* TipTap InputRules only fire on the `handleTextInput` view prop,
* which requires dispatching through the EditorView — insertContent()
* bypasses it. We call `view.someProp("handleTextInput", ...)` for
* each character, matching how ProseMirror's input rules plugin works.
*/
function simulateTyping(editor: Editor, text: string) {
for (const char of text) {
const { from, to } = editor.state.selection;
const deflt = () => editor.state.tr.insertText(char, from, to);
const handled = editor.view.someProp("handleTextInput", (f) =>
f(editor.view, from, to, char, deflt),
);
if (!handled) {
// If no input rule consumed it, insert as plain text
editor.view.dispatch(deflt());
}
}
}
/**
* Simulate pasting plain text into the editor.
*
* Uses ProseMirror's `pasteText()` which runs through the full paste
* pipeline including TipTap's PasteRule handlers.
*/
function simulatePaste(editor: Editor, text: string) {
editor.view.pasteText(text);
}
/** Extract all link marks from the editor doc for assertions. */
function extractLinks(editor: Editor): Array<{ text: string; href: string }> {
const links: Array<{ text: string; href: string }> = [];
editor.state.doc.descendants((node) => {
if (node.isText) {
const linkMark = node.marks.find((m) => m.type.name === "link");
if (linkMark) {
links.push({ text: node.text || "", href: linkMark.attrs.href });
}
}
});
return links;
}
describe("Markdown Link Extension", () => {
let editor: Editor;
beforeEach(() => {
editor = new Editor({
extensions: [
StarterKit.configure({
link: {
openOnClick: false,
enableClickSelection: true,
},
}),
MarkdownLinkExtension,
],
content: "",
});
});
afterEach(() => {
editor.destroy();
});
describe("Extension registration", () => {
it("registers the markdownLink extension", () => {
const ext = editor.extensionManager.extensions.find((e) => e.name === "markdownLink");
expect(ext).toBeDefined();
});
it("has the link mark type from StarterKit", () => {
expect(editor.schema.marks.link).toBeDefined();
});
it("registers input rules", () => {
const ext = editor.extensionManager.extensions.find((e) => e.name === "markdownLink");
expect(ext).toBeDefined();
// The extension defines addInputRules, verify it produced rules
const plugins = editor.state.plugins;
const inputRulesPlugin = plugins.find(
(p) => (p.spec as Record<string, unknown>).isInputRules,
);
expect(inputRulesPlugin).toBeDefined();
});
});
describe("Input rule — typed markdown links", () => {
it("converts [text](url) to a link mark on closing paren", () => {
editor.commands.focus();
simulateTyping(editor, "[Example](https://example.com)");
const links = extractLinks(editor);
expect(links).toHaveLength(1);
expect(links[0]).toEqual({ text: "Example", href: "https://example.com" });
});
it("converts [text](url) with preceding text", () => {
editor.commands.focus();
simulateTyping(editor, "Visit [Example](https://example.com)");
const links = extractLinks(editor);
expect(links).toHaveLength(1);
expect(links[0]).toEqual({ text: "Example", href: "https://example.com" });
// Preceding text should still be present as plain text
const plainText = editor.getText();
expect(plainText).toContain("Visit");
expect(plainText).toContain("Example");
});
it("trims whitespace from href", () => {
editor.commands.focus();
simulateTyping(editor, "[Docs]( https://docs.example.com )");
const links = extractLinks(editor);
expect(links).toHaveLength(1);
expect(links[0]!.href).toBe("https://docs.example.com");
});
it("preserves link text with spaces", () => {
editor.commands.focus();
simulateTyping(editor, "[My Cool Link](https://example.com)");
const links = extractLinks(editor);
expect(links).toHaveLength(1);
expect(links[0]!.text).toBe("My Cool Link");
});
});
describe("Paste rule — pasted markdown links", () => {
it("converts a pasted [text](url) to a link mark", () => {
editor.commands.focus();
simulatePaste(editor, "[Example](https://example.com)");
const links = extractLinks(editor);
expect(links).toHaveLength(1);
expect(links[0]).toEqual({ text: "Example", href: "https://example.com" });
});
it("converts multiple markdown links in pasted text", () => {
editor.commands.focus();
simulatePaste(editor, "See [Foo](https://foo.com) and [Bar](https://bar.com).");
const links = extractLinks(editor);
expect(links).toHaveLength(2);
expect(links[0]).toEqual({ text: "Foo", href: "https://foo.com" });
expect(links[1]).toEqual({ text: "Bar", href: "https://bar.com" });
});
});
describe("Protocol allowlist — rejects disallowed URIs", () => {
it("rejects javascript: protocol", () => {
editor.commands.focus();
simulateTyping(editor, "[click me](javascript:alert(1))");
const links = extractLinks(editor);
expect(links).toHaveLength(0);
// The raw markdown syntax should remain as literal text
expect(editor.getText()).toContain("[click me](javascript:alert(1))");
});
it("rejects data: protocol", () => {
editor.commands.focus();
simulateTyping(editor, "[click me](data:text/html,<script>alert(1)</script>)");
const links = extractLinks(editor);
expect(links).toHaveLength(0);
});
it("allows https: protocol", () => {
editor.commands.focus();
simulateTyping(editor, "[safe](https://example.com)");
const links = extractLinks(editor);
expect(links).toHaveLength(1);
});
it("allows http: protocol", () => {
editor.commands.focus();
simulateTyping(editor, "[safe](http://example.com)");
const links = extractLinks(editor);
expect(links).toHaveLength(1);
});
it("allows relative paths", () => {
editor.commands.focus();
simulateTyping(editor, "[page](/about)");
const links = extractLinks(editor);
expect(links).toHaveLength(1);
expect(links[0]!.href).toBe("/about");
});
it("allows mailto: protocol", () => {
editor.commands.focus();
simulateTyping(editor, "[email](mailto:user@example.com)");
const links = extractLinks(editor);
expect(links).toHaveLength(1);
});
});
describe("Edge cases", () => {
it("does not convert when link text is empty", () => {
editor.commands.focus();
simulateTyping(editor, "[](https://example.com)");
const links = extractLinks(editor);
expect(links).toHaveLength(0);
});
it("does not convert when href is empty", () => {
editor.commands.focus();
simulateTyping(editor, "[text]()");
const links = extractLinks(editor);
expect(links).toHaveLength(0);
});
it("handles special characters in link text", () => {
editor.commands.focus();
simulateTyping(editor, "[it's a <test> & more](https://example.com)");
const links = extractLinks(editor);
expect(links).toHaveLength(1);
expect(links[0]!.text).toBe("it's a <test> & more");
});
it("handles query strings and fragments in URL", () => {
editor.commands.focus();
simulateTyping(editor, "[search](https://example.com/path?q=hello&lang=en#section)");
const links = extractLinks(editor);
expect(links).toHaveLength(1);
expect(links[0]!.href).toBe("https://example.com/path?q=hello&lang=en#section");
});
});
describe("Coexistence with StarterKit Link extension", () => {
it("can still set links via the Link command API", () => {
editor.commands.insertContent("click here");
editor.commands.selectAll();
editor.commands.setLink({ href: "https://example.com" });
expect(editor.isActive("link")).toBe(true);
const links = extractLinks(editor);
expect(links).toHaveLength(1);
expect(links[0]).toEqual({ text: "click here", href: "https://example.com" });
});
it("can unset links via the Link command API", () => {
editor.commands.insertContent("click here");
editor.commands.selectAll();
editor.commands.setLink({ href: "https://example.com" });
expect(editor.isActive("link")).toBe(true);
editor.commands.unsetLink();
expect(editor.isActive("link")).toBe(false);
const links = extractLinks(editor);
expect(links).toHaveLength(0);
});
it("markdown link input does not break subsequent link commands", () => {
// First, create a link via markdown syntax
editor.commands.focus();
simulateTyping(editor, "[md link](https://md.example.com) ");
// Then create a link via command API
editor.commands.insertContent("cmd link");
// Select just the "cmd link" text
const docSize = editor.state.doc.content.size;
editor.commands.setTextSelection({ from: docSize - 8, to: docSize - 1 });
editor.commands.setLink({ href: "https://cmd.example.com" });
const links = extractLinks(editor);
expect(links).toHaveLength(2);
});
});
});

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { calculateReadingTime } from "../../src/components/PortableTextEditor";
describe("Editor Metrics", () => {
describe("calculateReadingTime", () => {
it("returns 0 minutes for empty document", () => {
expect(calculateReadingTime(0)).toBe(0);
});
it("returns 1 minute for less than 200 words", () => {
expect(calculateReadingTime(1)).toBe(1);
expect(calculateReadingTime(100)).toBe(1);
expect(calculateReadingTime(199)).toBe(1);
});
it("returns 1 minute for exactly 200 words", () => {
expect(calculateReadingTime(200)).toBe(1);
});
it("returns 2 minutes for 201-400 words", () => {
expect(calculateReadingTime(201)).toBe(2);
expect(calculateReadingTime(300)).toBe(2);
expect(calculateReadingTime(400)).toBe(2);
});
it("returns correct reading time for larger documents", () => {
expect(calculateReadingTime(1000)).toBe(5);
expect(calculateReadingTime(1001)).toBe(6);
expect(calculateReadingTime(2000)).toBe(10);
});
it("always rounds up (ceil)", () => {
// 201 / 200 = 1.005, ceil = 2
expect(calculateReadingTime(201)).toBe(2);
// 401 / 200 = 2.005, ceil = 3
expect(calculateReadingTime(401)).toBe(3);
});
});
});

View File

@@ -0,0 +1,275 @@
/**
* Plugin Block Conversion Tests
*
* Tests the Portable Text ↔ ProseMirror conversion for plugin blocks (embeds).
* Covers round-trip fidelity, data isolation, and edge cases that caused
* bugs in the initial implementation.
*/
import { describe, it, expect } from "vitest";
import {
_prosemirrorToPortableText as prosemirrorToPortableText,
_portableTextToProsemirror as portableTextToProsemirror,
} from "../../src/components/PortableTextEditor";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build a ProseMirror doc with the given content nodes */
function pmDoc(...content: unknown[]) {
return { type: "doc", content };
}
/** Build a ProseMirror pluginBlock node */
function pmPluginBlock(blockType: string, id: string, data: Record<string, unknown> = {}) {
return {
type: "pluginBlock",
attrs: { blockType, id, data },
};
}
/** Build a Portable Text plugin block (unknown _type → embed) */
function ptPluginBlock(type: string, id: string, extra: Record<string, unknown> = {}) {
return {
_type: type,
_key: "k1",
id,
...extra,
};
}
// =============================================================================
// ProseMirror → Portable Text (convertPMNode pluginBlock case)
// =============================================================================
describe("PM → PT: plugin blocks", () => {
it("converts a basic plugin block", () => {
const doc = pmDoc(pmPluginBlock("youtube", "https://youtu.be/abc"));
const blocks = prosemirrorToPortableText(doc);
expect(blocks).toHaveLength(1);
expect(blocks[0]).toMatchObject({
_type: "youtube",
id: "https://youtu.be/abc",
});
expect(blocks[0]!._key).toBeTruthy();
});
it("spreads data fields into the PT block", () => {
const doc = pmDoc(pmPluginBlock("chart", "chart-1", { color: "red", size: 42 }));
const blocks = prosemirrorToPortableText(doc);
expect(blocks[0]).toMatchObject({
_type: "chart",
id: "chart-1",
color: "red",
size: 42,
});
});
it("data fields cannot overwrite _type", () => {
const doc = pmDoc(pmPluginBlock("youtube", "vid-1", { _type: "malicious" }));
const blocks = prosemirrorToPortableText(doc);
expect(blocks[0]!._type).toBe("youtube");
});
it("data fields cannot overwrite _key", () => {
const doc = pmDoc(pmPluginBlock("youtube", "vid-1", { _key: "evil" }));
const blocks = prosemirrorToPortableText(doc);
expect(blocks[0]!._key).not.toBe("evil");
});
it("handles empty data gracefully", () => {
const doc = pmDoc(pmPluginBlock("tweet", "https://twitter.com/x/status/1"));
const blocks = prosemirrorToPortableText(doc);
expect(blocks[0]).toMatchObject({
_type: "tweet",
id: "https://twitter.com/x/status/1",
});
});
it("falls back blockType to 'embed' when missing", () => {
const doc = pmDoc({
type: "pluginBlock",
attrs: { blockType: null, id: "url", data: {} },
});
const blocks = prosemirrorToPortableText(doc);
expect(blocks[0]!._type).toBe("embed");
});
});
// =============================================================================
// Portable Text → ProseMirror (convertPTBlock default case)
// =============================================================================
describe("PT → PM: plugin blocks", () => {
it("converts an unknown block type with id to pluginBlock", () => {
const pm = portableTextToProsemirror([ptPluginBlock("youtube", "https://youtu.be/abc")]);
const node = pm.content?.[0] as { type: string; attrs: Record<string, unknown> };
expect(node.type).toBe("pluginBlock");
expect(node.attrs.blockType).toBe("youtube");
expect(node.attrs.id).toBe("https://youtu.be/abc");
});
it("stores extra fields as data", () => {
const pm = portableTextToProsemirror([
ptPluginBlock("chart", "chart-1", { color: "red", size: 42 }),
]);
const node = pm.content?.[0] as { type: string; attrs: { data: Record<string, unknown> } };
expect(node.attrs.data).toEqual({ color: "red", size: 42 });
});
it("filters _-prefixed keys from data", () => {
const pm = portableTextToProsemirror([
ptPluginBlock("youtube", "vid-1", {
_internal: "should-be-stripped",
_foo: "also-stripped",
caption: "keep-this",
}),
]);
const node = pm.content?.[0] as { type: string; attrs: { data: Record<string, unknown> } };
expect(node.attrs.data).toEqual({ caption: "keep-this" });
expect(node.attrs.data).not.toHaveProperty("_internal");
expect(node.attrs.data).not.toHaveProperty("_foo");
});
it("handles url field as fallback for id", () => {
const block = { _type: "embed", _key: "k1", url: "https://example.com" };
const pm = portableTextToProsemirror([block]);
const node = pm.content?.[0] as { type: string; attrs: Record<string, unknown> };
expect(node.type).toBe("pluginBlock");
expect(node.attrs.id).toBe("https://example.com");
});
it("treats blocks without id, url, or data as unknown (paragraph fallback)", () => {
const block = { _type: "mystery", _key: "k1" };
const pm = portableTextToProsemirror([block]);
const node = pm.content?.[0] as { type: string };
expect(node.type).toBe("paragraph");
});
it("converts blocks with field data but no id/url to pluginBlock", () => {
const block = { _type: "emdash-form", _key: "k1", formId: "abc-123" };
const pm = portableTextToProsemirror([block]);
const node = pm.content?.[0] as {
type: string;
attrs: { blockType: string; id: string; data: Record<string, unknown> };
};
expect(node.type).toBe("pluginBlock");
expect(node.attrs.blockType).toBe("emdash-form");
expect(node.attrs.id).toBe("");
expect(node.attrs.data).toEqual({ formId: "abc-123" });
});
it("converts blocks with empty id and field data to pluginBlock", () => {
const block = { _type: "emdash-form", _key: "k1", id: "", formId: "abc-123" };
const pm = portableTextToProsemirror([block]);
const node = pm.content?.[0] as {
type: string;
attrs: { blockType: string; id: string; data: Record<string, unknown> };
};
expect(node.type).toBe("pluginBlock");
expect(node.attrs.blockType).toBe("emdash-form");
expect(node.attrs.id).toBe("");
expect(node.attrs.data).toEqual({ formId: "abc-123" });
});
});
// =============================================================================
// Round-trip: PT → PM → PT
// =============================================================================
describe("Plugin block round-trip", () => {
it("basic plugin block survives round-trip", () => {
const original = [ptPluginBlock("youtube", "https://youtu.be/abc")];
const pm = portableTextToProsemirror(original);
const roundTripped = prosemirrorToPortableText(pm);
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0]).toMatchObject({
_type: "youtube",
id: "https://youtu.be/abc",
});
});
it("plugin block with data survives round-trip", () => {
const original = [ptPluginBlock("chart", "chart-1", { color: "red", size: 42 })];
const pm = portableTextToProsemirror(original);
const roundTripped = prosemirrorToPortableText(pm);
expect(roundTripped[0]).toMatchObject({
_type: "chart",
id: "chart-1",
color: "red",
size: 42,
});
});
it("_-prefixed keys do not accumulate across round-trips", () => {
// Simulate a block that somehow has _-prefixed keys in data
const withLeakyKeys = [
ptPluginBlock("youtube", "vid-1", {
_createdAt: "2024-01-01",
caption: "test",
}),
];
// First round-trip should strip _-prefixed keys
const pm1 = portableTextToProsemirror(withLeakyKeys);
const rt1 = prosemirrorToPortableText(pm1);
expect(rt1[0]).toMatchObject({ _type: "youtube", id: "vid-1", caption: "test" });
expect(rt1[0]).not.toHaveProperty("_createdAt");
// Second round-trip should be stable
const pm2 = portableTextToProsemirror(rt1);
const rt2 = prosemirrorToPortableText(pm2);
expect(rt2[0]).toMatchObject({ _type: "youtube", id: "vid-1", caption: "test" });
expect(Object.keys(rt2[0]!).filter((k) => k.startsWith("_"))).toEqual(["_type", "_key"]);
});
it("field-data block (no id) survives round-trip", () => {
const original = [{ _type: "emdash-form", _key: "k1", formId: "abc-123" }];
const pm = portableTextToProsemirror(original);
const roundTripped = prosemirrorToPortableText(pm);
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0]).toMatchObject({
_type: "emdash-form",
id: "",
formId: "abc-123",
});
});
it("data with _type/_key fields cannot overwrite block identity after round-trip", () => {
// Start from PM where data contains _type/_key as data fields
const doc = pmDoc(
pmPluginBlock("youtube", "vid-1", { _type: "evil", _key: "evil", caption: "test" }),
);
// PM → PT: fix 5 ensures _type/_key are set after data spread
const pt = prosemirrorToPortableText(doc);
expect(pt[0]!._type).toBe("youtube");
expect(pt[0]!._key).not.toBe("evil");
// PT → PM → PT: fix 6 strips _-prefixed keys, so they don't persist
const pm2 = portableTextToProsemirror(pt);
const rt = prosemirrorToPortableText(pm2);
expect(rt[0]!._type).toBe("youtube");
expect(rt[0]).toMatchObject({ caption: "test" });
});
});

View File

@@ -0,0 +1,618 @@
/**
* Slash command menu tests.
*
* Tests the "/" trigger, command filtering, keyboard navigation,
* command execution, and menu dismissal via Escape.
*
* The slash menu is internal to PortableTextEditor and driven by
* TipTap's Suggestion plugin. We test it through the full editor
* since there's no standalone export.
*/
import type { Editor } from "@tiptap/react";
import { userEvent } from "@vitest/browser/context";
import { describe, it, expect, vi } from "vitest";
import type { PortableTextEditorProps } from "../../src/components/PortableTextEditor";
import { PortableTextEditor } from "../../src/components/PortableTextEditor";
import { render } from "../utils/render";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../src/components/MediaPickerModal", () => ({
MediaPickerModal: () => null,
}));
vi.mock("../../src/components/SectionPickerModal", () => ({
SectionPickerModal: () => null,
}));
vi.mock("../../src/components/editor/DragHandleWrapper", () => ({
DragHandleWrapper: () => null,
}));
vi.mock("../../src/components/editor/ImageNode", async () => {
const { Node } = await import("@tiptap/core");
const ImageExtension = Node.create({
name: "image",
group: "block",
atom: true,
addAttributes() {
return {
src: { default: null },
alt: { default: "" },
title: { default: "" },
caption: { default: "" },
mediaId: { default: null },
provider: { default: "local" },
width: { default: null },
height: { default: null },
displayWidth: { default: null },
displayHeight: { default: null },
};
},
parseHTML() {
return [{ tag: "img[src]" }];
},
renderHTML({ HTMLAttributes }) {
return ["img", HTMLAttributes];
},
});
return { ImageExtension };
});
vi.mock("../../src/components/editor/PluginBlockNode", async () => {
const { Node } = await import("@tiptap/core");
const PluginBlockExtension = Node.create({
name: "pluginBlock",
group: "block",
atom: true,
addAttributes() {
return {
blockType: { default: "embed" },
id: { default: "" },
data: { default: {} },
};
},
parseHTML() {
return [{ tag: "div[data-plugin-block]" }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { ...HTMLAttributes, "data-plugin-block": "" }];
},
});
const embedMeta: Record<string, { label: string }> = {
youtube: { label: "YouTube Video" },
vimeo: { label: "Vimeo" },
tweet: { label: "Tweet" },
};
return {
PluginBlockExtension,
getEmbedMeta: (type: string) => ({
label: embedMeta[type]?.label ?? "Embed",
Icon: () => null,
}),
registerPluginBlocks: () => {},
resolveIcon: () => () => null,
};
});
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const WHITESPACE_SPLIT_REGEX = /\s+/;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Render the editor, wait for TipTap, return editor instance + ProseMirror element */
async function renderEditor(props: Partial<PortableTextEditorProps> = {}) {
let editorInstance: Editor | null = null;
const screen = await render(
<PortableTextEditor
onEditorReady={(editor) => {
editorInstance = editor;
}}
{...props}
/>,
);
await vi.waitFor(
() => {
expect(document.querySelector(".ProseMirror")).toBeTruthy();
expect(editorInstance).toBeTruthy();
},
{ timeout: 3000 },
);
const pm = document.querySelector(".ProseMirror") as HTMLElement;
return { screen, editor: editorInstance!, pm };
}
/** Focus the editor */
async function focusEditor(pm: HTMLElement) {
pm.focus();
await vi.waitFor(() => expect(document.activeElement).toBe(pm), { timeout: 1000 });
}
/** Get the slash menu portal element from document.body */
function getSlashMenu(): HTMLElement | null {
const portals = document.querySelectorAll("body > div");
for (const el of portals) {
if (el.querySelector("[data-index]") || el.textContent?.includes("No results")) {
return el as HTMLElement;
}
}
return null;
}
/** Wait for the slash menu to appear */
async function waitForSlashMenu(): Promise<HTMLElement> {
let menu: HTMLElement | null = null;
await vi.waitFor(
() => {
menu = getSlashMenu();
expect(menu).toBeTruthy();
},
{ timeout: 3000 },
);
return menu!;
}
/** Wait for the slash menu to disappear */
async function waitForSlashMenuClosed() {
await vi.waitFor(
() => {
expect(getSlashMenu()).toBeNull();
},
{ timeout: 3000 },
);
}
/** Get visible items in the slash menu */
function getSlashMenuItems(menu: HTMLElement): HTMLButtonElement[] {
return [...menu.querySelectorAll("button[data-index]")];
}
/**
* Check if an item is the selected/highlighted item.
* Selected items use "bg-kumo-tint text-kumo-default" (space-separated).
* Non-selected items use "hover:bg-kumo-tint/50".
*/
function isItemSelected(el: HTMLElement): boolean {
// Split className by spaces and check for exact "bg-kumo-tint" token
return el.className.split(WHITESPACE_SPLIT_REGEX).includes("bg-kumo-tint");
}
// =============================================================================
// Slash Command Menu
// =============================================================================
describe("Slash Command Menu", () => {
it("opens when typing / at the start of an empty line", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
// Default commands: heading1-3, bullet/numbered list, quote, code block, divider, image, section
expect(items.length).toBeGreaterThanOrEqual(8);
});
it("shows default block type commands", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles).toContain("Heading 1");
expect(titles).toContain("Heading 2");
expect(titles).toContain("Heading 3");
expect(titles).toContain("Bullet List");
expect(titles).toContain("Numbered List");
expect(titles).toContain("Quote");
expect(titles).toContain("Code Block");
expect(titles).toContain("Divider");
});
it("shows descriptions for each command", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
for (const item of items) {
const description = item.querySelector(".text-xs");
expect(description).toBeTruthy();
expect(description!.textContent!.length).toBeGreaterThan(0);
}
});
it("filters commands by query text", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
// Type filter text — Suggestion plugin watches text after "/"
await userEvent.keyboard("head");
await vi.waitFor(
() => {
const menu = getSlashMenu();
expect(menu).toBeTruthy();
const items = getSlashMenuItems(menu!);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles.length).toBeGreaterThanOrEqual(1);
expect(titles.every((t) => t.toLowerCase().includes("heading"))).toBe(true);
},
{ timeout: 3000 },
);
});
it("shows No results when no commands match", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
await userEvent.keyboard("xyznonexistent");
await vi.waitFor(
() => {
const menu = getSlashMenu();
expect(menu).toBeTruthy();
expect(menu!.textContent).toContain("No results");
},
{ timeout: 3000 },
);
});
it("highlights the first item by default", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
expect(isItemSelected(items[0]!)).toBe(true);
});
it("moves selection down with ArrowDown", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
await userEvent.keyboard("{ArrowDown}");
await vi.waitFor(() => {
const menu = getSlashMenu()!;
const items = getSlashMenuItems(menu);
expect(isItemSelected(items[1]!)).toBe(true);
expect(isItemSelected(items[0]!)).toBe(false);
});
});
it("moves selection up with ArrowUp from second item", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
// Move down, then back up
await userEvent.keyboard("{ArrowDown}");
await vi.waitFor(() => {
const items = getSlashMenuItems(getSlashMenu()!);
expect(isItemSelected(items[1]!)).toBe(true);
});
await userEvent.keyboard("{ArrowUp}");
await vi.waitFor(() => {
const items = getSlashMenuItems(getSlashMenu()!);
expect(isItemSelected(items[0]!)).toBe(true);
});
});
it("wraps selection around when pressing ArrowUp from first item", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
await userEvent.keyboard("{ArrowUp}");
await vi.waitFor(() => {
const menu = getSlashMenu()!;
const items = getSlashMenuItems(menu);
const lastItem = items.at(-1)!;
expect(isItemSelected(lastItem)).toBe(true);
});
});
it("executes selected command on Enter and converts to heading", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
// First item is "Heading 1"
await userEvent.keyboard("{Enter}");
await waitForSlashMenuClosed();
await vi.waitFor(() => {
expect(pm.querySelector("h1")).toBeTruthy();
});
});
it("closes menu on Escape without executing", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
await userEvent.keyboard("{Escape}");
await waitForSlashMenuClosed();
// Should still be a paragraph
expect(pm.querySelector("h1")).toBeNull();
});
it("executes command when clicking an item", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const quoteBtn = items.find(
(btn) => btn.querySelector(".font-medium")?.textContent === "Quote",
);
expect(quoteBtn).toBeTruthy();
quoteBtn!.click();
await waitForSlashMenuClosed();
await vi.waitFor(() => {
expect(pm.querySelector("blockquote")).toBeTruthy();
});
});
it("inserts a code block via slash command", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const codeBlockBtn = items.find(
(btn) => btn.querySelector(".font-medium")?.textContent === "Code Block",
);
expect(codeBlockBtn).toBeTruthy();
codeBlockBtn!.click();
await waitForSlashMenuClosed();
await vi.waitFor(() => {
expect(pm.querySelector("pre")).toBeTruthy();
});
});
it("inserts a horizontal rule via slash command", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const dividerBtn = items.find(
(btn) => btn.querySelector(".font-medium")?.textContent === "Divider",
);
expect(dividerBtn).toBeTruthy();
dividerBtn!.click();
await waitForSlashMenuClosed();
await vi.waitFor(() => {
expect(pm.querySelector("hr")).toBeTruthy();
});
});
it("inserts bullet list via slash command", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const bulletBtn = items.find(
(btn) => btn.querySelector(".font-medium")?.textContent === "Bullet List",
);
expect(bulletBtn).toBeTruthy();
bulletBtn!.click();
await waitForSlashMenuClosed();
await vi.waitFor(() => {
expect(pm.querySelector("ul")).toBeTruthy();
});
});
it("inserts numbered list via slash command", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const numberedBtn = items.find(
(btn) => btn.querySelector(".font-medium")?.textContent === "Numbered List",
);
expect(numberedBtn).toBeTruthy();
numberedBtn!.click();
await waitForSlashMenuClosed();
await vi.waitFor(() => {
expect(pm.querySelector("ol")).toBeTruthy();
});
});
it("highlights item on mouse hover", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
// React listens for pointerenter/mouseenter on the element.
// Use userEvent.hover which properly dispatches pointer + mouse events.
await userEvent.hover(items[2]!);
await vi.waitFor(() => {
const freshItems = getSlashMenuItems(menu);
expect(isItemSelected(freshItems[2]!)).toBe(true);
});
});
it("filters by alias (typing /h1 shows Heading 1)", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
await userEvent.keyboard("h1");
await vi.waitFor(
() => {
const menu = getSlashMenu();
expect(menu).toBeTruthy();
const items = getSlashMenuItems(menu!);
expect(items.length).toBeGreaterThanOrEqual(1);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles).toContain("Heading 1");
},
{ timeout: 3000 },
);
});
it("includes Image and Section commands", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles).toContain("Image");
expect(titles).toContain("Section");
});
it("prioritises title matches over description matches when filtering", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
// "sec" matches "Section" by title and headings by description ("section heading")
await userEvent.keyboard("sec");
await vi.waitFor(
() => {
const menu = getSlashMenu();
expect(menu).toBeTruthy();
const items = getSlashMenuItems(menu!);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles.length).toBeGreaterThan(1);
expect(titles[0]).toBe("Section");
},
{ timeout: 3000 },
);
});
it("includes plugin block commands when provided", async () => {
const { editor, pm } = await renderEditor({
pluginBlocks: [
{
pluginId: "test-plugin",
type: "youtube",
label: "YouTube Video",
},
],
});
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles).toContain("YouTube Video");
});
it("renders plugin block commands with a custom category override", async () => {
// A plugin block that opts into the "Sections" category instead of the
// default "Embeds". The category itself isn't currently surfaced in the
// rendered DOM (the slash menu doesn't group by category), but providing
// it must not break rendering and the block must still be selectable.
const { editor, pm } = await renderEditor({
pluginBlocks: [
{
pluginId: "marketing-blocks",
type: "marketing.hero",
label: "Hero",
category: "Sections",
},
],
});
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles).toContain("Hero");
});
it("renders plugin block commands without a category (default Embeds)", async () => {
// Existing plugins that omit `category` must continue to render under
// the default category. This guards against regressions in the type
// widening / fallback behaviour.
const { editor, pm } = await renderEditor({
pluginBlocks: [
{
pluginId: "test-plugin",
type: "vimeo",
label: "Vimeo",
// no category provided
},
],
});
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles).toContain("Vimeo");
});
});

View File

@@ -0,0 +1,823 @@
import type { Editor } from "@tiptap/core";
import { userEvent } from "@vitest/browser/context";
import { describe, it, expect, vi } from "vitest";
import {
PortableTextEditor,
type PortableTextEditorProps,
} from "../../src/components/PortableTextEditor";
import { render } from "../utils/render.tsx";
// ---------------------------------------------------------------------------
// Mocks — heavy components that need network / Astro context
// ---------------------------------------------------------------------------
vi.mock("../../src/components/MediaPickerModal", () => ({
MediaPickerModal: () => null,
}));
vi.mock("../../src/components/SectionPickerModal", () => ({
SectionPickerModal: () => null,
}));
vi.mock("../../src/components/editor/DragHandleWrapper", () => ({
DragHandleWrapper: () => null,
}));
vi.mock("../../src/components/editor/ImageNode", async () => {
const { Node } = await import("@tiptap/core");
const ImageExtension = Node.create({
name: "image",
group: "block",
atom: true,
addAttributes() {
return {
src: { default: null },
alt: { default: "" },
title: { default: "" },
caption: { default: "" },
mediaId: { default: null },
provider: { default: "local" },
width: { default: null },
height: { default: null },
displayWidth: { default: null },
displayHeight: { default: null },
};
},
parseHTML() {
return [{ tag: "img[src]" }];
},
renderHTML({ HTMLAttributes }) {
return ["img", HTMLAttributes];
},
});
return { ImageExtension };
});
vi.mock("../../src/components/editor/PluginBlockNode", async () => {
const { Node } = await import("@tiptap/core");
const PluginBlockExtension = Node.create({
name: "pluginBlock",
group: "block",
atom: true,
addAttributes() {
return {
blockType: { default: "embed" },
id: { default: "" },
data: { default: {} },
};
},
parseHTML() {
return [{ tag: "div[data-plugin-block]" }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { ...HTMLAttributes, "data-plugin-block": "" }];
},
});
return {
PluginBlockExtension,
getEmbedMeta: () => ({ label: "Embed", Icon: () => null }),
registerPluginBlocks: () => {},
resolveIcon: () => () => null,
};
});
const defaultValue = [
{
_type: "block" as const,
_key: "1",
style: "normal" as const,
children: [{ _type: "span" as const, _key: "s1", text: "Hello world" }],
},
];
async function renderEditor(props: Partial<PortableTextEditorProps> = {}) {
let editorInstance: Editor | null = null;
const onEditorReady = (editor: Editor) => {
editorInstance = editor;
};
const screen = await render(
<PortableTextEditor value={defaultValue} onEditorReady={onEditorReady} {...props} />,
);
// Wait for TipTap to initialize
await vi.waitFor(
() => {
expect(document.querySelector(".ProseMirror")).toBeTruthy();
},
{ timeout: 3000 },
);
return { screen, editor: editorInstance! };
}
/** Focus the ProseMirror editor and select all text */
async function focusAndSelectAll(screen: Awaited<ReturnType<typeof render>>) {
const prosemirror = screen.container.querySelector(".ProseMirror") as HTMLElement;
prosemirror.focus();
await vi.waitFor(() => expect(document.activeElement).toBe(prosemirror), { timeout: 1000 });
// Use Control on Linux CI, Meta on macOS
const mod = navigator.platform.includes("Mac") ? "{Meta>}" : "{Control>}";
const modUp = navigator.platform.includes("Mac") ? "{/Meta}" : "{/Control}";
await userEvent.keyboard(`${mod}{a}${modUp}`);
}
/**
* Returns a locator scoped to the editor toolbar.
*
* The bubble menu (which appears when text is selected) renders buttons with
* the same accessible names as some toolbar buttons (Bold, Italic, Underline,
* Strikethrough). An unscoped `getByRole("button", { name: "Bold" })` after
* selecting text races with the bubble menu and produces a strict-mode
* violation in CI. Scoping to the toolbar via its aria-label avoids the race.
*/
function getToolbarButton(screen: Awaited<ReturnType<typeof render>>, name: string) {
return screen.getByRole("toolbar", { name: "Text formatting" }).getByRole("button", { name });
}
// =============================================================================
// 1. Toolbar Presence and Structure
// =============================================================================
describe("Toolbar Presence and Structure", () => {
it("has role='toolbar' with correct aria-label", async () => {
const { screen } = await renderEditor();
const toolbar = screen.getByRole("toolbar");
await expect.element(toolbar).toHaveAttribute("aria-label", "Text formatting");
});
it("has all formatting buttons", async () => {
const { screen } = await renderEditor();
await expect.element(screen.getByRole("button", { name: "Bold" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Italic" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Underline" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Strikethrough" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Inline Code" })).toBeVisible();
});
it("has all heading buttons", async () => {
const { screen } = await renderEditor();
await expect.element(screen.getByRole("button", { name: "Heading 1" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Heading 2" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Heading 3" })).toBeVisible();
});
it("has all list buttons", async () => {
const { screen } = await renderEditor();
await expect.element(screen.getByRole("button", { name: "Bullet List" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Numbered List" })).toBeVisible();
});
it("has all block buttons", async () => {
const { screen } = await renderEditor();
await expect.element(screen.getByRole("button", { name: "Quote" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Code Block" })).toBeVisible();
});
it("has all alignment buttons", async () => {
const { screen } = await renderEditor();
await expect.element(screen.getByRole("button", { name: "Align Left" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Align Center" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Align Right" })).toBeVisible();
});
it("has all insert buttons", async () => {
const { screen } = await renderEditor();
await expect.element(screen.getByRole("button", { name: "Insert Link" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Insert Image" })).toBeVisible();
await expect
.element(screen.getByRole("button", { name: "Insert Horizontal Rule" }))
.toBeVisible();
});
it("has history buttons", async () => {
const { screen } = await renderEditor();
await expect.element(screen.getByRole("button", { name: "Undo" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Redo" })).toBeVisible();
});
it("has Spotlight Mode button", async () => {
const { screen } = await renderEditor();
await expect.element(screen.getByRole("button", { name: "Spotlight Mode" })).toBeVisible();
});
it("hides toolbar when minimal={true}", async () => {
const { screen } = await renderEditor({ minimal: true });
const toolbar = screen.container.querySelector('[role="toolbar"]');
expect(toolbar).toBeNull();
});
});
// =============================================================================
// 2. Formatting Button Toggle States
// =============================================================================
describe("Formatting Button Toggle States", () => {
it("Bold: click toggles aria-pressed to true", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
const btn = getToolbarButton(screen, "Bold");
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
});
it("Italic: click toggles aria-pressed to true", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
const btn = getToolbarButton(screen, "Italic");
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
});
it("Underline: click toggles aria-pressed to true", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
const btn = getToolbarButton(screen, "Underline");
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
});
it("Strikethrough: click toggles aria-pressed to true", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
const btn = getToolbarButton(screen, "Strikethrough");
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
});
it("Inline Code: click toggles aria-pressed to true", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
const btn = getToolbarButton(screen, "Inline Code");
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
});
it("Heading 1: click toggles aria-pressed to true and changes to h1", async () => {
const { screen, editor } = await renderEditor();
// Focus editor and place cursor (block commands need cursor in a paragraph)
editor.commands.focus();
const btn = screen.getByRole("button", { name: "Heading 1" });
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
expect(editor.isActive("heading", { level: 1 })).toBe(true);
});
});
it("Heading 2: click toggles aria-pressed to true", async () => {
const { screen, editor } = await renderEditor();
editor.commands.focus();
const btn = screen.getByRole("button", { name: "Heading 2" });
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
});
it("Heading 3: click toggles aria-pressed to true", async () => {
const { screen, editor } = await renderEditor();
editor.commands.focus();
const btn = screen.getByRole("button", { name: "Heading 3" });
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
});
it("Bullet List: click toggles aria-pressed to true", async () => {
const { screen, editor } = await renderEditor();
editor.commands.focus();
const btn = screen.getByRole("button", { name: "Bullet List" });
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
});
it("Numbered List: click toggles aria-pressed to true", async () => {
const { screen, editor } = await renderEditor();
editor.commands.focus();
const btn = screen.getByRole("button", { name: "Numbered List" });
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
});
it("Quote: click toggles aria-pressed to true", async () => {
const { screen, editor } = await renderEditor();
editor.commands.focus();
const btn = screen.getByRole("button", { name: "Quote" });
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
});
it("Code Block: click toggles aria-pressed to true", async () => {
const { screen, editor } = await renderEditor();
editor.commands.focus();
const btn = screen.getByRole("button", { name: "Code Block" });
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
});
it("Toggle off: clicking Bold twice returns aria-pressed to false", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
const btn = getToolbarButton(screen, "Bold");
// First click: on
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
// Second click: off
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("false");
});
});
});
// =============================================================================
// 3. Text Alignment
// =============================================================================
describe("Text Alignment", () => {
it("Align Center becomes pressed, Align Left becomes unpressed", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
const alignLeft = screen.getByRole("button", { name: "Align Left" });
const alignCenter = screen.getByRole("button", { name: "Align Center" });
alignCenter.element().click();
await vi.waitFor(() => {
expect(alignCenter.element().getAttribute("aria-pressed")).toBe("true");
expect(alignLeft.element().getAttribute("aria-pressed")).toBe("false");
});
});
it("Align Right becomes pressed, others unpressed", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
const alignLeft = screen.getByRole("button", { name: "Align Left" });
const alignCenter = screen.getByRole("button", { name: "Align Center" });
const alignRight = screen.getByRole("button", { name: "Align Right" });
alignRight.element().click();
await vi.waitFor(() => {
expect(alignRight.element().getAttribute("aria-pressed")).toBe("true");
expect(alignLeft.element().getAttribute("aria-pressed")).toBe("false");
expect(alignCenter.element().getAttribute("aria-pressed")).toBe("false");
});
});
it("Align Left becomes pressed after switching from another alignment", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
const alignLeft = screen.getByRole("button", { name: "Align Left" });
const alignRight = screen.getByRole("button", { name: "Align Right" });
// First switch to right
alignRight.element().click();
await vi.waitFor(() => {
expect(alignRight.element().getAttribute("aria-pressed")).toBe("true");
});
// Then switch back to left
alignLeft.element().click();
await vi.waitFor(() => {
expect(alignLeft.element().getAttribute("aria-pressed")).toBe("true");
expect(alignRight.element().getAttribute("aria-pressed")).toBe("false");
});
});
});
// =============================================================================
// 4. Undo/Redo
// =============================================================================
describe("Undo/Redo", () => {
it("initially Undo and Redo are disabled", async () => {
const { screen } = await renderEditor();
const undo = screen.getByRole("button", { name: "Undo" });
const redo = screen.getByRole("button", { name: "Redo" });
await expect.element(undo).toBeDisabled();
await expect.element(redo).toBeDisabled();
});
it("after making a change, Undo becomes enabled", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
// Make a change - toggle bold
getToolbarButton(screen, "Bold").element().click();
const undo = getToolbarButton(screen, "Undo");
await vi.waitFor(
() => {
expect(undo.element().disabled).toBe(false);
},
{ timeout: 3000 },
);
});
it("after undo, Redo becomes enabled", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
// Make a change
getToolbarButton(screen, "Bold").element().click();
const undo = getToolbarButton(screen, "Undo");
const redo = getToolbarButton(screen, "Redo");
// Wait for undo to be enabled, then click it
await vi.waitFor(
() => {
expect(undo.element().disabled).toBe(false);
},
{ timeout: 3000 },
);
undo.element().click();
await vi.waitFor(
() => {
expect(redo.element().disabled).toBe(false);
},
{ timeout: 3000 },
);
});
it("after redo, Undo is enabled and Redo is disabled", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
// Make a change
getToolbarButton(screen, "Bold").element().click();
const undo = getToolbarButton(screen, "Undo");
const redo = getToolbarButton(screen, "Redo");
// Undo
await vi.waitFor(
() => {
expect(undo.element().disabled).toBe(false);
},
{ timeout: 3000 },
);
undo.element().click();
// Redo
await vi.waitFor(
() => {
expect(redo.element().disabled).toBe(false);
},
{ timeout: 3000 },
);
redo.element().click();
await vi.waitFor(
() => {
expect(undo.element().disabled).toBe(false);
expect(redo.element().disabled).toBe(true);
},
{ timeout: 3000 },
);
});
});
// =============================================================================
// 5. Link Insertion (Toolbar Popover)
// =============================================================================
describe("Link Insertion", () => {
it("clicking Insert Link opens a popover with URL input", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
const linkBtn = screen.getByRole("button", { name: "Insert Link" });
linkBtn.element().click();
await vi.waitFor(() => {
const input = screen.container.querySelector('input[type="url"]');
expect(input).toBeTruthy();
});
});
it("popover has Cancel and Apply buttons", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
screen.getByRole("button", { name: "Insert Link" }).element().click();
await vi.waitFor(() => {
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Apply" })).toBeTruthy();
});
});
it("typing URL and clicking Apply sets the link", async () => {
const { screen, editor } = await renderEditor();
await focusAndSelectAll(screen);
screen.getByRole("button", { name: "Insert Link" }).element().click();
await vi.waitFor(() => {
expect(screen.container.querySelector('input[type="url"]')).toBeTruthy();
});
const input = screen.container.querySelector('input[type="url"]') as HTMLInputElement;
// Focus input and type URL
input.focus();
// Use native input value setter to trigger React's onChange
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!;
nativeInputValueSetter.call(input, "https://example.com");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
screen.getByRole("button", { name: "Apply" }).element().click();
await vi.waitFor(() => {
expect(editor.isActive("link")).toBe(true);
});
});
it("clicking Cancel closes the popover", async () => {
const { screen } = await renderEditor();
await focusAndSelectAll(screen);
screen.getByRole("button", { name: "Insert Link" }).element().click();
await vi.waitFor(() => {
expect(screen.container.querySelector('input[type="url"]')).toBeTruthy();
});
screen.getByRole("button", { name: "Cancel" }).element().click();
await vi.waitFor(() => {
expect(screen.container.querySelector('input[type="url"]')).toBeNull();
});
});
it("Remove button appears when link already exists", async () => {
const { screen, editor } = await renderEditor();
await focusAndSelectAll(screen);
// Set a link programmatically
editor.chain().focus().setLink({ href: "https://example.com" }).run();
await vi.waitFor(() => {
expect(editor.isActive("link")).toBe(true);
});
// Re-select all to ensure cursor is in the link
const mod = navigator.platform.includes("Mac") ? "{Meta>}" : "{Control>}";
const modUp = navigator.platform.includes("Mac") ? "{/Meta}" : "{/Control}";
await userEvent.keyboard(`${mod}{a}${modUp}`);
screen.getByRole("button", { name: "Insert Link" }).element().click();
await vi.waitFor(() => {
expect(screen.getByRole("button", { name: "Remove" })).toBeTruthy();
});
});
});
// =============================================================================
// 6. Focus Mode Toggle
// =============================================================================
describe("Focus Mode Toggle", () => {
it("initially Spotlight Mode aria-pressed is false", async () => {
const { screen } = await renderEditor();
const btn = screen.getByRole("button", { name: "Spotlight Mode" });
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
});
it("clicking Spotlight Mode toggles aria-pressed to true and adds class", async () => {
const { screen } = await renderEditor();
const btn = screen.getByRole("button", { name: "Spotlight Mode" });
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
// The wrapper div should have the spotlight-mode class
const wrapper = screen.container.querySelector(".spotlight-mode");
expect(wrapper).toBeTruthy();
});
});
it("clicking Spotlight Mode again toggles back to false and removes class", async () => {
const { screen } = await renderEditor();
const btn = screen.getByRole("button", { name: "Spotlight Mode" });
// Toggle on
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
});
// Toggle off
btn.element().click();
await vi.waitFor(() => {
expect(btn.element().getAttribute("aria-pressed")).toBe("false");
expect(screen.container.querySelector(".spotlight-mode")).toBeNull();
});
});
it("with controlled focusMode prop, reflects external state", async () => {
const { screen } = await renderEditor({ focusMode: "spotlight" });
// The button title changes to "Exit Spotlight Mode" when active
const btn = screen.getByRole("button", { name: "Exit Spotlight Mode" });
await expect.element(btn).toHaveAttribute("aria-pressed", "true");
const wrapper = screen.container.querySelector(".spotlight-mode");
expect(wrapper).toBeTruthy();
});
it("with onFocusModeChange callback, fires with correct mode", async () => {
const onFocusModeChange = vi.fn();
const { screen } = await renderEditor({
focusMode: "normal",
onFocusModeChange,
});
const btn = screen.getByRole("button", { name: "Spotlight Mode" });
btn.element().click();
await vi.waitFor(() => {
expect(onFocusModeChange).toHaveBeenCalledWith("spotlight");
});
});
});
// =============================================================================
// 7. WAI-ARIA Keyboard Navigation
// =============================================================================
describe("WAI-ARIA Keyboard Navigation", () => {
it("ArrowRight from Bold moves focus to Italic", async () => {
const { screen } = await renderEditor();
const bold = screen.getByRole("button", { name: "Bold" });
const italic = screen.getByRole("button", { name: "Italic" });
// Focus the Bold button
bold.element().focus();
expect(document.activeElement).toBe(bold.element());
// Press ArrowRight
await userEvent.keyboard("{ArrowRight}");
await vi.waitFor(() => {
expect(document.activeElement).toBe(italic.element());
});
});
it("ArrowLeft from Italic moves focus to Bold", async () => {
const { screen } = await renderEditor();
const bold = screen.getByRole("button", { name: "Bold" });
const italic = screen.getByRole("button", { name: "Italic" });
// Focus the Italic button
italic.element().focus();
expect(document.activeElement).toBe(italic.element());
// Press ArrowLeft
await userEvent.keyboard("{ArrowLeft}");
await vi.waitFor(() => {
expect(document.activeElement).toBe(bold.element());
});
});
it("Home moves focus to first button", async () => {
const { screen } = await renderEditor();
const bold = screen.getByRole("button", { name: "Bold" });
const alignCenter = screen.getByRole("button", { name: "Align Center" });
// Focus a button in the middle
alignCenter.element().focus();
// Press Home
await userEvent.keyboard("{Home}");
await vi.waitFor(() => {
expect(document.activeElement).toBe(bold.element());
});
});
it("End moves focus to last button", async () => {
const { screen } = await renderEditor();
const bold = screen.getByRole("button", { name: "Bold" });
// Focus the first button
bold.element().focus();
// Press End — last button is Spotlight Mode (or Exit Spotlight Mode)
await userEvent.keyboard("{End}");
await vi.waitFor(() => {
const active = document.activeElement as HTMLElement;
// Last button in the toolbar — its aria-label should be "Spotlight Mode"
expect(active.getAttribute("aria-label")).toBe("Spotlight Mode");
});
});
it("ArrowRight wraps from last to first button", async () => {
const { screen } = await renderEditor();
const spotlightBtn = screen.getByRole("button", { name: "Spotlight Mode" });
const bold = screen.getByRole("button", { name: "Bold" });
// Focus the last button
spotlightBtn.element().focus();
// Press ArrowRight - should wrap to first
await userEvent.keyboard("{ArrowRight}");
await vi.waitFor(() => {
expect(document.activeElement).toBe(bold.element());
});
});
it("ArrowLeft wraps from first to last button", async () => {
const { screen } = await renderEditor();
const bold = screen.getByRole("button", { name: "Bold" });
// Focus the first button
bold.element().focus();
// Press ArrowLeft - should wrap to last
await userEvent.keyboard("{ArrowLeft}");
await vi.waitFor(() => {
const active = document.activeElement as HTMLElement;
expect(active.getAttribute("aria-label")).toBe("Spotlight Mode");
});
});
});

View File

@@ -0,0 +1,324 @@
/**
* Block Transform Tests
*
* Tests that block transformations work correctly:
* - Transform paragraph to headings (H1, H2, H3)
* - Transform to blockquote, code block
* - Transform to bullet and ordered lists
* - Duplicate block preserves content
* - Delete block removes content
*
* These transformations are used by the BlockMenu component.
*/
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { blockTransforms } from "../../src/components/editor/BlockMenu";
describe("Block Transforms", () => {
let editor: Editor;
beforeEach(() => {
editor = new Editor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
],
content: "<p>Test content</p>",
});
});
afterEach(() => {
editor.destroy();
});
describe("Transform to Paragraph", () => {
it("transforms heading to paragraph", () => {
editor.commands.setHeading({ level: 1 });
expect(editor.isActive("heading", { level: 1 })).toBe(true);
const transform = blockTransforms.find((t) => t.id === "paragraph");
transform?.transform(editor);
expect(editor.isActive("heading")).toBe(false);
expect(editor.isActive("paragraph")).toBe(true);
});
it("preserves text content when transforming to paragraph", () => {
editor.commands.setHeading({ level: 2 });
const transform = blockTransforms.find((t) => t.id === "paragraph");
transform?.transform(editor);
expect(editor.getText().trim()).toBe("Test content");
});
});
describe("Transform to Heading", () => {
it("transforms paragraph to heading 1", () => {
const transform = blockTransforms.find((t) => t.id === "heading1");
transform?.transform(editor);
expect(editor.isActive("heading", { level: 1 })).toBe(true);
});
it("transforms paragraph to heading 2", () => {
const transform = blockTransforms.find((t) => t.id === "heading2");
transform?.transform(editor);
expect(editor.isActive("heading", { level: 2 })).toBe(true);
});
it("transforms paragraph to heading 3", () => {
const transform = blockTransforms.find((t) => t.id === "heading3");
transform?.transform(editor);
expect(editor.isActive("heading", { level: 3 })).toBe(true);
});
it("preserves text content when transforming to heading", () => {
const transform = blockTransforms.find((t) => t.id === "heading1");
transform?.transform(editor);
expect(editor.getText().trim()).toBe("Test content");
});
it("can change heading level", () => {
const h1Transform = blockTransforms.find((t) => t.id === "heading1");
h1Transform?.transform(editor);
expect(editor.isActive("heading", { level: 1 })).toBe(true);
const h2Transform = blockTransforms.find((t) => t.id === "heading2");
h2Transform?.transform(editor);
expect(editor.isActive("heading", { level: 2 })).toBe(true);
expect(editor.isActive("heading", { level: 1 })).toBe(false);
});
});
describe("Transform to Blockquote", () => {
it("transforms paragraph to blockquote", () => {
const transform = blockTransforms.find((t) => t.id === "blockquote");
transform?.transform(editor);
expect(editor.isActive("blockquote")).toBe(true);
});
it("preserves text content when transforming to blockquote", () => {
const transform = blockTransforms.find((t) => t.id === "blockquote");
transform?.transform(editor);
expect(editor.getText().trim()).toBe("Test content");
});
it("toggles blockquote off when already active", () => {
const transform = blockTransforms.find((t) => t.id === "blockquote");
transform?.transform(editor);
expect(editor.isActive("blockquote")).toBe(true);
transform?.transform(editor);
expect(editor.isActive("blockquote")).toBe(false);
});
});
describe("Transform to Code Block", () => {
it("transforms paragraph to code block", () => {
const transform = blockTransforms.find((t) => t.id === "codeBlock");
transform?.transform(editor);
expect(editor.isActive("codeBlock")).toBe(true);
});
it("preserves text content when transforming to code block", () => {
const transform = blockTransforms.find((t) => t.id === "codeBlock");
transform?.transform(editor);
expect(editor.getText().trim()).toBe("Test content");
});
it("toggles code block off when already active", () => {
const transform = blockTransforms.find((t) => t.id === "codeBlock");
transform?.transform(editor);
expect(editor.isActive("codeBlock")).toBe(true);
transform?.transform(editor);
expect(editor.isActive("codeBlock")).toBe(false);
});
});
describe("Transform to Bullet List", () => {
it("transforms paragraph to bullet list", () => {
const transform = blockTransforms.find((t) => t.id === "bulletList");
transform?.transform(editor);
expect(editor.isActive("bulletList")).toBe(true);
});
it("preserves text content when transforming to bullet list", () => {
const transform = blockTransforms.find((t) => t.id === "bulletList");
transform?.transform(editor);
expect(editor.getText().trim()).toBe("Test content");
});
it("toggles bullet list off when already active", () => {
const transform = blockTransforms.find((t) => t.id === "bulletList");
transform?.transform(editor);
expect(editor.isActive("bulletList")).toBe(true);
transform?.transform(editor);
expect(editor.isActive("bulletList")).toBe(false);
});
});
describe("Transform to Ordered List", () => {
it("transforms paragraph to ordered list", () => {
const transform = blockTransforms.find((t) => t.id === "orderedList");
transform?.transform(editor);
expect(editor.isActive("orderedList")).toBe(true);
});
it("preserves text content when transforming to ordered list", () => {
const transform = blockTransforms.find((t) => t.id === "orderedList");
transform?.transform(editor);
expect(editor.getText().trim()).toBe("Test content");
});
it("can switch between bullet and ordered list", () => {
const bulletTransform = blockTransforms.find((t) => t.id === "bulletList");
bulletTransform?.transform(editor);
expect(editor.isActive("bulletList")).toBe(true);
const orderedTransform = blockTransforms.find((t) => t.id === "orderedList");
orderedTransform?.transform(editor);
expect(editor.isActive("orderedList")).toBe(true);
expect(editor.isActive("bulletList")).toBe(false);
});
});
describe("Transform metadata", () => {
it("has all required transform definitions", () => {
const expectedIds = [
"paragraph",
"heading1",
"heading2",
"heading3",
"blockquote",
"codeBlock",
"bulletList",
"orderedList",
];
for (const id of expectedIds) {
const transform = blockTransforms.find((t) => t.id === id);
expect(transform, `Transform "${id}" should exist`).toBeDefined();
expect(transform?.label, `Transform "${id}" should have a label`).toBeTruthy();
expect(transform?.icon, `Transform "${id}" should have an icon`).toBeDefined();
expect(
typeof transform?.transform,
`Transform "${id}" should have a transform function`,
).toBe("function");
}
});
});
});
describe("Block Duplicate", () => {
let editor: Editor;
beforeEach(() => {
editor = new Editor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
],
content: "<p>First paragraph</p><p>Second paragraph</p>",
});
});
afterEach(() => {
editor.destroy();
});
it("duplicates block and preserves content", () => {
// Position cursor in first paragraph
editor.commands.setTextSelection(1);
const { selection } = editor.state;
const { $from, $to } = selection;
// Get the block node at current position
const blockStart = $from.start($from.depth);
const blockEnd = $to.end($to.depth);
// Get the content to duplicate
const slice = editor.state.doc.slice(blockStart, blockEnd);
// Insert after current block
editor
.chain()
.focus()
.command(({ tr }) => {
tr.insert(blockEnd + 1, slice.content);
return true;
})
.run();
const json = editor.getJSON();
expect(json.content?.length).toBe(3); // Now 3 paragraphs
// Check content
const texts =
json.content?.map((block) => {
if (block.type === "paragraph" && block.content?.[0]) {
return (block.content[0] as { text?: string }).text;
}
return "";
}) ?? [];
expect(texts[0]).toBe("First paragraph");
expect(texts[1]).toBe("First paragraph"); // Duplicated
expect(texts[2]).toBe("Second paragraph");
});
});
describe("Block Delete", () => {
let editor: Editor;
beforeEach(() => {
editor = new Editor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
],
content: "<p>First paragraph</p><p>Second paragraph</p>",
});
});
afterEach(() => {
editor.destroy();
});
it("deletes block at cursor position", () => {
// Position cursor in first paragraph
editor.commands.setTextSelection(1);
editor.commands.deleteNode("paragraph");
const json = editor.getJSON();
expect(json.content?.length).toBe(1); // Now 1 paragraph
// Check remaining content
const text =
json.content?.[0]?.type === "paragraph" && json.content[0].content?.[0]
? (json.content[0].content[0] as { text?: string }).text
: "";
expect(text).toBe("Second paragraph");
});
});