Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 and floating toolbar keyboard controls in both the admin and inline editors with Tiptap 3.32.0.

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. Text typed after that space is not part of the link.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The changed link-authoring behavior is explained only in the release note. .changeset/README.md requires useful upgrade explanations to appear in the canonical feature documentation too, but docs/src/content/docs/guides/working-with-content.mdx still gives no guidance on this. Add it under Write rich text, including the practical way to create a multi-word link: type the label first, select the whole label, then apply the link. Editors can then get the previous outcome after upgrading. Note that the other reviewer judged the changeset acceptable as written and treated the behavior change as an accepted Tiptap change. It does weaken the "add a link at an empty caret, then type" flow, because only the first word is now linked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added guidance under Write rich text explaining the space boundary and the multi-word workflow: type the label, select it, then apply the link. The docs build passes.

15 changes: 7 additions & 8 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4806,7 +4806,7 @@ const FORMATTING_MARKS = [
] as const;

function playBubbleEntrance(element: HTMLElement | null) {
const surface = element?.firstElementChild;
const surface = element;
if (!(surface instanceof HTMLElement) || typeof surface.animate !== "function") return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
surface.animate(
Expand Down Expand Up @@ -4929,9 +4929,8 @@ function EditorBubbleMenu({
}) => {
elements.floating.style.maxWidth = `${Math.max(0, availableWidth)}px`;
// The surface scrolls itself, so no wrapper clips its rounded ring.
const surface = elements.floating.firstElementChild;
const surface = elements.floating;
if (surface instanceof HTMLElement) {
surface.style.maxWidth = "100%";
surface.style.overflowX = "auto";
}
},
Comment on lines 4933 to 4940

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] elements.floating is always an HTMLElement, so the surface alias and the instanceof guard don't check anything any more. The same leftover pattern appears in playBubbleEntrance (const surface = element;, line 4809) and in ImageBubbleMenu.playEntrance (const toolbar = menu;, line 5506), where the instanceof check sits after menu?.isConnected. Simplifying them makes it clear that the floating element itself is now the surface:

Suggested change
elements.floating.style.maxWidth = `${Math.max(0, availableWidth)}px`;
// The surface scrolls itself, so no wrapper clips its rounded ring.
const surface = elements.floating.firstElementChild;
const surface = elements.floating;
if (surface instanceof HTMLElement) {
surface.style.maxWidth = "100%";
surface.style.overflowX = "auto";
}
},
elements.floating.style.maxWidth = `${Math.max(0, availableWidth)}px`;
// The surface scrolls itself, so no wrapper clips its rounded ring.
elements.floating.style.overflowX = "auto";
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplified the sizing and animation paths to operate directly on the floating surface, removing the leftover aliases and redundant guards.

Expand Down Expand Up @@ -5504,7 +5503,7 @@ function ImageBubbleMenu({
// A quick fade and scale in, played when the toolbar appears or moves to another image.
const playEntrance = React.useCallback(() => {
const menu = menuRef.current;
const toolbar = menu?.firstElementChild;
const toolbar = menu;
if (!menu?.isConnected || menu.hidden || !(toolbar instanceof HTMLElement)) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
for (const animation of toolbar.getAnimations()) animation.cancel();
Expand Down Expand Up @@ -5720,15 +5719,15 @@ function ImageBubbleMenu({
}}
// Capture, so an open hint can't stop Escape before the toolbar sees it.
onKeyDownCapture={(event) => {
if (event.key !== "Escape" || event.nativeEvent.isComposing) return;
if (event.nativeEvent.key !== "Escape" || event.nativeEvent.isComposing) return;
event.stopPropagation();
returnToEditor();
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (
event.key === "Tab" &&
event.shiftKey &&
event.nativeEvent.key === "Tab" &&
event.nativeEvent.shiftKey &&
event.target === menuRef.current?.querySelector("button, input")
) {
event.preventDefault();
Expand Down Expand Up @@ -6624,7 +6623,7 @@ function moveToolbarFocus(event: React.KeyboardEvent<HTMLElement>, vertical: boo
const forward = getComputedStyle(toolbar).direction === "rtl" ? -1 : 1;

let nextIndex: number;
switch (event.key) {
switch (event.nativeEvent.key) {
case "ArrowRight":
nextIndex = currentIndex + forward;
break;
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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This new internal import omits the .js extension required by the root AGENTS.md ESM import convention.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the new import to use 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";');
});
});
10 changes: 3 additions & 7 deletions packages/admin/tests/editor/bubble-menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,12 +279,6 @@ function hasNonZeroRadius(element: HTMLElement): boolean {
return declaredValues.some((value) => value > 0);
}

function expectRoundedFloatingWrapper(menu: HTMLElement) {
const wrapper = findScrollableAncestor(menu);
expect(wrapper).toBeTruthy();
expect(hasNonZeroRadius(wrapper!)).toBe(true);
}

/** Get a bubble menu button by aria-label */
function getBubbleButton(menu: HTMLElement, label: string): HTMLButtonElement | null {
return menu.querySelector(`[aria-label="${label}"]`);
Expand Down Expand Up @@ -638,7 +632,9 @@ describe("Bubble Menu", () => {
expect(menuRect.top).toBeGreaterThanOrEqual(
formattingToolbar!.getBoundingClientRect().bottom,
);
expectRoundedFloatingWrapper(tableToolbar);
expect(getComputedStyle(tableToolbar).overflowX).toBe("auto");
expect(hasNonZeroRadius(tableToolbar)).toBe(true);
expect(findScrollableAncestor(tableToolbar)).toBeNull();
});
});

Expand Down
8 changes: 6 additions & 2 deletions 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 Expand Up @@ -466,7 +470,7 @@ describe("Image toolbar", () => {
expect(box.right).toBeLessThanOrEqual(window.innerWidth);
}
const top = (label: string) => button(toolbar, label).getBoundingClientRect().top;
expect(top("Right")).toBe(top("Replace"));
await vi.waitFor(() => expect(top("Right")).toBe(top("Replace")));
expect(top("Add link")).toBeGreaterThan(top("Replace"));
} finally {
await page.viewport(1280, 800);
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
Loading
Loading