commit 6a212b7f65ed4410dec86ec4fe11c0e898dc87ee truex <truex@equwal.com> 2026-09-26 14:15:03 -0700 Make a tap on the page work like a finger The tap tolerance was 1.5% of the page width, about 6 CSS pixels on a phone. It is now 24 CSS pixels, converted to page pixels with the displayed size of the page and the pinch zoom. A tap on punctuation only sends nothing. A tap on a page that is not read yet tells how many pages are read. When the pause before a lookup fails, the lookup still runs, and the player plays again only when this app paused it. The web has no dictionary app, so the web copies the text to the clipboard itself and shows "Copied" or "Copy failed" in the strip. It does not pause. The Dictionary and "Pause on lookup" settings show only on Android.
index.html | 4 +-- src/hit-test.ts | 12 +++++++++ src/main.ts | 74 ++++++++++++++++++++++++++++++++++++++++++--------- test/hit-test.test.ts | 31 ++++++++++++++++++++- 4 files changed, 105 insertions(+), 16 deletions(-)
diff --git a/index.html b/index.html index a60f7f1..7fcc9df 100644 --- a/index.html +++ b/index.html @@ -81,13 +81,13 @@ ><input id="force-ocr" type="checkbox" /> Force OCR (the text layer of the PDF is wrong)</label > - <label + <label id="dict-row" >Dictionary <select id="dict"> <option value="">Ask each time</option> </select> </label> - <label class="check" + <label id="pause-row" class="check" ><input id="pause-lookup" type="checkbox" checked /> Pause on lookup</label > <div class="row"> diff --git a/src/hit-test.ts b/src/hit-test.ts index ea37133..d528593 100644 --- a/src/hit-test.ts +++ b/src/hit-test.ts @@ -25,6 +25,18 @@ const PUNCTUATION = /^[\p{P}\p{S}]+$/u; /** Text that ends a sentence. No next line follows it in a lookup. */ const SENTENCE_END = /[。.!?.!?」』]$/u; +/** How far from a character a tap may land, in CSS pixels: about the size of a finger. */ +export const TAP_CSS = 24; + +/** + * The tap tolerance in page pixels. The page is `pageWidth` pixels wide + * and shows `shownWidth` CSS pixels wide. A pinch zoom of `zoom` makes a + * CSS pixel bigger on the screen, so the finger covers fewer of them. + */ +export function tapTolerance(pageWidth: number, shownWidth: number, zoom = 1): number { + return (TAP_CSS * pageWidth) / (shownWidth * Math.max(1, zoom)); +} + /** Distance from a point to a box. Zero inside the box. */ function boxDistance(box: OcrToken['bbox'], x: number, y: number): number { const dx = Math.max(box.x0 - x, 0, x - box.x1); diff --git a/src/main.ts b/src/main.ts index a0b27df..1bdd9e3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,11 +6,12 @@ import { createAligner, type Aligner, type OcrToken, type TokenSpan } from './al import { bookText } from './book-text'; import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from './clock-source'; import { cueMoved, follow, type FollowMode } from './follower'; -import { lookupText, tokenAt } from './hit-test'; +import { lookupText, tapTolerance, tokenAt } from './hit-test'; import { lineBoxes } from './line-boxes'; import { clockText, errorMessage, + notReadText, pagesReadText, pdfLoadedText, playerMessage, @@ -36,9 +37,6 @@ import { /** Page width in pixels for OCR and the text layer. Boxes are stored in this scale. */ const OCR_WIDTH = 1600; -/** How far from a character a tap may land, as a share of the page width. */ -const TAP_TOLERANCE = 0.015; - /** The space around a box of the mark, in CSS pixels. */ const BOX_PAD = 2; @@ -71,7 +69,9 @@ const ui = { follow: el<HTMLFieldSetElement>('follow'), lang: el<HTMLSelectElement>('lang'), forceOcr: el<HTMLInputElement>('force-ocr'), + dictRow: el<HTMLLabelElement>('dict-row'), dict: el<HTMLSelectElement>('dict'), + pauseRow: el<HTMLLabelElement>('pause-row'), pauseLookup: el<HTMLInputElement>('pause-lookup'), clearCache: el<HTMLButtonElement>('clear-cache'), cues: el<HTMLOListElement>('cues'), @@ -557,31 +557,76 @@ ui.next.addEventListener('click', () => void showPage(state.currentPage + 1)); // --- Tap on a word --- /** Tap on the page: send the text under the tap to the dictionary. */ -ui.page.addEventListener('click', async (e) => { +ui.page.addEventListener('click', (e) => { const canvas = ui.page.querySelector('canvas'); + const pdf = state.pdf; + if (!canvas || !pdf) return; const size = state.pages[state.currentPage]; - if (!canvas || !size) return; + if (!size) { + say(notReadText(state.pages.filter((p) => p).length, pdf.numPages)); + return; + } const rect = canvas.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * size.width; const y = ((e.clientY - rect.top) / rect.height) * size.height; - const t = tokenAt(state.tokens, state.currentPage, x, y, TAP_TOLERANCE * size.width); + const zoom = window.visualViewport?.scale ?? 1; + const t = tokenAt( + state.tokens, + state.currentPage, + x, + y, + tapTolerance(size.width, rect.width, zoom), + ); if (t < 0) return; const text = lookupText(state.tokens, t); + // A tap on punctuation only gives no text. + if (text === '') return; + void (isAndroid ? lookUp(text) : copy(text)); +}); + +/** The web has no dictionary app: the text goes to the clipboard. */ +async function copy(text: string): Promise<void> { + try { + await navigator.clipboard.writeText(text); + say(`Copied: "${text}"`); + } catch { + say(`Copy failed: "${text}"`); + } +} + +/** + * Sends the text to the dictionary app. With "Pause on lookup", the + * player pauses first and plays again when the dictionary closes. When + * the pause fails, the lookup still runs. + */ +async function lookUp(text: string): Promise<void> { say(`Lookup: "${text}"`); const pause = ui.pauseLookup.checked && state.clock?.playing === true && !state.lookupPaused; - try { - if (pause) { - state.lookupPaused = true; + let paused = false; + if (pause) { + state.lookupPaused = true; + try { await clock.pause(); + paused = true; + } catch (err) { + sayError(err); } - const { closed } = await SubRead.lookup({ text }); - if (closed && state.lookupPaused) await clock.play(); + } + // Play again when the dictionary closed, or when it did not open. + let resume = true; + try { + ({ closed: resume } = await SubRead.lookup({ text })); } catch (err) { say(`Lookup failed: ${String(err)}`); + } + try { + if (paused && resume) await clock.play(); + } catch (err) { + sayError(err); } finally { if (pause) state.lookupPaused = false; } -}); +} // --- Dictionaries (Android) --- @@ -605,4 +650,7 @@ ui.dict.addEventListener('change', () => { loadSettings(); ui.make.hidden = !isAndroid; ui.audioRow.hidden = isAndroid; +// The web copies a lookup at once: there is no dictionary app to choose or to wait for. +ui.dictRow.hidden = !isAndroid; +ui.pauseRow.hidden = !isAndroid; if (isAndroid) void loadDictionaries().catch(sayError); diff --git a/test/hit-test.test.ts b/test/hit-test.test.ts index 5651a86..b629753 100644 --- a/test/hit-test.test.ts +++ b/test/hit-test.test.ts @@ -2,7 +2,14 @@ import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; import type { OcrToken } from '../src/align'; import { isCjk } from '../src/book-text'; -import { LOOKUP_LENGTH, lookupText, scanText, tokenAt } from '../src/hit-test'; +import { + LOOKUP_LENGTH, + lookupText, + scanText, + TAP_CSS, + tapTolerance, + tokenAt, +} from '../src/hit-test'; /** Tokens for one page: `lines` laid out left to right, 10 px per character. */ function tokensOf(lines: string[], page = 0): OcrToken[] { @@ -51,6 +58,28 @@ describe('tokenAt', () => { }); }); +describe('tapTolerance', () => { + it('converts the CSS pixels of a finger to page pixels', () => { + expect(tapTolerance(1600, 400)).toBe(TAP_CSS * 4); + expect(tapTolerance(1600, 800)).toBe(TAP_CSS * 2); + }); + + it('gets smaller when the page is zoomed in', () => { + expect(tapTolerance(1600, 400, 2)).toBe(TAP_CSS * 2); + expect(tapTolerance(1600, 400, 0.5)).toBe(TAP_CSS * 4); + }); + + it('finds a character 20 CSS pixels from a tap on a phone', () => { + // A 1600-pixel page on a phone, 344 CSS pixels wide. The old tolerance, + // 1.5% of the page width, was 24 page pixels: about 5 CSS pixels. + const page = tokensOf(['abc']); + const scale = 1600 / 344; + const x = page[2]!.bbox.x1 + 20 * scale; + expect(tokenAt(page, 0, x, 10, 0.015 * 1600)).toBe(-1); + expect(tokenAt(page, 0, x, 10, tapTolerance(1600, 344))).toBe(2); + }); +}); + /** Tokens of one line of words, with a word id per word. Spaces are not tokens. */ function wordTokens(words: string[], page = 0, line = 0): OcrToken[] { const tokens: OcrToken[] = [];