authorgravatar for 75276669+Gigioxx@users.noreply.github.comGuillermo Casanova <75276669+Gigioxx@users.noreply.github.com> 2026-09-21 00:01:10-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-21 00:01:10-03:00
logdca84efb53b071691a88c6712751fb7de9dcbfdb
tree343dff53bd51602ad82fc29c1b80abd655184e32
parentf7efb5354a3e6fb5e6b13a658ba57020e2c554ad
signature Signed by PGP key B5690EEEBB952194

fix(web): paste markdown with inline code inside bold, italic, or strikethrough (#12290)


5 files changed, 25 insertions(+), 1 deletions(-)

apps/web/package.json+1
...@@ -32,6 +32,7 @@...@@ -32,6 +32,7 @@
32 "@tanstack/react-pacer": "^0.19.4",32 "@tanstack/react-pacer": "^0.19.4",
33 "@tanstack/react-router": "^1.160.2",33 "@tanstack/react-router": "^1.160.2",
34 "@tiptap/core": "^3.31.3",34 "@tiptap/core": "^3.31.3",
35 "@tiptap/extension-code": "^3.31.3",
35 "@tiptap/extension-task-item": "^3.31.3",36 "@tiptap/extension-task-item": "^3.31.3",
36 "@tiptap/extension-task-list": "^3.31.3",37 "@tiptap/extension-task-list": "^3.31.3",
37 "@tiptap/pm": "^3.31.3",38 "@tiptap/pm": "^3.31.3",
apps/web/src/components/ComposerPromptEditorTiptap.tsx+4-1
...@@ -46,6 +46,7 @@ import {...@@ -46,6 +46,7 @@ import {
46 buildDocJson,46 buildDocJson,
47 buildTiptapContent,47 buildTiptapContent,
48 collapsedToFlat,48 collapsedToFlat,
49 ComposerCodeExtension,
49 ComposerTaskItemExtension,50 ComposerTaskItemExtension,
50 flatToCollapsed,51 flatToCollapsed,
51 flatToMarkdown,52 flatToMarkdown,
...@@ -754,8 +755,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -754,8 +755,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
754 dropcursor: false,755 dropcursor: false,
755 gapcursor: false,756 gapcursor: false,
756 trailingNode: false,757 trailingNode: false,
758 code: false,
757 // Plain mode has no marks: typed markers stay literal characters.759 // Plain mode has no marks: typed markers stay literal characters.
758 ...(richText ? {} : { bold: false, italic: false, strike: false, code: false }),760 ...(richText ? {} : { bold: false, italic: false, strike: false }),
759 }),761 }),
760 ComposerMentionExtension,762 ComposerMentionExtension,
761 ComposerSkillExtension,763 ComposerSkillExtension,
...@@ -764,6 +766,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -764,6 +766,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
764 ComposerMarkersExtension,766 ComposerMarkersExtension,
765 ...(richText767 ...(richText
766 ? [768 ? [
769 ComposerCodeExtension,
767 TaskList,770 TaskList,
768 ComposerTaskItemExtension.extend({771 ComposerTaskItemExtension.extend({
769 addInputRules() {772 addInputRules() {
apps/web/src/composer-rich-text-doc.test.ts+9
...@@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test";
7import {7import {
8 buildDocJson,8 buildDocJson,
9 collapsedToFlat,9 collapsedToFlat,
10 ComposerCodeExtension,
10 ComposerTaskItemExtension,11 ComposerTaskItemExtension,
11 flatToCollapsed,12 flatToCollapsed,
12 flatToMarkdown,13 flatToMarkdown,
...@@ -38,7 +39,9 @@ const schema = getSchemaByResolvedExtensions(...@@ -38,7 +39,9 @@ const schema = getSchemaByResolvedExtensions(
38 dropcursor: false,39 dropcursor: false,
39 gapcursor: false,40 gapcursor: false,
40 trailingNode: false,41 trailingNode: false,
42 code: false,
41 }),43 }),
44 ComposerCodeExtension,
42 stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }),45 stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }),
43 stubAtom("composer-skill", {46 stubAtom("composer-skill", {
44 skillName: { default: "" },47 skillName: { default: "" },
...@@ -64,6 +67,8 @@ const schema = getSchemaByResolvedExtensions(...@@ -64,6 +67,8 @@ const schema = getSchemaByResolvedExtensions(
64function roundTrip(value: string) {67function roundTrip(value: string) {
65 const json = buildDocJson(value, (name) => ({ label: name, description: null }));68 const json = buildDocJson(value, (name) => ({ label: name, description: null }));
66 const doc = ProseMirrorNode.fromJSON(schema, json);69 const doc = ProseMirrorNode.fromJSON(schema, json);
70 // `insertContent` validates every node against the schema; `fromJSON` does not.
71 doc.check();
67 return serializeEditorDoc(doc);72 return serializeEditorDoc(doc);
68}73}
6974
...@@ -135,6 +140,10 @@ describe("composer rich text document model", () => {...@@ -135,6 +140,10 @@ describe("composer rich text document model", () => {
135 "hello **bold** world",140 "hello **bold** world",
136 "a *italic* word and `code` here",141 "a *italic* word and `code` here",
137 "struck ~~out~~ now",142 "struck ~~out~~ now",
143 "**`x`**",
144 "*`x`*",
145 "~~`x`~~",
146 "**a `code` c**",
138 "***bold italic*** keeps nesting",147 "***bold italic*** keeps nesting",
139 "line one\nline two",148 "line one\nline two",
140 "trailing newline\n",149 "trailing newline\n",
apps/web/src/composer-rich-text-doc.ts+8
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { Node as ProseMirrorNode } from "@tiptap/pm/model";1import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
2import { Code } from "@tiptap/extension-code";
2import { TaskItem } from "@tiptap/extension-task-item";3import { TaskItem } from "@tiptap/extension-task-item";
34
4import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";5import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
...@@ -41,6 +42,13 @@ const TIPTAP_TO_MARK: Record<string, RichTextMark> = {...@@ -41,6 +42,13 @@ const TIPTAP_TO_MARK: Record<string, RichTextMark> = {
41 code: "code",42 code: "code",
42};43};
4344
45/**
46 * Tiptap's code mark excludes every other mark, which rejects the `bold+code`
47 * spans markdown like `**\`x\`**` parses into and drops the whole insert.
48 * Code nests inside emphasis here, so it only excludes itself like the rest.
49 */
50export const ComposerCodeExtension = Code.extend({ excludes: "code" });
51
44/**52/**
45 * Task list items keep their exact source indent in an attribute so nesting53 * Task list items keep their exact source indent in an attribute so nesting
46 * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` —54 * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` —
pnpm-lock.yaml+3
...@@ -637,6 +637,9 @@ importers:...@@ -637,6 +637,9 @@ importers:
637 '@tiptap/core':637 '@tiptap/core':
638 specifier: ^3.31.3638 specifier: ^3.31.3
639 version: 3.31.3(@tiptap/pm@3.31.3)639 version: 3.31.3(@tiptap/pm@3.31.3)
640 '@tiptap/extension-code':
641 specifier: ^3.31.3
642 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
640 '@tiptap/extension-task-item':643 '@tiptap/extension-task-item':
641 specifier: ^3.31.3644 specifier: ^3.31.3
642 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))645 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))