oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

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.
15import { test } from 'node:test'
16import assert from 'node:assert/strict'
17import { Schema, Fragment, Slice } from 'prosemirror-model'
18import { schema as basic } from 'prosemirror-schema-basic'
19import { alignedParagraph } from './align.mjs'
20import { 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.
26const schema = new Schema({
27 nodes: basic.spec.nodes.update('paragraph', alignedParagraph(basic.spec.nodes.get('paragraph'))),
28 marks: basic.spec.marks,
29})
30const { paragraph, hard_break: hardBreak, blockquote, heading } = schema.nodes
31const transform = blankLinesFromBreaks(schema)
32
33const p = (...content) => paragraph.create(null, content)
34const br = () => hardBreak.create()
35const t = s => schema.text(s)
36const 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().
40function 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
54test('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
62test('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
70test('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
77test('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
86test('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
91test('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
96test('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
105test('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
110test('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.
122test('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
129test('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
147test('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