From b0349b431cf7c3a7d8e48ea2e5ce05b4e73a92f8 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 26 May 2026 23:37:09 -0500 Subject: [PATCH 1/2] fix: lists rendering p tags causing new lines --- .changeset/fix-list-rendering-p-tags.md | 5 +++++ src/app/plugins/react-custom-html-parser.test.tsx | 11 +++++++++++ src/app/plugins/react-custom-html-parser.tsx | 7 +++++-- 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-list-rendering-p-tags.md diff --git a/.changeset/fix-list-rendering-p-tags.md b/.changeset/fix-list-rendering-p-tags.md new file mode 100644 index 000000000..813308d6c --- /dev/null +++ b/.changeset/fix-list-rendering-p-tags.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fixed lists rendering `p` html tags on new lines. diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx index 5f0cb8fb3..c29158224 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -446,4 +446,15 @@ describe('react custom html parser', () => { expect(screen.queryByText('<test>')).not.toBeInTheDocument(); } ); + + it('unwraps paragraph tags inside list items instead of rendering block breaks', () => { + const { container } = renderMessage( + '
  1. one

  2. two

' + ); + + expect(container.querySelector('p')).toBeNull(); + expect(container.textContent).toContain('one'); + expect(container.textContent).toContain('two'); + expect(container.textContent).toContain('bullet'); + }); }); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 8a15df7ea..adb78f3a6 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -111,7 +111,7 @@ function KatexRenderer({ useEffect(() => { let mounted = true; - Promise.all([import('katex'), import('katex/dist/katex.min.css')]).then(([katex]) => { + void Promise.all([import('katex'), import('katex/dist/katex.min.css')]).then(([katex]) => { if (mounted) { setHtml(katex.default.renderToString(math, { throwOnError: false, displayMode })); } @@ -430,7 +430,7 @@ export function CodeBlock({ const [copied, setCopied] = useTimeoutToggle(); const handleCopy = () => { - copyToClipboard(extractTextFromChildren(children)); + void copyToClipboard(extractTextFromChildren(children)); setCopied(); }; @@ -631,6 +631,9 @@ export const getReactCustomHtmlParser = ( } if (name === 'p') { + if (parent instanceof Element && parent.name === 'li') { + return <>{renderChildren()}; + } return ( {renderChildren()} From ee575c0dbdce638e0d759bee905c02ea4e713f81 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 26 May 2026 23:42:24 -0500 Subject: [PATCH 2/2] fix: nested list indentation --- .changeset/fix-nested-lists-behavior.md | 5 ++++ .../plugins/markdown/bidirectional.test.ts | 20 +++++++++++++ .../markdown/expandBlockNewlines.test.ts | 18 +++++++++++ .../plugins/markdown/expandBlockNewlines.ts | 30 +++++++++++++++++++ .../plugins/markdown/htmlToMarkdown.test.ts | 17 +++++++++++ src/app/plugins/markdown/htmlToMarkdown.ts | 7 +++-- 6 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-nested-lists-behavior.md diff --git a/.changeset/fix-nested-lists-behavior.md b/.changeset/fix-nested-lists-behavior.md new file mode 100644 index 000000000..f5b3113e4 --- /dev/null +++ b/.changeset/fix-nested-lists-behavior.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fixed nested lists having wrong indentation levels when editing. diff --git a/src/app/plugins/markdown/bidirectional.test.ts b/src/app/plugins/markdown/bidirectional.test.ts index 31ab6dd53..b5102798f 100644 --- a/src/app/plugins/markdown/bidirectional.test.ts +++ b/src/app/plugins/markdown/bidirectional.test.ts @@ -103,6 +103,26 @@ describe('bidirectional round-trip', () => { expect(result).toContain('2. Second'); }); + it('round-trips nested sublists with four-space indent', () => { + const markdown = '1. parent\n - child'; + const html = markdownToHtml(markdown); + expect(html).toMatch(/
  • [\s\S]*
      { + const markdown = '1. parent\n - child'; + const html = markdownToHtml(markdown); + expect(html).toMatch(/
    • [\s\S]*
        { const markdown = '||hidden message||'; const html = markdownToHtml(markdown); diff --git a/src/app/plugins/markdown/expandBlockNewlines.test.ts b/src/app/plugins/markdown/expandBlockNewlines.test.ts index 700d743a7..154284191 100644 --- a/src/app/plugins/markdown/expandBlockNewlines.test.ts +++ b/src/app/plugins/markdown/expandBlockNewlines.test.ts @@ -15,6 +15,24 @@ describe('expandBlockBoundariesAfterSingleNewlines', () => { it('still expands when a blockquote ends', () => { expect(expandBlockBoundariesAfterSingleNewlines('> quote\nplain')).toBe('> quote\n\nplain'); }); + + it('does not expand between consecutive ordered list items', () => { + expect(expandBlockBoundariesAfterSingleNewlines('1. one\n2. two')).toBe('1. one\n2. two'); + }); + + it('does not expand before a 2-space nested sublist', () => { + expect(expandBlockBoundariesAfterSingleNewlines('1. test\n - sub')).toBe('1. test\n - sub'); + }); + + it('does not expand before a 4-space nested sublist', () => { + expect(expandBlockBoundariesAfterSingleNewlines('1. test\n - sub')).toBe( + '1. test\n - sub' + ); + }); + + it('still expands before the first top-level list item after prose', () => { + expect(expandBlockBoundariesAfterSingleNewlines('intro\n- item')).toBe('intro\n\n- item'); + }); }); describe('consecutive blockquotes', () => { diff --git a/src/app/plugins/markdown/expandBlockNewlines.ts b/src/app/plugins/markdown/expandBlockNewlines.ts index d4164ba3a..f436bb66d 100644 --- a/src/app/plugins/markdown/expandBlockNewlines.ts +++ b/src/app/plugins/markdown/expandBlockNewlines.ts @@ -135,11 +135,41 @@ function nextLineIsBlockStarter(md: string, newlineIdx: number): boolean { return looksLikeBlockStart(effective); } +function leadingSpaces(line: string): number { + let k = 0; + while (k < line.length && line[k] === ' ') k++; + return k; +} + +function lineLooksLikeListItem(line: string): boolean { + const effective = effectiveContentAfterEscapes(line); + if (effective === null) return false; + return /^\d{1,9}\.\s/.test(effective) || /^[-*+]\s/.test(effective); +} + +function nextLineIsNestedListItem(md: string, newlineIdx: number): boolean { + const prevLine = lineAtNewline(md, newlineIdx); + const nextLine = lineAfterNewline(md, newlineIdx); + if (!lineLooksLikeListItem(nextLine)) return false; + return leadingSpaces(nextLine) > leadingSpaces(prevLine); +} + +function nextLineIsSiblingListItem(md: string, newlineIdx: number): boolean { + const prevLine = lineAtNewline(md, newlineIdx); + const nextLine = lineAfterNewline(md, newlineIdx); + if (!lineLooksLikeListItem(prevLine) || !lineLooksLikeListItem(nextLine)) return false; + return leadingSpaces(prevLine) === leadingSpaces(nextLine); +} + function shouldExpandSingleNewline(md: string, newlineIdx: number): boolean { // Consecutive `>` lines belong to one blockquote, keep the single `\n` between them. if (prevLineIsBlockquote(md, newlineIdx) && nextLineContinuesBlockquote(md, newlineIdx)) { return false; } + // Keep single `\n` between list items (siblings) and before nested sublists (2- or 4-space indent). + if (nextLineIsSiblingListItem(md, newlineIdx) || nextLineIsNestedListItem(md, newlineIdx)) { + return false; + } if (nextLineIsBlockStarter(md, newlineIdx)) return true; // CommonMark lazy continuation keeps non-`>` lines inside blockquotes, close on single `\n`. if (prevLineIsBlockquote(md, newlineIdx) && !nextLineContinuesBlockquote(md, newlineIdx)) { diff --git a/src/app/plugins/markdown/htmlToMarkdown.test.ts b/src/app/plugins/markdown/htmlToMarkdown.test.ts index 57e5e25bd..219e67089 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.test.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.test.ts @@ -4,9 +4,12 @@ import { MX_EMOTICON_MD_SEP, MX_EMOTICON_MD_START, } from './extensions/matrix-emoticon'; +import { toMatrixCustomHTML, trimCustomHtml } from '$components/editor/output'; import { plainToEditorInput } from '$components/editor/input'; import { BlockType } from '$components/editor/types'; +import { injectDataMd } from './injectDataMd'; import { htmlToMarkdown } from './htmlToMarkdown'; +import { markdownToHtml } from './markdownToHtml'; describe('htmlToMarkdown', () => { it('converts headings', () => { @@ -125,6 +128,20 @@ describe('htmlToMarkdown', () => { expect(result).toContain('Item 1'); }); + it('indents nested sublists with four spaces per level', () => { + const html = '
        1. parent

          • child

        '; + expect(htmlToMarkdown(html)).toContain('1. parent'); + expect(htmlToMarkdown(html)).toContain(' - child'); + }); + + it('edit-load and save round-trip keeps nested sublist', () => { + const md = '1. test\n - sub\n'; + const loaded = htmlToMarkdown(injectDataMd(markdownToHtml(md))); + expect(loaded).toContain(' - sub'); + const html = trimCustomHtml(toMatrixCustomHTML(plainToEditorInput(loaded), {})); + expect(html).toMatch(/
      • [\s\S]*
          { const result = htmlToMarkdown('bold'); expect(result).toContain('**bold**'); diff --git a/src/app/plugins/markdown/htmlToMarkdown.ts b/src/app/plugins/markdown/htmlToMarkdown.ts index cf00d0dd4..0e3756d8d 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.ts @@ -11,6 +11,9 @@ import { isAllowedHtmlTag } from './allowedHtmlTags'; import { formatMfmColorDataMd } from './extensions/matrix-mfm-color'; import { isMatrixHexColor } from '$utils/matrixHtml'; +/** CommonMark list nesting indent (four spaces per level). */ +const LIST_MARKDOWN_INDENT = ' '; + /** * Converts Matrix-compatible HTML back to markdown for round-trip editing. * Preserves original markdown syntax via data-md attributes and converts @@ -419,7 +422,7 @@ function processUnorderedList( insideCode: boolean = false ): string { const mdSequence = node.attribs['data-md'] || '-'; - const indent = ' '.repeat(depth); + const indent = LIST_MARKDOWN_INDENT.repeat(depth); const items = node.children .filter((c): c is Element => isTag(c) && c.name === 'li') .map((li) => { @@ -439,7 +442,7 @@ function processOrderedList(node: Element, depth: number = 0, insideCode: boolea ? mdSequence : `${mdSequence}.`; - const indent = ' '.repeat(depth); + const indent = LIST_MARKDOWN_INDENT.repeat(depth); const items = node.children .filter((c): c is Element => isTag(c) && c.name === 'li') .map((li, index) => {