// fixtures/paste.mjs — what a blank line means when it arrives from // somewhere else. // // Google Docs spells an empty paragraph as a bare
SIBLING of the //

s, inside the // wrapper it puts around a copied selection — not as an empty

, and // not as a
inside one. Three things then happen in order, all of // them prosemirror-view and prosemirror-model behaving as designed: // // 1. The clipboard parser ignores a
only when it is the last // child of a non-inline parent. Google's has a next sibling, so it // is kept, and schema-basic's hard_break rule (`{tag: "br"}`) // claims it. // 2. At the open top of a parsed slice there is no block for an // inline node to sit in, so the hard_break lands as a top-level // sibling of the paragraphs. normalizeSiblings then wraps it in // the context's wrapper — a paragraph — merging consecutive // breaks into one. The blank line arrives as p(hard_break). // 3. In the editor, addTextblockHacks appends //
to any textblock whose // last child is not text, because contenteditable needs it to put // a cursor there. p(hard_break) therefore renders



: // TWO lines where the author left one. // // Only the editor is wrong. The no-JavaScript read view gets


// from fragment_html and a lone
makes exactly one line box, and // internal/export/pdf.go's paragraph loop consumes a trailing break // without emitting a line. So this is a paste bug, not a rendering // disagreement, and it belongs on the way in. // // transformPasted is the hook because it runs after the clipboard // parser has finished, which is the first moment the distinction is // legible. Before that a
is ambiguous; after it, a paragraph whose // children are ALL hard_breaks can only have come from block-level //
s, since a trailing
inside a

was already dropped in step // 1 and a paragraph the user typed always has text in it. // // Deliberately not a parseDOM rule: matching a
by its siblings // needs a DOM test that Google's wrapper defeats, and parseDOM is // part of the schema, which is the wire contract. Deliberately not // transformPastedHTML: that is string surgery on untrusted markup, and // the gate has no DOM to test it in. // // Nothing new crosses the wire. The output is an ordinary empty // paragraph, which core/src/fragment.rs, internal/export/pdf.go and // internal/export/markdown.go have each rendered since before this file // existed — so unlike align.mjs, this adds no vocabulary that four // separate binaries have to agree on. // // The one cost, accepted knowingly: copying a CARLOS paragraph that // holds nothing but Shift+Enter breaks and pasting it back gives // separate empty paragraphs rather than one paragraph of breaks. In a // document whose paragraphs stack like lines (views/show.html's // `[docs-text] p { margin-block: 0 }`) the two are indistinguishable on // every surface, and the alternative — leaving p(hard_break) alone — // is the two-line bug this module exists to fix. import { Fragment, Slice } from 'prosemirror-model' // WebKit appends
to a copy that // ends at a block boundary — its own marker for where the copy stopped, // not something the author typed. Safari and Chrome both emit it, and // Google Docs' copy carries TWO of them, after the closing
of its // wrapper. // // ProseMirror's clipboard parser ignores a
that is the last child // of a non-inline parent, which disposes of exactly one. The first // survives, becomes a hard_break, and lands as the blank line that // appeared under a paste with nothing after it. Verified against the // real bytes off Paul's clipboard: One/Two/Three/blank/Four arrives as // six blocks, the sixth being that marker. // // Discriminating on the class rather than on position is what makes // this safe. A trailing blank line the author really did leave is a //
Google puts INSIDE its wrapper, carrying no class, and it is // kept — where a rule that simply dropped a trailing empty paragraph // would eat it. export function hardBreakIgnoringInterchange(base) { return { ...base, parseDOM: [{ tag: 'br.Apple-interchange-newline', ignore: true }, ...(base.parseDOM || [])], } } // Returns an EditorView `transformPasted` prop for a schema. Takes the // schema rather than reading it off the view so the function is pure // data-in, data-out and the test can call it with no DOM at all. export function blankLinesFromBreaks(schema) { const paragraph = schema.nodes.paragraph const hardBreak = schema.nodes.hard_break return slice => { const content = rewrite(slice.content, paragraph, hardBreak) if (content === slice.content) return slice return new Slice(content, slice.openStart, slice.openEnd) } } // Walks the slice, replacing every all-breaks paragraph with one empty // paragraph per break. Recurses through block containers — a blank line // inside a pasted list item or blockquote is the same mistake one level // down — but stops at any textblock, whose children are inline and none // of this file's business. // // Returns the fragment it was given, by identity, when nothing changed, // so an ordinary paste allocates nothing and blankLinesFromBreaks can // hand back the original slice untouched. function rewrite(fragment, paragraph, hardBreak) { const out = [] let changed = false fragment.forEach(node => { if (node.type === paragraph && node.childCount > 0 && isAllBreaks(node, hardBreak)) { // One empty paragraph per break, not one per run: two blank lines // in Google reach step 2 above merged into p(hard_break, // hard_break), and collapsing them to a single paragraph would // lose a line the author did leave. for (let i = 0; i < node.childCount; i += 1) out.push(paragraph.create(node.attrs)) changed = true return } if (node.isTextblock || node.isLeaf) { out.push(node) return } const inner = rewrite(node.content, paragraph, hardBreak) if (inner === node.content) { out.push(node) return } // Same node type with the same attributes and the same child types // — a paragraph replaced by paragraphs — so whatever content // expression accepted the old fragment accepts this one. out.push(node.copy(inner)) changed = true }) return changed ? Fragment.fromArray(out) : fragment } function isAllBreaks(node, hardBreak) { let all = true node.forEach(child => { if (child.type !== hardBreak) all = false }) return all }