Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-list-rendering-p-tags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fixed lists rendering `p` html tags on new lines.
5 changes: 5 additions & 0 deletions .changeset/fix-nested-lists-behavior.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fixed nested lists having wrong indentation levels when editing.
20 changes: 20 additions & 0 deletions src/app/plugins/markdown/bidirectional.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<li>[\s\S]*<ul/i);
const injected = injectDataMd(html);
const result = htmlToMarkdown(injected);
expect(result).toContain('1. parent');
expect(result).toContain(' - child');
});

it('round-trips nested sublists written with two-space indent', () => {
const markdown = '1. parent\n - child';
const html = markdownToHtml(markdown);
expect(html).toMatch(/<li>[\s\S]*<ul/i);
const injected = injectDataMd(html);
const result = htmlToMarkdown(injected);
expect(result).toContain('1. parent');
expect(result).toContain('- child');
});

it('round-trips spoiler syntax', () => {
const markdown = '||hidden message||';
const html = markdownToHtml(markdown);
Expand Down
18 changes: 18 additions & 0 deletions src/app/plugins/markdown/expandBlockNewlines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
30 changes: 30 additions & 0 deletions src/app/plugins/markdown/expandBlockNewlines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
17 changes: 17 additions & 0 deletions src/app/plugins/markdown/htmlToMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -125,6 +128,20 @@ describe('htmlToMarkdown', () => {
expect(result).toContain('Item 1');
});

it('indents nested sublists with four spaces per level', () => {
const html = '<ol><li><p>parent</p><ul><li><p>child</p></li></ul></li></ol>';
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(/<li>[\s\S]*<ul/i);
});

it('preserves data-md attributes for round-trip', () => {
const result = htmlToMarkdown('<strong data-md="**">bold</strong>');
expect(result).toContain('**bold**');
Expand Down
7 changes: 5 additions & 2 deletions src/app/plugins/markdown/htmlToMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) => {
Expand Down
11 changes: 11 additions & 0 deletions src/app/plugins/react-custom-html-parser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,4 +446,15 @@ describe('react custom html parser', () => {
expect(screen.queryByText('&lt;test&gt;')).not.toBeInTheDocument();
}
);

it('unwraps paragraph tags inside list items instead of rendering block breaks', () => {
const { container } = renderMessage(
'<ol><li><p>one</p></li><li><p>two</p></li></ol><ul><li><p>bullet</p></li></ul>'
);

expect(container.querySelector('p')).toBeNull();
expect(container.textContent).toContain('one');
expect(container.textContent).toContain('two');
expect(container.textContent).toContain('bullet');
});
});
7 changes: 5 additions & 2 deletions src/app/plugins/react-custom-html-parser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}
Expand Down Expand Up @@ -430,7 +430,7 @@ export function CodeBlock({
const [copied, setCopied] = useTimeoutToggle();

const handleCopy = () => {
copyToClipboard(extractTextFromChildren(children));
void copyToClipboard(extractTextFromChildren(children));
setCopied();
};

Expand Down Expand Up @@ -631,6 +631,9 @@ export const getReactCustomHtmlParser = (
}

if (name === 'p') {
if (parent instanceof Element && parent.name === 'li') {
return <>{renderChildren()}</>;
}
return (
<Text {...props} className={classNames(css.Paragraph, css.MarginSpaced)} size="Inherit">
{renderChildren()}
Expand Down
Loading