fix(reader): correct reading ruler direction for vertical-rl books (#4865) (#4879)

Vertical-rl (Japanese/Chinese vertical) books read top-to-bottom with
columns progressing right-to-left, but getDirection only derived rtl from
the horizontal dir/direction, which stays ltr for these books. As a result
viewSettings.rtl was false and the reading ruler laid columns out
left-to-right, advancing the band the wrong way (reverse reading order).

Treat writing-mode: vertical-rl as RTL in getDirection so vertical-rl runs
through the same rtl paths that horizontal-rtl already uses: the reading
ruler coordinate mapping, page-turn tap mapping, footer navigation, and the
progress bar. Page-turn taps for these books now follow the vertical-rl
convention (tap left to go forward), matching horizontal-rtl behavior.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-02 00:40:52 +09:00
committed by GitHub
parent 17e60f1e49
commit 49391124c5
3 changed files with 143 additions and 3 deletions
@@ -48,6 +48,25 @@ const makeLineRects = (count: number, pitch: number, height: number): RulerTestR
height,
}));
// A relocate range whose client rects are three vertical text columns (tall thin
// strips) in iframe-content coordinates. No frameElement -> rects map straight to
// overlay coordinates. Columns are listed right-to-left (reading order for
// vertical-rl), but the builder derives order from geometry + the rtl flag.
const makeVerticalColumnsRange = (): {
getClientRects: () => RulerTestRect[];
startContainer: object;
} => {
const columnRects: RulerTestRect[] = [
{ top: 50, bottom: 950, left: 760, right: 776, width: 16, height: 900 }, // rightmost column
{ top: 50, bottom: 950, left: 400, right: 416, width: 16, height: 900 }, // middle column
{ top: 50, bottom: 950, left: 40, right: 56, width: 16, height: 900 }, // leftmost column
];
return {
startContainer: { ownerDocument: { defaultView: {} } },
getClientRects: () => columnRects,
};
};
// A single visible section whose iframe is offset by `frameTop` along the scroll
// axis (negative = scrolled down). `buildScrolledLineBoxes` walks these contents.
const makeScrolledContents = (
@@ -299,6 +318,89 @@ describe('ReadingRuler', () => {
expect(rulerTop()).toBeCloseTo(initialTop, 5);
});
// Regression: issue #4865 — for vertical-rl (Japanese vertical) books columns
// progress right-to-left, so advancing the ruler forward must move the band to
// the LEFT. It used to run backwards because rtl was false for vertical-rl.
it('advances the vertical-rl ruler band to the left on a forward move', async () => {
mockProgress = { range: makeVerticalColumnsRange(), pageinfo: { current: 0 } };
const verticalSettings = { ...viewSettings } as ViewSettings;
const { container } = render(
<ReadingRuler
bookKey='book-1'
isVertical={true}
rtl={true}
lines={1}
position={4}
opacity={0.5}
color='transparent'
bookFormat='EPUB'
viewSettings={verticalSettings}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
const rulerLeft = () =>
parseFloat((container.querySelector('.ruler') as HTMLDivElement).style.left);
// Mount snaps the band onto the first (rightmost) column, near the right edge.
let before = 0;
await waitFor(() => {
before = rulerLeft();
expect(before).toBeGreaterThan(80);
});
const consumed = eventDispatcher.dispatchSync('reading-ruler-move', {
bookKey: 'book-1',
direction: 'forward',
});
expect(consumed).toBe(true);
// Forward => next column to the left => band's left percentage decreases.
await waitFor(() => {
expect(rulerLeft()).toBeLessThan(before);
});
});
// The vertical-lr (Mongolian) counterpart moves the other way: forward => right.
it('advances the vertical-lr ruler band to the right on a forward move', async () => {
mockProgress = { range: makeVerticalColumnsRange(), pageinfo: { current: 0 } };
const verticalSettings = { ...viewSettings } as ViewSettings;
const { container } = render(
<ReadingRuler
bookKey='book-1'
isVertical={true}
rtl={false}
lines={1}
position={4}
opacity={0.5}
color='transparent'
bookFormat='EPUB'
viewSettings={verticalSettings}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
const rulerLeft = () =>
parseFloat((container.querySelector('.ruler') as HTMLDivElement).style.left);
// Mount snaps the band onto the first (leftmost) column, near the left edge.
let before = 100;
await waitFor(() => {
before = rulerLeft();
expect(before).toBeLessThan(20);
});
const consumed = eventDispatcher.dispatchSync('reading-ruler-move', {
bookKey: 'book-1',
direction: 'forward',
});
expect(consumed).toBe(true);
await waitFor(() => {
expect(rulerLeft()).toBeGreaterThan(before);
});
});
it('still snaps the ruler to lines when advancing by click in scrolled mode', async () => {
const lineRects = makeLineRects(50, 100, 40);
mockProgress = { range: {}, pageinfo: { current: 0 } };
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { afterEach, describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { DocumentLoader } from '@/libs/document';
import { DocumentLoader, getDirection } from '@/libs/document';
if (typeof globalThis['CSS'] === 'undefined') {
(globalThis as Record<string, unknown>)['CSS'] = {
@@ -94,3 +94,34 @@ describe('DocumentLoader.open', () => {
expect(result.format).toBe('EPUB');
}, 15000);
});
describe('getDirection', () => {
afterEach(() => {
document.body.removeAttribute('style');
document.body.removeAttribute('dir');
document.documentElement.removeAttribute('dir');
document.body.innerHTML = '';
});
it('treats vertical-rl (Japanese vertical) as RTL so columns advance right-to-left', () => {
// vertical-rl reads top-to-bottom with columns progressing right-to-left, but
// its computed CSS `direction` stays `ltr`; the writing mode alone marks it RTL.
document.body.style.writingMode = 'vertical-rl';
expect(getDirection(document)).toEqual({ vertical: true, rtl: true });
});
it('treats vertical-lr (Mongolian) as vertical but left-to-right', () => {
document.body.style.writingMode = 'vertical-lr';
expect(getDirection(document)).toEqual({ vertical: true, rtl: false });
});
it('leaves horizontal-tb as neither vertical nor RTL', () => {
document.body.style.writingMode = 'horizontal-tb';
expect(getDirection(document)).toEqual({ vertical: false, rtl: false });
});
it('still honors an explicit rtl dir for horizontal text', () => {
document.body.setAttribute('dir', 'rtl');
expect(getDirection(document)).toEqual({ vertical: false, rtl: true });
});
});
+8 -1
View File
@@ -452,7 +452,14 @@ export const getDirection = (doc: Document) => {
}
}
const vertical = writingMode === 'vertical-rl' || writingMode === 'vertical-lr';
const rtl = doc.body.dir === 'rtl' || direction === 'rtl' || doc.documentElement.dir === 'rtl';
// `vertical-rl` (Japanese/Chinese vertical) advances columns right-to-left even
// though its computed `direction` stays `ltr`, so the writing mode itself marks
// it RTL. Without this the reading ruler and page turns run backwards (#4865).
const rtl =
writingMode === 'vertical-rl' ||
doc.body.dir === 'rtl' ||
direction === 'rtl' ||
doc.documentElement.dir === 'rtl';
return { vertical, rtl };
};