fix(reader): make Shift+P toggle, exit, and resume paragraph mode reliably (#4717) (#4725)

Three paragraph-mode problems, all fixed:

- Shift+P inside paragraph mode flashed and re-entered (and pressing it
  repeatedly did nothing). A single keypress toggled twice: eventDispatcher
  .dispatch() iterated the live listener Set while awaiting each listener, and
  the exit's awaited dispatch('paragraph-mode-disabled') let React re-run
  useParagraphMode's subscription effect mid-loop, adding a handler the same
  dispatch then called. Snapshot the listeners before iterating (dispatchSync
  already did), so a listener added during a dispatch can't fire for the
  current event.

- Shift+P / Escape only worked when focus sat on the overlay. Handle the
  overlay's keys the way a dialog/alert does: focus the dialog element on open
  and handle Escape / the toggle shortcut / paragraph navigation in its own
  onKeyDown (stopping propagation so the global handler can't double-fire),
  instead of a global window listener. Suppress the focus ring on the
  programmatically-focused, non-tab-stop container.

- Resume jumped to the chapter start, and repeated enter/exit walked further
  back. Two causes: (a) entering/exiting scrolled the underlying view to the
  focused paragraph's start, which rewinds a page when that paragraph began on
  the previous page — don't scroll on resume/exit (the paragraph is already on
  screen); navigation still scrolls. (b) resume preferred the rAF-debounced
  store progress and a stored last-paragraph CFI that can come out malformed
  and resolve to an empty range, shadowing the correct candidate and sending
  findByRange to the first block. Resume from the view's live, foliate-
  generated lastLocation CFI first (set synchronously on every relocate,
  resolved against the current document so it survives iframe recreation).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-22 14:22:00 +08:00
committed by GitHub
parent f4bb111267
commit 942095bcd6
6 changed files with 252 additions and 79 deletions
@@ -331,12 +331,21 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
};
}, [bookKey, addParagraph]);
// Focus the dialog when it opens (the dialog/alert pattern) so it receives
// keydowns directly via its own onKeyDown handler, regardless of where focus
// sat before — fixes Shift+P/Escape not toggling when focus was still inside
// the book iframe (#4717).
useEffect(() => {
if (!isVisible) return;
containerRef.current?.focus({ preventScroll: true });
}, [isVisible]);
const handleKeyDown = (e: KeyboardEvent) => {
// Keydown handler bound to the dialog element. Keeps every key inside the
// overlay (stopPropagation) so the global shortcut handler never sees it — the
// toggle therefore fires exactly once.
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation();
e.stopImmediatePropagation();
if (e.key === 'Escape' || e.key === 'Backspace') {
e.preventDefault();
@@ -344,10 +353,6 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
return;
}
// The overlay swallows every keydown in the capture phase, so the global
// toggle shortcut (Shift+P by default) never reaches useShortcuts. Honor
// it here so the same shortcut that enters paragraph mode also exits it
// (#4717).
if (matchesShortcut(e, loadShortcuts().onToggleParagraphMode.keys)) {
e.preventDefault();
onCloseRef.current?.();
@@ -358,18 +363,13 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
if (action === 'next') {
e.preventDefault();
eventDispatcher.dispatch('paragraph-next', { bookKey });
return;
}
if (action === 'prev') {
} else if (action === 'prev') {
e.preventDefault();
eventDispatcher.dispatch('paragraph-prev', { bookKey });
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [activePresentation, bookKey, isVisible, viewSettings]);
},
[activePresentation, bookKey, viewSettings],
);
useEffect(() => {
if (!isVisible) return;
@@ -473,6 +473,9 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
// Keep keyboard focus on the dialog so Escape/Shift+P keep working after a
// tap moved focus elsewhere (e.g. into the book iframe).
containerRef.current?.focus({ preventScroll: true });
// Tapping the empty area around the paragraph used to exit, which made it
// easy to leave paragraph mode by accident. Reveal the controls instead so
// exiting stays an explicit action (the bar's exit button or Escape).
@@ -487,6 +490,8 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
const handleContentClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// Keep keyboard focus on the dialog so it keeps receiving keys after a tap.
containerRef.current?.focus({ preventScroll: true });
const now = Date.now();
if (now - lastTapTimeRef.current < 300) {
@@ -543,6 +548,10 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
className={clsx(
'fixed inset-0 z-40',
'flex flex-col items-center justify-center',
// The dialog is focused programmatically (so it receives keys); it is not
// a tab stop, so suppress the focus ring that would otherwise outline the
// whole viewport.
'outline-none',
'transition-opacity duration-300 ease-out',
isOverlayMounted ? 'opacity-100' : 'opacity-0',
)}
@@ -555,7 +564,7 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
}}
onClick={handleBackdropClick}
onTouchStart={handleTouchStart}
onKeyDown={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
{/* TTS "following audio" indicator, pinned top-center. Anchored below the
top safe-area inset the overlay already accounts for; idle/unsupported