// 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
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
}