Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions apps/web/src/composer-rich-text-doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "``");
Expand Down
85 changes: 76 additions & 9 deletions apps/web/src/composer-rich-text-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type Command,
type Editor,
Extension,
InputRule,
markInputRule,
markPasteRule,
mergeAttributes,
Expand All @@ -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";
Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading