Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/tall-hats-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@emdash-cms/admin": patch
"emdash": patch
---

Fixes responsive table widths in the admin editor with Tiptap 3.32.0. Gives both editors' textboxes accessible names for screen readers.

Pins the editor's Tiptap dependencies to exactly 3.32.0 so installations use the version covered by the editor tests. Code-block highlighting keeps its existing lowlight extension version. Sites or plugins that share Tiptap dependencies with EmDash should align them to 3.32.0 to avoid installing multiple copies.

Links applied at an empty caret now end when you type a space, following Tiptap's updated behavior. In the visual editor, typing a space at the end of an existing link also ends the link. Text typed after that space is not part of the link.
4 changes: 4 additions & 0 deletions docs/src/content/docs/guides/working-with-content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ Use the toolbar to format the selected text. To insert a block, select the add-b
a paragraph or type `/` and search for a block by name. Image and gallery blocks open the same media
picker used by image fields.

To create a link with a multi-word label, type the label first, select the whole label, then choose
**Add link** in the toolbar and enter the destination. If you add a link at an empty caret and then
type its label, the link ends at the first space you type. Text after that space is not linked.

You can also drag image and video files into the editor or paste a copied image or screenshot. Each
file uploads to the Media Library and appears between the surrounding blocks when the upload
finishes. If an upload fails, the reason appears where the file would have gone.
Expand Down
4 changes: 4 additions & 0 deletions e2e/tests/visual-editing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,10 @@
const editor = page.locator(".emdash-inline-editor");
await expect(editor).toBeVisible({ timeout: 15000 });

await expect(
page.getByRole("textbox", { name: "Rich text editor", exact: true }),
).toBeEditable();

// The island failed to hydrate with zero height when highlight.js/lib/core
// was served raw as CommonJS, so also assert some static content rendered
// inside the editor rather than disappearing.
Expand Down Expand Up @@ -501,7 +505,7 @@
await turnInto.click();
await expect(menu).toBeVisible();
// Closes the menu, then opens it again a task later, while it fades out.
await turnInto.evaluate(async (trigger: HTMLElement) => {

Check failure on line 508 in e2e/tests/visual-editing.spec.ts

View workflow job for this annotation

GitHub Actions / E2E shard (7/8)

[chromium] › e2e/tests/visual-editing.spec.ts:486:2 › Inline Editor › shows a tooltip or menu reopened while it fades out

1) [chromium] › e2e/tests/visual-editing.spec.ts:486:2 › Inline Editor › shows a tooltip or menu reopened while it fades out Error: locator.evaluate: Execution context was destroyed, most likely because of a navigation. 506 | await expect(menu).toBeVisible(); 507 | // Closes the menu, then opens it again a task later, while it fades out. > 508 | await turnInto.evaluate(async (trigger: HTMLElement) => { | ^ 509 | trigger.click(); 510 | await new Promise((resolve) => setTimeout(resolve, 0)); 511 | trigger.click(); at /home/runner/work/emdash/emdash/e2e/tests/visual-editing.spec.ts:508:18
trigger.click();
await new Promise((resolve) => setTimeout(resolve, 0));
trigger.click();
Expand Down
9 changes: 6 additions & 3 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -468,8 +468,8 @@ function linkAtCaret(state: EditorState): ProseMirrorMark | undefined {

/**
* Links the selected text, or the whole link around the caret, to `value`.
* At a caret outside a link, the text typed next gets the link. Returns
* `false`, changing nothing, for a URL the editor won't link.
* At a caret outside a link, the text typed next gets the link up to the first
* space. Returns `false`, changing nothing, for a URL the editor won't link.
*/
function setSelectedTextLink(editor: Editor, value: string): boolean {
const href = normalizeLinkHref(value);
Expand Down Expand Up @@ -3974,6 +3974,7 @@ export function PortableTextEditor({
[], // Created once — all mutable state accessed via refs
);

const editorLabel = t`Rich text editor`;
// Stable editorProps reference — a new object every render would cause
// compareOptions to call setOptions → updateState → plugin teardown →
// Suggestion onExit → setSlashMenuState → re-render → infinite loop.
Expand All @@ -3984,13 +3985,15 @@ export function PortableTextEditor({
scrollThreshold: isDocument ? { top: 96, right: 0, bottom: 0, left: 0 } : 0,
scrollMargin: isDocument ? { top: 96, right: 5, bottom: 5, left: 5 } : 5,
attributes: {
"aria-label": editorLabel,
...(ariaLabelledby ? { "aria-labelledby": ariaLabelledby } : {}),
class: isDocument
? "emdash-document flow-root min-h-32 pb-2 focus:outline-none"
: "prose prose-sm sm:prose-base dark:prose-invert flow-root w-full max-w-[calc(75ch+8rem)] mx-auto focus:outline-none min-h-[200px] p-4 ps-14 pe-14 sm:ps-16 sm:pe-16",
dir: "auto",
},
}),
[isDocument],
[ariaLabelledby, editorLabel, isDocument],
);

const editor = useEditor({
Expand Down
4 changes: 4 additions & 0 deletions packages/admin/src/components/editor/TableExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ function handleRtlTableKey(view: EditorView, event: KeyboardEvent): boolean {
}

export const EmDashTable = Table.extend({
addNodeView() {
// The resize plugin owns the responsive table view, including in read-only editors.
return null;
},
addProseMirrorPlugins() {
return [
new Plugin({
Expand Down
94 changes: 94 additions & 0 deletions packages/admin/tests/editor/CodeBlockNode.highlighting.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Editor } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect } from "react";
import { describe, expect, it, vi } from "vitest";

import { CodeBlockExtension } from "../../src/components/editor/CodeBlockNode.js";
import { render } from "../utils/render.js";

import "../../src/styles.css";

function CodeEditor({
language,
code,
onEditor,
}: {
language: string | null | undefined;
code: string;
onEditor?: (editor: Editor) => void;
}) {
const editor = useEditor({
extensions: [StarterKit.configure({ codeBlock: false }), CodeBlockExtension],
content: {
type: "doc",
content: [
{ type: "codeBlock", attrs: { language }, content: [{ type: "text", text: code }] },
],
},
immediatelyRender: true,
});

useEffect(() => {
if (editor) onEditor?.(editor);
}, [editor, onEditor]);
return <EditorContent editor={editor} />;
}

describe("CodeBlockExtension highlighting", () => {
it.each([
["javascript", 'const greeting = "hello";'],
["dockerfile", "FROM node:22"],
])("renders syntax tokens for %s", async (language, code) => {
let editor: Editor | undefined;
const screen = await render(
<CodeEditor language={language} code={code} onEditor={(instance) => (editor = instance)} />,
);

await vi.waitFor(() => {
expect(screen.container.querySelector("code")?.textContent).toBe(code);
expect(screen.container.querySelectorAll('span[class*="hljs-"]').length).toBeGreaterThan(0);
});

expect(JSON.stringify(editor!.getJSON())).not.toContain("hljs-");
});

it.each(["plaintext", "astro", "zig", "custom-language", null, undefined])(
"leaves %s code unhighlighted",
async (language) => {
const screen = await render(
<CodeEditor language={language} code={'const greeting = "hello";'} />,
);

await vi.waitFor(() => {
expect(screen.container.querySelector("code")?.textContent).toBe(
'const greeting = "hello";',
);
});
expect(screen.container.querySelectorAll('span[class*="hljs-"]')).toHaveLength(0);
},
);

it("updates decorations when the selected language changes", async () => {
let editor: Editor | undefined;
const screen = await render(
<CodeEditor
language="javascript"
code={'const greeting = "hello";'}
onEditor={(instance) => (editor = instance)}
/>,
);
const editorElement = screen.container.querySelector(".ProseMirror")!;

await vi.waitFor(() => {
expect(editorElement.querySelectorAll('span[class*="hljs-"]').length).toBeGreaterThan(0);
});

editor!.commands.setNodeSelection(0);
editor!.commands.updateAttributes("codeBlock", { language: "astro" });
await vi.waitFor(() => {
expect(editorElement.querySelectorAll('span[class*="hljs-"]')).toHaveLength(0);
});
expect(editor!.getJSON().content?.[0]?.content?.[0]?.text).toBe('const greeting = "hello";');
});
});
55 changes: 1 addition & 54 deletions packages/admin/tests/editor/CodeBlockNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import { Editor, type Content } from "@tiptap/core";
import { closeHistory } from "@tiptap/pm/history";
import StarterKit from "@tiptap/starter-kit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { CodeBlockExtension } from "../../src/components/editor/CodeBlockNode";

Expand Down Expand Up @@ -203,57 +203,4 @@ describe("CodeBlockExtension", () => {
expect(editor.state.selection.toJSON()).toEqual({ type: "all" });
}
});

it.each([
["javascript", 'const greeting = "hello";'],
["dockerfile", "FROM node:22"],
])("renders syntax tokens for %s", async (language, code) => {
editor.commands.insertContent({
type: "codeBlock",
attrs: { language },
content: [{ type: "text", text: code }],
});

await vi.waitFor(() => {
expect(element.querySelectorAll('span[class*="hljs-"]').length).toBeGreaterThan(0);
});

expect(JSON.stringify(editor.getJSON())).not.toContain("hljs-");
});

it.each(["plaintext", "astro", "zig", "custom-language", null, undefined])(
"leaves %s code unhighlighted",
async (language) => {
editor.commands.insertContent({
type: "codeBlock",
attrs: { language },
content: [{ type: "text", text: 'const greeting = "hello";' }],
});

await vi.waitFor(() => {
expect(element.querySelectorAll('span[class*="hljs-"]')).toHaveLength(0);
});
},
);

it("updates decorations when the selected language changes", async () => {
editor.commands.insertContent({
type: "codeBlock",
attrs: { language: "javascript" },
content: [{ type: "text", text: 'const greeting = "hello";' }],
});

await vi.waitFor(() => {
expect(element.querySelectorAll('span[class*="hljs-"]').length).toBeGreaterThan(0);
});

editor.commands.setNodeSelection(0);
editor.commands.updateAttributes("codeBlock", { language: "astro" });

await vi.waitFor(() => {
expect(element.querySelectorAll('span[class*="hljs-"]')).toHaveLength(0);
});
const node = editor.getJSON().content?.find((item) => item.type === "codeBlock");
expect(node?.content?.[0]?.text).toBe('const greeting = "hello";');
});
});
19 changes: 19 additions & 0 deletions packages/admin/tests/editor/PortableTextEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1521,6 +1521,25 @@ describe("Portable Text ↔ ProseMirror conversion", () => {
// =============================================================================

describe("Editor component behaviour", () => {
it("names the editable textbox when no field label is supplied", async () => {
const screen = await render(<PortableTextEditor />);
const textbox = screen.getByRole("textbox", { name: "Rich text editor", exact: true });
await expect.element(textbox).toBeVisible();
expect(textbox.element().isContentEditable).toBe(true);
});

it("names the editable textbox from its field label", async () => {
const screen = await render(
<>
<span id="rich-text-label">Article body</span>
<PortableTextEditor aria-labelledby="rich-text-label" />
</>,
);
const textbox = screen.getByRole("textbox", { name: "Article body", exact: true });
await expect.element(textbox).toBeVisible();
expect(textbox.element().isContentEditable).toBe(true);
});

it("shows placeholder text in empty editor", async () => {
await render(<PortableTextEditor placeholder="Write something..." />);
const pm = await waitForEditor();
Expand Down
6 changes: 5 additions & 1 deletion packages/admin/tests/editor/image-toolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,11 @@ describe("Image toolbar", () => {
const { editor, img, getPanel } = await setup({ extraImage: true });
editor.commands.selectAll();
await vi.waitFor(() =>
expect(document.querySelectorAll(".ProseMirror .ProseMirror-selectednode")).toHaveLength(2),
expect(
[...document.querySelectorAll(".ProseMirror img")].filter((image) =>
image.closest(".emdash-block-selected"),
),
).toHaveLength(2),
);
const toolbar = await selectImage(img);

Expand Down
23 changes: 13 additions & 10 deletions packages/admin/tests/editor/table-resize.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,16 +186,19 @@ afterEach(() => {
});

describe("direction-aware table resizing", () => {
it("renders a fluid table with a numeric preferred minimum width", () => {
const { host } = createEditor("ltr");
const table = host.querySelector("table");

expect(table).toBeTruthy();
expect(table?.style.width).toBe("100%");
expect(table?.style.minWidth).toBe("288px");
expect(host.querySelector(".tableWrapper")).toBeTruthy();
expect(ResponsiveTableView).toBeTypeOf("function");
});
it.each([true, false])(
"renders a fluid table with a numeric preferred minimum width (editable: %s)",
(editable) => {
const { host } = createEditor("ltr", { editable });
const table = host.querySelector("table");

expect(table).toBeTruthy();
expect(table?.style.width).toBe("100%");
expect(table?.style.minWidth).toBe("288px");
expect(host.querySelector(".tableWrapper")).toBeTruthy();
expect(ResponsiveTableView).toBeTypeOf("function");
},
);

it("enforces the visual minimum for valid sub-minimum stored widths", () => {
const { editor, host } = createEditor("ltr", { content: tableContent(Array(10).fill(1)) });
Expand Down
9 changes: 5 additions & 4 deletions packages/admin/tests/editor/toolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ describe("Toolbar Presence and Structure", () => {
it("exposes the permanent Table control and keeps other extended actions in the block menu", async () => {
const { screen } = await renderEditor();
const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element();
await expect.element(screen.getByRole("button", { name: "Add link" })).toBeVisible();
await expect.element(getToolbarButton(screen, "Add link")).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Insert image" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Insert HTML" })).toBeVisible();
const table = screen.getByRole("button", { name: "Table" });
Expand Down Expand Up @@ -707,7 +707,7 @@ describe("Toolbar Presence and Structure", () => {
it("renders the link editor outside the horizontally scrolling toolbar", async () => {
const { screen } = await renderEditor();
const toolbar = screen.getByRole("toolbar", { name: "Text formatting" }).element();
screen.getByRole("button", { name: "Add link" }).element().click();
getToolbarButton(screen, "Add link").element().click();

await vi.waitFor(() => {
const input = document.querySelector<HTMLInputElement>(
Expand Down Expand Up @@ -1887,7 +1887,7 @@ describe("Link Insertion", () => {
});
});

it("applies a link to text typed after an empty caret", async () => {
it("exits the link when a space is typed after linked text", async () => {
const { screen, editor } = await renderEditor();
editor
.chain()
Expand Down Expand Up @@ -1916,7 +1916,8 @@ describe("Link Insertion", () => {
await vi.waitFor(() => {
const link = screen.container.querySelector("a");
expect(link?.getAttribute("href")).toBe("https://example.com");
expect(link?.textContent).toBe("linked text");
expect(link?.textContent).toBe("linked");
expect(link?.nextSibling?.textContent).toBe(" text");
});
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/InlinePortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1273,6 +1273,7 @@ export function InlinePortableTextEditor({
immediatelyRender: false,
editorProps: {
attributes: {
"aria-label": "Rich text editor",
class: "prose prose-sm sm:prose-base dark:prose-invert max-w-none emdash-inline-editor",
dir: "auto",
},
Expand Down
Loading
Loading