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.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.
57import { 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.
77export 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.
87export 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.
106function 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
137function isAllBreaks(node, hardBreak) {
138 let all = true
139 node.forEach(child => {
140 if (child.type !== hardBreak) all = false
141 })
142 return all
143}
144