| 1 | // fixtures/paste.test.mjs — blank lines survive a paste, and only blank |
| 2 | // lines are touched. |
| 3 | // |
| 4 | // Runs under Node's own test runner with no DOM. blankLinesFromBreaks |
| 5 | // takes a Slice and returns a Slice, both plain data, so the slices here |
| 6 | // are built by hand rather than parsed out of HTML — which also makes |
| 7 | // the cases readable as what they are: the shapes prosemirror-view's |
| 8 | // clipboard parser actually hands over, listed in paste.mjs's header. |
| 9 | // |
| 10 | // The case that matters most is the last one. A fix that turned every |
| 11 | // hard_break into a paragraph break would quietly destroy Shift+Enter, |
| 12 | // which editor-entry.js binds and core/src/fragment.rs renders, and it |
| 13 | // would do it only on paste — the one path with no test before this |
| 14 | // file. |
| 15 | import { test } from 'node:test' |
| 16 | import assert from 'node:assert/strict' |
| 17 | import { Schema, Fragment, Slice } from 'prosemirror-model' |
| 18 | import { schema as basic } from 'prosemirror-schema-basic' |
| 19 | import { alignedParagraph } from './align.mjs' |
| 20 | import { blankLinesFromBreaks, hardBreakIgnoringInterchange } from './paste.mjs' |
| 21 | |
| 22 | // The same schema editor-entry.js builds, minus the marks this file has |
| 23 | // no use for. alignedParagraph is kept because it is the paragraph the |
| 24 | // editor really uses, attrs and all, and paragraph.create(node.attrs) |
| 25 | // in paste.mjs has to keep them. |
| 26 | const schema = new Schema({ |
| 27 | nodes: basic.spec.nodes.update('paragraph', alignedParagraph(basic.spec.nodes.get('paragraph'))), |
| 28 | marks: basic.spec.marks, |
| 29 | }) |
| 30 | const { paragraph, hard_break: hardBreak, blockquote, heading } = schema.nodes |
| 31 | const transform = blankLinesFromBreaks(schema) |
| 32 | |
| 33 | const p = (...content) => paragraph.create(null, content) |
| 34 | const br = () => hardBreak.create() |
| 35 | const t = s => schema.text(s) |
| 36 | const slice = (...nodes) => new Slice(Fragment.fromArray(nodes), 0, 0) |
| 37 | |
| 38 | // Names the shape of a fragment the way the cases talk about it: |
| 39 | // paragraph("One") / paragraph(hard_break) / paragraph(). |
| 40 | function outline(fragment) { |
| 41 | const parts = [] |
| 42 | fragment.forEach(node => { |
| 43 | if (node.isText) { |
| 44 | parts.push(JSON.stringify(node.text)) |
| 45 | return |
| 46 | } |
| 47 | // A leaf has no content to print, and hard_break() would read as if |
| 48 | // it might have had some. |
| 49 | parts.push(node.isLeaf ? node.type.name : `${node.type.name}(${outline(node.content)})`) |
| 50 | }) |
| 51 | return parts.join(', ') |
| 52 | } |
| 53 | |
| 54 | test('a Google Docs blank line stops being two lines tall', () => { |
| 55 | // What the clipboard parser makes of `<p>One</p><br /><p>Two</p>`: |
| 56 | // the bare <br> becomes a paragraph of its own holding a hard_break, |
| 57 | // and the editor's addTextblockHacks then draws that two lines high. |
| 58 | const out = transform(slice(p(t('One')), p(br()), p(t('Two')))) |
| 59 | assert.equal(outline(out.content), 'paragraph("One"), paragraph(), paragraph("Two")') |
| 60 | }) |
| 61 | |
| 62 | test('two blank lines stay two blank lines', () => { |
| 63 | // normalizeSiblings merges consecutive top-level breaks into one |
| 64 | // paragraph, so the count of breaks is the count of blank lines the |
| 65 | // author left. Collapsing the run would lose one. |
| 66 | const out = transform(slice(p(t('One')), p(br(), br()), p(t('Two')))) |
| 67 | assert.equal(outline(out.content), 'paragraph("One"), paragraph(), paragraph(), paragraph("Two")') |
| 68 | }) |
| 69 | |
| 70 | test('a trailing break-only paragraph becomes a trailing blank line', () => { |
| 71 | // Google puts a <br> before the closing </b> of its wrapper, which is |
| 72 | // the extra line that appeared under a paste with nothing after it. |
| 73 | const out = transform(slice(p(t('Four')), p(br()))) |
| 74 | assert.equal(outline(out.content), 'paragraph("Four"), paragraph()') |
| 75 | }) |
| 76 | |
| 77 | test('a Shift+Enter break inside a line of text is left alone', () => { |
| 78 | // The whole risk of this module. A paragraph with any text in it is |
| 79 | // the author's own soft break and must survive verbatim. |
| 80 | const before = slice(p(t('Paul: the list,'), br(), t('by Thursday.'))) |
| 81 | const out = transform(before) |
| 82 | assert.equal(outline(out.content), 'paragraph("Paul: the list,", hard_break, "by Thursday.")') |
| 83 | assert.equal(out, before, 'an untouched slice should come back by identity, not rebuilt') |
| 84 | }) |
| 85 | |
| 86 | test('a paragraph ending in a break is left alone', () => { |
| 87 | const before = slice(p(t('Three'), br())) |
| 88 | assert.equal(transform(before), before) |
| 89 | }) |
| 90 | |
| 91 | test('an ordinary paste is returned by identity', () => { |
| 92 | const before = slice(p(t('One')), p(t('Two')), p()) |
| 93 | assert.equal(transform(before), before) |
| 94 | }) |
| 95 | |
| 96 | test('paragraph attributes are carried onto the blank lines', () => { |
| 97 | // align lives on the paragraph (see align.mjs), and paste.mjs builds |
| 98 | // the replacements with paragraph.create(node.attrs) so a centred |
| 99 | // blank line stays centred rather than silently becoming left. |
| 100 | const centred = paragraph.create({ align: 'center' }, [br()]) |
| 101 | const out = transform(slice(centred)) |
| 102 | assert.equal(out.content.firstChild.attrs.align, 'center') |
| 103 | }) |
| 104 | |
| 105 | test('a blank line inside a pasted blockquote is fixed too', () => { |
| 106 | const out = transform(slice(blockquote.create(null, [p(t('One')), p(br()), p(t('Two'))]))) |
| 107 | assert.equal(outline(out.content), 'blockquote(paragraph("One"), paragraph(), paragraph("Two"))') |
| 108 | }) |
| 109 | |
| 110 | test('a break-only heading is not a paragraph and is left alone', () => { |
| 111 | // Only paragraphs are rewritten. A heading holding a break is not a |
| 112 | // shape Google produces, and turning it into paragraphs would change |
| 113 | // the document's outline rather than its spacing. |
| 114 | const before = slice(heading.create({ level: 2 }, [br()])) |
| 115 | assert.equal(transform(before), before) |
| 116 | }) |
| 117 | |
| 118 | // The rest of the file needs no DOM. These two do — an ignore rule is |
| 119 | // matched by the parser against a real element — so they use the one |
| 120 | // stand-in the parser reads for a `br.Apple-interchange-newline` tag |
| 121 | // rule: the node name and the class list. |
| 122 | test('WebKit\'s copy marker is ignored', () => { |
| 123 | const patched = hardBreakIgnoringInterchange(basic.spec.nodes.get('hard_break')) |
| 124 | const [first] = patched.parseDOM |
| 125 | assert.equal(first.tag, 'br.Apple-interchange-newline') |
| 126 | assert.equal(first.ignore, true) |
| 127 | }) |
| 128 | |
| 129 | test('an ordinary br still parses as a hard_break', () => { |
| 130 | // The ignore rule goes FIRST and the original rules follow, so a <br> |
| 131 | // the author typed is still a break. Losing that would silently |
| 132 | // delete every Shift+Enter on the way in. |
| 133 | const patched = hardBreakIgnoringInterchange(basic.spec.nodes.get('hard_break')) |
| 134 | assert.ok( |
| 135 | patched.parseDOM.some(rule => rule.tag === 'br' && !rule.ignore), |
| 136 | 'the plain br rule must survive the patch', |
| 137 | ) |
| 138 | // Everything else about the node — toDOM, selectable, the group it is |
| 139 | // in — must come through untouched, or the wire format changes. |
| 140 | const base = basic.spec.nodes.get('hard_break') |
| 141 | for (const key of Object.keys(base)) { |
| 142 | if (key === 'parseDOM') continue |
| 143 | assert.equal(patched[key], base[key], `${key} should be carried over unchanged`) |
| 144 | } |
| 145 | }) |
| 146 | |
| 147 | test('the slice keeps its open depths', () => { |
| 148 | // openStart/openEnd say how the paste joins the text around the |
| 149 | // cursor. Rebuilding the content must not disturb them. |
| 150 | const before = new Slice(Fragment.fromArray([p(t('One')), p(br()), p(t('Two'))]), 1, 1) |
| 151 | const out = transform(before) |
| 152 | assert.equal(out.openStart, 1) |
| 153 | assert.equal(out.openEnd, 1) |
| 154 | }) |
| 155 | |