diff --git a/apps/web/package.json b/apps/web/package.json index d58299e8c54fc..0b96ca22c6fea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,12 +41,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 abc9549ee3d00..fe78eaacbe850 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -73,7 +73,9 @@ import { ComposerBoldSelectionOnlyExtension, convertBulletItemToTask, ComposerCodeExtension, + ComposerBoldExtension, ComposerItalicExtension, + ComposerStrikeExtension, ComposerTaskItemExtension, ComposerTaskListExtension, flatToMarkdown, @@ -831,7 +833,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, @@ -844,6 +847,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..9ed4fe3a24ece 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, @@ -1003,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: "" } }), @@ -1115,4 +1128,70 @@ describe("inline code entry", () => { editor.commands.setTextSelection(editor.state.doc.content.size - 1); expect(editor.state.doc.toJSON()).toEqual(before); }); + + 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"]); + + 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 c0e0a653016e3..b7df45983281f 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,29 @@ export const ComposerCodeExtension = Code.extend({ }, }); +// 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, + 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, state.selection.from, 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 +177,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 91c27d37762d3..bad0d0c36bb65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1094,6 +1094,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)) @@ -1112,6 +1115,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))