diff --git a/apps/readest-app/src/__tests__/components/useBookShortcuts.test.tsx b/apps/readest-app/src/__tests__/components/useBookShortcuts.test.tsx index d8ba59e3..44d924a3 100644 --- a/apps/readest-app/src/__tests__/components/useBookShortcuts.test.tsx +++ b/apps/readest-app/src/__tests__/components/useBookShortcuts.test.tsx @@ -29,6 +29,9 @@ const currentViewSettings = { defaultFontSize: 16, lineHeight: 1.5, readingRulerEnabled: true, + writingMode: 'horizontal-tb', + vertical: false, + rtl: false, paragraphMode: { enabled: false }, }; @@ -120,6 +123,10 @@ describe('useBookShortcuts', () => { vi.clearAllMocks(); shortcutState.actions = null; currentViewSettings.readingRulerEnabled = true; + currentViewSettings.writingMode = 'horizontal-tb'; + currentViewSettings.vertical = false; + currentViewSettings.rtl = false; + currentViewSettings.paragraphMode.enabled = false; mockView.book.dir = 'ltr'; }); diff --git a/apps/readest-app/src/__tests__/paragraph-mode.test.tsx b/apps/readest-app/src/__tests__/paragraph-mode.test.tsx new file mode 100644 index 00000000..2f3042c7 --- /dev/null +++ b/apps/readest-app/src/__tests__/paragraph-mode.test.tsx @@ -0,0 +1,283 @@ +import React from 'react'; +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import ParagraphOverlay from '@/app/reader/components/paragraph/ParagraphOverlay'; +import { useParagraphMode } from '@/app/reader/hooks/useParagraphMode'; +import type { FoliateView } from '@/types/view'; +import { eventDispatcher } from '@/utils/event'; +import { + getParagraphActionForKey, + getParagraphActionForZone, + getParagraphPresentation, +} from '@/utils/paragraphPresentation'; + +const currentViewSettings = { + paragraphMode: { enabled: true }, + writingMode: 'horizontal-tb', + vertical: false, + rtl: false, +}; + +const mockGetViewSettings = vi.fn(() => currentViewSettings); +const mockSetViewSettings = vi.fn(); +const mockGetProgress = vi.fn(() => null); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ envConfig: {}, appService: { hasSafeAreaInset: false } }), +})); + +vi.mock('@/helpers/settings', () => ({ + saveViewSettings: vi.fn(), +})); + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ + getViewSettings: mockGetViewSettings, + setViewSettings: mockSetViewSettings, + getProgress: mockGetProgress, + }), +})); + +global.ResizeObserver = class ResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + + observe(target: Element) { + this.callback([{ target } as ResizeObserverEntry], this); + } + + disconnect() {} + + unobserve() {} +} as typeof ResizeObserver; + +const createDoc = (body: string): Document => + new DOMParser().parseFromString(`${body}`, 'text/html'); + +const attachDefaultView = ( + doc: Document, + getComputedStyle: (element: Element) => CSSStyleDeclaration, +) => { + Object.defineProperty(doc, 'defaultView', { + value: { getComputedStyle }, + configurable: true, + }); +}; + +function createMockView(docs: Document[], initialPrimaryIndex: number) { + const contents = docs.map((doc, index) => ({ doc, index })); + + const renderer = { + primaryIndex: initialPrimaryIndex, + getContents: vi.fn(() => contents), + nextSection: vi.fn(async () => { + renderer.primaryIndex = Math.min(renderer.primaryIndex + 1, contents.length - 1); + }), + prevSection: vi.fn(async () => { + renderer.primaryIndex = Math.max(renderer.primaryIndex - 1, 0); + }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + goTo: vi.fn(), + scrollToAnchor: vi.fn(), + }; + + const view = { + renderer, + resolveCFI: vi.fn(), + getCFI: vi.fn(() => 'epubcfi(/6/4!/4/2/1:0)'), + } as unknown as FoliateView; + + return { view, renderer }; +} + +let hookApi: ReturnType | null = null; + +const HookHarness = ({ view }: { view: React.RefObject }) => { + hookApi = useParagraphMode({ bookKey: 'book-1', viewRef: view }); + return null; +}; + +describe('paragraph mode', () => { + beforeEach(() => { + vi.clearAllMocks(); + hookApi = null; + currentViewSettings.writingMode = 'horizontal-tb'; + currentViewSettings.vertical = false; + currentViewSettings.rtl = false; + }); + + afterEach(() => { + cleanup(); + }); + + it('preserves source presentation and navigation rules', () => { + const verticalDoc = createDoc('

縦書きの段落です。

'); + const verticalParagraph = verticalDoc.querySelector('p')!; + const verticalRange = verticalDoc.createRange(); + verticalRange.selectNodeContents(verticalParagraph); + + attachDefaultView(verticalDoc, (element: Element) => { + if (element === verticalParagraph || element === verticalDoc.body) { + return { + writingMode: 'vertical-rl', + direction: 'ltr', + textOrientation: 'upright', + unicodeBidi: 'plaintext', + textAlign: 'start', + } as CSSStyleDeclaration; + } + + return { + writingMode: 'horizontal-tb', + direction: 'ltr', + } as CSSStyleDeclaration; + }); + + const arabicDoc = createDoc('

هذا نص عربي

'); + const arabicParagraph = arabicDoc.querySelector('p')!; + const arabicRange = arabicDoc.createRange(); + arabicRange.selectNodeContents(arabicParagraph); + attachDefaultView( + arabicDoc, + () => + ({ + writingMode: 'horizontal-tb', + direction: 'rtl', + textAlign: 'start', + }) as CSSStyleDeclaration, + ); + + expect(getParagraphPresentation(verticalDoc, verticalRange)).toEqual( + expect.objectContaining({ + lang: 'ja', + dir: 'ltr', + writingMode: 'vertical-rl', + vertical: true, + }), + ); + expect(getParagraphPresentation(arabicDoc, arabicRange)).toEqual( + expect.objectContaining({ + dir: 'rtl', + rtl: true, + }), + ); + + expect(getParagraphActionForZone('left', { rtl: true, vertical: false })).toBe('next'); + expect(getParagraphActionForZone('top', { vertical: true, writingMode: 'vertical-rl' })).toBe( + 'prev', + ); + expect(getParagraphActionForKey('ArrowLeft', { rtl: true, vertical: false })).toBe('next'); + expect( + getParagraphActionForKey('ArrowLeft', { vertical: true, writingMode: 'vertical-rl' }), + ).toBe('next'); + }); + + it('uses the active primary section when moving across chapter boundaries', async () => { + const previousChapterDoc = createDoc('

Old chapter ending

'); + const nextChapterDoc = createDoc('

Chapter 2

First paragraph

'); + const { view, renderer } = createMockView([previousChapterDoc, nextChapterDoc], 0); + const viewRef = { current: view } as React.RefObject; + + render(); + + await waitFor(() => { + expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Old chapter ending'); + }); + + await act(async () => { + await hookApi?.goToNextParagraph(); + }); + + await waitFor(() => { + expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Chapter 2'); + }); + + expect(renderer.nextSection).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(renderer.goTo).toHaveBeenLastCalledWith(expect.objectContaining({ index: 1 })); + }); + }); + + it('renders preserved presentation and layout-aware click zones in the overlay', async () => { + const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch'); + const overlayBookKey = 'overlay-book'; + const doc = createDoc('

مرحبا بالعالم

'); + 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: { + lang: 'ja', + dir: 'ltr', + writingMode: 'vertical-rl', + textOrientation: 'upright', + vertical: true, + rtl: true, + }, + }); + }); + + const paragraphContent = await waitFor(() => { + const node = container.querySelector('.paragraph-content') as HTMLDivElement | null; + expect(node).not.toBeNull(); + return node!; + }); + expect(paragraphContent.getAttribute('lang')).toBe('ja'); + expect(paragraphContent.style.writingMode).toBe('vertical-rl'); + dispatchSpy.mockClear(); + + const contentArea = container.querySelector('.relative.flex') as HTMLDivElement; + vi.spyOn(contentArea, 'getBoundingClientRect').mockReturnValue({ + width: 300, + height: 300, + top: 0, + left: 0, + right: 300, + bottom: 300, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + fireEvent.click(contentArea, { clientX: 150, clientY: 20 }); + await waitFor(() => { + expect(dispatchSpy).toHaveBeenCalledWith('paragraph-prev', { bookKey: overlayBookKey }); + }); + + await act(async () => { + await eventDispatcher.dispatch('paragraph-focus', { + bookKey: overlayBookKey, + range, + presentation: { + dir: 'rtl', + writingMode: 'horizontal-tb', + vertical: false, + rtl: true, + }, + }); + }); + dispatchSpy.mockClear(); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 320)); + }); + + fireEvent.click(contentArea, { clientX: 40, clientY: 150 }); + await waitFor(() => { + expect(dispatchSpy).toHaveBeenCalledWith('paragraph-next', { bookKey: overlayBookKey }); + }); + }); +}); 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 858c24b7..d6c030df 100644 --- a/apps/readest-app/src/app/reader/components/paragraph/ParagraphBar.tsx +++ b/apps/readest-app/src/app/reader/components/paragraph/ParagraphBar.tsx @@ -2,12 +2,20 @@ import clsx from 'clsx'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { MdChevronLeft, MdChevronRight, MdClose } from 'react-icons/md'; +import { + MdChevronLeft, + MdChevronRight, + MdClose, + MdKeyboardArrowDown, + MdKeyboardArrowUp, +} from 'react-icons/md'; +import { ViewSettings } from '@/types/book'; import { Insets } from '@/types/misc'; import { useEnv } from '@/context/EnvContext'; import { useReaderStore } from '@/store/readerStore'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { useTranslation } from '@/hooks/useTranslation'; +import { getParagraphButtonDirections } from '@/utils/paragraphPresentation'; const INITIAL_SHOW_DURATION = 2500; const HIDE_DELAY = 2000; @@ -21,6 +29,7 @@ interface ParagraphBarProps { onPrev: () => void; onNext: () => void; onClose: () => void; + viewSettings?: ViewSettings; gridInsets: Insets; } @@ -41,12 +50,14 @@ const ParagraphBar: React.FC = ({ onPrev, onNext, onClose, + viewSettings, gridInsets, }) => { const _ = useTranslation(); const { appService } = useEnv(); const { hoveredBookKey } = useReaderStore(); const iconSize = useResponsiveSize(18); + const buttonDirections = getParagraphButtonDirections(viewSettings); const [isBarVisible, setIsBarVisible] = useState(true); const hideTimerRef = useRef | null>(null); @@ -145,6 +156,18 @@ const ParagraphBar: React.FC = ({ const isVisible = isBarVisible && !isHiddenByHover; const progress = totalParagraphs > 0 ? Math.round(((currentIndex + 1) / totalParagraphs) * 100) : 0; + const PrevIcon = + buttonDirections.prev === 'up' + ? MdKeyboardArrowUp + : buttonDirections.prev === 'right' + ? MdChevronRight + : MdChevronLeft; + const NextIcon = + buttonDirections.next === 'down' + ? MdKeyboardArrowDown + : buttonDirections.next === 'left' + ? MdChevronLeft + : MdChevronRight; return ( <> @@ -201,7 +224,7 @@ const ParagraphBar: React.FC = ({ title={_('Previous Paragraph')} aria-label={_('Previous Paragraph')} > - +
@@ -244,7 +267,7 @@ const ParagraphBar: React.FC = ({ title={_('Next Paragraph')} aria-label={_('Next Paragraph')} > - +