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:
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user