From c5304cd46ccead0b52037ebcd6a5e59464a69b71 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 3 Jul 2026 03:51:36 +0900 Subject: [PATCH] fix(reader): turn pages horizontally for vertical-rl books (#624) (#4899) Vertical-rl books paged along the vertical scroll axis: page turns slid up/down and only vertical swipes turned pages. Vertical books read with right-to-left page progression, so page turns now work horizontally, matching printed vertical books: - Swipes track the finger: the page follows a horizontal drag and the release commits the turn (past half a page width or a flick in the drag direction) or settles the page back. - Arrow keys, tap zones, and the wheel follow the same rtl mapping that horizontal-rtl books use. - Animated turns run a two-phase horizontal slide that continues from the dragged offset: the outgoing page exits along the page progression, the scroll jumps while off-screen, and the incoming page follows in from the opposite edge. A single-phase push is impossible because CSS multicol stacks vertical-rl pages along the vertical scroll axis inside one iframe, so the outgoing and incoming page can never be on screen side by side. - With animation disabled (or e-ink), turns swap instantly as before. The paginator changes live in the foliate-js submodule; this bumps the pointer and adds browser tests with a vertical-rl EPUB fixture covering direction detection, drag tracking, drag revert, horizontal swipe mapping in both directions, the legacy vertical swipe, the horizontal slide animation, the instant non-animated swap, and the unchanged horizontal-ltr swipe behavior. Fixes #624 Co-authored-by: Claude Fable 5 --- .../paginator-vertical-rl.browser.test.ts | 282 ++++++++++++++++++ .../fixtures/data/sample-vertical-rl.epub | Bin 0 -> 3279 bytes packages/foliate-js | 2 +- 3 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/src/__tests__/document/paginator-vertical-rl.browser.test.ts create mode 100644 apps/readest-app/src/__tests__/fixtures/data/sample-vertical-rl.epub 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 0000000000000000000000000000000000000000..1b416883352962f7c48a799d51e92eb2a147697b GIT binary patch literal 3279 zcmai12{@E%8=fIc$i8GrMd;Y~t)ejGF!su^WiT;iW{f3SW~^n(c0v)dOQJYri%GJ~ z88qmZ#&1bec3CQ88~oqooSas;ozOG3v94}RQO%^t9RbD9V%Zhyv zop*s{nc3sn8~118nGRnUpkc?U828bp(%J=bya=sN4dJMA;mEIwI!W_lPtuJWqL8i* zcYhK!qihFhKy6!lQ#}M+9lZ1tg2o;8PRX(gX_Ww%}KE)A0Wkb$f6VO!{O ze{I}~X+Cj(oPFY+Oq{SuT%rbieP6%3(IPoBjmNyh882FG9YBO=M`vq(CfbwMj9tUQ z|J(>x;0vuGMmF|)y1=p#okInlS%5(xp6@;=*j43gK7mMIBw5X>RaG$Z{izcCsFj-? z>)Ee!sB94Tm@LZE%U3}S+K2(6UNC9!X;4q%U6@b6d}L&YLUZYcPKPA}pQXE2l*4Si z^om~h*Vm9K7LM-|bu1TA*lg(uA4wQBl}%w)va- zPresIaMuTxtIMC_?}veZI|^wfDOAYhx*6a)bY6}_bbI*!OPM&xElooxI&4iHV@6*Y z^9suFX*VxI%GGhroYz%plycVo@E=`VgaLG+B33F~bgCH_G}D~ZbLs{MY>K$?PWzyq zHSSx>c1h7wkG-191Om0|?J#erWu87Tzd*RZ+D=bCWxYc2+X@m_PO{u1SpJ4fW1S+) z^|HNC7Z!|C6^I%cj?zXR|EcD%1GJdcI7-SV;f5yi;>3&YMd6&V6IwGJo1{S(6q(*b zBs7m04zD_m2PxA#j^7(JGRTKWTR2gEuMl}XNH;YKqkSb2gpf+2x4ae8@-@-2PZQum z7lH^b%4VHiw~Y|S$sHbQ1Szdd!N(JJ>KpK%Q}3C|CmNm~=oB?aYi2@`3sf%Op)A@k zk3BDx*E6Em`6~}~{jt1Y5By8TOBw0(UdysfjqU`PqZ8$kaANEwQ6Y$F&E`!R}AFufJ+YSuEETZf{Tf$=x%!oXCCSY|6`U+>Y& z3X(9q#|=&w2UEb_A|`Am%qE8=xM-Vhp14HugoyJ?d=I*xBzwV$ zOUbkI8@1)qRxkL16-@Q#la#b-So@#ZG_Dgc&B1q`mkf~S_^;$dQHU+{t(%R@_Qhot zEtD2a|F8b!wGiW==~v1sB1HeQ=Pq+jwv>Syv zcdg{}STF>2h5X>6jbg=?&y-g4Gcs>m?`-l2&CRd4piFtjf!4kTSIPFGTWjg@)Lxs6 zBut|gKHU1y0Bs^5v)>K1RS+O`g=uGQ8LragYU=lJ`@FkZMX()negF_=01Nd4L2Fop`A5XcAtI4E1fY8l`^ROU!=ll_-C}>~feU?O4yrW^Iwy>C(LBB`VgAU$hIJv5^3DT2rh}y?kUIqyY>M$ z%daCcGF9Yi%Hcxkfdso!69vvbBgDDn_74~=bizj+L9*yH*3KRWjrl4yFp5ysf~p+P3PlTaLC`Ml=I-2N->*jaPz;I|hdhjp0m)uw4pJ z#Gtvoyovj{uZ&`xX?$j5_ze|P{n1*bfJZ?OX?dZWVedDmO6kwnuMY30%|%lnu$IN& zhngJ5FYBZok~F)Sre6~CEc11+TLi578}5uF0;?UL;6rvH+*Yc_(3e zT6TF1&%;d-A01G}+2*`$8yYg&++v-lc@vU4xh$glRt!sJzEa=e53763;6)Cq1+^uA zGok>pW*}<@iciSUr|JUS*46GwAZc8Rh&S-muAkDl6bFD>21sVyX#hYv;|>Gl#&2yK zQYH&}GO)_)34{L*lHCDsk|WPM?U~Ot@EMUG8GTC;2_^y4DwY`p;`=_a7(*M53RFh> zT`EphOX^n@ys@EoeOy?^s>(;C;MkC@ZGKcVnW7?~V&&oM?foG34(|cEyh*OE5L2fo zE%B+{b08LeI}RdlVIyL@Hh$K%jHKjPvi_t9k!{0tf2w#!TLw$O@trDs`{t3VOVgzI z5yz2+6z$Qh?B70J6<0va)YpmMl_3euLZR06@q9XgGp(T8DZL@*6e?v7r4>obzYR}o zc>mOi&+h41!^a$J`36C=qZ*EfOp4s|@*!!Si0rCf{A0V1a^QlGMYacOyVxYgdqUQg z1w_=1&~E#mNS76M%^_OI`qr8iO*dvHH&^1&D{;ved4h9!a&Z##&wtg?h-?Zq9~cWi zV)=$RfpKQ;I5)Cb8BWiY@4)hVhQ&O@nins+JEzxW=x3fD`X$BHlyol5YxWqab*0wb z)A{$CN@t*@OIB}uylyD)AlXmrnwfj%E0=x=JAldXIU_H*Mu5N`{Z{=JuEp0Ky?D{IqqGvcq2_Ela^FpM3Lhjt-y`$MuXukUw z;P(y(_O4P12n_LsD|-e6U=z9yL>}Qfy%h;O