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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<void>((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);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Reference in New Issue
Block a user