fix(reader): respect epub page progression direction

The reader previously hardcoded left/right interactions (arrows, clicks,
swipes) to prev/next actions. This change uses physical left/right
navigation methods to respect the EPUB's resolved text direction (RTL).

Closes #444
This commit is contained in:
neon
2026-06-28 05:08:53 +00:00
parent ce2d49af3d
commit d809cf71c4
5 changed files with 195 additions and 20 deletions
+10 -3
View File
@@ -195,6 +195,12 @@ const getBackground = (doc) => {
: bodyStyle.background
}
export const getPageProgressionRtl = (bookDir, contentRtl) => {
if (bookDir === 'rtl') return true
if (bookDir === 'ltr') return false
return contentRtl
}
const makeMarginals = (length, part) =>
Array.from({ length }, () => {
const div = document.createElement('div')
@@ -682,7 +688,8 @@ export class Paginator extends HTMLElement {
}
#beforeRender({ vertical, rtl, background }) {
this.#vertical = vertical
this.#rtl = rtl
const pageProgressionRtl = getPageProgressionRtl(this.bookDir, rtl)
this.#rtl = pageProgressionRtl
this.#top.classList.toggle('vertical', vertical)
// set background to `doc` background
@@ -735,13 +742,13 @@ export class Paginator extends HTMLElement {
const divisor = Math.min(maxColumnCount, Math.ceil(size / maxInlineSize))
const columnWidth = size / divisor - gap
this.setAttribute('dir', rtl ? 'rtl' : 'ltr')
this.setAttribute('dir', pageProgressionRtl ? 'rtl' : 'ltr')
const marginalDivisor = vertical ? Math.min(2, Math.ceil(width / maxInlineSize)) : divisor
const marginalStyle = {
gridTemplateColumns: `repeat(${marginalDivisor}, 1fr)`,
gap: `${gap}px`,
direction: this.bookDir === 'rtl' ? 'rtl' : 'ltr',
direction: pageProgressionRtl ? 'rtl' : 'ltr',
}
Object.assign(this.#header.style, marginalStyle)
Object.assign(this.#footer.style, marginalStyle)
+6 -2
View File
@@ -845,12 +845,16 @@ export class View extends HTMLElement {
await this.renderer.next(distance)
}
#isRTL() {
return this.book?.dir === 'rtl' || this.renderer?.getAttribute?.('dir') === 'rtl'
}
goLeft() {
return this.book.dir === 'rtl' ? this.next() : this.prev()
return this.#isRTL() ? this.next() : this.prev()
}
goRight() {
return this.book.dir === 'rtl' ? this.prev() : this.next()
return this.#isRTL() ? this.prev() : this.next()
}
async *#searchSection(matcher, query, index) {
@@ -29,16 +29,22 @@ function installBrowserGlobals() {
describe('Foliate navigation', () => {
let View: new () => {
book: { sections: unknown[]; resolveHref: (href: string) => { index: number } }
book: { sections?: unknown[]; resolveHref?: (href: string) => { index: number }; dir?: string | null }
renderer: {
goTo: (resolved: { index: number }) => Promise<void>
getContents: () => { index: number; text?: string }[]
goTo?: (resolved: { index: number }) => Promise<void>
getContents?: () => { index: number; text?: string }[]
prev?: () => void
next?: () => void
getAttribute?: (name: string) => string | null
}
goTo: (target: string) => Promise<{ index: number } | undefined>
goLeft: () => Promise<void>
goRight: () => Promise<void>
}
let getKoreaderProgress: (index: number, range: Range | null) => string | null
let getKoreaderDocFragmentIndex: (sections: { id: string }[], index: number) => number | null
let getKoboSpanValue: (range: Range | null) => string | null
let getPageProgressionRtl: (bookDir: string | null | undefined, contentRtl: boolean) => boolean
let Paginator: new () => {
sections: { load: () => Promise<string | null> }[]
goTo: (target: { index: number }) => Promise<void>
@@ -55,7 +61,10 @@ describe('Foliate navigation', () => {
getKoreaderDocFragmentIndex: typeof getKoreaderDocFragmentIndex
getKoboSpanValue: typeof getKoboSpanValue
})
;({ Paginator } = (await import(paginatorModulePath)) as { Paginator: typeof Paginator })
;({ Paginator, getPageProgressionRtl } = (await import(paginatorModulePath)) as {
Paginator: typeof Paginator
getPageProgressionRtl: typeof getPageProgressionRtl
})
})
it('does not treat stale rendered contents as successful navigation', async () => {
@@ -108,6 +117,47 @@ describe('Foliate navigation', () => {
await expect(paginator.goTo({ index: 0 })).rejects.toThrow('Failed to load section 0')
})
it('uses explicit OPF page progression before content direction', () => {
expect(getPageProgressionRtl('rtl', false)).toBe(true)
expect(getPageProgressionRtl('ltr', true)).toBe(false)
expect(getPageProgressionRtl('default', true)).toBe(true)
expect(getPageProgressionRtl(undefined, false)).toBe(false)
})
it('maps physical left and right navigation using OPF RTL page progression', async () => {
const view = new View()
const prev = vi.fn<() => void>()
const next = vi.fn<() => void>()
view.book = { dir: 'rtl' }
view.renderer = { prev, next, getAttribute: () => null }
await view.goLeft()
await view.goRight()
expect(next).toHaveBeenCalledTimes(1)
expect(prev).toHaveBeenCalledTimes(1)
})
it('maps physical left and right navigation using renderer-resolved RTL direction', async () => {
const view = new View()
const prev = vi.fn<() => void>()
const next = vi.fn<() => void>()
view.book = { dir: null }
view.renderer = {
prev,
next,
getAttribute: (name: string) => (name === 'dir' ? 'rtl' : null),
}
await view.goLeft()
await view.goRight()
expect(next).toHaveBeenCalledTimes(1)
expect(prev).toHaveBeenCalledTimes(1)
})
it('preserves real inline elements for KOReader XPointer progress', () => {
const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML = '<p>First paragraph</p><p><span>Middle text</span> tail</p>'
@@ -4,6 +4,8 @@ import { useFoliateInput } from '../useFoliateInput'
interface ViewLike {
prev: () => void
next: () => void
goLeft?: () => void
goRight?: () => void
getBoundingClientRect: () => DOMRect
}
@@ -100,6 +102,34 @@ describe('useFoliateInput', () => {
input.cleanup()
})
it('uses physical left and right helpers for arrow keys when available', () => {
const prev = vi.fn<() => void>()
const next = vi.fn<() => void>()
const goLeft = vi.fn<() => void>()
const goRight = vi.fn<() => void>()
const view: ViewLike = {
prev,
next,
goLeft,
goRight,
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
}
const input = useFoliateInput(() => view, undefined, vi.fn<() => void>(), vi.fn<() => void>())
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true }))
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true }))
expect(goLeft).toHaveBeenCalledTimes(1)
expect(goRight).toHaveBeenCalledTimes(1)
expect(prev).toHaveBeenCalledTimes(1)
expect(next).toHaveBeenCalledTimes(1)
input.cleanup()
})
it('routes click-zone window messages to prev/next/middle actions', () => {
vi.useFakeTimers()
@@ -154,6 +184,58 @@ describe('useFoliateInput', () => {
}
})
it('routes click-zone window messages through physical left and right helpers when available', () => {
vi.useFakeTimers()
const prev = vi.fn<() => void>()
const next = vi.fn<() => void>()
const goLeft = vi.fn<() => void>()
const goRight = vi.fn<() => void>()
const onMiddleTap = vi.fn<() => void>()
const view: ViewLike = {
prev,
next,
goLeft,
goRight,
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
}
const input = useFoliateInput(() => view, onMiddleTap, vi.fn<() => void>(), vi.fn<() => void>())
const doc = makeDocTarget()
input.attachIframeClicks(doc)
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, 'maxTouchPoints')
const originalOntouchstart = Object.getOwnPropertyDescriptor(window, 'ontouchstart')
Object.defineProperty(navigator, 'maxTouchPoints', {
configurable: true,
get: () => 0,
})
Reflect.deleteProperty(window as unknown as Record<string, unknown>, 'ontouchstart')
doc.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
window.dispatchEvent(new MessageEvent('message', { data: { type: 'foliate-click', clientX: 5 }, origin: window.location.origin }))
vi.advanceTimersByTime(300)
vi.advanceTimersByTime(300)
doc.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
window.dispatchEvent(new MessageEvent('message', { data: { type: 'foliate-click', clientX: 95 }, origin: window.location.origin }))
vi.advanceTimersByTime(300)
expect(goLeft).toHaveBeenCalledTimes(1)
expect(goRight).toHaveBeenCalledTimes(1)
expect(prev).not.toHaveBeenCalled()
expect(next).not.toHaveBeenCalled()
input.cleanup()
if (originalMaxTouchPoints) {
Object.defineProperty(navigator, 'maxTouchPoints', originalMaxTouchPoints)
}
if (originalOntouchstart) {
Object.defineProperty(window, 'ontouchstart', originalOntouchstart)
}
})
it('stops responding to document keydown after cleanup', () => {
const next = vi.fn<() => void>()
const view: ViewLike = {
@@ -22,7 +22,33 @@ export function useFoliateInput(
let longHoldTimeout: ReturnType<typeof setTimeout> | null = null
function getViewEl() {
return getView() as { prev?: () => void; next?: () => void; getBoundingClientRect?: () => DOMRect } | null
return getView() as {
prev?: () => void
next?: () => void
goLeft?: () => void
goRight?: () => void
getBoundingClientRect?: () => DOMRect
} | null
}
function navigateLeft() {
const view = getViewEl()
if (view?.goLeft) view.goLeft()
else view?.prev?.()
}
function navigateRight() {
const view = getViewEl()
if (view?.goRight) view.goRight()
else view?.next?.()
}
function navigatePrev() {
getViewEl()?.prev?.()
}
function navigateNext() {
getViewEl()?.next?.()
}
function handleTouchStart(e: TouchEvent) {
@@ -74,8 +100,8 @@ export function useFoliateInput(
if (Math.abs(deltaX) >= SWIPE_THRESHOLD && Math.abs(deltaX) > deltaY) {
if (isNavigating) return
isNavigating = true
if (deltaX < 0) getViewEl()?.next?.()
else getViewEl()?.prev?.()
if (deltaX < 0) navigateRight()
else navigateLeft()
setTimeout(() => (isNavigating = false), 300)
return
}
@@ -195,11 +221,11 @@ export function useFoliateInput(
if (currentZone === 'left' && !isMobile) {
isNavigating = true
getViewEl()?.prev?.()
navigateLeft()
setTimeout(() => (isNavigating = false), 300)
} else if (currentZone === 'right' && !isMobile) {
isNavigating = true
getViewEl()?.next?.()
navigateRight()
setTimeout(() => (isNavigating = false), 300)
} else {
onMiddleTap?.()
@@ -212,17 +238,23 @@ export function useFoliateInput(
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) return
const view = getViewEl()
if (!view) return
if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
view.prev?.()
if (e.key === 'ArrowLeft') {
navigateLeft()
e.preventDefault()
} else if (e.key === 'ArrowRight' || e.key === 'PageDown') {
view.next?.()
} else if (e.key === 'ArrowRight') {
navigateRight()
e.preventDefault()
} else if (e.key === 'PageUp') {
navigatePrev()
e.preventDefault()
} else if (e.key === 'PageDown') {
navigateNext()
e.preventDefault()
} else if (e.key === ' ' && e.shiftKey) {
view.prev?.()
navigatePrev()
e.preventDefault()
} else if (e.key === ' ') {
view.next?.()
navigateNext()
e.preventDefault()
}
}