mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user