From 22f49b7f2ad75d13abd619545f37ba9488ab7578 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 6 May 2026 19:06:35 -0500 Subject: [PATCH 1/4] fix extraneous escape sequences when editing within codeblocks --- .changeset/fix-extraneous-markdown.md | 5 + .../plugins/markdown/bidirectional.test.ts | 18 +++ .../plugins/markdown/htmlToMarkdown.test.ts | 17 +++ src/app/plugins/markdown/htmlToMarkdown.ts | 142 +++++++++++------- .../plugins/markdown/markdownToHtml.test.ts | 11 ++ src/app/plugins/markdown/markdownToHtml.ts | 9 +- src/app/plugins/markdown/utils.ts | 31 ++++ 7 files changed, 174 insertions(+), 59 deletions(-) create mode 100644 .changeset/fix-extraneous-markdown.md 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/plugins/markdown/bidirectional.test.ts b/src/app/plugins/markdown/bidirectional.test.ts index dff56737c..a7767cf0a 100644 --- a/src/app/plugins/markdown/bidirectional.test.ts +++ b/src/app/plugins/markdown/bidirectional.test.ts @@ -46,6 +46,24 @@ describe('bidirectional round-trip', () => { 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 dfdb16fca..ceda1d3a7 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,46 +104,46 @@ 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': - return processInlineWrapper(node, '_'); + return processInlineWrapper(node, '_', listDepth, insideCode); 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'; @@ -152,34 +152,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') { @@ -190,7 +199,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 ?? ''; @@ -198,28 +207,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(''); @@ -230,19 +252,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)); } }); @@ -253,20 +275,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 @@ -286,38 +312,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}||`; } @@ -329,9 +355,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 24a7592ae..61acb9f5c 100644 --- a/src/app/plugins/markdown/markdownToHtml.test.ts +++ b/src/app/plugins/markdown/markdownToHtml.test.ts @@ -70,6 +70,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('preserves img[data-mx-emoticon] tags with valid mxc URLs', () => { const html = ':blobcat:'; diff --git a/src/app/plugins/markdown/markdownToHtml.ts b/src/app/plugins/markdown/markdownToHtml.ts index 02338da9e..aa76b8839 100644 --- a/src/app/plugins/markdown/markdownToHtml.ts +++ b/src/app/plugins/markdown/markdownToHtml.ts @@ -4,7 +4,10 @@ import { matrixSpoilerExtension } from './extensions/matrix-spoiler'; import { matrixMathExtension, matrixMathBlockExtension } from './extensions/matrix-math'; import { matrixSubscriptExtension } from './extensions/matrix-subscript'; import { matrixEmoticonExtension, preprocessEmoticon } from './extensions/matrix-emoticon'; -import { unescapeMarkdownBlockSequences, unescapeMarkdownInlineSequences } from './utils'; +import { + unescapeMarkdownBlockSequences, + unescapeMarkdownInlineSequencesExceptInCodeHtml, +} from './utils'; // Configure marked with Matrix extensions const processor = marked.use({ @@ -58,8 +61,8 @@ export function markdownToHtml(markdown: string): string { // Parse markdown to HTML using marked with our Matrix extensions const html = processor.parse(preprocessed) 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 e15350e2d..e32a85f23 100644
    --- a/src/app/plugins/markdown/utils.ts
    +++ b/src/app/plugins/markdown/utils.ts
    @@ -32,6 +32,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
    
    From 336140008d0c7b5ea40e5fbc0342e29ea7afa3e9 Mon Sep 17 00:00:00 2001
    From: 7w1 
    Date: Wed, 6 May 2026 19:48:23 -0500
    Subject: [PATCH 2/4] add back toolbar
    
    ---
     .changeset/add-back-msg-toolbar.md            |   5 +
     src/app/components/editor/MarkdownToolbar.tsx | 280 ++++++++++++++++++
     src/app/components/editor/index.ts            |   1 +
     src/app/components/editor/keyboard.ts         |  14 +-
     src/app/features/room/RoomInput.tsx           |  38 +++
     src/app/features/settings/general/General.tsx |   9 +
     src/app/state/settings.ts                     |   4 +
     7 files changed, 344 insertions(+), 7 deletions(-)
     create mode 100644 .changeset/add-back-msg-toolbar.md
     create mode 100644 src/app/components/editor/MarkdownToolbar.tsx
    
    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/src/app/components/editor/MarkdownToolbar.tsx b/src/app/components/editor/MarkdownToolbar.tsx
    new file mode 100644
    index 000000000..c4f40eaac
    --- /dev/null
    +++ b/src/app/components/editor/MarkdownToolbar.tsx
    @@ -0,0 +1,280 @@
    +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 { useState } from 'react';
    +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 (
    +    
    +      
    +        
    +          
    +            }
    +            />
    +            }
    +            />
    +            }
    +            />
    +            }
    +            />
    +            }
    +            />
    +            }
    +            />
    +          
    +          
    +          
    +            }
    +            />
    +            }
    +            />
    +            }
    +            />
    +            }
    +            />
    +            
    +          
    +        
    +      
    +    
    +  );
    +}
    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/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
    index 448dee7a3..25d20ca93 100644
    --- a/src/app/features/room/RoomInput.tsx
    +++ b/src/app/features/room/RoomInput.tsx
    @@ -22,6 +22,7 @@ import {
       Icon,
       IconButton,
       Icons,
    +  Line,
       Menu,
       MenuItem,
       Overlay,
    @@ -58,6 +59,7 @@ import {
       ANYWHERE_AUTOCOMPLETE_PREFIXES,
       BEGINNING_AUTOCOMPLETE_PREFIXES,
       getLinks,
    +  MarkdownToolbar,
       replaceWithElement,
       BlockType,
     } from '$components/editor';
    @@ -242,6 +244,11 @@ export const RoomInput = forwardRef(
         const mx = useMatrixClient();
         const useAuthentication = useMediaAuthentication();
         const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
    +    const [editorToolbar] = useSetting(settingsAtom, 'editorToolbar');
    +    const [composerToolbarOpen, setComposerToolbarOpen] = useSetting(
    +      settingsAtom,
    +      'composerToolbarOpen'
    +    );
     
         const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
         const [mentionInReplies] = useSetting(settingsAtom, 'mentionInReplies');
    @@ -260,6 +267,10 @@ export const RoomInput = forwardRef(
           pluralkitProxyMessageHandler.init();
         }, [pluralkitProxyMessageHandler]);
     
    +    useEffect(() => {
    +      if (!editorToolbar) setComposerToolbarOpen(false);
    +    }, [editorToolbar, setComposerToolbarOpen]);
    +
         const [pkCompatEnable] = useSetting(settingsAtom, 'pkCompat');
         const [pmpProxyingEnable] = useSetting(settingsAtom, 'pmpProxying');
         const emojiBtnRef = useRef(null);
    @@ -1505,6 +1516,24 @@ export const RoomInput = forwardRef(
                     )}
                   
     
    +              {editorToolbar && (
    +                 setComposerToolbarOpen(!composerToolbarOpen)}
    +                >
    +                  
    +                
    +              )}
    +
                   
                     {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
                       (
                   
                 
               }
    +          bottom={
    +            editorToolbar &&
    +            composerToolbarOpen && (
    +              
    + + +
    + ) + } /> {showSchedulePicker && ( ) { 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 }>) { } /> + + } + /> + Date: Wed, 6 May 2026 20:05:30 -0500 Subject: [PATCH 3/4] add toolbar to other places --- src/app/components/editor/MarkdownToolbar.tsx | 52 ++++++++++++++++++- .../upload-card/UploadDescriptionEditor.tsx | 4 ++ src/app/features/room/RoomInput.tsx | 41 ++------------- .../features/room/message/MessageEditor.tsx | 4 ++ .../features/settings/account/BioEditor.tsx | 4 ++ 5 files changed, 67 insertions(+), 38 deletions(-) diff --git a/src/app/components/editor/MarkdownToolbar.tsx b/src/app/components/editor/MarkdownToolbar.tsx index c4f40eaac..64ebd6e60 100644 --- a/src/app/components/editor/MarkdownToolbar.tsx +++ b/src/app/components/editor/MarkdownToolbar.tsx @@ -17,7 +17,9 @@ import { toRem, } from 'folds'; import type { MouseEventHandler, ReactNode } from 'react'; -import { useState } 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'; @@ -278,3 +280,51 @@ export function MarkdownToolbar() { ); } + +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/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) => ( ( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); - const [editorToolbar] = useSetting(settingsAtom, 'editorToolbar'); - const [composerToolbarOpen, setComposerToolbarOpen] = useSetting( - settingsAtom, - 'composerToolbarOpen' - ); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [mentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); @@ -267,10 +262,6 @@ export const RoomInput = forwardRef( pluralkitProxyMessageHandler.init(); }, [pluralkitProxyMessageHandler]); - useEffect(() => { - if (!editorToolbar) setComposerToolbarOpen(false); - }, [editorToolbar, setComposerToolbarOpen]); - const [pkCompatEnable] = useSetting(settingsAtom, 'pkCompat'); const [pmpProxyingEnable] = useSetting(settingsAtom, 'pmpProxying'); const emojiBtnRef = useRef(null); @@ -1516,23 +1507,7 @@ export const RoomInput = forwardRef( )} - {editorToolbar && ( - setComposerToolbarOpen(!composerToolbarOpen)} - > - - - )} + {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( @@ -1706,15 +1681,7 @@ export const RoomInput = forwardRef( } - bottom={ - editorToolbar && - composerToolbarOpen && ( -
    - - -
    - ) - } + bottom={} /> {showSchedulePicker && ( ( onKeyUp={handleKeyUp} bottom={ <> + ( + {(anchor: RectCords | undefined, setAnchor) => ( + + {(anchor: RectCords | undefined, setAnchor) => ( Date: Wed, 6 May 2026 20:50:32 -0500 Subject: [PATCH 4/4] Update markdownToHtml.ts --- src/app/plugins/markdown/markdownToHtml.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/plugins/markdown/markdownToHtml.ts b/src/app/plugins/markdown/markdownToHtml.ts index 970814d99..f0b1397fe 100644 --- a/src/app/plugins/markdown/markdownToHtml.ts +++ b/src/app/plugins/markdown/markdownToHtml.ts @@ -13,7 +13,6 @@ import { matrixUnderlineExtension } from './extensions/matrix-underline'; import { escapeLineStartBlockquoteWithoutFollowingSpace, unescapeMarkdownInlineSequencesExceptInCodeHtml, - unescapeMarkdownInlineSequences, } from './utils'; // Configure marked with Matrix extensions