diff --git a/packages/core/src/blocks/defaultBlocks.test.ts b/packages/core/src/blocks/defaultBlocks.test.ts new file mode 100644 index 0000000000..27b4e5b52e --- /dev/null +++ b/packages/core/src/blocks/defaultBlocks.test.ts @@ -0,0 +1,67 @@ +import { Editor, Extension, getSchema, Mark, Node } from "@tiptap/core"; +import { describe, expect, it } from "vite-plus/test"; +import { CommentMark } from "../comments/mark.js"; +import { NON_FORMATTING_MARK_GROUP } from "../schema/markGroups.js"; +import { defaultStyleSpecs } from "./defaultBlocks.js"; + +const extensions = [ + Node.create({ name: "doc", topNode: true, content: "text*" }), + Node.create({ name: "text", group: "inline" }), + ...Object.values(defaultStyleSpecs).map((style) => style.implementation.mark), +]; + +describe("inline code mark exclusions", () => { + it.each([false, true])( + "builds exclusions from enabled marks (comments enabled: %s)", + (withComments) => { + const editor = new Editor({ + element: null, + extensions: [ + ...extensions, + Extension.create({ + name: "customMarks", + addExtensions() { + return [ + Mark.create({ name: "customStyle" }), + ...(withComments + ? [ + CommentMark, + Mark.create({ + name: "customAnnotation", + group: NON_FORMATTING_MARK_GROUP, + }), + ] + : []), + ]; + }, + }), + ], + }); + + try { + const { marks } = editor.schema; + const code = marks.code; + expect(code.spec.excludes).not.toBe("_"); + for (const mark of Object.values(marks)) { + expect(code.excludes(mark)).toBe( + !mark.spec.group?.split(" ").includes(NON_FORMATTING_MARK_GROUP), + ); + } + + if (withComments) { + const codeMark = code.create(); + const commentMark = marks.comment.create({ threadId: "thread" }); + expect(codeMark.addToSet([commentMark])).toHaveLength(2); + expect(commentMark.addToSet([codeMark])).toHaveLength(2); + } + } finally { + editor.destroy(); + } + }, + ); + + it("supports schema creation without an editor", () => { + const { marks } = getSchema(extensions); + expect(marks.code.excludes(marks.bold)).toBe(true); + }); +}); diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts index d2eeaa0aa3..9dfd6b2b25 100644 --- a/packages/core/src/blocks/defaultBlocks.ts +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -18,6 +18,7 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "../schema/index.js"; +import { marksExcludingNonFormattingMarks } from "../schema/markGroups.js"; import { createAudioBlockSpec, createBulletListItemBlockSpec, @@ -139,6 +140,9 @@ export const defaultStyleSpecs = { strike: createStyleSpecFromTipTapMark(Strike, "boolean"), code: createStyleSpecFromTipTapMark( Code.extend({ + excludes() { + return marksExcludingNonFormattingMarks(this.editor); + }, addInputRules() { return [ // Matches any string that starts with a backtick, ends with a diff --git a/packages/core/src/schema/markGroups.ts b/packages/core/src/schema/markGroups.ts index 47f03531be..3681cf6f23 100644 --- a/packages/core/src/schema/markGroups.ts +++ b/packages/core/src/schema/markGroups.ts @@ -1,4 +1,4 @@ -import { Editor, getExtensionField } from "@tiptap/core"; +import { Editor, flattenExtensions, getExtensionField } from "@tiptap/core"; /** * ProseMirror mark group for "non-formatting" marks: comments and the @@ -36,7 +36,9 @@ export function nonFormattingMarks(editor: Editor | undefined): string { if (!editor) { return ""; } - const hasNonFormattingMark = editor.options.extensions.some((extension) => { + const hasNonFormattingMark = flattenExtensions( + editor.options.extensions, + ).some((extension) => { if (extension.type !== "mark") { return false; } @@ -48,3 +50,33 @@ export function nonFormattingMarks(editor: Editor | undefined): string { }); return hasNonFormattingMark ? NON_FORMATTING_MARK_GROUP : ""; } + +/** + * The `excludes` field value for marks which may coexist with annotation marks + * but should exclude all other marks. + * + * Returns `"_"` when no editor is available, preserving ProseMirror's default + * behavior for schema creation outside an editor. + */ +export function marksExcludingNonFormattingMarks( + editor: Editor | undefined, +): string { + if (!editor) { + return "_"; + } + + return flattenExtensions(editor.options.extensions) + .filter((extension) => { + if (extension.type !== "mark") { + return false; + } + + const group = getExtensionField(extension, "group") as string | undefined; + return ( + typeof group !== "string" || + !group.split(" ").includes(NON_FORMATTING_MARK_GROUP) + ); + }) + .map((extension) => extension.name) + .join(" "); +} diff --git a/tests/src/end-to-end/comments/comments.test.tsx b/tests/src/end-to-end/comments/comments.test.tsx index b5ec2a6267..fd7303ad03 100644 --- a/tests/src/end-to-end/comments/comments.test.tsx +++ b/tests/src/end-to-end/comments/comments.test.tsx @@ -1,7 +1,7 @@ import App from "@examples/07-collaboration/09-comments-testing/src/App"; import { beforeEach, describe, expect, test, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; -import { browserName, page, userEvent } from "../../utils/context.js"; +import { browserName, MOD, page, userEvent } from "../../utils/context.js"; import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; import { expectElement, @@ -190,6 +190,36 @@ describe("Check Comments functionality", () => { ).toBeVisible(); }); + test("Should preserve existing comments when adding a code mark", async () => { + await focusOnEditor(); + + await userEvent.keyboard("hello"); + await doubleClickElement(page.getByText("hello").element()); + + await userEvent.click(await waitForSelector('[data-test="addcomment"]')); + await waitForSelector(".bn-thread"); + + await userEvent.keyboard("test comment"); + await userEvent.click(await waitForSelector('button[data-test="save"]')); + + // Re-select the commented text and toggle inline code on it (Cmd/Ctrl+E). + await doubleClickElement( + document.querySelectorAll("span.bn-thread-mark")[0] as HTMLElement, + ); + await userEvent.keyboard(`{${MOD}>}e{/${MOD}}`); + + // The comment must be preserved, and the text must now also be inline code, + // i.e. the comment and code marks coexist on the same text. + await expectElement( + await waitForSelector("span.bn-thread-mark"), + ).toBeVisible(); + await expectElement( + await waitForSelector( + "span.bn-thread-mark code, code span.bn-thread-mark", + ), + ).toBeVisible(); + }); + test.skipIf(browserName === "webkit")( "Should select thread on first click and open link on second click", async () => {