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/.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 = 'parent
';
+ 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) => {
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(
+ 'one
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()}