From 9c150de0b9585a2ae5095cd0265e0812a2ef27c8 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Thu, 17 Sep 2026 15:32:13 -0300 Subject: [PATCH] fix(web): paste markdown with inline code inside bold, italic, or strikethrough Tiptap's code mark declares excludes: "_", so the bold+code, italic+code, and strike+code spans that markdown like **`x`** parses into fail node.check() inside insertContent. The paste handler had already called preventDefault, so nothing was inserted and no error surfaced. The composer's code mark now only excludes itself, matching the other marks and the serializer, which already emits **`x`** for bold+code. The round-trip tests now run doc.check() so schema-invalid mark sets fail the way insertContent does. Fixes #12247 --- apps/web/package.json | 1 + apps/web/src/components/ComposerPromptEditorTiptap.tsx | 5 ++++- apps/web/src/composer-rich-text-doc.test.ts | 9 +++++++++ apps/web/src/composer-rich-text-doc.ts | 8 ++++++++ pnpm-lock.yaml | 3 +++ 5 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index e6980cf778ea..ec7334e1fd86 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", + "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", "@tiptap/pm": "^3.31.3", diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 5126246dd05c..f8f0b07159fd 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -46,6 +46,7 @@ import { buildDocJson, buildTiptapContent, collapsedToFlat, + ComposerCodeExtension, ComposerTaskItemExtension, flatToCollapsed, flatToMarkdown, @@ -741,8 +742,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { dropcursor: false, gapcursor: false, trailingNode: false, + code: false, // Plain mode has no marks: typed markers stay literal characters. - ...(richText ? {} : { bold: false, italic: false, strike: false, code: false }), + ...(richText ? {} : { bold: false, italic: false, strike: false }), }), ComposerMentionExtension, ComposerSkillExtension, @@ -751,6 +753,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ComposerMarkersExtension, ...(richText ? [ + ComposerCodeExtension, TaskList, ComposerTaskItemExtension.extend({ addInputRules() { diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index a8798708974b..8fa311b1999c 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildDocJson, collapsedToFlat, + ComposerCodeExtension, ComposerTaskItemExtension, flatToCollapsed, flatToMarkdown, @@ -38,7 +39,9 @@ const schema = getSchemaByResolvedExtensions( dropcursor: false, gapcursor: false, trailingNode: false, + code: false, }), + ComposerCodeExtension, stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }), stubAtom("composer-skill", { skillName: { default: "" }, @@ -64,6 +67,8 @@ const schema = getSchemaByResolvedExtensions( function roundTrip(value: string) { const json = buildDocJson(value, (name) => ({ label: name, description: null })); const doc = ProseMirrorNode.fromJSON(schema, json); + // `insertContent` validates every node against the schema; `fromJSON` does not. + doc.check(); return serializeEditorDoc(doc); } @@ -135,6 +140,10 @@ describe("composer rich text document model", () => { "hello **bold** world", "a *italic* word and `code` here", "struck ~~out~~ now", + "**`x`**", + "*`x`*", + "~~`x`~~", + "**a `code` c**", "***bold italic*** keeps nesting", "line one\nline two", "trailing newline\n", diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index e6cedcc5abb3..ed83c35b5f5b 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1,4 +1,5 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Code } from "@tiptap/extension-code"; import { TaskItem } from "@tiptap/extension-task-item"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; @@ -41,6 +42,13 @@ const TIPTAP_TO_MARK: Record = { code: "code", }; +/** + * Tiptap's code mark excludes every other mark, which rejects the `bold+code` + * spans markdown like `**\`x\`**` parses into and drops the whole insert. + * Code nests inside emphasis here, so it only excludes itself like the rest. + */ +export const ComposerCodeExtension = Code.extend({ excludes: "code" }); + /** * Task list items keep their exact source indent in an attribute so nesting * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` — diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6feae2edd57c..c052af3c6a1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -633,6 +633,9 @@ importers: '@tiptap/core': specifier: ^3.31.3 version: 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-code': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-task-item': specifier: ^3.31.3 version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))