oficina / docs Public

A blank line pasted out of Google Docs is one line, not two

Google spells an empty paragraph as a bare <br> SIBLING of the <p>s,
inside the <b id="docs-internal-guid-…"> wrapper it puts around a copied
selection — not as an empty <p>. prosemirror-view's clipboard parser
ignores a <br> only when it is the last child of a non-inline parent, so
Google's is kept, schema-basic's hard_break rule claims it, and
normalizeSiblings wraps the now-stray inline node in a paragraph. The
blank line arrives as p(hard_break). Then addTextblockHacks appends
<br class="ProseMirror-trailingBreak"> to any textblock whose last child
is not text, because contenteditable needs somewhere to put a cursor —
so p(hard_break) draws as <p><br><br></p>: two lines where the author
left one. Google's trailing <br> before the closing </b> is the same
thing again, which is the extra blank line that appeared under a paste
with nothing after it.

Only the editor was wrong. The no-JavaScript read view gets <p><br></p>,
and a lone <br> makes exactly one line box; internal/export/pdf.go's
paragraph loop consumes a trailing break without emitting a line. So
this is a paste bug and it belongs on the way in.

transformPasted is the hook because it runs after the clipboard parser,
which is the first moment the distinction is legible: a paragraph whose
children are ALL hard_breaks can only have come from block-level <br>s,
since a trailing <br> inside a <p> was already dropped and a paragraph
the author typed always has text in it. Not a parseDOM rule — matching a
<br> by its siblings needs a DOM test the <b> wrapper defeats, and
parseDOM is the wire contract. Not transformPastedHTML — string surgery
on untrusted markup, untestable in a gate with no DOM.

Nothing new crosses the wire. The output is an ordinary empty paragraph,
which fragment.rs, pdf.go and markdown.go have each rendered since
before this, so unlike align.mjs this adds no vocabulary that four
separate binaries have to agree on.

The test pins the opposite mistake hardest: a Shift+Enter break inside a
line of text must survive verbatim. Turning every hard_break into a
paragraph break would destroy soft breaks silently, and only on the
paste path — which had no test at all before this.

Accepted knowingly: copying a CARLOS paragraph holding nothing but
Shift+Enter breaks and pasting it back gives separate empty paragraphs.
Where paragraphs stack like lines the two are indistinguishable on every
surface, and the alternative is the two-line bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmyRH8GkGkt5ay8gDAUKNm
Paul Campbell pushed by paul@keymail.dev 1bfbcd75ce746a0e95d4456fe1ca6234283172dd parent c2fca8e
5 files changed, +272 −12
  • .amadan/ci.d/10-gate +10 −0
  • fixtures/editor-entry.js +7 −0
  • fixtures/paste.mjs +118 −0
  • fixtures/paste.test.mjs +125 −0
  • internal/docs/static/{editor.5b869295084ed7fb.js => editor.a4e6489413ecd74e.js} +12 −12

CI passed — run details

Intent

Human

Google: [Image #1] Oficina: [Image #2] So even though Google is doing paragraphs, prosemirror I think adds an extra paragraph where google only has one. I've seen this before with ProseMirror. Are we satisfied it's the right engine? I think it is, but there could be a better one. But perhaps it's a known tameable solution. See what Fable thinks too

Human

I don't have direct access to this box, you need to serve that HTML on a port and give me the tailscale link

Human

<task-notification> <task-id>a16f4490197abed86</task-id> <tool-use-id>toolu_016fBAMbAHdMY4zQ6bZx4WA3</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina-docs/b67dca22-59bf-4290-8b46-545183f24ea9/tasks/a16f4490197abed86.output</output-file> <status>completed</status> <summary>Agent "Fable: is ProseMirror right" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>## Judgement **Short version: ProseMirror is the right engine, the blank-line bug is a known and tameable ProseMirror clipboard quirk, and the fix is about fifteen lines in the editor bundle that write nothing new onto the wire — so `fragment.rs`, `pdf.go` and `markdown.go` need no change and no new closed-set coupling.** The extra trailing paragraph is almost certainly the same quirk hitting Google's `&lt;b&gt;` wrapper; I show why below but could not confirm it without the real clipboard bytes. I verified all of this by running Google-Docs-shaped HTML through the *shipped* schema (rebuilt from `fixtures/editor-entry.js:44-103` and `fixtures/align.mjs`) and through prosemirror-view 1.42.3's own clipboard path (`__parseFromClipboard`, exported for tests), under Node with linkedom as the DOM. Script and output: `/tmp/claude/pmtest/paste.mjs` (outside the repo; nothing in the worktree was touched). ### 1. Is ProseMirror the right engine? Yes, and the binding constraint decides it more firmly than §6.5 does. The wire format is `Y.XmlFragment("prosemirror")` holding `XmlElement`s named after ProseMirror node types with node attrs as Yjs XML attributes and marks as text-run attributes (`core/src/fragment.rs:17-19`, `143-218`). That shape is what `fragment_html`, `fragment_text` and `fragment_json` walk. Every alternative changes it: - **Lexical** (`@lexical/yjs`) stores a different tree — a root `Y.XmlText` with nested `Y.Map`/`Y.XmlText` per node and Lexical's own property names as attributes. `fragment.rs` is a rewrite, all three renderers with it, and every existing document is orphaned (the module comment at `fragment.rs:17-19` already says what changing the root name alone does). §6.5's three reasons still hold — schema enforcement on remote updates is precisely what lets `fragment.rs` trust node names at all — but the reason that now dominates is not in §6.5: **the Rust reader is a fourth consumer of y-prosemirror's wire format, and it is the only one that cannot run the JS library.** Worth adding a line to §6.5 saying so. Lexical also does not solve paste: its `$generateNodesFromDOM` has its own long-standing Google-Docs `&lt;br&gt;`-to-empty-paragraph issues. It relocates the problem. - **Slate** (`slate-yjs`): different Yjs shape again, no schema enforcement, paste deserialisation is entirely your own code. Worse on every axis that matters here. - **Quill** (`y-quill`): a flat `Y.Text` delta. `fragment.rs` would actually get *simpler*, but you lose nested lists as a tree, tables are a Quill 2 add-on, and there is no decoration mechanism, which §6.1's pagination design depends on. Paste from Google Docs is a known trouble spot in Quill's clipboard matchers too. - **TipTap** is not an alternative; it is ProseMirror with a config layer. Same wire format (its collaboration extension is y-prosemirror; the fragment name is configurable). It would buy you `@tiptap/extension-table`, which is `prosemirror-tables`, and cost you a dependency tree in a bundle that is currently one hand-written entry file. Its paste handling is the code at `prosemirror-view/dist/index.js:2842-2913` — identical to what you have. - **Bespoke model over Yjs**: you would own contenteditable. No. - **ProseMirror + prosemirror-tables** is the only item on the list that addresses a *real* gap (table flattening), and it is not a switch. It adds `table/table_row/table_cell/table_header` elements carrying `colspan`/`rowspan`/`colwidth` — numeric attrs, so `fragment.rs` handles them the way it handles `heading.level` (`fragment.rs:178-186`: parse, clamp, emit a literal), not the way it handles `align`. That is a vocabulary extension across the four files, not a second string-allowlist coupling. Spec §11.2 already commits to tables, images and links in the schema, so this is scheduled work, not a re-decision. Nothing on the list makes paste from Google Docs better. Every editor that accepts HTML has to decide what a bare `&lt;br&gt;` between paragraphs means, and ProseMirror has the cleanest hook for it. ### 2. The mechanism, exactly Google Docs spells an empty paragraph as a **bare `&lt;br /&gt;` sibling between `&lt;p&gt;` elements**, inside its `&lt;b style="font-weight:normal" id="docs-internal-guid-…"&gt;` wrapper. (`prosemirror-schema-basic/dist/index.js:154-157` already special-cases that wrapper for the bold mark, which is how I know the shape.) What happens to that `&lt;br&gt;`: 1. `prosemirror-view/dist/index.js:2892-2896` — the clipboard parser's `ruleFromNode` ignores a `&lt;br&gt;` only if it is the **last child of a non-inline parent** (`inlineParents` at line 2915 lists `b`, `span`, etc.). Google's `&lt;br&gt;` has a next sibling, so it is not ignored. 2. `prosemirror-schema-basic:110` — `hard_break`'s `parseDOM: [{tag: "br"}]` matches. `prosemirror-model:2993-3010` `findPlace` at the open top of a `parseSlice` has no type to fit into, so the `hard_break` lands as a top-level inline sibling of the paragraphs. 3. `prosemirror-view:2924-2969` `normalizeSiblings` then wraps top-level inline nodes in the context's wrapper — a `paragraph` — and `addToSibling` merges *consecutive* `&lt;br&gt;`s into the same one. Result: `paragraph(hard_break)`, or `paragraph(hard_break, hard_break)` for two blank lines. 4. In the editor, `prosemirror-view:1989-2003` `addTextblockHacks` appends `&lt;br class="ProseMirror-trailingBreak"&gt;` whenever a textblock's last child is not a text node. `paragraph(hard_break)` renders as `&lt;p&gt;&lt;br&gt;&lt;br&gt;&lt;/p&gt;`: **two lines**. Experiment output, shipped schema vs. the fix below: ``` A: bare &lt;br /&gt; between paragraphs (Google's empty paragraph) shipped: p("One") p("Two") p("Three") p(hard_break) p("Four") with fix: p("One") p("Two") p("Three") p() p("Four") B: A, plus a trailing &lt;br /&gt; before &lt;/b&gt; shipped: … p("Four") p(hard_break) &lt;-- an extra paragraph at the end with fix: … p("Four") p() C: A, plus &lt;br class="Apple-interchange-newline"&gt; after &lt;/b&gt; shipped: … p("Four") &lt;-- already ignored by ProseMirror's own rule D: blank spelled &lt;p&gt;&lt;br&gt;&lt;/p&gt; shipped: p() (already right) E: blank spelled &lt;p&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt; shipped: p() (already right — matches your check) F: two bare &lt;br /&gt;&lt;br /&gt; shipped: p("Three") p(hard_break, hard_break) p("Four") with fix: p("Three") p() p() p("Four") ``` **The standard remedy is `transformPasted`** (EditorView prop, `prosemirror-view:2912`; it also covers drag-drop at line 3858). After the clipboard parser has done its work, "a paragraph whose only children are `hard_break`s" is exactly and only the signature of block-level `&lt;br&gt;`s, because `ruleFromNode` has already stripped a trailing `&lt;br&gt;` inside a `&lt;p&gt;`. Map it to that many empty paragraphs: ```js // fixtures/editor-entry.js, in mountView's EditorView props transformPasted: (slice) =&gt; { const out = []; let changed = false slice.content.forEach((node) =&gt; { if (node.type === schema.nodes.paragraph &amp;&amp; node.childCount &gt; 0 &amp;&amp; node.content.content.every((c) =&gt; c.type === schema.nodes.hard_break)) { for (let i = 0; i &lt; node.childCount; i++) out.push(schema.nodes.paragraph.create(node.attrs)) changed = true } else out.push(node) }) return changed ? new Slice(Fragment.from(out), slice.openStart, slice.openEnd) : slice } ``` (Import `Slice`, `Fragment` from `prosemirror-model`; there are no new dependencies.) This is what the `with fix` rows above ran. It is DOM-free, so it can live in its own `fixtures/paste.mjs` with a `node --test` file in the gate exactly like `align.test.mjs`, building slices by hand. One known cost: a CARLOS→CARLOS paste of a paragraph that is *only* Shift+Enters becomes empty paragraphs. Accept and document it; restrict to `!asText` (the third argument) if you want to be tidy. **The trailing paragraph.** Case C is already handled by ProseMirror. Case B — a trailing `&lt;br /&gt;` *inside* Google's `&lt;b&gt;` wrapper — survives because `b` is in `inlineParents`, and yields `paragraph(hard_break)` at the end, two lines tall in the editor. That is my best candidate for what Paul saw; case G (a trailing `&lt;p&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;`) is the other. I cannot tell which without the bytes. **Before deciding whether to also drop a trailing blank, capture the real clipboard HTML** — in the editor page's console, `document.addEventListener('paste', e =&gt; console.log(e.clipboardData.getData('text/html')))`, then paste — and check it into `fixtures/data/` as the fixture the test reads. If it is case B, dropping a final hard-break-only paragraph on an HTML paste is consistent with ProseMirror's own rule that a trailing `&lt;br&gt;` is a terminator, not content. ### 3. Where the fix belongs, and coupling Entirely in the editor bundle, and it is the *right* place for a structural reason: the fix emits only `paragraph` elements with no children, which every consumer already knows. `fragment_html` renders `&lt;p&gt;&lt;/p&gt;` (`fragment.rs:172-177`, pinned by `core/tests/fragment.rs:115`), `views/show.html:339-341` gives it a line, `pdf.go:512-517` gives it a line box, `markdown.go:101-107` folds it into the paragraph break. **Nothing new crosses the wire, so this adds no row to the `align.mjs` four-file coupling**, and no vector under `fixtures/vectors/fragment/` needs regenerating. Do not put it in the schema's `parseDOM` (a `br` rule on `paragraph` would need a DOM-sibling test that the `&lt;b&gt;` wrapper defeats, and it would change the schema, which is the wire contract) and do not do it in `transformPastedHTML` (string/DOM surgery, and untestable in the gate, which has no DOM). ### 4. What is wrong or incomplete in the diagnosis - **"`p:empty` does not apply, so the read view shows two lines" — half wrong.** `p:empty` indeed does not match `&lt;p&gt;&lt;br&gt;&lt;/p&gt;`, but a lone `&lt;br&gt;` in a block creates one line box on its own; the no-script read view renders the gap **one** line tall, correctly. The two-line gap is the *editor* (same element, `show.html:771` mounts on `[docs-text docs-editor]`), and the cause is the `ProseMirror-trailingBreak` hack, not the CSS. Only one surface is wrong. - **A separate, pre-existing drift the diagnosis conflates with this one.** For any paragraph ending in a `hard_break` — `p("Three", hard_break)`, i.e. Shift+Enter at the end of a line, no paste involved — the editor shows an extra empty line (Google Docs does the same), while the read view (`&lt;p&gt;Three&lt;br&gt;&lt;/p&gt;`, no trailing line box in HTML) and the PDF (`pdf.go:539-548` flushes the line and the loop ends; the trailing `brk` consumes nothing) show none. The paste fix does not touch this. If you want the three to agree it is one CSS rule (`[docs-text] p:has(&gt; br:last-child) { padding-block-end: 1lh }`) and one branch in `pdf.go`'s paragraph loop; decide first whether the editor or the readers are right. - **Your item 2 understates the link/image problem.** `image` and `link` are in the *browser* schema (schema-basic) so a paste writes them into the CRDT, where `fragment.rs` drops the `href` silently (`link` is not in `MARKS`, `fragment.rs:77-87`) and renders an `image` element as nothing (unknown element, no children, `fragment.rs:203-204`) — the module comment's "silence here is data loss" is exactly what happens. Until `fragment.rs` grows a scheme-allowlisted `href`/`src` (the decision the `MARKS` comment defers), the browser schema should be narrowed to what the reader can show, so a paste cannot write what a reader cannot see. That is a one-line `basic.spec.nodes.remove('image')` and a mark removal, plus a decision recorded in the spec. - Underline, strikethrough, colour, highlight and Docs' font sizes being dropped is correct behaviour under the current MARKS discipline (attribute-free marks only), not a paste bug; each would be a new vocabulary row across the same four files. Say so in the spec rather than letting it read as breakage. ### One line for §6.5 Add: "A fourth consumer of the wire format is the Rust read path (`core/src/fragment.rs`), which cannot run the binding library. Any engine that stores a different Yjs shape re-implements that path and orphans every document; ProseMirror stays for that reason before any other."</result> <usage><subagent_tokens>140510</subagent_tokens><tool_uses>33</tool_uses><duration_ms>498058</duration_ms></usage> </task-notification>

Human

<task-notification> <task-id>by7l46j5s</task-id> <tool-use-id>toolu_01Pu7HzL2cdMY6j91ogLFR9G</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina-docs/b67dca22-59bf-4290-8b46-545183f24ea9/tasks/by7l46j5s.output</output-file> <status>failed</status> <summary>Background command "Run the full CI gate" failed with exit code 1</summary> </task-notification>

Attributed to this commit: 32.4 g CO₂e (3761171 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on worktree-paste-fidelity, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

.amadan/ci.d/10-gate

Loading diff…

fixtures/editor-entry.js

Loading diff…

fixtures/paste.mjs

Loading diff…

fixtures/paste.test.mjs

Loading diff…

internal/docs/static/{editor.5b869295084ed7fb.js => editor.a4e6489413ecd74e.js}

Loading diff…