PT-4611: Standard view marker palette — stop typing reaching scripture text - #2870
timothy-mccormack wants to merge 10 commits into
Conversation
…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
left a comment
There was a problem hiding this comment.
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:
- 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. - 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). - Letters and digits are read from
keyCode, like Paratext 9's virtual-key handling, so a dead
key followed byefilterse, and a Cyrillic layout types the Latin letter for that key. - 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. - 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
runMarkerPaletteSessionwith an explicitkind. - Dismissing an open palette session when
isReadOnlyEffectiveturns 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 itsexperimental.tsexport; every lock call
in the web view (paletteInputLock,setPaletteInputLock, the lock half ofsetPaletteSession,
isReadOnlyEffectiveRefif nothing else uses it) and inFootnoteEditor.- 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 inFootnoteEditor'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
ForwardedSessionKindalias.
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 samecommitTypedMarker/
commitTypedClosercalls at the caret. The marker engine decides whether the marker starts a new
line (for example\pmid-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 usesapplyMarkerMenuSelection(item, { trigger: 'backslash', … })(retags a paragraph at its content start, splits mid-text); the Enter palette
usessplitParagraphWithMarkerand 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 throughupdateCommandPalette, 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.keysare 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
keyCode65–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 (
keyCode231 / packet keys): ifkeyCodeis not a letter
or digit butevent.keyis in the filter set, acceptevent.key— Paratext 9's KeyPress fallback
(MarkerDropdownControl.cs:194-214). - In Chromium on Windows
keyCodeis 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, includingenter(onmainthe Enter kind is not forwarded). Measured:
Enter followed immediately byq1putq1into the verse onmain, 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
compositionstartfires 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:
- Settle pending marker edits before opening a palette, so the editor's own settle-on-blur
(MarkerEditPluginBLUR_COMMAND, scripture-editors ~:1160) has nothing to do when focus
moves to the palette. Keep that handler's caret-node exception. - 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
EditorRefin scripture-editors. SettingmarkerSettleDelayMsto-1is not enough: it does
not cancel a timer already armed. On resume, re-arm the timer. A likely trigger without this:
\qt-sthen\commits\qt-sand reopens a palette, and one second later the settle closes
it. #2805 already guardscommitPendingMarkerEditswhile a palette is open for the same reason. - 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.
- Why close rather than keep the palette open: a commit applies at the caret. If something
- 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'sfocusEditor. 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
twofocusEditor()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 existingrestoreEditorSelectionhelper.
3.8 Consumers
- Main editor web view: all three kinds.
FootnoteEditorpopover (platform-bible-react): same spine and key table. Pass the new
option throughFootnoteEditorMarkerPalette.show(todayshow(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
footnoteMarkerPalettedriver opens palettes for the popover's editor, but #2870's dismissal only
checks the main editor'spaletteSession. 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.tsto the unified key rules. - Add an entry to
.context/standards/Architecture-Decisions.mdat 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-139for\.ParatextBase/ScriptureEditor/MarkerDropdownControl.cs:- The filter line is a
Labeland the focused control a read-onlyDataGridView, so there is no
text box. - KeyDown
:136-192: letters and digits come frome.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.
- The filter line is a
- 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'sfocus()kept the
palette open. - Opening window: 73–129 ms from Enter to palette focus; back-to-back
q,1landed in the
text onmain. - Hidden element in the iframe: keys dropped; simulated IME went into the editor; dead key +
elost. - Composition (Linux, Xvfb, us-intl):
- in the editor or the palette input:
keyCode229, composition,éinserted; - on a non-editable element:
key=Dead, thenkey=e,keyCode69, no composition.
- in the editor or the palette input:
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
backslashandenterexcept 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.
keyCodemapping: a Cyrillic key gives its Latin letter;Deadthenegivese; 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.
- an anchored palette focuses after its portal mounts (a regression test for
- 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 showsq1and the text is unchanged. - Enter +
p+ Space: a new\pparagraph. 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: filterse. - 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_MSof 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
resolveEditingSessionActivityand in the PR
description, and link PT-4817 there.
- Proposed fix: a palette session defers only within
- 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,
pluskeyForwardingfrom the palette. - One key table for all kinds; the Enter-only branches deleted or generalized as in 3.1;
ForwardedSessionKindalias 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/commitTypedCloserfor both palettes; highlighted
items unchanged (applyMarkerMenuSelectionfor\,splitParagraphWithMarkerfor 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. -
compositionstartin the editor during an open session closes the palette. - Pending marker edits settled before a palette opens.
- Idle settle paused during a session (new
EditorRefAPI 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-focustag and no focus-retake logic. -
restoreSelectionIfLostbeforefocusEditorin the dismissal branches too; repeated calls
consolidated. - Read-only mid-session dismissal kept, and extended to footnote-editor palettes opened
throughfootnoteMarkerPalette. -
FootnoteEditorpasses the new option; the popover stays open while its palette has focus. -
Standard-View-Invariants.md,keyboard-shortcuts.data.tsand 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
resolveEditingSessionActivityand in the PR description updated and linked to PT-4817. -
lib/platform-bible-react/distrebuilt, 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.
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>
Fixes PT-4611. Draft: see What still needs doing below.
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:+,-*\Dead, navigation keysLetters 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. ReadingkeyCodefirst 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 (turningqt-sintoqt6).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
marker-palette-input-lock.util.ts), its tests, its export, and every call site in the web view andFootnoteEditor.ForwardedSessionKindalias.Behaviour changes worth a reviewer's attention
\on an empty filter is ignored (previously landed in the text and dismissed).Option+ebeginsé. Ctrl+Option and Cmd+Option are still chords.document-level bindings (PlatformMenubar's barealt,notification-display's Alt+T) cannot reach past it.Testing
Unit: the key table is table-driven across all three kinds;
keyCoderesolution 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.
Option+ethenee; palette stays open; verse untouched ✅qand1keysq1, notй1; verse untouched ✅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:
Optionalone closed the palette —PlatformMenubar'sdocument-levelaltbinding fires a synthetic Escape. Old code, newly reachable once focus moved out of the WebView iframe.Option+eclosed it — the key table counted anyaltKeyas a command chord, which is wrong on macOS.Option+ethen filteredeeinstead ofe— the dead key was ingested as a letter by both the character path and the physical-key fallback.Still to do:
keyCodeis layout-assigned there rather than mapped through a US table, which is precisely the difference that made the firstkeyCodeimplementation wrong.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
EditorRefinscripture-editors, which has to merge first. Without it,\qt-sthen\reopens a palette that closes itself about a second later. Settling before opening does not substitute:commitPendingMarkerEditsskips the node under a live caret, which is exactly where the pending edit is.Known gaps, documented in
Standard-View-Invariants.mdpaste,cutanddropare 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.distconflict: reset to the target branch's copy and rebuild — never hand-merge the bundles.git add -A lib/platform-bible-react/dist:git add -uleaves the new content-hashed chunks untracked, publishing a bundle that imports files not in the repo.mainhas been merged in as of the latest commit, which also picked up thepackage-lock.jsonupdate forscripture-editors' newunicode-segmenterdependency — without it CI fails atnpm installbefore building anything.🤖 Generated with Claude Code
This change is