Skip to content

PT-4189: EditorRef note index/key/highlight and keep DOM focus on unfocused applyUpdate - #15

Merged
tjcouch-sil merged 76 commits into
mainfrom
pt-4189-footnote-pane-editing
Oct 12, 2026
Merged

tjcouch-sil merged 76 commits into
mainfrom
pt-4189-footnote-pane-editing

Conversation

@tjcouch-sil

@tjcouch-sil tjcouch-sil commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

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 EditorRef API 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 --onto so 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's main and did not come across.

Changes

  • EditorRef.getNoteIndex(noteKey) — document-order index of a note (the coordinate a USJ-built notes list addresses; the same index noteCallerOnClick reports). $getNoteIndex in shared-react backs it and ImmutableNoteCallerNode reuses it.
  • EditorRef.getNoteKey(noteIndex) — the inverse, so a host that addresses notes by index can hand replaceEmbedUpdate a key.
  • EditorRef.highlightNote(noteKeyOrIndex | undefined) — applies PT9's caller_highlight class (already in usj-nodes.css) to one note's caller. NoteCallerHighlightPlugin re-applies after every commit (Lexical recreates the caller DOM on collapse toggles and replaceEmbedUpdate re-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 active ViewOptions add 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. Like applyUpdate, it carries SKIP_DOM_SELECTION_TAG when 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 later focus() 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 $normalizeSelectionOutOfGlyphText exists to prevent, and a note ending a verse is followed by precisely such a node. Those notes take the parent-offset branch instead.
  • $getNoteIndex moves to its own leaf module (note-index.utils), re-exported from note.utils. ImmutableNoteCallerNode needs the index and note.utils needs ImmutableNoteCallerNode, 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.
  • A "category" field on selectNoteTextOffset (round 8, below).
  • MarginalRef delegates all of them.
  • Fixed: applyUpdate / replaceEmbedUpdate tag the update with SKIP_DOM_SELECTION_TAG when 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.
  • API report and CHANGELOG (Unreleased) updated. No version bump (release workflow owns it).
  • Refactor: how many leading code units of a text node are an opening glyph's display separator is now read in one place, $separatorPrefixLength in markerSeparators.utils.ts, which already owns that convention. Three sites derived it independently: the note caret offset behind selectNoteTextOffset, $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. $selectCharContentStart now 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.
  • The committed packages/platform/dist is 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 selectNoteTextOffset belongs in the editor

The 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-review passes over this branch and its consumer. Three fixes landed here, one of them a
behavior change in the shared ScriptureReferencePlugin that 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 \v follows the note directly.

selectAfterNote parks the caret just past the note, which for such a note is the slot the next
verse'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.

  • $getEffectiveVerseForBcv treated a caret inside an editable verse marker as past the verse
    number 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.
    getVerseNumberPrefixLength now ends the prefix where the number does, wherever in the marker it
    sits, and keeps its documented 0 fallback 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 $resolvePosition reports positions with. It asks the
    one 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 editable
markers 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.ts for the prefix itself. Each half of
the fix is independently falsifiable: reverting either one fails the new tests.

Also fixed

  • highlightNote resolved an index against a stale document (01f63874). It read the committed
    editor 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.read now, which commits pending updates
    first, as getNoteIndex and getNoteKey already do.
  • $selectNote missed an empty note's closer in the Markers view (6d5d1fe3). Selecting an
    expanded note with no content run (\f + \f*) lands the caret just before the closing glyph. The
    closer 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.
  • A flaky caret test (81e074df): a fresh editor moves its caret to scrRef's verse start a few
    microtasks 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 (+ dist
81fc0cbc), b8064029 (+ dist 94dcf375), and a0367cc6 (+ dist ab34b2cf) for the empty note.

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; dist
6c41a065, c3f5ea80 (verify-committed-dist clean). All three data-loss fixes below were already
on main, not introduced by this PR.

  • A nested span that opens a run was lost on load (found while fixing R2-1). $applyUpdate
    built 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* said saved back as \ft LORD said on any edit. It now builds
    the same shape as a nested span mid-run, and text continuing a closed run goes inside its closer.
  • Typing after an editable caller leaked the caller into content (PT-4974: Export the font part of the project stylesheet as generateUsjFontCss #42). What follows the
    caller's trailing separator is split into its own content node in the same commit, the caret
    following it.
  • Keystrokes dropped from onUsjChange after a caret-only commit (PT-4911: Hidden-marker views copy prose and filter \ and // on paste #41, R2-2, R2-3). The scrRef
    placement and both caret guards release their cursor-change tag after committing; the scrRef one
    only when the caret actually moved.
  • selectNote in a run holding a nested span (R2-1). Only a span's last child counts as its
    closing glyph.
  • Unfocused-update paths (R2-4..R2-7). isFocused() uses the same focus check as the rest of
    EditorRef; the tag-release listener is disarmed when an update commits nothing; a DOM selection
    that matches Lexical's survives an unfocused update; an unfocused selectAfterNote reports the new
    verse through onScrRefChange.
  • Smaller: the right-click menu claims the Escape that closes it, which the core PR's
    FootnoteEditor relies on; plugin order (R2-8).

Round 8 — fixes from TJ's hand QA (2026-09-25)

Commits acec819e..0e39a7f4, dist 0bab2a75 (verify-committed-dist clean), docs 791cf00f, 70291f25. Each fix has a test that fails without it.

  • Text written directly in a note was lost when applied to a collapsed view (A5). NoteNodePlugin reset 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 the x. 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.
  • A range could remove the protected note shell (A5). A double-click on the caller, a drag from the shell into the content, or select-all was grown to take in the whole \f + , and typing or deleting over it unwrapped the note. NoteShellCaretGuardPlugin now narrows such a range to the note's content; one wholly inside the shell collapses to its trailing edge.
  • No caret past a note that ends an unclosed char span (A6). \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.
  • Deleting the backslash of an undeclared marker did nothing (B5). A char span whose marker the stylesheet does not declare (\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.
  • Typing inside an existing note was reported as inserting it (A1). onUsjChange's insertedNodeKey is 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.
  • New API: selectNoteTextOffset(noteKeyOrIndex, utf16Offset, field?: "category") addresses a note's \cat value (C5); MarginalRef delegates it. (A getNoteCaret() added for a first cut of A1 was removed again in 0e690064, dist c73f9827: 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-dist clean). Each fix has a test that fails without it.

  • An unclosed note's caller can be deleted (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 …. $createWholeNote builds the caller slot for an empty caller in editable expanded mode too. The "empty caller, only for note editing" warning is gone.
  • Typing a note's own closer closes an unclosed note (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.
  • Caret after a typed undeclared marker (9f29be04): the caret restore counted a freshly built \df span (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).
  • A drag across a protected shell (fd45e7b2): the edit commands narrow the range first, since Lexical re-reads a still-dragging range from the DOM for the edit.
  • Caret inside a marker glyph (2e6060ff): selectNoteTextOffset(noteKeyOrIndex, utf16Offset, options?: { field?: "category"; glyph?: { index; offset } }) — replaces this PR's bare field argument, so nothing released changes.

Round 14 — lyonsil's round-13 follow-ups, and brought up to date with main (2026-10-12)

  • Text typed right after a note's closing glyph was lost (follow-ups on Adopt this repository into the paranext organization #1 and Separate the published artifact directory from the toolchain's scratch space (both are currently dist/) #5) (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 from getUsj() in every view. That covers clicking at the end of such a line in Unformatted view (already on main) 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.
  • Merged 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 and preservedKeys caret restore next to main's book-line rebuild, which now passes preservedKeys too. 3da2cc5d stubs Range rects in applyUpdateDomSelection.test.tsx, since main's caret change makes its focused case scroll a collapsed caret. Dist 7a4d6568; verify-committed-dist is clean.
  • Gates: platform-editor 2346 / 3 skipped, shared-react 1766 / 1 skipped, shared 713, utilities 56. nx run-many -t typecheck lint is clean.

Round 13 — lyonsil's review at 8f97b1be (2026-10-01)

Commits ec1de9f0, 4bcb4a9d, 3bcb062e, 68fc5d6a, bff1152f (verify-committed-dist clean). 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.

  • Note-shell deletes in a note editor (2, 3, 12). A forward Delete just before the note no longer removes its opening glyph and dissolves the note; a forward Delete right after the caller deletes the first content character; a forward line delete over wrapped content stops at the visual line instead of the closer.
  • Caret after a note (1, 5). Typing \f* in a view that shows notes expanded leaves the caret after the note. selectAfterNote on a note that ends an unclosed \wj no longer descends into the note. Closing a note inside an unclosed span is reported through onUsjChange (a caret announcement tagged that commit as a caret move, so the close never reached the host).
  • Reentrant calls (4, 17). takeOpsAfterNote called from onUsjChange reported stale state and, in the new test, looped without end; the apply now reports from the update's own onUpdate. The note-op reads use readLatest, so they are safe from onSelectionChange.
  • A space typed after a note's caller (18). Standard view collapses space runs when it saves; this run was split across the caller and the content, so it saved as content. It now collapses on save, as Paratext 9 reformats \f + text to \f + text. A note with a category keeps it (it follows \cat*), and a no-break space stays.
  • Two found while checking the clipboard E2E, which failed the same way on 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.
  • Refactors and test style (8, 10, 11, 13, 19, 20, 21), CHANGELOG (14–16). Done. Declined: 6 (merging the closing-marker checks changes behaviour at each use) and 9 (a speed-up that needs a second record of which notes are protected). We are keeping the CHANGELOG, best-effort until it is complete.

Hand QA for this round (Standard view, Power mode, project editable; both PRs) — run by TJ 2026-10-12

  1. Pane too small for a long note. Drag the footnotes pane to about one row tall. Press Enter on a long note's row, and click its caller in the text: the caret at the end of the note stays visible.
  2. Pane beside the text. Change footnote pane location to beside the text. Scroll mid-chapter, then close the pane with its X and reopen it with Toggle Footnotes: the verses in view stay the same each time.
  3. A closer typed mid-note, then undo and redo. In an unclosed note's row editor type \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.
  4. Closing a note inside an unclosed span. Make an unclosed \wj that 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.
  5. Unformatted (Markers) view. Type \f* to close an inline note and keep typing: the new text goes in the paragraph, not the note's last run.
  6. A space after the caller. In Markers view put the caret right after \f + and type a space. The space shows while editing; after saving and reloading it is gone. \f + xy saves as \f + xy.
  7. Row editor deletes. In a note's row editor: arrow left out of the content to just before the note, then Delete: the note stays. Click right after the caller and press Delete: the first content character goes. In a note wrapped over several lines, a line delete stops at the visual line.
  8. End key at a paragraph a footnote ends. In a paragraph that ends with a footnote press End, and in a chapter ending with one press Ctrl+End (Cmd+Down on a Mac): the caret is after the note, and typing or pasting goes after it. Then Ctrl+Home, Ctrl+Shift+End and copy: the clipboard holds the whole note.

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-editableMarkersView developer 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 the unclosed-note-close-undo E2E covers the text after the closer moving into the paragraph. Item 6's save is unit-tested in markerEditTier1.utils.test.tsx with 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 committed dist conflicted; it was rebuilt, and verify-committed-dist is clean. After the merge: platform-editor 2228 / 3 skipped and shared-react 1684 / 1 skipped pass; nx run-many -t typecheck lint and prettier --check . are clean.

Round 12 — TJ's round-11 hand QA (2026-09-30)

Commit 1a113a8e, dist 4ee4314e (verify-committed-dist clean).

  • An undo of a closer typed into an unclosed note also removed the text typed just before it. In the app the marker palette commits \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's takeOpsAfterNote still joins it. The tests now commit the closer through commitTypedCloser after a separate typing step, as the hosts do.
  • The close keeps the same note node. Content and attributes move into the existing note instead of a new one, so onUsjChange no 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, dist e9d665ad (verify-committed-dist clean). 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.)
  • The closer in a note editor is protected like the opener (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, dist 33e0bce1 (verify-committed-dist clean). Each fix has a test that fails without it.

  • Deletes at a protected shell's edge (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.
  • Forward Delete through an opening glyph (984d92a3): the press removing a glyph's last character also removed the separator, and $charTextNodeTransform read 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.
  • Undeclared marker typed in a note with \cat (e6ccf4cf): a note rebuild spliced the sentinel \df span 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; $getReplaceEmbedOps deletes 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/main is merged in (a48bcf79), picking up #13 (copy/paste fidelity), #18, and #21. Conflicts: Editor.tsx insertNote now uses main's discrete update together with this branch's $rememberExpandedNote. CHANGELOG.md keeps both sides' Fixed entries. The committed packages/platform/dist is rebuilt, and verify-committed-dist passes.

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-editor 1714, shared-react 1654 (1 skipped), shared 675, utilities 56, plus the demos. After the separator-prefix refactor: platform-editor 1718, shared-react 1654 (1 skipped), shared 684, utilities 56, plus typecheck and lint, and verify-committed-dist is clean at 6ad4a091. Includes noteIndexAndHighlight.test.tsx, applyUpdateDomSelection.test.tsx, and noteCaretPlacement.test.tsx — the last renders under STANDARD_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:check
  • node scripts/rebuild-committed-output.mjs followed by node scripts/verify-committed-dist.mjs — the committed output is current and reproduces byte-for-byte on a second rebuild
  • At the merged tip (a48bcf79): nx run-many -t test all 9 projects green (platform-editor 2005 / 3 skipped, shared-react 1676 / 1 skipped, shared 710, utilities 56), nx run-many -t lint typecheck, prettier --check ., and verify-committed-dist clean
  • Round 7 tip (c3f5ea80): platform-editor 2021 / 3 skipped, shared-react 1679 / 1 skipped, shared 710; nx run-many -t typecheck lint and prettier --check clean; verify-committed-dist clean
  • Round 12 tip (4ee4314e): platform-editor 2095 / 3 skipped; nx run-many -t typecheck lint clean; verify-committed-dist clean
  • Round 11 tip (e9d665ad): platform-editor 2094 / 3 skipped, shared-react 1684 / 1 skipped; nx run-many -t typecheck lint clean; verify-committed-dist clean
  • Round 10 tip (33e0bce1): platform-editor 2071 / 3 skipped, shared-react 1684 / 1 skipped, shared 710; nx run-many -t typecheck lint clean; verify-committed-dist clean
  • Round 9 tip: platform-editor 2058 / 3 skipped, shared-react 1684 / 1 skipped, shared 710; nx run-many -t typecheck lint clean; verify-committed-dist clean
  • Round 8 rework tip (c73f9827): platform-editor 2038 / 3 skipped; nx run-many -t typecheck lint clean; verify-committed-dist clean
  • Round 8 tip (70291f25): platform-editor 2042 / 3 skipped, shared-react 1684 / 1 skipped, shared 710; nx run-many -t typecheck lint clean (0 errors); verify-committed-dist clean
  • Consumed by the core branch and hand-QA'd there (round 1; the round-2 and round-3 QA of the caret APIs is listed on the core PR)

AI-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: 17

This 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 5a639930

  • A real defect: selectNoteTextOffset's documented no-content fallback landed nowhere.
    $selectNote's expanded branch is getChildren().reverse().find($isCharNode)?.selectEnd(), which
    for a note with no content run at all (\f + \f*) is a complete no-op — 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, just before the closing glyph. Found by a test written during the review.
    CHANGELOG entry added under Fixed.
  • The caret tests ran in a view mode the only consumer never uses. noteCaretPlacement.test.tsx
    rendered under STANDARD_VIEW_MODE, which is noteMode: "collapsed", while the consumer's note
    editor runs noteMode: 'expanded' — a materially different interior, where the adaptor builds the
    caller as a plain TextNode and folds a \cat category into an attribute display run. Both of
    those skips are promised in editor.model.ts and had zero coverage in either repo. An
    expanded describe block now covers the caller skip, the \cat skip, a \fp paragraph-break
    crossing, and the no-content fallback.
  • "does not mark the document dirty" tested nothing. onUsjChange fires only when the
    deserialized 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 highlightNote passed it twice
    over. It now counts commits via registerUpdateListener and asserts the highlight landed.
  • The glyph-avoidance test asserted only not.toBe("verse"), which also passes for a caret left
    before 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.
  • Documentation that had drifted or was missing: selectNoteTextOffset's focus behavior (see
    below) and its collapsed-note constraint; the EXPERIMENTAL: prefix on getNoteIndex,
    getNoteKey and highlightNote; the caller highlight described as "a thin top-and-bottom border"
    in three places when .caller_highlight also paints #ffffb5 (plus a warning that a host
    vendoring its own usj-nodes.css needs the rule, or the API is a silent visual no-op — verified
    core's copies do carry it); and why holdsDomFocus is deliberately broader than isFocused.
  • A backward-facing comment asserting a constraint that never existed. The $getNoteIndex
    re-export was annotated "re-exported here because this is where callers have always found it" —
    the symbol was created by this branch's own a88a0dc3 and moved out three commits later, so
    post-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), and
    NoteCallerHighlightPlugin imports $getNoteIndex from the leaf rather than re-coupling to the
    heavy module.
  • A polling comment added to markerMenuHarness.test.tsx read as self-contradictory; reworded to
    name the engine's deferred settle clock, matching its sibling 40 lines above. No behavior change.

Refuted

  • selectNoteTextOffset is missing selectAfterNote's SKIP_DOM_SELECTION_TAG focus guard
    was raised as a Critical. The asymmetry is correct, not an omission: selectNoteTextOffset falls
    back to $selectNote, which has never carried the guard, so adding it would make the fallback
    path behave differently from selectNote within the same call. Its job is "put the caret into
    a note this host is about to focus" — the only consumer, placeInitialCaret in core's
    footnote-editor.component.tsx, pairs it with focus() unconditionally. selectAfterNote is the
    opposite 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

  • $noteDataTextStart re-derives the NBSP separator-prefix offset that charStack.utils.ts and
    editor-delta.adaptor.ts each derive independently. Centralizing it into one
    $separatorPrefixLength in markerSeparators.utils.ts is right — that module declares itself the
    owner 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/isFocused unification: documented instead, since changing isFocused's meaning
    affects existing consumers.
  • Test-helper duplication (tagsOfUpdatesDuring, renderEditor, the zero-DOMRect Range
    polyfill — now on its 7th copy in the package): a package-wide cleanup, not this PR's.
  • getNoteIndex/getNoteKey each walking the whole document: one chapter's worth of notes.
  • applyUpdateDomSelection.test.tsx never exercising applyUpdate directly or its source: "remote" branch: a worthwhile addition, but the collab path needs more setup than a review pass
    should 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-existing no-console warnings in demos/)
  • rebuild-committed-output then verify-committed-dist — clean. etc/platform-editor.api.md
    is unchanged: no public type signature moved.

This change is Reviewable

tjcouch-sil and others added 13 commits September 16, 2026 11:39
…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
…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
tjcouch-sil and others added 2 commits September 18, 2026 11:38
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
tjcouch-sil and others added 8 commits September 18, 2026 14:10
`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
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
@tjcouch-sil
tjcouch-sil marked this pull request as ready for review September 21, 2026 18:08
tjcouch-sil and others added 7 commits September 28, 2026 16:30
…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 lyonsil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

lyonsil added a commit to lyonsil/pr-review that referenced this pull request Oct 1, 2026
…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>
tjcouch-sil and others added 5 commits October 1, 2026 15:55
…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
@tjcouch-sil

Copy link
Copy Markdown
Member Author

Round 13 is pushed (ec1de9f0, 4bcb4a9d, 3bcb062e, 68fc5d6a, bff1152f), with a thread reply on each inline finding. Fixed 1–5, 8, 10–17 and 19–21; #18 is fixed a different way (collapse on save); #6 and #9 are declined, with reasons in their threads.

Two bugs outside the review, found while checking the clipboard E2E (clipboard-usfm-round-trip.spec.ts failed the same way on 8f97b1be): a range whose end lands inside a collapsed note took half the note, and End / Ctrl+End in a block a footnote ends selected the note instead of leaving the caret past it. Both are fixed in this round with tests.

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 lyonsil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

:lgtm:

@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 lyonsil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@lyonsil resolved 2 discussions.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on tjcouch-sil).

tjcouch-sil and others added 5 commits October 12, 2026 04:20
…-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 lyonsil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

:lgtm:

@lyonsil reviewed 24 files and all commit messages, and made 1 comment.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on tjcouch-sil).

@tjcouch-sil
tjcouch-sil merged commit e723274 into main Oct 12, 2026
5 of 6 checks passed
@tjcouch-sil
tjcouch-sil deleted the pt-4189-footnote-pane-editing branch October 12, 2026 15:53
tjcouch-sil added a commit that referenced this pull request Oct 13, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants