diff --git a/.changeset/add-back-msg-toolbar.md b/.changeset/add-back-msg-toolbar.md new file mode 100644 index 000000000..391d8ff33 --- /dev/null +++ b/.changeset/add-back-msg-toolbar.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Adds back the message editor toolbar under an optional setting. No longer uses WYSIWYG, just applies markdown. http://localhost:8080/settings/general?focus=composer-formatting-toolbar&moe.sable.client.action=settings diff --git a/.changeset/fix-extraneous-markdown.md b/.changeset/fix-extraneous-markdown.md new file mode 100644 index 000000000..c42062d57 --- /dev/null +++ b/.changeset/fix-extraneous-markdown.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix extraneous markdown escape characters when editing code blocks. diff --git a/src/app/components/editor/MarkdownToolbar.tsx b/src/app/components/editor/MarkdownToolbar.tsx new file mode 100644 index 000000000..64ebd6e60 --- /dev/null +++ b/src/app/components/editor/MarkdownToolbar.tsx @@ -0,0 +1,330 @@ +import FocusTrap from 'focus-trap-react'; +import type { IconSrc, RectCords } from 'folds'; +import { + Badge, + Box, + config, + Icon, + IconButton, + Icons, + Line, + Menu, + PopOut, + Scroll, + Text, + Tooltip, + TooltipProvider, + toRem, +} from 'folds'; +import type { MouseEventHandler, ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { ReactEditor, useSlate } from 'slate-react'; +import { isMacOS } from '$utils/user-agent'; +import { KeySymbol } from '$utils/key-symbol'; +import { stopPropagation } from '$utils/keyboard'; +import { floatingToolbar } from '$styles/overrides/Composer.css'; +import { + applyMarkdownBlockPrefix, + applyMarkdownInline, + BLOCK_HOTKEYS, + INLINE_HOTKEYS, +} from './keyboard'; +import * as css from './Editor.css'; + +function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) { + return ( + + + {text} + {shortCode && ( + + + {shortCode} + + + )} + + + ); +} + +type MarkdownInlineButtonProps = { + marker: string; + icon: IconSrc; + tooltip: ReactNode; +}; + +function MarkdownInlineButton({ marker, icon, tooltip }: MarkdownInlineButtonProps) { + const editor = useSlate(); + + const handleClick = () => { + applyMarkdownInline(editor, marker); + ReactEditor.focus(editor); + }; + + return ( + + {(triggerRef) => ( + + + + )} + + ); +} + +type MarkdownBlockButtonProps = { + prefix: string; + icon: IconSrc; + tooltip: ReactNode; +}; + +function MarkdownBlockButton({ prefix, icon, tooltip }: MarkdownBlockButtonProps) { + const editor = useSlate(); + + const handleClick = () => { + applyMarkdownBlockPrefix(editor, prefix); + ReactEditor.focus(editor); + }; + + return ( + + {(triggerRef) => ( + + + + )} + + ); +} + +function MarkdownHeadingButton() { + const editor = useSlate(); + const [anchor, setAnchor] = useState(); + const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; + + const handleMenuSelect = (prefix: string) => { + setAnchor(undefined); + applyMarkdownBlockPrefix(editor, prefix); + ReactEditor.focus(editor); + }; + + const handleMenuOpen: MouseEventHandler = (evt) => { + setAnchor(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + setAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + } + delay={500} + > + {(triggerRef) => ( + handleMenuSelect('# ')} + size="400" + radii="300" + > + + + )} + + } + delay={500} + > + {(triggerRef) => ( + handleMenuSelect('## ')} + size="400" + radii="300" + > + + + )} + + } + delay={500} + > + {(triggerRef) => ( + handleMenuSelect('### ')} + size="400" + radii="300" + > + + + )} + + + + + } + > + + + + + + ); +} + +export function MarkdownToolbar() { + const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; + + return ( + + + + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + + } + /> + } + /> + } + /> + } + /> + + + + + + ); +} + +export type MarkdownFormattingToolbarToggleVariant = 'SurfaceVariant' | 'Background'; + +export function MarkdownFormattingToolbarToggle({ + variant, +}: { + variant: MarkdownFormattingToolbarToggleVariant; +}) { + const [editorToolbar] = useSetting(settingsAtom, 'editorToolbar'); + const [composerToolbarOpen, setComposerToolbarOpen] = useSetting( + settingsAtom, + 'composerToolbarOpen' + ); + + useEffect(() => { + if (!editorToolbar) setComposerToolbarOpen(false); + }, [editorToolbar, setComposerToolbarOpen]); + + if (!editorToolbar) return null; + + return ( + setComposerToolbarOpen(!composerToolbarOpen)} + > + + + ); +} + +export function MarkdownFormattingToolbarBottom() { + const [editorToolbar] = useSetting(settingsAtom, 'editorToolbar'); + const [composerToolbarOpen] = useSetting(settingsAtom, 'composerToolbarOpen'); + + if (!editorToolbar || !composerToolbarOpen) return null; + + return ( +
+ + +
+ ); +} diff --git a/src/app/components/editor/index.ts b/src/app/components/editor/index.ts index 09905d5f2..46ca31a11 100644 --- a/src/app/components/editor/index.ts +++ b/src/app/components/editor/index.ts @@ -6,4 +6,5 @@ export * from './keyboard'; export * from './output'; export * from './input'; +export * from './MarkdownToolbar'; export * from './types'; diff --git a/src/app/components/editor/keyboard.ts b/src/app/components/editor/keyboard.ts index 7d9b18d58..05eb43213 100644 --- a/src/app/components/editor/keyboard.ts +++ b/src/app/components/editor/keyboard.ts @@ -23,7 +23,7 @@ const isHeading1 = isKeyHotkey('mod+1'); const isHeading2 = isKeyHotkey('mod+2'); const isHeading3 = isKeyHotkey('mod+3'); -const insertMarkdownInline = (editor: Editor, marker: string) => { +export const applyMarkdownInline = (editor: Editor, marker: string) => { if (editor.selection && Range.isExpanded(editor.selection)) { const text = Editor.string(editor, editor.selection); Transforms.insertText(editor, `${marker}${text}${marker}`); @@ -33,7 +33,7 @@ const insertMarkdownInline = (editor: Editor, marker: string) => { } }; -const insertMarkdownBlock = (editor: Editor, prefix: string) => { +export const applyMarkdownBlockPrefix = (editor: Editor, prefix: string) => { if (editor.selection) { const path = editor.selection.anchor.path; const startPoint = Editor.start(editor, path); @@ -52,7 +52,7 @@ export const toggleKeyboardShortcut = (editor: Editor, event: KeyboardEvent): bo const blockToggled = BLOCK_KEYS.find((hotkey) => { if (isKeyHotkey(hotkey, event)) { event.preventDefault(); - insertMarkdownBlock(editor, BLOCK_HOTKEYS[hotkey]!); + applyMarkdownBlockPrefix(editor, BLOCK_HOTKEYS[hotkey]!); return true; } return false; @@ -61,24 +61,24 @@ export const toggleKeyboardShortcut = (editor: Editor, event: KeyboardEvent): bo if (isHeading1(event)) { event.preventDefault(); - insertMarkdownBlock(editor, '# '); + applyMarkdownBlockPrefix(editor, '# '); return true; } if (isHeading2(event)) { event.preventDefault(); - insertMarkdownBlock(editor, '## '); + applyMarkdownBlockPrefix(editor, '## '); return true; } if (isHeading3(event)) { event.preventDefault(); - insertMarkdownBlock(editor, '### '); + applyMarkdownBlockPrefix(editor, '### '); return true; } const inlineToggled = INLINE_KEYS.find((hotkey) => { if (isKeyHotkey(hotkey, event)) { event.preventDefault(); - insertMarkdownInline(editor, INLINE_HOTKEYS[hotkey]!); + applyMarkdownInline(editor, INLINE_HOTKEYS[hotkey]!); return true; } return false; diff --git a/src/app/components/upload-card/UploadDescriptionEditor.tsx b/src/app/components/upload-card/UploadDescriptionEditor.tsx index 8a4f72ff3..2805668ca 100644 --- a/src/app/components/upload-card/UploadDescriptionEditor.tsx +++ b/src/app/components/upload-card/UploadDescriptionEditor.tsx @@ -11,6 +11,8 @@ import { AutocompletePrefix, CustomEditor, EmoticonAutocomplete, + MarkdownFormattingToolbarBottom, + MarkdownFormattingToolbarToggle, createEmoticonElement, getAutocompleteQuery, getPrevWorldRange, @@ -158,6 +160,7 @@ export function DescriptionEditor({ variant="Background" bottom={ + + {(anchor: RectCords | undefined, setAnchor) => ( ( )} + + {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( ( } + bottom={} /> {showSchedulePicker && ( ( onKeyUp={handleKeyUp} bottom={ <> + ( + {(anchor: RectCords | undefined, setAnchor) => ( + + {(anchor: RectCords | undefined, setAnchor) => ( ) { const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); + const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); @@ -443,6 +444,14 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { } /> + + } + /> + { expect(result).toContain('fn main()'); }); + it('round-trips markdown-like characters inside code blocks without spurious escapes', () => { + const markdown = '```\n*literal* \\*typed\\*\n```'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain('*literal*'); + expect(result).toContain('\\*typed\\*'); + expect(result).not.toContain('\\*literal\\*'); + }); + + it('round-trips inline code containing asterisks', () => { + const markdown = 'Text `*x*` more'; + const html = markdownToHtml(markdown); + const injected = injectDataMd(html); + const result = htmlToMarkdown(injected); + expect(result).toContain('`*x*`'); + }); + it('round-trips blockquotes', () => { const markdown = '> Quote text'; const html = markdownToHtml(markdown); diff --git a/src/app/plugins/markdown/htmlToMarkdown.test.ts b/src/app/plugins/markdown/htmlToMarkdown.test.ts index ca61ee776..a48dd28a9 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.test.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.test.ts @@ -39,6 +39,23 @@ describe('htmlToMarkdown', () => { ); }); + it('does not escape markdown markers inside fenced code blocks', () => { + const result = htmlToMarkdown('
*literal*
'); + expect(result).toContain('*literal*'); + expect(result).not.toMatch(/\\\*literal\\\*/); + }); + + it('does not escape markdown markers inside inline code', () => { + const result = htmlToMarkdown('

before*x*after

'); + expect(result).toContain('`*x*`'); + expect(result).not.toContain('\\*x\\*'); + }); + + it('preserves backslash-asterisk literals inside code blocks', () => { + const result = htmlToMarkdown('
\\*typed\\*
'); + expect(result).toContain('\\*typed\\*'); + }); + it('converts links', () => { expect(htmlToMarkdown('link')).toContain( '[link](https://example.com)' diff --git a/src/app/plugins/markdown/htmlToMarkdown.ts b/src/app/plugins/markdown/htmlToMarkdown.ts index 26283f514..94c7381bf 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.ts @@ -60,9 +60,9 @@ function processNodes(nodes: ChildNode[]): string { .join(''); } -function processNode(node: ChildNode, listDepth: number = 0): string { +function processNode(node: ChildNode, listDepth: number = 0, insideCode: boolean = false): string { if (isText(node)) { - return escapeMarkdownInlineSequences(node.data); + return insideCode ? node.data : escapeMarkdownInlineSequences(node.data); } if (!isTag(node)) { @@ -74,19 +74,19 @@ function processNode(node: ChildNode, listDepth: number = 0): string { // Handle Matrix-specific attributes if (tag === 'span') { if (node.attribs['data-mx-spoiler'] !== undefined) { - return processSpoiler(node); + return processSpoiler(node, listDepth, insideCode); } if (node.attribs['data-mx-maths'] !== undefined) { return processMath(node, 'inline'); } if (node.attribs['data-md'] !== undefined) { - return processInlineMarkdown(node); + return processInlineMarkdown(node, listDepth, insideCode); } if ( node.attribs['data-mx-color'] !== undefined || node.attribs['data-mx-bg-color'] !== undefined ) { - return reconstructTag(node); + return reconstructTag(node, listDepth, insideCode); } } @@ -104,18 +104,18 @@ function processNode(node: ChildNode, listDepth: number = 0): string { case 'h4': case 'h5': case 'h6': - return processHeading(node, tag); + return processHeading(node, tag, listDepth, insideCode); case 'p': - return processParagraph(node); + return processParagraph(node, listDepth, insideCode); case 'strong': case 'b': - return processInlineWrapper(node, '**'); + return processInlineWrapper(node, '**', listDepth, insideCode); case 'em': case 'i': - return processInlineWrapper(node, '*'); + return processInlineWrapper(node, '*', listDepth, insideCode); case 'u': { const md = node.attribs['data-md']; @@ -124,28 +124,28 @@ function processNode(node: ChildNode, listDepth: number = 0): string { case 's': case 'del': - return processInlineWrapper(node, '~~'); + return processInlineWrapper(node, '~~', listDepth, insideCode); case 'code': - return processCode(node); + return processCode(node, listDepth); case 'pre': - return processPre(node); + return processPre(node, listDepth); case 'blockquote': - return processBlockquote(node); + return processBlockquote(node, listDepth, insideCode); case 'ul': - return processUnorderedList(node, listDepth); + return processUnorderedList(node, listDepth, insideCode); case 'ol': - return processOrderedList(node, listDepth); + return processOrderedList(node, listDepth, insideCode); case 'li': - return processListItem(node); + return processListItem(node, listDepth, insideCode); case 'a': - return processLink(node); + return processLink(node, listDepth, insideCode); case 'br': return '\n'; @@ -154,34 +154,43 @@ function processNode(node: ChildNode, listDepth: number = 0): string { return '---\n'; case 'sub': - return processSubscript(node); + return processSubscript(node, listDepth, insideCode); case 'img': return processImage(node); default: - return processInlineElements(node); + return processInlineElements(node, listDepth, insideCode); } } -function reconstructTag(node: Element): string { - const content = processInlineElements(node); +function reconstructTag(node: Element, listDepth: number = 0, insideCode: boolean = false): string { + const content = processInlineElements(node, listDepth, insideCode); const attributes = Object.entries(node.attribs) .map(([key, value]) => ` ${key}="${value}"`) .join(''); return `<${node.name}${attributes}>${content}`; } -function processInlineElements(node: Element): string { - return node.children.map((c) => processNode(c)).join(''); +function processInlineElements( + node: Element, + listDepth: number = 0, + insideCode: boolean = false +): string { + return node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); } -function processInlineWrapper(node: Element, marker: string): string { - const content = node.children.map((c) => processNode(c)).join(''); +function processInlineWrapper( + node: Element, + marker: string, + listDepth: number = 0, + insideCode: boolean = false +): string { + const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); return `${marker}${content}${marker}`; } -function processCode(node: Element): string { - const codeContent = node.children.map((c) => processNode(c)).join(''); +function processCode(node: Element, listDepth: number = 0): string { + const codeContent = node.children.map((c) => processNode(c, listDepth, true)).join(''); // Check if this is inside a pre (code block) if (node.parent && isTag(node.parent) && node.parent.name === 'pre') { @@ -192,7 +201,7 @@ function processCode(node: Element): string { return `\`${codeContent}\``; } -function processPre(node: Element): string { +function processPre(node: Element, listDepth: number = 0): string { // Get language from class="language-xxx" const codeChild = node.children.find((c): c is Element => isTag(c) && c.name === 'code'); const className = codeChild?.attribs.class ?? ''; @@ -200,28 +209,41 @@ function processPre(node: Element): string { const lang = langMatch ? langMatch[1] : ''; const codeContent = codeChild - ? codeChild.children.map((c) => processNode(c)).join('') - : node.children.map((c) => processNode(c)).join(''); + ? codeChild.children.map((c) => processNode(c, listDepth, true)).join('') + : node.children.map((c) => processNode(c, listDepth, true)).join(''); return `\`\`\`${lang}\n${codeContent}\`\`\`\n`; } -function processHeading(node: Element, tag: string): string { +function processHeading( + node: Element, + tag: string, + listDepth: number = 0, + insideCode: boolean = false +): string { const level = tag.charAt(1); - const content = node.children.map((c) => processNode(c)).join(''); + const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); return `${'#'.repeat(parseInt(level, 10))} ${content}\n`; } -function processParagraph(node: Element): string { - const content = node.children.map((c) => processNode(c)).join(''); +function processParagraph( + node: Element, + listDepth: number = 0, + insideCode: boolean = false +): string { + const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); return `${content}\n`; } -function processBlockquote(node: Element): string { +function processBlockquote( + node: Element, + listDepth: number = 0, + insideCode: boolean = false +): string { const content = node.children .map((child) => { if (isTag(child) && child.name === 'br') return '\n'; - const text = processNode(child); + const text = processNode(child, listDepth, insideCode); return text.replace(/\n/g, '\n> '); }) .join(''); @@ -232,19 +254,19 @@ function processBlockquote(node: Element): string { * Process children of a list item, separating inline content from nested lists. * Nested lists are processed with increased depth for indentation. */ -function processListItemChildren(li: Element, depth: number): string { +function processListItemChildren(li: Element, depth: number, insideCode: boolean = false): string { const inlineParts: string[] = []; const nestedParts: string[] = []; li.children.forEach((child) => { if (isTag(child) && (child.name === 'ul' || child.name === 'ol')) { // Nested list, process with increased depth - nestedParts.push(processNode(child, depth + 1)); + nestedParts.push(processNode(child, depth + 1, insideCode)); } else if (isTag(child) && child.name === 'p') { // Unwrap

inside

  • - inlineParts.push(child.children.map((c) => processNode(c)).join('')); + inlineParts.push(child.children.map((c) => processNode(c, depth, insideCode)).join('')); } else { - inlineParts.push(processNode(child)); + inlineParts.push(processNode(child, depth, insideCode)); } }); @@ -255,20 +277,24 @@ function processListItemChildren(li: Element, depth: number): string { return result; } -function processUnorderedList(node: Element, depth: number = 0): string { +function processUnorderedList( + node: Element, + depth: number = 0, + insideCode: boolean = false +): string { const mdSequence = node.attribs['data-md'] || '-'; const indent = ' '.repeat(depth); const items = node.children .filter((c): c is Element => isTag(c) && c.name === 'li') .map((li) => { - const content = processListItemChildren(li, depth); + const content = processListItemChildren(li, depth, insideCode); return `${indent}${mdSequence} ${content}`; }) .join('\n'); return items + '\n'; } -function processOrderedList(node: Element, depth: number = 0): string { +function processOrderedList(node: Element, depth: number = 0, insideCode: boolean = false): string { const mdSequence = node.attribs['data-md'] || '1.'; const [starOrHyphen] = mdSequence.match(/^\*|-$/) ?? []; const outPrefix = starOrHyphen @@ -288,38 +314,38 @@ function processOrderedList(node: Element, depth: number = 0): string { currentPrefix = `${start + index}.`; } } - const content = processListItemChildren(li, depth); + const content = processListItemChildren(li, depth, insideCode); return `${indent}${currentPrefix} ${content}`; }) .join('\n'); return items + '\n'; } -function processListItem(node: Element): string { +function processListItem(node: Element, listDepth = 0, insideCode = false): string { const content = node.children .map((child) => { if (isTag(child) && child.name === 'p') { - return child.children.map((c) => processNode(c)).join(''); + return child.children.map((c) => processNode(c, listDepth, insideCode)).join(''); } - return processNode(child); + return processNode(child, listDepth, insideCode); }) .join(''); return `- ${content}\n`; } -function processSubscript(node: Element): string { - const content = node.children.map((c) => processNode(c)).join(''); +function processSubscript(node: Element, listDepth = 0, insideCode = false): string { + const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); return `-# ${content}\n`; } -function processLink(node: Element): string { +function processLink(node: Element, listDepth = 0, insideCode = false): string { const href = node.attribs.href ?? ''; - const content = node.children.map((c) => processNode(c)).join(''); + const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); return `[${content}](${href})`; } -function processSpoiler(node: Element): string { - const content = node.children.map((c) => processNode(c)).join(''); +function processSpoiler(node: Element, listDepth = 0, insideCode = false): string { + const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); return `||${content}||`; } @@ -331,9 +357,13 @@ function processMath(node: Element, mode: 'inline' | 'block'): string { return `$${latex}$`; } -function processInlineMarkdown(node: Element): string { +function processInlineMarkdown( + node: Element, + listDepth: number = 0, + insideCode: boolean = false +): string { const mdSequence = node.attribs['data-md'] ?? ''; - const content = node.children.map((c) => processNode(c)).join(''); + const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); return `${mdSequence}${content}${mdSequence}`; } diff --git a/src/app/plugins/markdown/markdownToHtml.test.ts b/src/app/plugins/markdown/markdownToHtml.test.ts index b405fd37b..93fc71321 100644 --- a/src/app/plugins/markdown/markdownToHtml.test.ts +++ b/src/app/plugins/markdown/markdownToHtml.test.ts @@ -99,6 +99,17 @@ describe('markdownToHtml', () => { expect(result).toContain('not bold'); }); + it('preserves typed backslashes before punctuation inside fenced code', () => { + const result = markdownToHtml('```\n\\*literal\\*\n```'); + expect(result).toContain('\\*literal\\*'); + expect(result).toContain(' { + const result = markdownToHtml('Hi `\\*x\\*` there'); + expect(result).toContain('\\*x\\*'); + }); + it('does not treat >:3 as a block quote (requires space after >)', () => { const result = markdownToHtml('>:3'); expect(result).not.toContain('
    '); diff --git a/src/app/plugins/markdown/markdownToHtml.ts b/src/app/plugins/markdown/markdownToHtml.ts index 22ac5cd48..f0b1397fe 100644 --- a/src/app/plugins/markdown/markdownToHtml.ts +++ b/src/app/plugins/markdown/markdownToHtml.ts @@ -12,7 +12,7 @@ import { matrixEmoticonExtension, preprocessEmoticon } from './extensions/matrix import { matrixUnderlineExtension } from './extensions/matrix-underline'; import { escapeLineStartBlockquoteWithoutFollowingSpace, - unescapeMarkdownInlineSequences, + unescapeMarkdownInlineSequencesExceptInCodeHtml, } from './utils'; // Configure marked with Matrix extensions @@ -70,8 +70,8 @@ export function markdownToHtml(markdown: string): string { // Parse markdown to HTML using marked with our Matrix extensions const html = processor.parse(mathInput) as string; - // Unescape inline sequences (e.g., \*, \_) after parsing - const unescapedInline = unescapeMarkdownInlineSequences(html); + // Unescape inline sequences (e.g., \*, \_) after parsing, but not inside
    /
    +  const unescapedInline = unescapeMarkdownInlineSequencesExceptInCodeHtml(html);
     
       // Force all links to open in a new tab
       DOMPurify.addHook('afterSanitizeAttributes', (node) => {
    diff --git a/src/app/plugins/markdown/utils.ts b/src/app/plugins/markdown/utils.ts
    index e3cd00cce..822a1e5a1 100644
    --- a/src/app/plugins/markdown/utils.ts
    +++ b/src/app/plugins/markdown/utils.ts
    @@ -27,6 +27,37 @@ export const unescapeMarkdownInlineSequences = (text: string): string => {
       return parts.join('');
     };
     
    +const PLACEHOLDER_START = '\uE000';
    +const PLACEHOLDER_END = '\uE001';
    +
    +/**
    + * Like {@link unescapeMarkdownInlineSequences}, but leaves <pre>…</pre> and
    + * <code>…</code> regions unchanged so backslash escapes remain literal in HTML
    + * code blocks (CommonMark treats them as verbatim in the source markdown, and the post-parse
    + * HTML pass must not strip viewer-intended `\` characters there).
    + */
    +export const unescapeMarkdownInlineSequencesExceptInCodeHtml = (html: string): string => {
    +  const preserved: string[] = [];
    +  const tag = (idx: number) => `${PLACEHOLDER_START}${idx}${PLACEHOLDER_END}`;
    +
    +  let masked = html.replace(/]*>[\s\S]*?<\/pre>/gi, (chunk) => {
    +    preserved.push(chunk);
    +    return tag(preserved.length - 1);
    +  });
    +
    +  masked = masked.replace(/]*>[\s\S]*?<\/code>/gi, (chunk) => {
    +    preserved.push(chunk);
    +    return tag(preserved.length - 1);
    +  });
    +
    +  const unescaped = unescapeMarkdownInlineSequences(masked);
    +
    +  return unescaped.replace(
    +    new RegExp(`${PLACEHOLDER_START}(\\d+)${PLACEHOLDER_END}`, 'g'),
    +    (_, i) => preserved[parseInt(i, 10)] ?? ''
    +  );
    +};
    +
     /**
      * Recovers the markdown escape sequences in the given plain-text.
      * This function adds backslashes (`\`) before markdown characters that may need escaping
    diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
    index 8fafebafd..26985c66c 100644
    --- a/src/app/state/settings.ts
    +++ b/src/app/state/settings.ts
    @@ -72,6 +72,8 @@ export interface Settings {
       isWidgetDrawer: boolean;
       memberSortFilterIndex: number;
       enterForNewline: boolean;
    +  editorToolbar: boolean;
    +  composerToolbarOpen: boolean;
       messageLayout: MessageLayout;
       messageSpacing: MessageSpacing;
       hideMembershipEvents: boolean;
    @@ -188,6 +190,8 @@ export const defaultSettings: Settings = {
       isWidgetDrawer: false,
       memberSortFilterIndex: 0,
       enterForNewline: false,
    +  editorToolbar: false,
    +  composerToolbarOpen: false,
       messageLayout: 0,
       messageSpacing: '400',
       hideMembershipEvents: false,