diff --git a/apps/readest-app/src/__tests__/paragraph-bar.test.tsx b/apps/readest-app/src/__tests__/paragraph-bar.test.tsx
new file mode 100644
index 00000000..0ff2de12
--- /dev/null
+++ b/apps/readest-app/src/__tests__/paragraph-bar.test.tsx
@@ -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(
+ ,
+ );
+
+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');
+ });
+ });
+});
diff --git a/apps/readest-app/src/__tests__/paragraph-mode.test.tsx b/apps/readest-app/src/__tests__/paragraph-mode.test.tsx
index 2f3042c7..e36699e8 100644
--- a/apps/readest-app/src/__tests__/paragraph-mode.test.tsx
+++ b/apps/readest-app/src/__tests__/paragraph-mode.test.tsx
@@ -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('
Hello world
');
+ const paragraph = doc.querySelector('p')!;
+ const range = doc.createRange();
+ range.selectNodeContents(paragraph);
+
+ const { container } = render(
+ ,
+ );
+
+ 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);
+ });
});
diff --git a/apps/readest-app/src/app/reader/components/paragraph/ParagraphBar.tsx b/apps/readest-app/src/app/reader/components/paragraph/ParagraphBar.tsx
index d6c030df..42c6905d 100644
--- a/apps/readest-app/src/app/reader/components/paragraph/ParagraphBar.tsx
+++ b/apps/readest-app/src/app/reader/components/paragraph/ParagraphBar.tsx
@@ -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 = ({
};
}, [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 = ({
`}
= ({
[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
= ({
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],