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 <br /> (and sibling P/DIV
blocks inside an item as breaks), and reopen attribute-free <br> tokens
as breaks, so Shift+Enter or Enter no longer splits a row or ends a list.
Typed '<br>' 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 <br />
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 <br> 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>
This commit is contained in:
OrcaWin
2026-10-29 18:31:17 -07:00
committed by GitHub
co-authored by OrcaWin
parent 3fb6dc7301
commit ec62243ded
14 changed files with 1077 additions and 53 deletions
@@ -41,7 +41,37 @@ async function verifyCodeBlockSelections({
html: '<p>Before</p>\n<p>After</p>',
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: '<p>First</p>\n<p>After</p>',
literal: 'code',
caret: { selector: 'p', offset: 2 },
saved: 'Fi\n\n```\ncode\n```\n\nrst\n\nAfter'
},
{
markdown: 'First\n\nAfter',
html: '<p>First</p>\n<p>After</p>',
literal: 'code',
caret: { selector: 'p', offset: 'end' },
saved: 'First\n\n```\ncode\n```\n\nAfter'
},
{
markdown: '- First\n- Next',
html: '<ul><li><p>First</p></li><li><p>Next</p></li></ul>',
literal: 'code',
caret: { selector: 'li p', offset: 'end' },
saved: '- First\n ```\n code\n ```\n- Next'
},
{
markdown: '',
html: '<p class="is-empty"><br></p>',
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)
@@ -17,6 +17,48 @@ const TASK_LIST_HTML =
const CODE_BLOCK_HTML = '<pre data-language=""><code>code</code></pre><p><br></p>'
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 <br> it leaves saves as nothing.
exec(
scope,
'insertHTML',
caretFollowsContent(scope) ? `<br>${CODE_BLOCK_HTML}` : CODE_BLOCK_HTML
)
}
},
link: (scope) => insertUrl(scope, 'link', 'createLink'),
@@ -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 <br> 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 <br> 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(' | ')
@@ -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 <br> 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}`
}
@@ -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 = '<br />'
// Cells and items reopen `<br>` 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, '&lt;')
}
/** Where the trailing run of `<br />` 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<br />**` 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 <br> 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<Node>): string {
// Adjacent text nodes are joined before escaping so a `<br>` typed across a node split stays literal.
export function inlineNodesMarkdown(
nodes: Iterable<Node>,
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('')
}
@@ -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<Node>): string {
const markdown = inlineNodesMarkdown(nodes, 'item')
.replace(/\r\n?|\n/g, ' ')
.trim()
// A lone trailing <br> 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
})
@@ -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 `<br>` 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 ? '<br />' : escapeHtml(token)
} else if (image && isSafeUrl(image[2])) {
output += `<img src="${escapeAttr(image[2]!)}" alt="${escapeAttr(image[1] ?? '')}" />`
} else if (link && isSafeUrl(link[2])) {
output += `<a href="${escapeAttr(link[2]!)}">${renderInline(link[1]!)}</a>`
output += `<a href="${escapeAttr(link[2]!)}">${renderInline(link[1]!, allowLineBreaks)}</a>`
} else if (/^https?:\/\//i.test(token)) {
output += `<a href="${escapeAttr(token)}">${escapeHtml(token)}</a>`
} else if (token.startsWith('`')) {
@@ -37,11 +40,11 @@ export function renderInline(text: string): string {
pattern.lastIndex = spanEnd
}
} else if (token.startsWith('~~')) {
output += `<s>${renderInline(token.slice(2, -2))}</s>`
output += `<s>${renderInline(token.slice(2, -2), allowLineBreaks)}</s>`
} else if (token.startsWith('**') || token.startsWith('__')) {
output += `<strong>${renderInline(token.slice(2, -2))}</strong>`
output += `<strong>${renderInline(token.slice(2, -2), allowLineBreaks)}</strong>`
} else {
output += `<em>${renderInline(token.slice(1, -1))}</em>`
output += `<em>${renderInline(token.slice(1, -1), allowLineBreaks)}</em>`
}
lastIndex = pattern.lastIndex
match = pattern.exec(text)
@@ -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([
['<p>First</p><p>Second</p>', 'First<br />Second', 'First<br>Second'],
[
'<p><code>First</code></p><p><code>Second</code></p>',
'`First`<br />`Second`',
'<code>First</code><br><code>Second</code>'
]
])(
`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<HTMLInputElement>('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<HTMLInputElement>('input')).map((input) => input.checked)
).toEqual(checks)
expect(editor.querySelector(':scope > p')?.textContent).toBe('Outside')
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
}
)
}
it.each([
['<p>First<br></p><p>Second</p>', 'First<br />Second'],
['<p>First</p><p><br></p><p>Second</p>', 'First<br /><br />Second'],
['<p>First</p>\n <p>Second</p>', 'First<br />Second'],
['<p>First\n</p><p>Second</p>', 'First<br />Second'],
['First<div>Second</div>Third', 'First<br />Second<br />Third'],
['First<br><div>Second</div>', 'First<br />Second'],
['<strong>First</strong> <em>Second</em>', '**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', '<p>Second</p>')
const live = editor.innerHTML
const markdown = '- [x] First<br />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<HTMLInputElement>('input')!.checked).toBe(true)
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
})
@@ -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 =
'<p>Before <strong>bold</strong><br>continued</p><pre data-language="js"><code></code></pre><p>After <em>tail</em></p>'
const code = ' indented\n\ttab\n\t\n \n\n````\n<tag>&amp; &#60;'
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', '<pre data-language=""><code>a\nb</code></pre><p><br></p>')
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 = '<p><br></p><pre data-language=""><code>literal</code></pre><p>After</p>'
const saved = editorDocument.send.currentMarkdown()
editorDocument.send.setMarkdown(saved, 8)
expect(editor.querySelector<HTMLInputElement>('li > label input')?.checked).toBe(true)
expect(editor.querySelector('li pre')?.textContent).toBe('literal')
expect(editorDocument.send.currentMarkdown()).toBe(saved)
})
})
@@ -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<br>Second', 'First<br />Second'],
['<strong>First<br>Second</strong>', '**First<br />Second**'],
['<code>First</code><br><code>Second</code>', '`First`<br />`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<HTMLInputElement>('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<HTMLInputElement>('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', '<br>'],
['3. First\n4. Next', 'li > p', 'a<BR/>b'],
['- [ ] First\n- [x] Next', 'li > div > p', '<br onload="run()">']
])('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('- `<br>`\n- &#60;br&#62;\n- a<BR/>b\n- <br onload="run()">\n\n<br />')
expect(editor.querySelector('li code')?.textContent).toBe('<br>')
expect(editor.querySelectorAll('li')[1]?.textContent).toBe('<br>')
expect(editor.querySelectorAll('li')[2]?.querySelector('p')?.innerHTML).toBe('a<br>b')
expect(editor.querySelectorAll('li')[3]?.textContent).toBe('<br onload="run()">')
expect(editor.querySelector('[onload]')).toBe(null)
expect(editor.lastElementChild?.textContent).toBe('<br />')
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([
['<span>First</span><br>', 'First'],
['<p>First<br></p>', 'First'],
['<p><strong>First<br></strong></p>', '**First**'],
['<span>First</span><br><ul><li><p>Child</p></li></ul>', 'First\n - Child'],
['<p>First<br><br></p>', 'First<br /><br />'],
['<p><br>First</p>', '<br />First'],
['<p>\n First\n</p>', '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)
})
@@ -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 }
}
@@ -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 `<p>${renderInline(item.text, true)}</p>`
}
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 ? `<p>${renderInline(paragraph, true).replace(/\n/g, '<br />')}</p>` : '') +
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 (
`<li data-checked="${String(checked)}"><label contenteditable="false">` +
`<input type="checkbox" ${checked ? 'checked ' : ''}${scope.editable ? '' : 'disabled '}/>` +
`</label><div><p>${renderInline(item.text)}</p>${children}</div></li>`
`</label><div>${itemContent(item, renderBlocks)}${children}</div></li>`
)
}
const orderedAttrs =
kind === 'ol' && item.orderedNumber !== null
? ` value="${item.orderedNumber}" data-list-number="${item.orderedNumber}"`
: ''
return `<li${orderedAttrs}><p>${renderInline(item.text)}</p>${children}</li>`
return `<li${orderedAttrs}>${itemContent(item, renderBlocks)}${children}</li>`
})
.join('')
html.push(`<${tag}${attrs}>${rendered}</${tag}>`)
@@ -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<br>Second', 'First<br />Second'],
['<strong>First<br>Second</strong>', '**First<br />Second**'],
['<code>First</code><br><code>Second</code>', '`First`<br />`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([
['<br>', ''],
['First<br>', 'First'],
['<strong>First<br></strong>', '**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<br /><br /> |')
editorDocument.send.setMarkdown(markdown, 8)
expect(editorDocument.send.currentMarkdown()).toBe(markdown)
})
it.each(['<br>', '<BR />', 'a<br/>b', '<br onload="run()">'])(
'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('<b'), 'r>')
const markdown = editorDocument.send.currentMarkdown()
editorDocument.send.setMarkdown(markdown, 8)
expect(editor.querySelector('td')?.textContent).toBe('<br>')
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>` | &#60;br&#62; |\n| --- | --- |\n| a<BR/>b | <br onload="run()"> |\n\n<br />',
8
)
expect(editor.querySelector('th code')?.textContent).toBe('<br>')
expect(editor.querySelectorAll('th')[1]?.textContent).toBe('<br>')
expect(editor.querySelectorAll('td')[0]?.innerHTML).toBe('a<br>b')
expect(editor.querySelectorAll('td')[1]?.textContent).toBe('<br onload="run()">')
expect(editor.querySelector('[onload]')).toBe(null)
expect(editor.lastElementChild?.textContent).toBe('<br />')
expect(editor.lastElementChild?.children.length).toBe(0)
})
@@ -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 `<br>` 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) => `<th>${renderInline(cell)}</th>`).join('')
const head = headers.map((cell) => `<th>${renderInline(cell, true)}</th>`).join('')
const body = rows
.map(
(row) =>
`<tr>${headers.map((_, cellIndex) => `<td>${renderInline(row[cellIndex] ?? '')}</td>`).join('')}</tr>`
`<tr>${headers.map((_, cellIndex) => `<td>${renderInline(row[cellIndex] ?? '', true)}</td>`).join('')}</tr>`
)
.join('')
html.push(`<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`)
@@ -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(`<p>${renderInline(paragraph.join('\n')).replace(/\n/g, '<br />')}</p>`)
html.push(
`<p>${renderInline(paragraph.join('\n'), insideListItem).replace(/\n/g, '<br />')}</p>`
)
}
return html.join('\n') || '<p class="is-empty"><br /></p>'
}