Repository navigation
fix(editor): test and support Tiptap 3.32 #4401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
1ce5e56
8da5483
f5a0b04
b61976d
c6d7985
3304ab9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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( | ||||||||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion]
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||||||||||||||||||||||||||||
|
|
@@ -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(); | ||||||||||||||||||||||||||||
|
|
@@ -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(); | ||||||||||||||||||||||||||||
|
|
@@ -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; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| 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"; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [needs fixing] This new internal import omits the
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed the new import to use |
||||||
| 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";'); | ||||||
| }); | ||||||
| }); | ||||||
There was a problem hiding this comment.
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.mdrequires useful upgrade explanations to appear in the canonical feature documentation too, butdocs/src/content/docs/guides/working-with-content.mdxstill 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.There was a problem hiding this comment.
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.