diff --git a/tests/e2e/export-soft-break-layout.spec.ts b/tests/e2e/export-soft-break-layout.spec.ts new file mode 100644 index 0000000..208267b --- /dev/null +++ b/tests/e2e/export-soft-break-layout.spec.ts @@ -0,0 +1,102 @@ +import { readFileSync } from 'node:fs' +import { expect, test, type Page } from '@playwright/test' + +// Browser layout coverage for the #3676 export soft-break fix (PR review): +// the export stylesheet renders tight list items `white-space: pre-wrap`, and +// `white-space` inherits — so marked's formatting-only newlines around nested +// block children would each render as a visible empty line box. The export +// pipeline strips that serializer whitespace (markdownToHtml, +// `_stripListFormattingWhitespace`; shape pinned by +// third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts). This +// spec closes the loop in a real Chromium: the stylesheet plus the pipeline's +// output shape lay out without phantom blank rows, while authored soft +// breaks still render as real line breaks. + +// Playwright runs from the repo root; the config's testDir keeps that cwd. +const exportCss = readFileSync('third_party/muya/src/assets/styles/exportStyle.css', 'utf8') + +async function measureListItemHeight(page: Page, listInner: string, extraCss = '') { + await page.setContent( + `` + + `
`, + ) + return page.evaluate( + () => document.querySelector('.markdown-body > ul > li')!.getBoundingClientRect().height, + ) +} + +test('an authored soft break in a tight item renders as a real second line', async ({ page }) => { + const single = await measureListItemHeight(page, '
  • line A
  • ') + const soft = await measureListItemHeight(page, '
  • line A\nline B
  • ') + + expect(soft).toBeGreaterThan(single * 1.8) + expect(soft).toBeLessThan(single * 2.6) +}) + +test('a soft break between inline-wrapped lines renders as a real second line', async ({ page }) => { + // The authored newline lives in a whitespace-only text node BETWEEN two + // inline elements — the strip must not eat it (review round 2). + const single = await measureListItemHeight(page, '
  • left
  • ') + const soft = await measureListItemHeight( + page, + '
  • left\nright
  • ', + ) + + expect(soft).toBeGreaterThan(single * 1.8) + expect(soft).toBeLessThan(single * 2.6) +}) + +test('the cleaned nested-list shape adds no phantom blank rows', async ({ page }) => { + // Exactly what the export pipeline emits for `- line A\n - child`. + const cleaned = '
  • line A
  • ' + const withPreWrap = await measureListItemHeight(page, cleaned) + const control = await measureListItemHeight( + page, + cleaned, + '.markdown-body li { white-space: normal !important; }', + ) + + expect(withPreWrap).toBe(control) +}) + +test("marked's uncleaned serializer newlines DO create blank rows (premise)", async ({ page }) => { + // marked's raw serialization for the same markdown — the shape the + // pipeline strip exists to prevent. If this stops failing tall, the strip + // (and this suite) can be reconsidered. + const raw = '
  • line A\n\n
  • ' + const withPreWrap = await measureListItemHeight(page, raw) + const control = await measureListItemHeight( + page, + raw, + '.markdown-body li { white-space: normal !important; }', + ) + + expect(withPreWrap).toBeGreaterThan(control) +}) + +test('a heading as the sole item child adds no phantom blank rows', async ({ page }) => { + // `- # heading` emits `
  • \n
  • `; H1 must count as a block + // child in the cleanup or the trailing newline renders as a phantom row + // (review round 3 measured 85px instead of 43px). + const cleaned = '
  • heading

  • ' + const withPreWrap = await measureListItemHeight(page, cleaned) + const control = await measureListItemHeight( + page, + cleaned, + '.markdown-body li { white-space: normal !important; }', + ) + + expect(withPreWrap).toBe(control) +}) + +test('the cleaned nested-blockquote shape adds no phantom blank rows', async ({ page }) => { + const cleaned = '
  • line A

    quoted

  • ' + const withPreWrap = await measureListItemHeight(page, cleaned) + const control = await measureListItemHeight( + page, + cleaned, + '.markdown-body li { white-space: normal !important; }', + ) + + expect(withPreWrap).toBe(control) +}) diff --git a/third_party/muya/src/assets/styles/exportStyle.css b/third_party/muya/src/assets/styles/exportStyle.css index e362724..333faba 100644 --- a/third_party/muya/src/assets/styles/exportStyle.css +++ b/third_party/muya/src/assets/styles/exportStyle.css @@ -89,6 +89,22 @@ white-space: pre-wrap; } +/* Render soft line breaks (Shift+Enter → a bare `\n` inside a block) the way + the editor does (`.mu-content` is pre-wrap) instead of emitting a + non-standard `
    `, so the exported HTML stays CommonMark-conformant (the + `\n` is a plain line ending) yet still shows the break (marktext#3676). + + `li:not(:has(> p))` targets only tight list items, whose soft break is a + bare `\n` directly inside the `
  • `. Loose items wrap their content in + `

    ` (handled by the `p` rule); giving them `li` pre-wrap would expose + marked's pretty-printing newline between `

    ` and `
  • ` as a stray blank + line, so they are deliberately excluded. Specificity stays level with the + earlier `.toc-container ul li` rule (no-descending-specificity). */ +.markdown-body p, +.markdown-body li:not(:has(> p)) { + white-space: pre-wrap; +} + .markdown-body table { display: table; } diff --git a/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts b/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts new file mode 100644 index 0000000..110b5cc --- /dev/null +++ b/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest'; +import { MarkdownToHtml } from '../markdownToHtml'; +import { getHighlightHtml } from '../../utils/marked'; + +// marktext#3676 — a soft line break (Shift+Enter, serialized as a bare `\n` +// inside a block) shows as a line break in the editor (`.mu-content` is +// pre-wrap) but was lost on export because marked renders a soft break as a +// space. Rather than emit a non-standard `
    ` (which CommonMark reserves for +// hard breaks), the export keeps the conformant `\n` and renders it with +// `white-space: pre-wrap` on `.markdown-body p` and `li:not(:has(> p))` +// (tight items only). +// +// Because `white-space` inherits, marked's pretty-printing newlines around +// nested block children (`line A\n