| 1 | // fixtures/paste.mjs — what a blank line means when it arrives from |
| 2 | // somewhere else. |
| 3 | // |
| 4 | // Google Docs spells an empty paragraph as a bare <br> SIBLING of the |
| 5 | // <p>s, inside the <b style="font-weight:normal" id="docs-internal-guid-…"> |
| 6 | // wrapper it puts around a copied selection — not as an empty <p>, and |
| 7 | // not as a <br> inside one. Three things then happen in order, all of |
| 8 | // them prosemirror-view and prosemirror-model behaving as designed: |
| 9 | // |
| 10 | // 1. The clipboard parser ignores a <br> only when it is the last |
| 11 | // child of a non-inline parent. Google's has a next sibling, so it |
| 12 | // is kept, and schema-basic's hard_break rule (`{tag: "br"}`) |
| 13 | // claims it. |
| 14 | // 2. At the open top of a parsed slice there is no block for an |
| 15 | // inline node to sit in, so the hard_break lands as a top-level |
| 16 | // sibling of the paragraphs. normalizeSiblings then wraps it in |
| 17 | // the context's wrapper — a paragraph — merging consecutive |
| 18 | // breaks into one. The blank line arrives as p(hard_break). |
| 19 | // 3. In the editor, addTextblockHacks appends |
| 20 | // <br class="ProseMirror-trailingBreak"> to any textblock whose |
| 21 | // last child is not text, because contenteditable needs it to put |
| 22 | // a cursor there. p(hard_break) therefore renders <p><br><br></p>: |
| 23 | // TWO lines where the author left one. |
| 24 | // |
| 25 | // Only the editor is wrong. The no-JavaScript read view gets <p><br></p> |
| 26 | // from fragment_html and a lone <br> makes exactly one line box, and |
| 27 | // internal/export/pdf.go's paragraph loop consumes a trailing break |
| 28 | // without emitting a line. So this is a paste bug, not a rendering |
| 29 | // disagreement, and it belongs on the way in. |
| 30 | // |
| 31 | // transformPasted is the hook because it runs after the clipboard |
| 32 | // parser has finished, which is the first moment the distinction is |
| 33 | // legible. Before that a <br> is ambiguous; after it, a paragraph whose |
| 34 | // children are ALL hard_breaks can only have come from block-level |
| 35 | // <br>s, since a trailing <br> inside a <p> was already dropped in step |
| 36 | // 1 and a paragraph the user typed always has text in it. |
| 37 | // |
| 38 | // Deliberately not a parseDOM rule: matching a <br> by its siblings |
| 39 | // needs a DOM test that Google's <b> wrapper defeats, and parseDOM is |
| 40 | // part of the schema, which is the wire contract. Deliberately not |
| 41 | // transformPastedHTML: that is string surgery on untrusted markup, and |
| 42 | // the gate has no DOM to test it in. |
| 43 | // |
| 44 | // Nothing new crosses the wire. The output is an ordinary empty |
| 45 | // paragraph, which core/src/fragment.rs, internal/export/pdf.go and |
| 46 | // internal/export/markdown.go have each rendered since before this file |
| 47 | // existed — so unlike align.mjs, this adds no vocabulary that four |
| 48 | // separate binaries have to agree on. |
| 49 | // |
| 50 | // The one cost, accepted knowingly: copying a CARLOS paragraph that |
| 51 | // holds nothing but Shift+Enter breaks and pasting it back gives |
| 52 | // separate empty paragraphs rather than one paragraph of breaks. In a |
| 53 | // document whose paragraphs stack like lines (views/show.html's |
| 54 | // `[docs-text] p { margin-block: 0 }`) the two are indistinguishable on |
| 55 | // every surface, and the alternative — leaving p(hard_break) alone — |
| 56 | // is the two-line bug this module exists to fix. |
| 57 | import { Fragment, Slice } from 'prosemirror-model' |
| 58 | |
| 59 | // WebKit appends <br class="Apple-interchange-newline"> to a copy that |
| 60 | // ends at a block boundary — its own marker for where the copy stopped, |
| 61 | // not something the author typed. Safari and Chrome both emit it, and |
| 62 | // Google Docs' copy carries TWO of them, after the closing </b> of its |
| 63 | // wrapper. |
| 64 | // |
| 65 | // ProseMirror's clipboard parser ignores a <br> that is the last child |
| 66 | // of a non-inline parent, which disposes of exactly one. The first |
| 67 | // survives, becomes a hard_break, and lands as the blank line that |
| 68 | // appeared under a paste with nothing after it. Verified against the |
| 69 | // real bytes off Paul's clipboard: One/Two/Three/blank/Four arrives as |
| 70 | // six blocks, the sixth being that marker. |
| 71 | // |
| 72 | // Discriminating on the class rather than on position is what makes |
| 73 | // this safe. A trailing blank line the author really did leave is a |
| 74 | // <br> Google puts INSIDE its wrapper, carrying no class, and it is |
| 75 | // kept — where a rule that simply dropped a trailing empty paragraph |
| 76 | // would eat it. |
| 77 | export function hardBreakIgnoringInterchange(base) { |
| 78 | return { |
| 79 | ...base, |
| 80 | parseDOM: [{ tag: 'br.Apple-interchange-newline', ignore: true }, ...(base.parseDOM || [])], |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | // Returns an EditorView `transformPasted` prop for a schema. Takes the |
| 85 | // schema rather than reading it off the view so the function is pure |
| 86 | // data-in, data-out and the test can call it with no DOM at all. |
| 87 | export function blankLinesFromBreaks(schema) { |
| 88 | const paragraph = schema.nodes.paragraph |
| 89 | const hardBreak = schema.nodes.hard_break |
| 90 | return slice => { |
| 91 | const content = rewrite(slice.content, paragraph, hardBreak) |
| 92 | if (content === slice.content) return slice |
| 93 | return new Slice(content, slice.openStart, slice.openEnd) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | // Walks the slice, replacing every all-breaks paragraph with one empty |
| 98 | // paragraph per break. Recurses through block containers — a blank line |
| 99 | // inside a pasted list item or blockquote is the same mistake one level |
| 100 | // down — but stops at any textblock, whose children are inline and none |
| 101 | // of this file's business. |
| 102 | // |
| 103 | // Returns the fragment it was given, by identity, when nothing changed, |
| 104 | // so an ordinary paste allocates nothing and blankLinesFromBreaks can |
| 105 | // hand back the original slice untouched. |
| 106 | function rewrite(fragment, paragraph, hardBreak) { |
| 107 | const out = [] |
| 108 | let changed = false |
| 109 | fragment.forEach(node => { |
| 110 | if (node.type === paragraph && node.childCount > 0 && isAllBreaks(node, hardBreak)) { |
| 111 | // One empty paragraph per break, not one per run: two blank lines |
| 112 | // in Google reach step 2 above merged into p(hard_break, |
| 113 | // hard_break), and collapsing them to a single paragraph would |
| 114 | // lose a line the author did leave. |
| 115 | for (let i = 0; i < node.childCount; i += 1) out.push(paragraph.create(node.attrs)) |
| 116 | changed = true |
| 117 | return |
| 118 | } |
| 119 | if (node.isTextblock || node.isLeaf) { |
| 120 | out.push(node) |
| 121 | return |
| 122 | } |
| 123 | const inner = rewrite(node.content, paragraph, hardBreak) |
| 124 | if (inner === node.content) { |
| 125 | out.push(node) |
| 126 | return |
| 127 | } |
| 128 | // Same node type with the same attributes and the same child types |
| 129 | // — a paragraph replaced by paragraphs — so whatever content |
| 130 | // expression accepted the old fragment accepts this one. |
| 131 | out.push(node.copy(inner)) |
| 132 | changed = true |
| 133 | }) |
| 134 | return changed ? Fragment.fromArray(out) : fragment |
| 135 | } |
| 136 | |
| 137 | function isAllBreaks(node, hardBreak) { |
| 138 | let all = true |
| 139 | node.forEach(child => { |
| 140 | if (child.type !== hardBreak) all = false |
| 141 | }) |
| 142 | return all |
| 143 | } |
| 144 | |