Skip to content

PT-4611: Standard view marker palette — stop typing reaching scripture text - #2870

Draft
timothy-mccormack wants to merge 10 commits into
mainfrom
pt-4611-marker-palette
Draft

timothy-mccormack wants to merge 10 commits into
mainfrom
pt-4611-marker-palette

Conversation

@timothy-mccormack

@timothy-mccormack timothy-mccormack commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes PT-4611. Draft: see What still needs doing below.

Rewritten after @tjcouch-sil's review. This PR no longer does what its first version did. The earlier approach kept typing out of the verse by making the editor non-editable for the life of a palette session; that is gone, along with everything built on it. If you read the old description or the first round of commits, start again from here.

The bug

The Enter-split paragraph palette let every keystroke through to the document: typing at an open palette edited the verse underneath it, arrows moved the document caret instead of the highlight, and the reported "two palettes stacked" turned out to be one palette painting two panels.

The root cause was that the palette never held keyboard focus. The overlay's autofocus gave up when its input did not exist yet — which is always true on an anchored palette's first pass, because Radix's portal renders nothing then. So focus stayed in the editor and the editor kept the keys.

The design

The palette takes real keyboard focus, on its LIST rather than a text box (focusTarget: 'list', a new opt-in overlay option). Focusing something that cannot be edited is what keeps composed input out: an IME or dead key begins composing in any focused input whatever a handler cancels, and once a composition is under way per-key rules no longer apply to it. On a list, none starts.

The editor stays fully editable and keeps its caret throughout. Key routing in both consumers is simply "the editor has focus". The palette renders this session's query read-only and forwards every key back, so one table decides what each key means wherever focus happens to be.

One key table for all three session kinds, following Paratext 9's rules (MarkerDropdownControl.cs). Two palettes that look alike must not behave differently:

Key Behaviour
letters, digits, +, - narrow the filter
Backspace widens it; with nothing typed, closes
Enter / Tab (± Shift) commit the highlighted item; zero matches is a claimed no-op
Space commits the TYPED marker; empty filter closes and inserts nothing
* commits the typed marker's closing form
\ commits and reopens; empty filter is ignored
Ctrl/Cmd/Alt chord (not AltGr, not Option on macOS) closes
Escape closes
anything else — punctuation, accented or non-Latin characters, Dead, navigation keys ignored: claimed, nothing reaches the text, palette stays open

Letters and digits survive non-Latin keyboards. The character the layout produced wins whenever it can name a marker; the physical key (keyCode) is the fallback only when it cannot. So Cyrillic and Greek layouts type Latin marker names, while AZERTY and QWERTZ keep theirs. Reading keyCode first does not work: it is layout-assigned on Windows but mapped through a US table on macOS, and AZERTY's - key reports a digit code (turning qt-s into qt6).

Nothing may change the editor under an open palette. A commit applies at the caret, so a content or caret change closes the palette and refuses the commit — which is also what now answers paste/cut/drop, since they no longer have a lock blocking them.

What was removed

  • The input lock (marker-palette-input-lock.util.ts), its tests, its export, and every call site in the web view and FootnoteEditor.
  • The page-body key-routing rules that existed only because the lock blurred the editor.
  • The Enter-only branches of the key table and the ForwardedSessionKind alias.

Behaviour changes worth a reviewer's attention

  • A chord now closes both palettes (it previously passed through and left the Enter palette open).
  • Shift+Enter / Shift+Tab commit (previously inert).
  • Backspace on an empty filter closes (previously inert in the Enter palette).
  • \ on an empty filter is ignored (previously landed in the text and dismissed).
  • Option is a typing modifier on macOS, not a chord — Option+e begins é. Ctrl+Option and Cmd+Option are still chords.
  • A list-focused palette stops forwarded keys propagating in the renderer document, so document-level bindings (PlatformMenubar's bare alt, notification-display's Alt+T) cannot reach past it.

Testing

Unit: the key table is table-driven across all three kinds; keyCode resolution covers Cyrillic, AZERTY on both platforms, Czech +, Keyman packet keys; the change guard and the session spine have their own suites; the overlay covers list focus, read-only input, full key forwarding, and that the default palette is unchanged.

In the app (Power mode, Standard view, WEBBLANK Psalms 1:1), checked against the saved USFM rather than the screen: palette opens with list focus and the editor still contenteditable; filtering narrows without touching the text; punctuation, é and ж are ignored; Escape restores the caret to its exact offset; a commit splits the paragraph and applies the marker; chords, empty-filter Backspace and empty-filter \ behave as the table says.

Manual keyboard checks

Run by hand on macOS 13.7 (Power mode, Standard view, WEBBLANK Psalms 1:1). The saved USFM was diffed after every one and was byte-identical each time.

Check Result
Dead key — Option+e then e filter shows e; palette stays open; verse untouched ✅
Russian layout — the q and 1 keys filter shows q1, not й1; verse untouched ✅
Japanese – Romaji IME letters filter the list; no candidate window appears; verse untouched ✅
Keyman not run — third-party install, see below

This pass is what found three of the bugs fixed here, none of which any automated test could reach, because synthetic key events do not go through the OS composition path:

  1. Option alone closed the palette — PlatformMenubar's document-level alt binding fires a synthetic Escape. Old code, newly reachable once focus moved out of the WebView iframe.
  2. Option+e closed it — the key table counted any altKey as a command chord, which is wrong on macOS.
  3. Option+e then filtered ee instead of e — the dead key was ingested as a letter by both the character path and the physical-key fallback.

Still to do:

  • Windows pass. The same four checks; keyCode is layout-assigned there rather than mapped through a US table, which is precisely the difference that made the first keyCode implementation wrong.
  • Keyman, which needs a third-party install. Flagging rather than assuming: is it worth installing for this, or acceptable to merge untested given the packet-key path has unit coverage?

Noted behaviour, needs a product call

On the macOS Russian layout the key at the \ position produces ё, so the \ palette cannot be opened from it. The Enter palette is unaffected and markers filter in Latin as designed.

I believe leaving this is correct: the trigger is a literal character — a USFM marker is \p, the backslash is content — so matching the physical key instead would open a palette where a Russian user meant to type ё, costing them a real letter. The filter is the opposite case: no Cyrillic character can ever name a marker, so there the physical-key fallback is right, and that is what this PR adds. Worth a second opinion rather than a silent decision.

What still needs doing

  • Pause the editor's idle marker settle while a palette is open. Needs a new pause/resume API on EditorRef in scripture-editors, which has to merge first. Without it, \qt-s then \ reopens a palette that closes itself about a second later. Settling before opening does not substitute: commitPendingMarkerEdits skips the node under a live caret, which is exactly where the pending edit is.

Known gaps, documented in Standard-View-Invariants.md

  • PT-4817 — an open palette defers same-chapter data-provider updates with no time limit and can push the editor's content back over them. Automatic Send/Receive now dismisses the palette first, so that collision is closed; other external writers can still be overwritten.
  • paste, cut and drop are no longer blocked. Once the palette holds focus a paste goes to the palette, but a drop needs neither keydown nor focus. The change guard answers this: the content change closes the palette and refuses the commit, so the dropped text is the user's own edit rather than a marker landing where they did not choose.

Merge ordering

lib/platform-bible-react/dist/ is committed, so this collides with any other live pbr branch regardless of source files.

  • On a dist conflict: reset to the target branch's copy and rebuild — never hand-merge the bundles.
  • Staging needs git add -A lib/platform-bible-react/dist: git add -u leaves the new content-hashed chunks untracked, publishing a bundle that imports files not in the repo.
  • main has been merged in as of the latest commit, which also picked up the package-lock.json update for scripture-editors' new unicode-segmenter dependency — without it CI fails at npm install before building anything.

🤖 Generated with Claude Code


This change is Reviewable

timothy-mccormack and others added 3 commits September 28, 2026 15:49
…e text

The Enter-split paragraph palette let every keystroke through to the document:
typing at an open palette edited the verse underneath it, arrows moved the
document caret instead of the highlight, and a reported "two palettes stacked"
turned out to be one palette painting two panels.

Root cause: 'enter' was the only palette kind excluded from the keystroke
forwarding table, on the premise that its overlay is "always FOCUSED with no key
forwarding". The overlay never wins that focus, so the pass-through meant to
cover a sub-frame race lasted the whole session.

'enter' is now a forwarded kind that guards its pending paragraph split the way
'selection' guards a selection — both hold something a landing key would destroy.
Per the product ruling on the issue, ONLY selecting a marker may change the
scripture text: an unmodified Enter or Tab commits the highlighted item, and
everything else — Space, `*`, `\`, a MODIFIED Enter/Tab (Shift included),
punctuation, navigation keys, and Backspace on an empty filter — is claimed and
ignored with the palette left open. Only Escape dismisses.

Non-basic-Latin is ignored, matching PT9 (Vladimir on the issue). That cannot be
done by intercepting keys: measured in this Electron build, `beforeinput` for
`insertCompositionText` is not cancelable, and `compositionstart` accepts
preventDefault() and composes anyway. Instead the editor is made non-editable for
the life of a palette session, so composed input has nowhere to land. The lock
re-asserts itself, because the attribute is Lexical's and a mid-session editable
flip would otherwise write it back and silently reopen the hole; releasing it
restores the editor's real editable state rather than a hard-coded `true`, which
could hand back a browser-editable document that Lexical considers read-only.

The `\` palette's land-and-dismiss is unaffected: dismissal releases the lock
synchronously in the capture phase, before the character is processed.

Also fixed:
- the duplicate-panel appearance — PopoverContent and the Command inside both
  painted an opaque surface at the same rect with mismatched radii, so the outer
  one's corners, ring and shadow showed around the inner's border on every open
- the passive list had no scroll-into-view (cmdk supplied it only to focused
  palettes), so arrow navigation moved an off-screen highlight and Enter
  committed a marker the user never saw
- the palette was captioned with the platform-generic "Search..." instead of
  %markerMenu_searchPlaceholder_paragraph%, the key the toolbar paragraph-style
  control already uses
- the editor-side commit drivers now restore the caret first, as the shared spine
  does: they run while the lock has the editor blurred, and Lexical's blur
  processing can null the selection, so a commit could refuse silently or land at
  the document end
- the insert-comment hotkey no longer fires behind an open palette

Key routing is extracted to `shouldRoutePaletteKey` and covered by tests. An open
session routes keys whether or not the editor holds focus (the lock blurs it) and
whether or not the editor has turned read-only mid-session (a scheduled sync can
flip that with no user gesture) — without either, a palette could be left open
over a locked editor that not even Escape could close.

Verified in the running app against the saved USFM rather than the rendered view:
commits produce real structural splits, the lock engages and re-asserts, and
nothing else reaches the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Move the palette input lock into a shared createMarkerPaletteInputLock
  helper used by both the main editor and the footnote popover, so the
  "only selecting a marker may change the text" rule holds in both.
- The lock now cancels paste, cut and drop into the locked editor (Lexical
  checks its editable flag, not the DOM attribute, so Cmd/Ctrl+V still
  pasted under the open Enter palette).
- The lock keeps the element it locked and always releases it on unlock,
  so an unlock while the editor is unmounted no longer leaves a stale
  observer that turned the next lock into a no-op.
- End the palette session when the editor turns read-only mid-session, and
  route only Escape until it has: the editor's commit methods throw in
  read-only mode.
- Route an open session's keys only from the editor or the page, never from
  another element such as the comment box.
- The footnote popover routes session keys while the lock has blurred its
  editor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Open the Enter-split palette through the shared runMarkerPaletteSession
  spine, which now takes an explicit session kind, instead of a hand-copied
  open path. The two copies had already drifted on refocus and ordering.
- Write the web view's palette session only through setPaletteSession /
  clearPaletteSession, which keep the input lock in step, instead of pairing
  each write with a separate lock call at seven sites.
- Remove the redundant 'enter' IME/dead-key branch: the existing checks
  already pass those keys through without committing or dismissing.
- Correct docs that contradicted the code: the keydown table's module doc
  and TSDoc, the session spine, Standard-View-Invariants.md's Enter-split
  section, two keyboard-shortcut catalog entries, the overlay palette, and
  resolveEditingSessionActivity / useEditorPdpSync (which now record that an
  open palette can push the editor's content over an incoming merge).
- Rewrite backward-facing comments in present tense.
- Test the overlay palette's passive scroll-into-view, and correct the
  claimed-keys sweep's comment about what its 'enter' row can catch.

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

@tjcouch-sil tjcouch-sil 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.

Thanks for the careful investigation on this one: the root-cause work on the ticket and the measurements of what can and can't stop composed input saved a lot of time.

I went through the design in depth, including a hands-on trial in the running app, and I'd like to change direction on how the palette keeps keystrokes out of the text. The short version:

  • The palettes can hold real focus. The Enter palette never got focus because of a bug in the overlay's autofocus: it gives up when the input doesn't exist yet, and an anchored palette's portal hasn't rendered it on the first pass. Nothing was stealing focus. With focus working, the contenteditable="false" lock isn't needed, and neither is any of the fallout it causes (keys landing on the page body, the lost caret, focus never coming back after a \ palette closes, a separate lock in the footnote popover and the pane).
  • Both palettes follow one set of key rules, Paratext 9's. The Enter-only rules exist to protect a "pending paragraph split", but in Paratext 9 closing the Enter palette without a paragraph marker leaves the text unchanged, so there is nothing to protect.
  • Paratext 9's IME and dead-key behaviour comes from focusing something that isn't a text box and reading key codes. Marker palettes get a new opt-in overlay option that focuses the list and shows the filter read-only.

The full plan is below: what to keep, remove and add, the reasoning, what I considered and declined, the evidence, and a checklist. The panel fix, scroll-into-view, caption and spine refactor in this PR all stay.

Happy to talk any of it through.


Standard-view marker palettes: revised design for PT-4611 (review of #2870)

Status: review plan, 2026-09-30, to be implemented by the PR author on
pt-4611-marker-palette. This document is the full plan: what to build, why, what was considered
and declined, the evidence behind each call, and a checklist to compare the implementation against.

Summary

#2870 fixes a real data-integrity bug (typing at an open Enter palette edited the verse underneath
it) and makes composed input (dead keys, IMEs) unable to reach the text. It does the second part by
making the editor contenteditable="false" for the life of every palette session. That works, but
it takes focus away from the editor on every palette open — including the \ palette, the most-used
one — and every consumer then has to handle the fallout (keys landing on the page body, lost caret,
focus never coming back, a second lock in the footnote popover, the footnote pane in #2805).

The revised design removes the lock entirely:

  1. Both palettes follow one set of key rules — Paratext 9's — and differ only in which markers
    they list and in the apply step for a highlighted paragraph marker.
  2. The palette takes real keyboard focus. It never did before because of a bug in the overlay's
    autofocus. Marker palettes opt into a new overlay mode that focuses the palette's list and shows
    the filter in a read-only input, so no IME or dead-key composition can start — which is exactly
    how Paratext 9 behaves (its palette has no text box).
  3. Letters and digits are read from keyCode, like Paratext 9's virtual-key handling, so a dead
    key followed by e filters e, and a Cyrillic layout types the Latin letter for that key.
  4. Nothing may change the editor while a palette is open. Pending marker settles run before the
    palette opens and are paused while it is open; if the editor's content or caret changes anyway,
    the palette closes rather than committing somewhere the user did not choose.
  5. The editor stays fully editable the whole time. No lock, no attribute toggling, no special
    key routing for the page body.

1. The problem, restated

  • Standard view opens a marker palette from \ (at a caret, or over a selection) and from Enter
    (paragraph markers). The palette is rendered by the renderer's overlay service, outside the web
    view's iframe, so it can extend past the web view's edges.
  • On main, the \ palette at a caret is passive: it never takes focus, and the web view forwards
    keys to it from a capture-phase listener (PT-4188). The Enter palette and the \-over-selection
    palette ask for focus but never get it (see 3.2), and only the \ kinds have key forwarding.
    So at an open Enter palette every key reached the editor: typed letters went into the verse and
    arrows moved the document caret.
  • Product rulings on the ticket: only choosing a marker may change the scripture text; the palette
    ignores non-basic-Latin input, as Paratext 9 does. Both apply to both palettes.

2. What to keep, remove and add relative to #2870

Keep

  • The panel paint fix in overlay-command-palette.component.tsx (one painted surface).
  • Scroll-into-view for the host-driven list (the new list-focus mode is host-driven too).
  • The Enter palette's %markerMenu_searchPlaceholder_paragraph% caption.
  • Routing the Enter palette through runMarkerPaletteSession with an explicit kind.
  • Dismissing an open palette session when isReadOnlyEffective turns true mid-session (automatic
    Send/Receive): a palette cannot commit into a read-only editor.
  • The insert-comment hotkey gate (harmless).

Remove

  • marker-palette-input-lock.util.ts, its tests and its experimental.ts export; every lock call
    in the web view (paletteInputLock, setPaletteInputLock, the lock half of setPaletteSession,
    isReadOnlyEffectiveRef if nothing else uses it) and in FootnoteEditor.
  • The routing rules that exist only because the lock put keys on the page body:
    shouldRoutePaletteKey's "open session admits keys whether or not the editor is focused" and the
    "other element" rule, and the same change in FootnoteEditor's keydown listener. Keys now arrive
    either in the editor's frame (before the palette has focus) or from the palette through
    keyForwarding (after). Keeping the extracted, tested routing function is fine if it stays
    useful.
  • The Enter-only branches of handleMarkerPaletteSessionKeyDown (see 3.1) and the redundant
    ForwardedSessionKind alias.

Add — everything in section 3.

3. Design

3.1 One set of key rules for both palettes (Paratext 9 parity)

Both kinds use the same table. The only per-kind differences left are the item list and the
apply step for a highlighted item (3.2), plus the existing selection-palette refusals (Space with no
exact match and * with an empty filter leave a selection intact).

Key Behaviour (both palettes)
Letters, digits, +, - Add to the filter. Letters and digits are read from keyCode (3.4).
Backspace Remove the last filter character; with an empty filter, close the palette.
Space Empty filter: close, insert nothing. Otherwise commit the typed text even if it is not highlighted or not in the list (commitTypedMarker); a typed note marker still commits like Enter (shouldSpaceCommit, unchanged).
* Commit the typed text as a closing marker (commitTypedCloser).
\ Filter not empty: commit what was typed and reopen a fresh palette (the existing \qt-s\qt-e flow). Empty filter: ignored, palette stays open.
Enter, Tab (unmodified or with Shift) Commit the highlighted item. Zero matches: nothing happens, palette stays open.
Ctrl/Cmd/Alt chords (not AltGr) Close the palette. Chord+Enter stays claimed so the list cannot also commit.
Escape Close.
Up/Down arrows Move the highlight.
Anything else — punctuation, accented or non-Latin characters, Left/Right/Home/End/PageUp/PageDown/Delete, Dead Ignored. The palette stays open and nothing reaches the text.
Mouse Clicking an item commits it; clicking anywhere else closes (existing overlay click-away).

Enter-only branches in #2870's marker-palette-keydown.util.ts and what happens to each:

#2870 branch What it does Change
Modified Enter/Tab claimed and ignored Keeps a chorded Enter from a plain split Delete. Shift+Enter/Tab commits like Enter; Ctrl/Cmd/Alt+Enter is a chord and closes (still claimed).
Chords pass through, Enter palette stays open Protects the "pending split" Delete. Chords close both palettes.
Space, *, \ claimed and ignored Protects the "pending split" Delete. Use the shared rules above.
Backspace on an empty filter ignored Protects the "pending split" Delete. Closes, as in Paratext 9.
Catch-all: every other key claimed and ignored Protects the "pending split" Keep and apply to both kinds. This is the "ignore punctuation and non-Latin" rule.

Why: users should not have to learn two sets of rules for two palettes that look the same. In
Paratext 9 both palettes behave identically (MarkerDropdownControl.cs KeyDown/KeyPress,
:136-219). Every Enter-only rule in #2870 exists to protect a "pending paragraph split" — the idea
that closing the Enter palette without a choice loses the user's Enter. In Paratext 9, closing it
without a paragraph marker removes its temporary newline, so the text is unchanged; losing the Enter
is the intended outcome, not data loss. (Inserting that temporary newline in Paratext 10 is a
separate work item.)

Punctuation and \ with an empty filter: the old \ palette closed on an unrelated key and let
the key land in the text. Once the palette holds focus, nothing can land anyway, and the ruling is
that such keys are ignored in both palettes.

3.2 The apply step

  • Typed commits (Space, *, \ reopen), both palettes: the same commitTypedMarker /
    commitTypedCloser calls at the caret. The marker engine decides whether the marker starts a new
    line (for example \p mid-text starts a new paragraph, nd* goes inline, an unknown marker
    resolves by the existing marker rules). No palette-specific logic.
  • Highlighted item: unchanged. The \ palette uses applyMarkerMenuSelection(item, { trigger: 'backslash', … }) (retags a paragraph at its content start, splits mid-text); the Enter palette
    uses splitParagraphWithMarker and its list still offers only paragraph markers
    (getEnterMenuItems). Leave the \ palette's retagging alone.
  • Closing without a commit: nothing changes. There is no pending split.

3.3 The palette takes real focus

The existing bug (affects every non-passive palette): the autofocus effect in
src/renderer/components/overlays/overlay-command-palette.component.tsx (the tryFocus effect,
~:381-398 on main) returns immediately when inputRef.current is null and never retries. An
anchored palette renders through Radix Popover.Portal, which renders nothing on its first commit,
so the input does not exist yet when the effect runs, and focus is never requested. That is the
whole reason the Enter palette never won focus. Fix it for every palette that asks for focus: focus
when the element attaches (callback ref, or Radix onOpenAutoFocus), keeping the bounded retry.
The existing passive option remains the way to ask for no focus.

New opt-in option for marker palettes. Add an option to CommandPaletteRequest
(src/renderer/services/overlays/overlay.service-model.ts), for example focusTarget: 'list'
(default 'input', today's behaviour). With 'list':

  • The palette focuses its list (a focusable listbox with aria-activedescendant, as the passive
    listbox already has), not its text box.
  • The input is read-only and shows the host-driven filterText; filtering and highlight are driven
    by the host through updateCommandPalette, as passive mode does today.
  • Every keydown the palette receives is forwarded to the host through keyForwarding, so the
    shared key table stays the single place that decides what a key does (today only the keys in
    keyForwarding.keys are forwarded; list mode needs all of them).
  • Only the marker palettes set it (main editor, FootnoteEditor's popover, and the footnote pane
    from #2805). Other PAPI consumers keep today's behaviour.

Marker palettes stop using passive. All three kinds (backslash, selection, enter) use list
mode, so the selection palette's filter also becomes host-driven (containment matching, like the
other kinds).

Why list and not input: a focused text box accepts IME and dead-key composition no matter what
we cancel (measured: a Linux dead key composed é into the palette's input), so the user would see
the IME's candidate window and composing text in the palette. With focus on an element that cannot
be edited, no composition starts (measured: Dead, then e with keyCode 69, no composition
events) — the Paratext 9 behaviour. Real Windows and macOS IMEs could not be tested in the trial
environment; see the manual checks in section 6. If they turn out to compose even with the list
focused, fall back to focusing the input and ignoring composition (clear it on compositionend).

3.4 Read letters and digits from keyCode

  • keyCode 65–90 → the letter; 48–57 without Shift and 96–105 → the digit. + and - come from
    event.key.
  • Case: keep today's case handling (custom markers may be uppercase); for a non-Latin event.key,
    derive case from Shift/CapsLock.
  • Fallback for Keyman and similar input (keyCode 231 / packet keys): if keyCode is not a letter
    or digit but event.key is in the filter set, accept event.key — Paratext 9's KeyPress fallback
    (MarkerDropdownControl.cs:194-214).
  • In Chromium on Windows keyCode is the Windows virtual-key code, which is what Paratext 9 reads
    (e.KeyCode), so AZERTY letters come out right and Cyrillic keys give their Latin letter.
    Confirm macOS and Linux values in the manual checks.

3.5 Before the palette has focus (~70–130 ms)

  • The session is created synchronously in the trigger's keydown (already true). While a session is
    open and the editor still has focus, the web view's capture listener routes every key to the
    table — for all kinds, including enter (on main the Enter kind is not forwarded). Measured:
    Enter followed immediately by q1 put q1 into the verse on main, because nothing forwarded
    it.
  • Keys routed from the web view and keys forwarded by the palette go to the same
    runPaletteSessionKey.
  • Composition during this window: if compositionstart fires in the editor while a session is
    open, close the palette and let the composed text land as ordinary typing. This is the one
    accepted gap: forwarding cannot carry composition, and the only ways to stop it (make the editor
    non-editable, or move focus to a hidden element) are what this design rejects.

3.6 Nothing changes the editor while a palette is open

Why: any Lexical update that changes the selection while the editor is unfocused makes Lexical
call focus() on the editor root (updateDOMSelection, node_modules/lexical/Lexical.dev.js
~:7894-7900). That pulls focus out of the palette, the renderer's window-blur handler dismisses it
(overlay.service-host.ts ~:1196), and the user's next keys go into the text. The trial
reproduced this by forcing an update; in the flows it tried, none happened on their own.

Requirements:

  1. Settle pending marker edits before opening a palette, so the editor's own settle-on-blur
    (MarkerEditPlugin BLUR_COMMAND, scripture-editors ~:1160) has nothing to do when focus
    moves to the palette. Keep that handler's caret-node exception.
  2. Pause the idle settle while a palette is open (MarkerEditPlugin ~:696-733, one second
    after the last editor change; palette keys do not reset it). It needs a new pause/resume API on
    EditorRef in scripture-editors. Setting markerSettleDelayMs to -1 is not enough: it does
    not cancel a timer already armed. On resume, re-arm the timer. A likely trigger without this:
    \qt-s then \ commits \qt-s and reopens a palette, and one second later the settle closes
    it. #2805 already guards commitPendingMarkerEdits while a palette is open for the same reason.
  3. Change guard: while a palette is open, if the editor's content or caret changes, close the
    palette without committing. Take the baseline once the palette has focus (after the blur
    processing). Ignore Lexical nulling its selection on blur. Do not use Lexical's dirty-node
    markers to detect a content change: the root is marked dirty on every commit. Compare actual
    content (node map or USJ) and the caret position. Put it in the shared session spine, with an
    editor-side hook, so the popover and the pane get it too.
    • Why close rather than keep the palette open: a commit applies at the caret. If something
      moved the caret or changed the text, the marker could go somewhere the user did not choose. It
      is better for the palette to close and the user to reopen it where they want. The guard also
      guarantees that the caret saved at focus-out is exact when it is restored.
  4. Existing protections stay: same-chapter data-provider updates are deferred while a palette is
    open; a chapter or book change dismisses it; the editor turning read-only dismisses it.

Declined: tagging session updates with Lexical's skip-selection-focus tag, which updates the
selection without calling focus(). It would keep the palette open through an editor change, which
is exactly the case where the commit could land in the wrong place. With the guard closing the
palette, the tag is unnecessary.

3.7 Focus and caret when the palette closes

  • Escape, click-away and commits return focus through the overlay's focus restore
    (setDocumentFocusToTab → .editor-input) and the spine's focusEditor. Measured: after
    Escape, after an Enter commit and after a mouse-click commit, focus came back with the caret
    exactly where it was, typing continued there, and the scroll position did not move.
  • Restore the caret from the focus-out capture (restoreSelectionIfLost) before focusing in the
    dismissal branches too, not only the commit branches (marker-palette-session.util.ts, the
    two focusEditor() calls in the dismissal paths). It is a no-op when the selection is intact.
  • Consolidate the repeated restoreSelectionIfLost(editorRef.current, lastFocusOutSelectionRef.current) calls into the existing restoreEditorSelection helper.

3.8 Consumers

  • Main editor web view: all three kinds.
  • FootnoteEditor popover (platform-bible-react): same spine and key table. Pass the new
    option through FootnoteEditorMarkerPalette.show (today show(items, anchor, passive, keyForwarding)). The settle pause and change guard apply to the popover's own editor.
    Verify that the popover stays open when focus leaves the iframe for the renderer palette.
  • Read-only mid-session must dismiss footnote-editor palettes too. The web view's
    footnoteMarkerPalette driver opens palettes for the popover's editor, but #2870's dismissal only
    checks the main editor's paletteSession. So a popover palette stays open when an automatic
    Send/Receive makes the editor read-only. Track the popover's open palettes in the driver and
    dismiss them too.
  • Footnote pane from #2805: its row editors are FootnoteEditors driven by the same
    footnoteMarkerPalette, so they get this behaviour with no extra work.

3.9 Docs, catalog and comments

  • Update .context/standards/Standard-View-Invariants.md (the Enter-split section #2870 edited) to
    the unified model.
  • Update src/shared/data/keyboard-shortcuts.data.ts to the unified key rules.
  • Add an entry to .context/standards/Architecture-Decisions.md at its byte-order slug position.
    Suggested slug and title: adr-marker-palettes-take-real-focus: Standard-view marker palettes hold real keyboard focus in the overlay; the editor stays editable. Context, decision and
    alternatives are in this document (sections 1, 3 and 4).
  • New code comments should describe the rules, not cite the ticket or name who ruled.

4. Alternatives considered and declined

Alternative Why declined
The lock in #2870 (editor contenteditable="false" while any palette is open) It stops composed input, but it blurs the editor anyway, and every consumer inherits the side effects: keys land on the page body, so routing had to be rewritten; the caret must be restored everywhere; the MutationObserver fights Lexical over the attribute; paste/cut/drop need separate blocking; the popover and the pane each need their own lock. The review also found that focus never returns after a \ palette closes, so the next keys are lost (reproduced in Chromium with Lexical 0.43). All future code touching the editor has to remember the lock exists.
Lexical setEditable(false) instead of the DOM attribute Removes the observer and the separate paste blocking, and stops Lexical grabbing focus back, but has the same blur side effects and fires Lexical's editable listeners (toolbar and plugins react as if read-only).
The lock moved into scripture-editors behind one API The best form of the lock, but still leaves focus on the page body rather than on anything meaningful. Declined once focus in the palette proved workable.
A hidden focusable element in the iframe to take focus during the opening window Measured: keys that hit it were dropped rather than forwarded; simulated IME input still went into the editor through the DOM selection; a dead key + e was lost. An invisible focus target is also hard to reason about and easy to get stuck in.
Keyboard switching (switch to a Latin keyboard while the palette is open) Paratext 9 does not do this for the palette. Paratext 10's keyboard-switching feature is unmerged, every switch takes several asynchronous PAPI calls, and it has no way to turn an IME off.
Let composed text land, then remove it The IME candidate text shows in the verse, dead keys flicker, and half-composed text can be saved in the meantime.
Let composed or non-Latin input land and close the palette Contradicts the product ruling (Paratext 9 ignores non-basic-Latin input in the palette).
Keep the Enter palette's "pending split" with Enter-only key rules Paratext 9's net effect is that closing without a paragraph marker leaves the text unchanged, so there is nothing to protect, and two sets of rules for two identical-looking palettes are hard to learn.
Split the paragraph first, then open the palette Unnecessary once the pending split is gone; the temporary newline is a separate work item.
Focus the palette's text input and read keyCode Works for ordinary keys and Windows dead keys, but IME and macOS/Linux dead-key composition go into any focused text box. Kept as the fallback if the list does not keep the IME off.
Take focus back whenever the editor grabs it Focus ping-pong fires focus and blur events (window focus tracking, the #2805 pane focus state, future keyboard switching), and the renderer's window-blur handler dismisses the palette anyway.
Lexical's skip-selection-focus tag during palette sessions Keeps the palette open through an editor change, which risks a commit in the wrong place; the change guard closes it instead.
Make list focus the default for every command palette Other PAPI consumers may not want it; it is opt-in.

5. Evidence

Paratext 9 (the Paratext 9 source repository):

  • ParatextBase/ScriptureEditor/EditHandlers/MarkerDropdownEditHandler.cs:48-84: Enter inserts a
    temporary newline, then opens the palette; :87-139 for \.
  • ParatextBase/ScriptureEditor/MarkerDropdownControl.cs:
    • The filter line is a Label and the focused control a read-only DataGridView, so there is no
      text box.
    • KeyDown :136-192: letters and digits come from e.KeyCode.
    • KeyPress :194-214: Keyman fallback limited to [a-z1-9+]; Space commits the typed text; *
      commits a closer.
    • IsMarkerCharacter :216-219: everything else is ignored.
  • Paratext 9 does not switch keyboards or change IME mode for the palette.

Trial in the running app (origin/main d7a5f33, runtime patches only):

  • Nothing steals focus; the palette never asks for it. Zero focus() calls in the renderer
    after Enter; the palette mounts 70–230 ms after Enter.
  • Focus on mount fixes everything:
    • letters filter the list and the text hash is unchanged;
    • arrows move the highlight while the Lexical selection stays put;
    • focus moves once each way, with no ping-pong;
    • the caret is exact after Escape, an Enter commit and a click commit.
  • A forced Lexical update pulls focus back: updateDOMSelection → rootElement.focus(), then the
    renderer window blurs and the palette is dismissed. Blocking the editor root's focus() kept the
    palette open.
  • Opening window: 73–129 ms from Enter to palette focus; back-to-back q, 1 landed in the
    text on main.
  • Hidden element in the iframe: keys dropped; simulated IME went into the editor; dead key +
    e lost.
  • Composition (Linux, Xvfb, us-intl):
    • in the editor or the palette input: keyCode 229, composition, é inserted;
    • on a non-editable element: key=Dead, then key=e, keyCode 69, no composition.

Lexical 0.43: event handlers are gated on isEditable() (Lexical.dev.js:3026); the DOM
selection update, including focusing the root, runs only when editor._editable (:8581);
skip-selection-focus (:4380, :7896).

6. Verification

Unit tests

  • The key table is table-driven, with the same expectations for backslash and enter except the
    apply target. Cover:
    • chords close;
    • punctuation and non-Latin are ignored;
    • Backspace on an empty filter closes;
    • Space on an empty filter closes;
    • \ on an empty filter is ignored.
  • keyCode mapping: a Cyrillic key gives its Latin letter; Dead then e gives e; the Keyman
    packet-key fallback; AltGr characters.
  • Overlay:
    • an anchored palette focuses after its portal mounts (a regression test for tryFocus);
    • list mode focuses the list, makes the input read-only and forwards every key;
    • default mode is unchanged.
  • Change guard:
    • a content change closes the palette;
    • a caret move closes it;
    • Lexical nulling its selection on blur does not close it.
  • The idle settle does not fire while paused, and re-arms on resume.

End-to-end (Standard view, editable project)

  • Enter then immediately q1: the filter shows q1 and the text is unchanged.
  • Enter + p + Space: a new \p paragraph. Enter + nd*: \nd* inline. Check the saved USFM, not
    the rendered view.
  • Enter + Space, Enter + Escape, Enter + Backspace: the palette closes and the text is unchanged.
  • Punctuation and non-Latin characters are ignored with the palette still open.
  • Escape, an Enter commit and a click commit all return the caret to where it was.
  • \qt-s\, wait more than a second: the reopened palette is still open.
  • The footnote popover stays open while its \ palette has focus.

Manual (Windows and macOS; these cannot be tested on Linux in CI)

  • Japanese IME on, in both palettes: letters filter, no candidate window appears, and nothing reaches
    the text. If composition still happens, use the input fallback from 3.3.
  • Spanish (or US-International) dead key then e: filters e.
  • Russian layout: the Latin letters for the pressed keys.
  • A Keyman keyboard: filtering still works.

7. Follow-ups outside this PR

  • PT-4817 (https://paratextstudio.atlassian.net/browse/PT-4817): an open marker palette defers same-chapter data-provider updates with no time
    limit, and pushes the editor's content back over them. Automatic Send/Receive now dismisses the
    palette first, but other external writes can still be overwritten.
    • Proposed fix: a palette session defers only within EDITOR_OWNERSHIP_WINDOW_MS of the last
      local edit. After that, the update applies and the change guard closes the palette.
    • In this PR: update the known-gap text at resolveEditingSessionActivity and in the PR
      description, and link PT-4817 there.
  • The temporary-newline work item for the Enter palette (Paratext 9 parity) stays separate.

8. Implementation checklist (for comparing later)

  • Input lock and all its call sites removed (web view, FootnoteEditor, experimental.ts
    export, tests).
  • Page-body key-routing rules removed; routing is "editor focused + session" in the web view,
    plus keyForwarding from the palette.
  • One key table for all kinds; the Enter-only branches deleted or generalized as in 3.1;
    ForwardedSessionKind alias removed.
  • Every key rule in the 3.1 table implemented for both palettes; selection-palette refusals
    kept.
  • Letters and digits read from keyCode, with the Keyman fallback; case handling kept.
  • Typed commits use commitTypedMarker / commitTypedCloser for both palettes; highlighted
    items unchanged (applyMarkerMenuSelection for \, splitParagraphWithMarker for Enter);
    retagging untouched.
  • Overlay autofocus fixed for all non-passive palettes (focus when the element attaches).
  • New opt-in overlay option (focusTarget: 'list' or similar): list focused, read-only input,
    host-driven filter, every key forwarded. Only marker palettes use it; the default is
    unchanged.
  • Marker palettes no longer use passive; the selection palette's filter is host-driven.
  • Every key routed to the table between the trigger and the palette getting focus, for all
    kinds.
  • compositionstart in the editor during an open session closes the palette.
  • Pending marker edits settled before a palette opens.
  • Idle settle paused during a session (new EditorRef API in scripture-editors), re-armed on
    resume.
  • Change guard in the shared spine: a content or caret change closes the palette; blur's null
    selection ignored; no dirty-node heuristics.
  • No skip-selection-focus tag and no focus-retake logic.
  • restoreSelectionIfLost before focusEditor in the dismissal branches too; repeated calls
    consolidated.
  • Read-only mid-session dismissal kept, and extended to footnote-editor palettes opened
    through footnoteMarkerPalette.
  • FootnoteEditor passes the new option; the popover stays open while its palette has focus.
  • Standard-View-Invariants.md, keyboard-shortcuts.data.ts and the Architecture-Decisions
    entry updated.
  • No ticket numbers or names in new code comments (a TODO naming open PT-4817 is fine).
  • Known-gap text at resolveEditingSessionActivity and in the PR description updated and linked to PT-4817.
  • lib/platform-bible-react/dist rebuilt, not hand-merged; scripture-editors change merged
    first and the editor dependency moved.
  • Manual IME, dead-key, Cyrillic and Keyman checks on Windows and macOS recorded in the PR.

timothy-mccormack and others added 7 commits October 2, 2026 11:39
Implements the review's redesign of the Standard-view marker palettes.

The palettes used to keep typing out of the scripture text by making the
editor's content element non-editable for the life of a session. That
worked, but it blurred the editor, and every consumer inherited the
fallout: keys landed on the page body so routing had to admit them, the
caret had to be restored everywhere, a MutationObserver fought Lexical
over the attribute, paste/cut/drop needed separate blocking, and the
footnote popover carried a lock of its own. Focus also never came back
after a `\` palette closed.

Instead the palette now takes real keyboard focus, on its LIST rather
than a text box, through a new opt-in overlay option `focusTarget:
'list'`. Focusing something that cannot be edited is what keeps composed
input out: an IME or dead key begins composing in any focused input
whatever a handler cancels, and once a composition is under way per-key
rules no longer apply to it; on a list none starts. The editor stays
fully editable and keeps its caret, so key routing in both consumers is
simply "the editor has focus".

- Delete the input lock, its tests, its export and every call site.
- Add `focusTarget: 'list'`: focused list, read-only search box over the
  host-driven filter, and every key forwarded back to the requester, so
  one table decides what each key means.
- Fix the overlay autofocus, which gave up when its target had not
  mounted yet. That is always true on an anchored palette's first pass,
  because Radix's portal commits nothing then, which is why anchored
  palettes never took focus at all.
- Collapse the per-kind key tables into one, following Paratext 9's
  rules: chords close, Shift+Enter commits, an empty-filter Backspace
  closes, an empty-filter `\` is ignored, and any key that cannot name a
  marker is claimed and ignored rather than reaching the text.
- Resolve filter characters from the typed character first, falling back
  to the physical key only when that character cannot name a marker, so
  Cyrillic and Greek layouts type Latin marker names while AZERTY and
  QWERTZ keep theirs. Reading keyCode first broke `qt-s` on AZERTY and
  meant the same key filtered differently on macOS and Windows.
- Close the palette on `compositionstart`, in both consumers.
- Restore the caret on every dismissal, not only on commits, and for
  every session kind.
- Dismiss footnote-editor palettes when the editor turns read-only, and
  check read-only on the forwarded path as well as the capture path.
- Close a palette that outlives its session rather than swallowing every
  key it is forwarded, Escape included.
- Announce through the live region for passive palettes only; a focused
  list already announces its own `aria-activedescendant`.

Known gaps, written up in Standard-View-Invariants.md: the editor's own
blur and idle settles can still close an open palette, which needs a
pause/resume API in scripture-editors; and paste, cut and drop are no
longer blocked, which belongs with the change guard rather than with
another lock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A palette commit applies AT THE CARET. If the content or the caret moves
while a palette is open — an incoming update for the same chapter, a
drag-and-drop, a context-menu paste — then applying would put the marker
somewhere the user never chose, and the caret restored from the focus-out
capture would address content that no longer exists.

The guard acts twice. The editor's change callback closes the palette at
the moment of the change, because a palette floating over text it no
longer describes is confusing and refusing later just looks like nothing
happening. The shared session spine then refuses the apply as a backstop,
which covers a change arriving between the user's choice and the commit.

The baseline is taken when focus leaves the editor for the palette, the
last moment the caret is readable before Lexical's blur processing nulls
it — both consumers already capture there for the same reason, so the
guard rides along with that listener instead of adding its own.

Three rules, in `marker-palette-change-guard.util.ts`:

- A MISSING caret is not a move. Lexical nulls the editor-state selection
  on blur, which is exactly what happens when the palette takes focus, so
  an absent caret is the normal state while one is open.
- A missing baseline or sample never blocks. A guard that fires when it
  cannot see breaks the ordinary commit path, which is worse than not
  guarding at all.
- Compare actual content and the actual caret, never Lexical's dirty-node
  markers: the root is marked dirty on every commit, so those report a
  change for every palette that applies anything.

This is also what answers `paste`, `cut` and `drop`, which lost their
blocking with the input lock. Re-adding a lock is what this design
rejects; instead the content change they cause closes the palette and
refuses the commit. The drop itself still lands, as the user's own edit —
the one remaining difference from the old lock, recorded in
Standard-View-Invariants.md § 3.3.

Also fixes a comment that still described the Enter palette as passive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by hand on a real keyboard: with a palette open, pressing Option
closed it, and so did Option+e — which is how `é` is typed on macOS. Two
separate faults behind one symptom, neither reachable before this branch
and neither caught by any unit test.

Option alone: the palette now stops forwarded keys from propagating
further in the renderer's document. `PlatformMenubar` binds bare `alt`
with `react-hotkeys-hook` at DOCUMENT level and answers it by dispatching
a SYNTHETIC Escape at a menu trigger; that Escape dismissed the palette.
The binding is old, but it was unreachable while marker palettes were
passive: focus stayed inside the requesting WebView's iframe, and iframe
keydowns never reach the parent document. Focusing the list moved focus
into that document and put every document-level binding back in the path
— `notification-display`'s Alt+T is the same shape. Claiming the keyboard
for the focused palette fixes the class rather than one key, and leaves
the default input-focused palette behaving as before for other consumers.

Option+e: the key table counted any `altKey` as a command chord, and
chords close the palette. That is wrong on macOS, where Option COMPOSES
characters — `Option+e` begins `é`, `Option+n` begins `ñ`, `Option+a`
types `å` outright. It holds the role AltGr holds on Windows and Linux,
which this branch already excluded; macOS simply never got its half. So
a dead key, which arrives with `altKey` set because Option is physically
held, was read as a chord and never reached the rule that ignores keys
which cannot name a marker. Ctrl+Option and Cmd+Option are still chords:
their command modifier is what decides.

Verified in the running app: Option alone leaves the palette open with
its markers intact, and a dead-key-shaped event followed by `e` leaves
the filter reading `e` with the verse untouched — the behaviour the
review asks for. Tests pin both platforms, since the chord rule is now
platform-conditional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	.context/standards/Standard-View-Invariants.md
#	lib/platform-bible-react/dist/experimental.cjs
#	lib/platform-bible-react/dist/experimental.js
#	lib/platform-bible-react/dist/index.cjs
#	lib/platform-bible-react/dist/index.cjs.map
#	lib/platform-bible-react/dist/index.js
#	lib/platform-bible-react/dist/index.js.map
#	lib/platform-bible-react/dist/resizable-BZDCYerr.js
#	lib/platform-bible-react/dist/resizable-C8WaNNlu.cjs
#	lib/platform-bible-react/dist/resizable-S-VR7cMZ.js
#	lib/platform-bible-react/dist/resizable-YXmgsXI7.js
#	lib/platform-bible-react/src/components/advanced/footnote-editor/footnote-editor.component.tsx
#	src/renderer/components/overlays/overlay-command-palette.component.test.tsx
#	src/renderer/components/overlays/overlay-command-palette.component.tsx
main now leaves a key-forwarding palette unfocused, so asserting focus on
one that declares forwarding no longer describes the intended behaviour.
Split it: the default palette's focus and editability are checked without
forwarding, and the forwarding behaviour — undeclared keys not forwarded,
Escape dismissed locally — is checked on its own. List mode keeps its own
tests; it is the one mode that both forwards and takes focus, deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two entries had drifted from the code. The filter entry said letters come
from the physical key; that rule was inverted during review, because
reading keyCode first broke AZERTY and meant the same key filtered
differently on macOS and Windows. The character the layout produced is
what counts whenever it can name a marker, and the physical key is the
fallback only when it cannot.

The close entry recorded only Escape, with no mention that a chord closes
the menu as well — or of the two modifiers that are excluded because they
are being used to type rather than to command: AltGr on Windows and
Linux, and Option on macOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported from a real keyboard: with a palette open, Option+e then e
filtered `ee` where it should filter `e` — the dead key contributing
nothing and the letter after it narrowing the list.

Option is macOS's compose/alternate modifier: Option+e begins `é`,
Option+a types `å`, Option+c types `ç`. None of those can name a marker,
and the plain letter printed on the key is not what the user is typing.
Both resolution paths were turning it into one anyway — the character
path when the browser reports `e`, and the physical-key fallback when it
reports the dead character `´` and the `E` underneath is read instead.
So nothing typed with Option held is filter input.

Scoped to macOS deliberately. AltGr reports `ctrlKey && altKey` on
Windows and Linux and composes REAL marker characters on several European
layouts, so it must keep filtering; a test pins that.

This is the third fault behind one gesture, after the menubar's
document-level `alt` binding firing a synthetic Escape and the key table
counting any `altKey` as a command chord. None of the three was reachable
through synthetic key events, which is why they survived the automated
suites and surfaced only in manual use.

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

This branch has not been deployed

No deployments
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