fix(reader): prevent accidental paragraph-mode exit and center its bar (#4474) (#4539)

Paragraph mode could be exited by accident just by tapping a bit too high
or low on the screen: a tap on the empty area around the centered
paragraph hit the overlay backdrop, which closed the mode. Tapping the
neutral center of the paragraph did nothing, and once the control bar
auto-hid there was no touch gesture to bring it back — so removing the
stray-tap exits alone would have stranded touch users with no way out.

- Backdrop and center-zone taps now dispatch `paragraph-show-controls`
  instead of exiting; the bar re-appears so the explicit exit button
  stays reachable on touch.
- ParagraphBar listens for that event (scoped by bookKey) and re-shows.
- Exit now only happens via the bar's exit button, Escape/Backspace, or a
  deliberate double-tap on the paragraph (kept as a power-user shortcut).
- Center the bar with `fixed` instead of `absolute`: it was centered on
  the gridcell, which a pinned sidebar pushes off-center, while the
  paragraph centers on the viewport via the `fixed inset-0` overlay.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-12 02:30:41 +08:00
committed by GitHub
parent 64350ca632
commit 755bee1ee6
4 changed files with 216 additions and 6 deletions
@@ -0,0 +1,92 @@
import { act, cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ParagraphBar from '@/app/reader/components/paragraph/ParagraphBar';
import { eventDispatcher } from '@/utils/event';
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: { hasSafeAreaInset: false } }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({ hoveredBookKey: '' }),
}));
vi.mock('@/hooks/useResponsiveSize', () => ({
useResponsiveSize: (size: number) => size,
}));
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string) => key,
}));
const renderBar = (bookKey = 'book-1') =>
render(
<ParagraphBar
bookKey={bookKey}
currentIndex={0}
totalParagraphs={10}
onPrev={vi.fn()}
onNext={vi.fn()}
onClose={vi.fn()}
viewSettings={{ writingMode: 'horizontal-tb', vertical: false, rtl: false } as never}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
const getBarRoot = (container: HTMLElement) => container.querySelector('.z-50') as HTMLElement;
describe('ParagraphBar', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('is centered on the viewport (fixed), not the offset gridcell (absolute)', () => {
const { container } = renderBar();
const root = getBarRoot(container);
expect(root.className).toContain('fixed');
expect(root.className).not.toContain('absolute');
expect(root.className).toContain('left-1/2');
expect(root.className).toContain('-translate-x-1/2');
});
describe('show-controls event', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('reappears when paragraph-show-controls fires for its book', async () => {
const { container } = renderBar('book-1');
const root = getBarRoot(container);
expect(root.className).toContain('pointer-events-auto');
act(() => {
vi.advanceTimersByTime(2600);
});
expect(root.className).toContain('pointer-events-none');
await act(async () => {
await eventDispatcher.dispatch('paragraph-show-controls', { bookKey: 'book-1' });
});
expect(root.className).toContain('pointer-events-auto');
});
it('ignores paragraph-show-controls for a different book', async () => {
const { container } = renderBar('book-1');
const root = getBarRoot(container);
act(() => {
vi.advanceTimersByTime(2600);
});
expect(root.className).toContain('pointer-events-none');
await act(async () => {
await eventDispatcher.dispatch('paragraph-show-controls', { bookKey: 'other-book' });
});
expect(root.className).toContain('pointer-events-none');
});
});
});
@@ -280,4 +280,96 @@ describe('paragraph mode', () => {
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-next', { bookKey: overlayBookKey });
});
});
const renderVisibleOverlay = async (onClose: () => void) => {
const overlayBookKey = 'overlay-book';
const doc = createDoc('<p>Hello world</p>');
const paragraph = doc.querySelector('p')!;
const range = doc.createRange();
range.selectNodeContents(paragraph);
const { container } = render(
<ParagraphOverlay
bookKey={overlayBookKey}
dimOpacity={0.3}
viewSettings={{ writingMode: 'horizontal-tb', vertical: false, rtl: false } as never}
onClose={onClose}
/>,
);
await act(async () => {
await eventDispatcher.dispatch('paragraph-focus', {
bookKey: overlayBookKey,
range,
presentation: { dir: 'ltr', writingMode: 'horizontal-tb', vertical: false, rtl: false },
});
});
return { container, overlayBookKey };
};
const mockContentRect = (contentArea: HTMLElement) =>
vi.spyOn(contentArea, 'getBoundingClientRect').mockReturnValue({
width: 300,
height: 300,
top: 0,
left: 0,
right: 300,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);
it('reveals the controls instead of exiting when the backdrop is tapped', async () => {
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
const onClose = vi.fn();
const { container, overlayBookKey } = await renderVisibleOverlay(onClose);
const dialog = await waitFor(() => {
const node = container.querySelector('[role="dialog"]') as HTMLDivElement | null;
expect(node).not.toBeNull();
return node!;
});
dispatchSpy.mockClear();
fireEvent.click(dialog);
expect(onClose).not.toHaveBeenCalled();
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-show-controls', {
bookKey: overlayBookKey,
});
});
it('reveals the controls instead of exiting when the center zone is tapped', async () => {
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
const onClose = vi.fn();
const { container, overlayBookKey } = await renderVisibleOverlay(onClose);
const contentArea = container.querySelector('.relative.flex') as HTMLDivElement;
mockContentRect(contentArea);
dispatchSpy.mockClear();
fireEvent.click(contentArea, { clientX: 150, clientY: 150 });
expect(onClose).not.toHaveBeenCalled();
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-show-controls', {
bookKey: overlayBookKey,
});
expect(dispatchSpy).not.toHaveBeenCalledWith('paragraph-next', { bookKey: overlayBookKey });
expect(dispatchSpy).not.toHaveBeenCalledWith('paragraph-prev', { bookKey: overlayBookKey });
});
it('still exits on a double-tap of the paragraph', async () => {
const onClose = vi.fn();
const { container } = await renderVisibleOverlay(onClose);
const contentArea = container.querySelector('.relative.flex') as HTMLDivElement;
mockContentRect(contentArea);
fireEvent.click(contentArea, { clientX: 150, clientY: 150 });
fireEvent.click(contentArea, { clientX: 150, clientY: 150 });
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -15,6 +15,7 @@ import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { getParagraphButtonDirections } from '@/utils/paragraphPresentation';
const INITIAL_SHOW_DURATION = 2500;
@@ -152,6 +153,18 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
};
}, [checkTriggerZone, showBar]);
useEffect(() => {
// Touch taps in the overlay's neutral zones ask the bar to reappear so the
// exit button stays reachable after it has auto-hidden.
const handleShowControls = (event: CustomEvent) => {
if (event.detail?.bookKey === bookKey) {
showBar();
}
};
eventDispatcher.on('paragraph-show-controls', handleShowControls);
return () => eventDispatcher.off('paragraph-show-controls', handleShowControls);
}, [bookKey, showBar]);
const isHiddenByHover = hoveredBookKey === bookKey;
const isVisible = isBarVisible && !isHiddenByHover;
const progress =
@@ -185,7 +198,10 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
`}</style>
<div
className={clsx(
'absolute bottom-6 left-1/2 z-50 -translate-x-1/2',
// `fixed` (not `absolute`) so the bar centers on the viewport like the
// overlay paragraph; `absolute` centered it on the gridcell, which is
// pushed off-center when the sidebar is pinned (#4474).
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2',
'transition-[opacity,filter,transform] duration-200 ease-out',
isVisible
? 'pointer-events-auto translate-y-0 scale-100 opacity-100 blur-0'
@@ -379,11 +379,17 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
[activePresentation, bookKey, layoutContext.vertical, viewSettings],
);
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === containerRef.current) {
onCloseRef.current?.();
}
}, []);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
// 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).
if (e.target === containerRef.current) {
eventDispatcher.dispatch('paragraph-show-controls', { bookKey });
}
},
[bookKey],
);
const lastTapTimeRef = useRef(0);
const handleContentClick = useCallback(
@@ -423,6 +429,10 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
eventDispatcher.dispatch('paragraph-prev', { bookKey });
} else if (action === 'next') {
eventDispatcher.dispatch('paragraph-next', { bookKey });
} else {
// A tap in the neutral center zone reveals the controls so the exit
// button stays reachable on touch after the bar has auto-hidden.
eventDispatcher.dispatch('paragraph-show-controls', { bookKey });
}
},
[activePresentation, bookKey, layoutContext.vertical, viewSettings],