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],