Skip to content

PT-4201: Standard-view copy/paste USFM fidelity - #13

Open
tjcouch-sil wants to merge 31 commits into
mainfrom
standard-view-pt-4201
Open

tjcouch-sil wants to merge 31 commits into
mainfrom
standard-view-pt-4201

Conversation

@tjcouch-sil

@tjcouch-sil tjcouch-sil commented Sep 15, 2026

Copy link
Copy Markdown
Member

Implements PT-4201 (WI-14): copy/paste USFM fidelity in Standard view.

Moved from eten-tech-foundation/scripture-editors#532, following docs/transferring-work-from-eten-tech-foundation.md, so this work lands where Paratext 10 now develops platform-editor. Rebased with --onto origin/main from the old base (768f6476, the #531 annotations fix), so only this branch's own 19 commits replayed — clean, no conflicts, and verified to have carried every PT-4201 change intact.

Two things reviewers should know about the move:

  1. The committed packages/*/dist is rebuilt here (this repo commits it, eten-tech-foundation does not, so the inherited dist predated every commit on the branch). node scripts/verify-committed-dist.mjs passes.
  2. This repo's main does not have eten-tech-foundation's #531 ("apply annotations issued while a load is in flight"). That is why the suite reports fewer tests here than the old PR did — platform-editor 1850 vs 1864, shared-react 1668 vs 1674. Nothing from PT-4201 was lost; the delta is entirely use-load-gate.hook, LoadStatePlugin and the annotation-before-load tests, none of which this branch touches. Transferring #531 is a separate main-sync decision.

What this fixes

Verified against the current main, which still had none of these fixes.

Copy

  • text/plain is byte-faithful USFM: note callers emitted (were silently dropped and rewritten to + on re-paste), no phantom separator spaces, AttributeRunNode-transparent.
  • A copy with nothing selected no longer writes a # to the clipboard. @lexical/clipboard's execCommand hack injects a hidden <span>#</span> and bails before preventDefault() on an empty selection, so Chromium copied the placeholder. Reported for \fig, but it fired for a plain collapsed caret in every view and in scribe too, and for cut.
  • Opaque constructs survive the internal clipboard flavor. excludeFromCopy now keys on child count rather than tag: previously a copied figure/sidebar/periph/ref pasted its full literal USFM bytes on screen while the USJ silently dropped the node and its attributes.
  • Sidebars and tables copy on one line so a per-paragraph rebuild can reassemble them.

Paste

  • Positional NBSP rule (after-marker → space, before-marker → drop, interior → ~) replacing the blanket mapping that produced \f~ \fr~1:1 … unknown-marker soup.
  • \c/\id stripped anywhere in a pasted line — a pasted \c used to poison the save loop ("Multiple chapter markers present") with the error only in the renderer log.
  • Attribute-run pastes route as plain text, byte-matching typed input.
  • A pasted \fig's caption is no longer removed — a shared text-spacing transform ejected the freshly-materialized caption out of its wrapper. Only reproducible on a full Editor mount, which is why engine-level tests never saw it. The misplaced gray box was the same defect. It was also silently breaking \ref pastes.
  • A paste's provenance now survives a deferred Tier-2 settle, so a pended rebuild keeps the own-prefix dedup (typed input keeps its existing behavior, pinned both ways). Removed in the code-review round below — the dedup it carried was deleting the host paragraph's marker. See "Paste no longer edits the host paragraph's marker".

Review follow-ups (this round)

  • UnknownNode.isSelected dropped the node-selection case. The override answered purely on whether any of the node's own children were in selection.getNodes(), which is the right test for the range boundary it was written for but is never satisfied by a NodeSelection — that marks a node by its own key and puts no children in the list. A construct selected outright therefore reported unselected, and $appendNodesToJSON hoists an excluded wrapper's (also unselected) children in its place, so the copy would have carried nothing for it. The base key-membership answer now runs first for a node selection; the child-membership rule is unchanged for everything else. Not reachable from today's UI — both $createNodeSelection sites target verse markers — but it is a trap for whoever adds node-selection over figures or tables.
  • ContextMenuPlugin depended on ClipboardPlugin for its empty-copy guard. Both shipped editors mount the pair, but the plugin is exported on its own, and a host mounting it alone got exactly the placeholder-overwrites-clipboard bug the guard exists to prevent. It now registers the guard itself; a duplicate registration at the same priority is inert (the first listener to claim an empty selection stops propagation).

Both are covered by tests that go red when the fix is reverted — including a ContextMenuPlugin-without-ClipboardPlugin mount.

Verification

Full workspace green (--skip-nx-cache): platform-editor 1828, shared 683, shared-react 1666, utilities 56; typecheck and lint clean. Semantics documented in docs/superpowers/specs/2026-08-06-clipboard-semantics.md, with accepted asymmetries and remaining deferrals listed explicitly.

Re-verified after the follow-ups above: shared 697, shared-react 1674 (+1 skipped), platform-editor 1857 (+4 skipped); nx format:check, lint and typecheck clean.

The companion e2e spec runs and passes through the real OS clipboard (paranext-core #2656), against a build of this branch.

Every fix here was reviewed by reverting it and confirming the pins go red. Two review rounds caught defects in my own earlier attempts, including one that would have shipped silent data loss.

AI-assisted — Claude Code (Opus 5). Task ledger and per-task reports in .superpowers/sdd/ (untracked).

🤖 Generated with Claude Code

https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f

Hand-QA follow-ups (2026-09-16/17)

Four findings from the first hand-QA pass (ddea1549 + 40d06486, a1dede31, dist 44d69dbd/e91c930c):

  • A drag that starts on a read-only glyph, or runs from a figure's caption into its attribute run, copied nothing / stopped short. Two layers. (1) Lexical 0.43's $internalResolveSelectionPoint resolves a DOM point inside a decorator's element to null, nulls the whole selection, and the reconciler removeAllRanges() — a \fig's \fig / |src="…" / \fig* glyphs are ImmutableTypedTextNode decorators whose text is a DOM child of the decorator, so a drag whose focus entered the attribute run collapsed. New DecoratorBoundarySelectionPlugin (shared-react, mounted in every view) handles SELECTION_CHANGE_COMMAND at CRITICAL and gives Lexical the selection it refused: each offending end snapped to the decorator's boundary in its parent (a range grows outward so the glyph is wholly in; a caret takes the nearer side), built via $createRangeSelectionFromDom from a selection-shaped object. (2) Measured in the Electron app: Chromium extends a mouse drag only from a base it placed itself, so the plugin must NOT rewrite the DOM selection while a button is down — it marks the snapped selection clean during a drag and materializes it into the DOM on pointerup/pointercancel (an event type Lexical does not re-derive from the DOM), and writes immediately for keyboard/programmatic arrivals. Chromium also clamps any drag begun inside a contenteditable="false" island to that island, so a drag from the \fig glyph selects at most the whole figure — the browser's rule, recorded in the comments and the e2e. The note caller is excluded (its landings belong to TrailingNoteCaretGuardPlugin; measured conflict, pinned).
  • P10 → P9: a pasted note had no caller. P9 paste is HTML-first (IHtmlEditorCopyPaste.cs, then Standard-Reverse.xslt reads the fragment's text() as USFM). Standard view's text/html was Lexical's DOM export, which carries a collapsed note's caller only as data-caller and omits every UnknownNode (exportDOM{element: null}), so P9 received \x \xo …\x*. text/html is now the same USFM bytes as text/plain (usfmToClipboardHtml; invertDisplayNbspInHtml removed with the export it normalized). Asymmetries Flaky interaction tests: ScriptureReferencePlugin and marker-palette each failed once in CI #3/Consider committing built dist/ like platform-bible-react, so consumers stop building at install time #4 in docs/clipboard-semantics.md are closed by construction.
  • P9 → P10: a pasted footnote was the literal caller glyph. P9's text/plain is the visible text (a note = its caller), while the note's bytes ride the html as an escaped <!--usfm:…--> comment inside an exclude span. New paratext9HtmlToUsfm ports Standard-Reverse.xslt over the DOM and wins over text/plain whenever the html carries P9's signature (a usfm: comment, or usfm_<name> + usfmopen/usfmclosed — nothing P10/Word/Docs writes).
  • Simple mode: pasting a \fig did nothing — not intentional. Under structure protection the Standard-view claim declined every paste to StructureKeyboardPlugin's html sanitizer, whose carrier could not express a figure; the same decline also skipped the \c/\id strip (a protected paste of \c 7 created a second chapter node — the save-loop poisoning the strip exists to prevent) and the NBSP rule, and would have skipped the P9 decode. The engine has no protection gate for marker bytes (typed or pasted \v/\p tokenize identically in both modes), so the sanitizer only ever guarded DOM verse/para nodes that Standard view's html no longer produces. The claim now handles a protected document with the same bytes as an unprotected one, except it declines a selection $shouldBlockSelectionReplacement refuses (one owner for that rule) and never replays paragraphs (lines join with a single space). Documented in S3.

Pins: decoratorBoundarySelection.test.tsx (11 — DOM untouched while the button is down, materialized on release, written immediately otherwise, survives an event-less update), DecoratorBoundarySelectionPlugin.test.tsx (2), paratext9Clipboard.utils.test.ts (18), html/plain parity in clipboardCopyFidelity, P9→P10 in markerPasteFidelity, protectedFigurePaste.test.tsx (12), the rewritten structure-protection describe in whitespaceDisplay.plugin.utils.test.tsx (5; the Editor→plugin wiring pin stays falsifiable as a mode difference). Full suites: shared 697, shared-react 1670 (+1 skipped), platform-editor 1903 (+4 skipped); lint, prettier and per-package typecheck clean; verify-committed-dist ✓.

Known, deliberately left (pre-existing, reported to TJ): in Simple-mode Standard view a paste with the caret parked at the end of a \v glyph is refused outright by $selectionContainsVerseMarker (a text-point caret inside the mutable VerseNode counts as containment).

Real-browser verification: paranext-core's clipboard-usfm-round-trip isolated e2e, extended with the four drag/flavour/P9 steps, passes against dist e91c930c linked into the app — 1 passed (2.4 min), first attempt.

Code-review round (2026-09-17)

A /code-review pass over both PRs, then triage. Three commits: 24b030d2 (review fixes), a0efe7df (periph), 1b2163ac (paste behaviour).

Paste no longer edits the host paragraph's marker — behaviour change, please read

Pasting a whole-paragraph copy at an existing paragraph's content start was deleting the host paragraph's marker. A whole-paragraph copy carries its own \p literal, so the line ends up with two paragraph markers, and $withoutRedundantOwnPrefix resolved that by dropping the host's glyph: paste \q1 asdf at \p ‖fdsa and you got one \q1 asdffdsa paragraph, with the \p gone and content that belonged to it now under \q1. An \s1 heading pasted into came back as a plain \p.

That was never a product decision — it was added to un-skip a round-trip test whose target document is an empty \p scaffold, and the "stray empty paragraph" it removed was that scaffold. It is also not what Paratext 9 does: NormalizeTokenUsfm (ParatextData/UsfmToken.cs) emits a line break before every Paragraph token, so P9 gives you the empty \p and then the pasted paragraph. A paste now inserts what was pasted and nothing else, and if that means two paragraph markers in a row, that is what the bytes say — the same shape typing them produces.

Removing it takes the whole paste-provenance apparatus with it: the isPasteRebuild parameter through $buildParaFragment/$rebuildParas/$requestTier2ForNode, Tier2Context.pasteRebuildArmed, the pastePendedKeys set that carried "this rebuild came from a paste" across a deferred settle, its per-commit pruning, and consume-on-use. The read-only settle and the mutating rebuild now call $buildParaFragment with identical arguments, which also closes a review finding: the two could disagree about a deferred paste's shape, letting EditorRef.getUsj() predict a paragraph the settle would not produce.

It also exposed a real defect the dedup had been masking. A multi-line paste replays each line break as a paragraph split, and the engine injects a marker prefix onto every fresh split paragraph — including onto lines that already carry their own marker. For a marker with a separator and content the injected prefix resolves away; for \b, which has neither, both survived, so every blank line in a pasted document gained an empty \p in front of it — 16 of them in the 2sa fixture. $paraMarkerDeletionTransform's split branch now skips the injection when the paragraph's own text already opens with a paragraph-marker literal. Enter pressed directly in front of a marker literal is the same shape and gets the same answer.

The round-trip suites seeded that empty \p host and expected the paste to swallow it; they now assert it is there and strip it, so a genuine stray paragraph can no longer pass as the harness's own.

Correctness fixes (24b030d2)

  • A foreign Lexical editor's clipboard was treated as internal. isInternal answered the mere presence of application/x-lexical-editor, but Lexical's fast path is namespace-scoped ($insertDataTransferForRichText requires payload.namespace === editor._config.namespace). A payload from the app's own "Commenting" editor (CommentPlugin.tsx) made every claim stand aside, then fell through to the text/html DOM import — which runs neither the \c/\id strip nor the NBSP rule, so a \c copied out of a comment box reached the document unstripped. getPastePayload now mirrors Lexical's actual condition. (A sibling test already called this the "same-namespace-flavor guard", so the code was behind its own documented intent.)
  • The char-stack line replay ignored structure protection. Registered after the Standard-view claim at the same priority and before StructureKeyboardPlugin mounts, so the one paste that handler declines reached it and was replayed with removeText() plus a split per line. Measured with the guard removed: a protected paste deletes \v 18 and the \nd span and splits the paragraph — exactly what protection had just refused.
  • LEADING_MARKER_LITERAL accepted a closer (inherited from the shared terminated-marker shape), so a leading \zbold* read as the paragraph marker zbold — unknown markers are paragraph-kind — and the host's glyph was dropped: an \s1 heading became \p. This matched P9's own rule exactly once fixed: // End tokens are always end tokens, even if unknown (UsfmToken.cs). (Moot now that the dedup is gone, but the classification fix stands.)
  • A copy that produced no bytes still overwrote the clipboard with an empty string and an empty <p> — the same loss the collapsed-selection guard exists to prevent, reached through the non-collapsed door. The event is claimed and nothing is written.
  • A CR inside a decoded Paratext 9 note survived usfmFromComment and became a hard line break, splitting the note across two paragraphs.
  • DecoratorBoundarySelectionPlugin pointer tracking: the press was heard on the root while the release was on the document, so a press landing just outside the contenteditable root started a drag the plugin never saw; and a secondary-button press could leave the flag stuck when the native context menu swallowed the pointerup. Both fixed.

\periph keeps its division when the attribute list is refused (a0efe7df)

\periph has no closing marker, so a peripheral division's extent is a property of the marker, not of its attributes. Refusing the marker line's attribute list was demoting the whole division to an ordinary paragraph and re-parenting every block under it to the root. P9 reads it the other way: a failed SetAttributes leaves the marker-line text alone and the USX writer still opens <periph> with an empty id. The refused bytes now survive as the division's own title and serialize back byte-for-byte. This also removes a live-typing glitch — typing |id="cover" onto a periph line used to un-nest everything under it on the | keystroke.

Splitting the marker line at its FIRST | is unchanged: that matches P9's own text.IndexOf('|').

Test defects found and fixed

  • The optbreak carrier-agreement pin asserted not.toContain against a flavor that is always "" for that selection (the opaque-block rule suppresses it), so reverting UnknownNode.isSelected left it green. The override's range branch — its whole reason for existing — had no pin anywhere; two now live in UnknownNode.test.ts, verified to fail when the override is reverted.
  • A mixed-selection pin asserted only that the document survived, never that the paste landed.
  • The accepted P9 leading-space parity was parked in KNOWN_LOSSY, hence skipped, hence protecting nothing despite the file calling it "NOT a fix candidate". It is now pinned positively.
  • Two internal-paste pins passed "{}" as the lexical flavor — never same-namespace — so they pinned the old presence-only rule.
  • Two pre-existing possibly undefined type errors in the protected-paste suite (present before this round) are fixed.

Every behaviour fix above was verified by reverting it and watching the new test fail.

Verification

shared 700, shared-react 1670 (+1 skipped), platform-editor 1914 (+3 skipped); per-package typecheck, eslint and prettier clean; verify-committed-dist ✓.


This change is Reviewable

tjcouch-sil and others added 20 commits September 15, 2026 15:49
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A copy that does not come from a real browser clipboard event — Ctrl+C/Ctrl+X,
the context menu's Cut/Copy, EditorRef.copy()/.cut() — dispatches the command
with a null payload, and @lexical/clipboard has to synthesize an event before
anything can be written: it appends a hidden placeholder element to the editor,
points the DOM selection at it, and runs document.execCommand("copy"). Filling
that synthesized event declines an empty selection BEFORE suppressing the
browser's own copy, so the browser copies the placeholder and the user's real
clipboard contents are replaced by a character that was never in the document.

A read-only construct makes it likelier, not special: an UnknownNode renders
contentEditable=false and its marker glyphs are decorators that take no keyboard
selection, so trying to select a figure's marker text leaves the caret in the
prose beside it. A plain collapsed caret does the same, in every view, and cut
takes the identical path.

Two layers. copySelection/cutSelection (shared-react) do not synthesize a copy
for a selection that cannot produce content, and leave the key unclaimed so the
browser's own no-op copy runs — that is what "untouched" means. And the
Standard-view handler claims a null-payload dispatch it cannot build data for
rather than declining it into @lexical/rich-text's fallback. A real clipboard
event at a collapsed caret is still declined, and a node selection still goes to
Lexical, which copies it correctly.

Copying a read-only construct works through a selection that contains it — the
shape a browser resolves a drag over one into — which reaches its display glyphs
through the same walker as prose and yields the construct's real USFM bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…arker kinds

The paste carrier choice now states its trade-off where it is made. Choosing by
which carrier an NBSP survived in is the better rule for a foreign clipboard,
and unusable here: Standard view's own text/plain deliberately carries no NBSP
at all, so an NBSP-presence test is true of every P10 copy and would route the
editor's own round trip through html, whose decoded text drops a collapsed
note's caller. Recorded in the semantics doc's deferred list too.

PastePayload keeps only what a claim may act on. The unresolved carriers were
exposed and unread; every claim must replay the same bytes, and a second carrier
within reach invites one of them to pick differently.

Doc corrections: getPastePayload named three PASTE_COMMAND handlers and
described the Standard-view claim as NBSP-gated, both false since the merge;
isInternal claimed that claim declines internal payloads outright, false since
the attribute path claims them; $handlePasteForStandardView lost its
"call inside editor.update()" contract note while gaining a $getEditor() call
and a command dispatch that make it matter more.

The marker-kind classifiers move to a leaf module. Both tiers ask the question
and must answer it identically, and Tier 2 reaching into Tier 1 for it made the
two import each other. Their only inputs are a string and a stylesheet lookup,
so neither tier is needed to answer it.

Also restores the multi-paragraph-html paste test's return-contract assertion,
lost when it switched to a command dispatch — the content assertion alone cannot
tell this handler's replay from Lexical's own html import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rsion

The copy flavors no longer split into "text/plain inverts, everything else
ships the display form". text/html inverts too, collapse-awarely: an interior
single display-NBSP becomes the plain space it stands for, while a run of 2+ and
a fragment edge keep NBSP because a rich-text consumer collapses runs and drops
edge whitespace. Only the internal Lexical flavor is still untouched, so an
internal paste round-trips the display form exactly.

That also sharpens why the paste carrier is chosen by presence rather than by
where an NBSP survived: Standard view's own text/plain still carries no NBSP at
all, while its text/html now carries them precisely where a plain space would be
destroyed — so an NBSP-presence test would route every P10 round trip through
html and lose the note caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Checking the selection before dispatching read the COMMITTED editor state,
which Lexical is a microtask behind — so a selection made earlier in the same
tick was invisible and the copy was silently withheld. That is the ordinary
shape of the public EditorRef.copy() path: select something programmatically,
then copy it. Reading the live state was not a drop-in fix either, since the
context menu invokes its options from inside editor.update().

The guard is now a COPY_COMMAND/CUT_COMMAND listener registered above
@lexical/rich-text's fallback and below every feature handler. A command
listener runs inside the update, where the selection is authoritative for
pending and committed state alike, so the staleness class is gone rather than
narrowed — and one registration covers the shortcuts, the context menu and the
editor ref, since all of them dispatch the same command.

The three Standard-view pins asserted nothing that could fail. The dispatch's
return value is true either way (rich-text always claims), the clipboard-module
spy cannot see a call made through the externalized rich-text import, and the
execCommand assertion was masked by @lexical/clipboard's module-level latency
timer left set by the preceding test. They now watch for the dispatch reaching
past the claim — immune to module state and test order — and drain that timer,
and all three fail in a whole-file run when the claim is reverted.

Also pins the node-selection carve-out (a copy that has content must still
happen), the context-menu and editor-ref legs, records the empty-selection
no-op on the public API's TSDoc, and marks the browser's drag-over-a-read-only-
block selection shape as the assumption jsdom cannot verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…idened MarkerEditContext

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f
Pasting `\fig At once they left their nets.|src="avnt016.jpg" size="span"
ref="1.18"\fig*` into Standard view left a figure box holding only
`\fig |src="…"\fig*`, the caption stranded after the closing glyph as
paragraph prose, and an exported USJ whose `figure` object had no content
at all — the caption merged into the neighbouring text run.

`$textNodeInUnknownTransform` (TextSpacingPlugin) ejects any TextNode that
is new in the current update out of an UnknownNode parent, so a user typing
into a read-only opaque block never plants prose inside it. A Tier-2 rebuild
materializes the whole figure — wrapper and caption together — in one update,
so the caption looked exactly like typed text to that rule. Eject only when
the wrapper predates the update, which is the shape a typed intrusion
actually has.

The class is invisible to a MarkerEditPlugin-only harness (the ejection lives
in a sibling plugin), so the new pins mount the full Editor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f
…copy

An internal (`application/x-lexical-editor`) copy→paste of a figure, sidebar,
periph or ref pasted the construct's full literal USFM bytes onto the screen
while the exported USJ silently dropped the node and every attribute on it —
a convincing display over lost data that a save then persisted with no error.

`UnknownNode.excludeFromCopy` kept only a child-bearing optbreak out of the
exclusion; every other kind stayed excluded, and `$appendNodesToJSON` does not
drop an excluded node — it hoists its children into the parent, stranding the
content-free display decorators that render each kind's marker and attribute
bytes. Key both `excludeFromCopy` and the `isSelected` boundary override on the
node's own child count rather than on its kind: the kinds share one
display-decorator shape, so they shared one failure.

Verified per kind across all three real clipboard payload shapes (plain-only,
plain+html, and the full payload carrying the lexical flavor). Tables are not
part of this — `table`/`table:row`/`table:cell` are their own `ImmutableTable*`
nodes now and never took the exclusion — and are pinned in the same sweep to
say so. The plain-carrier losses each kind genuinely has (a block construct
split across lines; a `ref` wrapper USFM has no bytes for) are asserted as
losses rather than left unpinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f
A sidebar's nested `\p` and a table's rows and cells are real block-level
nodes, so the copy walker put a `\n` at each of them and spread ONE construct
over several lines. Paste replays every `\n` as a paragraph split and Tier 2
re-tokenizes strictly one paragraph at a time, so the `\esb`/`\esbe` and
`\tr`/`\th`/`\tc` assembly the fragment tokenizer already implements never saw
the whole construct: a pasted sidebar came back unclosed with its paragraph
hoisted out, a pasted table as empty row wrappers with the cells stranded as
sibling paragraphs.

Suppress the line break for a block boundary whose opaque ancestor is above it,
so the construct stays byte-contiguous and a single-paragraph rebuild can
reassemble it. The construct's own outermost node still starts its own line, so
a table stays separated from the prose around it. USFM markers self-delimit, so
the one-line form is valid USFM of the same document — just not the
line-per-marker layout a writer emits.

Tables needed one more thing on the paste side: usfm.sty classifies
`\th1`/`\tc1`/… as CHARACTER styles, so with a real project stylesheet a cell
marker reached the tokenizer as a char token and the table-cell assembly, which
only ran for paragraph tokens, never saw it. ParatextData derives a cell from
the marker NAME, so recognize one on either token kind, guarded on an open row.

The corpus sweep's sidebar and table fixtures are un-skipped and now sweep
clean. `\periph` is not fixed: the tokenizer has no periph assembly and periph
renders attributes as a pipe run on a marker with no closing bytes. That is
recorded and pinned as a loss rather than left to look fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f
A paste's own update does not always get to perform the rebuild its bytes ask
for. A paragraph whose re-tokenization would EJECT content out of a milestone
deliberately waits for caret departure, and by then `pasteRebuildArmed` — a
per-commit flag — has reset, so the departure settle rebuilt the pasted line as
though the user had TYPED it: the host's `\p ` glyph and the pasted line's own
`\p ` literal both survived, leaving a stray empty paragraph ahead of the
correct one.

`Tier2Context.pastePendedKeys` gives the arm a lifetime tied to the pended work
instead of to the commit clock. A pend recorded while the paste's own update is
armed records its key; the settle that routes that key to a rebuild consumes the
provenance and passes it down. Keying it that way is what keeps it out of typed
input: keys are only added inside a paste's own update, consumed by the first
rebuild that reaches them, and pruned by the per-commit update listener as soon
as they stop pending — and text typed beside the pasted bytes pends its own key,
which carries none.

Closes the last "Known Lossy" clipboard entry. The corpus `milestones (ts)`
fixture it was filed against does not in fact reach the deferred path (its
milestone ejects nothing), so it is un-skipped and swept clean; the shape that
does reach it is pinned directly, together with its counter-pin that the same
bytes with no paste behind them still split with an empty predecessor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f
Four review follow-ups on the opaque-construct clipboard cluster.

`UnknownNode.excludeFromCopy`'s rationale still read as an optbreak husk rule
after the guard was generalized to child count. It now names what the rule
actually excludes, including the case it was under-describing: a hidden-marker
view builds no display children at all, so ANY content-less construct there — a
caption-less figure, an empty ref — is childless and dropped. Recorded as a
deferred item rather than changed, since a marker-free view copying
marker-only constructs is a view-semantics decision.

Pinning the `isSelected` boundary on a second kind turned up a real
disagreement rather than confirming the shared rule. A construct whose display
bytes lead starts with a decorator, so an element-type boundary point on the
wrapper stays one and reaches no child; a construct with no display bytes (a
`ref`) starts with a real TextNode, and Lexical normalizes that same point into
a text point at the child's offset 0 — the child is then in `getNodes()` while
contributing zero characters, so the wrapper and its whole content went into
the lexical flavor while `text/plain` had none of it. `isSelected` now asks
whether the selection covers a child's CONTENT, which is what the copy walker
computes. Pinned for figure and ref in both directions.

The tokenizer's character-token cell arm gains sheet-backed unit coverage for
the align-infix names and both of its guards; a ranged cell is not a sheet
entry at all and still arrives as a paragraph token, so that is pinned too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f
…ts text

The content-coverage `isSelected` predicate closed the `ref` boundary's carrier
disagreement by trading it for something worse. Excluding the wrapper does not
drop it quietly: `$appendNodesToJSON` hoists its children in its place, and
`createUnknown` stamps `mode:"token"` on every text child, which
`$sliceSelectedTextNodeContent` refuses to slice — so the zero-width child kept
its full text and the copy carried `Genesis 1:1` with the `ref` node and its
`loc` attribute silently gone. That is the convincing-lie shape the suite exists
to prevent, and the tag-only assertion could not see it.

Revert to child membership, which keeps the construct whole and makes the
lexical flavor a superset of `text/plain` at that one boundary. Making it appear
in neither carrier is not reachable from the node API: the children are plain
TextNodes, and `exportNodeToJSON` requires every ElementNode's `exportJSON()` to
return a `children` array, so a node cannot exclude itself AND its children. The
residual is recorded in the deferred list with both measured payloads.

Every boundary assertion now reads the construct's own content bytes, and the
pins state the behavior per kind — a decorator-led construct is excluded from
both carriers, a text-led one is carried whole — so the lossy variant turns them
red instead of passing.

Also scoped the provenance doc to what was measured: typing into a DIFFERENT
node pends its own key and takes the typed path, but typing into the still-
pending node itself adds no key, so the paste path runs and the dedup applies to
the line those bytes are now part of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f
A copied `\periph` came back as an ordinary paragraph carrying its own attribute
bytes as literal content: `usfmFragmentToUsj.ts` had assemblies for `\esb`/`\esbe`
and `\tr`/`\th`/`\tc` but none for `\periph`.

The suspected second cause — that the one-line opaque-construct copy removes the
attribute run's only delimiter — does not hold. Measured: the one-line and
line-per-marker spellings tokenize identically, because a line break is ordinary
whitespace and the next `\marker` is what ends a division's marker line either way.
The one-lining is in fact required: a paste replays every line break as a paragraph
split and Tier 2 re-tokenizes one paragraph at a time, so only the one-line form
lets a single pass see the division together with the blocks inside it. Pasting the
line-per-marker spelling was measured to come back as an empty division with its
paragraph beside it, and that measurement is now a pin.

`\periph` therefore assembles like a sidebar: the marker line splits at its first
`|` into the division title (`alt`) and a named-attribute list, and the division
takes every following block until the next `\periph`, a chapter, or the fragment
end. A division has no closing bytes, so there is no unterminated state to record.
An attribute list that does not parse degrades the whole division to a literal
paragraph, as an unfoldable figure span does.

A sidebar now lands in an open division instead of beside it, which required
pushing it before assigning `sidebar` — `blockTarget()` answers "the open sidebar"
whenever one is open, so assigning first nested it inside itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f
Deferred item 9's two attribute-context paste residuals, characterized and closed.

(a) does not reproduce and is not paste-specific. A mixed selection reaching past a
char span's closing glyph into following sibling text settles to byte-identical USJ
whether the replacement is typed or pasted, across four selection shapes (forward
and backward, run-start-to-trailing-end and run-end-to-trailing-start included). The
span coming back `closed="false"` with its attribute run collapsed into content is
the arithmetic of a range that covered the closer, not a defect: deleting a range
containing the closing glyph deletes the closing glyph.

(b) reproduces, from BOTH directions, and is a data-fidelity hole. The value-byte
carve-outs — no `\c`/`\id` strip, no positional NBSP mapping — were applied to any
selection that merely TOUCHED an attribute run. Those carve-outs are earned by one
fact only: the bytes land in a node that stays tagged "attribute", which
`$textNodeTier2Transform` skips. A touching selection loses that the moment its
removal takes the range out of the run, so pasting `\c 5` over one produced a real
chapter node mid-paragraph with the rest of the paragraph stranded outside it — the
save-loop poisoning the strip exists to prevent — and a pasted interior NBSP was
read as display whitespace and saved as a plain space instead of surviving as data.
Measured with the range starting in body text and reaching into the run, and with
it starting inside the run and reaching past the closer.

The carve-outs now apply only to a selection wholly inside one attribute node. The
insertion MECHANISM is unchanged for every touching selection — one `insertText`,
never the paragraph-splitting line replay and never Lexical's rich-paste node
insertion — so a multi-line payload landing in body content this way still collapses
per newline rather than splitting a paragraph the removal has cut a char span in
half in.

The single-paragraph fixture the new pins use is what made this measurable at all:
the earlier probing needed a cross-paragraph range, which does not hold in jsdom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f
…e losses

Deferred item 7 excluded `2sa.usj.ts` from the copy→paste corpus sweep because it
did not round-trip clean when last measured. Re-measured against the same harness:
three classes of divergence remained, two of them clipboard bugs of the family this
round has been closing.

A collapsed note's `category` was dropped. `createNote` builds the `\cat` display
run only for the editable EXPANDED layout — a deliberate view decision — so a
collapsed note has no category bytes on screen for the copy walker to pick up. Same
shape as the note caller, which renders as an empty glyph and is recovered by the
walker for exactly this reason, so the category is recovered the same way, placed
where the file has it (`\f + \cat People\cat*\fr …`, no trailing space: a space
there re-tokenizes into a stray text child inside the note).

A spanning table cell's `colspan` was dropped. USFM tables have no pipe attributes
at all — a cell's width lives in its marker NAME (`\thc3-4`), which the tokenizer
trims off into `colspan` on the way in — and the display rendered the bare marker,
so the editor showed, copied, and re-tokenized a one-column cell.
`tableCellMarkerWithSpan` already existed for the `UnknownNode` byte builder, whose
table branch went dead when cells became `ImmutableTable*` nodes; it is now exported
and shared by both. The committed 2SA Lexical fixtures are regenerated: six lines,
the three spanning cells in editable and visible modes.

The third class is inherent and stays: a verse's `sid` is derived from book +
chapter + verse when ParatextData produces USX, and USFM has no bytes for it
anywhere. Measured rather than assumed — a plain load→save of this fixture keeps all
26 of them, so the loss belongs to the carrier, not the adaptors.

With those two fixed, 2SA round-trips to full USJ equality modulo the 26 verse sids,
so it joins the sweep as a running case rather than a skip: skipping would trade 143
items of real-world coverage for one attribute. The carve-out is asserted on both
sides (the source really carries them, the paste carries none) so it can neither be
vacuous nor outlive the gap, and the re-stamped document USJ version is asserted
rather than waived. The whole sweep file, 2SA included, runs in about a second.

Two gaps the old exclusion reason listed are already closed and were confirmed so:
the empty `\b` blank-line paragraph and the `\ref` target both round-trip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f
…periph title loss

The carve-out is earned by paste-equals-typing, not by the bytes never
re-tokenizing: the "attribute" tag survives insertion, but the caret-departure
settle re-tokenizes the paragraph, so a pasted \c in a value does become a
chapter marker — identically to typing it there. Both halves are now pinned
settled rather than read before the settle, so the residual is visible instead
of looking safe.

Periph's parsed attributes were spread after its title, and the title attribute
is not reserved, so a periph line carrying that attribute silently lost its
title.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f
Comment-hygiene sweep of the clipboard/tokenizer work: rewrite backward-facing
comments (dated live-repro narration, "used to"/"previously" change
narration, personal attribution, "this branch" framing) added by this branch
so they read as durable, forward-facing documentation instead of PR
provenance. No logic, assertions, or names changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCjocx9uZcPqB2esPh9r6f
`UnknownNode.isSelected` answered purely on child membership, which a
`NodeSelection` never satisfies — it marks a node by its own key. A
construct selected outright therefore reported unselected, and
`$appendNodesToJSON` hoisted its (also unselected) children, copying
nothing for it. Fall back to the base key-membership answer for a node
selection while keeping the child-membership rule that makes the two
clipboard carriers agree at a range boundary.

`ContextMenuPlugin` is exported on its own but relied on
`ClipboardPlugin` to register the empty-copy guard, so a host mounting
it alone got the placeholder-overwrites-clipboard bug the guard exists
to prevent. Register the guard from both plugins; a duplicate
registration at the same priority is inert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ention gaps

The `\c`/`\id` strip ran to the next backslash or newline, so a paste of
`x \c 5 y` landed as `x ` — content past the chapter number was destroyed
with no feedback. The tokenizer reads only the next whitespace-delimited
word as the number (getNextWord, usfmFragmentToUsj.ts), so removing more
than that was never required to prevent the second-ChapterNode poisoning.
Split into CHAPTER_TOKEN (marker + separator + one word, stopping at a
backslash) and BOOK_ID_TOKEN, which keeps the wider rule because an `\id`
line's whole remainder genuinely is payload. The pin that had encoded the
loss now asserts the content survives.

`Editor.tsx`'s structureProtectionMode pass-through had no coverage:
Editor and MarkerEditPlugin both default the prop to "off", so deleting
the line left all 4,234 tests green while silently starving
StructureKeyboardPlugin's sanitizer on a protected document's paste.
Threaded the option through mountStandardViewEditor and pinned it against
a real Editor mount.

Also from the review:

- Reuse shared-react's $hasCopyableSelection in $handleCopyForStandardView
  instead of respelling its negation. The two layers must agree on what
  "nothing to copy" means, including the node-selection carve-out, and one
  shared predicate is what keeps them agreeing.
- Derive the four new marker regexes from ENGINE_MARKER_NAME_BYTES. Two
  spellings had already diverged — `[a-z0-9-]i` excludes `_`, so
  `\my_marker` was a marker to the strip and not to the NBSP rules, and its
  separator NBSP survived into content as a literal `~`.
- Collapse isParaKindMarker/isCharKindMarker onto one body. The module
  exists so the two tiers cannot disagree; two copies of the same five
  lines is how they would come to.
- Drop the `$` sigil from normalizePastedNbsp and stripPastedChapterAndBookId.
  Both are pure string functions with no editor-context requirement to
  document, which CLAUDE.md requires of every exported `$`-prefixed one.
- Strip spec-section IDs and provenance narration from comments, per
  CLAUDE.md's rule that code must stand on its own.
- Move the clipboard semantics doc out of docs/superpowers, which
  .gitignore:67 ignores — it was tracked only by a force-add, so edits to
  the durable contract would never have shown in git status. Fix its dead
  cross-reference and the stale "not yet run" row for the e2e spec.
- Pin the Lexical version $selectionToUsfmText mirrors its walk from.

New pins were each verified by reverting the fix and confirming they go
red; the suite is green at shared 697, shared-react 1674, platform-editor
1864, with typecheck, lint and format clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This repository commits each published package's rolled-up build output,
which the eten-tech-foundation repository does not, so the dist inherited
by rebasing onto this main predates every commit on this branch.
Rebuilt with `npm run rebuild-committed-output`.

Also record the `EditorRef.copy()`/`cut()` behavior change in the
changelog. The signatures are unchanged, so a consumer upgrading gets no
compile-time signal that an empty-selection copy no longer writes to the
clipboard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tjcouch-sil and others added 4 commits September 16, 2026 19:00
…undaries

In Standard view a read-only construct's display bytes — a figure's
`\fig ` opener, its `|src="…"` attribute run, its `\fig*` closer — are
ImmutableTypedTextNode decorators whose glyph text is an ordinary DOM text
child of the decorator's own element. Lexical 0.43's
$internalResolveSelectionPoint resolves a DOM point inside a decorator's
element to null (the walk up from the text reaches the decorator, which is
not a TextNode), $internalResolveSelectionPoints then nulls the WHOLE
selection, and on commit updateDOMSelection calls removeAllRanges()
because the previous selection was inside the editor. Measured effects: a
mouse drag begun on the `\fig ` glyph left the editor with no selection
at all, so Ctrl+C reached only the empty-copy guard and the clipboard was
never written; a drag from the caption into the attribute run stopped
dead at the caption's end.

DecoratorBoundarySelectionPlugin (shared-react, mounted unconditionally)
handles SELECTION_CHANGE_COMMAND at CRITICAL — Lexical dispatches it even
for a selection it resolved to null, while the DOM selection is still
intact — and moves each offending end to the decorator's boundary in its
parent, the one form Lexical does resolve. A range grows outward so the
glyph is wholly in or wholly out; a caret takes the nearer side. The
snapped DOM points are turned into a Lexical selection in the same update
via $createRangeSelectionFromDom, because moving the DOM selection alone
would still leave a null pending selection for the reconciler to clear.

It only acts when $getSelection() is null (a resolved selection cannot
have an end inside a decorator, and reading the DOM over one would
clobber a programmatically placed caret), and it steps around the note
caller: TrailingNoteCaretGuardPlugin recognizes a landing on a collapsed
note's caller precisely by the selection being unresolved, and supplying
one there disabled that repair and left the caret in the note's hidden
body (measured, pinned).

Pins: six platform tests against a real `\fig` document (five were red
before the plugin), two shared-react guard-rail tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ments, keep paste rules under protection

Three hand-QA findings against Standard-view copy/paste, all rooted in
which clipboard carrier a consumer reads.

Paratext 9 paste is HTML-FIRST (IHtmlEditorCopyPaste.cs: the CF_HTML
fragment is pasteHTML'd whenever present, and Standard-Reverse.xslt reads
the fragment's text() as USFM). Standard view's text/html was Lexical's
DOM export, which is lossy in two independent ways:
ImmutableNoteCallerNode.exportDOM carries a collapsed note's caller only
as a data-caller attribute, and UnknownNode.exportDOM returns
{element: null}, so figures, sidebars, periphs, refs and optbreaks are
absent from the html altogether. A P10→P9 paste of
`\x - \xo 1:26: \xo*\xt 1Cor 11:7\xt*\x*` therefore arrived as `\x \xo …`
with no caller and rendered nothing. text/html is now the same USFM bytes
as text/plain (usfmToClipboardHtml: HTML-escaped, one
`<p><span style="white-space: pre-wrap;">…</span></p>` per line — the
block shape Lexical's own TextNode export produces, so this editor's own
html re-import path still sees one block per line). invertDisplayNbspInHtml
is gone with the DOM export it normalized. The internal
application/x-lexical-editor flavor is unchanged.

P9's own text/plain reduces a note to the caller glyph it displays (`a`,
or `*` for a `-` caller); the note's bytes ride the html as an escaped
`<!--usfm:…-->` comment inside a span P9 marks `exclude`. getPastePayload
preferred text/plain, so a P9→P10 footnote paste inserted a literal `a`.
paratext9HtmlToUsfm ports Standard-Reverse.xslt over the DOM (text,
usfm: comments with %XXXX unescaping, exclude/include modes, div/tr/br
newlines, span.attribute, usfmopen/usfmclosed with the nested `+`) and
wins over text/plain whenever the html carries P9's signature — a usfm:
comment, or a usfm_<name> class together with usfmopen/usfmclosed, which
no word-processor, browser or P10 html carries.

Under structure protection (Simple interface mode) $handlePasteForStandardView
declined every paste to StructureKeyboardPlugin's html sanitizer. That
carrier could not express a figure (the loss the hand QA saw: the same
clipboard pastes a `\fig` in Power mode and nothing in Simple mode), it
skipped stripPastedChapterAndBookId (a protected paste of `\c 7` created a
second chapter node, the save-loop poisoning the strip exists to prevent),
it skipped the positional NBSP rule, and it would have ignored the P9
decode entirely. The marker engine has no protection gate — a `\v` or `\p`
byte tokenizes into a real marker in either mode, typed or pasted — so the
sanitizer never protected Standard view against marker bytes, only against
verse/para NODES in a DOM import that Standard view's html no longer
produces. The claim now handles a protected document with the same bytes
as an unprotected one, except that it declines a selection
$shouldBlockSelectionReplacement refuses (so that refusal keeps one owner)
and never replays paragraphs: a multi-line payload joins with single
spaces, the sanitizer's own convention for a removed paragraph boundary.
Exposes structureKeyboard.utils from shared-react's index for that
predicate; shared-react is private, so nothing new reaches the published
surface.

Pins: paratext9Clipboard.utils.test.ts (18), the html/plain parity
describe in clipboardCopyFidelity, the P9→P10 paste describe in
markerPasteFidelity, protectedFigurePaste.test.tsx (12 — the same-editor
copy builds the figure under protection, `\c`/`\id` are stripped in both
modes, the P9 comment decodes under protection, a legacy DOM-export html
inserts prose and no figure), and the structure-protection describe in
whitespaceDisplay.plugin.utils.test.tsx, whose Editor→plugin wiring pin
stays falsifiable as a mode difference (a two-line paste yields one
paragraph protected, two unprotected). Semantics doc and changelog updated
to state the new contracts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebuilt with `pnpm rebuild-committed-output` after the clipboard and
selection changes; the type surface is unchanged (no d.ts or api.md
delta).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…to a decorator

Rewriting the DOM selection to the decorator's boundary was the part a real
browser rejected. Chromium extends a mouse drag only from a base it placed
itself: when a press lands in a `\fig ` glyph, the browser's own caret sits
in the glyph's text, and replacing it with the scripted element point beside
the decorator meant every subsequent mouse move re-placed a collapsed caret
under the pointer — no range ever formed, and with a caret Ctrl+C fires no
copy event at all. (Chromium also clamps a drag begun inside a
`contenteditable="false"` island to that island, so a drag from the glyph
selects at most the whole figure; that is the browser's rule, not ours.)

The snap now builds the Lexical selection from the snapped points directly
(a selection-shaped object handed to $createRangeSelectionFromDom, which
reads only the four anchor/focus fields) and, while a pointer button is
down, marks it clean so the commit leaves the browser's DOM selection
exactly where the browser put it. Raw interior DOM points are confined to
the drag: on pointerup/pointercancel the snapped selection is marked dirty
in an update whose event type Lexical does not re-derive from the DOM, so
the boundary form is written out through the ordinary reconciler path; a
snap with no button down (Shift+Arrow, programmatic) writes immediately.
Without that materialization the next event-less editor.update() would
re-read the interior points, resolve null, and its commit's
removeAllRanges() would take the visible selection with it.

SKIP_DOM_SELECTION_TAG was not usable for the drag case: a selection-only
repair dirties no nodes, so the tag survives into the next commit and
suppresses the DOM write for an unrelated caret move (measured).

Pins: the DOM points stay untouched while the button is down, are
materialized on release, are written immediately with no button down, and
the selection survives an event-less update afterwards; the copy pins now
dispatch a real `copy` event so the update clones the state selection the
way a browser copy does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tjcouch-sil and others added 3 commits September 17, 2026 08:43
Rebuilt with `pnpm rebuild-committed-output` after the selection-snap rework;
the type surface is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rough a read-only construct

The `application/x-lexical-editor` flavor exists to carry a construct whole.
Lexical cannot slice the token-mode text a read-only construct is built from
($sliceSelectedTextNodeContent skips token and segmented nodes), so a
selection ending partway through a figure's caption wrote a COMPLETE figure
into that flavor — full caption, every attribute — while text/plain and
text/html carried exactly the selected bytes. A paste that reads the internal
flavor (a native paste event; the keyboard, context-menu and EditorRef paste
paths rebuild the payload from navigator.clipboard.read(), which never
exposes it) therefore inserted a second whole figure instead of the four
characters selected, and a selection running from prose into a caption
invented the rest of the caption and the figure's attributes.

$getStandardViewClipboardData now writes only the two text flavors when
either end of the selection lies inside an opaque construct
($selectionReachesIntoOpaqueBlock); a selection with both ends outside still
ships the internal flavor and round-trips the construct intact. One recorded
narrowing: a selection whose end sits at a single-child `ref`'s own end is
inside the construct, so that copy now carries the ref's text without its
wrapper and `loc` — not a shape a browser drag produces while the construct
renders contenteditable=false.

Pins: partialConstructClipboard.test.tsx (7; four were red before the
change, the text-only paste shape and the whole-construct round trip were
green before and after); three boundary pins in unknownClipboardFidelity
restated. Semantics doc S2 and the changelog state the rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Rebuilt with `pnpm rebuild-committed-output` after the cut-through clipboard
flavor change; the type surface is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tjcouch-sil and others added 4 commits September 17, 2026 14:34
A foreign Lexical editor's clipboard was treated as internal. `isInternal`
answered the mere presence of `application/x-lexical-editor`, but Lexical's own
fast path is namespace-scoped, so a payload from the app's `"Commenting"` editor
fell through to the `text/html` DOM import — a path that runs neither the
`\c`/`\id` strip nor the positional NBSP rule. `getPastePayload` now mirrors
`$insertDataTransferForRichText`'s actual condition.

The char-stack line replay ignored structure protection. It is registered after
the Standard-view claim at the same priority and before `StructureKeyboardPlugin`
mounts, so the one paste that handler stands aside from reached it and was
replayed with `removeText()` plus a paragraph split per line — deleting the verse
marker protection had just refused to let a paste replace.

`LEADING_MARKER_LITERAL` accepted a closer, inherited from the shared terminated-
marker shape. A leading `\zbold*` therefore read as the paragraph marker `zbold`
(unknown markers are paragraphs), so the host paragraph's own glyph was dropped:
an `\s1` heading came back as a plain `\p`.

A copy that produced no bytes still overwrote the clipboard with an empty string.
The event is now claimed but nothing is written, which is what the collapsed-
selection leg already did.

A CR inside a decoded Paratext 9 note survived `usfmFromComment` and became a
hard line break, splitting the note across two paragraphs.

Also: pointer tracking in `DecoratorBoundarySelectionPlugin` listened for the
press on the root while the release was on the document, so a press landing just
outside the contenteditable root started a drag the plugin never saw; and a
secondary-button press could leave the flag stuck when the native context menu
swallowed the `pointerup`.

Tests: the two internal-paste pins built a `"{}"` payload that was never
same-namespace, so they asserted the old presence-only rule; the optbreak
carrier-agreement pin asserted `not.toContain` against a flavor that is always
empty for that selection, and `UnknownNode.isSelected`'s range branch — the whole
reason the override exists — had no pin anywhere; a mixed-selection pin asserted
only that the document survived, never that the paste landed; and the accepted
Paratext 9 leading-space parity was parked in `KNOWN_LOSSY`, where it was skipped
and protected nothing. Each is now falsifiable, verified by reverting the code it
pins. Two pre-existing `possibly undefined` type errors in the protected-paste
suite are fixed with one `ParaContent` alias.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fused

`\periph` has no closing marker, so the extent of a peripheral division is a
property of the marker itself and not of its attributes. Refusing the marker
line's attribute list was demoting the whole division to an ordinary paragraph,
which re-parented every block it contained up to the root.

Paratext 9 reads it the other way round: a failed `SetAttributes` leaves the
marker-line text alone (`ParatextData/UsfmToken.cs`) and the USX writer still
opens `<periph>` for any `\periph` token, with an empty `id`
(`ParatextData/UsxUsfmParserSink.cs`). The refused bytes now survive as the
division's own title instead, where the author can still see and fix them, and
they serialize back byte-for-byte because `alt` renders as literal marker content
rather than a pipe pair.

This also removes a live-typing glitch: Standard view re-tokenizes a marker line
as it is typed, so typing `|id="cover"` onto a periph line used to un-nest every
block under it on the `|` keystroke and re-nest them several keystrokes later.

Splitting the marker line at its FIRST `|` is unchanged — that matches P9's own
`text.IndexOf('|')`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…marker

Pasting a whole-paragraph copy at an existing paragraph's content start was
deleting the HOST paragraph's marker. A whole-paragraph copy carries its own
`\p ` literal, so the line ends up with two paragraph markers, and
`$withoutRedundantOwnPrefix` resolved that by dropping the host's glyph — an
`\s1` heading pasted into became a `\p`, and content that belonged to the host
ended up under the pasted marker. Nobody asked for that: a paste inserts what was
pasted, and if the result is two paragraph markers in a row, that is what the
bytes say. Paratext 9 reads them the same way — `NormalizeTokenUsfm`
(ParatextData/UsfmToken.cs) emits a line break before every Paragraph token — and
so does typing the same bytes here, so the split-with-empty-predecessor shape is
now universal and paste has no rule of its own.

Removing it takes the whole paste-provenance apparatus with it: the
`isPasteRebuild` parameter through `$buildParaFragment`/`$rebuildParas`/
`$requestTier2ForNode`, `Tier2Context.pasteRebuildArmed`, the
`pastePendedKeys` set that carried "this rebuild came from a paste" across a
deferred settle, its per-commit pruning, consume-on-use, and the arming callback.
The read-only settle and the mutating rebuild now call `$buildParaFragment` with
the same arguments, so the two can no longer disagree about a deferred paste's
shape — a divergence that let `EditorRef.getUsj()` predict a paragraph the settle
would not produce.

That exposed a real defect the dedup had been masking. A multi-line paste replays
each line break as a paragraph split, and the engine injects a marker prefix onto
every fresh split paragraph so it is not read as marker-deleted and merged back —
including onto lines that already carry their own marker. For a marker with a
separator and content the injected prefix is resolved away; for `\b`, which has
neither, both survived and every blank line in a pasted document gained an empty
`\p` in front of it (16 of them in the 2sa fixture). The split branch now skips
the injection when the paragraph's own text already opens with a paragraph-marker
literal. Enter pressed directly in front of a marker literal is the same shape and
gets the same answer.

Tests: the round-trip suites seeded an empty `\p` as the paste's insertion host
and expected the paste to swallow it; they now assert the host is there and strip
it, so a genuine stray paragraph can no longer pass as the harness's own. The two
pins that covered the dedup are rewritten to cover what replaced it — a pasted
marker literal splitting rather than retagging, and a deferred settle producing
the identical document for pasted and typed bytes — plus a new pin that a
multi-line paste invents no marker for a line that already has one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The kind-rule module's doc comments still named Tier 2's own-marker-prefix
dedup as the second user of `isParaKindMarker`; that caller is now
`$suppliesOwnParaMarker` in the paragraph-prefix transform, and the leaf-module
rationale is restated to match. `$pendDeferredRebuild` only existed to carry the
paste provenance, so its two call sites go back to the inline
`pendingKeys.add` every other pend in the file uses. Also removes a stray blank
line from `$buildParaFragment`'s doc comment. Committed platform dist rebuilt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@lyonsil lyonsil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the copy/paste USFM fidelity work on this branch — the Standard-view clipboard path, the
paste normalization passes, the Paratext 9 decoder, the marker-edit engine's paragraph handling, and
the new decorator-boundary selection plugin. 25 findings, most of them in
whitespaceDisplay.plugin.utils.ts and MarkerEditPlugin.tsx.

Each comment leads with a severity and a short status. The severity is the one settled on after the
finding was checked against the code, not a first impression. checked and confirmed means the
behaviour was reproduced or traced to its cause in this branch — where a comment says something was
run, it was run. Every comment ends with a concrete Fix:; where a fix could not be stated as a
verified edit, it is stated instead as the constraint any fix has to satisfy, and several name an
accompanying test, doc, or changelog update the change would need.

Findings that could not be attached to a line in this diff

#4 (high · checked and confirmed) libs/shared/src/nodes/features/UnknownNode.ts:253 - In Formatted and Paragraph-structure views, copying part of a figure's caption puts the entire figure on the clipboard.

What happens: the over-carry is mitigated only inside $getStandardViewClipboardData (whitespaceDisplay.plugin.utils.ts:836, the $selectionReachesIntoOpaqueBlock omission), and $handleCopyForStandardView is registered only when isStandardView (MarkerEditPlugin.tsx:872). In hidden-marker views Lexical's own copy runs, so $appendNodesToJSON decides everything: the new excludeFromCopy at :253 answers false for any child-bearing construct and the new isSelected at :288 answers true when the caption child is in getNodes(). createUnknown stamps mode: "token" on every text child and $sliceSelectedTextNodeContent refuses to slice those, so the whole construct is serialized.

Why it matters: the hazard the CHANGELOG describes for Standard view — a native paste inserts a second figure and a save persists it — is now reachable in the other editable views.

Fix: do not implement this by changing UnknownNode.excludeFromCopy/isSelected — Lexical hoists an excluded wrapper's children, and the caption child is mode: "token" and unsliceable, so that would emit the caption's full text as loose prose with no wrapper or attributes. Instead add a view-agnostic guard beside $selectionReachesIntoOpaqueBlock in libs/shared-react/src/plugins/usj/OpaqueBlockGuardPlugin.tsx (or wired into libs/shared-react/src/plugins/usj/clipboard.utils.ts) that runs on COPY_COMMAND/CUT_COMMAND in every editable view and, when $selectionReachesIntoOpaqueBlock() is true, keeps application/x-lexical-editor off the clipboard for that copy — mirroring $getStandardViewClipboardData — leaving Lexical's text/plain as the fallback. Pin a Formatted-view partial-caption copy in unknownClipboardFidelity.test.tsx, update docs/clipboard-semantics.md and packages/platform/CHANGELOG.md since this changes clipboard behaviour outside Standard view, and rebuild the committed packages/platform/dist.

How this was checked: The premise holds. MarkerEditPlugin.tsx:564 (if (!isEnabled || !viewOptions) return;) early-returns the whole registration effect for Formatted and Paragraph-structure views, both markerMode: "hidden" (view-options.utils.ts:212-236), so the COPY/CUT handlers at :872-895 never mount there, and OpaqueBlockGuardPlugin explicitly leaves copying untouched. Lexical's stock COPY handler then runs $appendNodesToJSON, which calls excludeFromCopy('html') — never 'clone' — returning false for a child-bearing figure at UnknownNode.ts:253-254, while isSelected at :288-298 returns true because the partially selected caption TextNode is in getNodes(). $sliceSelectedTextNodeContent refuses to slice a token-mode TextNode (LexicalSelection.dev.mjs:257), and createUnknown (usj-editor.adaptor.ts:850-852) stamps mode: "token" on every child regardless of markerMode. So the whole figure, with the caption's full untouched text, serializes into application/x-lexical-editor from a selection covering only part of the caption.

Not attached to a line: no longer on a changed line; cannot be posted inline

#5 (high · checked and confirmed) packages/platform/src/editor/markerEdit/MarkerEditPlugin.tsx:365 - Pasting a \c-bearing multi-line payload into an expanded note deletes the note's selected content and inserts nothing.

What happens: :365 tests the UNSTRIPPED payload.text for a newline, and only then does :378 compute normalizePastedNbsp(stripPastedChapterAndBookId(pastedText)) — and the strip drops whole lines (whitespaceDisplay.plugin.utils.ts:344). For "\\c 5\n\\id MAT" both lines go, leaving lines = [""]. $handlePasteLinesInNote (markerEditNote.utils.ts:113) removes the selection via $removeSelectionTouchingExpandedNote, inserts "", and returns "handled", so :390 calls preventDefault() and the CRITICAL claim consumes the command.

Why it matters: content is deleted with nothing pasted. The single-line variant is wrong too: "\\c 5\nfootnote" collapses to one line but still takes the \fp path and its stripLeadingParagraphMarker, which the genuine single-line path never applies.

Fix: decide emptiness before any selection mutation — $removeSelectionTouchingExpandedNote's selection.removeText() runs as a side effect of merely checking whether the paste applies, so declining afterwards cannot undo it. In the in-note CRITICAL claim, when isStandardView, compute const noteText = pastedText.includes("\n") ? normalizePastedNbsp(stripPastedChapterAndBookId(pastedText)) : pastedText; up front; if it is empty, call event?.preventDefault() and return true before calling $handlePasteLinesInNote, leaving the selection untouched. Do not copy the order at $handlePasteForStandardView:576 — it carries the same defect. Re-run the newline test on noteText rather than pastedText, so "\c 5\nfootnote" collapses to the ordinary single-line path instead of $handlePasteLinesInNote's stripLeadingParagraphMarker. Pin both shapes in noteEnterFp.test.tsx. Rebuild the committed packages/platform/dist (node scripts/rebuild-committed-output.mjs).

How this was checked: Confirmed empirically. In the in-note CRITICAL PASTE_COMMAND claim, the newline test at MarkerEditPlugin.tsx:365 runs on the unstripped pastedText; the strip happens only afterward at :378-379. For "\c 5\n\id MAT", stripPastedChapterAndBookId drops both lines, so lines is [""]. $handlePasteLinesInNote (markerEditNote.utils.ts:113) first calls $removeSelectionTouchingExpandedNote, which unconditionally does selection.removeText() before it knows the pasted content is empty; $insertLineTextAtCaret("") is then a no-op and the claim returns "handled", so preventDefault() fires at :395 and the command is consumed. Verified with the renderStandardEditorWithUnclosedNote fixture: selecting "no" out of a note's "A note" content and pasting "\c 5\n\id MAT" left the note as "A te" — the selection deleted, nothing inserted. The single-line variant also diverges as described: "\c 5\nfootnote" still enters the multi-line branch and gets stripLeadingParagraphMarker, which a genuine single-line paste of "footnote" never receives.

Not attached to a line: no longer on a changed line; cannot be posted inline

#10 (high · checked and confirmed) packages/platform/src/editor/markerEdit/whitespaceDisplay.plugin.utils.ts:902 - A copy with nothing to put on the clipboard still wipes the clipboard when it arrives as a real browser copy event.

What happens: the null-dispatch branch at :893 skips copyToClipboard entirely for an empty payload, so the clipboard is untouched. The native-event branch does not: :902 cancels the browser's own copy unconditionally and :903 then writes nothing. A canceled copy event still commits its DataTransfer, and Blink's SystemClipboard::WriteDataObject replaces the whole clipboard — so an empty object clears it.

Why it matters: the comment at :880 promises "leaving whatever the user copied last intact". It is reachable from any host path that delivers a real ClipboardEvent — an Electron Edit-menu Copy, or a host that does not route through ClipboardPlugin's keydown.

Fix: in $handleCopyForStandardView, restructure the native-event leg so event.preventDefault() and the setData loop both sit inside the !isEmptyPayload branch, leaving preventDefault() uncalled when the payload is empty so the browser's own no-op-on-nothing-to-copy default runs — mirroring the collapsed-caret branch at :872-877, which already declines without calling it. Pin both dispatch legs in the empty-copy describe of whitespaceDisplay.plugin.utils.test.tsx: the null-event leg, and a real ClipboardEvent-shaped stub with a spyable preventDefault, asserting it is NOT called on the native leg when the payload is empty. Add a line to the empty-copy rule in docs/clipboard-semantics.md covering a real native ClipboardEvent reaching a non-collapsed, zero-byte selection, not only the collapsed-caret case already documented. Rebuild the committed packages/platform/dist (node scripts/rebuild-committed-output.mjs).

How this was checked: Confirmed the asymmetry directly: git diff against the merge base shows this PR added the isEmptyPayload guard around the null-dispatch leg's copyToClipboard call (:893) and around the native-event leg's setData loop (:903-904), but left the native leg's event.preventDefault() at :902 unconditional, exactly as it was pre-PR. The native-event leg is genuinely reachable: Lexical's core attaches a real copy DOM listener to the editor root and dispatches COPY_COMMAND with the actual ClipboardEvent, and MarkerEditPlugin.tsx:879-891 forwards it unchanged into $handleCopyForStandardView at COMMAND_PRIORITY_HIGH, above Lexical's own fallback — so any host path delivering a genuine native copy event, an Electron or OS Edit-menu Copy being the standard example, reaches this branch. Within this repo's own UI, ClipboardPlugin and ContextMenuPlugin only dispatch via copySelection/cutSelection, which pass null (clipboard.utils.ts:60,66). The sibling collapsed-caret branch a few lines above (:872-877) establishes the intended precedent: for a real ClipboardEvent with nothing to copy it returns false without calling preventDefault, precisely so the browser writes nothing — docs/clipboard-semantics.md:27 states this outright. The empty-payload branch breaks that pattern.

Not attached to a line: no longer on a changed line; cannot be posted inline

#27 (low · checked and confirmed) packages/platform/src/editor/markerEdit/whitespaceDisplay.plugin.utils.ts:199 - One Ctrl+V parses the pasted HTML six times before a byte is inserted.

What happens: getPastePayload unconditionally computes htmlText at :201 (a DOMParser.parseFromString plus three querySelectorAll sweeps) and paratext9Text at :221 (a second parse, a comment TreeWalker, and a querySelectorAll("*") over every element) before isInternal or the selection is consulted. Three handlers each call it for the same event: $handlePasteForStandardView (:548), the in-note CRITICAL claim (MarkerEditPlugin.tsx:362) and the char-stack claim (MarkerEditPlugin.tsx:433). htmlText is only ever read by plainText || htmlText, which essentially no real clipboard reaches.

Why it matters: pasting a Word, Outlook or browser payload — hundreds of KB is routine — blocks the main thread inside editor.update() for six full parses. The base commit did one.

Fix: gate paratext9HtmlToUsfm behind html.includes("usfm:") || html.includes("usfmopen") || html.includes("usfmclosed") — every branch of hasParatext9Signature requires one of those literal byte runs, so this cannot silently disable the P9 decode. Make the local htmlText in getPastePayload (:201) compute on demand inside the plainText || htmlText fallback; it is not a PastePayload field, so no consumer is affected. For the repeat calls, prefer computing the payload once in the shared caller over a module-level cache: Lexical's triggerCommandListeners hands every priority tier the same payload reference for one dispatchCommand, so no cache is needed. If a WeakMap is used anyway, guard the key — read and write it only when event !== null && typeof event === "object", because WeakMap throws on a null key and this function's signature admits null. Add a call-count assertion (spy on DOMParser.prototype.parseFromString) in whitespaceDisplay.plugin.utils.test.tsx proving the protected + char-stack path parses once per event, and rebuild the committed packages/platform/dist.

Not attached to a line: no longer on a changed line; cannot be posted inline

(AI-assisted, with my guidance)

// confined to one commit: `$commitPendingUpdates` only empties `editor._updateTags` when the update
// dirtied nodes, and a selection repair dirties none — so the tag would survive into the next
// commit and swallow the DOM write for the caret move after it.
selection.dirty = !isPointerDown;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#3 - high · checked and confirmed

A drag that starts on a read-only glyph dies mid-drag whenever another selection-change listener touches a node in the same commit.

What happens: $commitPendingUpdates (installed lexical@0.43.0, Lexical.dev.mjs:8497) sets needsUpdate = editor._dirtyType !== NO_DIRTY_NODES — true whenever any node was dirtied this commit — and the DOM-selection write at :8588 (if (needsUpdate || pendingSelection === null || pendingSelection.dirty)) then runs regardless of pendingSelection.dirty === false. onSelectionChange dispatches SELECTION_CHANGE_COMMAND from inside that same update, and every priority runs inline in the one commit. $syncCaretHost (transientCaretHost.ts, reached from the unconditionally mounted EmptyVerseCaretGuardPlugin/TrailingNoteCaretGuardPlugin, Editor.tsx:1127/1164) evicts a stale placeholder host and dirties a node in that same commit, so selection.dirty = !isPointerDown at :184 is ignored and the scripted element point is written over Chromium's drag base.

Why it matters: the plugin's own doc at :216 says writing that base "stops the drag dead" — no range forms and Ctrl+C reaches the empty-copy guard. It fails intermittently, keyed on whether a caret host happened to be present.

Fix: do not move the snap into a nested editor.update() — a nested call from inside a command handler is queued to a later, separate commit, so the current commit still has pendingSelection === null and updateDOMSelection calls domSelection.removeAllRanges(), destroying the drag base on every unresolved arrival and failing the existing button-down pin. Keep $setSelection(selection) and selection.dirty = !isPointerDown synchronous exactly as today (:170-185), and additionally call $addUpdateTag(SKIP_DOM_SELECTION_TAG) (from "lexical"; $addUpdateTag is already used in transientCaretHost.ts, so the import is proven reachable) in the same CRITICAL handler whenever isPointerDown is true — the outer guard that reads it is not gated by needsUpdate. Because editor._updateTags is auto-cleared only when the commit dirties a node, register an update listener that removes just that tag on the next commit whose tags carry it, so it cannot swallow the DOM write for a later, unrelated selection change; that field is private, so say why at the site. Pin a drag with a stale caret host armed, plus a follow-up selection change proving the tag did not leak, in decoratorBoundarySelection.test.tsx. Rebuild the committed packages/platform/dist (node scripts/rebuild-committed-output.mjs).

How this was checked: Read the installed Lexical source directly. $commitPendingUpdates (Lexical.dev.mjs:8497) sets needsUpdate = editor._dirtyType !== NO_DIRTY_NODES, and the DOM-selection write at :8588 calls updateDOMSelection when needsUpdate is true regardless of pendingSelection.dirty === false. onSelectionChange (:2190/:2288) dispatches SELECTION_CHANGE_COMMAND from inside the same updateEditorSync, and triggerCommandListeners runs every priority inline in that one active update, so all listeners genuinely share one commit. Of the two coincident listeners, NoteNodePlugin.tsx:151 bails at its own gate (viewOptions?.noteMode !== "expandInline", :307) and no view mode ever sets that value, so it is dead code today and does not contribute. $syncCaretHost (transientCaretHost.ts, used by the unconditionally mounted EmptyVerseCaretGuardPlugin/TrailingNoteCaretGuardPlugin, Editor.tsx:1127/1164) is live, and the failure reproduces: resting the caret in an empty verse so a placeholder host is planted, holding the pointer down, then collapsing the DOM caret inside a figure's \fig glyph, the committed selection came back dirty: false — the plugin correctly detected the drag — yet the DOM's anchorNode was rewritten from the raw glyph text node to the figure's own element at offset 0, and the placeholder host was gone from the tree in that same commit.

Other findings in this file: #21

isMaterializePending.current = false;
// Re-dirty the snapped selection so Lexical's ordinary reconciler pass writes the boundary form
// into the DOM, now that no drag needs the browser's own base any more.
editor.update(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#21 - low · checked and confirmed

The pointer-release handler opens an editor.update() from inside a DOM event listener, which this repo's CLAUDE.md forbids.

What happens: CLAUDE.md (repo root, Code Style) states: "Don't call editor.update(...) from inside a listener (event handler, update listener, etc.) — nested updates risk infinite cascade loops. Lexical's command system (editor.registerCommand) is the canonical entry point for mutating state in response to user interactions." release is registered as a capture-phase document listener for pointerup and pointercancel at :300:302, and its body opens an update at :285.

Why it matters: there is precedent in the codebase (NoteNodePlugin.tsx:128, ContextMenuPlugin.tsx:251), so this is a convention violation rather than a live cascade risk. It is order-fragile: if the release lands while another update is in flight, Lexical queues the callback, window.event is no longer the pointerup the doc comment at :234 relies on, and the update can re-derive its selection from the DOM. The same pattern already exists at NoteNodePlugin.tsx:128 and ContextMenuPlugin.tsx:251, so this continues a mixed convention rather than inventing one — though ActiveTextPlugin.tsx:52 shows the team deliberately avoids the same trap elsewhere.

Fix: do not switch to editor.dispatchCommand on the grounds that it removes the window.event dependency — it does not: dispatchCommand resolves through triggerCommandListeners's updateEditorSync, which reaches the same $beginUpdate/$internalCreateSelection(editor, null) path, and therefore the same windowObj.event fallback, whenever this editor is not already the active one — the ordinary case here. Instead add a comment immediately above release at DecoratorBoundarySelectionPlugin.tsx:279 stating that this editor.update() is a deliberate, precedented exception to the CLAUDE.md rule (matching NoteNodePlugin.tsx:128 and ContextMenuPlugin.tsx:251), and name the constraint it depends on: it must run synchronously off a trusted pointerup/pointercancel dispatch with no update of this editor already active, because Lexical queues the call otherwise (the editor._updating gate in updateEditor) and the queued replay, drained via $processNestedUpdates, invokes the callback without calling $internalCreateSelection again — at which point window.event no longer reflects that pointerup.

How this was checked: CLAUDE.md's Code Style section (line 268) reads verbatim: "Don't call editor.update(...) from inside a listener (event handler, update listener, etc.) — nested updates risk infinite cascade loops. Lexical's command system (editor.registerCommand) is the canonical entry point for mutating state in response to user interactions." No exception is carved out. release (DecoratorBoundarySelectionPlugin.tsx:279-289) is registered as a capture-phase pointerup/pointercancel document listener at :300-302 and opens editor.update() at :285-288; the whole file is new in this PR. The class doc at :236-239 explains why the update must happen at this moment: inside a pointerup listener, Lexical's $internalCreateRangeSelection reads windowObj.event when no explicit event is passed, and pointerup is not one of the types that makes it re-derive the selection from the DOM, so the update clones the already-snapped selection instead of re-resolving interior points. That is load-bearing, not stylistic. The cited precedent checks out: NoteNodePlugin.tsx:163 wires a dblclick listener whose body calls editor.update(...) at :127-128, and ContextMenuPlugin.tsx:260 wires a capture-phase keydown whose Enter branch calls editor.update(...) at :251.

Other findings in this file: #3

// together. Ejecting there strands the construct's own text outside it: a figure's caption
// lands after the `\fig*` glyph as ordinary paragraph prose, and the exported USJ keeps a
// `figure` object with no content at all.
if (wasNodeCreated(editor, node.getKey()) && !wasNodeCreated(editor, unknownNode.getKey()))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#22 - low · checked and confirmed

Text planted inside a figure is left there instead of being ejected whenever the same commit also rebuilds that figure.

What happens: wasNodeCreated is !$getNodeByKey(key) read against the committed state (libs/shared-react/src/nodes/usj/node-react.utils.ts:359), so it is true for any node whose key is new in the running update. A Tier-2 rebuild replaces a paragraph's children with fresh keys, so in any commit that rebuilds the host paragraph the new !wasNodeCreated(unknownNode) conjunct at :195 is false and the ejection is disabled for that whole commit.

Why it matters: this transform is the backstop for edits OpaqueBlockGuardPlugin's selection-based refusals do not see — it keys on $getSelection(), which is stale for DROP and absent for collab. A stray TextNode planted by a drop, a collab op or a rich paste in such a commit stays inside the read-only construct and serializes into the figure's USJ content.

Fix: decide on the TEXT node's provenance rather than the wrapper's key age. Any replacement must distinguish "this text arrived with the wrapper the materializer just built" from "this text is new in a commit that happened to rebuild the wrapper", without reading key age. Extend the boundary pin in TextSpacingPlugin.test.tsx to a rebuild-plus-intrusion commit. Rebuild the committed packages/platform/dist (node scripts/rebuild-committed-output.mjs) so verify-committed-dist stays green.

// editable-marker view's own `text/html` is not a DOM export at all (Standard view renders the
// copy walker's USFM bytes — `usfmToClipboardHtml`, platform's whitespaceDisplay.plugin.utils.ts),
// and where Lexical's exporter DOES run, `$appendNodesToHTML` (`@lexical/html`) computes this same
// `excludeFromCopy('html')` value but returns early on `exportDOM()`'s unconditional

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#23 - low · checked and confirmed

In Formatted and Paragraph-structure views the two readable clipboard flavors still decode to different documents.

What happens: exportDOM (:182) still returns {element: null} unconditionally, and $appendNodesToHTML (@lexical/html) returns early on a null element before it ever consults excludeFromCopy — which the new comment at :216 itself states. The CHANGELOG's fidelity fix lands only through $getStandardViewClipboardData, registered solely under isStandardView (MarkerEditPlugin.tsx:872).

Why it matters: in the hidden-marker views the stock Lexical copy runs, so a copy spanning a figure ships text/html with the construct absent while text/plain (selection.getTextContent()) carries its display text. The "both readable flavors decode to one document" promise holds only in Standard view.

Fix: prefer documenting the existing scope over changing exportDOM. Add a line to the text/html entry in packages/platform/CHANGELOG.md and to the top of docs/clipboard-semantics.md stating that Formatted, Paragraph-structure and Unformatted copies still go through Lexical's native $generateHtmlFromNodes, which drops any UnknownNode/ImmutableNoteCallerNode subtree from text/html — unchanged from before this PR. If a functional fix is pursued instead, it must not stop at UnknownNode.exportDOM returning a DocumentFragment: StructureKeyboardPlugin's protected-mode $handlePaste imports text/html directly, so the change must also pin a protected-mode paste of a figure in StructureKeyboardPlugin.test.tsx, asserting the result is either the reconstructed figure or a decline — never loose caption text — and still update both documents.

Other findings in this file: #24

// boundary rather than a structural loss. That residual stands deliberately: closing it needs a
// lever Lexical does not offer — `exportNodeToJSON` requires every ElementNode's `exportJSON()`
// to return a `children` array, so a node cannot say "drop me AND my children".
override isSelected(selection?: BaseSelection | null): boolean {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#24 - low · checked and confirmed

UnknownNode.isSelected() reports a figure as selected when the caret is merely resting inside its caption with nothing selected.

What happens: super.isSelected is consulted only on the $isNodeSelection arm at :288, so for a RangeSelection the override at :298 replaces the base entirely. Lexical's base returns false for any element-type range that isCollapsed(); a collapsed caret inside a figure caption puts that caption in getNodes(), so figure.isSelected() returns true with nothing selected. Conversely a childless construct answers false for a range that fully contains it. The base's inline-decorator-at-parent-end carve-out is dropped as well.

Why it matters: isSelected is public API — $removeNode calls it with no argument, and host or plugin code asking for decoration state now gets a different meaning here than from every other node in the tree.

Fix: keep the $isNodeSelection gate on the base branch and add a collapsed-selection short-circuit before the child-membership test in UnknownNode.isSelected: after the existing $isNodeSelection(targetSelection) && super.isSelected(targetSelection) branch, if (targetSelection.isCollapsed()) return false;, then the existing getChildren().some(...) test. A NodeSelection.isCollapsed() is hard-coded false (Lexical.dev.mjs:5837-5839), so this cannot suppress the node-selection branch, and no extra narrowing is needed. Do NOT make super.isSelected unconditional — that turns the pin at UnknownNode.test.ts:461-482 red and does not fix the defect anyway, because the child-membership clause still matches. Verified: all 24 existing tests pass, the collapsed-caret probe now returns false, and a childless construct inside a non-collapsed range still returns false. Add a collapsed-caret-in-caption case to the isSelected() describe in UnknownNode.test.ts, and rebuild the committed packages/platform/dist.

Other findings in this file: #23

}
prevWasElement = false;
if ($isImmutableNoteCallerNode(node)) {
if (node !== lastNode || !selection.isCollapsed()) text += ` ${$noteCallerText(node)}`;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#19 - medium · checked and confirmed

A copy that starts at a collapsed note's caller begins with a stray space and a caller that has no opening marker.

What happens: the leading space at :735 stands for the mandatory separator after \f/\x, but the guard copied from Lexical's decorator branch only suppresses the trailing-boundary case (node !== lastNode). There is no node !== firstNode arm, and $getStandardViewClipboardData already refused a collapsed selection, so !selection.isCollapsed() is always true and the branch always emits. A selection anchored on the caller therefore produces " + \\fr 1:1 \\ft …\\f*".

Why it matters: the doc comment at :648 asserts "the sibling caller rule guards its own boundary the same way" — it guards only one end. Pasted back, the stray space lands in body content ahead of an unmatched \f*. The anchor is genuinely reachable: every node inside a collapsed note except the caller's own glyph is display: none, so a click at the left edge of the caller's rendered button lands exactly there, and neither NoteShellCaretGuardPlugin nor ArrowNavigationPlugin corrects it — both scope their shell-atomicity handling to expanded notes only.

Fix: at whitespaceDisplay.plugin.utils.ts:735, keep the caller's character and drop only the invented leading space when the opening marker is not part of the selection — the space and $noteCallerText(node) are one template string today, so they must be split rather than gated together: text += (node !== firstNode ? " " : "") + $noteCallerText(node); inside the existing node !== lastNode || !selection.isCollapsed() guard. Gating the whole expression on node !== firstNode would drop the caller character itself, losing selected content. Pin both shapes in clipboardCopyFidelity.test.tsx: a selection starting at the caller, which must carry no leading space, and the whole-note case, which must still produce the opening marker followed by its separator and caller. Add a line to docs/clipboard-semantics.md noting the caller's leading space is conditional on the opening marker being in the selection. Rebuild the committed packages/platform/dist (node scripts/rebuild-committed-output.mjs).

How this was checked: Reproduced directly: with the selection anchored as an element point right before the collapsed note's caller (whitespaceDisplay.plugin.utils.ts:735), excluding the note's opening \f marker, and the focus at the end of the note, the text/plain payload came back as " - \cat People\cat*\fr 1:1 \ft Caller test.\f*" — a leading space and the caller with no \f in front of it. That anchor is reachable: every node inside a collapsed note except the caller's own glyph is display: none (usj-nodes.css, .note.collapsed span[data-lexical-text="true"]), so a click at the left edge of the caller's rendered button lands exactly there, and neither NoteShellCaretGuardPlugin nor ArrowNavigationPlugin corrects it — both scope their shell-atomicity handling to getIsCollapsed() === false, expanded notes only (NoteShellCaretGuardPlugin.tsx:34, ArrowNavigationPlugin.tsx:568).

Other findings in this file: #1, #2, #9, #16, #17, #20, #28, #29, #30, #31

text += $isNoteInternalDisplaySeparator(node)
? ""
: nodeText.replaceAll(NBSP, " ") + $collapsedNoteCategoryBytes(node, node === lastNode);
} else if (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#20 - medium · checked and confirmed

A copy that includes the \id line puts a literal non-breaking space on the clipboard.

What happens: createBook (packages/platform/src/editor/adaptors/usj-editor.adaptor.ts:309) builds the book line in markerMode: "editable" as createImmutableTypedText("marker", openingMarkerText(marker) + " " + code + NBSP), and ImmutableTypedTextNode extends DecoratorNode. The committed fixture confirms the byte: libs/test-data/src/data/2sa.lexical.editable.ts:22 is "\\id 2SA\u00A0". The decorator branch at :760 does text += node.getTextContent() with no inversion, while the sibling $isTextNode branch at :755 does nodeText.replaceAll(NBSP, " ").

Why it matters: it contradicts the documented rule that no NBSP reaches either readable flavor; pasting into Paratext 9 or a text editor delivers a non-breaking space where the marker's structural separator belongs, and USFM's byte form for a data NBSP is ~. It is exactly this narrow: a sweep of every other createImmutableTypedText site found the editable chapter and verse glyphs are TextNodes that already invert correctly, and every raw-NBSP decorator branch is gated to non-Standard-view modes — createBook alone pushes its decorator for editable mode too.

Fix: in whitespaceDisplay.plugin.utils.ts, change the decorator/linebreak branch at :756-761 to text += node.getTextContent().replaceAll(NBSP, " ");NBSP is already imported in this file. Add the missing calling-context line to $selectionToUsfmText's TSDoc, which CLAUDE.md requires of every exported $-prefixed function and which this new function lacks: it is read-only, so say it is safe from editor.getEditorState().read(...) or inside a command handler. Pin a copy spanning a book's \id line in clipboardCopyFidelity.test.tsxtest-data's usj2Sa already carries the byte — asserting the text/plain payload has a plain space after the book code. Add an [Unreleased]### Fixed entry to packages/platform/CHANGELOG.md, and a bullet to docs/clipboard-semantics.md naming this case and its pin, matching that file's existing per-case bullets. Rebuild the committed packages/platform/dist (node scripts/rebuild-committed-output.mjs).

How this was checked: The $isDecoratorNode/$isLineBreakNode branch of $selectionToUsfmText (whitespaceDisplay.plugin.utils.ts:756-761) does text += node.getTextContent() with no NBSP inversion, while the sibling $isTextNode branch at :755 does nodeText.replaceAll(NBSP, " "). The book's \id line is built by createBook (usj-editor.adaptor.ts:309) as an ImmutableTypedTextNode, which extends DecoratorNode and whose getTextContent() returns this.__text verbatim, NBSP included. The committed fixture confirms the raw byte: libs/test-data/src/data/2sa.lexical.editable.ts:22 hexdumps as c2 a0. Building a minimal book+para USJ, mounting it through MarkerEditPlugin in Standard view, selecting the whole document and dispatching COPY_COMMAND produced a text/plain payload with a live, literal NBSP right after the book code (hex ...325341c2a02d2054...). docs/clipboard-semantics.md states the governing rule outright: text/plain inverts every display-NBSP to a plain space and leaves a data-NBSP as the ~ the display already shows, with the consequence that no NBSP reaches either readable flavor. The \id line's trailing NBSP is a structural display separator, not a data-NBSP, so this violates a documented guarantee rather than taking an intentional exception.

Other findings in this file: #1, #2, #9, #16, #17, #19, #28, #29, #30, #31

* The `text/html` clipboard flavor for `usfm` — the SAME bytes as `text/plain`, HTML-escaped and
* laid out one block per line.
*
* Standard view's fidelity carrier is USFM text (`docs/clipboard-semantics.md`, S1/S3), and the two

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#30 - low · checked and confirmed

A spec-section label ships in a source comment, which this repo's CLAUDE.md forbids.

What happens: the TSDoc at :770 reads "Standard view's fidelity carrier is USFM text (docs/clipboard-semantics.md, S1/S3)". CLAUDE.md (repo root, Code Style) states: "Keep issue-tracker references out of code and comments: no Jira IDs (e.g. PT-4187), internal task/QA labels (Task 8), or spec section numbers (§5.5). Code must stand on its own."

Why it matters: the section numbers are unresolvable from the code and go stale the moment the document is reordered. The docs/clipboard-semantics.md path itself is fine and the rule does not bar it.

Fix: drop the S1/S3 label at :770 and keep the document path, naming the rule the comment relies on instead of its section number.

How this was checked: The TSDoc at whitespaceDisplay.plugin.utils.ts:770 reads "Standard view's fidelity carrier is USFM text (docs/clipboard-semantics.md, S1/S3)", which carries the spec-section labels S1/S3. CLAUDE.md forbids exactly this: "Keep issue-tracker references out of code and comments: no Jira IDs (e.g. PT-4187), internal task/QA labels (Task 8), or spec section numbers (§5.5). Code must stand on its own." The sections do exist in docs/clipboard-semantics.md (at :11 and :58), and the document path itself is allowed by the same rule; the section labels are what it bars. git diff confirms this line was added by this PR.

Other findings in this file: #1, #2, #9, #16, #17, #19, #20, #28, #29, #31

return usfm
.split("\n")
.map((line) => line.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;"))
.map((line) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#31 - low · checked and confirmed

A copy containing an empty paragraph yields one fewer paragraph through text/html than through text/plain.

What happens: the doc at :787 asserts "nothing the copy walker produces contains one" (a blank line). That is not what the mirrored walk does: $selectionToUsfmText sets prevWasElement = !node.isEmpty() at :730, so an empty non-inline element between two non-empty ones contributes \n when visited and fails to suppress the next block's \n, giving "a\n\nb". usfmToClipboardHtml at :799 then emits <p>a</p><p></p><p>b</p>, and htmlPasteText's .replace(/\n+/g, "\n") at :105 collapses the pair back. A trailing \n is trimmed by the same tail.

Why it matters: it breaks this file's own rule that a consumer's flavor choice cannot change the content it receives. It is reachable wherever a top-level ParaNode is genuinely empty — a showParaMarkerPrefixes: false surface, or a paragraph mid-rebuild. The showParaMarkerPrefixes: false popover's scaffold paragraph normally stands alone rather than sandwiched between two content paragraphs, so the everyday trigger is more likely a mid-rebuild transient than that surface.

Fix: narrow the promise instead of chasing a round trip htmlPasteText cannot give. Its collapse-and-trim at whitespaceDisplay.plugin.utils.ts:105 is shared by every paste path in this file — getPastePayload's fallback and both MarkerEditPlugin claims — and exists to tolerate messy nested HTML from real clipboards; loosening it to preserve this file's own flat per-line output would have to be re-verified against all of them, for a benefit that only matters when a top-level paragraph is genuinely empty. State the limit where the guarantee is already stated: in docs/clipboard-semantics.md, next to the "the two decode to the same document text" line, record that an interior or trailing blank line in the selection does not survive the text/html leg — text/plain carries it exactly, text/html collapses it, by the nature of htmlPasteText's shared newline handling. Replace the proposed htmlPasteText(usfmToClipboardHtml(x)) === x assertion with a pin in the usfmToClipboardHtml mechanics describe asserting the actual output for "a\n\nb" and "a\nb\n" — that the round trip returns "a\nb", not the original — so the limitation is enforced rather than merely written down. Leave usfmToClipboardHtml's <p></p> emission and the pin at :503-508 untouched. This adds only a doc line and a test, so it needs no CHANGELOG entry and no dist rebuild; the alternative route of changing htmlPasteText's collapsing logic does change clipboard behaviour and picks up both, plus a re-run of every paste test that depends on the current collapsing.

How this was checked: Running usfmToClipboardHtml then htmlPasteText on "a\n\nb", "a\nb\n" and "a\n\n\nb" (whitespaceDisplay.plugin.utils.ts:795-811 and :93-105) shows all three round-trip to "a\nb" — the interior blank line and the trailing blank line are both lost when a consumer reads text/html instead of text/plain. The doc comment's claim that this cannot happen ("nothing the copy walker produces contains one", :787-789) is also wrong on its own terms: building a genuinely empty top-level ParaNode between two non-empty ones and running the real copy path ($selectionToUsfmText via $getStandardViewClipboardData) produces text/plain: "a\n\nb" directly, because prevWasElement = !node.isEmpty() at :730 treats a childless element the same as "no element seen yet". A childless top-level paragraph is not hypothetical: showParaMarkerPrefixes: false (libs/shared-react/src/views/view-options.utils.ts:120-137) is a real shipped option the footnote-editor popover uses, named explicitly in note-ops-popover-roundtrip.test.tsx:62-69.

Other findings in this file: #1, #2, #9, #16, #17, #19, #20, #28, #29, #30

// an empty `<p>`. The event is still CLAIMED — the selection is this handler's to answer — it
// just writes nothing, leaving whatever the user copied last intact. A cut still removes the
// range: the bytes it would have carried are the ones that do not exist, not the nodes.
const isEmptyPayload = !data["text/plain"] && !data["application/x-lexical-editor"];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#9 - high · checked and confirmed

The empty-copy guard does not fire for the case its own comment names, so copying an optbreak husk replaces the clipboard with an empty string.

What happens: the comment names "an optbreak husk … selected by the two element points either side of it". For that selection both ends are outside the husk, so $selectionReachesIntoOpaqueBlock() is false and $getStandardViewClipboardData (:836) still calls $getLexicalContent. $getLexicalContent returns null only when getNodes().length === 0 (@lexical/clipboard); here it is non-empty, so it returns the string {"namespace":"…","nodes":[]}. isEmptyPayload at :887 is therefore true && false === false, and the payload is written with "text/plain": "".

Why it matters: the same holds for any range covering only nodes the walker drops, such as a note's internal NBSP separator. The user's clipboard is replaced with an empty string instead of being left alone.

Fix: key the gate on the readable bytes — change whitespaceDisplay.plugin.utils.ts:887 to const isEmptyPayload = !data["text/plain"];, so a lexical flavor with an empty nodes array cannot mask an empty copy. Pin the husk selection (an optbreak with its own // child removed, selected by two element points on the parent flanking it) in optbreakClipboardFidelity.test.tsx. Add a line to the empty-copy rule in docs/clipboard-semantics.md stating that it also covers a non-collapsed selection whose only covered node is a childless husk construct, not just a collapsed caret or a null-payload dispatch. Rebuild the committed packages/platform/dist (node scripts/rebuild-committed-output.mjs).

How this was checked: Reproduced the exact scenario the comment above the check names. In a live editor, removing an optbreak's own // display child creates the childless husk the comment describes; setting a selection with two element points on the parent paragraph flanking it matches "the two element points either side of it". With that selection $selectionReachesIntoOpaqueBlock() returns false, so $getStandardViewClipboardData (whitespaceDisplay.plugin.utils.ts:836-842) proceeds to call $getLexicalContent(editor), which returned the literal string {"namespace":"TestEditor","nodes":[]} — non-empty and truthy even though it encodes zero nodes. That is assigned to data["application/x-lexical-editor"] while data["text/plain"] is "". At :887 the guard evaluates to true && false = false, so it does not fire and the handler writes the near-empty payload. On the common Ctrl+C and context-menu path (copyToClipboard(editor, null, data) at :893) that round-trips through a synthesized native copy event and overwrites the real system clipboard's text/plain with an empty string.

Other findings in this file: #1, #2, #16, #17, #19, #20, #28, #29, #30, #31

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants