From 888f4afde9b4a27f677c704faaddd514f73df88e Mon Sep 17 00:00:00 2001 From: Mohammed Efaz <44260523+WhiteHades@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:56:38 +0600 Subject: [PATCH] fix: preserve paragraph mode reading layouts and other UI/UX fixes (#3730) * fix: paragraph mode * test: paragraph mode * test: remove paragraph mode * fix: paragraph utils * fix: paragraph hook * fix: paragraph overlay * fix: paragraph bar * fix: paragraph control * fix: paragraph shortcuts * test: paragraph utils * test: paragraph hook * test: paragraph overlay * test: paragraph shortcuts * fix: paragraph overlay * test: paragraph mode * test: shortcuts * test: remove overlay * test: remove hook * test: remove utils * fix: paragraph overlay * fix: paragraph overlay * feat: paragraph overlay * fix: vertical container sizing * test: paragraph container * fix: paragraph animation * fix: paragraph text animation * fix: remove container morph --- .../components/useBookShortcuts.test.tsx | 7 + .../src/__tests__/paragraph-mode.test.tsx | 283 +++++++++++++++++ .../components/paragraph/ParagraphBar.tsx | 29 +- .../components/paragraph/ParagraphControl.tsx | 2 + .../components/paragraph/ParagraphOverlay.tsx | 292 ++++++++++++------ .../src/app/reader/hooks/useBookShortcuts.ts | 21 +- .../src/app/reader/hooks/useParagraphMode.ts | 41 ++- .../src/utils/paragraphPresentation.ts | 170 ++++++++++ 8 files changed, 730 insertions(+), 115 deletions(-) create mode 100644 apps/readest-app/src/__tests__/paragraph-mode.test.tsx create mode 100644 apps/readest-app/src/utils/paragraphPresentation.ts 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縦書きの段落です。
'); + 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('First paragraph
'); + const { view, renderer } = createMockView([previousChapterDoc, nextChapterDoc], 0); + const viewRef = { current: view } as React.RefObjectمرحبا بالعالم
'); + const paragraph = doc.querySelector('p')!; + const range = doc.createRange(); + range.selectNodeContents(paragraph); + + const { container } = render( +