diff --git a/apps/readest-app/src/__tests__/document/paginator-vertical-rl.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-vertical-rl.browser.test.ts new file mode 100644 index 00000000..2f09b085 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/paginator-vertical-rl.browser.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc } from '@/libs/document'; +import type { Renderer } from '@/types/view'; + +// Regression tests for readest#624: books with writing-mode: vertical-rl +// (Japanese/Chinese vertical text) must turn pages horizontally like printed +// vertical books. CSS fragmentation stacks vertical-rl pages top-to-bottom, so +// the scroll axis stays vertical internally, but the page-turn INPUTS are +// horizontal (swipe right = next for vertical-rl) and turns swap instantly +// instead of sliding along the vertical scroll axis. + +const VERTICAL_EPUB_URL = new URL('../fixtures/data/sample-vertical-rl.epub', import.meta.url).href; +const HORIZONTAL_EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href; + +// The paginator's swipe entry point isn't part of the app-facing Renderer type. +type SwipePaginator = Renderer & { + snap: (vx: number, vy: number, dx: number, dy: number, dt: number) => void; +}; + +let verticalBook: BookDoc; +let horizontalBook: BookDoc; +let getDirection: (doc: Document) => { vertical: boolean; rtl: boolean }; + +const loadEPUB = async (url: string, name: string) => { + const resp = await fetch(url); + const buffer = await resp.arrayBuffer(); + const file = new File([buffer], name, { type: 'application/epub+zip' }); + const loader = new DocumentLoader(file); + const { book } = await loader.open(); + return book; +}; + +const waitForStabilized = (el: HTMLElement, timeout = 10000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout); + el.addEventListener( + 'stabilized', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Poll until the paginator lands on the expected page or timeout. */ +const waitForPage = async (el: Renderer, page: number, timeout = 3000) => { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (el.page === page) return; + await wait(50); + } +}; + +describe('Vertical-rl pagination (browser)', () => { + let paginator: SwipePaginator; + + beforeAll(async () => { + verticalBook = await loadEPUB(VERTICAL_EPUB_URL, 'sample-vertical-rl.epub'); + horizontalBook = await loadEPUB(HORIZONTAL_EPUB_URL, 'sample-alice.epub'); + ({ getDirection } = (await import('foliate-js/paginator.js')) as unknown as { + getDirection: (doc: Document) => { vertical: boolean; rtl: boolean }; + }); + }, 30000); + + const createPaginator = () => { + const el = document.createElement('foliate-paginator') as SwipePaginator; + Object.assign(el.style, { + width: '800px', + height: '600px', + position: 'absolute', + left: '0', + top: '0', + }); + document.body.appendChild(el); + return el; + }; + + const setup = async (book: BookDoc, index = 0) => { + paginator = createPaginator(); + paginator.open(book); + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index }); + await stabilized; + }; + + afterEach(async () => { + if (paginator) { + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + try { + paginator.destroy(); + } catch { + /* iframe body may already be torn down */ + } + paginator.remove(); + } + }); + + it('marks vertical-rl documents as rtl in getDirection', async () => { + await setup(verticalBook); + const contents = paginator.getContents(); + const doc = contents[0]!.doc; + expect(getDirection(doc)).toEqual({ vertical: true, rtl: true }); + }); + + const makeTouch = (x: number, y: number) => + new Touch({ identifier: 1, target: paginator, screenX: x, screenY: y, clientX: x, clientY: y }); + + const fireTouch = (type: string, x: number, y: number) => + paginator.dispatchEvent( + new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type === 'touchend' ? [] : [makeTouch(x, y)], + changedTouches: [makeTouch(x, y)], + }), + ); + + const viewTransform = () => { + const container = paginator.shadowRoot!.getElementById('container')!; + const child = container.children[0] as HTMLElement | undefined; + const transform = child && getComputedStyle(child).transform; + return transform && transform !== 'none' ? new DOMMatrix(transform) : null; + }; + + it('tracks the finger horizontally during a drag', async () => { + await setup(verticalBook); + paginator.setAttribute('animated', ''); + const page = paginator.page; + + let x = 700; + fireTouch('touchstart', x, 400); + for (let i = 0; i < 5; i++) { + x += 30; + fireTouch('touchmove', x, 400); + await wait(16); + } + // Mid-drag the page follows the finger to the right, horizontally only. + const midDrag = viewTransform(); + expect(midDrag).not.toBeNull(); + expect(midDrag!.m41).toBeGreaterThan(0); + expect(midDrag!.m42).toBe(0); + + fireTouch('touchend', x, 400); + await waitForPage(paginator, page + 1); + expect(paginator.page).toBe(page + 1); + }); + + it('reverts an aborted drag without turning the page', async () => { + await setup(verticalBook); + paginator.setAttribute('animated', ''); + const page = paginator.page; + + let x = 700; + fireTouch('touchstart', x, 400); + for (let i = 0; i < 5; i++) { + x += 30; + fireTouch('touchmove', x, 400); + await wait(16); + } + expect(viewTransform()?.m41 ?? 0).toBeGreaterThan(0); + // Finger mostly returns, rests, then lifts: the drag is below the commit + // threshold and there is no flick, so the page must not turn. + for (let i = 0; i < 4; i++) { + x -= 30; + fireTouch('touchmove', x, 400); + await wait(16); + } + await wait(150); + fireTouch('touchend', x, 400); + await wait(400); + expect(paginator.page).toBe(page); + expect(viewTransform()?.m41 ?? 0).toBe(0); + }); + + /** + * Sample the mid-flight view transforms of a page turn, tagging each sample + * with the scroll offset it was taken at. The two-phase slide jumps the + * scroll at its midpoint, so samples at the starting offset belong to the + * exit phase and samples at any other offset to the enter phase. + */ + const sampleTurn = async (startPos: number, timeout = 700) => { + const container = paginator.shadowRoot!.getElementById('container')!; + const exit: DOMMatrix[] = []; + const enter: DOMMatrix[] = []; + const t0 = performance.now(); + while (performance.now() - t0 < timeout) { + const child = container.children[0] as HTMLElement | undefined; + const transform = child && getComputedStyle(child).transform; + if (transform && transform !== 'none') { + const m = new DOMMatrix(transform); + if (m.m41 !== 0 || m.m42 !== 0) { + (paginator.containerPosition === startPos ? exit : enter).push(m); + } + } + await new Promise((r) => requestAnimationFrame(r)); + } + return { exit, enter }; + }; + + it('slides pages horizontally (not along the scroll axis) when animated', async () => { + await setup(verticalBook); + paginator.setAttribute('animated', ''); + const size = paginator.size; + const before = paginator.containerPosition; + + const turn = paginator.next(); + const forward = await sampleTurn(before); + // Forward in vertical-rl reads right-to-left: the outgoing page exits to + // the right and the incoming page enters from the left, horizontal only. + const forwardSamples = [...forward.exit, ...forward.enter]; + expect(forwardSamples.length).toBeGreaterThan(0); + expect(forwardSamples.every((m) => m.m42 === 0)).toBe(true); + expect(forward.exit.every((m) => m.m41 > 0)).toBe(true); + expect(forward.enter.every((m) => m.m41 < 0)).toBe(true); + await turn; + expect(paginator.containerPosition).toBe(before + size); + + const back = paginator.prev(); + const backward = await sampleTurn(before + size); + const backwardSamples = [...backward.exit, ...backward.enter]; + expect(backwardSamples.length).toBeGreaterThan(0); + expect(backwardSamples.every((m) => m.m42 === 0)).toBe(true); + expect(backward.exit.every((m) => m.m41 < 0)).toBe(true); + expect(backward.enter.every((m) => m.m41 > 0)).toBe(true); + await back; + expect(paginator.containerPosition).toBe(before); + }); + + it('swaps pages instantly when animation is disabled', async () => { + await setup(verticalBook); + const size = paginator.size; + const before = paginator.containerPosition; + await paginator.next(); + expect(paginator.containerPosition).toBe(before + size); + }); + + it('turns to the next page on a rightward swipe (vertical-rl reads right-to-left)', async () => { + await setup(verticalBook); + const page = paginator.page; + // Finger moves right: vx/dx negative in the paginator's swipe deltas. + paginator.snap(-1.2, 0, -150, 0, 120); + await waitForPage(paginator, page + 1); + expect(paginator.page).toBe(page + 1); + }); + + it('turns back on a leftward swipe in vertical-rl', async () => { + await setup(verticalBook); + const page = paginator.page; + paginator.snap(-1.2, 0, -150, 0, 120); + await waitForPage(paginator, page + 1); + expect(paginator.page).toBe(page + 1); + + // Finger moves left: vx/dx positive. + paginator.snap(1.2, 0, 150, 0, 120); + await waitForPage(paginator, page); + expect(paginator.page).toBe(page); + }); + + it('still pages forward on the legacy upward swipe for vertical books', async () => { + await setup(verticalBook); + const page = paginator.page; + // Finger moves up: vy/dy positive. + paginator.snap(0, 1.2, 0, 150, 120); + await waitForPage(paginator, page + 1); + expect(paginator.page).toBe(page + 1); + }); + + it('keeps leftward-swipe-to-advance for horizontal ltr books', async () => { + // Index 3 is the first chapter (multi-page); earlier spine items are + // single-page cover/title sections a swipe would step across. + await setup(horizontalBook, 3); + const page = paginator.page; + // Finger moves left: vx/dx positive → next page in ltr. + paginator.snap(1.2, 0, 150, 0, 120); + await waitForPage(paginator, page + 1); + expect(paginator.page).toBe(page + 1); + }); +}); diff --git a/apps/readest-app/src/__tests__/fixtures/data/sample-vertical-rl.epub b/apps/readest-app/src/__tests__/fixtures/data/sample-vertical-rl.epub new file mode 100644 index 00000000..1b416883 Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/sample-vertical-rl.epub differ diff --git a/packages/foliate-js b/packages/foliate-js index 0f857071..cecaef95 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 0f85707129f61f42ee7a9313b7bd58a2afd0432d +Subproject commit cecaef95be787cfb135b3d9a68325d62676c8f58