('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 ', '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}${tag}>`)
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**'],
+ ['FirstSecond', '`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(``)
@@ -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') || '
'
}