// fixtures/paste.test.mjs — blank lines survive a paste, and only blank // lines are touched. // // Runs under Node's own test runner with no DOM. blankLinesFromBreaks // takes a Slice and returns a Slice, both plain data, so the slices here // are built by hand rather than parsed out of HTML — which also makes // the cases readable as what they are: the shapes prosemirror-view's // clipboard parser actually hands over, listed in paste.mjs's header. // // The case that matters most is the last one. A fix that turned every // hard_break into a paragraph break would quietly destroy Shift+Enter, // which editor-entry.js binds and core/src/fragment.rs renders, and it // would do it only on paste — the one path with no test before this // file. import { test } from 'node:test' import assert from 'node:assert/strict' import { Schema, Fragment, Slice } from 'prosemirror-model' import { schema as basic } from 'prosemirror-schema-basic' import { alignedParagraph } from './align.mjs' import { blankLinesFromBreaks, hardBreakIgnoringInterchange } from './paste.mjs' // The same schema editor-entry.js builds, minus the marks this file has // no use for. alignedParagraph is kept because it is the paragraph the // editor really uses, attrs and all, and paragraph.create(node.attrs) // in paste.mjs has to keep them. const schema = new Schema({ nodes: basic.spec.nodes.update('paragraph', alignedParagraph(basic.spec.nodes.get('paragraph'))), marks: basic.spec.marks, }) const { paragraph, hard_break: hardBreak, blockquote, heading } = schema.nodes const transform = blankLinesFromBreaks(schema) const p = (...content) => paragraph.create(null, content) const br = () => hardBreak.create() const t = s => schema.text(s) const slice = (...nodes) => new Slice(Fragment.fromArray(nodes), 0, 0) // Names the shape of a fragment the way the cases talk about it: // paragraph("One") / paragraph(hard_break) / paragraph(). function outline(fragment) { const parts = [] fragment.forEach(node => { if (node.isText) { parts.push(JSON.stringify(node.text)) return } // A leaf has no content to print, and hard_break() would read as if // it might have had some. parts.push(node.isLeaf ? node.type.name : `${node.type.name}(${outline(node.content)})`) }) return parts.join(', ') } test('a Google Docs blank line stops being two lines tall', () => { // What the clipboard parser makes of `

One


Two

`: // the bare
becomes a paragraph of its own holding a hard_break, // and the editor's addTextblockHacks then draws that two lines high. const out = transform(slice(p(t('One')), p(br()), p(t('Two')))) assert.equal(outline(out.content), 'paragraph("One"), paragraph(), paragraph("Two")') }) test('two blank lines stay two blank lines', () => { // normalizeSiblings merges consecutive top-level breaks into one // paragraph, so the count of breaks is the count of blank lines the // author left. Collapsing the run would lose one. const out = transform(slice(p(t('One')), p(br(), br()), p(t('Two')))) assert.equal(outline(out.content), 'paragraph("One"), paragraph(), paragraph(), paragraph("Two")') }) test('a trailing break-only paragraph becomes a trailing blank line', () => { // Google puts a
before the closing of its wrapper, which is // the extra line that appeared under a paste with nothing after it. const out = transform(slice(p(t('Four')), p(br()))) assert.equal(outline(out.content), 'paragraph("Four"), paragraph()') }) test('a Shift+Enter break inside a line of text is left alone', () => { // The whole risk of this module. A paragraph with any text in it is // the author's own soft break and must survive verbatim. const before = slice(p(t('Paul: the list,'), br(), t('by Thursday.'))) const out = transform(before) assert.equal(outline(out.content), 'paragraph("Paul: the list,", hard_break, "by Thursday.")') assert.equal(out, before, 'an untouched slice should come back by identity, not rebuilt') }) test('a paragraph ending in a break is left alone', () => { const before = slice(p(t('Three'), br())) assert.equal(transform(before), before) }) test('an ordinary paste is returned by identity', () => { const before = slice(p(t('One')), p(t('Two')), p()) assert.equal(transform(before), before) }) test('paragraph attributes are carried onto the blank lines', () => { // align lives on the paragraph (see align.mjs), and paste.mjs builds // the replacements with paragraph.create(node.attrs) so a centred // blank line stays centred rather than silently becoming left. const centred = paragraph.create({ align: 'center' }, [br()]) const out = transform(slice(centred)) assert.equal(out.content.firstChild.attrs.align, 'center') }) test('a blank line inside a pasted blockquote is fixed too', () => { const out = transform(slice(blockquote.create(null, [p(t('One')), p(br()), p(t('Two'))]))) assert.equal(outline(out.content), 'blockquote(paragraph("One"), paragraph(), paragraph("Two"))') }) test('a break-only heading is not a paragraph and is left alone', () => { // Only paragraphs are rewritten. A heading holding a break is not a // shape Google produces, and turning it into paragraphs would change // the document's outline rather than its spacing. const before = slice(heading.create({ level: 2 }, [br()])) assert.equal(transform(before), before) }) // The rest of the file needs no DOM. These two do — an ignore rule is // matched by the parser against a real element — so they use the one // stand-in the parser reads for a `br.Apple-interchange-newline` tag // rule: the node name and the class list. test('WebKit\'s copy marker is ignored', () => { const patched = hardBreakIgnoringInterchange(basic.spec.nodes.get('hard_break')) const [first] = patched.parseDOM assert.equal(first.tag, 'br.Apple-interchange-newline') assert.equal(first.ignore, true) }) test('an ordinary br still parses as a hard_break', () => { // The ignore rule goes FIRST and the original rules follow, so a
// the author typed is still a break. Losing that would silently // delete every Shift+Enter on the way in. const patched = hardBreakIgnoringInterchange(basic.spec.nodes.get('hard_break')) assert.ok( patched.parseDOM.some(rule => rule.tag === 'br' && !rule.ignore), 'the plain br rule must survive the patch', ) // Everything else about the node — toDOM, selectable, the group it is // in — must come through untouched, or the wire format changes. const base = basic.spec.nodes.get('hard_break') for (const key of Object.keys(base)) { if (key === 'parseDOM') continue assert.equal(patched[key], base[key], `${key} should be carried over unchanged`) } }) test('the slice keeps its open depths', () => { // openStart/openEnd say how the paste joins the text around the // cursor. Rebuilding the content must not disturb them. const before = new Slice(Fragment.fromArray([p(t('One')), p(br()), p(t('Two'))]), 1, 1) const out = transform(before) assert.equal(out.openStart, 1) assert.equal(out.openEnd, 1) })