From e2235760aad85b2ae0513455628884aff0e8a5e2 Mon Sep 17 00:00:00 2001 From: Ege Feyzioglu Date: Mon, 26 Oct 2026 14:21:09 -0400 Subject: [PATCH 1/3] fix(web): composer mark shortcuts ignore delimiters inside inline code Typing the closing `*`, `**`, or `~~` of an emphasis shortcut matched an opening delimiter that sat inside an earlier code span, so ```*foo` bar `*`` italicized the whole line. Tiptap's own guard relies on the code mark excluding every other mark, which the composer's code mark does not do. Mark input rules now skip a match whose opening delimiter is code-marked. Bold and strike get composer extensions like italic already had. --- apps/web/package.json | 2 + .../components/ComposerPromptEditorTiptap.tsx | 7 +- apps/web/src/composer-rich-text-doc.test.ts | 86 +++++++++++++++++++ apps/web/src/composer-rich-text-doc.ts | 52 ++++++++++- pnpm-lock.yaml | 6 ++ 5 files changed, 151 insertions(+), 2 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 27565feebbadd..a75db482abd44 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -46,12 +46,14 @@ "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", "@tiptap/extension-blockquote": "^3.31.3", + "@tiptap/extension-bold": "^3.31.3", "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-code-block": "^3.31.3", "@tiptap/extension-heading": "^3.31.3", "@tiptap/extension-horizontal-rule": "^3.31.3", "@tiptap/extension-italic": "^3.31.3", "@tiptap/extension-list": "^3.31.3", + "@tiptap/extension-strike": "^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 f0a23d2ab4336..cdfed8f39fee1 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -74,7 +74,9 @@ import { ComposerBoldSelectionOnlyExtension, convertBulletItemToTask, ComposerCodeExtension, + ComposerBoldExtension, ComposerItalicExtension, + ComposerStrikeExtension, ComposerTaskItemExtension, ComposerTaskListExtension, flatToMarkdown, @@ -834,7 +836,8 @@ function createComposerExtensions(richText: boolean) { listKeymap: false, undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY }, italic: false, - ...(richText ? {} : { bold: false, strike: false }), + bold: false, + strike: false, }), ComposerUndoGroupingExtension, ComposerTextDirectionExtension, @@ -847,6 +850,8 @@ function createComposerExtensions(richText: boolean) { ? [ ComposerBoldSelectionOnlyExtension, ComposerItalicExtension, + ComposerBoldExtension, + ComposerStrikeExtension, ComposerCodeExtension, ComposerCodeBlockExtension.extend({ addNodeView() { diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 3ad569e4b8a40..e61b5229bd078 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -20,7 +20,10 @@ import { ComposerBlockExtensions, ComposerCodeBlockExtension, ComposerListExtensions, + ComposerBoldExtension, ComposerCodeExtension, + ComposerItalicExtension, + ComposerStrikeExtension, ComposerTaskItemExtension, flatToMarkdown, flatToPm, @@ -1116,3 +1119,86 @@ describe("inline code entry", () => { expect(editor.state.doc.toJSON()).toEqual(before); }); }); + +describe("mark shortcuts around inline code", () => { + function editorFor() { + const editor = new Editor({ + element: null, + extensions: [ + StarterKit.configure({ + bold: false, + code: false, + codeBlock: false, + italic: false, + strike: false, + trailingNode: false, + }), + ComposerBoldExtension, + ComposerItalicExtension, + ComposerStrikeExtension, + ComposerCodeExtension, + ], + content: { type: "doc", content: [{ type: "paragraph" }] }, + }); + editor.view.updateState(editor.state.reconfigure({ plugins: editor.extensionManager.plugins })); + onTestFinished(() => editor.destroy()); + return editor; + } + + function type(editor: Editor, text: string) { + for (const ch of text) { + const { from, to } = editor.state.selection; + const insert = () => editor.state.tr.insertText(ch, from, to); + const handled = editor.state.plugins.some((plugin) => + plugin.props.handleTextInput?.call(plugin, editor.view, from, to, ch, insert), + ); + if (!handled) editor.view.dispatch(insert()); + } + } + + function marked(editor: Editor, name: string) { + const text: string[] = []; + editor.state.doc.descendants((node) => { + if (node.isText && node.marks.some((mark) => mark.type.name === name)) { + text.push(node.text ?? ""); + } + }); + return text; + } + + /** Types `text` into a backtick pair and steps the caret out so it formats. */ + function typeCode(editor: Editor, text: string) { + const offset = editor.state.selection.from; + type(editor, "``"); + editor.commands.setTextSelection(offset + 1); + type(editor, text); + editor.commands.setTextSelection(offset + text.length + 2); + expect(marked(editor, "code")).toContain(text); + } + + it.each([ + { mark: "italic", open: "*" }, + { mark: "bold", open: "**" }, + { mark: "strike", open: "~~" }, + ])("keeps a $mark delimiter inside an earlier code span literal", ({ mark, open }) => { + const editor = editorFor(); + typeCode(editor, `${open}foo`); + type(editor, ` bar \`${open}`); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`\`${open}foo\` bar \`${open}`); + expect(marked(editor, mark)).toEqual([]); + expect(marked(editor, "code")).toEqual([`${open}foo`]); + }); + + it.each([ + { mark: "italic", open: "*" }, + { mark: "bold", open: "**" }, + { mark: "strike", open: "~~" }, + ])("still wraps a code span in $mark when both delimiters are outside it", ({ mark, open }) => { + const editor = editorFor(); + type(editor, open); + typeCode(editor, "foo"); + type(editor, ` bar${open}`); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${open}\`foo\` bar${open}`); + expect(marked(editor, mark)).toEqual(["foo", " bar"]); + }); +}); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index c0e0a653016e3..d3bfefd65423a 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -6,6 +6,11 @@ import { type Node as ProseMirrorNode, type ResolvedPos, } from "@tiptap/pm/model"; +import { + Bold, + starInputRegex as boldStarInputRegex, + underscoreInputRegex as boldUnderscoreInputRegex, +} from "@tiptap/extension-bold"; import { Code } from "@tiptap/extension-code"; import { Blockquote } from "@tiptap/extension-blockquote"; import { CodeBlock } from "@tiptap/extension-code-block"; @@ -16,11 +21,13 @@ import { type Command, type Editor, Extension, + InputRule, markInputRule, markPasteRule, mergeAttributes, } from "@tiptap/core"; import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; +import { Strike, inputRegex as strikeInputRegex } from "@tiptap/extension-strike"; import { TaskItem } from "@tiptap/extension-task-item"; import { TaskList } from "@tiptap/extension-task-list"; import { @@ -140,6 +147,34 @@ export const ComposerCodeExtension = Code.extend({ }, }); +/** + * Tiptap's mark rules only skip a match overlapping a mark that excludes theirs, + * and the composer's code mark excludes just itself, so a closing `*` matched a + * `*` inside an earlier code span. A delimiter in a code span is literal, so the + * rule needs the opening delimiter outside code; the caret guard covers the close. + */ +function markInputRuleOutsideCode(find: RegExp, type: MarkType): InputRule { + const rule = markInputRule({ find, type }); + return new InputRule({ + find, + handler: (props) => { + const { state, range, match } = props; + const code = state.schema.marks.code; + const content = match[match.length - 1] ?? ""; + const textStart = range.from + match[0].indexOf(content); + const textEnd = textStart + content.length; + if ( + code && + (state.doc.rangeHasMark(range.from + match[0].search(/\S/), textStart, code) || + state.doc.rangeHasMark(textEnd, range.to, code)) + ) { + return null; + } + return rule.handler(props); + }, + }); +} + /** * The composer writes italic as `*text*`, so it keeps only the `*` shortcuts. * Tiptap's `_text_` rules fire on the `_` in names like `node_modules` and @@ -147,13 +182,28 @@ export const ComposerCodeExtension = Code.extend({ */ export const ComposerItalicExtension = Italic.extend({ addInputRules() { - return [markInputRule({ find: starInputRegex, type: this.type })]; + return [markInputRuleOutsideCode(starInputRegex, this.type)]; }, addPasteRules() { return [markPasteRule({ find: starPasteRegex, type: this.type })]; }, }); +export const ComposerBoldExtension = Bold.extend({ + addInputRules() { + return [ + markInputRuleOutsideCode(boldStarInputRegex, this.type), + markInputRuleOutsideCode(boldUnderscoreInputRegex, this.type), + ]; + }, +}); + +export const ComposerStrikeExtension = Strike.extend({ + addInputRules() { + return [markInputRuleOutsideCode(strikeInputRegex, this.type)]; + }, +}); + /** * Mod+B bolds selected text only. Without a selection Tiptap would toggle bold * for the next typed text, which users hit by accident when they mean the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80fb20106b354..2dbdf85d28d54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1091,6 +1091,9 @@ importers: '@tiptap/extension-blockquote': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-bold': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@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)) @@ -1109,6 +1112,9 @@ importers: '@tiptap/extension-list': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-strike': + 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)) From d5a51614a08b78fe27f6f0852fd9511308bc3849 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 05:00:03 +0000 Subject: [PATCH 2/3] refactor(web): reuse inline code input test setup --- apps/web/src/composer-rich-text-doc.test.ts | 49 +++++---------------- apps/web/src/composer-rich-text-doc.ts | 9 +--- 2 files changed, 13 insertions(+), 45 deletions(-) diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index e61b5229bd078..32e33998a7c5c 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -1006,7 +1006,17 @@ describe("inline code entry", () => { const editor = new Editor({ element: null, extensions: [ - StarterKit.configure({ code: false, codeBlock: false, trailingNode: false }), + StarterKit.configure({ + bold: false, + code: false, + codeBlock: false, + italic: false, + strike: false, + trailingNode: false, + }), + ComposerBoldExtension, + ComposerItalicExtension, + ComposerStrikeExtension, ComposerCodeExtension, ComposerCodeBlockExtension, stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }), @@ -1118,43 +1128,6 @@ describe("inline code entry", () => { editor.commands.setTextSelection(editor.state.doc.content.size - 1); expect(editor.state.doc.toJSON()).toEqual(before); }); -}); - -describe("mark shortcuts around inline code", () => { - function editorFor() { - const editor = new Editor({ - element: null, - extensions: [ - StarterKit.configure({ - bold: false, - code: false, - codeBlock: false, - italic: false, - strike: false, - trailingNode: false, - }), - ComposerBoldExtension, - ComposerItalicExtension, - ComposerStrikeExtension, - ComposerCodeExtension, - ], - content: { type: "doc", content: [{ type: "paragraph" }] }, - }); - editor.view.updateState(editor.state.reconfigure({ plugins: editor.extensionManager.plugins })); - onTestFinished(() => editor.destroy()); - return editor; - } - - function type(editor: Editor, text: string) { - for (const ch of text) { - const { from, to } = editor.state.selection; - const insert = () => editor.state.tr.insertText(ch, from, to); - const handled = editor.state.plugins.some((plugin) => - plugin.props.handleTextInput?.call(plugin, editor.view, from, to, ch, insert), - ); - if (!handled) editor.view.dispatch(insert()); - } - } function marked(editor: Editor, name: string) { const text: string[] = []; diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index d3bfefd65423a..3da0b439df0ea 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -147,13 +147,8 @@ export const ComposerCodeExtension = Code.extend({ }, }); -/** - * Tiptap's mark rules only skip a match overlapping a mark that excludes theirs, - * and the composer's code mark excludes just itself, so a closing `*` matched a - * `*` inside an earlier code span. A delimiter in a code span is literal, so the - * rule needs the opening delimiter outside code; the caret guard covers the close. - */ -function markInputRuleOutsideCode(find: RegExp, type: MarkType): InputRule { +// Code permits nested emphasis, so Tiptap's exclusion check cannot protect literal delimiters. +function markInputRuleOutsideCode(find: RegExp, type: MarkType) { const rule = markInputRule({ find, type }); return new InputRule({ find, From 6629ef30ec1e2e428a39467172405e2005d64624 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 05:07:51 +0000 Subject: [PATCH 3/3] fix(web): ignore replaced selection in delimiter guard --- apps/web/src/composer-rich-text-doc.test.ts | 20 ++++++++++++++++++++ apps/web/src/composer-rich-text-doc.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 32e33998a7c5c..9ed4fe3a24ece 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -1173,5 +1173,25 @@ describe("inline code entry", () => { type(editor, ` bar${open}`); expect(serializeEditorDoc(editor.state.doc).value).toBe(`${open}\`foo\` bar${open}`); expect(marked(editor, mark)).toEqual(["foo", " bar"]); + + editor.commands.setContent({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: `${open}foo tail ` }, + { type: "text", text: "x", marks: [{ type: "code" }] }, + ], + }, + ], + }); + editor.commands.setTextSelection({ + from: open.length + 4, + to: editor.state.doc.content.size - 1, + }); + type(editor, open); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${open}foo${open}`); + expect(marked(editor, mark)).toEqual(["foo"]); }); }); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 3da0b439df0ea..b7df45983281f 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -161,7 +161,7 @@ function markInputRuleOutsideCode(find: RegExp, type: MarkType) { if ( code && (state.doc.rangeHasMark(range.from + match[0].search(/\S/), textStart, code) || - state.doc.rangeHasMark(textEnd, range.to, code)) + state.doc.rangeHasMark(textEnd, state.selection.from, code)) ) { return null; }