Repository navigation
PT-4189: EditorRef note index/key/highlight and keep DOM focus on unfocused applyUpdate - #15
Conversation
…ys and document-order indexes Adds $getNoteIndex to shared-react's note.utils (a DFS over note nodes, reused by ImmutableNoteCallerNode's existing getNoteIndex so both paths share one implementation) and exposes it plus its inverse as EditorRef.getNoteIndex/getNoteKey. This is the coordinate bridge a footnotes pane needs: the host addresses notes by the document-order index a USJ walk yields, while the editor identifies them by Lexical node key. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cexoio1HtVmuAp2zJQKK3g
…ete getNoteKey TSDoc MarginalRef extends EditorRef, and Marginal.tsx builds its useImperativeHandle object by hand, so the two new EditorRef members need matching delegations there, following selectNote/getNoteOps. Also adds the missing @PARAM noteIndex to getNoteKey's TSDoc. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cexoio1HtVmuAp2zJQKK3g
… border NoteCallerHighlightPlugin keeps `caller_highlight` on exactly one note's caller element, re-applying it after every commit because Lexical recreates the caller DOM on a collapse toggle. The class is set imperatively, never through node state, so a highlight cannot dirty the document. The highlighted note is tracked by key. A commit that destroys it keeps the highlight only for an in-place replacement — replaceEmbedUpdate re-keys the note it rewrites — recognized as a note created in the same commit at the destroyed note's document-order index. A note that left the document, or a wholesale load (EXTERNAL_USJ_MUTATION_TAG) whose fresh notes are a different chapter's, drops the highlight instead of sliding it onto an unrelated note. The caller is found among the note's children rather than as its first child: in editable marker mode a note opens with its marker glyph. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cexoio1HtVmuAp2zJQKK3g
The public TSDoc promised a highlight without naming the two paths where none appears and nothing is reported: a note BUILT expanded under editable marker mode has a plain-text caller instead of the immutable caller element the style attaches to, and the note is resolved eagerly with no retry, so a call made before the document has loaded or with a stale key or out-of-range index is discarded and clears any highlight already showing. Both sentences go on EditorRef.highlightNote and on the plugin handle's setHighlightedNote, where the behavior lives. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cexoio1HtVmuAp2zJQKK3g
…Key/highlightNote Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cexoio1HtVmuAp2zJQKK3g
…Update Lexical reconciles the DOM selection after every commit, and writing a selection inside a contenteditable focuses that element as a browser side effect. A programmatic applyUpdate therefore stole the caret from whatever the user was actually typing in - most visibly a host's inline note editor applying its live edits back into the Scripture text, which blurred one debounce interval after the first keystroke. Tag the commit with Lexical's SKIP_DOM_SELECTION_TAG when this editor's root does not contain the active element. A focused editor still reconciles, so a collaborator's op keeps the local caret in the right place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cexoio1HtVmuAp2zJQKK3g
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cexoio1HtVmuAp2zJQKK3g
…ed-palette test Space commits the typed marker literal at once, but the char node it becomes settles afterwards; the assertion read it synchronously and failed under CI load (the palette itself reopens, filters, and commits correctly after any delay). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cexoio1HtVmuAp2zJQKK3g
…a note Two host-facing caret placements a footnotes pane needs. selectNoteTextOffset lands at an offset over a note's CONTENT text, skipping every display artifact the view adds around it - marker glyphs in either marker mode, attribute display runs, engine-owned NBSP spacers, an opening glyph's NBSP separator prefix, and an expanded editable note's caller. A host that captured a click position over its own rendering of the same note now resolves to the same character whatever markerMode this editor renders in, which a host-side DOM walk cannot do: under markerMode "editable" a glyph is real text inside the char span and the span's first text child carries the separator as a prefix, neither of which a per-node DOM filter can drop. selectAfterNote parks the caret immediately after a note - past its caller in a collapsed note. Like applyUpdate, it carries SKIP_DOM_SELECTION_TAG when this editor's root does not hold DOM focus, so a host can set it while the user keeps typing in a note editor elsewhere; that rule now has one name (holdsDomFocus) rather than two copies. Also polyfills Range.getBoundingClientRect for jsdom, which Lexical measures through whenever it scrolls a collapsed caret into view after a commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013PLV1rbFoxTNwRtwfoUTdk
markerMenuContext.utils.test.tsx asserts that a missing Range rect is exactly what leaves anchorRect undefined headlessly, so polyfilling Range.getBoundingClientRect globally made that behavior untestable. Install it only in the caret-placement suite that needs it, and restore the original afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013PLV1rbFoxTNwRtwfoUTdk
… module ImmutableNoteCallerNode needs $getNoteIndex, and the note machinery in note.utils needs ImmutableNoteCallerNode, so the two imported each other. That works only for as long as every use stays inside a function body; the first module-level use - or a CJS evaluation order that loads the caller node first - fails at load with an uninitialized binding. Move the index into note-index.utils, a leaf that imports neither, and re-export it from note.utils so callers keep finding it where they always have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/9e7049b7-4943-4d56-b83c-1f99d89866e7
$selectAfterNote parked the caret at offset 0 of whatever text node followed the note, but a glyph text node's bytes are a picture of its own state - a verse number, a marker's syntax - so offset 0 is a position INSIDE the picture, which the next keystroke splits. That is exactly what $normalizeSelectionOutOfGlyphText exists to prevent, and a note ending a verse is followed by precisely such a node. Skip glyph text so those notes take the parent-offset branch instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/9e7049b7-4943-4d56-b83c-1f99d89866e7
…et APIs This repository commits each published package's dist so paranext-core can copy it out of a checkout without running this toolchain, and the branch's editor changes had not been built into it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/d979f940-2a90-4191-8659-7d82e6558085
…ight APIs
Selecting an EXPANDED note that holds no content run at all (`\f + \f*`) was a
no-op: `$selectNote`'s expanded branch selects the end of the last char child,
and such a note has none, so the caret stayed outside the note and the next
keystroke landed in the surrounding text. It now lands at the child slot
content would occupy. `selectNoteTextOffset` documents that fallback, so the
promise it makes now holds.
Documentation that had drifted or was missing:
- `selectNoteTextOffset` takes DOM focus where its neighbour `selectAfterNote`
deliberately does not. That asymmetry is correct — it mirrors `selectNote`,
which it falls back to, and its job is placing the caret in an editor the
host is about to focus — but it was unstated, and the two neighbouring caret
APIs read as behaving oppositely for no reason. Also records that the caret
is placed, not revealed, while a note is collapsed.
- `getNoteIndex`, `getNoteKey` and `highlightNote` now carry the `EXPERIMENTAL:`
prefix every neighbouring note method has.
- The caller highlight is a yellow fill AND thin blue borders, not just the
borders; corrected in the model, the plugin and the changelog, with a note
that a host vendoring its own `usj-nodes.css` needs the rule.
- `holdsDomFocus` says why it is deliberately broader than `isFocused`.
- The `$getNoteIndex` re-export's comment claimed a backwards-compatibility
constraint that never existed; it now states the real reason (the barrel
exports this file, not the leaf). `NoteCallerHighlightPlugin` imports the
leaf directly rather than re-coupling to the heavy module.
Tests:
- The caret tests ran only under `noteMode: "collapsed"`, while the consumer's
note editor runs expanded — a materially different interior where the caller
is a plain TextNode and a `\cat` category becomes an attribute run. Both are
documented behaviors with no coverage anywhere. Added an expanded block
covering the caller skip, the `\cat` skip, a `\fp` paragraph-break crossing,
and the no-content fallback (which is what found the defect above).
- "does not mark the document dirty" asserted only that `onUsjChange` did not
fire, which a node-state highlight producing identical USJ would also pass,
and never checked that the highlight applied. It now counts commits and
asserts the highlight landed.
- The glyph-avoidance case asserted `not.toBe("verse")`, which passes for any
other wrong placement; it now asserts the caret's actual position.
- Added the missing positive pre-assertion to "clears when the note leaves".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/4c051464-cd5d-40f7-ab18-232f1c272d27
How many leading code units of a text node are an opening glyph's display separator was worked out independently in three places: the note caret offset (`$noteDataTextStart`), `$selectCharContentStart`, and the collab emit path's note-content strip. markerSeparators.utils.ts already owns the convention and the predicate that builds the prefix, so the reading now lives there too, as `$separatorPrefixLength`: plain host text directly after a char-span opening glyph that starts with the NBSP. - The note caret offset and `$selectCharContentStart` call it directly. `$selectCharContentStart` used to skip ANY leading NBSP; every caller hands it the text straight after a span's opener, where the two agree. - The collab emit path keeps its narrower positional rule on top (only the span's own opener, its first child), so the ops it sends are unchanged. Adds unit tests pinning each guard in the helper (each was verified to fail a test when removed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/e30d5c4e-6477-44a0-af88-da5bf2056809
…elper Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/e30d5c4e-6477-44a0-af88-da5bf2056809
`highlightNote` read the committed editor state, so an index resolved against the document as it was before any update still pending: a host that inserted or removed a note and then highlighted by index in the same turn marked the wrong note, or none. It now reads with `editor.read`, which commits pending updates first, as `getNoteIndex` and `getNoteKey` already do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/62270ca3-4092-4411-a74c-f2fa9943757f
…t fix Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/62270ca3-4092-4411-a74c-f2fa9943757f
Selecting an expanded note with no content run (`\f + \f*`) lands the caret in the slot just before the note's closing glyph. The closer was found by looking for a closing marker node, which is what `markerMode: "editable"` builds. `"visible"` (the Markers view) builds the closer as display-only typed text, so it was not found and the caret went after `\f*` instead. The lookup now recognizes both shapes, and a test pins both marker modes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/131a9039-65c3-4bd1-9d12-987ea2fb1b8b
…g one A fresh editor moves its caret to the start of `scrRef`'s verse a few microtasks after the document loads. The caret-placement tests sometimes acted before that move landed, so it overwrote the caret under test. "stays out of a glyph when the note is followed by one" failed about 3 runs in 8 this way. The render helper now waits a macrotask for the mount-time move before returning. 12 consecutive runs pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/131a9039-65c3-4bd1-9d12-987ea2fb1b8b
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/131a9039-65c3-4bd1-9d12-987ea2fb1b8b
Picking a footnote in the host's footnotes pane parks the Scripture caret just past the note's caller. When the note ends its verse — common, since a footnote usually hangs off the verse's last word — the next verse's marker follows the note directly, so that slot is also the slot the marker occupies. The host then publishes the note's own verse, and the plugin read the caret as being in the NEXT verse, treated the publish as a cross-verse navigation, and yanked the caret back to the start of the verse the user had just picked a note in. Two readings of "which verse is the caret in" had drifted apart: - `$getEffectiveVerseForBcv` counted a caret inside an editable verse marker as being past the verse number whenever the marker's text did not START with that number. Standard and Unformatted views render the whole marker (`\v 3 `) as the verse node's text, so every position in it — including offset 0, where a verse-final note leaves the caret — read as the new verse. The prefix now ends where the number does, wherever it sits. - `$moveCaretToVerseStart`'s "already here" guard read the verse off the caret's node alone, which is offset-blind, rather than through the resolution `$resolvePosition` reports positions with. It now asks the one question one way (I9), so the publish is recognized as a no-op. Navigating to the verse whose marker follows such a note still moves the caret, and is pinned alongside the two no-op cases. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/f68e1e86-7eff-4dd8-9d54-d90693e2fd51
…ary fix Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/f68e1e86-7eff-4dd8-9d54-d90693e2fd51
…-editing # Conflicts: # packages/platform/dist/index.js # packages/platform/dist/index.js.map
…rker, and note-close fixes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A2bfdNETynYMPVnLcrc1sm
… one undo step - EditorRef.takeOpsAfterNote removes what follows a note in its paragraph and returns its ops, joined to the undo step of the edit that left the text there. A note editor that took the text a mid-note \f* moved out then gets one undo that opens the note again with the text inside, instead of an undo that puts the text back after the note (where the host took it again, on every press). - The close of an unclosed note at a typed closer merges into the undo step the closer was typed in, so one undo takes the whole \f* away, not just its '*'. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…l is In a note editor (shell not editable) the note's closer could be deleted: a caret after \f* or at the row's end stayed there, Backspace from there removed the closer, forward Delete ran straight into it, and typing over a range inside it prepended to it. Range narrowing was the only closer protection. - A caret in, on, or past the closer (or past the whole note at the row's end) moves to the end of the note's content, as a text point: an element point at the closer's index resolves to the closer's own start. - Backspace or Delete there is claimed and moves the caret instead; a forward delete with nothing left before the closer is refused, and a word or line delete stops at the closer, mirroring the opener's backward rule. - A range narrowed off the closer collapses to that same text point. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he closer guard Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…xt typed before it In the app the marker palette commits a typed \f* and the note's close in one update. The close carried a history-merge tag, so that update joined the previous undo step: one undo took the close away together with the text typed just before it, and that text was lost. The close now forms its own undo step (a note editor's takeOpsAfterNote still joins it). Closing the note also keeps the same note node rather than replacing it. The replacement was reported to hosts as an inserted note, so Platform.Bible opened its new-note editor on the note just closed and moved focus there. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-editing # Conflicts: # packages/platform/dist/index.js # packages/platform/dist/index.js.map
lyonsil
left a comment
There was a problem hiding this comment.
Round 3 review of this PR at 8f97b1b, covering the whole branch against main: 20 findings. Their numbers are this round's own and do not refer to findings from earlier rounds. Each comment shows the severity the reviewer settled on; checked and confirmed means the finding was verified against the code, and needs a human call means it could not be settled either way and is yours to judge.
On the CHANGELOG findings (#14, #15, #16): if we'd rather drop the CHANGELOG and rely on the git history, as we do in all our other repos, that would be fine too.
Findings that could not be attached to a line in this diff
#14 (low · checked and confirmed) packages/platform/CHANGELOG.md:145 - The CHANGELOG's Fixed list has no entry for four user-visible fixes this PR makes.
What happens: these commits change behavior a user sees but add nothing under ### Fixed (CHANGELOG.md:145): 984d92a (forward Delete through an opening glyph goes one character at a time), c76b532 (a backward delete at a protected shell's edge keeps the caller), 91c76e5 (a host note editor's closing glyph is protected), and e6ccf4c (an undeclared marker typed in a note stays a sibling of the run it ends).
Why it matters: the CHANGELOG is how a host picking up a new version of this package learns what changed in editing behavior. Its Unreleased entries move under the version heading at publish, so a fix left out now is never recorded.
Fix: add a Fixed entry for each of the four, in the file's existing voice.
How this was checked: All four commits (984d92a, c76b532, 91c76e5, e6ccf4c) are in this PR (none is an ancestor of the merge base), and each is a user-visible fix. I read the whole Unreleased section (packages/platform/CHANGELOG.md:11-260) and grepped for delete, Backspace, separator, closer, closing glyph and undeclared. No entry covers forward Delete through an opening glyph, a backward delete at a protected shell's edge removing the caller, or a note editor's closing glyph being protected. Lines 148 and 149 are about the unclosed-note caller and typing a closer, and line 153 is about the caret after an undeclared marker, not the other three fixes. The nearest entry for e6ccf4c is line 152-153 (caret after typing \df ), but e6ccf4c fixes a different symptom: the undeclared span spliced back inside the open \ft (showing as \+df) in a note with a \cat run, and the caret landing on \cat. So that one is also missing. The anchor line 145 (### Fixed) was not changed by this PR (blame d5412cb is in the base).
Not attached to a line: not on a changed line; cannot be posted inline
(AI-assisted, with my guidance)
…ding The contract let the slot be dropped when the severity was "self-evident from the claim". The generator took that to mean every nit, and a finding with no stated consequence reads as one not worth raising - including findings that were (a frozen-update throw, a stray saved space). On paranext/scripture-editors#15 eleven of twenty findings reached the triage preview without one. The slot is now always required, minor findings included, which state their cost instead ("test-only, with no behavior effect"). why_it_matters_errors rejects a missing or empty slot in both validate and validate --draft, so it is written with the finding rather than patched in after the refuters have read it. REFUTED items are exempt, as they never post. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ote, reentrant applies - A protected note shell (isNoteShellEditable: false): a forward Delete with the caret just before the note is refused instead of removing the opener and unwrapping the note; a forward Delete at the shell's trailing edge reaches the content after the caller; a forward line delete over wrapped content is not clamped to the closer when the closer is on another line. - Typing a note's own closer in a view that shows notes expanded leaves the caret after the note (only a host's note editor keeps it in the note). - selectAfterNote on a note ending an unclosed char span places an element point after the note instead of descending into the note ($selectElementPoint moves into note.utils and is shared with arrow navigation). - applyOps reports from the update's onUpdate, so takeOpsAfterNote called from onUsjChange shows in getUsj() and reports its own onUsjChange. - getNoteOps/getOpsAfterNote/takeOpsAfterNote read with readLatest, safe from a callback during an update. - $findFirstAncestorNoteNode replaces three hand-written note lookups; test fixtures follow the nested-chain style; TSDoc and CHANGELOG fixes. Rebuilt the committed dist. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n save Standard view collapses space runs when it saves, but a run split across the caller and the note's content (a space typed after an expanded note's caller, or `\f + text` in the file) was saved as leading note content. The reverse adaptor now drops leading plain spaces from the note's first content string in Standard view, as Paratext 9 reformats `\f + text` to `\f + text`. A note with a category keeps them (they follow `\cat*`), and an NBSP stays. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e inside an unclosed span $selectElementPoint again announces every move past a collapsed note, as arrow navigation relies on for its caret host. $selectAfterNote keeps a paragraph's own select, and a caller that also changes the document (closing a note) places the caret without the announcement: the guard's repair tags its whole commit as a caret move, which kept a note closed at the end of an unclosed \wj out of onUsjChange and getUsj(). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Selecting to the end of the document from a paragraph a footnote ends puts the range's end at the start of the note's hidden content, since the browser has no position inside it. Copy then carried `\f + ` without the note's content, and cut or typing over it left half a note. The trailing-note guard now moves a range end that is inside a collapsed note to the near side of the note. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDp2WYL6HtB3S7MSRqMheJ
… caret there In a block that a collapsed note ends there is no caret position past the note, and the browser answers a jump to the end (End, Ctrl+End, Cmd+Down) by selecting the note. The next paste, typing or deletion then replaced it. The trailing-note guard now turns that selection into a caret past the note when it arrives right after such a press. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDp2WYL6HtB3S7MSRqMheJ
|
Round 13 is pushed ( Two bugs outside the review, found while checking the clipboard E2E ( CHANGELOG: kept, as best-effort until it is complete (without it, users of the package cannot see what changes between versions). The hand QA list for this round, covering both PRs, is in the description. |
lyonsil
left a comment
There was a problem hiding this comment.
@lyonsil reviewed 44 files and all commit messages, made 1 comment, and resolved 17 discussions.
Reviewable status: all files reviewed, 2 unresolved discussions (waiting on tjcouch-sil).
lyonsil
left a comment
There was a problem hiding this comment.
@lyonsil resolved 2 discussions.
Reviewable status:complete! all files reviewed, all discussions resolved (waiting on tjcouch-sil).
…-editing # Conflicts: # packages/platform/dist/index.js # packages/platform/dist/index.js.map # packages/platform/src/editor/Editor.tsx # packages/platform/src/editor/markerEdit/settleShared.utils.ts # packages/platform/src/editor/markerEdit/tier2Rebuild.utils.ts # packages/platform/src/editor/markerEdit/virtualSettle.utils.ts
After main's caret changes the focused case leaves a collapsed caret, and Lexical's scroll-into-view then measures it through a Range, which jsdom does not implement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the note A browser reading back a caret at the end of a line a note ends resolves it to the end of the note's closing glyph, so the next keystroke became `\f*Z`: invisible in a collapsed note and dropped from the USJ in every view. Tier 1 already splits text typed after a char span's own closer out of the span; the same split now covers a note's own closer. That covers clicking at the end of such a line in Unformatted view and typing after closing a note that ends an unclosed char span (lyonsil round 13 follow-ups #1 and #5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lyonsil
left a comment
There was a problem hiding this comment.
@lyonsil reviewed 24 files and all commit messages, and made 1 comment.
Reviewable status:complete! all files reviewed, all discussions resolved (waiting on tjcouch-sil).
Brings in #23 (copy limit), #14 (context menu, \id-line editing and the book settle scope), #24 (empty-verse caret) and #15 (note index, key and highlight, unfocused applyUpdate, and the note-editing fixes). Conflicts, resolved to keep both sides' intent: - Settle engine (tier2Rebuild, virtualSettle): this branch's caret and annotation carry ($captureCaret/$restoreCaret through pairRuns, mark carry) replaces main's byte anchor with preserved-key counting, which covers the same typed-undeclared-marker case through the literal alignment. Main's book scope ($rebuildBook, $settledBookLine), note close at a typed closer, undeclared-marker-in-note rule and region caret restore are kept and ported onto this branch's capture, char opener renames, carried runs and tokenizeFragment (tokenizedBookLine now takes the fragment, so placeholders keep their kinds). One fragment join: $buildParaScopeFragment uses main's $appendParaFragments. $charNeedsSentinelAs asks main's edited-glyph question of a span as renamed. - applyUpdate: main's applyOps (focus rule, extra tag, report from onUpdate so a call from an update listener works) with this branch's applying-update signal and settled-USJ report. The signal is now set inside the update and cleared in onUpdate, so a nested call is applied and announced once with its ops; the "inside an update" warning and its test expectation go, since the call now behaves. - Note shell guard: this branch's rule that a caret never rests outside a protected note (front and past-closer carets go into the content, ranges are confined, select-all handled in its own update, moves announced with APP_PLACED_CARET_COMMAND) on main's command handling (deletes refused or stopped at the shell and closer edges, edits confine the range first). The past-closer caret lands at main's content-end text point. - Caller: this branch's caller transform, regex and caret handling are kept; main's caller-slot predicate replaces the duplicate scan, and $createWholeNote builds the slot for an empty caller (unmergeable). The editor->USJ adaptor keeps the identity-based caller slot and adds main's Standard-view space collapse after the caller. - Separator prefix: both helpers kept ($charSeparatorPrefixLength is mark-transparent); both use main's $ownsOpenerSeparator. - Literal-only blocks: the shared $isInLiteralOnlyBlock drops the book, which main gave a settle scope. - Tags: this branch's "tag only a commit that moved a node" and APP_PLACED_CARET_COMMAND are kept over main's release-after-commit in the caret repairs and scripture-reference placement. - Committed dist: this branch's; rebuilt separately. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us
Summary
Editor-side half of Paratext 10's PT-4189 (footnotes edited in the footnotes pane in Standard view; core PR paranext/paranext-core#2805). Additive
EditorRefAPI plus one focus fix; nothing removed.Moved here from eten-tech-foundation/scripture-editors#556, which is closed with a pointer to this PR. The branch was rebased with
--ontoso it carries only its own commits; the old base (eten's #531, "apply annotations issued while a load is in flight") is not on this repository'smainand did not come across.Changes
EditorRef.getNoteIndex(noteKey)— document-order index of a note (the coordinate a USJ-built notes list addresses; the same indexnoteCallerOnClickreports).$getNoteIndexinshared-reactbacks it andImmutableNoteCallerNodereuses it.EditorRef.getNoteKey(noteIndex)— the inverse, so a host that addresses notes by index can handreplaceEmbedUpdatea key.EditorRef.highlightNote(noteKeyOrIndex | undefined)— applies PT9'scaller_highlightclass (already inusj-nodes.css) to one note's caller.NoteCallerHighlightPluginre-applies after every commit (Lexical recreates the caller DOM on collapse toggles andreplaceEmbedUpdatere-keys), follows an in-place replacement in the same commit, refuses to adopt a note across an external load, and never dirties the document. Two silent no-op modes are documented in the TSDoc (a note built expanded in editable marker mode has a plain text caller; the address is resolved eagerly, never retried).EditorRef.selectNoteTextOffset(noteKeyOrIndex, utf16Offset)— puts the caret at an offset within a note's own text, counting the note's content only. Every display artifact the activeViewOptionsadd around that content is skipped: marker glyphs in either marker mode, attribute display runs, engine-owned NBSP spacers, an opening glyph's NBSP separator prefix, and an expanded editable note's caller. See Why this belongs in the editor below.EditorRef.selectAfterNote(noteKeyOrIndex)— puts the caret immediately after a note (past its caller in a collapsed note), where PT9 leaves it once the user is done reading. LikeapplyUpdate, it carriesSKIP_DOM_SELECTION_TAGwhen this editor's root does not hold DOM focus, so a host can park the Scripture caret while the user keeps typing in a note editor elsewhere; the selection is still recorded, so a laterfocus()lands on it. It skips a following GLYPH text node: a glyph's bytes are a picture of its own state (a verse number, a marker's syntax), so offset 0 there is a position inside the picture that the next keystroke splits — exactly what$normalizeSelectionOutOfGlyphTextexists to prevent, and a note ending a verse is followed by precisely such a node. Those notes take the parent-offset branch instead.$getNoteIndexmoves to its own leaf module (note-index.utils), re-exported fromnote.utils.ImmutableNoteCallerNodeneeds the index andnote.utilsneedsImmutableNoteCallerNode, so the two imported each other — which works only while every use stays inside a function body; the first module-level use, or a CJS evaluation order that loads the caller node first, fails at load with an uninitialized binding. No API change."category"field onselectNoteTextOffset(round 8, below).MarginalRefdelegates all of them.applyUpdate/replaceEmbedUpdatetag the update withSKIP_DOM_SELECTION_TAGwhen the editor does not hold DOM focus, so a host editing a note in a separate editor (the pane row editor) keeps its caret; explicit caret APIs (focus,setSelection,selectNote) are unchanged. Test fails without the tag. That rule now has one name (holdsDomFocus) shared by both sites rather than two copies.$separatorPrefixLengthinmarkerSeparators.utils.ts, which already owns that convention. Three sites derived it independently: the note caret offset behindselectNoteTextOffset,$selectCharContentStart, and the collab emit path's note-content strip. The emit path keeps its narrower positional rule on top (only the span's own opener, its first child), so the ops it sends are unchanged.$selectCharContentStartnow skips a leading NBSP only in the separator slot. Every caller already hands it the text straight after a span's opener, so their results do not change. New unit tests pin each guard in the helper; removing any one of them fails a test.packages/platform/distis rebuilt in its own commit. This repository commits each published package's build output so paranext-core can copy it out of a checkout without running this toolchain; eten-tech-foundation's did not, so the branch arrived without that rebuild.Why
selectNoteTextOffsetbelongs in the editorThe host captures a caret position over its own read-only rendering of a note (a footnotes pane row) and needs it resolved inside a live editor showing the same note. It was doing that by walking the editor's DOM behind a text-node filter — which worked only for the marker mode that filter had been verified against. Under
markerMode: "editable"an opening glyph is a real editable text node inside the char span, and the span's first text child carries the NBSP display separator as a prefix; the walk counted both as note text, so every run shifted the caret, compounding per run. A prefix of a node cannot be dropped by a per-node filter at all.Only the editor can tell its own display artifacts from content, by node type, so that is where the resolution now lives. The offset origin becomes the note's USJ text — stable across marker modes — which is what makes the two renderings agree.
Known gap,
TODO(PT-4322): text a source wrote directly inside a note rather than inside an\ft-style run is not counted, because the walk requires a char-span ancestor (which is also what excludes the caller). A host that renders such text inline still drifts by its length.Round 5 — automated review passes
Two
/code-reviewpasses over this branch and its consumer. Three fixes landed here, one of them abehavior change in the shared
ScriptureReferencePluginthat needed a decision first.A caret before a verse number belongs to the verse before it (
c73ed3d6)The reported symptom is in the consumer: picking a footnote whose caller sits at the end of its
verse sent the Scripture caret back to the start of that verse. It is the common shape — a footnote
usually hangs off the verse's last word, so the next
\vfollows the note directly.selectAfterNoteparks the caret just past the note, which for such a note is the slot the nextverse's marker occupies. The host then publishes the note's own verse, and the plugin read that slot
as being in the next verse — so the publish looked like a cross-verse navigation and the plugin
moved the caret to the note's verse start.
The fix is here rather than in the host because the disagreement is internal to the plugin every
view shares: two readings of "which verse is the caret in" had drifted apart.
$getEffectiveVerseForBcvtreated a caret inside an editable verse marker as past the versenumber unless the marker's text started with that number. Standard and Unformatted views render
the whole marker (
\v 3) as the verse node's text, so every position in it — offset 0 included,which is exactly where a verse-final note leaves the caret — read as the new verse.
getVerseNumberPrefixLengthnow ends the prefix where the number does, wherever in the marker itsits, and keeps its documented
0fallback for text that does not contain the number at all.$moveCaretToVerseStart's "already here" no-op read the verse off the caret's node alone(
$findThisVerse), which is offset-blind, instead of through$resolveVerseNode+$getEffectiveVerseForBcv— the resolution$resolvePositionreports positions with. It asks theone question one way now, recorded as invariant I9 in that file's header. The no-op is gated
on a verse node actually being resolved, never on the effective number alone: a document swap
nulls the selection, which resolves to verse 0, and a navigation to verse 0 must still place a
caret in the arriving document.
Pinned by three tests in
ScriptureReferencePlugin.test.tsx— the caret stays put under editablemarkers and under the immutable verse decorator, and navigating to the verse whose marker follows
the note still moves it — plus two in
node-react-utils.test.tsfor the prefix itself. Each half ofthe fix is independently falsifiable: reverting either one fails the new tests.
Also fixed
highlightNoteresolved an index against a stale document (01f63874). It read the committededitor state, so a host that inserted or removed a note and highlighted by index in the same turn
marked the wrong note, or none. It reads with
editor.readnow, which commits pending updatesfirst, as
getNoteIndexandgetNoteKeyalready do.$selectNotemissed an empty note's closer in the Markers view (6d5d1fe3). Selecting anexpanded note with no content run (
\f + \f*) lands the caret just before the closing glyph. Thecloser was found by looking for a closing marker node, which is what
markerMode: "editable"builds;
"visible"builds it as display-only typed text, so the caret went after\f*instead.The lookup recognizes both shapes, and a test pins both marker modes.
81e074df): a fresh editor moves its caret toscrRef's verse start a fewmicrotasks after the document loads, and the placement tests sometimes acted before that landed,
overwriting the caret under test (~3 runs in 8). The render helper waits a macrotask for it now.
Round 6 — lyonsil's review (36 findings)
Every finding was checked. 35 are fixed with falsifiable tests. #23 is answered on its thread (a
reload deliberately drops the highlight for the host to re-address). Commits:
01edbdcf(+ dist81fc0cbc),b8064029(+ dist94dcf375), anda0367cc6(+ distab34b2cf) for the empty note.focus()could not find (Remove the scribe package #2/Flaky interaction tests: ScriptureReferencePlugin and marker-palette each failed once in CI #3/Separate the published artifact directory from the toolchain's scratch space (both are currently dist/) #5). Lexical 0.43 resets its pending updatetags only after a commit that changed nodes. A tag on a selection-only commit therefore rides onto
the NEXT commit: a leftover
SKIP_DOM_SELECTION_TAGstoppedfocus()from focusing anything, anda leftover
CURSOR_CHANGE_TAGgot the next edit dropped fromonUsjChange. The skipped DOM writealso left a stale DOM selection inside the editor, which the next event-less update read back
over the parked caret. New
editorUpdate.utils(releaseTagsAfterNextCommit,clearStaleDomSelection,editorHoldsDomFocus,readLatest) fixes this forselectAfterNote,applyUpdate,commitPendingMarkerEdits, and the caret-guard repairs.actual previous verse node (bridge
1-2, segment2a, omitted verse) rather than number−1.$moveCaretToVerseStartasks$resolvePosition, so a heading before verse 1 is "already here"for verse 0.
\f + \f*landedinside the closing glyph (
x\f*): shown, never saved. A newEmptyNoteCaretGuardPlugingives thecontent slot a transient caret host, so what is typed becomes the note's plain content
(
\f + x\f*). It acts only where the caller is protected from typing; where the caller is editabletext, the end of it stays the caller's.
selectNotelands ahead of an explicitly closed last run'scloser.
selectNoteTextOffsetcounts text written directly in the note and never lands in anunmatched closer's glyph.
getNoteIndex,getNoteKeyandhighlightNoteread thein-flight state there instead of committing (and freezing) it.
commitPendingMarkerEditssettles without taking focus back (the host now settles the text on entering its pane, which
retires PT-4370: Settled positions across the public position API #11's idle-settle focus steal). The highlight falls back to an editable caller (PT-4540: Make a paragraph's gutter marker selectable #22) and
skips its walks when nothing was created (Rest the caret at an empty verse's editable marker, not between markers #24). Plus the TSDoc/refactor/test-hygiene items
(Put a fallback chain behind every font the project stylesheet names #12/Fix the editor context menu and \id-line editing #14–PT-4537: Keep a visible caret in an empty verse #16/PT-4313: Mirror the core review of the gutter coverage test #18/Let a host suspend the gesture-free marker-settle clocks (unblocks PT-4611) #25–Treat implied paragraphs as transparent in USJ selection locations #28/PT-4121: Keep paragraph markers when the structure is locked #30–PT-4885: Keep the line still and the caret after the number in an empty verse #34/PT-4391: Keep a lone ~ between markers on save in every view #36–PT-4965: Put chapter 1's verses in a paragraph #40).
Round 7 — lyonsil's follow-ups (10 findings)
Every finding was checked against the code, and each fix has a test that fails without it. Replies
are on each thread. Commits:
59f95510,ba573d98,7c75f96c,8dfcc2dd,7c5a7d11; dist6c41a065,c3f5ea80(verify-committed-distclean). All three data-loss fixes below were alreadyon
main, not introduced by this PR.$applyUpdatebuilt a run's first-op nested span with its glyphs beside it instead of inside it, and the
marker engine then read them as the outer run's text: a note loaded into the footnote popover or
a pane row as
\ft \+nd LORD\+nd* saidsaved back as\ft LORD saidon any edit. It now buildsthe same shape as a nested span mid-run, and text continuing a closed run goes inside its closer.
caller's trailing separator is split into its own content node in the same commit, the caret
following it.
onUsjChangeafter a caret-only commit (PT-4911: Hidden-marker views copy prose and filter \ and // on paste #41, R2-2, R2-3). The scrRefplacement and both caret guards release their cursor-change tag after committing; the scrRef one
only when the caret actually moved.
selectNotein a run holding a nested span (R2-1). Only a span's last child counts as itsclosing glyph.
isFocused()uses the same focus check as the rest ofEditorRef; the tag-release listener is disarmed when an update commits nothing; a DOM selectionthat matches Lexical's survives an unfocused update; an unfocused
selectAfterNotereports the newverse through
onScrRefChange.FootnoteEditor relies on; plugin order (R2-8).
Round 8 — fixes from TJ's hand QA (2026-09-25)
Commits
acec819e..0e39a7f4, dist0bab2a75(verify-committed-distclean), docs791cf00f,70291f25. Each fix has a test that fails without it.NoteNodePluginreset every plain text child of a collapsed note to the NBSP spacer, so the pane row editor's apply of\f + x\ft a\f*(or\f + x\f*) dropped thex. The apply still replaced the note node, so the host's session key stopped resolving and nothing saved again until the row was reopened. Only tagged spacers (and text the caret is typing into) are reset now. This also affected the round-6 empty-note typing (\f + \f*→ plain content) once it reached the Scripture editor.\f +, and typing or deleting over it unwrapped the note.NoteShellCaretGuardPluginnow narrows such a range to the note's content; one wholly inside the shell collapses to its trailing edge.\p asdf \wj stuff\f + …\f*with no\wj*: a click past the end of the line lost the caret and focus, because the trailing-note guard only handled a note directly in its paragraph. It now walks up through inline ancestors that render nothing after the note. ArrowRight into the same spot left an invisible caret in the note's hidden closer; that landing now stays past the note.\df) was always kept whole through a Tier 2 rebuild, so the edited glyph never reached the tokenizer and was healed back to\+df. It is kept whole only while its own glyphs still spell it.onUsjChange'sinsertedNodeKeyis now reported only for a node the previous editor state did not have. This is what made the core host open the row editor at the end of an unclosed note the user was typing in.selectNoteTextOffset(noteKeyOrIndex, utf16Offset, field?: "category")addresses a note's\catvalue (C5);MarginalRefdelegates it. (AgetNoteCaret()added for a first cut of A1 was removed again in0e690064, distc73f9827: the host now edits an unclosed note in place, as PT9 does, so nothing reads it.) A new test pins an unclosed note staying unclosed and expanded through a note editor's apply.Round 9 — TJ's round-8 hand QA (2026-09-28)
Commits
fd45e7b2..2e6060ff, changelog, dist (verify-committed-distclean). Each fix has a test that fails without it.e3c2af75): the caller pattern needed a non-empty word, the whitespace display and the caller transform then looped over the empty slot, and the reverse adaptor leaked the slot's spaces into content. The caller slot is found by position (noteCallerSlot.utils.ts), the note keeps caller"", and the text shows\f \fr ….$createWholeNotebuilds the caller slot for an empty caller in editable expanded mode too. The "empty caller, only for note editing" warning is gone.9f29be04): the note-scoped rebuild tokenized the content without the note's opener, so\f*came back unmatched. It now closes the note there (collapsed in Standard view) and moves what followed the closer after the note; the read-only settle mirrors it.9f29be04): the caret restore counted a freshly built\dfspan (a Tier 2 sentinel) as one placeholder byte while the position was measured over its typed bytes, so the caret landed in the next closing glyph and later keystrokes edited it. Spans the rebuild did not preserve are counted byte by byte (FragmentAccumulator.preservedKeys).fd45e7b2): the edit commands narrow the range first, since Lexical re-reads a still-dragging range from the DOM for the edit.2e6060ff):selectNoteTextOffset(noteKeyOrIndex, utf16Offset, options?: { field?: "category"; glyph?: { index; offset } })— replaces this PR's barefieldargument, so nothing released changes.Round 14 — lyonsil's round-13 follow-ups, and brought up to date with
main(2026-10-12)3ef69481). A browser reading back a caret at the end of a line a note ends resolves it to the end of the note's\f*, so the next keystroke became\f*Z: invisible in a collapsed note and missing fromgetUsj()in every view. That covers clicking at the end of such a line in Unformatted view (already onmain) and typing after closing a note that ends an unclosed\wj. Tier 1's split of text typed after a char span's own closer now also covers a note's own closer, so the text moves after the note as it is typed. Three tests put the caret where the readback lands (jsdom never reads it back), and each fails without the fix.main(2acc7a02: Fix the editor context menu and \id-line editing #14\id-line editing, Rest the caret at an empty verse's editable marker, not between markers #24 empty-verse caret). Conflicts were in this branch's settle code and were resolved by keeping both sides: the branch's note close andpreservedKeyscaret restore next to main's book-line rebuild, which now passespreservedKeystoo.3da2cc5dstubsRangerects inapplyUpdateDomSelection.test.tsx, since main's caret change makes its focused case scroll a collapsed caret. Dist7a4d6568;verify-committed-distis clean.platform-editor2346 / 3 skipped,shared-react1766 / 1 skipped,shared713,utilities56.nx run-many -t typecheck lintis clean.Round 13 — lyonsil's review at
8f97b1be(2026-10-01)Commits
ec1de9f0,4bcb4a9d,3bcb062e,68fc5d6a,bff1152f(verify-committed-distclean). Each fix has a test that fails without it. The 20 findings: fixed 1–5, 8, 10–17, 19–21 and 18 (a different way, below); declined 6 and 9.\f*in a view that shows notes expanded leaves the caret after the note.selectAfterNoteon a note that ends an unclosed\wjno longer descends into the note. Closing a note inside an unclosed span is reported throughonUsjChange(a caret announcement tagged that commit as a caret move, so the close never reached the host).takeOpsAfterNotecalled fromonUsjChangereported stale state and, in the new test, looped without end; the apply now reports from the update's ownonUpdate. The note-op reads usereadLatest, so they are safe fromonSelectionChange.\f + textto\f + text. A note with a category keeps it (it follows\cat*), and a no-break space stays.8f97b1be. A range whose end lands inside a collapsed note (select to the end of the document from a paragraph a footnote ends) took half the note, so copy carried\f +without the content. It now takes the whole note. End, Ctrl+End and Cmd+Down in such a block selected the note instead of leaving the caret past it, so the next paste replaced it; the guard turns that into a caret past the note.Hand QA for this round (Standard view, Power mode, project editable; both PRs) — run by TJ 2026-10-12
\f*in the middle. Press Escape at once: the text after the closer is in the paragraph after the note, once. Repeat, but wait, then Ctrl+Z and Ctrl+Y in the row editor: the text moves into and out of the note each time, with no loop and no doubled text.\wjthat ends in an unclosed footnote, and type\f*at the end of the footnote. The footnote closes and the change saves (reload the chapter). If the caret ends inside the footnote rather than after it, tell me.\f*to close an inline note and keep typing: the new text goes in the paragraph, not the note's last run.\f +and type a space. The space shows while editing; after saving and reloading it is gone.\f + xysaves as\f + xy.Run by TJ 2026-10-12: 1-4, 7 and 8 passed. 5 and 6 can't be run as written: the app's Markers view is read-only (only the
dev-editableMarkersViewdeveloper flag makes it editable). Users reach both cases only on an unclosed note in Standard view, which the text shows expanded, and tests already cover that case. For item 5, the caret landing after the closed note is item 4, which passed by hand, and theunclosed-note-close-undoE2E covers the text after the closer moving into the paragraph. Item 6's save is unit-tested inmarkerEditTier1.utils.test.tsxwith Standard view's options and the note expanded. The collapse belongs to Standard view's whitespace rules, so it would not apply in Markers view anyway; the step named the wrong view.Brought up to date with
main(2026-09-30)Merge
8f97b1be(#23, copy limit). Only the committeddistconflicted; it was rebuilt, andverify-committed-distis clean. After the merge:platform-editor2228 / 3 skipped andshared-react1684 / 1 skipped pass;nx run-many -t typecheck lintandprettier --check .are clean.Round 12 — TJ's round-11 hand QA (2026-09-30)
Commit
1a113a8e, dist4ee4314e(verify-committed-distclean).\f*and the close in one update. Round 11's merge tag on the close joined that update to the previous undo step. The close is its own undo step again; a note editor'stakeOpsAfterNotestill joins it. The tests now commit the closer throughcommitTypedCloserafter a separate typing step, as the hosts do.onUsjChangeno longer reports the closed note as inserted. Hosts open their new-note editor on an inserted note.Round 11 — TJ's round-10 hand QA (2026-09-30)
Commits
50aa4947,91c76e55, diste9d665ad(verify-committed-distclean). Each fix has a test that fails without it.EditorRef.takeOpsAfterNote(50aa4947): removes what follows a note in its paragraph and returns its ops, merged into the undo step of the edit that left the text there. A note editor that closed its note mid-note gets one undo that opens the note again with the text inside. Before, undo put the text back after the note, where the host took it again. (Round 11 also merged the close itself into the closer's typing step; round 12 reverts that.)91c76e55,NoteShellCaretGuardPlugin). Before, a caret after\f*or at the row end stayed there, Backspace from there deleted the closer, forward Delete ran into it, and typing over a range inside it prepended to it. Now such a caret goes to the content's end, as a text point, because an element point at the closer's index resolves into the closer. Deletes there move the caret instead of deleting. A forward delete with nothing before the closer is refused, and a word or line delete stops at it. Only range narrowing existed for the closer before this change, as far back as the guard goes.Round 10 — TJ's round-9 hand QA (2026-09-28)
Commits
c76b532f..b834e3bc, dist33e0bce1(verify-committed-distclean). Each fix has a test that fails without it.c76b532f): with the caret at the shell's trailing edge (where a drag or a click in the shell leaves it), Backspace, a word delete or a line delete took the caller token. Refused, or stopped at the shell's edge.984d92a3): the press removing a glyph's last character also removed the separator, and$charTextNodeTransformread the separator NBSP as the empty-char placeholder and jumped the caret to the span's end. One character per press now, caret in place.\cat(e6ccf4cf): a note rebuild spliced the sentinel\dfspan back inside the open\ft, and the caret restore landed on\cat. Inside a note an undeclared span is re-tokenized, since the tokenizer reads it as a char span there. (Paragraph context still keeps it whole.)getOpsAfterNote(b834e3bc): reads what follows a note in its paragraph;$getReplaceEmbedOpsdeletes the old embed before further inserts, so a note editor that closed its note mid-note can apply the rest after the note.Brought up to date with
main(2026-09-24)origin/mainis merged in (a48bcf79), picking up #13 (copy/paste fidelity), #18, and #21. Conflicts:Editor.tsxinsertNotenow usesmain'sdiscreteupdate together with this branch's$rememberExpandedNote.CHANGELOG.mdkeeps both sides' Fixed entries. The committedpackages/platform/distis rebuilt, andverify-committed-distpasses.Testing
Full local run of what CI runs, at the rebased tip:
nx run-many -t test— all 9 projects green:@eten-tech-foundation/platform-editor1714,shared-react1654 (1 skipped),shared675,utilities56, plus the demos. After the separator-prefix refactor:platform-editor1718,shared-react1654 (1 skipped),shared684,utilities56, plus typecheck and lint, andverify-committed-distis clean at6ad4a091. IncludesnoteIndexAndHighlight.test.tsx,applyUpdateDomSelection.test.tsx, andnoteCaretPlacement.test.tsx— the last renders underSTANDARD_VIEW_MODE, i.e.markerMode: "editable", deliberately (that is the mode the caret bug lived in), and — since the review pass — under the expanded note mode a host's own note editor actually uses.nx run-many -t typecheck lint,prettier --check .,nx format:checknode scripts/rebuild-committed-output.mjsfollowed bynode scripts/verify-committed-dist.mjs— the committed output is current and reproduces byte-for-byte on a second rebuilda48bcf79):nx run-many -t testall 9 projects green (platform-editor2005 / 3 skipped,shared-react1676 / 1 skipped,shared710,utilities56),nx run-many -t lint typecheck,prettier --check ., andverify-committed-distcleanc3f5ea80):platform-editor2021 / 3 skipped,shared-react1679 / 1 skipped,shared710;nx run-many -t typecheck lintandprettier --checkclean;verify-committed-distclean4ee4314e):platform-editor2095 / 3 skipped;nx run-many -t typecheck lintclean;verify-committed-distcleane9d665ad):platform-editor2094 / 3 skipped,shared-react1684 / 1 skipped;nx run-many -t typecheck lintclean;verify-committed-distclean33e0bce1):platform-editor2071 / 3 skipped,shared-react1684 / 1 skipped,shared710;nx run-many -t typecheck lintclean;verify-committed-distcleanplatform-editor2058 / 3 skipped,shared-react1684 / 1 skipped,shared710;nx run-many -t typecheck lintclean;verify-committed-distcleanc73f9827):platform-editor2038 / 3 skipped;nx run-many -t typecheck lintclean;verify-committed-distclean70291f25):platform-editor2042 / 3 skipped,shared-react1684 / 1 skipped,shared710;nx run-many -t typecheck lintclean (0 errors);verify-committed-distcleanAI-assisted — session 1, session 2, session 3, session 4, session 5, session 6, session 7, session 8, session 9, session 10, session 11; the human directed scope and reviewed. Round 13: session 12, session 13. Round 14: session 14.
🤖 Generated with Claude Code
Code Review Summary — automated pass, 2026-09-16
Review model: Claude Opus 5 (1M context) · Base:
origin/main· Files changed: 17This PR was reviewed together with its consumer, paranext-core#2805 — six parallel
analysis passes in total, two of them (API/correctness, style/tests) on this repo. The full
cross-repo summary, including the core half's findings and the open decisions for the review
meeting, is in the same block on core#2805. Below is what applied here.
Fixed in
5a639930selectNoteTextOffset's documented no-content fallback landed nowhere.$selectNote's expanded branch isgetChildren().reverse().find($isCharNode)?.selectEnd(), whichfor a note with no content run at all (
\f + \f*) is a complete no-op — the caret stayed outsidethe note and the next keystroke landed in the surrounding text. It now lands at the child slot
content would occupy, just before the closing glyph. Found by a test written during the review.
CHANGELOGentry added under Fixed.noteCaretPlacement.test.tsxrendered under
STANDARD_VIEW_MODE, which isnoteMode: "collapsed", while the consumer's noteeditor runs
noteMode: 'expanded'— a materially different interior, where the adaptor builds thecaller as a plain
TextNodeand folds a\catcategory into an attribute display run. Both ofthose skips are promised in
editor.model.tsand had zero coverage in either repo. Anexpandeddescribe block now covers the caller skip, the\catskip, a\fpparagraph-breakcrossing, and the no-content fallback.
onUsjChangefires only when thedeserialized USJ differs, so a highlight held in node state would dirty the editor and still pass;
and the test never asserted the highlight applied, so a no-op
highlightNotepassed it twiceover. It now counts commits via
registerUpdateListenerand asserts the highlight landed.not.toBe("verse"), which also passes for a caret leftbefore the note or back in the preceding text. It now asserts the caret's actual position (the
para-anchored slot immediately after the note). Added the missing positive pre-assertion to"clears when the highlighted note leaves the document" too.
selectNoteTextOffset's focus behavior (seebelow) and its collapsed-note constraint; the
EXPERIMENTAL:prefix ongetNoteIndex,getNoteKeyandhighlightNote; the caller highlight described as "a thin top-and-bottom border"in three places when
.caller_highlightalso paints#ffffb5(plus a warning that a hostvendoring its own
usj-nodes.cssneeds the rule, or the API is a silent visual no-op — verifiedcore's copies do carry it); and why
holdsDomFocusis deliberately broader thanisFocused.$getNoteIndexre-export was annotated "re-exported here because this is where callers have always found it" —
the symbol was created by this branch's own
a88a0dc3and moved out three commits later, sopost-merge that reads as a backwards-compatibility constraint nobody would dare remove. It now
states the real reason (the barrel exports
note.utils, not the leaf), andNoteCallerHighlightPluginimports$getNoteIndexfrom the leaf rather than re-coupling to theheavy module.
markerMenuHarness.test.tsxread as self-contradictory; reworded toname the engine's deferred settle clock, matching its sibling 40 lines above. No behavior change.
Refuted
selectNoteTextOffsetis missingselectAfterNote'sSKIP_DOM_SELECTION_TAGfocus guardwas raised as a Critical. The asymmetry is correct, not an omission:
selectNoteTextOffsetfallsback to
$selectNote, which has never carried the guard, so adding it would make the fallbackpath behave differently from
selectNotewithin the same call. Its job is "put the caret intoa note this host is about to focus" — the only consumer,
placeInitialCaretin core'sfootnote-editor.component.tsx, pairs it withfocus()unconditionally.selectAfterNoteis theopposite case: driving the Scripture caret from the pane, where focus must not move. Fixed as a
documentation gap instead: the TSDoc now states the asymmetry and tells hosts to pair it with
focus().Dismissed, with reasons
$noteDataTextStartre-derives the NBSP separator-prefix offset thatcharStack.utils.tsandeditor-delta.adaptor.tseach derive independently. Centralizing it into one$separatorPrefixLengthinmarkerSeparators.utils.tsis right — that module declares itself theowner of the representation, and any divergence is silent caret drift — but it touches the emit
path's stricter variant and belongs in its own change. Flagged for follow-up. (Done
2026-09-18 in
1097b210. The emit path keeps its stricter rule on top of the shared helper.)holdsDomFocus/isFocusedunification: documented instead, since changingisFocused's meaningaffects existing consumers.
tagsOfUpdatesDuring,renderEditor, the zero-DOMRectRangepolyfill — now on its 7th copy in the package): a package-wide cleanup, not this PR's.
getNoteIndex/getNoteKeyeach walking the whole document: one chapter's worth of notes.applyUpdateDomSelection.test.tsxnever exercisingapplyUpdatedirectly or itssource: "remote"branch: a worthwhile addition, but the collab path needs more setup than a review passshould improvise. Flagged for follow-up.
Gates after the fixes
nx run-many -t test --projects=platform-editor,shared-react— 1717 passed (98 files)nx run-many -t typecheck lint— pass (2 pre-existingno-consolewarnings indemos/)rebuild-committed-outputthenverify-committed-dist— clean.etc/platform-editor.api.mdis unchanged: no public type signature moved.
This change is