diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index ab7f2c542a19f..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(); @@ -1129,6 +1139,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 7025b687850da..63c2547322e1f 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -38,6 +38,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"; @@ -80,7 +81,7 @@ 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; }, }, @@ -90,7 +91,7 @@ function codePairCaretPlugin(type: MarkType) { const $from = view.state.doc.resolve(from); if (!$from.parent.isTextblock || $from.parent.type.spec.code) return false; const before = $from.nodeBefore?.text ?? ""; - if (/(^|[^`])`$/.test(before)) { + if (closesBacktickPair($from)) { view.dispatch(insert().setMeta(codePairKey, { from: from - 1, to: from + 1 })); return true; } @@ -151,6 +152,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. @@ -168,8 +208,44 @@ 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 (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.matchAll(/(?