From ec62243dedec0ba82b8ef393438eecd8f35fa0ef Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 29 Oct 2026 18:31:17 -0700 Subject: [PATCH] fix(mobile): keep table/list line breaks, item paragraphs and list code blocks through rich Markdown saves (#35259) * fix(mobile): keep table-cell and list-item line breaks on their own source line Table cells and list items save line breaks as
(and sibling P/DIV blocks inside an item as breaks), and reopen attribute-free
tokens as breaks, so Shift+Enter or Enter no longer splits a row or ends a list. Typed '
' text gets only its '<' entity-escaped, raw text newlines become spaces, and browser caret placeholder breaks are dropped (a cell that holds only a placeholder saves empty; a cleared item keeps a
so its marker still parses). * fix(mobile): keep fenced code blocks inside Markdown list items Indented fences under a list item (README steps such as '1. Install:' followed by an indented bash fence) now read as a code block inside the item and save back under the item's continuation indent. Only items whose continuation holds a fence change; headings, quotes, rules and tables after the fence end the item and read as before. Top-level and list code blocks share one PRE writer, blank separator lines are not indented, and a fence needs no blank line beside it so tight items stay tight. * fix(mobile): keep caret code blocks out of the paragraph they follow Code block with a collapsed caret after text in its block ran native insertHTML, which merged the PRE into that paragraph as prose. Prepend a
only when content precedes the caret, so block-start, empty-line and empty-document insertions stay as they were. * fix(mobile): keep authored runs of trailing table-cell breaks * fix(mobile): use template literal for list code-fence probe --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../mobile-web-app-code-block-selection.mjs | 62 +++++- .../rich-markdown/editor-commands.ts | 49 ++++- .../rich-markdown/html-block-markdown.ts | 22 ++- .../rich-markdown/html-code-block-markdown.ts | 11 ++ .../rich-markdown/html-inline-markdown.ts | 180 ++++++++++++++++-- .../rich-markdown/html-list-markdown.ts | 131 ++++++++++++- .../rich-markdown/markdown-inline-render.ts | 17 +- .../markdown-list-block-boundaries.test.ts | 107 +++++++++++ .../markdown-list-code-blocks.test.ts | 155 +++++++++++++++ .../markdown-list-linebreaks.test.ts | 127 ++++++++++++ .../rich-markdown/markdown-list-parse.ts | 97 +++++++++- .../rich-markdown/markdown-list-render.ts | 32 +++- .../markdown-table-linebreaks.test.ts | 121 ++++++++++++ .../rich-markdown/markdown-to-html.ts | 19 +- 14 files changed, 1077 insertions(+), 53 deletions(-) create mode 100644 mobile/src/components/rich-markdown/html-code-block-markdown.ts create mode 100644 mobile/src/components/rich-markdown/markdown-list-block-boundaries.test.ts create mode 100644 mobile/src/components/rich-markdown/markdown-list-code-blocks.test.ts create mode 100644 mobile/src/components/rich-markdown/markdown-list-linebreaks.test.ts create mode 100644 mobile/src/components/rich-markdown/markdown-table-linebreaks.test.ts diff --git a/config/scripts/mobile-web-app/mobile-web-app-code-block-selection.mjs b/config/scripts/mobile-web-app/mobile-web-app-code-block-selection.mjs index db51faf8b06..ee550cf77a4 100644 --- a/config/scripts/mobile-web-app/mobile-web-app-code-block-selection.mjs +++ b/config/scripts/mobile-web-app/mobile-web-app-code-block-selection.mjs @@ -41,7 +41,37 @@ async function verifyCodeBlockSelections({ html: '

Before

\n

After

', literal: 'code', collapsed: true, - saved: 'Before\n\n```\ncode\n```\n\nAfter' + saved: 'Before\n\n```\ncode\n```\n\nAfter', + cleanBefore: true + }, + { + markdown: 'First\n\nAfter', + html: '

First

\n

After

', + literal: 'code', + caret: { selector: 'p', offset: 2 }, + saved: 'Fi\n\n```\ncode\n```\n\nrst\n\nAfter' + }, + { + markdown: 'First\n\nAfter', + html: '

First

\n

After

', + literal: 'code', + caret: { selector: 'p', offset: 'end' }, + saved: 'First\n\n```\ncode\n```\n\nAfter' + }, + { + markdown: '- First\n- Next', + html: '', + literal: 'code', + caret: { selector: 'li p', offset: 'end' }, + saved: '- First\n ```\n code\n ```\n- Next' + }, + { + markdown: '', + html: '


', + literal: 'code', + caret: { selector: 'p', offset: 0 }, + saved: '```\ncode\n```', + cleanBefore: true }, { markdown: 'Before\n\nSelected text\n\n- after', @@ -55,14 +85,23 @@ async function verifyCodeBlockSelections({ const { page, consoleErrors } = await openPage(browser) try { await setContent(page, sample.markdown, sample.html) - await (sample.partial || sample.collapsed || sample.paragraph + await (sample.partial || sample.collapsed || sample.paragraph || sample.caret ? page.evaluate( - ({ partial, paragraph }) => { + ({ partial, paragraph, caret }) => { const editor = document.querySelector('#first-surface #editor') const paragraphs = editor.querySelectorAll('p') editor.focus() const range = document.createRange() - if (partial) { + if (caret) { + const target = editor.querySelector(caret.selector) + if (caret.offset === 'end') { + range.selectNodeContents(target) + range.collapse(false) + } else { + range.setStart(target.firstChild ?? target, caret.offset) + range.collapse(true) + } + } else if (partial) { range.setStart(paragraphs[0].firstChild, 7) range.setEnd(paragraphs[2].firstChild, 4) } else if (paragraph) { @@ -74,7 +113,11 @@ async function verifyCodeBlockSelections({ window.getSelection().removeAllRanges() window.getSelection().addRange(range) }, - { partial: sample.partial === true, paragraph: sample.paragraph === true } + { + partial: sample.partial === true, + paragraph: sample.paragraph === true, + caret: sample.caret ?? null + } ) : select(page, 'all')) const selected = await page.evaluate(() => window.getSelection().toString()) @@ -83,6 +126,15 @@ async function verifyCodeBlockSelections({ await expect .poll(() => page.evaluate(() => globalThis.__orcaEditor.changes.at(-1))) .toBe(saved) + if (sample.cleanBefore) { + // No blank line may appear above a code block inserted at an empty line or a block start. + expect( + await page.evaluate(() => { + const before = document.querySelector('#first-surface #editor pre').previousSibling + return before === null || (before.nodeName !== 'BR' && before.textContent !== '') + }) + ).toBe(true) + } await page.keyboard.press(`${historyModifier}+z`) await expect.poll(() => readSurface(page)).toBe(sample.html) expect(await page.evaluate(() => window.getSelection().toString())).toBe(selected) diff --git a/mobile/src/components/rich-markdown/editor-commands.ts b/mobile/src/components/rich-markdown/editor-commands.ts index bc5eacaab71..9ebd7bb692e 100644 --- a/mobile/src/components/rich-markdown/editor-commands.ts +++ b/mobile/src/components/rich-markdown/editor-commands.ts @@ -17,6 +17,48 @@ const TASK_LIST_HTML = const CODE_BLOCK_HTML = '
code


' +function drawsContent(node: Node): boolean { + return ( + /[^ \t\r\n]/.test(node.textContent ?? '') || + ((node instanceof Element || node instanceof DocumentFragment) && + node.querySelector('img') !== null) || + (node instanceof Element && node.tagName.toLowerCase() === 'img') + ) +} + +// Native insertHTML merges the fragment's first block into a block with content before the caret, so the PRE becomes prose. +function caretFollowsContent(scope: RichMarkdownEditorScope): boolean { + const selection = scope.getSelection() + if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) { + return false + } + const caret = selection.getRangeAt(0) + const editor = editorElement(scope) + if (caret.startContainer === editor) { + const previous = editor.childNodes.item(caret.startOffset - 1) + return ( + previous !== null && + !(previous instanceof Element && previous.closest('table')) && + drawsContent(previous) + ) + } + const anchor = + caret.startContainer instanceof Element + ? caret.startContainer + : caret.startContainer.parentElement + if (!anchor || !editor.contains(anchor) || anchor.closest('table, pre')) { + return false + } + const block = anchor.closest('p, div, li, h1, h2, h3, h4, h5, h6, blockquote') + if (!block || block === editor || !editor.contains(block)) { + return false + } + const before = scope.getDocument().createRange() + before.setStart(block, 0) + before.setEnd(caret.startContainer, caret.startOffset) + return drawsContent(before.cloneContents()) +} + function exec(scope: RichMarkdownEditorScope, command: string, value?: string) { scope.getDocument().execCommand(command, false, value) } @@ -81,7 +123,12 @@ const COMMANDS: Record< if (scope.getSelection()?.isCollapsed === false) { exec(scope, 'formatBlock', 'pre') } else { - exec(scope, 'insertHTML', CODE_BLOCK_HTML) + // A leading break ends the caret's line first; the stray root
it leaves saves as nothing. + exec( + scope, + 'insertHTML', + caretFollowsContent(scope) ? `
${CODE_BLOCK_HTML}` : CODE_BLOCK_HTML + ) } }, link: (scope) => insertUrl(scope, 'link', 'createLink'), diff --git a/mobile/src/components/rich-markdown/html-block-markdown.ts b/mobile/src/components/rich-markdown/html-block-markdown.ts index d668e17f73e..4d211268dce 100644 --- a/mobile/src/components/rich-markdown/html-block-markdown.ts +++ b/mobile/src/components/rich-markdown/html-block-markdown.ts @@ -1,10 +1,11 @@ -import { codeFenceFor } from './markdown-code-fence' +import { codeBlockMarkdown } from './html-code-block-markdown' import { escapeTableCell } from './markdown-table-rows' import { inlineChildren, inlineMarkdown, inlineNodesMarkdown, - textContent + textContent, + trailingLineBreaks } from './html-inline-markdown' import { holdsUnownedList, listMarkdown } from './html-list-markdown' @@ -139,6 +140,15 @@ function headingCodeMarkdown(element: Element): string { return blocks.join('\n\n') } +// A lone trailing
is the browser's caret placeholder (a cleared cell keeps one); a run of two is authored spacing. +function tableCellMarkdown(cell: Element): string { + const markdown = inlineChildren(cell, 'cell') + .replace(/\r\n?|\n/g, ' ') + .trim() + const trailing = trailingLineBreaks(markdown) + return trailing.count === 1 ? markdown.slice(0, trailing.start).trimEnd() : markdown +} + // Unknown block elements serialize inline content so browser-created tags cannot lose user text. export function blockMarkdown(node: Node): string { if (node.nodeType === Node.TEXT_NODE) { @@ -164,11 +174,7 @@ export function blockMarkdown(node: Node): string { return quoteMarkdown(node) } if (tag === 'pre') { - const language = node.getAttribute('data-language') ?? '' - // Drop trailing newlines: browsers leave a placeholder
after Enter at the end of a block. - const code = textContent(node).replace(/\n+$/, '') - const fence = codeFenceFor(code) - return `${fence}${language}\n${code}\n${fence}` + return codeBlockMarkdown(node) } if (tag === 'ul' || tag === 'ol') { return listMarkdown(node, 0) @@ -179,7 +185,7 @@ export function blockMarkdown(node: Node): string { return '' } const cellsFor = (row: Element) => - Array.from(row.children).map((cell) => escapeTableCell(inlineChildren(cell).trim())) + Array.from(row.children).map((cell) => escapeTableCell(tableCellMarkdown(cell))) const headers = cellsFor(rows[0]!) const bodyRows = rows.slice(1).map(cellsFor) const separator = headers.map(() => '---').join(' | ') diff --git a/mobile/src/components/rich-markdown/html-code-block-markdown.ts b/mobile/src/components/rich-markdown/html-code-block-markdown.ts new file mode 100644 index 00000000000..3cfb0ffb588 --- /dev/null +++ b/mobile/src/components/rich-markdown/html-code-block-markdown.ts @@ -0,0 +1,11 @@ +import { textContent } from './html-inline-markdown' +import { codeFenceFor } from './markdown-code-fence' + +/** A PRE as a fenced block, written the same at top level and inside list items. */ +export function codeBlockMarkdown(pre: Element): string { + const language = pre.getAttribute('data-language') ?? '' + // Drop trailing newlines: browsers leave a placeholder
after Enter at the end of a block. + const code = textContent(pre).replace(/\n+$/, '') + const fence = codeFenceFor(code) + return `${fence}${language}\n${code}\n${fence}` +} diff --git a/mobile/src/components/rich-markdown/html-inline-markdown.ts b/mobile/src/components/rich-markdown/html-inline-markdown.ts index cc99828ff0d..a26fd365003 100644 --- a/mobile/src/components/rich-markdown/html-inline-markdown.ts +++ b/mobile/src/components/rich-markdown/html-inline-markdown.ts @@ -46,35 +46,123 @@ function isInlineCode(node: Node): boolean { ) } +/** + * How breaks inside the serialized run are spelled: `newline` for multi-line blocks, `cell` for table + * cells and `item` for list items, which must stay on one source line. Items also turn sibling P/DIV + * blocks (native Enter) into breaks. + */ +export type InlineLineBreaks = 'newline' | 'cell' | 'item' + +export const LINE_BREAK_TAG = '
' + +// Cells and items reopen `
` tokens as breaks, so escape only that opener in typed text; raw newlines render as spaces. +function singleLineText(text: string): string { + return text.replace(/\r\n?|\n/g, ' ').replace(/<(?=[bB][rR][ \t]*\/?>)/g, '<') +} + +/** Where the trailing run of `
` tags (spaces between them included) starts, and its length. */ +export function trailingLineBreaks(markdown: string): { start: number; count: number } { + let start = markdown.length + let count = 0 + for (;;) { + let end = start + while (end > 0 && /\s/.test(markdown[end - 1] ?? '')) { + end -= 1 + } + if (!markdown.endsWith(LINE_BREAK_TAG, end)) { + return { start, count } + } + start = end - LINE_BREAK_TAG.length + count += 1 + } +} + +function markMarkdown(delimiter: string, inner: string, breaks: InlineLineBreaks): string { + if (breaks === 'newline') { + return `${delimiter}${inner}${delimiter}` + } + // Edge breaks go outside the delimiters: CommonMark readers leave `**a
**` unclosed. + let start = 0 + while (inner.startsWith(LINE_BREAK_TAG, start)) { + start += LINE_BREAK_TAG.length + } + let end = inner.length + while (end > start && inner.endsWith(LINE_BREAK_TAG, end)) { + end -= LINE_BREAK_TAG.length + } + const body = inner.slice(start, end) + return body ? `${inner.slice(0, start)}${delimiter}${body}${delimiter}${inner.slice(end)}` : inner +} + +function isCollapsibleText(node: Node): boolean { + return node.nodeType === Node.TEXT_NODE && /^[ \t\r\n]*$/.test(node.textContent ?? '') +} + +// The last node that draws something, so a trailing
is found through marks and wrappers. +function lastRenderedLeaf(node: Node): Node | null { + for (let child = node.lastChild; child; child = child.previousSibling) { + if (child.nodeType === Node.TEXT_NODE) { + if (!isCollapsibleText(child)) { + return child + } + continue + } + if (!(child instanceof Element)) { + continue + } + const tag = child.tagName.toLowerCase() + if (tag === 'br' || tag === 'img') { + return child + } + if (tag === 'label' || tag === 'ul' || tag === 'ol') { + continue + } + const leaf = lastRenderedLeaf(child) + if (leaf) { + return leaf + } + } + return null +} + +function endsWithLineBreak(node: Node): boolean { + const leaf = + node instanceof Element && node.tagName.toLowerCase() === 'br' ? node : lastRenderedLeaf(node) + return leaf instanceof Element && leaf.tagName.toLowerCase() === 'br' +} + // Serialize task label as empty: checkbox chrome is replaced by list writer’s Markdown marker. -export function inlineMarkdown(node: Node | null | undefined): string { +export function inlineMarkdown( + node: Node | null | undefined, + breaks: InlineLineBreaks = 'newline' +): string { if (!node) { return '' } if (node.nodeType === Node.TEXT_NODE) { - return textContent(node) + return breaks === 'newline' ? textContent(node) : singleLineText(textContent(node)) } if (!(node instanceof Element)) { return '' } const tag = node.tagName.toLowerCase() if (tag === 'br') { - return '\n' + return breaks === 'newline' ? '\n' : LINE_BREAK_TAG } if (tag === 'strong' || tag === 'b') { - return `**${inlineChildren(node)}**` + return markMarkdown('**', inlineChildren(node, breaks), breaks) } if (tag === 'em' || tag === 'i') { - return `*${inlineChildren(node)}*` + return markMarkdown('*', inlineChildren(node, breaks), breaks) } if (tag === 's' || tag === 'del' || tag === 'strike') { - return `~~${inlineChildren(node)}~~` + return markMarkdown('~~', inlineChildren(node, breaks), breaks) } if (isInlineCode(node)) { return codeMarkdown(inlineCodeText(node)) } if (tag === 'a') { - return `[${inlineChildren(node)}](${node.getAttribute('href') ?? ''})` + return `[${inlineChildren(node, breaks)}](${node.getAttribute('href') ?? ''})` } if (tag === 'img') { return `![${node.getAttribute('alt') ?? ''}](${node.getAttribute('src') ?? ''})` @@ -82,33 +170,87 @@ export function inlineMarkdown(node: Node | null | undefined): string { if (tag === 'label') { return '' } - return inlineChildren(node) + return inlineChildren(node, breaks) } -export function inlineChildren(element: Element): string { - return inlineNodesMarkdown(element.childNodes) +export function inlineChildren(element: Element, breaks: InlineLineBreaks = 'newline'): string { + return inlineNodesMarkdown(element.childNodes, breaks) } -export function inlineNodesMarkdown(nodes: Iterable): string { +// Adjacent text nodes are joined before escaping so a `
` typed across a node split stays literal. +export function inlineNodesMarkdown( + nodes: Iterable, + breaks: InlineLineBreaks = 'newline' +): string { const parts: string[] = [] let code = '' + let text = '' + let whitespace = '' + let emitted = false + let previousBlock = false + let previousEndsWithBreak = false + const emit = (markdown: string, block: boolean, endsWithBreak: boolean) => { + if (block || previousBlock) { + if (emitted && !previousEndsWithBreak) { + parts.push(LINE_BREAK_TAG) + } + } else if (whitespace) { + parts.push(whitespace) + } + whitespace = '' + parts.push(markdown) + emitted = true + previousBlock = block + previousEndsWithBreak = endsWithBreak + } + const flushText = () => { + if (!text) { + return + } + const value = text.replace(/\u00a0/g, ' ') + if (breaks === 'newline') { + emit(value, false, false) + } else if (breaks === 'item' && /^[ \t\r\n]*$/.test(text)) { + // Formatting whitespace beside a block draws nothing; between inline runs it is a space. + whitespace += singleLineText(value) + } else { + emit(singleLineText(value), false, false) + } + text = '' + } + const flushCode = () => { + if (code) { + emit(codeMarkdown(code), false, false) + code = '' + } + } for (const child of nodes) { if (isInlineCode(child)) { + flushText() code += inlineCodeText(child) continue } - const markdown = inlineMarkdown(child) + if (child.nodeType === Node.TEXT_NODE) { + if (child.textContent) { + flushCode() + text += child.textContent + } + continue + } + const block = breaks === 'item' && child instanceof Element && /^(p|div)$/i.test(child.tagName) + // Spaces at a block's edges collapse, so they stay out of the line they would now share. + const markdown = block ? inlineMarkdown(child, breaks).trim() : inlineMarkdown(child, breaks) if (!markdown) { continue } - if (code) { - parts.push(codeMarkdown(code)) - code = '' - } - parts.push(markdown) + flushText() + flushCode() + emit(markdown, block, breaks === 'item' && endsWithLineBreak(child)) } - if (code) { - parts.push(codeMarkdown(code)) + flushText() + flushCode() + if (whitespace && !previousBlock) { + parts.push(whitespace) } return parts.join('') } diff --git a/mobile/src/components/rich-markdown/html-list-markdown.ts b/mobile/src/components/rich-markdown/html-list-markdown.ts index e5c50dbf451..372b78006df 100644 --- a/mobile/src/components/rich-markdown/html-list-markdown.ts +++ b/mobile/src/components/rich-markdown/html-list-markdown.ts @@ -1,4 +1,17 @@ -import { inlineChildren } from './html-inline-markdown' +import { codeBlockMarkdown } from './html-code-block-markdown' +import { inlineNodesMarkdown, LINE_BREAK_TAG, trailingLineBreaks } from './html-inline-markdown' +import { openingFence } from './markdown-code-fence' + +function itemInlineMarkdown(nodes: Iterable): string { + const markdown = inlineNodesMarkdown(nodes, 'item') + .replace(/\r\n?|\n/g, ' ') + .trim() + // A lone trailing
is the browser's invisible caret placeholder; a run of two draws an empty line. + const trailing = trailingLineBreaks(markdown) + return trailing.count === 1 && trailing.start > 0 + ? markdown.slice(0, trailing.start).trimEnd() + : markdown +} // Remove checkbox label/nested lists on clone only: live checkbox must remain, nested lists serialize at own indentation. export function listItemText(item: Element): string { @@ -9,7 +22,104 @@ export function listItemText(item: Element): string { } clone.querySelectorAll('label').forEach((label) => label.remove()) clone.querySelectorAll('ul, ol').forEach((list) => list.remove()) - return inlineChildren(clone).trim() + return itemInlineMarkdown(clone.childNodes) +} + +function hasOwnedCodeBlock(item: Element): boolean { + const pending = Array.from(item.children) + for (let node = pending.pop(); node; node = pending.pop()) { + const tag = node.tagName.toLowerCase() + if (tag === 'pre') { + return true + } + if (tag === 'p' || tag === 'div') { + pending.push(...Array.from(node.children)) + } + } + return false +} + +// An item's paragraphs and code blocks as Markdown lines, or null when the one-line writer applies. +// Headings, quotes, tables and rules also fall back to that writer rather than lose their markers here. +function listItemCodeBlocks(item: Element, isTask: boolean): string | null { + if (!hasOwnedCodeBlock(item)) { + return null + } + let body = '' + let previousCode: boolean | null = null + let firstCode = false + // A fence needs no blank line beside it, so tight README items stay tight; paragraphs need one. + const push = (markdown: string, code: boolean) => { + if (previousCode === null) { + firstCode = code + } else { + body += previousCode || code ? '\n' : '\n\n' + } + body += markdown + previousCode = code + } + let inline: Node[] = [] + let nested = false + const flush = () => { + const markdown = itemInlineMarkdown(inline) + inline = [] + // A paragraph holding only the caret placeholder saves as nothing, as it does outside lists. + if (markdown && markdown !== LINE_BREAK_TAG) { + push(markdown, false) + } + } + const pending: (Node | null)[] = [] + const pushChildren = (parent: Node) => { + for (let index = parent.childNodes.length - 1; index >= 0; index -= 1) { + const child = parent.childNodes.item(index) + if (child) { + pending.push(child) + } + } + } + pushChildren(item) + while (pending.length) { + const node = pending.pop() + if (!node) { + flush() + continue + } + if (node instanceof Element) { + const tag = node.tagName.toLowerCase() + if (tag === 'ul' || tag === 'ol' || tag === 'label') { + nested ||= tag !== 'label' + continue + } + if (nested || /^(h[1-6]|blockquote|table|hr)$/.test(tag)) { + return null + } + if (tag === 'pre') { + const language = node.getAttribute('data-language') ?? '' + if (openingFence(`\`\`\`${language}`)?.language !== language) { + return null + } + flush() + push(codeBlockMarkdown(node), true) + continue + } + if (tag === 'p' || tag === 'div') { + flush() + pending.push(null) + pushChildren(node) + continue + } + } + if (nested && node.textContent?.trim()) { + return null + } + inline.push(node) + } + flush() + if (!body) { + return null + } + // The task reader cannot open a fence after the checkbox, so an empty first line holds the place. + return isTask && firstCode ? `${LINE_BREAK_TAG}\n${body}` : body } /** The lists directly inside an item, rather than every list anywhere beneath it. */ @@ -43,10 +153,25 @@ export function listMarkdown(element: Element, depth: number): string { const listNumber = item.getAttribute('data-list-number') || item.getAttribute('value') marker = tag === 'ol' ? `${listNumber || orderedStart + index}. ` : '- ' } - const line = indent + marker + listItemText(item) + const blocks = listItemCodeBlocks(item, isTask) + const continuation = indent + ' '.repeat(tag === 'ol' ? marker.length : 2) + // Blank separator lines stay empty; content lines take the item's continuation indent. + const line = blocks + ? indent + + marker + + blocks + .split('\n') + .map((text, lineIndex) => (lineIndex === 0 || !text ? text : continuation + text)) + .join('\n') + : indent + marker + listItemText(item) + // Children of a wide ordered marker start at its content column, or readers end the item. + const nestedIndent = blocks + ? ' '.repeat(Math.max(0, continuation.length - indent.length - 2)) + : '' const nested = directNestedLists(item) .map((list) => listMarkdown(list, depth + 1)) .filter(Boolean) + .map((markdown) => (nestedIndent ? markdown.replace(/^(?=.)/gm, nestedIndent) : markdown)) .join('\n') return nested ? `${line}\n${nested}` : line }) diff --git a/mobile/src/components/rich-markdown/markdown-inline-render.ts b/mobile/src/components/rich-markdown/markdown-inline-render.ts index 9cccdcdcd30..efdfdccd61e 100644 --- a/mobile/src/components/rich-markdown/markdown-inline-render.ts +++ b/mobile/src/components/rich-markdown/markdown-inline-render.ts @@ -5,9 +5,10 @@ import { import { escapeAttr, escapeHtml, escapeLiteralHtml, isSafeUrl } from './markdown-escaping' // Build global exec regex per recursive call: shared lastIndex would leak between nested parses. Escape text between recognized inline forms. -export function renderInline(text: string): string { +// Table cells and list items opt in to attribute-free `
` tokens, the only way they hold a line break. +export function renderInline(text: string, allowLineBreaks = false): string { const pattern = - /(!\[[^\]]*\]\([^)]+\)|`+|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g + /(!\[[^\]]*\]\([^)]+\)|`+|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+|<[bB][rR][ \t]*\/?>)/g let codeSpans: MarkdownCodeSpanScanner | undefined let output = '' let lastIndex = 0 @@ -17,10 +18,12 @@ export function renderInline(text: string): string { const token = match[0] const image = token.match(/^!\[([^\]]*)\]\(([^)]+)\)$/) const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/) - if (image && isSafeUrl(image[2])) { + if (token.startsWith('<')) { + output += allowLineBreaks ? '
' : escapeHtml(token) + } else if (image && isSafeUrl(image[2])) { output += `${escapeAttr(image[1] ?? '')}` } else if (link && isSafeUrl(link[2])) { - output += `${renderInline(link[1]!)}` + output += `${renderInline(link[1]!, allowLineBreaks)}` } else if (/^https?:\/\//i.test(token)) { output += `${escapeHtml(token)}` } else if (token.startsWith('`')) { @@ -37,11 +40,11 @@ export function renderInline(text: string): string { pattern.lastIndex = spanEnd } } else if (token.startsWith('~~')) { - output += `${renderInline(token.slice(2, -2))}` + output += `${renderInline(token.slice(2, -2), allowLineBreaks)}` } else if (token.startsWith('**') || token.startsWith('__')) { - output += `${renderInline(token.slice(2, -2))}` + output += `${renderInline(token.slice(2, -2), allowLineBreaks)}` } else { - output += `${renderInline(token.slice(1, -1))}` + output += `${renderInline(token.slice(1, -1), allowLineBreaks)}` } lastIndex = pattern.lastIndex match = pattern.exec(text) diff --git a/mobile/src/components/rich-markdown/markdown-list-block-boundaries.test.ts b/mobile/src/components/rich-markdown/markdown-list-block-boundaries.test.ts new file mode 100644 index 00000000000..9f596557563 --- /dev/null +++ b/mobile/src/components/rich-markdown/markdown-list-block-boundaries.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it } from 'vitest' +import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document' +import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup' +import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support' +import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract' + +const stopDocuments: (() => void)[] = [] +const lists = [ + ['bullet', '- First\n- Next\n\nOutside', 'li'], + ['task', '- [ ] First\n- [x] Next\n\nOutside', 'li > div'] +] as const + +function surface(source: string) { + document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP + const messages: MobileRichMarkdownEditorMessage[] = [] + const editorDocument = createRichMarkdownEditorDocument({ + ...quietRichMarkdownEditorHost(), + postToHost: (message) => messages.push(message) + }) + stopDocuments.push(editorDocument.stop) + editorDocument.send.setMarkdown(source, 7) + return { editorDocument, editor: document.getElementById('editor')!, messages } +} + +afterEach(() => { + stopDocuments.splice(0).forEach((stop) => stop()) + document.body.innerHTML = '' +}) + +for (const [kind, source, selector] of lists) { + it.each([ + ['

First

Second

', 'First
Second', 'First
Second'], + [ + '

First

Second

', + '`First`
`Second`', + 'First
Second' + ] + ])( + `preserves ${kind} item block boundaries through host save and reopen: %s`, + (html, text, inline) => { + const { editorDocument, editor, messages } = surface(source) + const body = editor.querySelector(selector)! + body.innerHTML = html + const live = body.outerHTML + const contents = Array.from(editor.querySelectorAll('li')).map((item) => item.textContent) + const checks = Array.from(editor.querySelectorAll('input')).map( + (input) => input.checked + ) + editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertParagraph' })) + const markdown = source.replace('First', text) + + expect(body.outerHTML).toBe(live) + expect(messages.filter((message) => message.type === 'change')).toEqual([ + { type: 'change', generation: 7, markdown } + ]) + editorDocument.send.setMarkdown(markdown, 8) + expect(editor.querySelector(selector)!.querySelector('p')!.innerHTML).toBe(inline) + expect(Array.from(editor.querySelectorAll('li')).map((item) => item.textContent)).toEqual( + contents + ) + expect( + Array.from(editor.querySelectorAll('input')).map((input) => input.checked) + ).toEqual(checks) + expect(editor.querySelector(':scope > p')?.textContent).toBe('Outside') + expect(editorDocument.send.currentMarkdown()).toBe(markdown) + } + ) +} + +it.each([ + ['

First

Second

', 'First
Second'], + ['

First


Second

', 'First

Second'], + ['

First

\n

Second

', 'First
Second'], + ['

First\n

Second

', 'First
Second'], + ['First
Second
Third', 'First
Second
Third'], + ['First
Second
', 'First
Second'], + ['First Second', '**First** *Second*'] +])('keeps existing breaks, empty lines and inline spaces without duplication: %j', (html, text) => { + const { editorDocument, editor } = surface('- First\n- Next') + const item = editor.querySelector('li')! + item.innerHTML = html + const live = item.outerHTML + const markdown = `- ${text}\n- Next` + + expect(editorDocument.send.currentMarkdown()).toBe(markdown) + expect(item.outerHTML).toBe(live) + editorDocument.send.setMarkdown(markdown, 8) + expect(editorDocument.send.currentMarkdown()).toBe(markdown) +}) + +it('retains nested lists and checkbox labels while serializing the owning item once', () => { + const { editorDocument, editor } = surface('- [x] First\n 8. Child\n 9. Next\n- [ ] Last') + const paragraph = editor.querySelector('li > div > p')! + paragraph.insertAdjacentHTML('afterend', '

Second

') + const live = editor.innerHTML + const markdown = '- [x] First
Second\n 8. Child\n 9. Next\n- [ ] Last' + + expect(editorDocument.send.currentMarkdown()).toBe(markdown) + expect(editor.innerHTML).toBe(live) + editorDocument.send.setMarkdown(markdown, 8) + expect(editor.querySelectorAll('li').length).toBe(4) + expect(editor.querySelectorAll('ol li').length).toBe(2) + expect(editor.querySelectorAll('input').length).toBe(2) + expect(editor.querySelector('input')!.checked).toBe(true) + expect(editorDocument.send.currentMarkdown()).toBe(markdown) +}) diff --git a/mobile/src/components/rich-markdown/markdown-list-code-blocks.test.ts b/mobile/src/components/rich-markdown/markdown-list-code-blocks.test.ts new file mode 100644 index 00000000000..001da174a63 --- /dev/null +++ b/mobile/src/components/rich-markdown/markdown-list-code-blocks.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from 'vitest' +import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document' +import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup' +import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support' +import type { RichMarkdownEditorDocument } from './document-host-seams' +import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract' + +let mounted: RichMarkdownEditorDocument | undefined + +afterEach(() => { + mounted?.stop() + mounted = undefined + document.body.innerHTML = '' +}) + +function open(markdown: string) { + document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP + const messages: MobileRichMarkdownEditorMessage[] = [] + const editorDocument = createRichMarkdownEditorDocument( + quietRichMarkdownEditorHost({ postToHost: (message) => messages.push(message) }) + ) + mounted = editorDocument + editorDocument.send.setMarkdown(markdown, 7) + return { editorDocument, messages, editor: document.getElementById('editor')! } +} + +const contexts = [ + { name: 'bullet', source: '- Intro\n- Next\n\nOutside', selector: 'li' }, + { name: 'ordered', source: '10. Intro\n11. Next\n\nOutside', selector: 'li' }, + { name: 'task', source: '- [x] Intro\n- [ ] Next\n\nOutside', selector: 'li > div' }, + { + name: 'nested ordered', + source: '- Parent\n 3. Intro\n 4. Next\n- Last\n\nOutside', + selector: 'ol li' + } +] + +describe('mobile list code blocks', () => { + it.each(contexts)( + 'preserves literal code and continuation paragraphs in $name items', + ({ source, selector }) => { + const { editorDocument, messages, editor } = open(source) + const body = editor.querySelector(selector)! + body.innerHTML = + '

Before bold
continued

After tail

' + const code = ' indented\n\ttab\n\t\n \n\n````\n& <' + body.querySelector('code')!.textContent = code + const items = editor.querySelectorAll('li').length + const before = body.innerHTML + messages.length = 0 + editor.dispatchEvent(new Event('input')) + const saved = editorDocument.send.currentMarkdown() + expect(body.innerHTML).toBe(before) + expect(messages).toEqual([{ type: 'change', markdown: saved, generation: 7 }]) + editorDocument.send.setMarkdown(saved, 8) + const reopened = editor.querySelector(selector)! + expect(reopened.querySelector('pre')?.textContent).toBe(code) + expect(reopened.querySelector('pre')?.getAttribute('data-language')).toBe('js') + expect(Array.from(reopened.querySelectorAll('p')).map((node) => node.textContent)).toEqual([ + 'Before boldcontinued', + 'After tail' + ]) + expect(reopened.querySelector('p br')).not.toBeNull() + expect(reopened.querySelector('strong')?.textContent).toBe('bold') + expect(reopened.querySelector('em')?.textContent).toBe('tail') + expect(editor.querySelectorAll('li').length).toBe(items) + expect(editor.querySelector(':scope > p')?.textContent).toBe('Outside') + expect(editorDocument.send.currentMarkdown()).toBe(saved) + } + ) + + it.each([ + { source: '1. Install:\n ```bash\n npm i\n ```\n2. Run', selector: 'li' }, + { + source: '- Parent\n 3. Intro\n\n ```js\n code\n ```\n\n After\n 4. Next', + selector: 'ol li' + } + ])('reads an indented fenced block inside its item: $source', ({ source, selector }) => { + const { editorDocument, editor } = open(source) + const body = editor.querySelector(selector)! + expect(body.querySelector('pre')).not.toBeNull() + expect(editor.querySelectorAll(':scope > ol, :scope > ul').length).toBe(1) + const saved = editorDocument.send.currentMarkdown() + editorDocument.send.setMarkdown(saved, 8) + expect(editor.querySelector(selector)?.querySelector('pre')?.textContent).toBe( + body.querySelector('pre')?.textContent + ) + expect(editorDocument.send.currentMarkdown()).toBe(saved) + }) + + it('keeps tables, quotes, rules and headings after a list fence as their own blocks', () => { + const source = [ + '1. Run:', + ' ```bash', + ' x', + ' ```', + ' | Flag | Desc |', + ' | --- | --- |', + ' | a | b |', + '2. Note', + ' ```', + ' y', + ' ```', + ' > careful', + '3. Rule', + ' ```', + ' z', + ' ```', + ' ***', + '- # Title', + ' ```', + ' w', + ' ```', + ' ### Sub' + ].join('\n') + const { editorDocument, editor } = open(source) + expect(editor.querySelectorAll('li pre').length).toBe(4) + expect(editor.querySelector('table td')?.textContent).toBe('a') + expect(editor.querySelector('hr')).not.toBeNull() + expect(editor.querySelector('ul li p')?.textContent).toBe('# Title') + + const saved = editorDocument.send.currentMarkdown() + expect(saved).toContain('| Flag | Desc |') + expect(saved).toContain('careful') + expect(saved).toContain('### Sub') + expect(saved).toContain('- # Title') + editorDocument.send.setMarkdown(saved, 8) + expect(editorDocument.send.currentMarkdown()).toBe(saved) + }) + + it('writes a code block inserted into an item as a fence its reader keeps in place', () => { + const { editorDocument, editor } = open('- Intro\n- Next') + editor + .querySelector('li')! + .insertAdjacentHTML('beforeend', '
a\nb


') + + const saved = editorDocument.send.currentMarkdown() + expect(saved).toBe('- Intro\n ```\n a\n b\n ```\n- Next') + editorDocument.send.setMarkdown(saved, 8) + expect(editor.querySelectorAll('li').length).toBe(2) + expect(editor.querySelector('li pre')?.textContent).toBe('a\nb') + }) + + it('keeps an empty first task line before code and retains checked state', () => { + const { editorDocument, editor } = open('- [x] Intro\n- [ ] Next') + const body = editor.querySelector('li > div')! + body.innerHTML = '


literal

After

' + const saved = editorDocument.send.currentMarkdown() + editorDocument.send.setMarkdown(saved, 8) + expect(editor.querySelector('li > label input')?.checked).toBe(true) + expect(editor.querySelector('li pre')?.textContent).toBe('literal') + expect(editorDocument.send.currentMarkdown()).toBe(saved) + }) +}) diff --git a/mobile/src/components/rich-markdown/markdown-list-linebreaks.test.ts b/mobile/src/components/rich-markdown/markdown-list-linebreaks.test.ts new file mode 100644 index 00000000000..ebcc69ac183 --- /dev/null +++ b/mobile/src/components/rich-markdown/markdown-list-linebreaks.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it } from 'vitest' +import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document' +import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup' +import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support' +import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract' + +const stopDocuments: (() => void)[] = [] +const lists = [ + ['bullet', '- First\n- Next\n\nOutside', 'li > p'], + ['task', '- [ ] First\n- [x] Next\n\nOutside', 'li > div > p'] +] as const + +function surface(source: string) { + document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP + const messages: MobileRichMarkdownEditorMessage[] = [] + const editorDocument = createRichMarkdownEditorDocument({ + ...quietRichMarkdownEditorHost(), + postToHost: (message) => messages.push(message) + }) + stopDocuments.push(editorDocument.stop) + editorDocument.send.setMarkdown(source, 7) + return { editorDocument, editor: document.getElementById('editor')!, messages } +} + +afterEach(() => { + stopDocuments.splice(0).forEach((stop) => stop()) + document.body.innerHTML = '' +}) + +for (const [kind, source, selector] of lists) { + it.each([ + ['First
Second', 'First
Second'], + ['First
Second
', '**First
Second**'], + ['First
Second', '`First`
`Second`'] + ])(`keeps ${kind} item soft break inside its list through host save: %s`, (html, text) => { + const { editorDocument, editor, messages } = surface(source) + editor.querySelector(selector)!.innerHTML = html + const live = editor.querySelector(selector)!.outerHTML + const items = Array.from(editor.querySelectorAll('li')).map((item) => item.textContent) + const checkboxes = Array.from(editor.querySelectorAll('input')).map( + (input) => input.checked + ) + editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' })) + const markdown = source.replace('First', text) + + expect(editor.querySelector(selector)!.outerHTML).toBe(live) + expect(messages.filter((message) => message.type === 'change')).toEqual([ + { type: 'change', generation: 7, markdown } + ]) + editorDocument.send.setMarkdown(markdown, 8) + expect(Array.from(editor.querySelectorAll('li')).map((item) => item.textContent)).toEqual(items) + expect( + Array.from(editor.querySelectorAll('input')).map((input) => input.checked) + ).toEqual(checkboxes) + expect(editor.querySelector(selector)?.innerHTML).toBe(html) + expect(editor.querySelector(':scope > p')?.textContent).toBe('Outside') + expect(editorDocument.send.currentMarkdown()).toBe(markdown) + }) +} + +it.each([ + ['- First\n- Next', 'li > p', '
'], + ['3. First\n4. Next', 'li > p', 'a
b'], + ['- [ ] First\n- [x] Next', 'li > div > p', '
'] +])('keeps typed list markup literal: %s', (source, selector, text) => { + const { editorDocument, editor } = surface(source) + editor.querySelector(selector)!.textContent = text + const markdown = editorDocument.send.currentMarkdown() + editorDocument.send.setMarkdown(markdown, 8) + + expect(editor.querySelector(selector)?.textContent).toBe(text) + expect(editor.querySelector(selector)?.children.length).toBe(0) + expect(editor.querySelectorAll('li').length).toBe(2) + expect(editorDocument.send.currentMarkdown()).toBe(markdown) +}) + +it('recognizes attribute-free list breaks while keeping code and paragraph markup literal', () => { + const { editor } = surface('- `
`\n- <br>\n- a
b\n-
\n\n
') + + expect(editor.querySelector('li code')?.textContent).toBe('
') + expect(editor.querySelectorAll('li')[1]?.textContent).toBe('
') + expect(editor.querySelectorAll('li')[2]?.querySelector('p')?.innerHTML).toBe('a
b') + expect(editor.querySelectorAll('li')[3]?.textContent).toBe('
') + expect(editor.querySelector('[onload]')).toBe(null) + expect(editor.lastElementChild?.textContent).toBe('
') + expect(editor.lastElementChild?.children.length).toBe(0) +}) + +it.each([ + ['- First\n- Next\n- Last', 'li > p'], + ['- [ ] First\n- [x] Next\n- [ ] Last', 'li > div > p'] +])('keeps a cleared item in its list instead of writing a bare marker: %s', (source, selector) => { + const { editorDocument, editor } = surface(source) + editor.querySelectorAll(selector)[1]!.replaceChildren(document.createElement('br')) + const markdown = editorDocument.send.currentMarkdown() + editorDocument.send.setMarkdown(markdown, 8) + + expect(editor.children.length).toBe(1) + expect(Array.from(editor.querySelectorAll('li')).map((item) => item.textContent)).toEqual([ + 'First', + '', + 'Last' + ]) + expect(editorDocument.send.currentMarkdown()).toBe(markdown) +}) + +it.each([ + ['First
', 'First'], + ['

First

', 'First'], + ['

First

', '**First**'], + ['First
  • Child

', 'First\n - Child'], + ['

First

', 'First

'], + ['


First

', '
First'], + ['

\n First\n

', 'First'] +])('drops only the caret placeholder break from an item: %j', (html, text) => { + const { editorDocument, editor } = surface('- First\n- Next\n\nOutside') + const item = editor.querySelector('li')! + item.innerHTML = html + const live = item.outerHTML + const markdown = `- ${text}\n- Next\n\nOutside` + + expect(editorDocument.send.currentMarkdown()).toBe(markdown) + expect(item.outerHTML).toBe(live) + editorDocument.send.setMarkdown(markdown, 8) + expect(editorDocument.send.currentMarkdown()).toBe(markdown) +}) diff --git a/mobile/src/components/rich-markdown/markdown-list-parse.ts b/mobile/src/components/rich-markdown/markdown-list-parse.ts index b3b9f4a744e..39267c2c5e1 100644 --- a/mobile/src/components/rich-markdown/markdown-list-parse.ts +++ b/mobile/src/components/rich-markdown/markdown-list-parse.ts @@ -1,3 +1,6 @@ +import { closesFence, openingFence } from './markdown-code-fence' +import { isTableSeparator } from './markdown-table-rows' + /** One list line, with its marker read and its nesting resolved against the lines around it. */ export type ParsedListItem = { indent: number @@ -6,6 +9,8 @@ export type ParsedListItem = { /** Whether the item's checkbox is ticked, or null when it is not a task at all. */ task: boolean | null text: string + /** The item's own lines, first line included, when they hold a fenced code block. */ + blockSource?: string children: ParsedListItem[] } @@ -41,6 +46,92 @@ export function listKind(item: ParsedListItem): 'task' | 'ol' | 'ul' { return item.ordered ? 'ol' : 'ul' } +function removeIndentation(line: string, width: number): string { + let index = 0 + let removed = 0 + while (index < line.length && removed < width) { + if (line[index] === ' ') { + removed += 1 + } else if (line[index] === '\t') { + removed += 4 + } else { + break + } + index += 1 + } + return ' '.repeat(Math.max(0, removed - width)) + line.slice(index) +} + +// Headings, quotes, rules and tables end the item's blocks, so they keep reading as they did before. +function endsItemBlocks(content: string, next: string): boolean { + const line = content.trimStart() + return ( + /^(#{1,6}\s|>|(-{3,}|\*{3,}|_{3,})\s*$)/.test(line) || + (line.includes('|') && isTableSeparator(next)) + ) +} + +// Indented continuation lines join the item only when they hold a fence; other items keep one-line reading. +function listItemBlocks( + lines: string[], + startIndex: number, + item: ParsedListItem +): { source: string; nextIndex: number } | null { + if ( + /^\[[ xX]\]$/.test(item.text) || + (item.task !== null && (!item.text.trim() || openingFence(item.text))) + ) { + return null + } + const marker = (lines[startIndex] ?? '').match(/^(\s*)(?:[-*+]|\d+[.)])([ \t]+)/) + if (!marker) { + return null + } + const width = indentationWidth(marker[0]) + const body = [item.text] + let fence = openingFence(item.text) + let hasCode = fence !== null + let index = startIndex + 1 + let end = index + while (index < lines.length) { + const line = lines[index] ?? '' + if (!line.trim()) { + body.push(fence ? removeIndentation(line, width) : '') + index += 1 + if (fence) { + end = index + } + continue + } + if (indentationWidth(line.match(/^[ \t]*/)?.[0] ?? '') < width) { + break + } + const content = removeIndentation(line, width) + if ( + !fence && + (parseListLine(line) || + endsItemBlocks(content, removeIndentation(lines[index + 1] ?? '', width))) + ) { + break + } + body.push(content) + if (fence) { + if (closesFence(content, fence.fence)) { + fence = null + } + } else { + fence = openingFence(content) + hasCode ||= fence !== null + } + index += 1 + end = index + } + if (!hasCode) { + return null + } + return { source: body.slice(0, end - startIndex).join('\n'), nextIndex: end } +} + // Parse contiguous list run with stack: indent can drop several levels and caller needs end index to resume block parsing. export function parseListTree( lines: string[], @@ -60,7 +151,11 @@ export function parseListTree( } stack[stack.length - 1]!.children.push(item) stack.push(item) - index += 1 + const blocks = listItemBlocks(lines, index, item) + if (blocks) { + item.blockSource = blocks.source + } + index = blocks?.nextIndex ?? index + 1 } return { items: root.children, nextIndex: index } } diff --git a/mobile/src/components/rich-markdown/markdown-list-render.ts b/mobile/src/components/rich-markdown/markdown-list-render.ts index 46af79cd67c..0f2d2dc0161 100644 --- a/mobile/src/components/rich-markdown/markdown-list-render.ts +++ b/mobile/src/components/rich-markdown/markdown-list-render.ts @@ -1,9 +1,31 @@ +import { openingFence } from './markdown-code-fence' import { renderInline } from './markdown-inline-render' import { listKind, type ParsedListItem } from './markdown-list-parse' import type { RichMarkdownEditorScope } from './document-scope' +// The leading lines stay one inline paragraph so block syntax on the marker line (`- # Title`) reads as text. +function itemContent(item: ParsedListItem, renderBlocks: (markdown: string) => string): string { + if (item.blockSource === undefined) { + return `

${renderInline(item.text, true)}

` + } + const lines = item.blockSource.split('\n') + let lead = 0 + while (lead < lines.length && lines[lead]?.trim() && !openingFence(lines[lead] ?? '')) { + lead += 1 + } + const paragraph = lines.slice(0, lead).join('\n') + return ( + (paragraph ? `

${renderInline(paragraph, true).replace(/\n/g, '
')}

` : '') + + renderBlocks(lines.slice(lead).join('\n')) + ) +} + // Render one list per same-kind run; disable task checkboxes under read-only surface or user toggles would go unrecorded. -export function renderListItems(scope: RichMarkdownEditorScope, items: ParsedListItem[]): string { +export function renderListItems( + scope: RichMarkdownEditorScope, + items: ParsedListItem[], + renderBlocks: (markdown: string) => string +): string { const html: string[] = [] let index = 0 while (index < items.length) { @@ -22,20 +44,22 @@ export function renderListItems(scope: RichMarkdownEditorScope, items: ParsedLis : '' const rendered = group .map((item) => { - const children = item.children.length ? renderListItems(scope, item.children) : '' + const children = item.children.length + ? renderListItems(scope, item.children, renderBlocks) + : '' if (kind === 'task') { const checked = item.task === true return ( `
  • ${renderInline(item.text)}

    ${children}
  • ` + `
    ${itemContent(item, renderBlocks)}${children}
    ` ) } const orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ` value="${item.orderedNumber}" data-list-number="${item.orderedNumber}"` : '' - return `

    ${renderInline(item.text)}

    ${children}` + return `${itemContent(item, renderBlocks)}${children}` }) .join('') html.push(`<${tag}${attrs}>${rendered}`) diff --git a/mobile/src/components/rich-markdown/markdown-table-linebreaks.test.ts b/mobile/src/components/rich-markdown/markdown-table-linebreaks.test.ts new file mode 100644 index 00000000000..58cdc2896d0 --- /dev/null +++ b/mobile/src/components/rich-markdown/markdown-table-linebreaks.test.ts @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it } from 'vitest' +import { createRichMarkdownEditorDocument } from './create-rich-markdown-editor-document' +import { RICH_MARKDOWN_EDITOR_MARKUP } from './document-markup' +import { quietRichMarkdownEditorHost } from './rich-markdown-editor-host.test-support' +import type { MobileRichMarkdownEditorMessage } from '../mobile-rich-markdown-editor-contract' + +const stopDocuments: (() => void)[] = [] +const original = '| Header | Other |\n| --- | --- |\n| First | Right |\n\nOutside' + +afterEach(() => { + stopDocuments.splice(0).forEach((stop) => stop()) + document.body.innerHTML = '' +}) + +function surface() { + document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP + const messages: MobileRichMarkdownEditorMessage[] = [] + const editorDocument = createRichMarkdownEditorDocument({ + ...quietRichMarkdownEditorHost(), + postToHost: (message) => messages.push(message) + }) + stopDocuments.push(editorDocument.stop) + editorDocument.send.setMarkdown(original, 7) + return { editorDocument, editor: document.getElementById('editor')!, messages } +} + +function cellTexts(editor: HTMLElement) { + return Array.from(editor.querySelectorAll('tr')).map((row) => + Array.from(row.children).map((node) => node.textContent) + ) +} + +it.each([ + ['First
    Second', 'First
    Second'], + ['First
    Second
    ', '**First
    Second**'], + ['First
    Second', '`First`
    `Second`'] +])('keeps a cell line break in its own table row through host save: %s', (html, cell) => { + const { editorDocument, editor, messages } = surface() + editor.querySelector('td')!.innerHTML = html + const live = editor.innerHTML + editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' })) + const markdown = original.replace('| First |', `| ${cell} |`) + + expect(editor.innerHTML).toBe(live) + expect(messages.filter((message) => message.type === 'change')).toEqual([ + { type: 'change', generation: 7, markdown } + ]) + editorDocument.send.setMarkdown(markdown, 8) + expect(cellTexts(editor)).toEqual([ + ['Header', 'Other'], + ['FirstSecond', 'Right'] + ]) + expect(editor.querySelector('td')?.innerHTML).toBe(html) + expect(editorDocument.send.currentMarkdown()).toBe(markdown) +}) + +it.each([ + ['
    ', ''], + ['First
    ', 'First'], + ['First
    ', '**First**'], + ['\n First\n', 'First'] +])('drops placeholder breaks and formatting newlines from a cell: %j', (html, cell) => { + const { editorDocument, editor } = surface() + editor.querySelector('td')!.innerHTML = html + + const markdown = editorDocument.send.currentMarkdown() + expect(markdown).toBe(original.replace('| First |', cell ? `| ${cell} |` : '| |')) + editorDocument.send.setMarkdown(markdown, 8) + expect(editor.querySelectorAll('tr').length).toBe(2) + expect(editor.querySelector('td')?.querySelector('br')).toBe(null) +}) + +it('keeps an authored run of trailing cell breaks byte-for-byte', () => { + const { editorDocument } = surface() + const markdown = original.replace('| First |', '| x

    |') + editorDocument.send.setMarkdown(markdown, 8) + + expect(editorDocument.send.currentMarkdown()).toBe(markdown) +}) + +it.each(['
    ', '
    ', 'a
    b', '
    '])( + 'keeps typed table markup literal: %s', + (text) => { + const { editorDocument, editor } = surface() + editor.querySelector('td')!.textContent = text + const markdown = editorDocument.send.currentMarkdown() + editorDocument.send.setMarkdown(markdown, 8) + + expect(editor.querySelector('td')?.textContent).toBe(text) + expect(editor.querySelector('td')?.children.length).toBe(0) + expect(editor.querySelectorAll('tr').length).toBe(2) + expect(editorDocument.send.currentMarkdown()).toBe(markdown) + } +) + +it('keeps a typed break literal when the browser splits it across text nodes', () => { + const { editorDocument, editor } = surface() + editor.querySelector('td')!.replaceChildren(document.createTextNode('') + const markdown = editorDocument.send.currentMarkdown() + editorDocument.send.setMarkdown(markdown, 8) + + expect(editor.querySelector('td')?.textContent).toBe('
    ') + expect(editor.querySelector('td')?.children.length).toBe(0) +}) + +it('recognizes attribute-free table breaks without interpreting code or paragraph HTML', () => { + const { editorDocument, editor } = surface() + editorDocument.send.setMarkdown( + '| `
    ` | <br> |\n| --- | --- |\n| a
    b |
    |\n\n
    ', + 8 + ) + + expect(editor.querySelector('th code')?.textContent).toBe('
    ') + expect(editor.querySelectorAll('th')[1]?.textContent).toBe('
    ') + expect(editor.querySelectorAll('td')[0]?.innerHTML).toBe('a
    b') + expect(editor.querySelectorAll('td')[1]?.textContent).toBe('
    ') + expect(editor.querySelector('[onload]')).toBe(null) + expect(editor.lastElementChild?.textContent).toBe('
    ') + expect(editor.lastElementChild?.children.length).toBe(0) +}) diff --git a/mobile/src/components/rich-markdown/markdown-to-html.ts b/mobile/src/components/rich-markdown/markdown-to-html.ts index 3d65100259c..bb87cc60312 100644 --- a/mobile/src/components/rich-markdown/markdown-to-html.ts +++ b/mobile/src/components/rich-markdown/markdown-to-html.ts @@ -29,7 +29,12 @@ function quoteMarkup(probe: () => Element, quote: string[], structured: string): } // Read fenced/table/list blocks by their own consumed line runs, not flat replacements; empty source still needs paragraph for placeholder. -export function markdownToHtml(scope: RichMarkdownEditorScope, markdown: string): string { +// Inside a list item paragraphs read `
    ` tokens, the item writer's only line-break spelling. +export function markdownToHtml( + scope: RichMarkdownEditorScope, + markdown: string, + insideListItem = false +): string { let lines = markdown.replace(/\r\n?/g, '\n').split('\n') let html: string[] = [] let index = 0 @@ -96,11 +101,11 @@ export function markdownToHtml(scope: RichMarkdownEditorScope, markdown: string) rows.push(splitTableRow(lines[index] ?? '')) index += 1 } - const head = headers.map((cell) => `${renderInline(cell)}`).join('') + const head = headers.map((cell) => `${renderInline(cell, true)}`).join('') const body = rows .map( (row) => - `${headers.map((_, cellIndex) => `${renderInline(row[cellIndex] ?? '')}`).join('')}` + `${headers.map((_, cellIndex) => `${renderInline(row[cellIndex] ?? '', true)}`).join('')}` ) .join('') html.push(`${head}${body}
    `) @@ -129,7 +134,9 @@ export function markdownToHtml(scope: RichMarkdownEditorScope, markdown: string) const list = parseListTree(lines, index) // Empty marker produces no list item and no index progress; fall through as text instead of continuing forever. if (list.nextIndex > index) { - html.push(renderListItems(scope, list.items)) + html.push( + renderListItems(scope, list.items, (content) => markdownToHtml(scope, content, true)) + ) index = list.nextIndex continue } @@ -153,7 +160,9 @@ export function markdownToHtml(scope: RichMarkdownEditorScope, markdown: string) paragraph.push(lines[index] ?? '') index += 1 } - html.push(`

    ${renderInline(paragraph.join('\n')).replace(/\n/g, '
    ')}

    `) + html.push( + `

    ${renderInline(paragraph.join('\n'), insideListItem).replace(/\n/g, '
    ')}

    ` + ) } return html.join('\n') || '


    ' }