Repository navigation
PT-4608: Keep chapter-line edits from losing text or the caret - #19
Conversation
Deleting a selection that started on an editable chapter line and ran into the text merged the rest of the paragraph into the chapter line: Lexical always merges the block a deletion ends in into the block it starts in. A chapter serializes as its number alone, so the merged text stayed on screen and was silently dropped from the saved document. Every gesture that deletes a selection did it -- Delete, Backspace, typing over it, cut, Enter and paste -- including select-all-and-type, which saved nothing but the chapter number. ChapterNode now reports that it cannot be empty. Lexical then never treats it as a block to merge into, and removes it once every byte of its marker is deleted, so a deletion across the chapter line deletes the chapter marker (as in the USFM text) and leaves the text after the selection in its own paragraph. The host restores a later chapter's marker when it saves. The cost is that with the caret inside a chapter line Lexical has no block to split: Enter and Shift+Enter there landed empty paragraphs at the end of the document (Shift+Enter threw), and a multi-line paste put its later lines there too. MarkerEditPlugin now refuses paragraph and line-break splits on a chapter line, as before, and takes a paste there as one line of text with its line breaks turned into spaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o \p Typing over a selection that took a paragraph's \p marker, where no paragraph comes before it to merge into, resets it to \p with a fresh prefix. The reset always parked the caret at the start of the content, ahead of the character just typed, so every later keystroke landed in front of it: typing "new" produced "ewn". A selection running from a chapter line into the text now reaches this reset (the chapter line no longer absorbs the paragraph), and so does one starting at the \p of the first paragraph after a chapter. The reset now moves the caret only when it would otherwise sit in front of the new prefix, as the prefix injection already does for a split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setUsj skipped any document equal to editedUsjRef, the USJ deserialized from node state. While a marker edit is pending, node state still holds what was there before it: backspace a chapter's number out of its glyph and the chapter node still says 2, though the glyph and getUsj() say it has none. A host putting the number back -- paranext-core does, because Paratext refuses a later chapter without one -- handed setUsj a document equal to that node state, so the load was skipped. The numberless glyph stayed on screen, a caret placed past the number had nothing to resolve against, and it stayed wherever focus had left it: the end of the chapter, where the next keys deleted text. setUsj now compares the incoming document against the settled one -- getUsj(), what is on screen. With nothing pending that is the same comparison as before. With an edit pending, a document that undoes it loads, and the settled document itself is skipped, keeping the caret and the undo history of the edit in progress where it used to reload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A chapter line reports it cannot be empty, so Lexical does not treat it as a block: a paragraph split requested there has no block to split and lands at the document root instead, adding empty paragraphs after the chapter and moving the caret there. Enter and Shift+Enter on a chapter line are refused through INSERT_PARAGRAPH_COMMAND, but a marker-menu pick (EditorRef.splitParagraphWithMarker, and applyMarkerMenuSelection's paragraph branch) splits directly without dispatching that command. The chapter glyph is plain text rather than a marker glyph, so a host's Enter handler opens its paragraph menu on a chapter line, and picking any marker there appended empty paragraphs. $splitParagraphWithMarker now runs the same chapter-line refusal first, so the pick does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Enter on a chapter line was refused, so there was no way to add a paragraph right after \c N from the chapter line, as Enter does in the USFM text. A chapter line holds nothing but its own marker and cannot be split, so Enter anywhere on it now starts a new paragraph after it and puts the caret there: a plain Enter gives \p, and a marker-menu pick (the Enter menu, or a paragraph picked from the backslash menu) gives the picked marker. Shift+Enter is still refused, and a paste still goes in as one line. The marker menu now counts the chapter the caret is on as preceding it, since the paragraph it applies goes after that chapter. Without that, the Enter menu on a later chapter's line saw no chapter before the caret, took it for the book introduction, and put \ip first instead of \p. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A drop reaches the editor as CONTROLLED_TEXT_INSERTION_COMMAND carrying the drop's DataTransfer, and Lexical inserts it through its clipboard path, which splits at every line break with insertParagraph directly instead of dispatching INSERT_PARAGRAPH_COMMAND. With the caret on a chapter line there is no block to split, so dropping two lines there put the first after the chapter and the second after the chapter's last paragraph. A drop on a chapter line now goes in as one line of text, as a paste there does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…chapter - Enter on a chapter line starts its paragraph after the chapter's `\ca`/`\cp` attribute region, not directly after the chapter node: a paragraph put between a chapter and its `\cp` paragraph parted the two, so the `\cp` no longer followed its `\c` in the saved USFM. - Shift+Enter over a selection lying wholly on one chapter line leaves the selection alone. The line break is refused, so deleting the selection first only took bytes (e.g. the number) out of the marker. - A drop on a chapter line reads its data the way a paste does, falling back to the decoded `text/html` when there is no `text/plain` (new shared `getDataTransferPayload`). An html-only drop previously fell through to Lexical's rich-text insertion, which has no block to insert into there. - `$chapterLineOf` uses `$findMatchingParent`; the chapter-line test header describes the behavior rather than its history. Rebuilt packages/platform/dist. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…aratext 9 does A \cp paragraph can stand on its own, so a paragraph put between a chapter and a following \cp is what the user asked for; putting it after the \cp region instead was a departure from Paratext 9. The test now pins the Paratext 9 order. (packages/platform/dist is rebuilt with the main merge.) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sorbs-text Resolution: main's Standard-view external-paste handler now claims external pastes at HIGH and replays them as typed, so the chapter line's LOW "insert as one line" claim no longer ran for them. The chapter line now uses main's own pipeline instead of a second one: - The insertion tail of `$handlePasteForStandardView` is extracted, unchanged, as `$insertPastedText` (strip `\c`/`\id`, normalize NBSPs, replay lines as typed, or join them in a protected document). - A paste or drop on a chapter line goes through `$insertPastedText`, so its first line goes onto the chapter line and each later line starts a paragraph after it, exactly as typing it there does. - The chapter-line paste claim moves to CRITICAL, ahead of Lexical's rich paste and structure protection's html sanitizer. Both insert nodes at the caret, and a chapter line has no block for them: an internal paste in a protected document threw and did nothing. - `getDataTransferPayload(dataTransfer, namespace)` reads a drop with the same carrier rules as a paste, Paratext 9 html decoding included. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In a structure-protected document a chapter's marker is structure, as a verse number is. A paste or drop on the chapter line is now refused, changing nothing, when it would replace any of the marker or land inside it; just past the end of the line, where typing adds text after the marker, it still goes in as typing would (one line, no paragraph). The protection's own refusals (a range across a paragraph boundary or a verse) are still asked first. Rebuilt packages/platform/dist. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Under structure protection the keyboard cannot add a structure marker (the marker menu refuses every paragraph marker and \v), but a paste could: the pasted text re-tokenized, so `aa \v 9 bb` added verse 9. A same-editor copy was worse, adding a verse and splitting the paragraph, because it reached the html sanitizer, which inserts the copy's USFM bytes as text. Protected pastes now drop every marker the stylesheet types as a paragraph, and \v with its number (`stripPastedBlockMarkers`), keeping the text around them; character markers, notes, figures and unknown markers stay. This runs in the one insertion path every Standard-view paste uses (`$insertPastedText`, also the chapter-line paste and drop), and in the in-note line replay. Under protection a same-editor copy now takes that path too instead of the sanitizer. clipboard-semantics.md and the doc comments now describe the three things protection changes. Rebuilt packages/platform/dist. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
While a marker edit is in progress the editor holds two documents: what is
on screen, and its record from before the edit. setUsj compared an
incoming document only against the screen, so a host re-sending the text as
it was before the edit replaced the edit, the caret and the undo history.
It now does nothing when handed EITHER document.
That default alone would skip a correction that puts back what the edit
removed (a host restoring a chapter number the user backspaced away sends
exactly the pre-edit document), and the two cases look identical to the
editor. So the host says which it means: `setUsj(usj, { force: true })`
(new `SetUsjOptions`, exported) loads regardless. Marginal passes the
option through.
Rebuilt packages/platform/dist and the API report.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The editor's setUsj now keeps a marker edit in progress when handed the document from before it (paranext/scripture-editors#19). A repair can be exactly that document — a chapter number the user backspaced away, put back — so the push-back passes `{ force: true }` to have it loaded. Without it the correction still reaches the screen, but only a save later: once the edit settles, the next save repairs it again and that load goes through. The chapter-marker e2e specs pass either way; the editor's own setUsj test is what distinguishes the two. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The repo's lint prefers an index signature over Record, and this one error failed the lint step on every CI platform. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
🤖 Claude: ### Code review Found 3 issues:
scripture-editors/libs/shared/src/nodes/usj/ChapterNode.ts Lines 176 to 180 in d2ca50e scripture-editors/libs/shared-react/src/nodes/usj/note.utils.ts Lines 236 to 238 in d2ca50e
scripture-editors/libs/shared/src/converters/usfm/usfmFragmentToUsj.ts Lines 421 to 431 in d2ca50e
scripture-editors/packages/platform/src/editor/editor.model.ts Lines 62 to 77 in d2ca50e scripture-editors/packages/platform/src/editor/editor.model.ts Lines 184 to 187 in d2ca50e 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
…n a chapter line A chapter line is not a block ElementNode (ChapterNode.canBeEmpty() is false), so RangeSelection.insertNodes throws when an inline insertion (a note, character marker, verse, or milestone) is requested with a collapsed caret there -- exactly where paranext-core leaves the caret after a chapter-marker correction. Add $moveCaretOffChapterLine (chapterLine.utils.ts), which relocates such a caret to the content start of the first paragraph after the chapter line (skipping its adjacent \ca/\cp attribute nodes), or no-ops when no such paragraph exists. Call it from every platform-side entry point an inline insertion can start from: getUsjMarkerAction's note and generic branches (covering EditorRef.insertMarker and the marker-menu apply paths that delegate to it) and the marker-menu apply's direct note-insertion call. Non-collapsed selections that touch a chapter line are unaffected by this change and are already handled upstream: $normalizeSelectionOutOfGlyphText steps a range's endpoints past chapter-glyph text before the generic char/verse branch wraps it, and $insertNoteWithSelect collapses a non-collapsed selection to its end (then normalizes that) before inserting a note -- both leave the chapter untouched in the common case. One gap remains unfixed, out of scope here: a non-collapsed selection wholly WITHIN the chapter glyph still throws for note insertion (the end-collapse lands back on the glyph's trailing end before my guard runs), and the same selection shape still corrupts the chapter's number for character/verse insertion (an existing bug, unrelated to this change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
$handlePasteForStandardView sends a paste whose selection touches attribute-display text (a char span's |attrs run, a verse's \va/\vp run, a milestone's attribute run) to $insertPastedTextIntoAttributeContext, which never applied stripPastedBlockMarkers even under structure protection. Measured: a \v 9 or \p pasted into a \va run's value settles into a real verse or a paragraph split (stranding the run's own closer as an unmatched marker) exactly as typing it would -- protection does not hold there. Both selection shapes need the strip, not just the one that lands as ordinary paragraph content: a selection wholly within one attribute node (the paste-≡-typed value-byte carve-out) settles its structure bytes the same way a selection that only touches the run does, so protection's rule that a protected paste never adds structure outranks the carve-out for exactly the bytes that would become structure. $insertPastedTextIntoAttributeContext now takes isStructureProtected and getMarker and applies stripPastedBlockMarkers on both branches under protection; the doc comments on it and on $handlePasteForStandardView's now-inaccurate "needs no special case" sentence are corrected to match. Also rewrites stripPastedBlockMarkers's doc comment: an unknown marker's bytes are kept because they cannot be classified, not because "nothing says they are structure" -- in body text the tokenizer reads an unrecognized marker as a paragraph regardless (PT9's DetermineUnknownTokenType), so a protected paste of one can still split the paragraph, the same as typing it would. No behavior change for that case. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SetUsjOptions (@public) and the force flag it adds are new public API. Prefix their doc comments with the same EXPERIMENTAL: convention applyUpdate and the other experimental members of EditorRef already use, so they can change without a breaking release, and note it on setUsj's options param doc too. Rebuilt the committed dist and API report (pnpm rebuild-committed-output) to pick up this change plus the Item 1 and Item 2 source changes already on this branch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… line A selection lying wholly on a chapter line, such as the chapter number's digit, is the same request as a caret there: a note insertion threw for want of a block ancestor, and a character marker wrapped the selected digit out of the chapter number. Treat it like the caret and move to the start of the chapter's text. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rbs-text # Conflicts: # packages/platform/dist/index.js # packages/platform/dist/index.js.map
An inline insertion with the caret on a chapter line went to the start of the next paragraph. Paratext 9 (and typing in this editor) puts content after a chapter number on a markerless line straight after the chapter line: ahead of a following \ca, which stops being the chapter's attribute, and on its own line before a following \cp. The insertion now re-tokenizes the chapter's bytes with one character standing in for the content at the end of the chapter glyph (the same settle typing runs), and the caret takes that character's place. Tests pin each shape to what ParatextData makes of the same USFM (\c 2 ^ / \ca 3\ca* -> \c 2 / ^\ca 3\ca*, \c 2 ^ / \cp B -> \c 2 / ^ / \cp B) for notes, character markers and verses, plus typed text as the reference. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Replying to the code review:
The branch is also merged with |
|
🤖 Claude: Round 2 addressed in
🤖 Generated with Claude Code |
rolfheij-sil
left a comment
There was a problem hiding this comment.
🤖 Claude: ### Code review (round 3)
Thanks — all four round-2 items check out at 46eb98f3: a private-use character elsewhere in the document is left alone, an insertion past the \ca run keeps the alternate number, a glyph without its trailing space no longer throws, and the clipboard doc covers attribute context. The earlier repro tests now produce the expected documents, and the packages/platform suite passes here at this head (2161 passed, 3 skipped).
One non-blocking question, about the \cp sibling of round-2 item 2. With the caret after the chapter's \cp value ({ chapter 2, pubnumber "B" }, caret at the end of the \cp B run), insertMarker("f"), insertMarker("nd") and insertMarker("v") put the inserted item before the \cp: the result is [chapter 2 (no pubnumber), inserted, para cp "B", …]. Typing X at that caret keeps the run the chapter's (pubnumber: "BX"). $insertionAnchorOnChapterLine only looks at the \ca run, so with no \ca the anchor falls back to the number, and with both runs the insertion goes between \ca and \cp. Reproduced in a vitest against this head. Is that intended for \cp? If not, anchoring after the \cp run too ($chapterPubnumberRunPieces) would match typing; if it is, a line in the doc saying \cp deliberately differs from \ca would settle it.
Approving.
🤖 Generated with Claude Code
With the caret at the end of a chapter's `\cp` run, insertMarker put a note, character marker or verse BEFORE the `\cp`, which detached the published number. Paratext 9 puts the marker's bytes at the caret (`\cp B\f ...\f*`), and ParatextData reads that back as the chapter keeping its pubnumber with the inserted content after it (probed through GetChapterUsx/SetChapterUsx; that shape is also a round-trip fixed point). A caret anywhere in the `\cp` run now puts the content on a new markerless line right after the chapter line, without re-tokenizing the chapter: a stand-in after the run would be read as more of the published number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arker Deleting part of a chapter's `\c N` glyph (`\ 2` once the `c` is gone, `c 2` once the backslash is), typing another marker over the `\c`, or typing text ahead of it left bytes that no longer tokenize as a chapter. The chapter settle refused them and kept them as a pending literal while serialization fell back to the stored number: the file kept `\c N`, the screen kept the damage indefinitely, and anything typed there was never saved. The chapter settle now replaces the chapter's region with what its bytes tokenize to, in both the live settle and the read-only one `getUsj()` reads, as Paratext 9 keeps the bytes it holds. Text ahead of any marker stands markerless rather than in the `\p` the tokenizer opens for it, and the glyph's trailing separator is dropped. The host's save repair then puts back a marker the chapter needs above that text, as Paratext 9 does. An emptied line waits for the caret to leave before the live settle removes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bare backslash in USFM (`\ 2`) is a marker whose name is empty: ParatextData reads it as `<para style="">` and writes that back as `\ 2`, a fixed point, and Paratext 9 shows and saves the bytes as they are. Both converters tested `style`/`marker` for truthiness, so the empty name was dropped: the paragraph reached the editor with no marker, which shows it as `\p`, and the next save wrote `\p 2` over the author's `\ 2`. Both directions now test for presence, as setAttributes already does for every other attribute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`\ 2` left on a chapter line once the `c` is deleted settled as plain text, while ParatextData reads the same bytes as a paragraph with no marker name. The saved chapter then read back as a different shape than the editor sent, and the editor reloaded it once the user went idle. The chapter settle now produces that nameless paragraph itself, so the save reads back as sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🤖 Claude: Round 3 addressed in
It doesn't go through the stand-in/re-tokenize path: a stand-in after a New tests: a note, a character marker and a verse inserted from the end of a Also in this round ( 🤖 Generated with Claude Code |
…rbs-text # Conflicts: # packages/platform/dist/index.js # packages/platform/dist/index.js.map # packages/platform/src/editor/markerEdit/virtualSettle.utils.ts # packages/platform/src/editor/markerMenu/markerMenuApply.utils.ts # packages/platform/src/editor/markerMenu/markerMenuContext.utils.ts
|
🤖 Claude: Code review (round 4) Thanks — round 4 checks out at No new issues found. 🤖 Generated with Claude Code |
USFM puts markerless text standing behind a line break into the paragraph before it, so `\toc1 stuff` / `x\c 1` saves and reloads as `\toc1 stuff x`, as Paratext 9 keeps it. The chapter settle left that text on a markerless line of its own instead, which then changed shape on reload. The live settle moves the line's nodes onto the end of the preceding paragraph (keeping a caret restored into them), and the read-only settle mirrors it, so getUsj() agrees with the settled document. A space is put between them only where the line break would have read as one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🤖 Claude: Code review (round 5)
No new issues found. 🤖 Generated with Claude Code |
…rbs-text # Conflicts: # packages/platform/dist/index.js # packages/platform/dist/index.js.map # packages/platform/src/editor/markerEdit/tier2Rebuild.utils.ts # packages/platform/src/editor/markerEdit/virtualSettle.utils.ts # packages/platform/src/editor/markerEdit/whitespaceDisplay.plugin.utils.test.tsx # packages/utilities/dist/index.cjs.map # packages/utilities/dist/index.js.map # packages/utilities/src/converters/usj/nameless-marker.test.ts # packages/utilities/src/converters/usj/usj-to-usx.ts # packages/utilities/src/converters/usj/usx-to-usj.ts
… is no longer a chapter Once a chapter line's bytes stop reading as a chapter marker (`\ 2` after its `c` is deleted, `\sc 2` after an `s` is typed into it), the line settles as what it now is. getSelection() reported a caret anywhere on that line as the start of the line, because the settled side of a chapter scope was built only when it still held a chapter. Build it as any other settled root content when it does not, so the caret keeps its exact place: `$.content[0]['marker']` just past the backslash, or the offset in the line's text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ositions and marks A chapter line can settle into more than its chapter: text typed ahead of the marker becomes a line of its own (or joins the paragraph before the chapter), and bytes that stop reading as a chapter marker settle as what they now are. Three readers of that output assumed the chapter came first and alone: - the live and read-only settles carried annotation marks only when the first rebuilt node was the chapter, so a mark beside the chapter was dropped when text was typed ahead of it or the line became `\ 2`; - the position plan did not know a leading line joins the paragraph before the chapter, so while such an edit was pending every top-level index from there on was reported, and resolved, one item off. $settledChapterScopeFragment reads the settled nodes the one way all three now use, and the chapter's position plan takes in the paragraph a leading line joins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… arrives The browser moves the caret itself for most keys and tells Lexical only through `selectionchange`, which it fires asynchronously. A key pressed before that event is handled acts on where the caret was: Home then ArrowRight on a chapter line jumped to the next paragraph, and End, three ArrowLefts and Backspace deleted the line's trailing space instead of the `c`. Most visible on the first keys after a chapter loads. KeyDownCaretSyncPlugin, first among the critical-priority key handlers, replaces a collapsed text caret with the DOM's when the DOM's sits at a different text position. A caret at the DOM position Lexical renders is kept, so a position the editor chose among several at one DOM point is left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…c prevents Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s on an element After Home the browser can hold its caret on the element at the boundary before the text rather than in the text node, and the caret sync left that alone, so ArrowRight could still act on Lexical's stale end-of-line caret. The sync now reads the DOM's caret back as a Lexical position wherever it is, and replaces Lexical's only when that position differs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rbs-text # Conflicts: # libs/shared-react/src/plugins/usj/index.ts # packages/platform/dist/index.js # packages/platform/dist/index.js.map
katherinejensen00
left a comment
There was a problem hiding this comment.
Approving the PR. There is two small test fixes, but not worth holding the merge up over.
[Tests] caret-restore.util.test.ts (~L257)
8f9f4cc1a2 deleted the scheduler-level test "accepts a paragraph-start placement the editor reads back as the marker location". That test pinned the fix for the 2 s snap-back. isSamePlaceInDocument now covers only the end-of-text / next-marker alias. If the editor ever reads a caret at the start of a paragraph's text back as the paragraph's own location, the snap-back returns and no test fails. Question for the author: does that alias still exist? If it does, add a comparator case. If it does not, say so where the test was.
[Tests] chapter-marker-repair.util.test.ts (~L326)
No test passes a caret any more when the whole chapter marker was deleted. The deleted glyph-specific test also pinned something that is not glyph-specific: a caret in the line that now stands where the deleted marker stood is not treated as a moved chapter line. The guard is isMarkerDeleted || storedChapterIndex < 0 ? undefined : … (~L693). Drop it, and every test still passes. Suggested replacement: nameless('2 more') with the caret at $.content[0].content[0] offset 2, expecting the restored-marker caret, not $.content[1]….
@katherinejensen00 reviewed 32 files and all commit messages, and made 1 comment.
Reviewable status:complete! all files reviewed, all discussions resolved (waiting on tjcouch-sil).
|
🤖 Claude: Both test findings are addressed in paranext/paranext-core#2816 ( caret-restore.util.test.ts. The alias no longer exists. I set the caret at the start of a paragraph's text through the real The same check found a different read-back that did matter. A view that hides markers can't show a caret on a chapter number. So a caret placed just past one (the target after a correction) goes to the start of the next paragraph, and the editor reports it there. The restore treated that as a placement that hadn't taken and placed it again every 50 ms for two seconds. In the Formatted view, Backspace at the start of a chapter deletes its marker, and that triggers the repair. A caret the user moved straight afterwards was pulled back to the start of the chapter, and the log then said the chapter had not loaded in time. The restore's read-back check now also takes a caret at the start of the line right after a target at the end of a line. That is a different USFM position, so it is a placement check, not a location equality. It is renamed chapter-marker-repair.util.test.ts. I added a caret to the existing "only the marker was deleted" case ( Gates: typecheck and lint are clean, and the editor extension's tests pass (4824). All eight 🤖 Generated with Claude Code |
* PT-4608: Add the chapter-marker repair design and implementation plan
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Port PT9's FixChapterNumbers to USJ as a save-path repair
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Strip nested chapter markers before the anchor logic runs
A chapter object nested inside a paragraph is never legal USJ, and it reaches
the writer as a `\c` regardless of where it sits. Stripping it only on the paths
that go on to place a top-level marker left it in place for an intro-only
chapter 1, which draws the same ValidateChapterNumber rejection — and the same
poisoned save loop — this module exists to prevent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Repair chapter markers before the PDP write and tell the user
Paratext refuses to write a chapter document whose `\c` marker disagrees with
the chapter being written, and the rejection leaves the bad marker in the
editor, so every later save of that chapter is rejected too. Run the repair on
the way out of the editor, push the repaired document back into the editor when
the save still targets the chapter on screen, and warn the user under a stable
notification id so the repeated saves of a long edit update one toast.
The chapter is taken from the selector this save closure captured, not read
live: a pending trailing save can fire after the user has navigated away, and
the repair has to be computed against the chapter the content was typed in
while the editor push-back is skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Guard the repair push-back and route its toast to this editor
`setEditorUsj` sets the editor's USJ and clears annotation info, and this file
already treats that pair as able to throw — the failed-save revert wraps the
identical two lines in try/catch. The repair path is the more exposed of the
two, being the only caller that feeds the editor a synthesized document rather
than one sourced from the PDP or read back out of the editor. An exception
there skipped the PDP write that un-poisons the chapter, so the next save
repaired the same document and failed the same way.
The notification also carries `webViewId` now. Sharing a `notificationId`
without it opens a second toast whenever an update routes to a different
window, and the save that raises this one can come from the window-blur flush —
exactly when this window is no longer focused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Put the chapter-repair push-back decision under test
The decision of whether a repaired chapter document is pushed back into
the editor lived inline in `saveUsjToPdpIfUpdatedInternal`, which nothing
can reach from a test. Lift it into `applyChapterSavePreparation` in the
repair util, where the four behaviours it owns can be pinned: the
push-back applies when the save still targets the chapter on screen, is
skipped when the user has navigated away, moves the sent-to-PDP baseline
and the editor document together, and reports the correction to the user
in both cases.
Also drive the "leaves a correct chapter alone" coverage through real USX
parsed by `usxStringToUsj`, in the shapes the data provider serves
(chapter 1 with its `\id` line and headers, a later chapter, a chapter
carrying `altnumber`/`pubnumber`). A false `didRepair` on an untouched
document would toast the user on every save of an ordinary typing
session, and the hand-built fixtures are too small to catch that.
No behaviour change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Tell the user when the backend refuses a save
The editor saved the chapter being edited every time the user stopped
typing, and reported a rejection only for the two message shapes it
recognized — a Send/Receive edit block and a permissions failure. Every
other rejection went to the log alone, so a chapter whose saves were all
being refused looked, on screen, exactly like a chapter that was saving
fine.
Classify the rejection instead, in a new `save-failure-report.util`, and
give an unrecognized one its own generic notification naming the project.
The backend's own wording is written for a developer, so it stays in the
`logger.error` call and never reaches the toast. Only the two recognized
kinds still revert the editor to the last PDP state: nothing here knows
the backend would accept that document in place of an unrecognized
rejection, and the editor holds the user's only copy of the edit.
The same module owns the repeat policy: a chapter the backend keeps
refusing is refused on every save, so a report fires only when the KIND
of rejection changes, and a write that runs without throwing clears the
memory and dismisses the toast.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Decide the save-failure revert before awaiting the toast
The revert of a rejected save ran after `await`ing the notification, which
is a cross-process call that yields across macrotask boundaries. That is
long enough for `useEditorPdpSync`'s effect to apply a newer PDP delivery
into the editor, which the revert then overwrote with the older captured
snapshot — recording the older document as last-sent and suppressing
re-convergence until the next delivery. The hook defers only while the
editor is focused with a recent local edit, so this bit exactly where a
cross-chapter rejection fires: a chapter-switch flush or a trailing save.
`planSaveFailureResponse` now works out the kind, whether to report, and
whether to revert in one synchronous call, so the editor's content is put
back before anything is awaited. `shouldRevert` is deliberately
independent of `shouldReport`: the deduplication gates the toast across a
run of identical rejections and must never gate the revert.
Also give the generic save-failed toast `duration: 0`. Auto-close is
governed solely by `duration`, and an omitted one computes 10-35 seconds
from the message length — so the one toast a failing run produces closed
itself and left the chapter silently failing to save, which is the
condition this notice exists to make visible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Correct chapter markers in the C# chapter-write backstop
`ScrText.ValidateChapterNumber` refuses a chapter write whose `\c` marker
disagrees with the chapter being written — a wrong number, a second marker
typed mid-chapter, and a deleted marker are all hard rejections — and the
rejection does not repair whatever produced the bad marker, so every later
write of that chapter fails too.
The Scripture editor now corrects its own document before sending it and
tells the user, so this is the backstop for every other writer: an
extension or any non-editor caller that writes chapter USFM or USX gets
the same correction, silently apart from a log line.
`ChapterMarkerCorrection.FixChapterMarkers` ports PT9's
`UsfmEditorTextLoader.FixChapterNumbers`, keeping its handling of the two
shapes chapter 1 alone allows (an introduction ahead of the marker, and an
introduction-only chapter with no marker at all) and its habit of leaving
USFM that needs no correction byte-identical. PT9's `Debug.Assert` on a
zero chapter number becomes a plain return: a book-level write
legitimately carries a marker per chapter, so only `SetChapterUsfm` and
`SetChapterUsx` are wired to it. PT9's `errorMessage` is dropped; the
renderer owns the user-facing message.
In `SetChapterUsx` the correction runs before the comparison against what
is on disk, so that comparison sees what would actually be written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Pin the chapter setters to the chapter-marker correction
The correction's own table covers the transformation, but nothing pinned
the two setters to actually running it, which is the whole of what the
backstop does for callers. These go through the real save path, and the
first test pins the premise that makes the other two falsifiable: Paratext
throws `ChapterizationException` for exactly the USFM they hand the
setters, so a setter that stopped correcting would fail rather than
silently pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Close the chapter-marker correction review round
Port fidelity to PT9's table is what this correction rests on, and three
of that table's line-ending rows were unpinned: the mixed-ending no-change
path, and the two rows that put Unix endings through the introduction walk
and the marker strip loop, where `Unix2Dos` is what keeps the match
indexes pointing at the right bytes.
In `SetChapterUsx` the correction log now sits after the
unchanged-content short-circuit, so "corrected before saving" is only
written when a save follows. The message itself moves next to the other
private helpers, shared by both setters, so a reworded log cannot diverge
between them.
The USX wiring test also edits a verse, not just the chapter number: the
chapter it seeds already read "two", so asserting that text could not tell
a corrected write from no write at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Put a restored later-chapter marker at the very start
`findChapterStartIndex` placed a restored chapter marker behind a leading
`\id` book node for chapters after the first. `ScrText.ValidateChapterNumber`
rejects `verseRef.ChapterNum != 1 && parts[0] != ""` with "Text present before
chapter marker.", so that shape is one ParatextData refuses — the repair could
emit a document the writer will not take. Paratext 9's `FixChapterNumbers` and
the C# `ChapterMarkerCorrection` sibling both prefix the marker at position 0
unconditionally; match them. The stray book node is left in place, as Paratext 9
leaves it. Chapter 1's placement rule is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Record the chapter-marker save-boundary repair decision
Adds `adr-chapter-marker-repair-at-the-save-boundary` in byte-order slug
position: repair the chapter document on the way out in both write layers
rather than preventing the edit, leave book-level writes alone, and put a
restored later-chapter marker at index 0 because ParatextData refuses any
content ahead of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Promote save-boundary repair into the patterns standard
The decision log keeps the why; the standards keep the current rule. Adds a
cross-cutting section stating the rule a future implementer needs: repair
content the ParatextData writer refuses at the save boundary rather than
policing the edit, and do it in every layer that can originate the write,
because a correction only the backend makes is invisible to a focused editor
and loses to `EDITOR_OWNERSHIP_WINDOW_MS` deferral. States the chapter-marker
position-0 invariant both ports must hold, and cross-references
`adr-chapter-marker-repair-at-the-save-boundary`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Correct the ADR's cited mechanisms and pin the degenerate case
Book-level writes are safe because `SetBookUsfm`/`SetBookUsx` never call the
corrector, not because of the `chapterNum <= 0` guard inside it; say so, and
name both ports' symmetric early returns as the defensive guards they are.
Qualify Paratext 9's user-facing behavior: it reloads with `Revert()` on any
repair but reports only the mid-chapter-marker case, so notifying on every
repair is ours. Label the Paratext 9 repo paths `in PT9 ...` per the house
convention so they do not read as paranext-core paths.
Adds a test for a later-chapter document holding nothing but book nodes, the
degenerate branch of the placement rule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2
* PT-4608: Cover the chapter-marker repair end to end
The unit suites pin both ports of the repair and the save-path plumbing, but
neither can reach the two things that decide whether the fix works: that the
Standard-view gesture really does poison the document, and that the shape the
correction produces is one real Paratext will accept.
chapter-marker-repair.spec.ts drives the gesture in the running app and asserts
the marker is put back, the correction notice is raised, the keystrokes that
follow the push-back still reach the document, the chapter goes on saving across
a chapter switch, and a repair carried by the chapter-switch flush reports while
leaving the chapter navigated to holding its own content. Stubbing
repairChapterMarkers to never repair makes it fail on data-number="15", so the
assertions are load-bearing rather than incidental.
chapter-marker-backend-correction.spec.ts writes wrong-number, extra-marker and
deleted-marker chapters straight to the Paratext PDP over PAPI, with no UI. It is
the only test that puts ChapterMarkerCorrection's output in front of
ScrText.ValidateChapterNumber, which is the code that refuses these documents in
the first place.
The correction notice is watched with a MutationObserver installed before the
gesture rather than a locator assertion after it: the notice carries no
duration: 0, so it closes itself on a length-derived timer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Name the chapter the correction notice is about
The notice said only which project a chapter marker had been corrected in.
That reads fine when the repair happens under the user's cursor, but it is not
always what happens: a save carried by the chapter-switch flush repairs the
chapter the user has just left, so the notice arrives while a different chapter
is on screen and silently invites the reader to blame that one.
Name the book and chapter instead, captured in the save closure alongside the
chapter selector so a flush-carried save reports the chapter its content was
typed in rather than the chapter now displayed. The book name is localized
through %Book.<id>%, which the localization service backfills with English names
for every canonical id, falling back to the three-letter id while the lookup is
still unresolved.
The e2e spec now matches on the whole sentence including "Jonah 1", so the flush
step fails if the notice ever goes back to naming whatever is on screen.
Also corrects a comment claiming the repair runs ~100ms after the keystroke. It
runs on the ordinary 700ms trailing save; measured in-page, keystroke to
push-back is 766ms. The earlier figure came from reading nominal loop labels in a
probe as elapsed time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Put the caret back where the chapter marker was corrected
Hand QA found the correction leaves the user with no insertion point. The
push-back is EditorRef.setUsj, and the editor loads external content through
LoadStatePlugin, which parses a fresh editor state, sets it, and dispatches
CLEAR_HISTORY_COMMAND. Every Lexical node key regenerates and the parsed state
carries no selection, so the caret the correction was made under disappears
mid-typing.
The repair now reports where the caret belongs in the coordinates of the
document it produced: just past the corrected or restored chapter number,
failing that the boundary a removed marker occupied, failing that the surviving
marker. The save path applies it once the editor has loaded that document, and
only when the editor held DOM focus at push-back time, so a caret is never
pulled out of a footnote popover that legitimately has it.
The target is computed from the repair rather than carried across from the
pre-repair selection on purpose. EditorRef.getSelection addresses the live tree
while the repair works on getUsj()'s settled document, and the two differ for
exactly the gesture that matters: a \c typed under the caret is still a pending
literal in the live tree while the settled document already holds a chapter
node. A captured path would be in the wrong coordinate system.
The offset encodes the editor's glyph byte layout (\c, NBSP, number, space),
which nothing in this repo can check, so the e2e spec reads the web view's own
DOM selection and asserts exactly one glyph character follows the caret.
Disabling the restore makes that step fail with charsAfterCaret -1, so it is
load-bearing. Its neighbouring step now clicks into the body before typing,
since the restored caret sits in the marker, where characters are marker bytes.
Undo and redo history is still emptied by every repair; fixing that needs the
mutation to move into the editor as node surgery, which is a scripture-editors
change. Both halves are recorded in the decision log as
adr-chapter-marker-repair-replaces-the-document, with why delta ops are not the
lever.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Record that a repair clears every annotation too
The push-back's reload does not only drop the caret and the undo stack. A load
replaces the node map, and the TypedMarkNodes that carry annotations live only
in that tree, never in USJ, so every annotation goes with it. That is not new to
the repair — it holds for every setEditorUsj call, the ordinary PDP echo
included — and the local bookkeeping already follows it, since setEditorUsj runs
clearAnnotationInfo.
What no path announces is the clearing itself. A per-annotation removal sends
that annotation's interactionCommand with a TypedMarkRemovalCause, but a
wholesale clear fires no onRemove at all, and the editor exposes no event for it
either. Reusing 'destroyed' would be false: the published contract defines it as
"when the text the annotation was on is completely deleted", so a consumer
acting on it could discard data whose text is intact. An honest announcement
needs a new AnnotationAction value, which is an extension-facing API decision
spanning every reload path rather than this one, so it is recorded rather than
made here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Only repair a chapter document the user has actually changed
Review pass over the branch turned up two live defects and a set of smaller
ones.
The repair fired on documents nobody had touched. useEditorPdpSync calls
saveUsjToPdpIfUpdated on any delivery that matches the last-sent baseline, no
local edit required, and a legitimately blank chapter carries no \c node at all
— the state isChapterBlank and EmptyChapterView are built around. So for every
chapter after the first, opening a blank one synthesized a bare \c, pushed it
into the editor, wrote it to the project and told the user their chapter marker
had been incorrect. That also flipped isChapterBlank false, taking away the
versification-aware scaffold the "Add chapter number" button would have built.
prepareUsjForChapterSave now returns early when the editor's document still
equals what the PDP holds: a document that has not moved is the PDP's own
content on display, and correcting stored content is the C# backstop's job, not
a view's. The gate reads the pre-repair document, so the deliberate
repair-before-compare ordering is unchanged and a repair that lands back on the
PDP's content still reports itself.
The notification ids were module-level, so every open editor shared them.
papi.notifications.dismiss fans out to every ready window, so one editor's
recovered save took down another editor's still-standing "could not be saved"
notice while that editor went on failing with its report already spent; and two
editors correcting a marker overwrote each other's notice instead of raising
two. Both ids now come from the web view id.
The permissions arm had inherited the new report gate while keeping the default
auto-close, which made a persistent permissions failure silent after its first
toast. It now shares the unknown arm's persistent, routed notice, differing only
in message. Sync-blocked stays transient because SyncBlockedBanner holds that
state in view.
Also: Spanish for the two new strings, with the en-only test blocks replaced by
parity and per-locale placeholder assertions; a C# case pinning that \ca/\cl/\cp
survive a correction; XML doc on both chapter setters saying they rewrite what
they are handed; an unmount cleanup for the caret-restore timeout;
deepEqualAcrossIframes and isLocalizeKey in place of hand-rolled equivalents;
and a comment pinning CorrectFirstChapter's offset to Paratext 9's own, which a
reviewer had read as an off-by-two — the PT9 source uses the identical
expression, and the "simpler" reading changes the answer for `\c 1\ip`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Put the save-failure report gate under test
The gate that turns a run of identical backend rejections into one report lived
half in a pure module and half in the web view: the module decided, and the
component kept the memory, set it on report, and cleared it plus dismissed the
notice on the next write that completed. Only the deciding half had tests, and
the half without them is the one that decides whether a user ever learns their
chapter stopped saving — the notice is raised once per run and stays up under a
stable id, so a clear that goes missing leaves a stale toast on screen AND
leaves the next rejection of the same kind unreported.
Give the memory to the module that owns the gate. planSaveFailureResponse now
takes the memory and records what it says to report; clearOutstandingSaveFailure
performs the other transition and reports whether there is a notice to take
down, so the web view never dismisses an id it never sent. Neither transition
can drift from the other now, because neither is written anywhere else.
Six cases cover the run: one report per run of identical rejections, the kind
actually recorded, the revert continuing on every occurrence while the report
stays suppressed, and the completed write both re-arming the report and asking
for the dismissal. Falsified — stubbing clearOutstandingSaveFailure to a
constant false turns the two lifecycle cases red and leaves the other sixteen
green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Say what the correction did without passing judgment
"The chapter marker in Jonah 1 was incorrect and was automatically corrected"
reads as a verdict on the user for a marker they were in the middle of typing.
It now says what actually happened: it did not match the chapter, so it was
corrected. Both locales, and the end-to-end spec matches on the sentence.
Correct the ADR while the wording is in hand. It claimed the caret landing
inside the marker glyph was "bounded by the notice that explains it", and the
notice explains nothing of the sort — it does not mention the caret, the emptied
undo stack, or the cleared annotations. Keeping it to one sentence is the right
call, but the losses are then unmitigated rather than explained, and the log
should say so.
Pin why the end-to-end caret cover transfers
--------------------------------------------
The caret target addresses the chapter marker's first content item — the glyph
the editor renders — and the review asked whether that resolves against a marker
the repair SYNTHESIZES, which has no content array, when the only end-to-end
cover is of one it RENUMBERS. It does, and for a reason worth pinning rather
than rediscovering: the parser emits a chapter node childless too, so the
renumbered marker is no different. A test now holds that, because it is a
property of the parser that nothing else here would notice changing.
An end-to-end step for the deleted-marker gesture was written, run, and dropped:
deleting the glyph one byte at a time does momentarily empty the chapter node,
but the editor puts one back on its own, so the save path renumbers rather than
restores. With the restore branch stubbed out to do nothing, the step still
passed — as did a caret assertion with the restore branch's caret target forced
to undefined, since by that point in the run the caret sits one character from
the end of a chapter glyph regardless. Two sabotages, two green runs; a test
that cannot fail is worse than no test. The renderer's restore branch appears to
be reachable only by writers that are not the editor, which is already covered
by chapter-marker-backend-correction.spec.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Cite the chapter-marker guards by name, not line number
The ADR's line citation into chapter-marker-repair.util.ts had already
drifted (138 vs 223) and the file is still moving; naming the guarded
functions keeps the reference valid.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Keep the caret in the text when a chapter marker is restored
Typing into a chapter with no `\c` at all -- in Power mode, any chapter
the book has not reached yet, which shows as the ordinary empty editor --
reaches the repair's restore branch: the marker is added, the document is
pushed back into the editor, and the notice is raised. The caret then went
to just past the new chapter number, so it jumped out of the text being
typed into the marker glyph. Whatever was typed next became part of the
chapter number (`2QQNEXT`) and the following repair renumbered it away,
silently.
A restore now reports CARET_AT_DOCUMENT_END instead of a position, and the
web view carries it out by focusing the editor with no selection, which
Lexical resolves to the end of the root -- where the user was typing. A
renumber keeps its caret just past the corrected number. The notice is
unchanged.
The new isolated e2e, chapter-marker-restore.spec.ts, is the first test
that reaches the restore branch through the UI: it cuts Jonah back to
chapter 1 with a book write (which the correction never touches), types
into the blank Jonah 2, and asserts the marker, the notice, the caret at
the end of the typed text, that the next keystrokes continue that text,
and the saved USFM. Put back to the old caret target, it fails on the
caret step.
The toast recorder both specs use moves to e2e-tests/fixtures. The unit
test that argued the renumber e2e also covered the restore caret is gone,
since the restore no longer addresses the marker glyph and has its own e2e.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Never save the chapter being left over the chapter being loaded
After navigation the editor goes on showing the chapter it is leaving,
still editable, until the new chapter's content arrives, while every save
scheduled in that window is already bound to the new chapter. A keystroke
typed there was written to the new chapter when its content arrived (the
replace path flushes the pending save first), or when the debounce fired.
Paratext used to refuse that write as a wrong chapter number; with the
chapter-marker repair, the marker was renumbered to match and the old
chapter's text replaced the new chapter's. Leaving a blank chapter did the
same with almost nothing, because the repair restores the missing marker.
The editor's document identity, which useEditorPdpSync already tracked,
is now passed out to the web view, and a save whose content is not from
the chapter it writes to is dropped (isEditorContentForChapter). The
replace path moves that identity only after the flush, so the flushed
save still sees the chapter the editor is holding. Keystrokes typed in
that window are not written anywhere: they came after the user asked to
leave the chapter, and the new chapter replaces them on screen.
The decision record and the repair pattern now say what else the
chapter-number refusal was catching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Record that deleting a chapter marker is a restore too
The restore caret goes to the end of the document on the reasoning that a
restore means the user was typing into a chapter with no marker. Deleting
the marker from a chapter that has text reaches the same branch -- the
editor removes a chapter node whose glyph is emptied -- and there the end
is not where the user was. The doc comment and the decision record now
say so, and why the end is still the choice: text typed there is kept,
where text typed into the new marker is corrected away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Revert "PT-4608: Record that deleting a chapter marker is a restore too"
This reverts commit 4395655e777. The note was inferred from the editor's
chapter transform (an emptied chapter node removes itself), but no
deletion gesture probed in the running app reaches the restore branch:
- Backspacing the glyph away: the editor puts a chapter node back, and
the save renumbers it (caret goes to the marker, as for any renumber).
- Selecting from the chapter line into verse 1 and pressing Delete,
Ctrl+Shift+Home then typing, and select-all then typing: the chapter
node survives each time, absorbing the merged or typed text into its
glyph and keeping its number, so no repair runs at all.
So a restore is reached by typing into a chapter with no marker, as the
doc comment and the decision record already said.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Don't replace the document under a user who is still typing
A save does not only run once typing pauses: the PDP sync saves as soon
as a delivery arrives, which can be mid-typing. If that save repaired a
chapter marker, the push-back replaced the whole document under the user,
and the editor has no caret until the repair's caret restore runs, so the
keys typed in between landed wherever the browser left them -- at the end
of the chapter, deleting or adding text there.
The push-back now waits until typing has paused, read from the same
last-local-edit stamp that schedules the debounced save. The repair is
still written and still reported; the debounced save that follows the
pause makes the same repair and puts it on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Put the caret back on the chapter line when its marker is deleted
Backspacing a later chapter's whole marker faster than the save debounce
removes the chapter, and the save restores it. A restore sent the caret
to the end of the document, which is right for the other way into it --
typing into a blank chapter, where the user was typing at the end -- but
here the user was working on the chapter line, and the caret jumped to
the end of the chapter.
When the only difference from the stored chapter is the missing marker,
the restore now puts the caret just past the restored number instead.
Typing into a blank chapter, or any change beyond the marker, keeps the
caret at the end of the text.
This also puts back the note that deleting a marker reaches the restore,
which 7c07706ac80 reverted on a probe that could not reach it: the
chapter only disappears when the whole marker goes before a save runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Cover the caret after correcting a marker still being edited
Backspacing a later chapter's number a key at a time, pausing after
each, sent the caret to the end of the chapter after every correction,
and the next keys deleted the chapter's last characters, with nothing on
screen to say so. The number was still a pending edit in the editor when
the repair put it back, and the editor's setUsj measured the corrected
document against node state -- which still held the number -- so it
skipped the load. The numberless glyph stayed, the caret target just
past the number had nothing to resolve against, and the caret stayed
where focus() had put it.
The fix is in scripture-editors (setUsj now compares against the settled
document), so a repair always loads. This records that dependency in the
decision log, says why the caret restore places its caret even when the
editor already reports a selection (a load leaves a stray browser
selection where the replaced text was -- in verse 1's number, where the
next Backspace deletes it), and adds chapter-marker-deleted.spec.ts:
backspace the whole marker away quickly (a restore), then the number one
key at a time with a save between each (a renumber), asserting each time
that the marker comes back with the caret just past the number, and that
the saved chapter keeps all of its text.
Needs the scripture-editors change staged: platform-yalc must move past
it before this merges.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Don't replace the document under an open palette or note editor
The chapter-marker repair's push-back replaces the whole document, which
regenerates every node. Typing in a marker palette or a footnote popover
never stamps the main editor's last-local-edit time, so a debounced save
firing while one was open pushed the repair back under it: the popover's
own save could no longer find its note, and a palette pick could land at
a restored selection whose path had shifted (a removed mid-chapter \c).
The push-back is now also skipped while a session is open, using the same
live/stale session check as the PDP sync (without its side effect of
closing a stale note session). The repair is still written and reported;
the next save after the session closes puts it on screen. The parameter
is renamed isUserTyping -> isUserEditing to cover both cases.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Leave a correct chapter marker with its \ca on its line alone
The C# backstop treated a chapter marker as correct only when it stood on
a line of its own (\c N and a line break), as Paratext 9 does. A correctly
numbered marker whose line also carries its alternate number
(\c 2 \ca 3\ca*) was "corrected" by moving the rest of the line onto the
next line: a whitespace-only change that was logged as a correction and
rewrote the chapter on disk the first time it was saved.
Paratext's own ValidateChapterNumber reads the number only up to the
first non-word character, so that line was never refused. A marker whose
number matches the chapter and is ended by whitespace or the end of the
text is now left as it is. A wrong or partial number (\c 23 for chapter
2) is still corrected. This is a deliberate departure from Paratext 9,
recorded in adr-chapter-marker-repair-at-the-save-boundary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Keep Paratext 9's \c/\ca layout but don't report it as a correction
A correctly numbered chapter marker whose line also carries its alternate
number (\c 2 \ca 3\ca*) is written again as Paratext 9 writes it: \c 2 on
its own line with the rest of the line below it. Files keep the shape
Paratext 9 gives them, so a chapter doesn't flip between the two layouts
in its revision history depending on which program saved it last.
That layout is no longer reported as a correction, though. The marker was
never wrong (ValidateChapterNumber reads the number only up to the first
non-word character), and a logged correction on editor traffic is meant
to signal a gap in the renderer's repair. A wrong, extra or misplaced
marker is still corrected and logged as before.
The saved chapter reads back as the same USX the editor sent, so the
editor has nothing to reload, and re-saving it leaves the file alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Refuse a save whose setter has moved on to another chapter
A write that comes back unchanged retries through the closure of the
chapter it was typed in, but read the PDP setter from a ref that
navigation had already re-pointed at the new chapter. Paratext used to
refuse that write for its wrong chapter number; the backend correction
now renumbers the marker instead, so chapter N's text could silently
replace chapter N+1's. The ref now carries the chapter its setter
writes to, and a save bound to another chapter refuses it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Leave the sent-to-PDP baseline to the write after a push-back
Putting a repaired chapter back into the editor also recorded it as the
last document sent to the PDP, before the write had run. The write can
still be dropped (another write is in flight, or the save now belongs to
another chapter), and the baseline then named a document that never
left: the in-flight write's echo no longer matched it, so the PDP sync
replaced the editor with that older document whenever the editor was not
being typed in, taking the repaired edits with it. The write moves the
baseline itself, synchronously, when it actually starts, so the push-back
no longer touches it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Correct the marker on screen when the user leaves the editor
A save that runs as the user leaves the editor was treated as a save that
ran mid-typing, because the last keystroke is newer than the debounce by
definition there. So the repaired chapter was written while the editor
kept the wrong marker, and the next edit anywhere in that chapter
corrected it then — taking the caret to the chapter line, away from where
the user had started typing.
The editor is a web view in an iframe, and it stays its own document's
activeElement after the window around it loses focus, so its isFocused()
cannot tell typing here from typing elsewhere in the app. The window's
own focus can, and it now gates both the recency check and the caret
restore: a correction made for a window the user has left reaches the
editor without focusing it, which would otherwise pull the user out of
the control they moved to.
The e2e spec covers both halves: that the window's focus follows the user
out of the editor, and that a marker corrected by the save on leaving is
on screen while the caret stays in the control that was clicked. Without
this change it fails on the caret, which the correction's later, echo-
driven push-back pulls back into the editor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Put the caret back only once the editor has the corrected chapter
The caret restore ran on one fixed 200 ms delay after the push-back, and
the load it was waiting on has no signal to wait for. When the load took
longer, setSelection addressed the outgoing document, quietly changed
nothing, and left the caret where focus() had put it: the end of the
chapter. The next Backspace then deleted text there, out of sight of the
user, who was working on the chapter line — backspacing a chapter number
away a key at a time lost the last characters of the chapter this way.
The restore now waits until the editor is holding the document the caret
addresses, reads the placement back to confirm it took, and retries until
it does or gives up after two seconds. It also sets the selection BEFORE
focusing rather than after: focus() on an editor with no selection is
what takes the caret to the end of the document, so the caret never
passes through the destructive position at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* PT-4608: Place the caret only once the corrected chapter has actually loaded
- The caret restore now waits for the outgoing document's first block to
leave the editor. `getUsj()` returns the incoming document as soon as
`setUsj` is called, before the load, so with no pending marker edit the
restore could place the caret in the outgoing tree and then lose it to the
load.
- The end-of-document restore (a restored marker) goes through the same wait
instead of one fixed 200ms delay, and is cancellable like the others.
- A placement at a paragraph's boundary 0 is accepted when the editor reads it
back as that paragraph's marker location (no offset). Before, the removed-
marker target never matched, so the caret was re-applied every 50ms for 2s.
- `prepareUsjForChapterSave` reuses its first save comparison when nothing was
repaired instead of comparing the whole chapter again.
- Test and e2e comments describe the behavior rather than its history.
Verified: typecheck, lint, extension unit tests (1618), and the four
chapter-marker isolated e2e specs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Report Send/Receive and permissions refusals as before this change
Both are again reported on every occurrence, each with its own transient
notice, exactly as they were before the save-failure report existed. Only a
refusal the editor cannot name (the new "could not be saved" notice) is
reported once per run and stays up until a save gets through.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Announce a chapter-marker correction only once it has taken effect
The notice used to be sent before the write, so a write refused afterwards
(its chapter no longer the one selected) left the user told about a
correction that was never made. It now goes out once the corrected chapter
is written, or straight away when there is nothing to write and the editor
itself was corrected (the repair put the chapter back to what is stored).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Hold off edits while the editor moves to another chapter
From the moment another chapter is selected until its content arrives, the
editor still shows the chapter being left. Keys typed there were shown and
then lost (no save can take them: they belong to neither chapter), and each
one moved the caret in the old chapter, which reported its verse and could
pull the reference back to the chapter the user had just left.
`usePauseEditing` now holds those keys off while `isUsjFromPdpLoading`, in
the capture phase above the editor root: editing keys, caret-moving keys and
select-all, plus beforeinput, paste, cut and drop. The editor is NOT made
read-only, so it keeps the focus and the caret; copying and application
shortcuts go through. No keyboard shortcut is added, so the shortcuts
catalog is unchanged.
E2E `chapter-switch-edit-pause.spec.ts` moves chapter the way F8 does
(`platform.goToNextChapter`) while a stream of keys is typed, and checks
that every key the old chapter showed was saved, that keys typed during the
move were held off, and that the editor kept the focus. With the hold-off
disabled it fails: the keys land in the old chapter and pull the reference
back to it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Force the corrected chapter into the editor
The editor's setUsj now keeps a marker edit in progress when handed the
document from before it (paranext/scripture-editors#19). A repair can be
exactly that document — a chapter number the user backspaced away, put back
— so the push-back passes `{ force: true }` to have it loaded.
Without it the correction still reaches the screen, but only a save later:
once the edit settles, the next save repairs it again and that load goes
through. The chapter-marker e2e specs pass either way; the editor's own
setUsj test is what distinguishes the two.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Record in the ADR that the repair push-back forces its load
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Put the caret after every removed marker, not just the first
The caret for a repair that only removed markers goes to the item that
followed the first removed one. When that item was itself a removed marker
(`\c 5` then `\c 6` typed at the end of a chapter), the target addressed a
position past the end of the repaired document, so the caret restore could
never place it and gave up after two seconds, leaving the caret wherever the
load put it. The target is now the first item after the removed marker that
the repair keeps, falling back to the surviving marker when there is none.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Announce a chapter-marker correction as soon as it is on screen
A correction pushed back into the editor was announced only once its write
landed, so a write dropped because another was still running announced
nothing, and the next save found the chapter already correct. A correction
that reaches the screen is now announced at once; one kept off screen (the
user is typing, or has left the chapter) still waits for its write.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Force the editor back to the stored chapter after a refused save
After a Send/Receive or permissions refusal, the editor is put back to the
stored chapter. When the refused edit was a marker edit still in progress,
the stored chapter could equal the editor's record from before that edit,
so the editor skipped the load and kept the refused edit on screen.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Keep a paragraph whole when a chapter marker typed into it is removed
The editor holds a `\c` typed mid-paragraph as the paragraph cut short,
the chapter node, and the rest of the paragraph's text as bare top-level
content. The repair dropped the extra chapter node but left that text
outside any paragraph, and aimed the caret at it, where it did not
resolve. Paratext 9 removes only the marker's own bytes, so the repair
now appends the inline content that followed a removed marker back onto
the paragraph it was cut from, and places the caret at the join.
New isolated e2e chapter-marker-mid-paragraph.spec.ts types `\c 5 `
into Ruth 2:1 and checks the notice, the single chapter marker, the
caret landing in the rejoined text, and the saved paragraph; it fails
on the caret step against the previous repair.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Take down a stale save-failed notice when the editor mounts
The "could not be saved" notice stays up until a later save gets through
and dismisses it, but only a mount whose own failure memory recorded the
failure does that. A reloaded web view keeps its id with empty memory,
so the notice could outlive it indefinitely while saves worked. Dismiss
the editor's save-failed id once on mount.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Drop development history from the backend-correction spec header
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Point the repair's undo limitation at PT-4818
The ADR now records that keeping undo history across a correction is
open in PT-4818 (OT ops, a generic history-preserving load, or editor
node surgery) rather than settled on the in-editor repair.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* PT-4608: Hold off the Edit menu and toolbar undo/redo while a chapter loads
The Project menu's Edit flyout and the toolbar's Undo/Redo buttons drive
the editor through EditorRef directly, past the DOM events usePauseEditing
cancels, so between selecting another chapter and its arrival they could
edit the chapter being left, an edit that is then dropped. The flyout now
treats a loading chapter as read-only (Copy still runs), and the toolbar
buttons do nothing while it loads, as Ctrl+Z and Ctrl+Y already do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Hold off footnote, cross-reference and marker palette edits while a chapter loads
Insert Footnote and Insert Cross-reference (context menu, Ctrl+T,
Ctrl+Shift+T, the top menu) and the marker palettes' commits change the
editor through EditorRef, past what usePauseEditing holds off, so between
selecting another chapter and its arrival they could edit the chapter being
left, an edit that is then dropped. Each now checks a ref that tracks the
load; the inserts check it again after their version-history commit, since
the load can start during that await.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Keep the deleted-marker spec's five Backspaces inside the save debounce
`locator.press` focuses its target before every key and can take over a
second a key on a cold first launch. The five Backspaces meant to remove
`\c 2 ` before any save then straddle the 700 ms debounce: a save repairs
the marker partway through and the remaining keys delete the repaired one
down to `\ `, which the editor no longer reads as a chapter, so nothing
repairs it again and the caret step times out.
The keys now go through `mainPage.keyboard` (the glyph click leaves the
focus in the editor), and the step asserts they landed inside the debounce
so a slow run fails on that precondition rather than 20 s later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Keep a chapter line's leftover text below the restored marker, with the caret
The editor now saves a chapter line's bytes as what they are once they
stop being a chapter marker (`\ 2` after the `c` is deleted, text typed
ahead of the `\c`), as Paratext 9 saves the bytes it holds. The save
repair already restores a later chapter's marker at the top and moves
anything ahead of it below, and leaves chapter 1 without one, all as
Paratext 9's FixChapterNumbers does.
What it did not do was keep the user's caret: a restore sent it to the end
of the chapter, or just past the restored number. When the caret is in a
line of top-level text the repair moved, it now stays at the same
character of that line.
New e2e `chapter-line-left-as-text.spec.ts` covers both edits end to end:
the notice, the restored marker above the text, the caret, and the saved
USFM. The ADR's consequences record the editor half.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Keep the caret in a nameless line the repair moves, and pin `\ 2` loading and saving as itself
The editor now settles a chapter line's bare `\ 2` as the paragraph with
no marker name ParatextData reads it as, rather than as plain text. The
caret the user had in that line follows it below the restored marker: to
the same character of its text, or to the start of the text when it was
inside the `\ `, which holds no caret.
The e2e now also reloads Ruth 2 and saves an edit to the line, pinning
that `\ 2` loads back as itself and is saved as `\ 29`, not `\p`. It
reads the caret as the text before it within the line, so the check does
not depend on how the browser spells the position.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Keep the caret in a marker typed into a chapter marker
Typing an `s` into `\c 2` leaves `\sc 2`, a character style. The repair
restores `\c 2` above it, but the caret went to the end of the document:
the moved-line caret mapping only knew plain text and nameless markers.
The save normally runs before the editor settles the line, so the caret
counts the whole chapter line; it is now placed at the same character of
what the line became, in its marker name or in its text, and a caret read
after the line settled keeps its place in the marker name. Only the line
the stored chapter marker stood on is read this way, since a caret in any
other paragraph's text reads the same.
Also pins end to end that text typed ahead of `\c 1` joins `\mt1 Ruth`, as
it saves and reloads (scripture-editors join on the chapter settle).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Don't read a paragraph the chapter line merged into as the chapter line
A selection from the chapter line into the paragraph below it, typed over,
removes the chapter line and leaves that paragraph where the line stood. The
caret mapping then took the paragraph for what was left of the chapter line
and counted its text as if it began with the paragraph's own marker, so the
caret landed in `\p` and the next key retagged the paragraph (`\pc`).
The stored chapter line's index is now used only when the line changed in
place: the stored line after it is still the one after it. Otherwise the
usual caret applies, as before the moved-line caret was added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Record in the ADR when the repair keeps the live caret
`adr-chapter-marker-repair-replaces-the-document` still said the caret target
is never carried across from the pre-repair selection. The caret in what is
left of the chapter line is, and is ranked ahead of every computed target.
The entry now says when that caret is read, why its coordinates hold there,
and that a paragraph the chapter line merged into is not read that way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Hold off the paragraph and character-marker menus while a chapter loads
While another chapter is loading, the editor still shows the chapter being
left and any edit to it is dropped. The paragraph-style picker, the inline
marker menu, the character-marker menu and the character-marker bar's
removal changed the editor without checking for that, so picking a
paragraph style right after navigating retagged a paragraph that was then
silently thrown away.
Each now does nothing while a chapter loads (the menus still close). The
removal checks both before its version-history snapshot, so no empty restore
point is taken, and again after it, since a load can start during the await.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Keep the caret in a paragraph the chapter line merged into
Selecting from the chapter line into the paragraph below and typing over it
merges the two. The repair restores the marker and moves that paragraph down
whole, but the caret then went to the end of the chapter, so the next keys
landed there.
That merge is recognized by the line after the merged paragraph being the
stored line two after the marker. The caret in it addresses the paragraph
alone, so it keeps its path with only the paragraph's index changed.
`chapter-line-left-as-text.spec.ts` covers the gesture in Jonah 2; with the
caret mapping disabled, that step fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Read the moved line's caret by where it is on screen, not by its neighbours
When the two lines after the chapter marker were the same, a chapter line
merged into the paragraph below it looked, from its neighbours, like a
chapter line changed in place. The caret was then counted as if it held the
chapter glyph's marker bytes and landed inside the paragraph's marker, so the
next key retagged it (`\pc`).
The glyph arithmetic now applies only while the caret is on a chapter line on
screen (`isSelectionInChapterLine`, read from the web view's DOM selection).
Anywhere else on the line where the stored marker stood, the caret already
addresses the line as the saved document holds it, so its path is kept with
only the line's index changed. That replaces both neighbour comparisons, and
also keeps the caret in a line that has already settled.
`chapter-line-left-as-text.spec.ts` fails with the signal forced off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Read only the stored chapter line as a chapter line, and pin the caret signal
A nameless paragraph (`\ `) at any other index still had its `\ ` counted in
the caret's offset, so after deleting the chapter line above a `\ 2 more`
line, a caret in that line came back two characters early. The marker-byte
arithmetic now runs only on the line the chapter marker stood on, as the
docs and the ADR already say; elsewhere the usual caret applies.
The on-screen chapter-line signal defaults to `false`, so dropping it at the
save compiled and passed every unit test. A source-reading contract test
now pins it at the call site, and the helper is tested with the selection
anchored on the `p.chapter` element itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add an e2e for markers typed through the `\` palette
The palette's Space commit writes the typed marker's bytes and the editor
turns them into the marker as it settles, with settling held while the
palette is open. Unit tests cover the editor and the key routing separately;
nothing checked the two together in the running app. The spec types `\nd `
and `\v ` through the palette, both waiting for it and typing straight
through, and checks the text typed next lands in the `\nd` span and becomes
the verse number.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Check typed verse numbers Jonah 1 doesn't already have
Jonah 1 already has verses 8 and 9, so the typed-commit spec's verse
checks passed before anything was typed. Use 91 and 92 (Jonah 1 ends at
17) and assert each is absent first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Address carets in the saved document, as the editor now does
The editor now reports and takes caret positions in the document getUsj()
returns (settled positions), so the repair's caret targets are written
that way too:
- just past a chapter number is the end of its `number` value
(`$.content[i]['number']`), no longer an offset into the drawn glyph;
- a caret in what is left of a chapter line keeps its exact place there,
read straight from getSelection(), with only the line's index changed.
The glyph character counting and the DOM signal that told it when the
caret was on a chapter line on screen are gone;
- a removed marker's place is spelled canonically (the start of a text,
a marker's location, or the end of the line), not as a child index on
its parent.
The caret restore confirms a placement by comparing places rather than
location shapes: the editor reports the end of a text that a marker
follows as the text's end, and two texts side by side as one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Take a caret the editor put on the next line as placed
A view that hides markers cannot show a caret on a chapter number, so the
editor puts a caret placed just past one at the start of the next
paragraph and reports it there. The caret restore read that as a failed
placement and placed it again every 50 ms for its whole two-second window,
so in the Formatted view a caret the user moved right after a repair was
pulled back to the start of the chapter, and the restore then logged that
the editor had not loaded the chapter in time. Two places with only a line
break between them in the USFM are now the same place.
The text-start alias the deleted scheduler test covered no longer exists:
setSelection/getSelection round-trip the start of a paragraph's text
exactly in all five views.
Also pins the caret for a deleted chapter marker: a caret in the line that
moved up into the marker's place does not stay there.
e2e chapter-marker-formatted-caret.spec.ts fails without the change (the
moved caret is back at the start of the chapter) and passes with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PT-4608: Name the caret check for what it accepts
The caret restore's read-back check also accepts a caret at the start of
the line after a target at the end of a line, which is a different USFM
position, so it was wrong to call it a "same place" comparison. It is now
isCaretPlacedAt(usj, caret, target), and it accepts that case in one
direction only: the caret after the target, never before it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the committed platform dist conflicted; rebuilt from the merged source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rpus (#29) * feat(utilities): let usjToUsxString stamp a caller-specified USX version usjToUsxString(usj, { usxVersion }) stamps the requested version on the root element instead of always writing USX_VERSION. USJ cannot carry the source USX version (the editor regenerates usj.version everywhere), so a host that needs its USX back at the version ParatextData served it must say so explicitly. Default output is byte-identical to before. Part of the PT-4197 data-fidelity audit; lets paranext-core drop its post-hoc version="3.1" string replacement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: composed PDP round trip, USX-first net, corpus extension (PT-4197) Pins the full load/save trip over the real per-chapter USX the paranext-core PDP produces (vendored under testUsfmCorpus/pdp-usx/): - corpus-pdp-usx-composed-round-trip: USX -> USJ -> mounted editor -> USJ -> USX per capture, in Standard, formatted and paragraph-structure views, with a pinned expected-divergence list (an unlisted divergence fails; a listed one that stops happening fails). - testusfm-usx-usj-usx-round-trip: the USX-first converter net, so an asymmetric usx<->usj pair cannot hide from both directions. - corpus-testusfm-round-trip and corpus-transform-fixed-point now run every fixture in all three editing views (they were Standard-only). - New fixtures testUSFM-2SA-4 (NBSP shapes, verse bridges/segments, \fe, \x, \cl/\cd, callers, \ref), testUSFM-2SA-5 (damaged-but-at-rest: nameless markers, double \ca, chapter-line text) and testUSFM-1SA-1 (book intro), with .usj derived by the documented recipe (provider USX + usxStringToUsj; 2SA-5 hand-corrected to the intended marker:"" shape). Known losses are routed through each suite's expected-failure mechanism, attributed to scripture-editors #11, #19 or PT-4391, so a merge that fixes one flips the pin visibly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: drop the pins SE #11 fixed (PT-4197) SE #11 fixed the leading-NBSP strip in a char span, the double-\ca fold in the tokenizer, and the nameless-marker converter bug, so their pins flipped as designed. The 2SA-5 .usj no longer needs a hand edit: the converter now writes exactly what it holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep an authored lone-NBSP content string outside Standard view (PT-4391) Outside Standard view a data NBSP is held as a literal NBSP, so a content string that is exactly one NBSP (`\nd ~\nd*`, `\p ~`, a `~` between two spans or after a note) was byte-identical to the engine's lone-NBSP display nodes and the editor->USJ conversion dropped it, already at load. The conversion now drops a lone NBSP only in the slots the engine's display nodes occupy: right after an editable glyph, right after a read-only glyph inside a span, first in a span or note, after a collapsed note's caller or span, or inside an attribute run (tagged/token nodes and the mark cases are unchanged). The one slot an author's `~` shares with a display node - a span's first content in the marker modes without a separator, where the empty-span placeholder sits - is disambiguated by a new node state, `authoredNbspState`, that the forward adaptor's createChar sets. The same rule is the live `$isLoneNbspContent` (node.utils.ts), used by the logical content model and the Tier-2 fragment flattening so annotation positions and settles agree with the export. CharNodePlugin no longer treats an authored `~` as an empty span's placeholder when text is typed beside it. Flips the pinned drops: constructed-usj-round-trip's paragraph-leading lone NBSP, the 2SA-4 entries in the composed PDP round trip and the testUSFM round trip (adaptor and transform legs), the formatted-view leading-NBSP pin, and the logical-model spacer pins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: one shared registry of editor views and one mountInView (PT-4197) Every per-view list the editor suites iterate is now a projection of EDITOR_VIEWS in editorViews.test-helpers.tsx: the location oracle's ORACLE_VIEWS/oracleView, the corpus suites' ROUND_TRIP_VIEW_CONFIGS and EDITING_VIEW_CONFIGS (moved out of corpus-data.ts, which is compiled with the library and so cannot import a test helper), and the new EDITING_VIEWS (standard, formatted, paragraph-structure). The two private Editor mounts (settledGetUsj's mountEditor and the location oracle's mountInView) merge into one mountInView(usj, view, opts); mountStandardViewEditor/mountExpandedNoteEditor are thin wrappers. Also adds footnotePopoverView(host) (mirrors paranext-core's FootnoteEditor options), forEachView, knownFailureRunner, and noteEditorRef's optionsInView(view). The Standard-with-expanded-notes round-trip label is now its registry name, standard+expandedNotes (was standard-expanded). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: run the position oracles in formatted and paragraph-structure (PT-4197) logicalModel.oracle gains formatted and paragraph-structure (no separators); canonicalLocations gains paragraph-structure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: run the footnote popover round trip over every editing host (PT-4197) note-ops-popover-roundtrip mounts its host in Standard, formatted and paragraph-structure, and its popover with footnotePopoverView(host), the options paranext-core's FootnoteEditor builds (so the Standard popover now also carries isNoteShellEditable: false, as the app's does). Glyph shape and the Tier-2 fixed point are asserted only over an editable host; ops/USJ idempotence and the Save write-back hold in all three. New: Enter inside the popover's note content. It keeps the note whole in every view, but over a hidden-marker host (formatted, paragraph-structure) Enter is Lexical's own paragraph split: the caret leaves the note for a new scaffolding paragraph, so the keystrokes that follow are outside the note and the popover's Save (note op only) drops them. Pinned as known failures. knownFailureRunner now returns a registrar that appends the reason to the test name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: run the corpus transform legs through the real Editor per view (PT-4197) corpus-transform-fixed-point and the testUSFM transform leg mounted only CharNodePlugin, MarkerEditPlugin and TextSpacingPlugin, so in formatted and paragraph-structure the transforms those views actually run (the note, paragraph and paragraph-marker-prefix plugins) never ran. Both now mount the real Editor in each editing view through mountInView, dirty every node once and read the document back with the adaptor Editor uses on a local change. The composed provider-USX suite shares the same runEveryTransform helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: annotation and undo rows in paragraph-structure and every editing view (PT-4197) exactAnnotationLocations rows that stopped at standard/formatted (figure caption hold, undo/redo, comment inside a caption, an NBSP inside a char span, labels at a range's end) also run in paragraph-structure. undoReachesFile's plain-text undo/redo control runs in all three editing views. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: navigation-then-edit save pins in every editing view (PT-4197) navigationThenEdit and scrRefNavigationThenEdit run in Standard, formatted and paragraph-structure: an edit right after a navigation must still reach onUsjChange (a lost onUsjChange is a lost save). Each row starts from no document selection. Without that, a row that follows another inherits jsdom's stale selection and stops detecting the leak it pins: with a cursor-change tag forced onto the navigation's caret placement, only every other row failed until the reset was added. scrRefNavigationThenEdit also asserts the reported USJ carries the note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: host change-notification rows in every editing view (PT-4197) onUsjChangeSettled's view-independent rows run in Standard, formatted and paragraph-structure: once per content commit (a new plain-text row; the attribute row stays Standard's), nothing for a selection-only commit or a commit that changes no bytes (after a load, a reload, and a paragraph touch after a lossless or double-space load), a local applyUpdate announced once, and every remote applyUpdate row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: Editor undo and character-marker ref rows in every editing view (PT-4197) The verse-spanning-delete undo regressions (guarded and native) and the removeCharacterMarker/replaceCharacterMarker/extendCharacterMarker ref rows that mounted the library's default (formatted) view now run in Standard, formatted and paragraph-structure. Two extendCharacterMarker rows fail in Standard view and are pinned as known failures: coalescing two \bd runs leaves two adjacent \bd spans, and extending \bd with \nd as a conflicting marker removes \nd but never applies \bd while reporting success. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: note EditorRef suites in every editing view (PT-4197) noteIndexAndHighlight, applyUpdateDomSelection and noteLooseTextApply mount through optionsInView(view) in Standard, formatted and paragraph-structure. The one row whose premise is Standard's display of an unclosed note's caller (plain caller text, not an immutable caller element) stays editable-marker only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: paragraph split and deletion content rows in every editing view (PT-4197) charStackParagraphSplit and paraWholeDeletion pin the marker-edit engine on hand-built Standard trees. Each gains real-Editor rows over Standard, formatted and paragraph-structure judged on the saved USJ: Enter inside \nd (and inside \wj \+nd) keeps both halves styled, and a deletion of a paragraph's text, or across a paragraph boundary, keeps the content the selection did not cover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: word the navigation row-isolation comment as the hazard it is (PT-4197) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: satisfy the typecheck in two per-view rows (PT-4197) The popover suite's known-failure map is a plain string map, and the deletion rows' Delete keypress checks the editor root for null itself (the marker-edit requireDefined admits null). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: never save an empty span's placeholder or drop an authored `~` (PT-4391) Follow-up review fixes to the lone-NBSP content rule outside Standard view. - CharNodePlugin: merging adjacent same-attribute glyph-less spans no longer carries an empty span's placeholder into the merged content. Landing after a nested span it read as content (the regression), and after a text-ending or `~` span Lexical merged it into the text before (`x~`, `~~`). The empty side now gives up its children and a caret on its placeholder moves to the seam. An author-marked `~` is never treated as a placeholder. - Remove-style ($removeCharNodeKeepingContent) unwraps an author-marked lone `~` instead of deleting the span with it. - NoteNodePlugin stripped a leading NBSP from any note char text on any transform (`\ft ~x` saved `\ft x`; typing after `\ft ~` lost it). It now uses CharNodePlugin's placeholder rule, which drops only a byte typed over a span that held nothing but the unmarked placeholder. - $isCharContentEmpty counted any NBSP-only text as empty, so a close-and-reopen after a `~` span's `~` dropped the span with its `~` (unformatted, formatted, paragraph-structure). It now strips only the separator and asks $isLoneNbspContent about a lone NBSP. Tests: gesture-level guards through the real Editor in every view (lone-nbsp-gestures), CharNodePlugin merge/typing and NoteNodePlugin units, $isCharContentEmpty units, and live/serialized agreement rows inside spans and notes (read-only glyphs, collapsed-caller notes, author-marked nodes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: preserve the USX/USJ version end to end (PT-4197) A document now leaves the converters and the editor at the version it arrived at, instead of being re-stamped 3.1 and forced back to 3.0 by the host. - `usxStringToUsj` keeps the root `<usx version>` (USJ_VERSION only when the USX declares none or an empty one); `usjToUsxString` writes `usj.version` (USX_VERSION only when the USJ has none). - `Usj.version` is widened from `typeof USJ_VERSION` to `string` — a public type change, recorded in the utilities CHANGELOG and api report. - The `usxVersion` option on `usjToUsxString` (Unreleased only) is removed; the round-trip audit suites now round-trip the version with no help, and the testUSFM USJ -> USX -> USJ net compares whole documents, version included. - The editor remembers the version of `defaultUsj` / each `setUsj` and stamps it on every USJ it builds (load, local commit, apply, stale refresh, settled read via `$settledUsj`). The reverse adaptor takes the version to stamp; internal scratch USJ fed to the forward adaptor is unchanged. Loading a non-USJ_VERSION document no longer warns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Swallow Enter inside an expanded note where markers are not editable Outside Standard view nothing turns Enter in a note into a \fp break, so Lexical split the paragraph holding the note and left the caret outside it; what was typed next was lost on Save. NoteEnterGuardPlugin claims KEY_ENTER, INSERT_PARAGRAPH and INSERT_LINE_BREAK at critical priority while the selection touches an expanded note, in every markerMode but "editable". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(platform-editor): save no space typed right after a char opener, as Paratext 9 Standard view saved a space typed directly after a char span's opener as content (`\nd things\nd*` -> content " things"). Paratext 9 skips all whitespace after any opening marker (UsfmToken.Tokenize), so its Standard view saves `\nd things\nd*`, and so does ParatextData on Platform.Bible's own save path (ConvertUsxToUsfm runs NormalizeUsfm) — the editor's getUsj() was the only place the space survived, so the editor reported a document the file never held. The span's content now drops its leading plain spaces on save, the rule a paragraph's start and the text after a verse number already follow: the screen keeps the typed space and the caret after it; the file does not get it. The logical position model drops the same spaces so positions agree with getUsj(). A span holding nothing but spaces keeps them (a selected space wrapped in a span), since dropping that would join the words around it. Catalog row O-5 (PT-4197). Pins updated deliberately: typedSeparatorSpace, separatorGapSettle, verseAdjacentTyping, charAttributeTypedSettle (Standard only; unformatted keeps whitespace), the adaptor caller-lookalike case, the logical-model unit and oracle tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(platform-editor): report the space before a verse after a char span while pending When a settle creates a verse directly after a char span (e.g. a `\` typed in front of `v 2 b` after `\w grace\w*`), TextSpacingPlugin's verse transform puts a space between them once the verse lands. The read-only settle behind a pending getUsj() creates no live nodes, so it reported the span and the verse touching, then the same bytes came back spaced after the settle. The read-only settle now mirrors the transform over the output of every paragraph or chapter scope it re-tokenizes. A scope the live settle only renames in place (signature unchanged) re-creates no verse, so it is left alone on both sides. Paratext 9 has this space too: UsfmToken.Tokenize inserts one between a closing marker and a following \v on every read, and the file is the same with or without it (the writer puts a line break before \v) — measured with ParatextData 9.5.0.24. Catalog row O-7 (PT-4197). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(platform-editor): rebuild committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep pasted prose inside the char span or note it is pasted into Lexical inserts a paste's nodes (internal flavor, or text/html, which is what Ctrl+V delivers) with RangeSelection.insertNodes, which splits every inline ancestor of the caret up to the paragraph: text pasted inside \nd Lord\nd* split the span and landed between the halves, and text pasted inside a note landed in the paragraph after it (cut + paste back inside a note moved the text out). A SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler now inserts a payload of plain prose as text when the paste starts in ordinary text inside an inline element, the way typing it would. Structured payloads are still Lexical's. Found by the PT-4197 clipboard corpus sweep (finding CB-2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the no-break space a cut leaves as a char span's only text In the views without char glyphs a span's lone NBSP is also the empty-span caret placeholder, which the save drops unless the text is tagged as the author's. Only the USJ-to-editor adaptor tagged it, so cutting or deleting `trail` from `\nd trail~\nd*` saved an empty span: the NBSP outside the selection was lost. A CharNodePlugin transform now tags a lone-NBSP text in a glyph-less span as authored when that text held more than the placeholder in the last committed state. Found by the PT-4197 clipboard corpus sweep (finding CB-4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Sweep copy, cut and paste over the testUSFM corpus in every editing view Standard view's USFM clipboard has a corpus sweep; formatted and paragraph-structure had none. The new suite samples whole verses, ranges starting or ending inside a char span, ranges holding a footnote, crossing a paragraph boundary or a verse number, spanning a paragraph, and inside note content, then copies and pastes (all flavors, html+plain as Ctrl+V delivers, plain), cuts, cuts and pastes back, and pastes USFM-looking text, checking each result and its undo/redo against a USJ-level model. Pinned with knownFailureRunner (68 rows): - paragraph-structure: a copy across a paragraph boundary carries the gutter glyph (\q1) in text/plain, so CommandMenuPlugin refuses its paste (CB-1); - hidden views, html flavor: char attributes (closed) are not carried and a note's display spacers come back as text (CB-3); - Standard reference rows over damaged USFM (2SA-5) and one derived-sid loss. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Rebuild the committed dist for the clipboard fixes Carries the inline-prose paste and the remaining-NBSP transform into packages/platform/dist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: typing-fidelity oracle harness for the hidden-marker views (PT-4197) A keystroke oracle for formatted and paragraph-structure, where no pending marker edit exists to settle. The expectation for each keystroke is computed from the loaded USJ alone: each leaf block read as units (a character, or an atom shown whole: verse, collapsed note, milestone, figure), a caret as a unit index, and typing, Backspace, Delete, Enter and typing over a range edited into the USJ tree there. The saved USJ must be one of the expected documents, Undo must restore the loaded USJ exactly and Redo re-apply the keystroke's. Keystrokes arrive as a browser delivers them: the caret placed with a selection change, the DOM selection made the editor's, a real keydown on the root, then beforeinput and the text insertion. Selection.modify (which jsdom lacks and Lexical's deleteCharacter needs) is supplied at character granularity. Failures are reported and listed by shape. Exports the pending-equals-settled oracle's keystroke corpus for reuse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: Delete before a collapsed note no longer throws (PT-4197) In the hidden-marker views, Delete just before a collapsed note removes its caller; NoteNodePlugin then removed the note and went on to move its first child out of the removed node, which threw ('Expected node to have a parent'), discarding the edit. Return once the note is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: joining a paragraph into an empty \b keeps its text (PT-4197) ParaNodePlugin refuses text typed into an empty blank line by clearing it. A join with the paragraph after it (Backspace at that paragraph's start, Delete in the blank line, or a range across the break typed over) also lands content in the empty \b, and the clear deleted that whole paragraph's text in the formatted view. Clear only when the paragraph after the blank line is still there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: Undo takes back Enter in front of a char span or a note (PT-4197) Lexical's history folds an update whose only dirty leaf is a text that kept its text into the previous undo step, even when tagged as a push. A paragraph break in front of an inline element is such an update in the formatted view, so Undo did not take the break back. ParaNodePlugin now marks a created paragraph's first leaf dirty as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: an author's no-break space left alone in a span is saved (PT-4197) In the views without a span separator (hidden and visible markers), a lone NBSP in a span is the empty-span placeholder unless it carries authoredNbspState, which the adaptor set only on a span text that was exactly one NBSP at load. Deleting the rest of \nd ~x\nd* (or x~) left an unmarked NBSP, and the span saved empty; an NBSP typed into an empty span lost to the placeholder drop the same way. The adaptor now marks every span text holding an NBSP (Lexical keeps node state through text edits and copies it on splitText), and the placeholder drop marks the NBSP it leaves behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: typing over a range across a span edge or a note keeps the rest (PT-4197) Lexical's RangeSelection.insertText moves the content after a range's last text to after its first text, into the inline element the range starts in, and resolves a range start at a text's end into the inline element after it. In the hidden-marker views that pulled the text after an enclosing char span inside the span (out of order), and, for a range starting just before a collapsed note, put the typed text and the rest of the range into the note, which was then removed with them. InlineRangeEditPlugin (mounted for markerMode hidden) replaces a range over more than one text of one paragraph itself: it cuts the edge texts, removes the nodes between and the spans and notes that leaves empty, and puts a point Lexical resolved into a collapsed note back beside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: run the typing-fidelity oracle in formatted and paragraph-structure (PT-4197) Two files per view (the keystroke corpus with the short testUSFM chapters; the long testUSFM and WEB chapters), paragraph-structure under Simple mode's guarded structure protection. The shapes still failing are listed with their reasons: Redo lost or a second Undo needed around an empty verse, one Enter-at-paragraph-start Undo in WEB Matthew 5, and one ignored Backspace at an element caret in 1 Samuel 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build: rebuild the committed platform output (PT-4197) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(platform-editor): rebuild committed dist for the typing and clipboard merges Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * test(platform-editor): hold each keystroke to keeping content, and pin separator removal The pending/settled oracle compared the pending document with the settled one, so content a transform dropped in the keystroke's own update was invisible: the pending document already lacked it and settled to itself. A mutation that removes a span's following text once its separator is deleted passed every check. Each edit's screen is now read as the edit left it (before transforms) and as it landed, and the landed screen may only respell the spliced one: no content byte (whitespace and marker tokens aside) may go. Range keystrokes are excluded: the oracle replaces a range with a direct insertText that skips the commands the marker-edit engine answers, so their transforms are not what a keystroke does. All four oracle views stay green with nothing new listed. Catalog row O-8 (PT-4197) reported Backspace after a `\w` opener's separator losing the span and the text after it. It does not: the bytes on screen (`\p In the \wgrace|grace\w* of God`) settle, pending and settled alike, to `\p In the ` and a new unknown paragraph `\wgrace` holding the rest — ParatextData's own reading (an unknown marker is a paragraph outside a note, a char span inside one; checked against its USX). The report came from a probe that printed only the first block. New cases pin it for Backspace and Delete across attributes, `\nd`, nesting and a note, comparing the whole document with ParatextData's USX. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(platform-editor): save a char span holding only spaces as empty, as Paratext 9 Standard view already dropped the spaces after a char opener's separator, but kept them when they were all the span held (a selected space wrapped in `\nd`). ParatextData skips all whitespace after an opener there too: `one\nd \nd*two` saves as `one\nd \nd*two` (USX `<char style="nd" />`), so the words join in the file. The editor's saved USJ now matches; the screen still shows the space inside the span. The logical position model drops the exception with it, so positions agree with getUsj(). A no-break space is data and stays: `\nd ~lead\nd*` and `\nd ~\nd*` keep it on load and with a space typed in front of it (new tests; ParatextData keeps both too). Pin updated deliberately: markerMenuApply's whitespace-only wrap now saves `["one", {nd}, "two"]` while the live span still holds the space. Catalog row O-5 follow-up (PT-4197). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform-editor): rebuild committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(shared-react): keep alike char spans apart once a deletion leaves them side by side In the hidden-marker views (formatted, paragraph-structure) CharNodePlugin combined two adjacent same-marker, same-attribute spans whenever either was dirtied, so deleting the text between `\w one|lemma="x"\w*` and `\w two|lemma="x"\w*`, or joining the paragraphs they end and start, saved one `\w onetwo\w*` and changed word segmentation (typing oracle finding T-7). Standard view keeps them apart (its spans carry glyphs), and so does Paratext 9. Two spans that were already alike, separate spans in the last committed state now stay separate. Spans an update creates (a marker applied to a run, a split's continuation, remote content) still combine with an alike neighbor, as does a span whose marker an update changes to match. - Regression tests in every editing view plus paragraph-structure (guarded): deleting the space between, and joining the paragraphs. - CharNodePlugin unit tests that expect a combine now add their spans by an update once the plugin runs (they previously relied on the plugin's registration re-dirtying the initial state, i.e. spans the document already held); new unit test for the keep-apart case. - Typing oracle: drop the "adjacent alike spans are one" allowance; both views' corpus and chapters pass without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform-editor): rebuild committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * test(clipboard): name the tickets that fix the clipboard corpus pins The paragraph-structure gutter-glyph copy pins (CB-1, 44 rows) name PT-4911, and the hidden-view html-flavor pins (CB-3, 13 rows) name PT-4921, in a TODO beside each rule and at the head of its reason. The Standard-view damaged-USFM and derived-sid rows have no ticket yet and are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(shared-react): arm a chapter for the guarded two-press delete Under "guarded" structure protection (Simple mode) a chapter marker beside the caret was outside the two-press guard: Delete at the end of the block before `\c N` removed the chapter on the first press with no arming, and Backspace at the start of the paragraph after it armed that paragraph, whose merge then did nothing because a paragraph cannot merge into a chapter (typing oracle finding T-10). $structuralDeleteTarget now resolves the atomic chapter glyph (ImmutableChapterNode) beside a block edge as a "chapter" target. The first press node-selects it (as for a verse) and the armed chapter carries the verse-selected class, so it shows the armed styling; the second press removes it and returns the caret where the first press found it. The opposite key cancels. No host hint kind is published for a chapter (the host's hint copy is verse/selection-only), matching paragraph merges. Standard view (editable chapter text), "protected" (still refused) and "off" (Power mode) are unchanged. Tests: guardedChapterDeletion.test.tsx in formatted and paragraph-structure views - Backspace after / Delete before `\c 1` and `\c 2` arm then delete (guarded), cancel on the opposite key, refused under protected, native under off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(platform-editor): keep a nameless character marker's empty name on export ParatextData reads a bare `\` inside a note (`\f + \ft \ b\f*`) as `<char style="" status="unknown">`. The converters already keep the empty style as `marker: ""`, but the editor's export turned an empty char marker into no marker at all, so getUsj() handed back a char with no name and the USX written from it was not the USX loaded. createCharMarker now keeps `marker: ""`, the way nameless paragraphs already round-trip. A char with no marker at all in its USJ (not a shape USX can express) now exports as `marker: ""`, as an absent paragraph marker already exports as `p`. Tests: namelessChar.test.tsx loads ParatextData's captured USX for an unclosed and a `\*`-closed nameless span in a footnote, and asserts getUsj() and the USX written from it are unchanged in Standard, formatted and paragraph-structure views; converter tests for a char's empty style. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * docs: correct what ParatextData does with a marker-bearing \cp Comments claimed ParatextData keeps every `\cp` that holds a marker as its own marker. Measured, it folds the text before the first marker into the chapter's pubnumber and leaves the rest as loose content after the chapter; only a `\cp` whose content starts with a marker stays a `\cp`. The editor deliberately settles such a `\cp` to the USFM-spec shape: the saved bytes are the ones Paratext 9 writes, and a reload shows Paratext 9's shape. No behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform-editor): rebuild committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * feat(shared-react): publish a chapter hint kind for the guarded delete An armed chapter in Simple mode's two-press delete now sets data-verse-delete-kind="chapter" (with data-verse-delete-intent) on the editor root, as an armed verse or selection does, so the host can show a chapter-specific "press again" hint. A paragraph merge still publishes no hint kind. CHANGELOG records the chapter arming and the new value. Tests: guardedChapterDeletion (formatted + paragraph-structure) and the StructureKeyboardPlugin DOM-signal suite expect kind=chapter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * test(platform): Standard view may drop an empty derived sid on a verse paste Pasting `\v 9 x` as plain text in Standard view rebuilds part of the paragraph it lands in, and the 2SA-3 fixture's empty derived `sid`s on the re-derived verses do not survive. Dropping derived metadata is accepted (data-fidelity catalog F-8), so this is now an expectation rather than a known failure: in the paragraph the verse lands in, an empty `sid` may be dropped, and everything else (non-empty `sid`s included) must still match exactly. The row still performs the paste, undo and redo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * test: name the tickets for the remaining untracked expected failures - PT-4924: typing-oracle T-11 / T-12 / T-16 entries in both views' expected-failures JSON (in each reason). - PT-4925: Standard view's extendCharacterMarker coalesce/conflict rows (EXTEND_KNOWN_FAILURES). - PT-4388: the Standard-view 2SA-5 damaged-USFM clipboard rows and the fragment tokenizer's "testUSFM 2SA 5" known divergence (bare `\` as a nameless marker, waiting on ParatextData 9.6). Every pin and what it asserts is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix: a join into an empty \b keeps the joined paragraph, as Paratext 9 (PT-4197) Backspace at the start of a paragraph after an empty \b, Delete in the blank line, or a range across the break typed over joined the paragraph into the \b and kept the blank-line marker on text. Paratext 9's formatted view joins paragraphs the way Gecko joins two blocks: the second block survives (JoinNodeTransaction keeps the right node), so the blank line goes and the paragraph keeps its own marker. ParaNodePlugin now gives a blank \b that takes in a joined paragraph that paragraph's marker and attributes, rewriting a gutter glyph to match, in formatted, paragraph-structure and Simple's guarded two-press join. Standard view edits marker text and is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * test: a typed U+00A0 saves as a space in Standard view, as Paratext 9 (PT-4197) Paratext 9's Standard view (AllowInvisibleChars off) saves every U+00A0 in the editor as a space (UsfmHtmlUtilities.LegacyPostprocessUsfm) and the author's no-break space is typed as `~`. Ours does the same; pin it between letters, after a space and at a span's start, and pin `~` typed saving an NBSP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build: rebuild committed dist for the \b join fix (PT-4197) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(shared-react): paragraph-structure Enter drops the break's space Enter at a word break (`In| the`) left ` the` in the new paragraph in the paragraph-structure (Simple) view, while Standard and formatted save `the`. ParaNodePlugin trims a new paragraph's leading space from its first child, which in that view is the gutter marker glyph rather than the text, so nothing was trimmed. It now looks past a leading gutter glyph. Tests: typingFidelity.regressions "Enter at a word break" in all three editing views, unguarded and guarded (red in paragraph-structure: saved " the beginning"). The typing oracle no longer accepts a new paragraph that starts with text keeping its leading space (it fails 23 corpus rows without the fix). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform): rebuild the committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * feat(platform): ViewOptions.allowInvisibleCharacters keeps ~ and U+00A0 apart in Standard view A Paratext project with AllowInvisibleChars on stores a no-break space as U+00A0 itself and `~` as an ordinary character (measured with ParatextData: load, save and round trip all carry both verbatim and distinct). Standard view always applied the `~` = NBSP rules, so a literal `~` in such a project was read as a no-break space and saved as U+00A0, and a U+00A0 typed there saved as a space. With the new option on, a stored U+00A0 displays as NBSP_SYMBOL (U+237D) instead of `~`, `~` stays an ordinary character, and every layer that maps display or fragment text to data takes the spelling from getNbspSpelling(viewOptions): the load/save adaptors, the tier-2 and virtual settle tokenizer (new UsfmFragmentOptions.nbspSpelling), the Unformatted content fragments, paste, copy (U+00A0 written as itself), the Markers view's copy, and a typed U+00A0 (command and native beforeinput routes). getViewMode ignores the field. Off is unchanged. Tests: invisibleCharacters.test.tsx (load/save, edit, span and note, typed ~, typed U+00A0 by both routes, paste, copy, settle and unsettled read, Unformatted settle, Markers copy, formatted/paragraph-structure pins), tokenizer nbspSpelling, getViewMode/getNbspSpelling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform): rebuild the committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * feat(platform): toUsfmNbspSpelling for hosts that read the editor's display text A host that copies or searches text read from the editor's DOM (the footnote popover's Copy button, a Find seeded from the selection) sees Standard view's display spelling of a no-break space. With allowInvisibleCharacters on that is U+237D, which is not the project's text; this turns it back into U+00A0, and leaves the text unchanged when the option is off (`~` is the project's spelling then). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform): rebuild the committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * test(platform-editor): keep a note's empty caller empty through edits in every view ParatextData reads `\f \ft x\f*` (one or two spaces after the marker) as caller="" and writes it back as `\f \ft x\f*` (measured with the corpus capture harness), and the tokenizer has followed that since PR #11. The existing rows covered only the expanded editable views; this pins a load, an edit beside the note and one inside it, and a departure in every editor view, saving caller "". Fails in all 8 views if the exporter writes "+" for an empty caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * feat(platform): U+001A is the no-break-space stand-in, drawn as a blank, and never leaves the editor With ViewOptions.allowInvisibleCharacters on, Standard view held a stored U+00A0 as U+237D (shown as a visible box glyph). U+237D can occur in Paratext 9 text, so a literal one was saved as a no-break space. It now holds U+001A (SUBSTITUTE), which ParatextData's ScrText.GetText turns into a space on every read, so no project text can hold one. - Rendering: a one-glyph font (empty, 0.25em wide, unicode-range U+1A, NBSP_STAND_IN_FONT_FAMILY) draws it as a blank the width of a space. The editor adds the face to its document through the CSS Font Loading API (from bytes, so no CSP font-src is involved). Chromium draws a control character as a box unless the FIRST family has a glyph for it, so generateUsjCss and usj-nodes.css lead every Scripture font-family with it. - It never leaves the editor: delta ops given out (onUsjChange, getNoteOps, getOpsAfterNote, takeOpsAfterNote) carry U+00A0 for it and a space for an NBSP the view shows in a space run; applyUpdate and replaceEmbedUpdate take a U+00A0 that is not a marker separator as a stored no-break space; the internal Lexical clipboard flavor is left out when the selection holds a stand-in; a drag out carries the copy's USFM text and html; annotation callbacks and note-caller previews give U+00A0. - Incoming text: every C0 control character but tab, LF and CR becomes a space in Standard view before anything reads it (load incl. attribute values, paste, drop, typing by both routes, applied ops). - A drop into Standard view now goes in as a paste of the same data. - The fragment tokenizer keeps the spelling as a character (regularizeSpaces turned control characters into spaces first). Leaking before (measured against the U+237D build): ops out, note ops, the footnote-editor round trip, ops in (U+00A0 saved as a space), the Lexical clipboard flavor, the cut, the async paste, drag and drop, and annotation text. Forced-on run (every registry view with the option): only the 5 intended off-mode pins fail. Known gap (both settings, not changed here): an attribute value's U+00A0 is shown raw in its attribute run, and a settle of its paragraph reads it as a space. Tests: nbspStandIn.test.tsx (35), whitespaceDisplay.utils.test.ts (control characters, ops mapping), usj-nodes.css and generateUsjCss font stack, invisibleCharacters.test.tsx on the new stand-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(platform): a note caller's click hands out the note's ops with U+00A0 The getNoteOps a noteCallerOnClick handler receives read the note's ops straight from the tree, so with allowInvisibleCharacters on they carried the internal stand-in; Platform.Bible's footnote popover applied them, and the incoming rule turned each stand-in into a space (seen in the running app). They now go out through opsForHost like every other op. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform): rebuild the committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * Address review-paratext findings on the data-fidelity branch - Fix InlineRangeEditPlugin: typing over a range whose two edge texts are both wholly selected no longer falls through to Lexical, which pulled the text after a char span into the span. The plugin now steps aside only for a whole-edge deletion. Regression rows added to typingFidelity.regressions.test.tsx. - Add standardViewDrop.test.tsx: drop-as-paste rows under both allowInvisibleCharacters settings. - Add the missing CHANGELOG entries (platform Fixed section; utilities note that the Scribe editor does not yet carry the USJ version through). - Replace in-code Jira/branch references with forward-facing wording per CLAUDE.md, keeping open TODO(PT-x) markers. - Document that allowInvisibleCharacters must be right before the first load; correct the vertical-tab/form-feed wording; clipboard-semantics doc updates. - Cleanups: $collapsedNoteOf uses $findMatchingParent, ParaNodePlugin key lookup, CharNodePlugin TSDoc, un-export $isSelectionInExpandedNote, NoteEnterGuardPlugin ordering comment, corpus fixed-point test uses knownFailureRunner, reshaped half-flat test trees, comment re-wraps, demo armed-chapter CSS. - Rebuild committed packages/platform/dist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_01KiNAWjh4KAwjzn1g34EePL * fix(platform): an attribute value's no-break space keeps its spelling in Standard view Standard view showed a U+00A0 inside an attribute value as a raw U+00A0 in its attribute run, which the engine reads as display spacing, so a settle of the paragraph (Tier-2 rebuild or virtual settle) saved it as a plain space, in both ViewOptions.allowInvisibleCharacters settings. The value now takes the body text's spelling (`~`, or the U+001A stand-in with the setting on) everywhere attribute state becomes display text: - the adaptor's runs for char spans, milestones, \va/\vp, \cat, \ca/\cp and an UnknownNode's display parts (figure src, sidebar \cat, periph); - the display-run syncs, which redraw from node state without view options: MarkerEditPlugin registers the editor's spelling (registerNbspDisplaySpelling / $getNbspDisplaySpelling in shared); - the `|...` bytes an unwrapped or emptied span leaves as content, a collapsed note's copied \cat, and the annotation byte maps. The tokenizer reads the spelling back as U+00A0 inside milestone (including an absorbed list), figure and \periph attribute values, as it already did for body text, char-span attributes and the attribute markers. Unformatted view, which shows a value's U+00A0 as itself, now respells it past the run's leading separator in its settle fragment (attributeFragmentText), as it does content, instead of flattening it to a space. Tests: attributeValueNbsp.test.tsx (each producer shape in Standard view with the option off and on: display, paragraph settle, value edit read before and after the settle, copy and paste with every flavor and with plain text alone; figure; span unwrap and paragraph-break emptying; a collapsed note's copied \cat; Unformatted; formatted and paragraph-structure pins) and tokenizer cases in usfmFragmentToUsj.test.ts. The nbspStandIn chapter row now settles the chapter line instead of pinning the raw display. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform): rebuild the committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(shared-react): rejoin a char span that Enter split when Backspace joins the paragraphs back Keeping alike char spans apart once both are in the document also kept apart the two halves of one span that Enter cut in two, so Enter then Backspace inside `\nd thing\nd*` saved `\nd th\nd*\nd ing\nd*` in the views that hide markers. - CharNode.insertNewAfter records, per editor, which span each split continuation came from; $wereSplitFromOneSpan lets those pieces merge again. Spans the document held apart still stay apart. - Regression test in every hidden-marker view: Enter inside a span, then Backspace, saves one span. - InlineRangeEditPlugin: comment the composition gap (an input method started over a cross-span range takes Lexical's own path). - CHANGELOG entry extended. - Rebuild committed packages/platform/dist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_018WCZ5KDNh1Vqzt3JP6jCnp * fix(shared-react): keep Undo/Redo working around empty paragraphs and caret hosts - Undo after a guarded two-step removal of an empty paragraph (an empty `\b`) now brings it back. Lexical's history folds an update whose only dirty leaf is a text that kept its text into the step before, even one tagged history-push, so `$mergeParaIntoPrevious` marks the removed paragraph's leaves dirty to make the removal its own undo step. - Redo works after an Undo leaves the caret beside an empty verse or a trailing note. Caret-host commits (create, move, removal on blur) are tagged history-merge through `$tagCaretHostCommit`, so they no longer become an undo step that clears the redo stack. - Drop the testUSFM-2SA-2/2SA-3 rows from both typing-oracle expected-failure lists: the Undo/Redo shapes they pinned now pass. - Pin an author's literal `~` in a milestone attribute value turning into U+00A0 on a Standard-view settle with `it.fails` (needs a product call). - Use a code span for `USJ_VERSION` in `Usj.version`'s TSDoc so consumers' typedoc can resolve the comment. - Rebuild the committed dist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_018WCZ5GnNin2H8EzQyWWq6p * fix(platform): keep a pasted no-break space at the start of a line Standard view's paste read a U+00A0 at the start of the pasted text, or of any line of it, as display spacing and inserted a plain space. So a pasted lone no-break space became a space, one beside a space collapsed away, and with allowInvisibleCharacters on the editor's own copy of a stored no-break space pasted back as a space or as nothing (the copy spells a stored no-break space as U+00A0 there). No copy reaching the paste puts display spacing at a line start: the editor's own copy writes a plain space for every display NBSP in both readable flavors, a browser's default copy writes text/plain with every NBSP as a space, and a Paratext 9 clipboard is decoded with its NBSPs resolved. normalizePastedNbsp now keeps every pasted U+00A0 as a stored no-break space except one right beside a marker token, which is still read as a separator. pastedNbsp.test.tsx pastes each case through the editor in both settings: from a foreign text/plain and html-only clipboard, and the editor's own copy pasted back. Two rows stay expected failures with the setting on: a stored no-break space right after a closer or right before an opener copies as U+00A0 and pastes back as a space under the marker-adjacent rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(platform): stop exporting NBSP_STAND_IN_FONT_FAMILY Hosts name the no-break-space stand-in's font family in their own stylesheets, which cannot import a constant, so the export gave them nothing to use. The constant stays internal to generateUsjCss and the font registration, and the docs now state the family name, PlatformEditorNbspStandIn, which must lead any host Scripture font-family and must not change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): keep an author's lone no-break space in delta ops - The delta-op producer dropped every text that is exactly one U+00A0 inside a note's contents, and every char span whose only text is one U+00A0 (as an empty span's placeholder). In views that hold a stored no-break space as U+00A0 itself (formatted, paragraph-structure), that is also a `~` between two of a note's char spans, or a `~` that is a span's whole text. - Both checks now ask `$isLoneNbspContent`, the rule the editor-to-USJ conversion already keeps content by, so a note's ops (`getNoteOps`, which Simple mode's footnotes pane edits) carry what `getUsj()` saves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(platform): Standard view's delta ops read as USJ text in both no-break-space settings - `opsForHost`/`opsFromHost` told a marker's separator from a stored no-break space only within one op, and took any op that is exactly one U+00A0 as a spacer. The editor emits each text node as its own op, so a glyph's separator (`\nd`, then ` A`) went out as a space, and with `allowInvisibleCharacters` on a stored no-break space that is a whole op's text (a `\ft` that is only `~`, a `~` between two spans or after a note, text carried out past a note) came back in as display spacing and was saved as a space or dropped. An NBSP is now structural only right after an opening glyph, in the same op or ending the op before. - With `allowInvisibleCharacters` off, Standard view's ops went out as its display text: `~` for a stored no-break space and U+00A0 for each space of a run. Simple mode's footnotes pane edits a paragraph-structure editor's notes in Standard view, so a pane edit saved a space run as no-break spaces, and a stored no-break space showed in the pane as a blank rather than `~`. Both settings now map the same way: the stored no-break space's spelling to U+00A0 and a run's NBSPs to spaces going out, U+00A0 to the spelling coming in. - A paragraph-structure editor still reads a char span whose whole text is one U+00A0 from applied ops as an empty span's placeholder, so the pane drops that `~`; pinned with `it.fails`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(platform): mark toUsfmNbspSpelling experimental It stays exported, but is expected to be removed once Standard view holds a stored no-break space as itself (PT-4931): display text will then already carry the project's spelling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): keep an applied no-break space that is a char span's whole text - Applying delta ops built every char span's text without the mark a load puts on a span's no-break space (`authoredNbspState`). In the marker modes without a separator after a span's opener (formatted, paragraph-structure), a span whose whole text is one U+00A0 then read as an empty span's placeholder and saved as nothing: Simple mode's footnotes pane dropped a `\ft ~` (or any `~`-only span) its row editor handed back, and the same happened to a `~`-only span inserted by `applyUpdate`. - The op producer leaves an empty span's placeholder out of its ops, so an applied U+00A0 is always content. `$markAppliedNbsp` marks the text of every char-span insert (`$handleCharText`, a note's contents, an unknown embed's contents) that holds one, as the USJ adaptor marks a loaded span's text; editable marker mode is left unmarked, as on a load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(platform): expect the footnotes pane to keep a `~`-only char span The paragraph-structure main editor now marks an applied lone no-break space as content, so the row that pinned the drop with `it.fails` runs as an ordinary test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): keep a char span or milestone in text carried out past a note Text a host's note editor carries out past its note (`takeOpsAfterNote`, applied after the note with `replaceEmbedUpdate`) lost its markup: - A char span from a Standard-view note editor became an unclosed span plus a stray `\nd*` in Standard view, and its glyphs became span text in the paragraph-structure and formatted views. The carried ops held the span's marker glyphs as text, the shape of the note editor's own delta document. `getOpsAfterNote`/`takeOpsAfterNote` now produce content-only ops (`$getParticularNodeOps`'s new `contentOnly` option), the shape a note's contents already have, and `takeOpsAfterNote` deletes what it carries by the nodes' apply-coordinates length (`$getApplyLength`), since the ops no longer count the glyphs. - Applying a char span in editable marker mode added its glyphs but advanced past its text alone, so the ops after it landed inside the span's opener. `$handleCharText` now gives a fresh span the separator after its opener, as a load does, and counts everything it added. - Plain text inserted at the end of a nested span went into the outer span; it now goes after the outermost span. - A milestone vanished in every view: the op producer writes a milestone under `milestone`, and the applier read only `ms`. It now reads both. Standard view still splits a carried `\nd L \+wj M\+wj*\nd*` into two `\nd` spans, pinned with `it.fails` in noteCarriedText.test.tsx. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(platform): pin copying a whole note through the editor's own copy A host's copy-this-note button selects the whole note, calls EditorRef.copy() and puts the selection back with setSelection(). Pin that the sequence writes the USFM a Ctrl+C of the note writes (no display NBSP, a stored NBSP as ~ or U+00A0 by setting, no closer an unclosed note lacks, no text after the note), applies the copy limit (a note that does not fit copies nothing), and leaves the selection, the document and a pending marker edit as they were when it runs in one synchronous handler. The mount helper takes a copy limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(platform): read a pasted no-break space as a space only after an opening marker A paste turned a U+00A0 right after any marker token, or right before one, into a space. With allowInvisibleCharacters on, Standard view's own copy writes a stored no-break space as U+00A0, so copying one that sat after a closer or in front of a marker and pasting it back saved a space. Only a U+00A0 directly after an opening marker (\nd, \+nd) is now read as a space: the marker's separator, as ParatextData reads it. After a closer or a milestone's \* self-closer, and in front of any marker, a U+00A0 is kept as a stored no-break space. A foreign copy of the display that carries a run's or a note layout's spacing there (the⍽\nd, \f +⍽\fr) now keeps it as a no-break space rather than a space: nothing is lost. The two own-copy round-trip rows that pinned the old reading as expected failures now pass; the paste suites are re-pinned to the new reading, and a pasted \nd~Lord\nd* is pinned to keep every byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): emit milestone ops under the ms key the op types declare `$getMilestoneOp` wrote a milestone as `{ insert: { milestone: … } }`, but `OTEmbedTypes`/`validOTEmbedTypes` declare `ms`, and so do `isInsertEmbedOpOfType` and the applier. A host reading ops through the public type guard never saw a milestone, and `getInsertedNodeKey` (which only recognises `validOTEmbedTypes`) could not resolve one. The producer now writes `ms`; the applier keeps reading `milestone` too, so ops from earlier builds still apply their milestones. Tests that pinned `milestone` (adaptor ops, the shared converter fixtures) now expect `ms`, the applier's `milestone` test is named as the legacy-key case, and a produce-then-apply round trip pins the producer key and that both milestones survive with sid/eid/who. Before the producer change the round trip's apply half already passed (4e43000f made the applier read both keys); the key pin failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): build adjacent char ops of different attributes as separate spans Applying ops joined a char op into the span before it, or into the span at the insertion point, when the two matched on marker and cid alone. So the footnote editor's round trip (getNoteOps, applyUpdate, replaceEmbedUpdate) turned `\w a|lemma="x"\w*\w b|lemma="y"\w*` into `\w ab|lemma="x"\w*` in every view, and an op inserted right before a `\w` span of another lemma went into that span's text, losing its own lemma (and, for a nested char array, its outer span). `$hasAllSameCharAttributes` (shared) compares the marker, the cid and every other attribute. `$createNestedChars` and `$handleCharText` use it to decide whether an op continues an existing span, and `$charNodeTransform` uses it in place of its own attribute comparison. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): keep adjacent spans that arrive in one paste apart In the views that hide marker glyphs, `$charNodeTransform` joined two alike spans that a paste inserted side by side, so pasting the editor's own copy of `\w a\w*\w b\w*` or `\nd A\nd*\nd B\nd*` saved one span. Standard view, and Paratext, keep them as the two spans the copied content held. `CharNodePlugin` records the char spans each clipboard insertion brings in (`SELECTION_INSERT_CLIPBOARD_NODES_COMMAND`, and the protected-document paste and drop, which insert directly), and `$charNodeTransform` keeps two of them apart for the rest of that update. A pasted span still joins an alike span the document held, so pasting a piece of a span inside a span of the same marker extends it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(platform): rebuild the committed dist for the merged paste and ops fixes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): count an author's `~`-only char span in delta-doc positions - The ops stream emits a char span whose whole text is an author's no-break space (`$isLoneNbspContent`), but delta-doc counting still took every lone U+00A0 that is a span's only child as the empty span's placeholder and counted it 0. Every delta-doc position after such a span (`\nd ~\nd*` in formatted, paragraph-structure and the other views without a separator after the opener) was one short of the ops stream, so a fast-path keystroke after it went out with a retain one too small. - The placeholder check now asks `$isLoneNbspContent`, as the producer does. Apply coordinates are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): keep a note's `~` that follows the opener's separator in delta ops - In editable marker mode (unformatted) the text after a note span's opener is the separator followed by the author's text. For `\ft ~\ft*` or `\fk ~\+wj x\+wj*\fk*` that is two no-break spaces; the producer stripped the separator and then asked `$isLoneNbspContent` about the node, whose whole text is not one U+00A0, so the author's `~` was taken for layout and dropped from the note's ops. `getNoteOps` and `getEditorDelta` then disagreed with `getUsj()`, which keeps it. - A lone NBSP left after stripping a separator is now content; the position rule is asked only about text that kept its node's every byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): give an applied `~` the separator after a span's opener in editable mode - `$createNestedChars` told an empty span's placeholder from first content by its bytes, so an op whose text is one U+00A0 (an author's `~` as a span's whole text, or leading a nested span) got no separator after the opener in editable marker mode. Saving then stripped the `~` as the separator: in the unformatted view, the footnote popover's save and the Simple-mode pane round trip both lost `\ft ~\ft*` and `\fk ~\+wj x\+wj*\fk*`. - The placeholder is now known by where it came from (an empty op's text), carried through the merge recursion; every op's own text takes the separator. An empty op still gets only the placeholder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(platform): pin that saved USJ and delta ops agree on every lone `~` - `lone-nbsp-agreement.test.tsx` runs each lone-`~` shape (a span's whole text, between two spans, leading an element-first span; in body text and in a note) through every view in both settings of `allowInvisibleCharacters`: the saved USJ keeps it, `getEditorDelta` carries the same lone NBSPs, delta-doc positions after it match the ops stream, and a note's `~` survives a save of its own ops, the footnote popover's round trip and the footnotes pane's round trip. Text typed into an element-first spacer or an empty span's placeholder saves as content. - The popover round-trip suite gains an authored `\fq ~\fq*` and `\fk ~\+wj x\+wj*\fk*` through the popover's load and save, in the editing views and unformatted. Tests adapted from Jolie Rabideau's #36. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): keep a newly marked span apart from an alike span beside it In the views that hide marker glyphs, applying a character marker to the text right after a span of the same marker joined the new span into the old one: the marker menu or `insertMarker('nd')` on "God" after `\nd Lord\nd*` saved `\nd LordGod\nd*`. Paratext 9 and Standard view make a second span. `getUsjMarkerAction` records every span a marker apply creates (`$recordNewlyMarkedSpan`), and `$charNodeTransform` keeps a recorded span apart from an alike span the document already held. The pieces one apply creates still combine with each other; Enter-then-Backspace rejoining, the op applier's nested fragments, a paste extending a span, and `extendCharacterMarker` record nothing and keep combining. A selection that reaches into part of an alike span and past it now extends that span instead of nesting a copy inside it. This deliberately departs from Paratext 9, which mangles the line in that case (TJ, 2026-10-27). Standard view kee…
…rpus (#29) * feat(utilities): let usjToUsxString stamp a caller-specified USX version usjToUsxString(usj, { usxVersion }) stamps the requested version on the root element instead of always writing USX_VERSION. USJ cannot carry the source USX version (the editor regenerates usj.version everywhere), so a host that needs its USX back at the version ParatextData served it must say so explicitly. Default output is byte-identical to before. Part of the PT-4197 data-fidelity audit; lets paranext-core drop its post-hoc version="3.1" string replacement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: composed PDP round trip, USX-first net, corpus extension (PT-4197) Pins the full load/save trip over the real per-chapter USX the paranext-core PDP produces (vendored under testUsfmCorpus/pdp-usx/): - corpus-pdp-usx-composed-round-trip: USX -> USJ -> mounted editor -> USJ -> USX per capture, in Standard, formatted and paragraph-structure views, with a pinned expected-divergence list (an unlisted divergence fails; a listed one that stops happening fails). - testusfm-usx-usj-usx-round-trip: the USX-first converter net, so an asymmetric usx<->usj pair cannot hide from both directions. - corpus-testusfm-round-trip and corpus-transform-fixed-point now run every fixture in all three editing views (they were Standard-only). - New fixtures testUSFM-2SA-4 (NBSP shapes, verse bridges/segments, \fe, \x, \cl/\cd, callers, \ref), testUSFM-2SA-5 (damaged-but-at-rest: nameless markers, double \ca, chapter-line text) and testUSFM-1SA-1 (book intro), with .usj derived by the documented recipe (provider USX + usxStringToUsj; 2SA-5 hand-corrected to the intended marker:"" shape). Known losses are routed through each suite's expected-failure mechanism, attributed to scripture-editors #11, #19 or PT-4391, so a merge that fixes one flips the pin visibly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: drop the pins SE #11 fixed (PT-4197) SE #11 fixed the leading-NBSP strip in a char span, the double-\ca fold in the tokenizer, and the nameless-marker converter bug, so their pins flipped as designed. The 2SA-5 .usj no longer needs a hand edit: the converter now writes exactly what it holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep an authored lone-NBSP content string outside Standard view (PT-4391) Outside Standard view a data NBSP is held as a literal NBSP, so a content string that is exactly one NBSP (`\nd ~\nd*`, `\p ~`, a `~` between two spans or after a note) was byte-identical to the engine's lone-NBSP display nodes and the editor->USJ conversion dropped it, already at load. The conversion now drops a lone NBSP only in the slots the engine's display nodes occupy: right after an editable glyph, right after a read-only glyph inside a span, first in a span or note, after a collapsed note's caller or span, or inside an attribute run (tagged/token nodes and the mark cases are unchanged). The one slot an author's `~` shares with a display node - a span's first content in the marker modes without a separator, where the empty-span placeholder sits - is disambiguated by a new node state, `authoredNbspState`, that the forward adaptor's createChar sets. The same rule is the live `$isLoneNbspContent` (node.utils.ts), used by the logical content model and the Tier-2 fragment flattening so annotation positions and settles agree with the export. CharNodePlugin no longer treats an authored `~` as an empty span's placeholder when text is typed beside it. Flips the pinned drops: constructed-usj-round-trip's paragraph-leading lone NBSP, the 2SA-4 entries in the composed PDP round trip and the testUSFM round trip (adaptor and transform legs), the formatted-view leading-NBSP pin, and the logical-model spacer pins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: one shared registry of editor views and one mountInView (PT-4197) Every per-view list the editor suites iterate is now a projection of EDITOR_VIEWS in editorViews.test-helpers.tsx: the location oracle's ORACLE_VIEWS/oracleView, the corpus suites' ROUND_TRIP_VIEW_CONFIGS and EDITING_VIEW_CONFIGS (moved out of corpus-data.ts, which is compiled with the library and so cannot import a test helper), and the new EDITING_VIEWS (standard, formatted, paragraph-structure). The two private Editor mounts (settledGetUsj's mountEditor and the location oracle's mountInView) merge into one mountInView(usj, view, opts); mountStandardViewEditor/mountExpandedNoteEditor are thin wrappers. Also adds footnotePopoverView(host) (mirrors paranext-core's FootnoteEditor options), forEachView, knownFailureRunner, and noteEditorRef's optionsInView(view). The Standard-with-expanded-notes round-trip label is now its registry name, standard+expandedNotes (was standard-expanded). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: run the position oracles in formatted and paragraph-structure (PT-4197) logicalModel.oracle gains formatted and paragraph-structure (no separators); canonicalLocations gains paragraph-structure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: run the footnote popover round trip over every editing host (PT-4197) note-ops-popover-roundtrip mounts its host in Standard, formatted and paragraph-structure, and its popover with footnotePopoverView(host), the options paranext-core's FootnoteEditor builds (so the Standard popover now also carries isNoteShellEditable: false, as the app's does). Glyph shape and the Tier-2 fixed point are asserted only over an editable host; ops/USJ idempotence and the Save write-back hold in all three. New: Enter inside the popover's note content. It keeps the note whole in every view, but over a hidden-marker host (formatted, paragraph-structure) Enter is Lexical's own paragraph split: the caret leaves the note for a new scaffolding paragraph, so the keystrokes that follow are outside the note and the popover's Save (note op only) drops them. Pinned as known failures. knownFailureRunner now returns a registrar that appends the reason to the test name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: run the corpus transform legs through the real Editor per view (PT-4197) corpus-transform-fixed-point and the testUSFM transform leg mounted only CharNodePlugin, MarkerEditPlugin and TextSpacingPlugin, so in formatted and paragraph-structure the transforms those views actually run (the note, paragraph and paragraph-marker-prefix plugins) never ran. Both now mount the real Editor in each editing view through mountInView, dirty every node once and read the document back with the adaptor Editor uses on a local change. The composed provider-USX suite shares the same runEveryTransform helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: annotation and undo rows in paragraph-structure and every editing view (PT-4197) exactAnnotationLocations rows that stopped at standard/formatted (figure caption hold, undo/redo, comment inside a caption, an NBSP inside a char span, labels at a range's end) also run in paragraph-structure. undoReachesFile's plain-text undo/redo control runs in all three editing views. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: navigation-then-edit save pins in every editing view (PT-4197) navigationThenEdit and scrRefNavigationThenEdit run in Standard, formatted and paragraph-structure: an edit right after a navigation must still reach onUsjChange (a lost onUsjChange is a lost save). Each row starts from no document selection. Without that, a row that follows another inherits jsdom's stale selection and stops detecting the leak it pins: with a cursor-change tag forced onto the navigation's caret placement, only every other row failed until the reset was added. scrRefNavigationThenEdit also asserts the reported USJ carries the note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: host change-notification rows in every editing view (PT-4197) onUsjChangeSettled's view-independent rows run in Standard, formatted and paragraph-structure: once per content commit (a new plain-text row; the attribute row stays Standard's), nothing for a selection-only commit or a commit that changes no bytes (after a load, a reload, and a paragraph touch after a lossless or double-space load), a local applyUpdate announced once, and every remote applyUpdate row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: Editor undo and character-marker ref rows in every editing view (PT-4197) The verse-spanning-delete undo regressions (guarded and native) and the removeCharacterMarker/replaceCharacterMarker/extendCharacterMarker ref rows that mounted the library's default (formatted) view now run in Standard, formatted and paragraph-structure. Two extendCharacterMarker rows fail in Standard view and are pinned as known failures: coalescing two \bd runs leaves two adjacent \bd spans, and extending \bd with \nd as a conflicting marker removes \nd but never applies \bd while reporting success. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: note EditorRef suites in every editing view (PT-4197) noteIndexAndHighlight, applyUpdateDomSelection and noteLooseTextApply mount through optionsInView(view) in Standard, formatted and paragraph-structure. The one row whose premise is Standard's display of an unclosed note's caller (plain caller text, not an immutable caller element) stays editable-marker only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: paragraph split and deletion content rows in every editing view (PT-4197) charStackParagraphSplit and paraWholeDeletion pin the marker-edit engine on hand-built Standard trees. Each gains real-Editor rows over Standard, formatted and paragraph-structure judged on the saved USJ: Enter inside \nd (and inside \wj \+nd) keeps both halves styled, and a deletion of a paragraph's text, or across a paragraph boundary, keeps the content the selection did not cover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: word the navigation row-isolation comment as the hazard it is (PT-4197) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: satisfy the typecheck in two per-view rows (PT-4197) The popover suite's known-failure map is a plain string map, and the deletion rows' Delete keypress checks the editor root for null itself (the marker-edit requireDefined admits null). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: never save an empty span's placeholder or drop an authored `~` (PT-4391) Follow-up review fixes to the lone-NBSP content rule outside Standard view. - CharNodePlugin: merging adjacent same-attribute glyph-less spans no longer carries an empty span's placeholder into the merged content. Landing after a nested span it read as content (the regression), and after a text-ending or `~` span Lexical merged it into the text before (`x~`, `~~`). The empty side now gives up its children and a caret on its placeholder moves to the seam. An author-marked `~` is never treated as a placeholder. - Remove-style ($removeCharNodeKeepingContent) unwraps an author-marked lone `~` instead of deleting the span with it. - NoteNodePlugin stripped a leading NBSP from any note char text on any transform (`\ft ~x` saved `\ft x`; typing after `\ft ~` lost it). It now uses CharNodePlugin's placeholder rule, which drops only a byte typed over a span that held nothing but the unmarked placeholder. - $isCharContentEmpty counted any NBSP-only text as empty, so a close-and-reopen after a `~` span's `~` dropped the span with its `~` (unformatted, formatted, paragraph-structure). It now strips only the separator and asks $isLoneNbspContent about a lone NBSP. Tests: gesture-level guards through the real Editor in every view (lone-nbsp-gestures), CharNodePlugin merge/typing and NoteNodePlugin units, $isCharContentEmpty units, and live/serialized agreement rows inside spans and notes (read-only glyphs, collapsed-caller notes, author-marked nodes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: preserve the USX/USJ version end to end (PT-4197) A document now leaves the converters and the editor at the version it arrived at, instead of being re-stamped 3.1 and forced back to 3.0 by the host. - `usxStringToUsj` keeps the root `<usx version>` (USJ_VERSION only when the USX declares none or an empty one); `usjToUsxString` writes `usj.version` (USX_VERSION only when the USJ has none). - `Usj.version` is widened from `typeof USJ_VERSION` to `string` — a public type change, recorded in the utilities CHANGELOG and api report. - The `usxVersion` option on `usjToUsxString` (Unreleased only) is removed; the round-trip audit suites now round-trip the version with no help, and the testUSFM USJ -> USX -> USJ net compares whole documents, version included. - The editor remembers the version of `defaultUsj` / each `setUsj` and stamps it on every USJ it builds (load, local commit, apply, stale refresh, settled read via `$settledUsj`). The reverse adaptor takes the version to stamp; internal scratch USJ fed to the forward adaptor is unchanged. Loading a non-USJ_VERSION document no longer warns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Swallow Enter inside an expanded note where markers are not editable Outside Standard view nothing turns Enter in a note into a \fp break, so Lexical split the paragraph holding the note and left the caret outside it; what was typed next was lost on Save. NoteEnterGuardPlugin claims KEY_ENTER, INSERT_PARAGRAPH and INSERT_LINE_BREAK at critical priority while the selection touches an expanded note, in every markerMode but "editable". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(platform-editor): save no space typed right after a char opener, as Paratext 9 Standard view saved a space typed directly after a char span's opener as content (`\nd things\nd*` -> content " things"). Paratext 9 skips all whitespace after any opening marker (UsfmToken.Tokenize), so its Standard view saves `\nd things\nd*`, and so does ParatextData on Platform.Bible's own save path (ConvertUsxToUsfm runs NormalizeUsfm) — the editor's getUsj() was the only place the space survived, so the editor reported a document the file never held. The span's content now drops its leading plain spaces on save, the rule a paragraph's start and the text after a verse number already follow: the screen keeps the typed space and the caret after it; the file does not get it. The logical position model drops the same spaces so positions agree with getUsj(). A span holding nothing but spaces keeps them (a selected space wrapped in a span), since dropping that would join the words around it. Catalog row O-5 (PT-4197). Pins updated deliberately: typedSeparatorSpace, separatorGapSettle, verseAdjacentTyping, charAttributeTypedSettle (Standard only; unformatted keeps whitespace), the adaptor caller-lookalike case, the logical-model unit and oracle tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(platform-editor): report the space before a verse after a char span while pending When a settle creates a verse directly after a char span (e.g. a `\` typed in front of `v 2 b` after `\w grace\w*`), TextSpacingPlugin's verse transform puts a space between them once the verse lands. The read-only settle behind a pending getUsj() creates no live nodes, so it reported the span and the verse touching, then the same bytes came back spaced after the settle. The read-only settle now mirrors the transform over the output of every paragraph or chapter scope it re-tokenizes. A scope the live settle only renames in place (signature unchanged) re-creates no verse, so it is left alone on both sides. Paratext 9 has this space too: UsfmToken.Tokenize inserts one between a closing marker and a following \v on every read, and the file is the same with or without it (the writer puts a line break before \v) — measured with ParatextData 9.5.0.24. Catalog row O-7 (PT-4197). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(platform-editor): rebuild committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep pasted prose inside the char span or note it is pasted into Lexical inserts a paste's nodes (internal flavor, or text/html, which is what Ctrl+V delivers) with RangeSelection.insertNodes, which splits every inline ancestor of the caret up to the paragraph: text pasted inside \nd Lord\nd* split the span and landed between the halves, and text pasted inside a note landed in the paragraph after it (cut + paste back inside a note moved the text out). A SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler now inserts a payload of plain prose as text when the paste starts in ordinary text inside an inline element, the way typing it would. Structured payloads are still Lexical's. Found by the PT-4197 clipboard corpus sweep (finding CB-2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the no-break space a cut leaves as a char span's only text In the views without char glyphs a span's lone NBSP is also the empty-span caret placeholder, which the save drops unless the text is tagged as the author's. Only the USJ-to-editor adaptor tagged it, so cutting or deleting `trail` from `\nd trail~\nd*` saved an empty span: the NBSP outside the selection was lost. A CharNodePlugin transform now tags a lone-NBSP text in a glyph-less span as authored when that text held more than the placeholder in the last committed state. Found by the PT-4197 clipboard corpus sweep (finding CB-4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Sweep copy, cut and paste over the testUSFM corpus in every editing view Standard view's USFM clipboard has a corpus sweep; formatted and paragraph-structure had none. The new suite samples whole verses, ranges starting or ending inside a char span, ranges holding a footnote, crossing a paragraph boundary or a verse number, spanning a paragraph, and inside note content, then copies and pastes (all flavors, html+plain as Ctrl+V delivers, plain), cuts, cuts and pastes back, and pastes USFM-looking text, checking each result and its undo/redo against a USJ-level model. Pinned with knownFailureRunner (68 rows): - paragraph-structure: a copy across a paragraph boundary carries the gutter glyph (\q1) in text/plain, so CommandMenuPlugin refuses its paste (CB-1); - hidden views, html flavor: char attributes (closed) are not carried and a note's display spacers come back as text (CB-3); - Standard reference rows over damaged USFM (2SA-5) and one derived-sid loss. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Rebuild the committed dist for the clipboard fixes Carries the inline-prose paste and the remaining-NBSP transform into packages/platform/dist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: typing-fidelity oracle harness for the hidden-marker views (PT-4197) A keystroke oracle for formatted and paragraph-structure, where no pending marker edit exists to settle. The expectation for each keystroke is computed from the loaded USJ alone: each leaf block read as units (a character, or an atom shown whole: verse, collapsed note, milestone, figure), a caret as a unit index, and typing, Backspace, Delete, Enter and typing over a range edited into the USJ tree there. The saved USJ must be one of the expected documents, Undo must restore the loaded USJ exactly and Redo re-apply the keystroke's. Keystrokes arrive as a browser delivers them: the caret placed with a selection change, the DOM selection made the editor's, a real keydown on the root, then beforeinput and the text insertion. Selection.modify (which jsdom lacks and Lexical's deleteCharacter needs) is supplied at character granularity. Failures are reported and listed by shape. Exports the pending-equals-settled oracle's keystroke corpus for reuse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: Delete before a collapsed note no longer throws (PT-4197) In the hidden-marker views, Delete just before a collapsed note removes its caller; NoteNodePlugin then removed the note and went on to move its first child out of the removed node, which threw ('Expected node to have a parent'), discarding the edit. Return once the note is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: joining a paragraph into an empty \b keeps its text (PT-4197) ParaNodePlugin refuses text typed into an empty blank line by clearing it. A join with the paragraph after it (Backspace at that paragraph's start, Delete in the blank line, or a range across the break typed over) also lands content in the empty \b, and the clear deleted that whole paragraph's text in the formatted view. Clear only when the paragraph after the blank line is still there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: Undo takes back Enter in front of a char span or a note (PT-4197) Lexical's history folds an update whose only dirty leaf is a text that kept its text into the previous undo step, even when tagged as a push. A paragraph break in front of an inline element is such an update in the formatted view, so Undo did not take the break back. ParaNodePlugin now marks a created paragraph's first leaf dirty as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: an author's no-break space left alone in a span is saved (PT-4197) In the views without a span separator (hidden and visible markers), a lone NBSP in a span is the empty-span placeholder unless it carries authoredNbspState, which the adaptor set only on a span text that was exactly one NBSP at load. Deleting the rest of \nd ~x\nd* (or x~) left an unmarked NBSP, and the span saved empty; an NBSP typed into an empty span lost to the placeholder drop the same way. The adaptor now marks every span text holding an NBSP (Lexical keeps node state through text edits and copies it on splitText), and the placeholder drop marks the NBSP it leaves behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: typing over a range across a span edge or a note keeps the rest (PT-4197) Lexical's RangeSelection.insertText moves the content after a range's last text to after its first text, into the inline element the range starts in, and resolves a range start at a text's end into the inline element after it. In the hidden-marker views that pulled the text after an enclosing char span inside the span (out of order), and, for a range starting just before a collapsed note, put the typed text and the rest of the range into the note, which was then removed with them. InlineRangeEditPlugin (mounted for markerMode hidden) replaces a range over more than one text of one paragraph itself: it cuts the edge texts, removes the nodes between and the spans and notes that leaves empty, and puts a point Lexical resolved into a collapsed note back beside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: run the typing-fidelity oracle in formatted and paragraph-structure (PT-4197) Two files per view (the keystroke corpus with the short testUSFM chapters; the long testUSFM and WEB chapters), paragraph-structure under Simple mode's guarded structure protection. The shapes still failing are listed with their reasons: Redo lost or a second Undo needed around an empty verse, one Enter-at-paragraph-start Undo in WEB Matthew 5, and one ignored Backspace at an element caret in 1 Samuel 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build: rebuild the committed platform output (PT-4197) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(platform-editor): rebuild committed dist for the typing and clipboard merges Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * test(platform-editor): hold each keystroke to keeping content, and pin separator removal The pending/settled oracle compared the pending document with the settled one, so content a transform dropped in the keystroke's own update was invisible: the pending document already lacked it and settled to itself. A mutation that removes a span's following text once its separator is deleted passed every check. Each edit's screen is now read as the edit left it (before transforms) and as it landed, and the landed screen may only respell the spliced one: no content byte (whitespace and marker tokens aside) may go. Range keystrokes are excluded: the oracle replaces a range with a direct insertText that skips the commands the marker-edit engine answers, so their transforms are not what a keystroke does. All four oracle views stay green with nothing new listed. Catalog row O-8 (PT-4197) reported Backspace after a `\w` opener's separator losing the span and the text after it. It does not: the bytes on screen (`\p In the \wgrace|grace\w* of God`) settle, pending and settled alike, to `\p In the ` and a new unknown paragraph `\wgrace` holding the rest — ParatextData's own reading (an unknown marker is a paragraph outside a note, a char span inside one; checked against its USX). The report came from a probe that printed only the first block. New cases pin it for Backspace and Delete across attributes, `\nd`, nesting and a note, comparing the whole document with ParatextData's USX. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(platform-editor): save a char span holding only spaces as empty, as Paratext 9 Standard view already dropped the spaces after a char opener's separator, but kept them when they were all the span held (a selected space wrapped in `\nd`). ParatextData skips all whitespace after an opener there too: `one\nd \nd*two` saves as `one\nd \nd*two` (USX `<char style="nd" />`), so the words join in the file. The editor's saved USJ now matches; the screen still shows the space inside the span. The logical position model drops the exception with it, so positions agree with getUsj(). A no-break space is data and stays: `\nd ~lead\nd*` and `\nd ~\nd*` keep it on load and with a space typed in front of it (new tests; ParatextData keeps both too). Pin updated deliberately: markerMenuApply's whitespace-only wrap now saves `["one", {nd}, "two"]` while the live span still holds the space. Catalog row O-5 follow-up (PT-4197). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform-editor): rebuild committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(shared-react): keep alike char spans apart once a deletion leaves them side by side In the hidden-marker views (formatted, paragraph-structure) CharNodePlugin combined two adjacent same-marker, same-attribute spans whenever either was dirtied, so deleting the text between `\w one|lemma="x"\w*` and `\w two|lemma="x"\w*`, or joining the paragraphs they end and start, saved one `\w onetwo\w*` and changed word segmentation (typing oracle finding T-7). Standard view keeps them apart (its spans carry glyphs), and so does Paratext 9. Two spans that were already alike, separate spans in the last committed state now stay separate. Spans an update creates (a marker applied to a run, a split's continuation, remote content) still combine with an alike neighbor, as does a span whose marker an update changes to match. - Regression tests in every editing view plus paragraph-structure (guarded): deleting the space between, and joining the paragraphs. - CharNodePlugin unit tests that expect a combine now add their spans by an update once the plugin runs (they previously relied on the plugin's registration re-dirtying the initial state, i.e. spans the document already held); new unit test for the keep-apart case. - Typing oracle: drop the "adjacent alike spans are one" allowance; both views' corpus and chapters pass without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform-editor): rebuild committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * test(clipboard): name the tickets that fix the clipboard corpus pins The paragraph-structure gutter-glyph copy pins (CB-1, 44 rows) name PT-4911, and the hidden-view html-flavor pins (CB-3, 13 rows) name PT-4921, in a TODO beside each rule and at the head of its reason. The Standard-view damaged-USFM and derived-sid rows have no ticket yet and are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(shared-react): arm a chapter for the guarded two-press delete Under "guarded" structure protection (Simple mode) a chapter marker beside the caret was outside the two-press guard: Delete at the end of the block before `\c N` removed the chapter on the first press with no arming, and Backspace at the start of the paragraph after it armed that paragraph, whose merge then did nothing because a paragraph cannot merge into a chapter (typing oracle finding T-10). $structuralDeleteTarget now resolves the atomic chapter glyph (ImmutableChapterNode) beside a block edge as a "chapter" target. The first press node-selects it (as for a verse) and the armed chapter carries the verse-selected class, so it shows the armed styling; the second press removes it and returns the caret where the first press found it. The opposite key cancels. No host hint kind is published for a chapter (the host's hint copy is verse/selection-only), matching paragraph merges. Standard view (editable chapter text), "protected" (still refused) and "off" (Power mode) are unchanged. Tests: guardedChapterDeletion.test.tsx in formatted and paragraph-structure views - Backspace after / Delete before `\c 1` and `\c 2` arm then delete (guarded), cancel on the opposite key, refused under protected, native under off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(platform-editor): keep a nameless character marker's empty name on export ParatextData reads a bare `\` inside a note (`\f + \ft \ b\f*`) as `<char style="" status="unknown">`. The converters already keep the empty style as `marker: ""`, but the editor's export turned an empty char marker into no marker at all, so getUsj() handed back a char with no name and the USX written from it was not the USX loaded. createCharMarker now keeps `marker: ""`, the way nameless paragraphs already round-trip. A char with no marker at all in its USJ (not a shape USX can express) now exports as `marker: ""`, as an absent paragraph marker already exports as `p`. Tests: namelessChar.test.tsx loads ParatextData's captured USX for an unclosed and a `\*`-closed nameless span in a footnote, and asserts getUsj() and the USX written from it are unchanged in Standard, formatted and paragraph-structure views; converter tests for a char's empty style. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * docs: correct what ParatextData does with a marker-bearing \cp Comments claimed ParatextData keeps every `\cp` that holds a marker as its own marker. Measured, it folds the text before the first marker into the chapter's pubnumber and leaves the rest as loose content after the chapter; only a `\cp` whose content starts with a marker stays a `\cp`. The editor deliberately settles such a `\cp` to the USFM-spec shape: the saved bytes are the ones Paratext 9 writes, and a reload shows Paratext 9's shape. No behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform-editor): rebuild committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * feat(shared-react): publish a chapter hint kind for the guarded delete An armed chapter in Simple mode's two-press delete now sets data-verse-delete-kind="chapter" (with data-verse-delete-intent) on the editor root, as an armed verse or selection does, so the host can show a chapter-specific "press again" hint. A paragraph merge still publishes no hint kind. CHANGELOG records the chapter arming and the new value. Tests: guardedChapterDeletion (formatted + paragraph-structure) and the StructureKeyboardPlugin DOM-signal suite expect kind=chapter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * test(platform): Standard view may drop an empty derived sid on a verse paste Pasting `\v 9 x` as plain text in Standard view rebuilds part of the paragraph it lands in, and the 2SA-3 fixture's empty derived `sid`s on the re-derived verses do not survive. Dropping derived metadata is accepted (data-fidelity catalog F-8), so this is now an expectation rather than a known failure: in the paragraph the verse lands in, an empty `sid` may be dropped, and everything else (non-empty `sid`s included) must still match exactly. The row still performs the paste, undo and redo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * test: name the tickets for the remaining untracked expected failures - PT-4924: typing-oracle T-11 / T-12 / T-16 entries in both views' expected-failures JSON (in each reason). - PT-4925: Standard view's extendCharacterMarker coalesce/conflict rows (EXTEND_KNOWN_FAILURES). - PT-4388: the Standard-view 2SA-5 damaged-USFM clipboard rows and the fragment tokenizer's "testUSFM 2SA 5" known divergence (bare `\` as a nameless marker, waiting on ParatextData 9.6). Every pin and what it asserts is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix: a join into an empty \b keeps the joined paragraph, as Paratext 9 (PT-4197) Backspace at the start of a paragraph after an empty \b, Delete in the blank line, or a range across the break typed over joined the paragraph into the \b and kept the blank-line marker on text. Paratext 9's formatted view joins paragraphs the way Gecko joins two blocks: the second block survives (JoinNodeTransaction keeps the right node), so the blank line goes and the paragraph keeps its own marker. ParaNodePlugin now gives a blank \b that takes in a joined paragraph that paragraph's marker and attributes, rewriting a gutter glyph to match, in formatted, paragraph-structure and Simple's guarded two-press join. Standard view edits marker text and is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * test: a typed U+00A0 saves as a space in Standard view, as Paratext 9 (PT-4197) Paratext 9's Standard view (AllowInvisibleChars off) saves every U+00A0 in the editor as a space (UsfmHtmlUtilities.LegacyPostprocessUsfm) and the author's no-break space is typed as `~`. Ours does the same; pin it between letters, after a space and at a span's start, and pin `~` typed saving an NBSP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build: rebuild committed dist for the \b join fix (PT-4197) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(shared-react): paragraph-structure Enter drops the break's space Enter at a word break (`In| the`) left ` the` in the new paragraph in the paragraph-structure (Simple) view, while Standard and formatted save `the`. ParaNodePlugin trims a new paragraph's leading space from its first child, which in that view is the gutter marker glyph rather than the text, so nothing was trimmed. It now looks past a leading gutter glyph. Tests: typingFidelity.regressions "Enter at a word break" in all three editing views, unguarded and guarded (red in paragraph-structure: saved " the beginning"). The typing oracle no longer accepts a new paragraph that starts with text keeping its leading space (it fails 23 corpus rows without the fix). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform): rebuild the committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * feat(platform): ViewOptions.allowInvisibleCharacters keeps ~ and U+00A0 apart in Standard view A Paratext project with AllowInvisibleChars on stores a no-break space as U+00A0 itself and `~` as an ordinary character (measured with ParatextData: load, save and round trip all carry both verbatim and distinct). Standard view always applied the `~` = NBSP rules, so a literal `~` in such a project was read as a no-break space and saved as U+00A0, and a U+00A0 typed there saved as a space. With the new option on, a stored U+00A0 displays as NBSP_SYMBOL (U+237D) instead of `~`, `~` stays an ordinary character, and every layer that maps display or fragment text to data takes the spelling from getNbspSpelling(viewOptions): the load/save adaptors, the tier-2 and virtual settle tokenizer (new UsfmFragmentOptions.nbspSpelling), the Unformatted content fragments, paste, copy (U+00A0 written as itself), the Markers view's copy, and a typed U+00A0 (command and native beforeinput routes). getViewMode ignores the field. Off is unchanged. Tests: invisibleCharacters.test.tsx (load/save, edit, span and note, typed ~, typed U+00A0 by both routes, paste, copy, settle and unsettled read, Unformatted settle, Markers copy, formatted/paragraph-structure pins), tokenizer nbspSpelling, getViewMode/getNbspSpelling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform): rebuild the committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * feat(platform): toUsfmNbspSpelling for hosts that read the editor's display text A host that copies or searches text read from the editor's DOM (the footnote popover's Copy button, a Find seeded from the selection) sees Standard view's display spelling of a no-break space. With allowInvisibleCharacters on that is U+237D, which is not the project's text; this turns it back into U+00A0, and leaves the text unchanged when the option is off (`~` is the project's spelling then). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform): rebuild the committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * test(platform-editor): keep a note's empty caller empty through edits in every view ParatextData reads `\f \ft x\f*` (one or two spaces after the marker) as caller="" and writes it back as `\f \ft x\f*` (measured with the corpus capture harness), and the tokenizer has followed that since PR #11. The existing rows covered only the expanded editable views; this pins a load, an edit beside the note and one inside it, and a departure in every editor view, saving caller "". Fails in all 8 views if the exporter writes "+" for an empty caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * feat(platform): U+001A is the no-break-space stand-in, drawn as a blank, and never leaves the editor With ViewOptions.allowInvisibleCharacters on, Standard view held a stored U+00A0 as U+237D (shown as a visible box glyph). U+237D can occur in Paratext 9 text, so a literal one was saved as a no-break space. It now holds U+001A (SUBSTITUTE), which ParatextData's ScrText.GetText turns into a space on every read, so no project text can hold one. - Rendering: a one-glyph font (empty, 0.25em wide, unicode-range U+1A, NBSP_STAND_IN_FONT_FAMILY) draws it as a blank the width of a space. The editor adds the face to its document through the CSS Font Loading API (from bytes, so no CSP font-src is involved). Chromium draws a control character as a box unless the FIRST family has a glyph for it, so generateUsjCss and usj-nodes.css lead every Scripture font-family with it. - It never leaves the editor: delta ops given out (onUsjChange, getNoteOps, getOpsAfterNote, takeOpsAfterNote) carry U+00A0 for it and a space for an NBSP the view shows in a space run; applyUpdate and replaceEmbedUpdate take a U+00A0 that is not a marker separator as a stored no-break space; the internal Lexical clipboard flavor is left out when the selection holds a stand-in; a drag out carries the copy's USFM text and html; annotation callbacks and note-caller previews give U+00A0. - Incoming text: every C0 control character but tab, LF and CR becomes a space in Standard view before anything reads it (load incl. attribute values, paste, drop, typing by both routes, applied ops). - A drop into Standard view now goes in as a paste of the same data. - The fragment tokenizer keeps the spelling as a character (regularizeSpaces turned control characters into spaces first). Leaking before (measured against the U+237D build): ops out, note ops, the footnote-editor round trip, ops in (U+00A0 saved as a space), the Lexical clipboard flavor, the cut, the async paste, drag and drop, and annotation text. Forced-on run (every registry view with the option): only the 5 intended off-mode pins fail. Known gap (both settings, not changed here): an attribute value's U+00A0 is shown raw in its attribute run, and a settle of its paragraph reads it as a space. Tests: nbspStandIn.test.tsx (35), whitespaceDisplay.utils.test.ts (control characters, ops mapping), usj-nodes.css and generateUsjCss font stack, invisibleCharacters.test.tsx on the new stand-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(platform): a note caller's click hands out the note's ops with U+00A0 The getNoteOps a noteCallerOnClick handler receives read the note's ops straight from the tree, so with allowInvisibleCharacters on they carried the internal stand-in; Platform.Bible's footnote popover applied them, and the incoming rule turned each stand-in into a space (seen in the running app). They now go out through opsForHost like every other op. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform): rebuild the committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * Address review-paratext findings on the data-fidelity branch - Fix InlineRangeEditPlugin: typing over a range whose two edge texts are both wholly selected no longer falls through to Lexical, which pulled the text after a char span into the span. The plugin now steps aside only for a whole-edge deletion. Regression rows added to typingFidelity.regressions.test.tsx. - Add standardViewDrop.test.tsx: drop-as-paste rows under both allowInvisibleCharacters settings. - Add the missing CHANGELOG entries (platform Fixed section; utilities note that the Scribe editor does not yet carry the USJ version through). - Replace in-code Jira/branch references with forward-facing wording per CLAUDE.md, keeping open TODO(PT-x) markers. - Document that allowInvisibleCharacters must be right before the first load; correct the vertical-tab/form-feed wording; clipboard-semantics doc updates. - Cleanups: $collapsedNoteOf uses $findMatchingParent, ParaNodePlugin key lookup, CharNodePlugin TSDoc, un-export $isSelectionInExpandedNote, NoteEnterGuardPlugin ordering comment, corpus fixed-point test uses knownFailureRunner, reshaped half-flat test trees, comment re-wraps, demo armed-chapter CSS. - Rebuild committed packages/platform/dist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_01KiNAWjh4KAwjzn1g34EePL * fix(platform): an attribute value's no-break space keeps its spelling in Standard view Standard view showed a U+00A0 inside an attribute value as a raw U+00A0 in its attribute run, which the engine reads as display spacing, so a settle of the paragraph (Tier-2 rebuild or virtual settle) saved it as a plain space, in both ViewOptions.allowInvisibleCharacters settings. The value now takes the body text's spelling (`~`, or the U+001A stand-in with the setting on) everywhere attribute state becomes display text: - the adaptor's runs for char spans, milestones, \va/\vp, \cat, \ca/\cp and an UnknownNode's display parts (figure src, sidebar \cat, periph); - the display-run syncs, which redraw from node state without view options: MarkerEditPlugin registers the editor's spelling (registerNbspDisplaySpelling / $getNbspDisplaySpelling in shared); - the `|...` bytes an unwrapped or emptied span leaves as content, a collapsed note's copied \cat, and the annotation byte maps. The tokenizer reads the spelling back as U+00A0 inside milestone (including an absorbed list), figure and \periph attribute values, as it already did for body text, char-span attributes and the attribute markers. Unformatted view, which shows a value's U+00A0 as itself, now respells it past the run's leading separator in its settle fragment (attributeFragmentText), as it does content, instead of flattening it to a space. Tests: attributeValueNbsp.test.tsx (each producer shape in Standard view with the option off and on: display, paragraph settle, value edit read before and after the settle, copy and paste with every flavor and with plain text alone; figure; span unwrap and paragraph-break emptying; a collapsed note's copied \cat; Unformatted; formatted and paragraph-structure pins) and tokenizer cases in usfmFragmentToUsj.test.ts. The nbspStandIn chapter row now settles the chapter line instead of pinning the raw display. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * build(platform): rebuild the committed dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz * fix(shared-react): rejoin a char span that Enter split when Backspace joins the paragraphs back Keeping alike char spans apart once both are in the document also kept apart the two halves of one span that Enter cut in two, so Enter then Backspace inside `\nd thing\nd*` saved `\nd th\nd*\nd ing\nd*` in the views that hide markers. - CharNode.insertNewAfter records, per editor, which span each split continuation came from; $wereSplitFromOneSpan lets those pieces merge again. Spans the document held apart still stay apart. - Regression test in every hidden-marker view: Enter inside a span, then Backspace, saves one span. - InlineRangeEditPlugin: comment the composition gap (an input method started over a cross-span range takes Lexical's own path). - CHANGELOG entry extended. - Rebuild committed packages/platform/dist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_018WCZ5KDNh1Vqzt3JP6jCnp * fix(shared-react): keep Undo/Redo working around empty paragraphs and caret hosts - Undo after a guarded two-step removal of an empty paragraph (an empty `\b`) now brings it back. Lexical's history folds an update whose only dirty leaf is a text that kept its text into the step before, even one tagged history-push, so `$mergeParaIntoPrevious` marks the removed paragraph's leaves dirty to make the removal its own undo step. - Redo works after an Undo leaves the caret beside an empty verse or a trailing note. Caret-host commits (create, move, removal on blur) are tagged history-merge through `$tagCaretHostCommit`, so they no longer become an undo step that clears the redo stack. - Drop the testUSFM-2SA-2/2SA-3 rows from both typing-oracle expected-failure lists: the Undo/Redo shapes they pinned now pass. - Pin an author's literal `~` in a milestone attribute value turning into U+00A0 on a Standard-view settle with `it.fails` (needs a product call). - Use a code span for `USJ_VERSION` in `Usj.version`'s TSDoc so consumers' typedoc can resolve the comment. - Rebuild the committed dist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_018WCZ5GnNin2H8EzQyWWq6p * fix(platform): keep a pasted no-break space at the start of a line Standard view's paste read a U+00A0 at the start of the pasted text, or of any line of it, as display spacing and inserted a plain space. So a pasted lone no-break space became a space, one beside a space collapsed away, and with allowInvisibleCharacters on the editor's own copy of a stored no-break space pasted back as a space or as nothing (the copy spells a stored no-break space as U+00A0 there). No copy reaching the paste puts display spacing at a line start: the editor's own copy writes a plain space for every display NBSP in both readable flavors, a browser's default copy writes text/plain with every NBSP as a space, and a Paratext 9 clipboard is decoded with its NBSPs resolved. normalizePastedNbsp now keeps every pasted U+00A0 as a stored no-break space except one right beside a marker token, which is still read as a separator. pastedNbsp.test.tsx pastes each case through the editor in both settings: from a foreign text/plain and html-only clipboard, and the editor's own copy pasted back. Two rows stay expected failures with the setting on: a stored no-break space right after a closer or right before an opener copies as U+00A0 and pastes back as a space under the marker-adjacent rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(platform): stop exporting NBSP_STAND_IN_FONT_FAMILY Hosts name the no-break-space stand-in's font family in their own stylesheets, which cannot import a constant, so the export gave them nothing to use. The constant stays internal to generateUsjCss and the font registration, and the docs now state the family name, PlatformEditorNbspStandIn, which must lead any host Scripture font-family and must not change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): keep an author's lone no-break space in delta ops - The delta-op producer dropped every text that is exactly one U+00A0 inside a note's contents, and every char span whose only text is one U+00A0 (as an empty span's placeholder). In views that hold a stored no-break space as U+00A0 itself (formatted, paragraph-structure), that is also a `~` between two of a note's char spans, or a `~` that is a span's whole text. - Both checks now ask `$isLoneNbspContent`, the rule the editor-to-USJ conversion already keeps content by, so a note's ops (`getNoteOps`, which Simple mode's footnotes pane edits) carry what `getUsj()` saves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(platform): Standard view's delta ops read as USJ text in both no-break-space settings - `opsForHost`/`opsFromHost` told a marker's separator from a stored no-break space only within one op, and took any op that is exactly one U+00A0 as a spacer. The editor emits each text node as its own op, so a glyph's separator (`\nd`, then ` A`) went out as a space, and with `allowInvisibleCharacters` on a stored no-break space that is a whole op's text (a `\ft` that is only `~`, a `~` between two spans or after a note, text carried out past a note) came back in as display spacing and was saved as a space or dropped. An NBSP is now structural only right after an opening glyph, in the same op or ending the op before. - With `allowInvisibleCharacters` off, Standard view's ops went out as its display text: `~` for a stored no-break space and U+00A0 for each space of a run. Simple mode's footnotes pane edits a paragraph-structure editor's notes in Standard view, so a pane edit saved a space run as no-break spaces, and a stored no-break space showed in the pane as a blank rather than `~`. Both settings now map the same way: the stored no-break space's spelling to U+00A0 and a run's NBSPs to spaces going out, U+00A0 to the spelling coming in. - A paragraph-structure editor still reads a char span whose whole text is one U+00A0 from applied ops as an empty span's placeholder, so the pane drops that `~`; pinned with `it.fails`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(platform): mark toUsfmNbspSpelling experimental It stays exported, but is expected to be removed once Standard view holds a stored no-break space as itself (PT-4931): display text will then already carry the project's spelling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): keep an applied no-break space that is a char span's whole text - Applying delta ops built every char span's text without the mark a load puts on a span's no-break space (`authoredNbspState`). In the marker modes without a separator after a span's opener (formatted, paragraph-structure), a span whose whole text is one U+00A0 then read as an empty span's placeholder and saved as nothing: Simple mode's footnotes pane dropped a `\ft ~` (or any `~`-only span) its row editor handed back, and the same happened to a `~`-only span inserted by `applyUpdate`. - The op producer leaves an empty span's placeholder out of its ops, so an applied U+00A0 is always content. `$markAppliedNbsp` marks the text of every char-span insert (`$handleCharText`, a note's contents, an unknown embed's contents) that holds one, as the USJ adaptor marks a loaded span's text; editable marker mode is left unmarked, as on a load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(platform): expect the footnotes pane to keep a `~`-only char span The paragraph-structure main editor now marks an applied lone no-break space as content, so the row that pinned the drop with `it.fails` runs as an ordinary test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): keep a char span or milestone in text carried out past a note Text a host's note editor carries out past its note (`takeOpsAfterNote`, applied after the note with `replaceEmbedUpdate`) lost its markup: - A char span from a Standard-view note editor became an unclosed span plus a stray `\nd*` in Standard view, and its glyphs became span text in the paragraph-structure and formatted views. The carried ops held the span's marker glyphs as text, the shape of the note editor's own delta document. `getOpsAfterNote`/`takeOpsAfterNote` now produce content-only ops (`$getParticularNodeOps`'s new `contentOnly` option), the shape a note's contents already have, and `takeOpsAfterNote` deletes what it carries by the nodes' apply-coordinates length (`$getApplyLength`), since the ops no longer count the glyphs. - Applying a char span in editable marker mode added its glyphs but advanced past its text alone, so the ops after it landed inside the span's opener. `$handleCharText` now gives a fresh span the separator after its opener, as a load does, and counts everything it added. - Plain text inserted at the end of a nested span went into the outer span; it now goes after the outermost span. - A milestone vanished in every view: the op producer writes a milestone under `milestone`, and the applier read only `ms`. It now reads both. Standard view still splits a carried `\nd L \+wj M\+wj*\nd*` into two `\nd` spans, pinned with `it.fails` in noteCarriedText.test.tsx. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(platform): pin copying a whole note through the editor's own copy A host's copy-this-note button selects the whole note, calls EditorRef.copy() and puts the selection back with setSelection(). Pin that the sequence writes the USFM a Ctrl+C of the note writes (no display NBSP, a stored NBSP as ~ or U+00A0 by setting, no closer an unclosed note lacks, no text after the note), applies the copy limit (a note that does not fit copies nothing), and leaves the selection, the document and a pending marker edit as they were when it runs in one synchronous handler. The mount helper takes a copy limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(platform): read a pasted no-break space as a space only after an opening marker A paste turned a U+00A0 right after any marker token, or right before one, into a space. With allowInvisibleCharacters on, Standard view's own copy writes a stored no-break space as U+00A0, so copying one that sat after a closer or in front of a marker and pasting it back saved a space. Only a U+00A0 directly after an opening marker (\nd, \+nd) is now read as a space: the marker's separator, as ParatextData reads it. After a closer or a milestone's \* self-closer, and in front of any marker, a U+00A0 is kept as a stored no-break space. A foreign copy of the display that carries a run's or a note layout's spacing there (the⍽\nd, \f +⍽\fr) now keeps it as a no-break space rather than a space: nothing is lost. The two own-copy round-trip rows that pinned the old reading as expected failures now pass; the paste suites are re-pinned to the new reading, and a pasted \nd~Lord\nd* is pinned to keep every byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): emit milestone ops under the ms key the op types declare `$getMilestoneOp` wrote a milestone as `{ insert: { milestone: … } }`, but `OTEmbedTypes`/`validOTEmbedTypes` declare `ms`, and so do `isInsertEmbedOpOfType` and the applier. A host reading ops through the public type guard never saw a milestone, and `getInsertedNodeKey` (which only recognises `validOTEmbedTypes`) could not resolve one. The producer now writes `ms`; the applier keeps reading `milestone` too, so ops from earlier builds still apply their milestones. Tests that pinned `milestone` (adaptor ops, the shared converter fixtures) now expect `ms`, the applier's `milestone` test is named as the legacy-key case, and a produce-then-apply round trip pins the producer key and that both milestones survive with sid/eid/who. Before the producer change the round trip's apply half already passed (4e43000f made the applier read both keys); the key pin failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): build adjacent char ops of different attributes as separate spans Applying ops joined a char op into the span before it, or into the span at the insertion point, when the two matched on marker and cid alone. So the footnote editor's round trip (getNoteOps, applyUpdate, replaceEmbedUpdate) turned `\w a|lemma="x"\w*\w b|lemma="y"\w*` into `\w ab|lemma="x"\w*` in every view, and an op inserted right before a `\w` span of another lemma went into that span's text, losing its own lemma (and, for a nested char array, its outer span). `$hasAllSameCharAttributes` (shared) compares the marker, the cid and every other attribute. `$createNestedChars` and `$handleCharText` use it to decide whether an op continues an existing span, and `$charNodeTransform` uses it in place of its own attribute comparison. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): keep adjacent spans that arrive in one paste apart In the views that hide marker glyphs, `$charNodeTransform` joined two alike spans that a paste inserted side by side, so pasting the editor's own copy of `\w a\w*\w b\w*` or `\nd A\nd*\nd B\nd*` saved one span. Standard view, and Paratext, keep them as the two spans the copied content held. `CharNodePlugin` records the char spans each clipboard insertion brings in (`SELECTION_INSERT_CLIPBOARD_NODES_COMMAND`, and the protected-document paste and drop, which insert directly), and `$charNodeTransform` keeps two of them apart for the rest of that update. A pasted span still joins an alike span the document held, so pasting a piece of a span inside a span of the same marker extends it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(platform): rebuild the committed dist for the merged paste and ops fixes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): count an author's `~`-only char span in delta-doc positions - The ops stream emits a char span whose whole text is an author's no-break space (`$isLoneNbspContent`), but delta-doc counting still took every lone U+00A0 that is a span's only child as the empty span's placeholder and counted it 0. Every delta-doc position after such a span (`\nd ~\nd*` in formatted, paragraph-structure and the other views without a separator after the opener) was one short of the ops stream, so a fast-path keystroke after it went out with a retain one too small. - The placeholder check now asks `$isLoneNbspContent`, as the producer does. Apply coordinates are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): keep a note's `~` that follows the opener's separator in delta ops - In editable marker mode (unformatted) the text after a note span's opener is the separator followed by the author's text. For `\ft ~\ft*` or `\fk ~\+wj x\+wj*\fk*` that is two no-break spaces; the producer stripped the separator and then asked `$isLoneNbspContent` about the node, whose whole text is not one U+00A0, so the author's `~` was taken for layout and dropped from the note's ops. `getNoteOps` and `getEditorDelta` then disagreed with `getUsj()`, which keeps it. - A lone NBSP left after stripping a separator is now content; the position rule is asked only about text that kept its node's every byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): give an applied `~` the separator after a span's opener in editable mode - `$createNestedChars` told an empty span's placeholder from first content by its bytes, so an op whose text is one U+00A0 (an author's `~` as a span's whole text, or leading a nested span) got no separator after the opener in editable marker mode. Saving then stripped the `~` as the separator: in the unformatted view, the footnote popover's save and the Simple-mode pane round trip both lost `\ft ~\ft*` and `\fk ~\+wj x\+wj*\fk*`. - The placeholder is now known by where it came from (an empty op's text), carried through the merge recursion; every op's own text takes the separator. An empty op still gets only the placeholder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(platform): pin that saved USJ and delta ops agree on every lone `~` - `lone-nbsp-agreement.test.tsx` runs each lone-`~` shape (a span's whole text, between two spans, leading an element-first span; in body text and in a note) through every view in both settings of `allowInvisibleCharacters`: the saved USJ keeps it, `getEditorDelta` carries the same lone NBSPs, delta-doc positions after it match the ops stream, and a note's `~` survives a save of its own ops, the footnote popover's round trip and the footnotes pane's round trip. Text typed into an element-first spacer or an empty span's placeholder saves as content. - The popover round-trip suite gains an authored `\fq ~\fq*` and `\fk ~\+wj x\+wj*\fk*` through the popover's load and save, in the editing views and unformatted. Tests adapted from Jolie Rabideau's #36. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(shared-react): keep a newly marked span apart from an alike span beside it In the views that hide marker glyphs, applying a character marker to the text right after a span of the same marker joined the new span into the old one: the marker menu or `insertMarker('nd')` on "God" after `\nd Lord\nd*` saved `\nd LordGod\nd*`. Paratext 9 and Standard view make a second span. `getUsjMarkerAction` records every span a marker apply creates (`$recordNewlyMarkedSpan`), and `$charNodeTransform` keeps a recorded span apart from an alike span the document already held. The pieces one apply creates still combine with each other; Enter-then-Backspace rejoining, the op applier's nested fragments, a paste extending a span, and `extendCharacterMarker` record nothing and keep combining. A selection that reaches into part of an alike span and past it now extends that span instead of nesting a copy inside it. This deliberately departs from Paratext 9, which mangles the line in that case (TJ, 2026-10-27). Standard view kee…
Summary
Three ways editing at an editable chapter line lost what the user typed, found while working paranext-core#2816 (PT-4608), which puts a later chapter's marker back when the user deletes or damages it, because Paratext refuses a later chapter without one.
\c N.\pparked the caret ahead of the character just typed.setUsjskipped a document that undid a pending marker edit, so the host's correction never reached the screen and the caret went astray. Backspace a chapter's number out of its glyph: the edit is pending, so the glyph andgetUsj()say the chapter has no number while node state still says2.setUsjcompared the incoming document againsteditedUsjRef, which is deserialized from node state, so the host's corrected document (number put back) matched and the load was skipped. The numberless glyph stayed on screen. The host then placed a caret just past the number, which didn't exist, so the caret stayed wherefocus()left it: the end of the chapter. The next keystrokes deleted the chapter's last characters, with nothing on screen to say so.It also makes Enter on the chapter line start a new paragraph after it. Enter there had always done nothing, so there was no way to add a paragraph after
\c Nfrom its own line.It also stops a chapter line's bytes from going unsaved once they stop being a chapter marker. Backspace the
cout of\c 2, type another marker over the\c, or type ahead of it, and the bytes no longer tokenize as a chapter. The settle used to refuse them and keep them as pending literal text while the saved document fell back to the stored number: the file kept\c 2, the screen kept\ 2indefinitely, and anything typed there was never saved.Changes
ChapterNode.canBeEmpty()returnsfalse(libs/shared). Lexical then never merges a block into the chapter line, and it removes the chapter once every byte of its marker is deleted. A deletion across the chapter line now deletes the chapter marker, as it does in the USFM text, and leaves the rest of the paragraph in its own paragraph. The host restores a later chapter's marker when it saves.Enter on a chapter line starts a new paragraph after it (
chapterLine.utils.ts). With nothing to merge into, Lexical has no block to split there, and left alone it put empty paragraphs at the end of the document (Shift+Enter threw). A chapter line holds nothing but its own marker, so wherever the caret is on it, Enter now starts a paragraph right after the chapter line and moves the caret into it, the way Enter after\c Nadds a paragraph in the USFM text. It goes directly after the chapter even when a\cpparagraph follows, as in Paratext 9: a\cpcan stand on its own, so a paragraph between the two is what the user asked for. A plain Enter gives\p. A marker-menu pick (EditorRef.splitParagraphWithMarker, or a paragraph picked throughapplyMarkerMenuSelection) gives the picked marker; those splits bypassINSERT_PARAGRAPH_COMMAND, so$splitParagraphWithMarkerhandles the chapter line itself. Before this branch, Enter on a chapter line did nothing. Shift+Enter is refused, including over a selection lying wholly on the chapter line, which is left as it is.The marker menu counts the chapter the caret is on as coming before it (
markerMenuContext.utils.ts), since a paragraph applied there goes after the chapter. Without this, the Enter menu on a later chapter's line saw no chapter ahead of the caret, took the caret for the book introduction, and put\ipfirst instead of\p.The
\preset keeps the caret after the typed text (markerEditDeletion.utils.ts). It moves the caret only when it would otherwise sit in front of the new prefix, as the prefix injection already does for a split.setUsjkeeps an edit in progress, and a host can force a correction (Editor.tsx, new exportedSetUsjOptions). While a marker edit is in progress the editor holds two documents: what is on screen (whatgetUsj()settles) and its node-state record from before the edit.setUsjnow does nothing when handed EITHER, so a host re-sending the text as it was does not throw the edit, the caret and the undo history away, and an echo of what is on screen does not reload. But a correction can be exactly the pre-edit record — a chapter number the user backspaced away, put back — and the editor cannot tell that from a re-send, so the host says which it means:setUsj(usj, { force: true })loads regardless.Marginalpasses the option through; the API report is updated.SetUsjOptionsandforceare markedEXPERIMENTAL:, likeapplyUpdate, so they can still change without a breaking release.A chapter line whose bytes stop being a chapter marker is saved as what they are (
tier2Rebuild.utils.ts$rebuildChapterandchapterFragmentContent, mirrored invirtualSettle.utils.ts$settledChaptersogetUsj()sees it straight away). The chapter's region is replaced by what its bytes tokenize to:c 2becomes a markerless line of text,\ 2a paragraph with no marker name (what ParatextData reads those bytes as),\q1 2becomes a\q1paragraph, andX\c 2becomesXfollowed by the chapter, which the host's save repair then turns into\c 2withXon the line below it, as Paratext 9 saves it. When a paragraph comes before the chapter, leading text like that joins the end of it instead (\mt1 Ruth/X\c 1→\mt1 Ruth X,$joinLeadingLineToPreviousParagraph, mirrored forgetUsj()): USFM reads text standing behind a line break with no marker of its own as part of the paragraph before it, so that is where the save and the reload put it. A space goes between them only where the line break would read as one (not after text already ending in whitespace, a verse marker, or an empty paragraph's own separator). That is what Paratext 9 keeps, since it saves the bytes it holds. The tokenizer's own\pfor leading text is unwrapped, because nobody typed it, and the glyph's trailing separator is dropped. The host's save repair then puts back a marker the chapter needs above that text (paranext-core#2816). A line left empty waits for the caret to leave before the live settle removes it.A nameless marker survives the USX/USJ converters (
usx-to-usj.ts,usj-to-usx.tsinpackages/utilities). A bare backslash (\ 2) is a marker with an empty name: ParatextData reads it as<para style="">and writes that back as\ 2, and Paratext 9 keeps the bytes as they are. Both converters tested the name for truthiness and dropped it, so the line reached the editor with no marker, which shows it as\p, and the next save wrote\p 2. Both now test for presence, assetAttributesalready does for every other attribute.A note, character marker, verse or milestone inserted on a chapter line goes where Paratext 9 puts it (
chapterLine.utils.ts$moveCaretOffChapterLine, called fromgetUsjMarkerActionand the marker menu's note path). The chapter line is no longer a block (change 1), soRangeSelection.insertNodesthrew there ("expected a block ElementNode ancestor") —EditorRef.insertMarker("f")with the caret where paranext-core leaves it after a correction, for example. Content put after a chapter number goes, in Paratext 9 and when typed here, on a markerless line straight after the chapter line; a following\cathen follows it on that line and a following\cpkeeps its own line, and neither is the chapter's attribute any more:\c 1 ^/\ca 2\ca*→\c 1/^\ca 2\ca*, and\c 1 ^/\cp 2→\c 1/^/\cp 2. The insertion gets there the way typing does: the chapter's bytes are re-tokenized ($rebuildChapter) with one character standing in for the content after the chapter number, and the caret takes that character's place. With the caret past the chapter's\carun, the stand-in goes after the run instead, so the run stays the chapter's (\c 1 \ca 2\ca*^→\c 1 \ca 2\ca*/^), as typing there does. With the caret in the chapter's\cprun, the content goes on a markerless line after the chapter line and the\cpstays the chapter's (\c 1 \cp B^→\c 1 \cp B/^): that is what ParatextData makes of the marker's bytes put at that caret (\cp B\f …\f*), and it reads back unchanged. No re-tokenize happens there, since a stand-in after a\cprun would be read as more of the published number. Typing at that caret still goes into the published number, as those bytes do in Paratext 9. The stand-in is a Unicode noncharacter (U+FDD0), looked for only among the nodes the re-tokenize produced, so a private-use character in a project's text is never taken for it; and a separator goes before it when the chapter glyph has lost its trailing space, which would otherwise join it to the number. A selection lying wholly on the chapter line (part of the number selected) inserts in the same place rather than wrapping or replacing the number.A paste or drop on a chapter line goes in as it would be typed there (
chapterLine.utils.ts,MarkerEditPlugin.tsx). Lexical's clipboard insertion (a drop, a same-editor rich paste) splits at every line break by callinginsertParagraphdirectly, and inserts nodes at the caret, and a chapter line has no block for either. So a paste or drop on a chapter line is claimed at CRITICAL and inserted through the same codemain's Standard-view paste uses (its insertion tail, extracted unchanged as$insertPastedText):\c/\iddropped, NBSPs normalized, the first line onto the chapter line and each later line a paragraph after it. A drop reads its data as a paste does (getDataTransferPayload, Paratext 9 html decoding included).Structure protection covers pastes (
whitespaceDisplay.plugin.utils.ts,chapterLine.utils.ts). UnderstructureProtectionMode: "protected"the keyboard cannot add a structure marker (Platform.Bible's marker menu refuses every paragraph marker and\v), but a paste could: pasted text re-tokenizes, soaa \v 9 bbadded verse 9, and a same-editor copy (which went to the html sanitizer) added a verse and split the paragraph. A protected paste now drops every marker the stylesheet types as a paragraph, and\vwith its number (stripPastedBlockMarkers), keeping the text; character markers, notes and figures stay. This applies to a paste into or touching attribute text (a|…run, a\vavalue) too: there\v 9became a verse and\psplit the paragraph. A protected same-editor copy takes the same path instead of the sanitizer. A marker the stylesheet does not know is kept: nothing says for sure that it is structure, and the stylesheet the editor is given includes the project'scustom.sty, so a marker it does not know could be a project's own (it is read as a paragraph in body text, as Paratext 9 reads it, and as it is when typed). On a chapter line, a protected paste that would rewrite the chapter's own marker (a range touching the line, or a caret anywhere but just past its end) is refused and changes nothing, as pasting over a verse number is.docs/clipboard-semantics.mddescribes the three things protection now changes, including in attribute context.The caret's place on a chapter line that is no longer a chapter is reported exactly (
positions/settledScopes.utils.ts). With PT-4370: Settled positions across the public position API #11's settled positions,getSelection()reports the caret in the documentgetUsj()returns, by comparing what is on screen with what it settles into. A chapter scope's settled side was built only when it still held a chapter, so for\ 2or\sc 2a caret anywhere on the line came back as the start of the line, and paranext-core#2816's repair could not put it back where the user was. It is now built as any other settled root content, so the caret is reported at its place:$.content[0]['marker']just past the backslash, or an offset in the line's text.A chapter line that settles into more than its chapter is read the same way by every reader (
$settledChapterScopeFragmentintier2Rebuild.utils.ts). With text typed ahead of the marker or the line become\ 2, both settles carried annotation marks only when the chapter came first, so a mark beside the chapter was dropped; and when that text joins the paragraph before the chapter, the position plan did not know, so while the edit was pending every top-level index from there on was reported and resolved one item off. The chapter's position plan now takes in the paragraph a leading line joins.A key acts on the caret the browser just moved (
KeyDownCaretSyncPlugin, mounted first among the critical-priority key handlers). The browser moves the caret itself and reports it throughselectionchange, which it fires asynchronously, so a key handled before that event acted on the old caret: Home then ArrowRight on a chapter line could jump to the next paragraph, and End, ArrowLeft three times and Backspace could delete the line's trailing space instead of thec. When a key arrives and the DOM's caret reads back as a different position from Lexical's, Lexical's is replaced; a caret at the DOM position Lexical renders is kept, so a position the editor chose among several at one DOM point is left alone. This is editor-wide and was the same onmain; it is here because paranext-core#2816's chapter-line specs hit it.ChapterNodeis shared with the scribe package, so (1) changes scribe's chapter-line deletion the same way.Known limitation
Under protection, dragging html onto a chapter line still reaches
StructureKeyboardPlugin's drop sanitizer, which throws Lexical's "expected a block ElementNode ancestor" invariant there, so the drop does nothing (reported throughonError; the editor state is left as it was). It needs the caret to have been on the chapter line before the drop, since that handler inserts at the pre-drop selection, and nothing is lost. Typing over the chapter number in a protected document still changes it: protection does not treat the chapter marker as structure. Left alone on purpose.Relation to #11
Change 3 is the same kind of bug as #11 (PT-4370, merged 2026-10-21): a pending edit makes the live tree and the settled document disagree. #11 translates positions between the two in
getSelection/setSelection; this fixessetUsj's equality check. Merging the two showed where they met: #11's position mapping assumed a chapter scope settles into a chapter, which this branch's change to chapter-line settling broke (the first two changes above, after "Structure protection covers pastes").Testing
chapterLineEditing.test.tsx: every deleting gesture across the chapter line (Delete, Backspace, both selection directions, typing over, typing a key at a time, cut, Enter, multi-line paste, select-all-and-type) keeps the text, read back throughgetUsj(), including a chapter line only partly selected and a chapter whose marker alone is deleted. On the chapter line itself: Enter at its start, middle and end starts a\pafter it (undone in one step, and before a following\cp), marker-menu picks start the picked paragraph, the Enter menu puts\pfirst, Shift+Enter does nothing (also over a selected number), a paste, a drop and an html-only drop go in as typed, a pasted\cis dropped, a same-editor paste works under protection, and protected pastes that would rewrite the chapter marker are refused.protectedFigurePaste.test.tsx: under protection a pasted verse or paragraph marker is dropped with its text kept (plain text, multi-line, a same-editor copy, and inside or touching an attribute run), a character marker is kept, and the unprotected paste still builds them.chapterLineEditing.test.tsx(insertions): a note, a character marker and a verse inserted on a chapter line, with no attribute, a\caand a\cp, each give exactly the document ParatextData makes of the same USFM (checked by round-tripping\c 2 X,\c 2 \f + \ft N\f*,\c 2 \nd X\nd*,\c 2 \v 9, and each followed by\ca 3\ca*or\cp B, through a Paratext project); the same from a caret just past the number, over a selected digit, with nothing after the chapter line, and from the marker menu; and typed text, the reference the insertions follow, lands the same way.setUsj.test.tsx: with the chapter number backspaced out (pending),setUsjof the document from before the edit leaves the edit and its caret alone; the same document forced loads it and a caret just past the number resolves; handing it the document on screen changes nothing; any other document loads.packages/platformsuite: 2,000 passed across 110 files (at2d6ec2d7, and again at89ad7ed2); typecheck and lint clean forplatformandscribe. CI's lint step had failed on oneRecord<string, string>inchapterLineEditing.test.tsx, which the repo's lint wants as an index signature (89ad7ed2, test-only, so the committed build output is unchanged).rebuild-committed-outputthenverify-committed-distreports the committed build output current.d2ca50e4rewraps an over-long comment inchapterLine.utils.ts(only the source map changes), andverify-committed-distagain reports the committed build output current.e9720406, merged withmain(PT-4730: Limit how much text can be copied from the editor #23, the copy limit) on 2026-10-01: fullpackages/platformsuite 2,154 passed / 3 skipped across 112 files; typecheck and lint clean forplatform; committed build output verifies. The new insertion tests fail against the previous next-paragraph placement (14 of them).46eb98f3: fullpackages/platformsuite 2,161 passed / 3 skipped across 112 files; typecheck and lint clean forplatform; committed build output verifies. The new insertion tests (a private-use character elsewhere, the caret past a\carun for a note, a character marker and a verse, a glyph without its trailing space) fail againste9720406, and typing past a\carun is pinned beside them as the reference.9a60a0bb: fullpackages/platformsuite 2,167 passed / 3 skipped; typecheck and lint clean forplatform; committed build output verifies. The six new tests (a note, a character marker and a verse inserted from the end of a\cprun, with and without a\cabefore it) fail against46eb98f3.90604b3f: fullpackages/platformsuite 2,174 passed / 3 skipped; typecheck and lint clean forplatform(one pre-existingno-loop-funcwarning inverse-block.utils.ts); committed build output verifies. Seven new chapter-line tests (\ 2,c 2,\, an emptied line, text typed ahead of the\c, the line once the caret leaves,\q1typed over the\c) fail without the fix. The test that pinned the old refusal (\q1 1over a chapter with\ca) now pins the paragraph it becomes.0f949073:packages/platform2,174 passed / 3 skipped andpackages/utilities60 passed; typecheck and lint clean for both; committed build output verifies. The newnameless-marker.test.tsfails without the converter fix (three of its four cases; the fourth pins that an element with no marker still gets nostyle).5bd302c3(merged withmain, Fix the editor context menu and \id-line editing #14, PT-4189: EditorRef note index/key/highlight and keep DOM focus on unfocused applyUpdate #15 and Rest the caret at an empty verse's editable marker, not between markers #24, on 2026-10-14): fullpackages/platformsuite 2,435 passed / 3 skipped across 126 files,libs/shared713 andlibs/shared-react1,766 passed; typecheck and lint clean; committed build output verifies. The eight new tests for text typed ahead of a chapter marker (a paragraph before it ending in text, a space, a note, a verse marker, or nothing; leading whitespace; a markerless line before it; the caret) fail with the join removed from both settles. In the running app with paranext-core#2816, all sevenchapter-*specs pass, including the new\mt1 Ruth Xstep.3d8c3aa0(merged withmain, Let a host suspend the gesture-free marker-settle clocks (unblocks PT-4611) #25, on 2026-10-19): fullpackages/platformsuite 2,457 passed / 3 skipped across 127 files; typecheck and lint clean forplatform-editor,shared,shared-reactandutilities; committed build output verifies.ceb92b56(merged withmain, PT-4370: Settled positions across the public position API #11 and PT-4540: Make a paragraph's gutter marker selectable #22, on 2026-10-22):packages/platform4,071 passed / 17 skipped,libs/shared-react2,097,libs/shared895 andpackages/utilities64 passed; typecheck clean for every project; lint clean forplatform-editorandshared-reactapart from existing warnings; committed build output verifies. The merge conflicted in$rebuildChapter/$settledChapterScope(kept this branch's settle-to-what-the-bytes-are andmain's annotation carry and$captureCaret;chapterFragmentContentnow drops display-only line breaks itself, asmain's tokenizer path does), in the nameless-marker converters and their test (both branches had added the same behavior;main's wording kept, plus one case only this branch tested), and in one test comment. Three tests were updated tomain's rules: a paragraph's leading space is not saved (PT-4370: Settled positions across the public position API #11c3b2f878), a caret past the chapter number is['number'], and PT-4370: Settled positions across the public position API #11's "refuses a footnote inserted through insertMarker" now pins this branch's placement after the chapter line, where the note is saved. Each new test (the caret on\ 2/\sc 2, the joined paragraph's positions and a host annotation after it, marks across text typed ahead or\ 2, and the key-press caret) fails without its fix. In the running app with paranext-core#2816, the sevenchapter-*specs and the palette specs pass.2d6ec2d7), the four chapter-marker specs, the newchapter-switch-edit-pausespec andclipboard-usfm-round-trippass.Consumer note
paranext-core#2816 depends on this: it forces its correction with
setUsj(usj, { force: true }), and its deleted-marker handling relies on change 1. Merge this first, then moveplatform-yalc. Branch merged withmain(including #13, the copy/paste work, and #21) on 2026-09-24, again (#23) on 2026-10-01, again (#14, #15, #24) on 2026-10-14, again (#25, the settle suspension) on 2026-10-19, where only the committed build output conflicted and was rebuilt from the merged source, and again (#11, #22) on 2026-10-22 (see Testing). That last merge had three conflicts:$splitParagraphWithMarkernow handles the chapter line first and returnstrue(main made it return whether it split), the Enter menu's stack counts both a book line and a chapter line the caret is on, and an import list.AI Involvement
AI-assisted. Claude found the three bugs while testing paranext-core#2816 in the running app, traced each one to its cause, and wrote the changes and tests; the later changes came from review rounds.
🤖 Generated with Claude Code
This change is