mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
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:
@@ -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, '<')
|
||||
}
|
||||
|
||||
/** 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 ` ?? ''})`
|
||||
@@ -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>& <'
|
||||
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- <br>\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>` | <br> |\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>'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user