From 302c6201aec58ea338784201a4f02c4f2a63f535 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 3 Jun 2026 16:28:13 +0200 Subject: [PATCH 1/3] Fixed comment marks not getting applied to inline code --- packages/core/src/blocks/defaultBlocks.ts | 18 +++++++++++ .../src/end-to-end/comments/comments.test.tsx | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts index d2eeaa0aa3..1166972fb0 100644 --- a/packages/core/src/blocks/defaultBlocks.ts +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -1,4 +1,5 @@ import { InputRule, markInputRule } from "@tiptap/core"; +import type { MarkType } from "@tiptap/pm/model"; import Bold from "@tiptap/extension-bold"; import Code from "@tiptap/extension-code"; import Italic from "@tiptap/extension-italic"; @@ -139,6 +140,23 @@ export const defaultStyleSpecs = { strike: createStyleSpecFromTipTapMark(Strike, "boolean"), code: createStyleSpecFromTipTapMark( Code.extend({ + onBeforeCreate() { + // By default, code marks are configured to not overlap with any other + // marks with `exclude: "_"`. However, comment marks should still be + // allowed to overlap code marks. There is unfortunately no way to make + // the `exclude` option contain all possible marks except comments, so + // we instead remove the comment mark from it in `onBeforeCreate`. + const commentType = this.editor.schema.marks.comment; + const codeType = this.type as MarkType & { + excluded?: readonly MarkType[]; + }; + + if (commentType && codeType.excluded?.includes(commentType)) { + codeType.excluded = codeType.excluded.filter( + (markType) => markType !== commentType, + ); + } + }, addInputRules() { return [ // Matches any string that starts with a backtick, ends with a diff --git a/tests/src/end-to-end/comments/comments.test.tsx b/tests/src/end-to-end/comments/comments.test.tsx index b5ec2a6267..f879275cf9 100644 --- a/tests/src/end-to-end/comments/comments.test.tsx +++ b/tests/src/end-to-end/comments/comments.test.tsx @@ -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("{ControlOrMeta>}e{/ControlOrMeta}"); + + // 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 () => { From 41938f6e6c2ea0182832276ad3428240f7bde8b3 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 23 Sep 2026 10:40:25 +0200 Subject: [PATCH 2/3] Implemented PR feedback --- .../core/src/blocks/defaultBlocks.test.ts | 56 +++++++++++++++++++ packages/core/src/blocks/defaultBlocks.ts | 31 +++++----- .../src/end-to-end/comments/comments.test.tsx | 4 +- 3 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/blocks/defaultBlocks.test.ts diff --git a/packages/core/src/blocks/defaultBlocks.test.ts b/packages/core/src/blocks/defaultBlocks.test.ts new file mode 100644 index 0000000000..8974248a4c --- /dev/null +++ b/packages/core/src/blocks/defaultBlocks.test.ts @@ -0,0 +1,56 @@ +import { Editor, Extension, getSchema, Mark, Node } from "@tiptap/core"; +import { describe, expect, it } from "vite-plus/test"; +import { CommentMark } from "../comments/mark.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] : []), + ]; + }, + }), + ], + }); + + 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.name !== "comment"); + } + + 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 1166972fb0..0e79fe8889 100644 --- a/packages/core/src/blocks/defaultBlocks.ts +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -1,5 +1,4 @@ -import { InputRule, markInputRule } from "@tiptap/core"; -import type { MarkType } from "@tiptap/pm/model"; +import { flattenExtensions, InputRule, markInputRule } from "@tiptap/core"; import Bold from "@tiptap/extension-bold"; import Code from "@tiptap/extension-code"; import Italic from "@tiptap/extension-italic"; @@ -140,22 +139,20 @@ export const defaultStyleSpecs = { strike: createStyleSpecFromTipTapMark(Strike, "boolean"), code: createStyleSpecFromTipTapMark( Code.extend({ - onBeforeCreate() { - // By default, code marks are configured to not overlap with any other - // marks with `exclude: "_"`. However, comment marks should still be - // allowed to overlap code marks. There is unfortunately no way to make - // the `exclude` option contain all possible marks except comments, so - // we instead remove the comment mark from it in `onBeforeCreate`. - const commentType = this.editor.schema.marks.comment; - const codeType = this.type as MarkType & { - excluded?: readonly MarkType[]; - }; - - if (commentType && codeType.excluded?.includes(commentType)) { - codeType.excluded = codeType.excluded.filter( - (markType) => markType !== commentType, - ); + excludes() { + // Exclude all enabled marks except comments when building the schema. + // The extension manager is not available yet during schema creation. + if (!this.editor) { + return "_"; } + + return flattenExtensions(this.editor.options.extensions) + .filter( + (extension) => + extension.type === "mark" && extension.name !== "comment", + ) + .map((extension) => extension.name) + .join(" "); }, addInputRules() { return [ diff --git a/tests/src/end-to-end/comments/comments.test.tsx b/tests/src/end-to-end/comments/comments.test.tsx index f879275cf9..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, @@ -206,7 +206,7 @@ describe("Check Comments functionality", () => { await doubleClickElement( document.querySelectorAll("span.bn-thread-mark")[0] as HTMLElement, ); - await userEvent.keyboard("{ControlOrMeta>}e{/ControlOrMeta}"); + 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. From 71dcf9b5346bbbb7eab93bf473b41d44211afcb9 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 23 Sep 2026 13:20:31 +0200 Subject: [PATCH 3/3] refactor(core): generalize code mark exclusions --- .../core/src/blocks/defaultBlocks.test.ts | 15 ++++++-- packages/core/src/blocks/defaultBlocks.ts | 17 ++------- packages/core/src/schema/markGroups.ts | 36 +++++++++++++++++-- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/packages/core/src/blocks/defaultBlocks.test.ts b/packages/core/src/blocks/defaultBlocks.test.ts index 8974248a4c..27b4e5b52e 100644 --- a/packages/core/src/blocks/defaultBlocks.test.ts +++ b/packages/core/src/blocks/defaultBlocks.test.ts @@ -1,6 +1,7 @@ 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 = [ @@ -22,7 +23,15 @@ describe("inline code mark exclusions", () => { addExtensions() { return [ Mark.create({ name: "customStyle" }), - ...(withComments ? [CommentMark] : []), + ...(withComments + ? [ + CommentMark, + Mark.create({ + name: "customAnnotation", + group: NON_FORMATTING_MARK_GROUP, + }), + ] + : []), ]; }, }), @@ -34,7 +43,9 @@ describe("inline code mark exclusions", () => { const code = marks.code; expect(code.spec.excludes).not.toBe("_"); for (const mark of Object.values(marks)) { - expect(code.excludes(mark)).toBe(mark.name !== "comment"); + expect(code.excludes(mark)).toBe( + !mark.spec.group?.split(" ").includes(NON_FORMATTING_MARK_GROUP), + ); } if (withComments) { diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts index 0e79fe8889..9dfd6b2b25 100644 --- a/packages/core/src/blocks/defaultBlocks.ts +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -1,4 +1,4 @@ -import { flattenExtensions, InputRule, markInputRule } from "@tiptap/core"; +import { InputRule, markInputRule } from "@tiptap/core"; import Bold from "@tiptap/extension-bold"; import Code from "@tiptap/extension-code"; import Italic from "@tiptap/extension-italic"; @@ -18,6 +18,7 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "../schema/index.js"; +import { marksExcludingNonFormattingMarks } from "../schema/markGroups.js"; import { createAudioBlockSpec, createBulletListItemBlockSpec, @@ -140,19 +141,7 @@ export const defaultStyleSpecs = { code: createStyleSpecFromTipTapMark( Code.extend({ excludes() { - // Exclude all enabled marks except comments when building the schema. - // The extension manager is not available yet during schema creation. - if (!this.editor) { - return "_"; - } - - return flattenExtensions(this.editor.options.extensions) - .filter( - (extension) => - extension.type === "mark" && extension.name !== "comment", - ) - .map((extension) => extension.name) - .join(" "); + return marksExcludingNonFormattingMarks(this.editor); }, addInputRules() { return [ 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(" "); +}