diff --git a/desktop/src/features/messages/lib/composerTextNode.ts b/desktop/src/features/messages/lib/composerTextNode.ts new file mode 100644 index 00000000000..64d791947a7 --- /dev/null +++ b/desktop/src/features/messages/lib/composerTextNode.ts @@ -0,0 +1,27 @@ +import { Node } from "@tiptap/core"; + +/** + * Composer text node with Markdown serialization that leaves angle brackets + * untouched. tiptap-markdown's stock text serializer HTML-escapes them, + * which makes literal inline-code Markdown render `<`/`>` visibly. + */ +export const ComposerText = Node.create({ + name: "text", + group: "inline", + + addStorage() { + return { + markdown: { + serialize( + state: { text: (value: string) => void }, + node: { text: string }, + ) { + state.text(node.text); + }, + parse: { + // handled by markdown-it + }, + }, + }; + }, +}); diff --git a/desktop/src/features/messages/lib/markdownSerialization.test.mjs b/desktop/src/features/messages/lib/markdownSerialization.test.mjs new file mode 100644 index 00000000000..90b1db9170f --- /dev/null +++ b/desktop/src/features/messages/lib/markdownSerialization.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { Markdown as TiptapMarkdown } from "tiptap-markdown"; + +import { ComposerText } from "./composerTextNode.ts"; + +function createEditor(content) { + return new Editor({ + element: null, + extensions: [ + StarterKit.configure({ + heading: false, + link: false, + text: false, + trailingNode: false, + }), + ComposerText, + TiptapMarkdown.configure({ + html: false, + breaks: true, + }), + ], + content, + }); +} + +function serializeComposerMarkdown(editor) { + // Mirror getMarkdownFromEditor: the composer intentionally removes + // prosemirror-markdown's backslash escapes for user-authored Markdown. + let markdown = editor.storage.markdown.getMarkdown(); + markdown = markdown.replace(/\\\n/g, "\n"); + return markdown.replace(/\\([`*\\~[\]_])/g, "$1"); +} + +test("inline-code Markdown preserves angle brackets in Markdown serialization", () => { + const editor = createEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "`/website`", + }, + ], + }, + ], + }); + + try { + assert.equal(serializeComposerMarkdown(editor), "`/website`"); + } finally { + editor.destroy(); + } +}); + +test("typed HTML entities remain literal text", () => { + const editor = createEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "literal <repo>" }], + }, + ], + }); + + try { + assert.equal(serializeComposerMarkdown(editor), "literal <repo>"); + } finally { + editor.destroy(); + } +}); + +test("Markdown punctuation and inline-code delimiters remain unchanged", () => { + const editor = createEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "**bold** and `inline` [brackets] ~tilde~", + }, + ], + }, + ], + }); + + try { + assert.equal( + serializeComposerMarkdown(editor), + "**bold** and `inline` [brackets] ~tilde~", + ); + } finally { + editor.destroy(); + } +}); diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index aeb7198b4c5..b20a58ce5eb 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -41,6 +41,7 @@ import { SpoilerMark } from "./spoilerMark"; import { createComposerLinkPasteHandler } from "./composerMessageLinkNode"; import type { ComposerMessageLinkChannel } from "./useComposerMessageLinks"; import { useComposerMessageLinks } from "./useComposerMessageLinks"; +import { ComposerText } from "./composerTextNode"; function hardBreakLineBounds($from: ResolvedPos) { const parentStart = $from.start(); @@ -244,6 +245,7 @@ export function useRichTextEditor({ { extensions: [ StarterKit.configure({ + text: false, // Use hard breaks (Shift+Enter) — Enter submits the message. hardBreak: { keepMarks: true, @@ -270,6 +272,7 @@ export function useRichTextEditor({ // below with custom options (autolink, openOnClick, etc.). link: false, }), + ComposerText, // macOS text fields traditionally support a small set of Emacs-style // Control shortcuts. Keep movement and kill-line scoped to the current // hard-break-delimited line rather than the whole ProseMirror block.