// 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)
})