commit 7fd00df8862e4a63456de44f2c94845edf735c95
truex <truex@equwal.com>
2026-09-26 19:01:46 -0700
Make an Anki card from a long press on a word
A press of 500 ms on the page, without a move of more than 10 CSS
pixels, sends a card to SubRead Anki. nav.ts gives the long press from
the same pointer events as the swipe, so a tap, a swipe and a long
press never mix: a move stops the long press, a long press is no
swipe, and the click after a long press does no lookup. The page shows
no context menu and no selection for the press.
ankiCard makes the card from the tokens:
- The sentence is the text of the marked cue when the word is in the
span of that cue, else sentenceAround.
- A CJK token gives the text only: OCR does not show where a Japanese
word ends, so the user taps the word in SubRead Anki.
- A Latin token also gives the word: the tokens of its word id without
the punctuation at the edges ("hills," gives "hills").
- The source is "<book>, p. <page>".
"Pause on lookup" pauses the player while the card shows (withPause,
now shared with the lookup). The strip says "Card added to Anki.", the
SubRead Anki link, or "SubRead Anki could not start."; a closed card
says nothing. The web says "Anki cards need SubRead Anki on Android."
The hint of the menu names the gesture.
index.html | 4 +-
src/anki-card.ts | 76 ++++++++++++++++++++
src/main.ts | 113 ++++++++++++++++++++++--------
src/messages.ts | 17 +++++
src/nav.ts | 74 +++++++++++++++++---
src/paging.ts | 11 +++
src/style.css | 4 ++
src/suite.ts | 12 ++--
test/anki-card.test.ts | 185 +++++++++++++++++++++++++++++++++++++++++++++++++
test/messages.test.ts | 22 ++++++
test/paging.test.ts | 21 ++++++
test/suite.test.ts | 9 ++-
12 files changed, 503 insertions(+), 45 deletions(-)
diff --git a/index.html b/index.html
index d316283..2d84221 100644
--- a/index.html
+++ b/index.html
@@ -75,8 +75,8 @@
<aside id="panel">
<h1>Scan Subread</h1>
<p class="hint">
- Load a PDF and the subtitles of its audiobook. The page follows the player. Tap a word to
- look it up.
+ Load a PDF and the subtitles of its audiobook. The page follows the player. Tap a word:
+ dictionary. Long press: Anki card.
</p>
<label>PDF (scan or text) <input id="pdf-file" type="file" accept="application/pdf" /></label>
diff --git a/src/anki-card.ts b/src/anki-card.ts
new file mode 100644
index 0000000..f4297ef
--- /dev/null
+++ b/src/anki-card.ts
@@ -0,0 +1,76 @@
+// The fields of a SubRead Anki card for a long press on the page. Pure: no DOM.
+
+import type { OcrToken, TokenSpan } from './align';
+import { isCjk, joinWords } from './book-text';
+import { sentenceAround } from './sentence';
+import type { AnkiCard } from './subread';
+
+/** Punctuation and symbols at the start and at the end of a word. */
+const EDGES = /^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu;
+
+/**
+ * The word of token `index`: the text of the tokens with its page, line
+ * and word id, without punctuation and symbols at its edges. "hills,"
+ * gives "hills", and "don't" stays "don't".
+ */
+export function wordAt(tokens: readonly OcrToken[], index: number): string {
+ const token = tokens[index];
+ if (!token) return '';
+ const same = (t: OcrToken | undefined): boolean =>
+ t !== undefined && t.page === token.page && t.line === token.line && t.word === token.word;
+ let start = index;
+ let end = index + 1;
+ while (same(tokens[start - 1])) start--;
+ while (same(tokens[end])) end++;
+ return tokens
+ .slice(start, end)
+ .map((t) => t.text)
+ .join('')
+ .replace(EDGES, '');
+}
+
+/** Where the sentence of a card is from: "<book name without .pdf>, p. <page number>". */
+export function cardSource(bookName: string, page: number): string {
+ return `${bookName.replace(/\.pdf$/i, '')}, p. ${page + 1}`;
+}
+
+export interface CardInput {
+ tokens: readonly OcrToken[];
+ /** The pressed token. */
+ index: number;
+ /** The span of each cue, from the alignment. */
+ spans: readonly TokenSpan[];
+ /** The cue with the mark on the page, or -1. */
+ markedCue: number;
+ /** The texts of the cues. */
+ cues: readonly { text: string }[];
+ /** Where the sentence is from: see cardSource. */
+ source: string;
+}
+
+/**
+ * The card for a long press on token `index`, or null when there is no
+ * such token.
+ *
+ * - The sentence is the text of the marked cue when the token is in the
+ * span of that cue, else the sentence around the token (sentenceAround).
+ * - A CJK token gives `{ text, source }`. OCR does not show where a
+ * Japanese word ends, so SubRead Anki shows the sentence, and the user
+ * taps the word.
+ * - Another token gives `{ word, sentence, source }`. A token of
+ * punctuation only has no word: it gives `{ text, source }`.
+ */
+export function ankiCard(input: CardInput): AnkiCard | null {
+ const { tokens, index, source } = input;
+ const token = tokens[index];
+ if (!token) return null;
+ const span = input.spans[input.markedCue];
+ const cue = input.cues[input.markedCue];
+ const inCue = !!span && !!cue && span.matched && span.start <= index && index < span.end;
+ const sentence = inCue
+ ? joinWords(cue.text.split('\n').map((line) => line.trim()))
+ : sentenceAround(tokens, index);
+ if (isCjk(token.text)) return { text: sentence, source };
+ const word = wordAt(tokens, index);
+ return word ? { word, sentence, source } : { text: sentence, source };
+}
diff --git a/src/main.ts b/src/main.ts
index d7559cf..f3ab66a 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -7,6 +7,7 @@
import { App } from '@capacitor/app';
import { createAligner, type Aligner, type OcrToken, type TokenSpan } from './align';
+import { ankiCard, cardSource } from './anki-card';
import { backStep, keepScreenOn } from './app-state';
import { resultName, srtName, subreadConcerns, subtitlesOnOpen } from './book-subtitles';
import { bookText } from './book-text';
@@ -17,6 +18,8 @@ import { follow, type FollowEvent, type FollowMode } from './follower';
import { lookupText, tapTolerance, tokenAt } from './hit-test';
import { lineBoxes, padBox } from './line-boxes';
import {
+ ANKI_WEB_TEXT,
+ ankiText,
CLEAR_ALL_QUESTION,
clearBookQuestion,
clockText,
@@ -50,7 +53,7 @@ import type { PdfDoc } from './pdf';
import { nextPage } from './read-order';
import { scrollTarget } from './scroll';
import { fill, onAction, say, showPlayer, showReading } from './status';
-import { isAndroid, SubRead, type SubtitlesResult, type SuiteApps } from './subread';
+import { isAndroid, SubRead, type AnkiCard, type SubtitlesResult, type SuiteApps } from './subread';
import { suiteChecklist } from './suite';
import { decodeSubtitles, lastCueAt, parseSubtitles, type Cue } from './subtitles';
import {
@@ -201,6 +204,7 @@ const nav = setupNav(
drawerOpen: drawer.covers,
},
turnByUser,
+ longPress,
);
// --- Settings ---
@@ -1243,34 +1247,79 @@ async function showPage(index: number): Promise<void> {
// --- Tap on a word ---
-/** Tap on the page: send the text under the tap to the dictionary. */
-ui.page.addEventListener('click', (e) => {
+/**
+ * The token under a point of the screen, in client pixels: an index of
+ * state.tokens, or -1 when no token is near. Null when the point is not on
+ * the page, or the page is not read yet: then the strip tells how many
+ * pages are read.
+ */
+function tokenAtPoint(clientX: number, clientY: number): number | null {
const canvas = ui.page.querySelector('canvas');
const pdf = state.pdf;
- if (!canvas || !pdf) return;
+ if (!canvas || !pdf || ui.page.hidden) return null;
+ const rect = canvas.getBoundingClientRect();
+ const inside =
+ clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
+ if (!inside) return null;
const size = state.pages[state.currentPage];
if (!size) {
say(notReadText(state.pages.filter((p) => p).length, pdf.numPages));
- return;
+ return null;
}
- 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 x = ((clientX - rect.left) / rect.width) * size.width;
+ const y = ((clientY - rect.top) / rect.height) * size.height;
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;
+ return tokenAt(state.tokens, state.currentPage, x, y, tapTolerance(size.width, rect.width, zoom));
+}
+
+/** Tap on the page: send the text under the tap to the dictionary. */
+ui.page.addEventListener('click', (e) => {
+ const t = tokenAtPoint(e.clientX, e.clientY);
+ if (t === null || 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));
});
+/**
+ * Long press on the page: a card in SubRead Anki for the word and its
+ * sentence (ankiCard). Gives true when the press was on a word, so that
+ * the click after it does no lookup.
+ */
+function longPress(clientX: number, clientY: number): boolean {
+ const index = tokenAtPoint(clientX, clientY);
+ const file = state.file;
+ if (index === null || index < 0 || !file) return false;
+ if (!isAndroid) {
+ say(ANKI_WEB_TEXT);
+ return true;
+ }
+ const card = ankiCard({
+ tokens: state.tokens,
+ index,
+ spans: state.spans,
+ markedCue: state.markedCue,
+ cues: state.cues,
+ source: cardSource(file.name, state.tokens[index]!.page),
+ });
+ if (card) void addCard(card);
+ return true;
+}
+
+/** Sends the card to SubRead Anki. "Pause on lookup" pauses the player while the card shows. */
+async function addCard(card: AnkiCard): Promise<void> {
+ await withPause(async () => {
+ try {
+ const m = ankiText(await SubRead.ankiAdd(card));
+ if (m) say(m);
+ } catch (err) {
+ say(`Anki card failed: ${String(err)}`);
+ }
+ return true;
+ });
+}
+
/** The web has no dictionary app: the text goes to the clipboard. */
async function copy(text: string): Promise<void> {
try {
@@ -1281,13 +1330,27 @@ async function copy(text: string): Promise<void> {
}
}
-/**
- * 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.
- */
+/** Sends the text to the dictionary app, with "Pause on lookup". */
async function lookUp(text: string): Promise<void> {
say(`Lookup: "${text}"`);
+ await withPause(async () => {
+ // Play again when the dictionary closed, or when it did not open.
+ try {
+ return (await SubRead.lookup({ text })).closed;
+ } catch (err) {
+ say(`Lookup failed: ${String(err)}`);
+ return true;
+ }
+ });
+}
+
+/**
+ * Runs `open`, an app over the reader: the dictionary or SubRead Anki.
+ * With "Pause on lookup", the player pauses first, and plays again when
+ * `open` gives true: the app closed. When the pause fails, `open` still
+ * runs.
+ */
+async function withPause(open: () => Promise<boolean>): Promise<void> {
const pause = ui.pauseLookup.checked && state.clock?.playing === true && !state.lookupPaused;
let paused = false;
if (pause) {
@@ -1299,13 +1362,7 @@ async function lookUp(text: string): Promise<void> {
sayError(err);
}
}
- // 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)}`);
- }
+ const resume = await open();
try {
if (paused && resume) await clock.play();
} catch (err) {
diff --git a/src/messages.ts b/src/messages.ts
index fec7795..b745df4 100644
--- a/src/messages.ts
+++ b/src/messages.ts
@@ -14,6 +14,23 @@ export interface Message {
export const OVERLAY_RELEASES = 'https://github.com/equwal/subread-overlay/releases/latest';
export const SUBREAD_RELEASES = 'https://github.com/equwal/subread-android/releases/latest';
+export const DICTIONARY_RELEASES = 'https://github.com/equwal/subread-dictionary/releases/latest';
+export const ANKI_RELEASES = 'https://github.com/equwal/subread-anki/releases/latest';
+
+/** A long press on the web: there is no SubRead Anki. */
+export const ANKI_WEB_TEXT = 'Anki cards need SubRead Anki on Android.';
+
+/** The result of SubRead Anki in the strip. Null when the user closed the card. */
+export function ankiText(result: { added?: boolean; error?: string }): Message | null {
+ if (result.error === 'not_installed') {
+ return {
+ text: 'SubRead Anki is not installed.',
+ link: { href: ANKI_RELEASES, text: 'Get SubRead Anki' },
+ };
+ }
+ if (result.error !== undefined) return { text: 'SubRead Anki could not start.' };
+ return result.added ? { text: 'Card added to Anki.' } : null;
+}
/** "1 page", "2 pages". */
function count(n: number, noun: string): string {
diff --git a/src/nav.ts b/src/nav.ts
index 48f6c88..a5a2042 100644
--- a/src/nav.ts
+++ b/src/nav.ts
@@ -1,11 +1,21 @@
// 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.
+// the viewer, and the page jump of the page label. Also the long press on
+// the viewer, because it shares the pointer events of the swipe. The
+// decisions are in paging.ts.
-import { arrowsOff, arrowStep, keyStep, parsePage, swipeStep, type Step } from './paging';
+import {
+ arrowsOff,
+ arrowStep,
+ keyStep,
+ LONG_PRESS_MS,
+ movedTooFar,
+ 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. */
+/** A click this soon after the end of a swipe or a long press comes from it, 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. */
@@ -46,8 +56,17 @@ 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 {
+/**
+ * `turnTo(index)` shows the page that the user asked for. `longPress(x, y)`
+ * gets a long press on the viewer, in client pixels, and gives true when
+ * it used it. Then the click that follows does nothing.
+ */
+export function setupNav(
+ ui: NavElements,
+ s: NavState,
+ turnTo: (index: number) => void,
+ longPress: (x: number, y: number) => boolean,
+): Nav {
function go(step: Step): void {
const to = s.current() + step;
if (to >= 0 && to < s.pages()) turnTo(to);
@@ -92,20 +111,57 @@ export function setupNav(ui: NavElements, s: NavState, turnTo: (index: number) =
window.visualViewport?.addEventListener('resize', markZoom);
markZoom();
- let start: { id: number; x: number; y: number } | null = null;
+ // One finger on the viewer gives a tap, a swipe or a long press, never
+ // two of them. A move of more than LONG_PRESS_MOVE stops the long press,
+ // and a long press is no swipe.
+
+ /** The finger on the viewer: where it went down, and true after its long press. */
+ let start: { id: number; x: number; y: number; pressed: boolean } | null = null;
+ let pressTimer: ReturnType<typeof setTimeout> | undefined;
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;
+ clearTimeout(pressTimer);
+ // A second finger makes a pinch. The right mouse button opens the menu of the browser.
+ if (!e.isPrimary || e.button !== 0) {
+ start = null;
+ return;
+ }
+ const finger = { id: e.pointerId, x: e.clientX, y: e.clientY, pressed: false };
+ start = finger;
+ pressTimer = setTimeout(() => {
+ if (start !== finger || s.drawerOpen()) return;
+ finger.pressed = longPress(finger.x, finger.y);
+ }, LONG_PRESS_MS);
+ });
+ ui.viewer.addEventListener('pointermove', (e) => {
+ if (
+ start &&
+ e.pointerId === start.id &&
+ movedTooFar(e.clientX - start.x, e.clientY - start.y)
+ ) {
+ clearTimeout(pressTimer);
+ }
});
ui.viewer.addEventListener('pointercancel', () => {
+ clearTimeout(pressTimer);
start = null;
});
+ // Android shows a menu for a long press on the page. The long press makes a card instead.
+ ui.viewer.addEventListener('contextmenu', (e) => {
+ if (start) e.preventDefault();
+ });
ui.viewer.addEventListener('pointerup', (e) => {
if (!start || e.pointerId !== start.id) return;
+ clearTimeout(pressTimer);
const dx = e.clientX - start.x;
const dy = e.clientY - start.y;
+ const pressed = start.pressed;
start = null;
+ // The click after a long press is not a tap on a word.
+ if (pressed) {
+ swipedAt = e.timeStamp;
+ return;
+ }
if (s.drawerOpen()) return;
const v = ui.viewer;
const step = swipeStep(
diff --git a/src/paging.ts b/src/paging.ts
index 5946d3e..9a8c257 100644
--- a/src/paging.ts
+++ b/src/paging.ts
@@ -12,6 +12,17 @@ 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;
+/** A press this long, in milliseconds, without a move is a long press: an Anki card. */
+export const LONG_PRESS_MS = 500;
+
+/** A finger that moves more than this, in CSS pixels, makes no long press. */
+export const LONG_PRESS_MOVE = 10;
+
+/** True when the finger moved too far for a long press. Such a move can be a swipe. */
+export function movedTooFar(dx: number, dy: number): boolean {
+ return Math.hypot(dx, dy) > LONG_PRESS_MOVE;
+}
+
/** 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) {
diff --git a/src/style.css b/src/style.css
index d8ad45d..b4d0d1a 100644
--- a/src/style.css
+++ b/src/style.css
@@ -412,6 +412,10 @@ body.zoomed #viewer {
margin: 0 auto;
width: 100%;
max-width: 1000px;
+ /* A long press makes an Anki card: no selection, no image menu. */
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-touch-callout: none;
}
#page canvas {
diff --git a/src/suite.ts b/src/suite.ts
index a4328ce..40f8656 100644
--- a/src/suite.ts
+++ b/src/suite.ts
@@ -1,12 +1,16 @@
// The checklist of the SubRead suite: which apps are on the device, and
// what to do for an app that is missing. Pure: no DOM.
-import { OPEN_OVERLAY, OVERLAY_RELEASES, SUBREAD_RELEASES, type Message } from './messages';
+import {
+ ANKI_RELEASES,
+ DICTIONARY_RELEASES,
+ OPEN_OVERLAY,
+ OVERLAY_RELEASES,
+ SUBREAD_RELEASES,
+ type Message,
+} from './messages';
import type { SuiteApps } from './subread';
-export const DICTIONARY_RELEASES = 'https://github.com/equwal/subread-dictionary/releases/latest';
-export const ANKI_RELEASES = 'https://github.com/equwal/subread-anki/releases/latest';
-
/** One line of the checklist. `ok` is true when the app is ready. */
export interface ChecklistItem extends Message {
ok: boolean;
diff --git a/test/anki-card.test.ts b/test/anki-card.test.ts
new file mode 100644
index 0000000..82472ed
--- /dev/null
+++ b/test/anki-card.test.ts
@@ -0,0 +1,185 @@
+import fc from 'fast-check';
+import { describe, expect, it } from 'vitest';
+import type { OcrToken, TokenSpan } from '../src/align';
+import { ankiCard, cardSource, wordAt } from '../src/anki-card';
+
+const box = { x0: 0, y0: 0, x1: 1, y1: 1 };
+
+/** Tokens for one page, one per character: each line is a list of words. */
+function pageTokens(lines: readonly (readonly string[])[], page = 0): OcrToken[] {
+ const out: OcrToken[] = [];
+ lines.forEach((words, li) => {
+ words.forEach((word, wi) => {
+ for (const ch of word) {
+ out.push({
+ text: ch,
+ page,
+ line: page * 1000 + li,
+ word: page * 1000 + li * 100 + wi,
+ bbox: box,
+ });
+ }
+ });
+ });
+ return out;
+}
+
+/** The index of token number `n` (from 0) with the text `ch`. */
+function find(tokens: readonly OcrToken[], ch: string, n = 0): number {
+ let seen = 0;
+ for (let i = 0; i < tokens.length; i++) {
+ if (tokens[i]!.text !== ch) continue;
+ if (seen === n) return i;
+ seen++;
+ }
+ throw new Error(`No token ${ch} number ${n}.`);
+}
+
+// The first page of sample-eng-2p: two cues on two lines.
+const eng = pageTokens([
+ ['The', 'morning', 'sun', 'rose', 'over', 'the', 'quiet', 'hills,'],
+ ['and', 'the', 'village', 'below', 'began', 'to', 'stir.'],
+]);
+const engCues = [
+ { text: 'The morning sun rose over the quiet hills,' },
+ { text: 'and the village below began to stir.' },
+];
+const firstLine = eng.filter((t) => t.line === 0).length;
+const engSpans: TokenSpan[] = [
+ { start: 0, end: firstLine, matched: true },
+ { start: firstLine, end: eng.length, matched: true },
+];
+
+// sample-jpn: one OCR word for a whole line, as Japanese OCR gives.
+const jpn = pageTokens([['吾輩は猫である。名前はまだ無い。']]);
+const jpnCues = [{ text: '吾輩は猫である。' }, { text: '名前はまだ無い。' }];
+const jpnSpans: TokenSpan[] = [
+ { start: 0, end: 8, matched: true },
+ { start: 8, end: 16, matched: true },
+];
+
+const source = 'sample-eng-2p, p. 1';
+
+describe('ankiCard', () => {
+ it('gives the word without its comma, and the marked cue as the sentence', () => {
+ const card = ankiCard({
+ tokens: eng,
+ index: find(eng, ','),
+ spans: engSpans,
+ markedCue: 0,
+ cues: engCues,
+ source,
+ });
+ expect(card).toEqual({
+ word: 'hills',
+ sentence: 'The morning sun rose over the quiet hills,',
+ source,
+ });
+ });
+
+ it('gives the sentence around the token when the token is not in the marked cue', () => {
+ const input = { tokens: eng, spans: engSpans, cues: engCues, source };
+ // The first "v" is in "over".
+ const village = find(eng, 'v', 1);
+ const sentence =
+ 'The morning sun rose over the quiet hills, and the village below began to stir.';
+ expect(ankiCard({ ...input, index: village, markedCue: 0 })).toEqual({
+ word: 'village',
+ sentence,
+ source,
+ });
+ expect(ankiCard({ ...input, index: village, markedCue: -1 })).toEqual({
+ word: 'village',
+ sentence,
+ source,
+ });
+ const unmatched = [engSpans[0]!, { start: 0, end: 0, matched: false }];
+ expect(ankiCard({ ...input, spans: unmatched, index: village, markedCue: 1 })).toEqual({
+ word: 'village',
+ sentence,
+ source,
+ });
+ });
+
+ it('gives a CJK token the text only, so the user taps the word in SubRead Anki', () => {
+ const input = { tokens: jpn, spans: jpnSpans, cues: jpnCues, source: 'sample-jpn, p. 1' };
+ expect(ankiCard({ ...input, index: find(jpn, '猫'), markedCue: 0 })).toEqual({
+ text: '吾輩は猫である。',
+ source: 'sample-jpn, p. 1',
+ });
+ expect(ankiCard({ ...input, index: find(jpn, '前'), markedCue: -1 })).toEqual({
+ text: '名前はまだ無い。',
+ source: 'sample-jpn, p. 1',
+ });
+ });
+
+ it('joins the lines of a cue: a space between Latin lines, none between CJK lines', () => {
+ const cues = [{ text: 'was clear and bright,\nover the hills.' }];
+ const spans: TokenSpan[] = [{ start: 0, end: eng.length, matched: true }];
+ expect(ankiCard({ tokens: eng, index: 0, spans, markedCue: 0, cues, source })?.sentence).toBe(
+ 'was clear and bright, over the hills.',
+ );
+ const jpnCue = [{ text: '吾輩は猫\nである。' }];
+ const all: TokenSpan[] = [{ start: 0, end: jpn.length, matched: true }];
+ expect(
+ ankiCard({ tokens: jpn, index: 0, spans: all, markedCue: 0, cues: jpnCue, source })?.text,
+ ).toBe('吾輩は猫である。');
+ });
+
+ it('gives the text only for a token of punctuation with no word', () => {
+ const dash = pageTokens([['Wait', '—', 'no.']]);
+ const card = ankiCard({
+ tokens: dash,
+ index: find(dash, '—'),
+ spans: [],
+ markedCue: -1,
+ cues: [],
+ source,
+ });
+ expect(card).toEqual({ text: 'Wait — no.', source });
+ });
+
+ it('gives null for a token that does not exist', () => {
+ const input = { tokens: eng, spans: engSpans, markedCue: 0, cues: engCues, source };
+ expect(ankiCard({ ...input, index: eng.length })).toBeNull();
+ expect(ankiCard({ ...input, index: -1 })).toBeNull();
+ });
+});
+
+describe('wordAt', () => {
+ it('keeps the punctuation inside a word', () => {
+ const tokens = pageTokens([['"don\'t"', 'go.']]);
+ expect(wordAt(tokens, find(tokens, 'd'))).toBe("don't");
+ expect(wordAt(tokens, find(tokens, '"', 1))).toBe("don't");
+ });
+
+ it('gives a word with no punctuation at its edges, from the letters of its line', () => {
+ const ch = fc.constantFrom('a', 'Z', '7', ',', '.', '"', '!', '(', ')', "'", '-');
+ const word = fc.array(ch, { minLength: 1, maxLength: 6 }).map((cs) => cs.join(''));
+ const lines = fc.array(fc.array(word, { minLength: 1, maxLength: 4 }), {
+ minLength: 1,
+ maxLength: 3,
+ });
+ fc.assert(
+ fc.property(lines, fc.nat(), (text, pick) => {
+ const tokens = pageTokens(text);
+ const index = pick % tokens.length;
+ const got = wordAt(tokens, index);
+ expect(got).not.toMatch(/^[\p{P}\p{S}]|[\p{P}\p{S}]$/u);
+ const t = tokens[index]!;
+ const own = tokens
+ .filter((o) => o.line === t.line && o.word === t.word)
+ .map((o) => o.text)
+ .join('');
+ expect(own).toContain(got);
+ }),
+ );
+ });
+});
+
+describe('cardSource', () => {
+ it('names the book without .pdf and the page from 1', () => {
+ expect(cardSource('sample-eng-2p.pdf', 0)).toBe('sample-eng-2p, p. 1');
+ expect(cardSource('吾輩は猫である.PDF', 9)).toBe('吾輩は猫である, p. 10');
+ });
+});
diff --git a/test/messages.test.ts b/test/messages.test.ts
index 54bb67e..98d160b 100644
--- a/test/messages.test.ts
+++ b/test/messages.test.ts
@@ -2,6 +2,9 @@ import fc from 'fast-check';
import { describe, expect, it } from 'vitest';
import { PlayerError, type ClockState } from '../src/clock-source';
import {
+ ANKI_RELEASES,
+ ANKI_WEB_TEXT,
+ ankiText,
clockText,
errorMessage,
matchedText,
@@ -207,6 +210,25 @@ describe('the end of the reading', () => {
});
});
+describe('ankiText', () => {
+ it('tells the result of SubRead Anki', () => {
+ expect(ankiText({ added: true })).toEqual({ text: 'Card added to Anki.' });
+ expect(ankiText({ error: 'not_installed' })).toEqual({
+ text: 'SubRead Anki is not installed.',
+ link: { href: ANKI_RELEASES, text: 'Get SubRead Anki' },
+ });
+ expect(ankiText({ error: 'cannot_start' })).toEqual({ text: 'SubRead Anki could not start.' });
+ });
+
+ it('says nothing when the user closed the card', () => {
+ expect(ankiText({ added: false })).toBeNull();
+ });
+
+ it('tells the web that cards need Android', () => {
+ expect(ANKI_WEB_TEXT).toBe('Anki cards need SubRead Anki on Android.');
+ });
+});
+
describe('SubRead texts', () => {
it('tells how far the reading is while SubRead waits', () => {
expect(waitPagesText(3, 40)).toBe('Reading pages 3/40 first...');
diff --git a/test/paging.test.ts b/test/paging.test.ts
index 62f3be9..95887b1 100644
--- a/test/paging.test.ts
+++ b/test/paging.test.ts
@@ -5,6 +5,8 @@ import {
arrowStep,
defaultRtl,
keyStep,
+ LONG_PRESS_MOVE,
+ movedTooFar,
parsePage,
SWIPE_MIN,
SWIPE_RATIO,
@@ -14,6 +16,25 @@ import {
const flat = { zoomed: false, wide: false };
+describe('movedTooFar', () => {
+ it('lets a finger shake up to 10 CSS pixels in a long press', () => {
+ expect(LONG_PRESS_MOVE).toBe(10);
+ expect(movedTooFar(0, 0)).toBe(false);
+ expect(movedTooFar(6, -8)).toBe(false);
+ expect(movedTooFar(7, 8)).toBe(true);
+ });
+
+ it('never gives a long press for a swipe that turns a page', () => {
+ // A swipe, a tap and a long press never mix.
+ const move = fc.integer({ min: -400, max: 400 });
+ fc.assert(
+ fc.property(move, move, fc.boolean(), (dx, dy, rtl) => {
+ if (swipeStep({ dx, dy, ...flat }, rtl) !== 0) expect(movedTooFar(dx, dy)).toBe(true);
+ }),
+ );
+ });
+});
+
describe('keyStep', () => {
it('turns with the arrow keys and the page keys', () => {
expect(keyStep('ArrowRight', false)).toBe(1);
diff --git a/test/suite.test.ts b/test/suite.test.ts
index b9d04ce..d6b0e4e 100644
--- a/test/suite.test.ts
+++ b/test/suite.test.ts
@@ -1,7 +1,12 @@
import { describe, expect, it } from 'vitest';
-import { OVERLAY_RELEASES, SUBREAD_RELEASES } from '../src/messages';
+import {
+ ANKI_RELEASES,
+ DICTIONARY_RELEASES,
+ OVERLAY_RELEASES,
+ SUBREAD_RELEASES,
+} from '../src/messages';
import type { SuiteApps } from '../src/subread';
-import { ANKI_RELEASES, DICTIONARY_RELEASES, suiteChecklist } from '../src/suite';
+import { suiteChecklist } from '../src/suite';
const openOverlay = { id: 'open-overlay', text: 'Open SubRead Overlay' };