From cade21ea45452c20254264461bde0bd00c815078 Mon Sep 17 00:00:00 2001 From: walshy Date: Fri, 30 Oct 2026 03:44:28 -0500 Subject: [PATCH 1/2] fix(web): format inline code typed with a dead-key backtick On layouts where backtick is a dead key (macOS British-PC, German and others), typing `` then filling the pair never formats it as code. The typed-pair handler ignores composing input, so the pair is never recorded. A later backtick then reuses the literal closing backtick as an opener and formats the wrong span, e.g. ` and ` instead of the words. Record a pair from a composed backtick insert, and skip the inline-code input rule when its opening backtick would close an earlier literal one, since backticks pair left to right. --- apps/web/src/composer-rich-text-doc.test.ts | 42 ++++++++++ apps/web/src/composer-rich-text-doc.ts | 85 ++++++++++++++++++--- 2 files changed, 118 insertions(+), 9 deletions(-) diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 3ad569e4b8a40..712bd09a86fa4 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -1066,6 +1066,48 @@ describe("inline code entry", () => { }, ); + it.each([ + [ + "formats a pair", + "do ", + [ + { type: "text", text: "do " }, + { type: "text", marks: [{ type: "code" }], text: "xyz" }, + ], + ], + [ + "keeps a pair literal after an unmatched backtick", + "a ` ", + [{ type: "text", text: "a ` `xyz`" }], + ], + ])("%s whose backticks came from a dead key", (_name, prefix, content) => { + const editor = editorFor(); + type(editor, prefix); + editor.view.dispatch(editor.state.tr.insertText("`").setMeta("composition", 1)); + editor.view.dispatch(editor.state.tr.insertText("`").setMeta("composition", 2)); + editor.commands.setTextSelection(prefix.length + 2); + type(editor, "xyz"); + expect(editor.state.doc.textContent).toBe(`${prefix}\`xyz\``); + + editor.commands.setTextSelection(prefix.length + 6); + expect(editor.state.doc.firstChild?.toJSON().content).toEqual(content); + }); + + it("does not reuse the closing backtick of a literal pair to open code", () => { + const editor = editorFor(); + editor.view.dispatch(editor.state.tr.insertText("is `enabled`", 1)); + type(editor, " and `"); + expect(editor.state.doc.firstChild?.toJSON().content).toEqual([ + { type: "text", text: "is `enabled` and `" }, + ]); + + type(editor, "persist`"); + expect(editor.state.doc.firstChild?.toJSON().content).toEqual([ + { type: "text", text: "is `enabled` and " }, + { type: "text", marks: [{ type: "code" }], text: "persist" }, + ]); + }); + it("keeps composition edits literal until the caret leaves the typed pair", () => { const editor = editorFor(); type(editor, "``"); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index c0e0a653016e3..76a2e9b5bbcdc 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -16,6 +16,7 @@ import { type Command, type Editor, Extension, + InputRule, markInputRule, markPasteRule, mergeAttributes, @@ -31,6 +32,7 @@ import { TextSelection, type Transaction, } from "@tiptap/pm/state"; +import { ReplaceStep } from "@tiptap/pm/transform"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; import { nextOrderedMarkerText } from "~/composer-list-continuation"; @@ -73,21 +75,15 @@ function codePairCaretPlugin(type: MarkType) { const to = tr.mapping.mapResult(pair.to, -1); return from.deleted || to.deleted ? [] : [{ from: from.pos, to: to.pos }]; }); - const typed = tr.getMeta(codePairKey) as CodePair | undefined; + const typed = (tr.getMeta(codePairKey) as CodePair | undefined) ?? composedCodePair(tr); return typed ? [...mapped, typed] : mapped; }, }, props: { handleTextInput(view, from, to, text, insert) { if (view.composing || from !== to || text !== "`") return false; - const $from = view.state.doc.resolve(from); - if ( - !$from.parent.isTextblock || - $from.parent.type.spec.code || - !/(^|[^`])`$/.test($from.nodeBefore?.text ?? "") - ) { - return false; - } + if (!closesBacktickPair(view.state.doc.resolve(from))) return false; + view.dispatch(insert().setMeta(codePairKey, { from: from - 1, to: from + 1 })); return true; }, @@ -121,6 +117,45 @@ function codePairCaretPlugin(type: MarkType) { }); } +/** + * Whether a backtick typed at `$pos` would complete an empty `` pair. The + * backtick before `$pos` only opens one when earlier backticks are all paired. + */ +function closesBacktickPair($pos: ResolvedPos): boolean { + return ( + $pos.parent.isTextblock && + !$pos.parent.type.spec.code && + /(^|[^`])`$/.test($pos.nodeBefore?.text ?? "") && + literalBackticksBefore($pos) % 2 === 1 + ); +} + +/** + * Layouts where backtick is a dead key type it through composition, and the + * pair handler above skips composing input. Read the pair + * from the composed insert instead; dispatching mid-composition would break it. + */ +function composedCodePair(tr: Transaction): CodePair | undefined { + if (tr.getMeta("composition") === undefined || tr.steps.length !== 1) { + return undefined; + } + + const step = tr.steps[0]; + if (!(step instanceof ReplaceStep) || step.from !== step.to || step.slice.size !== 1) { + return undefined; + } + + if (step.slice.content.firstChild?.text !== "`") { + return undefined; + } + + if (!closesBacktickPair(tr.before.resolve(step.from))) { + return undefined; + } + + return { from: step.from - 1, to: step.from + 1 }; +} + /** * Tiptap's code mark excludes every other mark, which rejects the `bold+code` * spans markdown like `**\`x\`**` parses into and drops the whole insert. @@ -138,8 +173,40 @@ export const ComposerCodeExtension = Code.extend({ addProseMirrorPlugins() { return [...(this.parent?.() ?? []), codePairCaretPlugin(this.type)]; }, + // Tiptap's rule reads back from the caret, so after a literal `enabled` the + // next typed backtick would reuse its closing backtick and wrap ` and ` in + // code. Backticks pair left to right, so skip an opener that closes a pair. + addInputRules() { + return (this.parent?.() ?? []).map( + (rule) => + new InputRule({ + find: rule.find, + undoable: rule.undoable, + handler: (props) => { + if (literalBackticksBefore(props.state.doc.resolve(props.range.from)) % 2 === 1) { + return null; + } + + return rule.handler(props); + }, + }), + ); + }, }); +/** Counts backticks typed as plain text before `$pos` in its textblock; code text never pairs. */ +function literalBackticksBefore($pos: ResolvedPos): number { + let count = 0; + $pos.parent.forEach((node, offset) => { + if (!node.isText || offset >= $pos.parentOffset) return; + if (node.marks.some((mark) => mark.type.spec.code)) return; + + const text = node.text!.slice(0, $pos.parentOffset - offset); + count += text.split("`").length - 1; + }); + return count; +} + /** * 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 From fa748f862b31356e55be40ad716b1dea0f90cb04 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 09:47:01 +0000 Subject: [PATCH 2/2] fix(web): keep backtick pairing local to inline text --- apps/web/src/composer-rich-text-doc.test.ts | 12 +++++++++++- apps/web/src/composer-rich-text-doc.ts | 8 ++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 40b735ab136e7..77383df61e8f7 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -1089,9 +1089,19 @@ describe("inline code entry", () => { type(editor, "`"); expect(editor.state.doc.firstChild?.firstChild?.marks).toEqual([]); expect(editor.state.doc.textContent).toBe("`beforeafter`"); + + const local = editorFor(); + type(local, "a ` "); + local.commands.insertContent({ type: nodeType }); + type(local, "`local`"); + expect(local.state.doc.firstChild?.lastChild?.toJSON()).toEqual({ + type: "text", + marks: [{ type: "code" }], + text: "local", + }); }); - it.each(["", "prefix ", "@README.md"])( + it.each(["", "prefix ", "@README.md", "a ``` "])( "formats a filled pair after %j, leaves following text plain, and keeps the normal input rule", (prefix) => { const editor = editorFor(); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 333f4f8c977e2..63c2547322e1f 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -233,11 +233,15 @@ export const ComposerCodeExtension = Code.extend({ function literalBackticksBefore($pos: ResolvedPos): number { let count = 0; $pos.parent.forEach((node, offset) => { - if (!node.isText || offset >= $pos.parentOffset) return; + if (offset >= $pos.parentOffset) return; + if (!node.isText) { + count = 0; + return; + } if (node.marks.some((mark) => mark.type.spec.code)) return; const text = node.text!.slice(0, $pos.parentOffset - offset); - count += text.split("`").length - 1; + count += [...text.matchAll(/(?