commit bf39f7038ecff318c806b079c3beab529d476077 truex <truex@equwal.com> 2026-09-26 14:30:46 -0700 Turn pages with keys and swipes, and go to a page Only the Prev and Next buttons turned the page, so page 30 of 40 took 29 presses. The new module nav.ts adds these ways: - ArrowRight, ArrowLeft, PageDown and PageUp turn the page, but not while the focus is in a field. - A horizontal swipe on the viewer of at least 50 CSS pixels, with |dx| more than 1.5 |dy|, turns the page. A zoomed page or a page wider than the viewer does not turn. The click after a swipe does no lookup. - The page label opens a field for a page number. Enter goes, Escape or a tap outside cancels. The new setting "Pages turn right to left (vertical Japanese)" is on for the jpn_vert OCR language until the user sets it. Then ArrowLeft, a swipe to the right and the left arrow go to the next page. The pure module paging.ts holds the decisions.
index.html | 13 ++++ src/main.ts | 49 ++++++++---- src/nav.ts | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/paging.ts | 86 +++++++++++++++++++++ src/style.css | 20 +++++ test/paging.test.ts | 149 +++++++++++++++++++++++++++++++++++++ 6 files changed, 513 insertions(+), 14 deletions(-)
diff --git a/index.html b/index.html index 79218ee..fd3affe 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,16 @@ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7" /></svg> </button> <button id="page-label" title="Go to a page" disabled>No PDF</button> + <input + id="page-input" + type="text" + inputmode="numeric" + enterkeyhint="go" + autocomplete="off" + aria-label="Go to page" + title="Type a page number. Enter goes, Escape cancels." + hidden + /> <button id="page-right" class="icon" aria-label="Next page" title="Next page" disabled> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7" /></svg> </button> @@ -106,6 +116,9 @@ <option value="jpn+eng">Japanese + English</option> </select> </label> + <label class="check" + ><input id="rtl" type="checkbox" /> Pages turn right to left (vertical Japanese)</label + > <label class="check" ><input id="force-ocr" type="checkbox" /> Force OCR (the text layer of the PDF is wrong)</label diff --git a/src/main.ts b/src/main.ts index 71b3f64..e0d6397 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,7 +17,9 @@ import { playerMessage, readingText, } from './messages'; +import { setupNav } from './nav'; import { createOcr, type Ocr } from './ocr'; +import { defaultRtl } from './paging'; import { loadPdf, type PdfDoc } from './pdf'; import { nextPage } from './read-order'; import { say, showPlayer, showReading } from './status'; @@ -78,6 +80,8 @@ const ui = { play: el<HTMLButtonElement>('play'), syncPage: el<HTMLButtonElement>('sync-page'), pageLabel: el<HTMLButtonElement>('page-label'), + pageInput: el<HTMLInputElement>('page-input'), + rtl: el<HTMLInputElement>('rtl'), viewer: el<HTMLDivElement>('viewer'), empty: el<HTMLElement>('empty'), openPdf: el<HTMLButtonElement>('open-pdf'), @@ -113,6 +117,19 @@ const state = { const view = createPageView(ui, marks); +const desktop = window.matchMedia('(min-width: 900px)'); + +const nav = setupNav( + ui, + { + pages: () => state.pdf?.numPages ?? 0, + current: () => state.currentPage, + rtl: () => ui.rtl.checked, + drawerOpen: () => !desktop.matches && document.body.classList.contains('menu-open'), + }, + (index) => void showPage(index), +); + // --- Settings --- const settings = { @@ -138,8 +155,24 @@ function loadSettings(): void { ui.subLang.value = settings.get('subLang', 'auto'); ui.forceOcr.checked = settings.get('forceOcr', '0') === '1'; ui.pauseLookup.checked = settings.get('pauseLookup', '1') === '1'; + loadRtl(); +} + +/** + * The reading direction: the setting of the user, else the default for + * the OCR language, so that vertical Japanese turns right to left. + */ +function loadRtl(): void { + const saved = settings.get('rtl', ''); + ui.rtl.checked = saved === '' ? defaultRtl(ui.lang.value) : saved === '1'; + nav.update(); } +ui.rtl.addEventListener('change', () => { + settings.set('rtl', ui.rtl.checked ? '1' : '0'); + nav.update(); +}); + ui.follow.addEventListener('change', () => { settings.set('follow', followMode()); state.markedCue = -1; @@ -152,6 +185,7 @@ ui.pauseLookup.addEventListener('change', () => ui.subLang.addEventListener('change', () => settings.set('subLang', ui.subLang.value)); ui.lang.addEventListener('change', () => { settings.set('lang', ui.lang.value); + loadRtl(); void readBook(); }); ui.forceOcr.addEventListener('change', () => { @@ -528,16 +562,6 @@ ui.audioFile.addEventListener('change', () => { // --- Page view --- -/** Shows the page label, and enables the arrows that lead to a page. */ -function updateNav(): void { - const pdf = state.pdf; - const page = state.currentPage; - ui.pageLabel.disabled = !pdf; - ui.pageLabel.textContent = pdf ? `${page + 1} / ${pdf.numPages}` : 'No PDF'; - ui.pageLeft.disabled = !pdf || page <= 0; - ui.pageRight.disabled = !pdf || page >= pdf.numPages - 1; -} - /** The mark of page `index`: one box per text line of the marked cue. */ function marks(index: number): Marks | null { const entry = state.pages[index]; @@ -554,13 +578,10 @@ async function showPage(index: number): Promise<void> { const pdf = state.pdf; if (!pdf || index < 0 || index >= pdf.numPages) return; state.currentPage = index; - updateNav(); + nav.update(); await view.show(pdf, index); } -ui.pageLeft.addEventListener('click', () => void showPage(state.currentPage - 1)); -ui.pageRight.addEventListener('click', () => void showPage(state.currentPage + 1)); - // --- Tap on a word --- /** Tap on the page: send the text under the tap to the dictionary. */ diff --git a/src/nav.ts b/src/nav.ts new file mode 100644 index 0000000..0659027 --- /dev/null +++ b/src/nav.ts @@ -0,0 +1,210 @@ +// Page turns by the user: the arrows of the top bar, the keys, a swipe on +// the viewer, and the page jump of the page label. The decisions are in +// paging.ts. + +import { arrowsOff, arrowStep, keyStep, parsePage, swipeStep, type Step } from './paging'; +import { say } from './status'; + +/** A click this soon after the end of a swipe comes from the swipe, and does no lookup. */ +const CLICK_AFTER_SWIPE_MS = 400; + +/** A click this soon after the tap that cancels the page jump comes from that tap. */ +const CLICK_AFTER_CANCEL_MS = 1000; + +/** A page zoomed in more than this is zoomed. */ +const ZOOMED = 1.01; + +export interface NavElements { + viewer: HTMLElement; + pageLeft: HTMLButtonElement; + pageRight: HTMLButtonElement; + pageLabel: HTMLButtonElement; + pageInput: HTMLInputElement; +} + +export interface NavState { + /** The count of pages, or 0 with no PDF. */ + pages(): number; + /** The index of the page shown. */ + current(): number; + /** True when the pages turn right to left. */ + rtl(): boolean; + /** True while the drawer covers the page. */ + drawerOpen(): boolean; +} + +export interface Nav { + /** Shows the page label, and the state and the names of the arrows. */ + update(): void; +} + +function zoomed(): boolean { + return (window.visualViewport?.scale ?? 1) > ZOOMED; +} + +/** `turnTo(index)` shows the page that the user asked for. */ +export function setupNav(ui: NavElements, s: NavState, turnTo: (index: number) => void): Nav { + function go(step: Step): void { + const to = s.current() + step; + if (to >= 0 && to < s.pages()) turnTo(to); + } + + // --- The arrows --- + + ui.pageLeft.addEventListener('click', () => go(arrowStep('left', s.rtl()))); + ui.pageRight.addEventListener('click', () => go(arrowStep('right', s.rtl()))); + + function name(button: HTMLButtonElement, step: Step): void { + const text = step > 0 ? 'Next page' : 'Previous page'; + if (button.title === text) return; + button.title = text; + button.setAttribute('aria-label', text); + } + + // --- The keys --- + + document.addEventListener('keydown', (e) => { + if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || s.drawerOpen()) return; + const target = e.target; + if ( + target instanceof HTMLElement && + (target.closest('input, select, textarea') || target.isContentEditable) + ) { + return; + } + const step = keyStep(e.key, s.rtl()); + if (step === 0) return; + e.preventDefault(); + go(step); + }); + + // --- The swipe --- + + // The browser pans the page only vertically, so a sideways move stays + // with the swipe. A zoomed page pans in each direction. + const markZoom = (): void => { + document.body.classList.toggle('zoomed', zoomed()); + }; + window.visualViewport?.addEventListener('resize', markZoom); + markZoom(); + + let start: { id: number; x: number; y: number } | null = null; + let swipedAt = -Infinity; + ui.viewer.addEventListener('pointerdown', (e) => { + // A second finger makes a pinch, not a swipe. + start = e.isPrimary ? { id: e.pointerId, x: e.clientX, y: e.clientY } : null; + }); + ui.viewer.addEventListener('pointercancel', () => { + start = null; + }); + ui.viewer.addEventListener('pointerup', (e) => { + if (!start || e.pointerId !== start.id) return; + const dx = e.clientX - start.x; + const dy = e.clientY - start.y; + start = null; + if (s.drawerOpen()) return; + const v = ui.viewer; + const step = swipeStep( + { dx, dy, zoomed: zoomed(), wide: v.scrollWidth > v.clientWidth + 1 }, + s.rtl(), + ); + if (step === 0) return; + swipedAt = e.timeStamp; + go(step); + }); + // The click after a swipe is not a tap on a word. + ui.viewer.addEventListener( + 'click', + (e) => { + if (e.timeStamp - swipedAt < CLICK_AFTER_SWIPE_MS) { + swipedAt = -Infinity; + e.stopPropagation(); + e.preventDefault(); + } + }, + true, + ); + + // --- The page jump --- + + function openJump(): void { + if (s.pages() === 0) return; + ui.pageInput.value = String(s.current() + 1); + ui.pageInput.placeholder = `1-${s.pages()}`; + ui.pageLabel.hidden = true; + ui.pageInput.hidden = false; + ui.pageInput.focus(); + ui.pageInput.select(); + } + + function closeJump(): void { + if (ui.pageInput.hidden) return; + ui.pageInput.hidden = true; + ui.pageLabel.hidden = false; + } + + ui.pageLabel.addEventListener('click', openJump); + ui.pageInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + closeJump(); + ui.pageLabel.focus(); + } else if (e.key === 'Enter') { + e.preventDefault(); + const index = parsePage(ui.pageInput.value, s.pages()); + if (index === null) { + say(`Type a page number from 1 to ${s.pages()}.`); + return; + } + closeJump(); + ui.pageLabel.focus(); + if (index !== s.current()) turnTo(index); + } + }); + // Tab or another focus change cancels the jump. + ui.pageInput.addEventListener('blur', closeJump); + + // A tap outside the open page jump only cancels it: the click that + // follows does nothing else. + let cancelledAt = -Infinity; + document.addEventListener( + 'pointerdown', + (e) => { + if (ui.pageInput.hidden || e.target === ui.pageInput) return; + closeJump(); + cancelledAt = e.timeStamp; + }, + true, + ); + document.addEventListener( + 'click', + (e) => { + if (e.timeStamp - cancelledAt < CLICK_AFTER_CANCEL_MS) { + cancelledAt = -Infinity; + e.stopPropagation(); + e.preventDefault(); + } + }, + true, + ); + + return { + update() { + const pages = s.pages(); + const page = s.current(); + const rtl = s.rtl(); + ui.pageLabel.disabled = pages === 0; + ui.pageLabel.textContent = pages > 0 ? `${page + 1} / ${pages}` : 'No PDF'; + ui.pageLabel.setAttribute( + 'aria-label', + pages > 0 ? `Page ${page + 1} of ${pages}. Go to a page.` : 'No PDF', + ); + const off = arrowsOff(page, pages, rtl); + ui.pageLeft.disabled = off.left; + ui.pageRight.disabled = off.right; + name(ui.pageLeft, arrowStep('left', rtl)); + name(ui.pageRight, arrowStep('right', rtl)); + if (pages === 0) closeJump(); + }, + }; +} diff --git a/src/paging.ts b/src/paging.ts new file mode 100644 index 0000000..5946d3e --- /dev/null +++ b/src/paging.ts @@ -0,0 +1,86 @@ +// Page turns from keys, swipes and the arrows of the top bar. Pure: no DOM. +// +// In right-to-left mode (vertical Japanese) the next page is on the left: +// ArrowLeft, a swipe to the right and the left arrow go forward. + +/** A page turn: 1 goes to the next page, -1 to the page before. */ +export type Step = -1 | 1; + +/** The shortest swipe that turns a page, in CSS pixels. */ +export const SWIPE_MIN = 50; + +/** A swipe turns a page only when its horizontal move is more than this times its vertical move. */ +export const SWIPE_RATIO = 1.5; + +/** The step of a key, or 0 for a key that turns no page. */ +export function keyStep(key: string, rtl: boolean): Step | 0 { + switch (key) { + case 'PageDown': + return 1; + case 'PageUp': + return -1; + case 'ArrowRight': + return rtl ? -1 : 1; + case 'ArrowLeft': + return rtl ? 1 : -1; + default: + return 0; + } +} + +export interface Swipe { + /** The move of the finger in CSS pixels. A positive dx goes right, a positive dy goes down. */ + dx: number; + dy: number; + /** True when the user zoomed in on the page. */ + zoomed: boolean; + /** True when the viewer can scroll sideways. */ + wide: boolean; +} + +/** + * The step of a swipe, or 0 when the swipe turns no page. A finger that + * moves to the left goes to the next page, as on paper. A page that is + * zoomed in or wider than the viewer needs the swipe to pan, so it does + * not turn. + */ +export function swipeStep(s: Swipe, rtl: boolean): Step | 0 { + if (s.zoomed || s.wide) return 0; + const x = Math.abs(s.dx); + if (x < SWIPE_MIN || x <= SWIPE_RATIO * Math.abs(s.dy)) return 0; + return s.dx < 0 !== rtl ? 1 : -1; +} + +/** The step of the left or the right arrow of the top bar. */ +export function arrowStep(side: 'left' | 'right', rtl: boolean): Step { + return (side === 'right') !== rtl ? 1 : -1; +} + +/** + * Which arrows are off: an arrow is off when its step leads to no page. + * `pages` is 0 when no PDF is open. + */ +export function arrowsOff( + page: number, + pages: number, + rtl: boolean, +): { left: boolean; right: boolean } { + const off = (step: Step): boolean => page + step < 0 || page + step >= pages; + return { left: off(arrowStep('left', rtl)), right: off(arrowStep('right', rtl)) }; +} + +/** + * The index of the page that the user typed: a number from 1 to `pages`. + * NFKC reads full-width digits. Null for other text. + */ +export function parsePage(text: string, pages: number): number | null { + const t = text.normalize('NFKC').trim(); + if (!/^[0-9]+$/.test(t)) return null; + const n = Number(t); + return n >= 1 && n <= pages ? n - 1 : null; +} + +/** The reading direction before the user sets one: right to left for vertical Japanese. */ +export function defaultRtl(ocrLang: string): boolean { + return ocrLang.includes('vert'); +} diff --git a/src/style.css b/src/style.css index 0a0a3b9..efa5215 100644 --- a/src/style.css +++ b/src/style.css @@ -104,6 +104,19 @@ body { font-variant-numeric: tabular-nums; } +/* The page jump takes the place of the page label. */ +#page-input { + flex: 1 1 auto; + min-width: 0; + width: 4rem; + box-sizing: border-box; + height: 2.25rem; + padding: 0 0.4rem; + font: inherit; + font-size: 1rem; + text-align: center; +} + /* The status strip: the player, the reading of the pages, the last event. */ #strip { flex: none; @@ -286,6 +299,13 @@ body.menu-open #panel { /* A scroll bar that comes and goes does not change the width of the page. */ scrollbar-gutter: stable; padding: 0.5rem; + /* The browser pans vertically only, so a sideways swipe turns the page (nav.ts). */ + touch-action: pan-y pinch-zoom; +} + +/* A zoomed page pans in each direction, and a swipe does not turn it. */ +body.zoomed #viewer { + touch-action: auto; } /* The start card: what the app does, and how to start. */ diff --git a/test/paging.test.ts b/test/paging.test.ts new file mode 100644 index 0000000..62f3be9 --- /dev/null +++ b/test/paging.test.ts @@ -0,0 +1,149 @@ +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { + arrowsOff, + arrowStep, + defaultRtl, + keyStep, + parsePage, + SWIPE_MIN, + SWIPE_RATIO, + swipeStep, + type Swipe, +} from '../src/paging'; + +const flat = { zoomed: false, wide: false }; + +describe('keyStep', () => { + it('turns with the arrow keys and the page keys', () => { + expect(keyStep('ArrowRight', false)).toBe(1); + expect(keyStep('ArrowLeft', false)).toBe(-1); + expect(keyStep('PageDown', false)).toBe(1); + expect(keyStep('PageUp', false)).toBe(-1); + expect(keyStep('ArrowDown', false)).toBe(0); + expect(keyStep(' ', false)).toBe(0); + }); + + it('goes forward with ArrowLeft in right-to-left mode', () => { + expect(keyStep('ArrowLeft', true)).toBe(1); + expect(keyStep('ArrowRight', true)).toBe(-1); + expect(keyStep('PageDown', true)).toBe(1); + expect(keyStep('PageUp', true)).toBe(-1); + }); +}); + +describe('swipeStep', () => { + it('turns forward for a swipe to the left, back for a swipe to the right', () => { + expect(swipeStep({ dx: -80, dy: 10, ...flat }, false)).toBe(1); + expect(swipeStep({ dx: 80, dy: -10, ...flat }, false)).toBe(-1); + }); + + it('turns forward for a swipe to the right in right-to-left mode', () => { + expect(swipeStep({ dx: 80, dy: 0, ...flat }, true)).toBe(1); + expect(swipeStep({ dx: -80, dy: 0, ...flat }, true)).toBe(-1); + }); + + it('does not turn for a short swipe, a steep swipe, a zoomed page or a wide page', () => { + expect(swipeStep({ dx: -49, dy: 0, ...flat }, false)).toBe(0); + expect(swipeStep({ dx: -60, dy: 40, ...flat }, false)).toBe(0); + expect(swipeStep({ dx: -80, dy: 0, zoomed: true, wide: false }, false)).toBe(0); + expect(swipeStep({ dx: -80, dy: 0, zoomed: false, wide: true }, false)).toBe(0); + }); + + const swipe: fc.Arbitrary<Swipe> = fc.record({ + dx: fc.integer({ min: -400, max: 400 }), + dy: fc.integer({ min: -400, max: 400 }), + zoomed: fc.boolean(), + wide: fc.boolean(), + }); + + it('turns exactly for a long, flat swipe on a page that is not zoomed or wide', () => { + fc.assert( + fc.property(swipe, fc.boolean(), (s, rtl) => { + const turns = + !s.zoomed && + !s.wide && + Math.abs(s.dx) >= SWIPE_MIN && + Math.abs(s.dx) > SWIPE_RATIO * Math.abs(s.dy); + expect(swipeStep(s, rtl) !== 0).toBe(turns); + }), + ); + }); + + it('turns the other way for the mirror swipe, and for the other direction', () => { + fc.assert( + fc.property(swipe, fc.boolean(), (s, rtl) => { + const step = swipeStep(s, rtl); + expect(swipeStep({ ...s, dx: -s.dx }, rtl)).toBe(-step || 0); + expect(swipeStep(s, !rtl)).toBe(-step || 0); + }), + ); + }); +}); + +describe('arrows', () => { + it('gives the right arrow the next page, except in right-to-left mode', () => { + expect(arrowStep('right', false)).toBe(1); + expect(arrowStep('left', false)).toBe(-1); + expect(arrowStep('right', true)).toBe(-1); + expect(arrowStep('left', true)).toBe(1); + }); + + it('turns off the arrow that leads to no page', () => { + expect(arrowsOff(0, 40, false)).toEqual({ left: true, right: false }); + expect(arrowsOff(39, 40, false)).toEqual({ left: false, right: true }); + expect(arrowsOff(0, 40, true)).toEqual({ left: false, right: true }); + expect(arrowsOff(0, 1, false)).toEqual({ left: true, right: true }); + // No PDF. + expect(arrowsOff(-1, 0, false)).toEqual({ left: true, right: true }); + }); + + it('turns an arrow off exactly when its step leaves the book', () => { + const book = fc + .integer({ min: 1, max: 500 }) + .chain((pages) => fc.tuple(fc.constant(pages), fc.nat({ max: pages - 1 }), fc.boolean())); + fc.assert( + fc.property(book, ([pages, page, rtl]) => { + const off = arrowsOff(page, pages, rtl); + for (const side of ['left', 'right'] as const) { + const to = page + arrowStep(side, rtl); + expect(off[side]).toBe(to < 0 || to >= pages); + } + }), + ); + }); +}); + +describe('parsePage', () => { + it('reads a page number from 1 to the page count', () => { + expect(parsePage('30', 40)).toBe(29); + expect(parsePage(' 1 ', 40)).toBe(0); + expect(parsePage('30', 40)).toBe(29); + }); + + it('gives null for text that is no page of the book', () => { + for (const text of ['', '0', '41', '-3', '2.5', '1e1', 'abc', '3 4']) { + expect(parsePage(text, 40)).toBeNull(); + } + }); + + it('reads back each page number that it can show', () => { + const book = fc + .integer({ min: 1, max: 5000 }) + .chain((pages) => fc.tuple(fc.constant(pages), fc.nat({ max: pages - 1 }))); + fc.assert( + fc.property(book, ([pages, index]) => { + expect(parsePage(String(index + 1), pages)).toBe(index); + }), + ); + }); +}); + +describe('defaultRtl', () => { + it('is right to left for vertical Japanese only', () => { + expect(defaultRtl('jpn_vert')).toBe(true); + expect(defaultRtl('jpn')).toBe(false); + expect(defaultRtl('jpn+eng')).toBe(false); + expect(defaultRtl('eng')).toBe(false); + }); +});