Skip to content

Commit e70820c

Browse files
Eliott Gandiolleclaude
andcommitted
Add Bionic Reading toggle and surface OCR progress phase
- Bionic Reading: bold the leading portion of each word, persisted in localStorage with a toolbar toggle. - Show the reflow progress phase label (e.g. "Reading scanned pages (OCR)…"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 31ac44a commit e70820c

3 files changed

Lines changed: 89 additions & 3 deletions

File tree

src/App.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
22
import { reflowPdf, type ReflowPage } from "./pdf";
33
import { fetchPdf } from "./fetchPdf";
4+
import { bionic } from "./bionic";
45
import { Lightbox } from "./Lightbox";
56
import { ReadingProgress } from "./ReadingProgress";
67

@@ -9,6 +10,7 @@ const DEFAULT_URL =
910

1011
const LAST_URL_KEY = "paper-reader:last-url";
1112
const FONT_SIZE_KEY = "paper-reader:font-size";
13+
const BIONIC_KEY = "paper-reader:bionic";
1214

1315
// Reader font size in px. Generous default; clamp keeps the line measure sane.
1416
const DEFAULT_FONT = 21;
@@ -37,6 +39,26 @@ export function App() {
3739
}
3840
return DEFAULT_FONT;
3941
});
42+
const [bionicOn, setBionicOn] = useState(() => {
43+
try {
44+
return localStorage.getItem(BIONIC_KEY) === "1";
45+
} catch {
46+
return false;
47+
}
48+
});
49+
50+
// Persist the Bionic Reading preference across visits.
51+
const toggleBionic = useCallback(() => {
52+
setBionicOn((prev) => {
53+
const next = !prev;
54+
try {
55+
localStorage.setItem(BIONIC_KEY, next ? "1" : "0");
56+
} catch {
57+
/* ignore */
58+
}
59+
return next;
60+
});
61+
}, []);
4062

4163
// Persist the reader's chosen font size across visits.
4264
const changeFont = useCallback((delta: number) => {
@@ -60,8 +82,8 @@ export function App() {
6082
const buf = await fetchPdf(clean);
6183
setStatus({ state: "loading", done: 0, total: 0, phase: "Parsing pages…" });
6284
const result = await reflowPdf(buf, {
63-
onProgress: (done, total) =>
64-
setStatus({ state: "loading", done, total, phase: "Reflowing pages…" }),
85+
onProgress: (done, total, phase) =>
86+
setStatus({ state: "loading", done, total, phase: phase ?? "Reflowing pages…" }),
6587
});
6688
setPages(result);
6789
setLoadedUrl(clean);
@@ -164,6 +186,14 @@ export function App() {
164186
A
165187
</button>
166188
</div>
189+
<button
190+
className={`bionic-toggle${bionicOn ? " on" : ""}`}
191+
onClick={toggleBionic}
192+
aria-pressed={bionicOn}
193+
title="Bionic Reading — bold the start of each word to guide the eye"
194+
>
195+
<b>Bio</b>nic
196+
</button>
167197
</div>
168198
{status.state === "loading" && (
169199
<div className="progress">
@@ -199,7 +229,8 @@ export function App() {
199229
</figure>
200230
);
201231
}
202-
return b.level === "h" ? <h2 key={key}>{b.text}</h2> : <p key={key}>{b.text}</p>;
232+
const content = bionicOn ? bionic(b.text) : b.text;
233+
return b.level === "h" ? <h2 key={key}>{content}</h2> : <p key={key}>{content}</p>;
203234
})
204235
)}
205236
</article>

src/bionic.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Fragment, type ReactNode } from "react";
2+
3+
// "Bionic Reading": bold the leading portion of each word so the eye fixates on
4+
// it and the brain fills in the rest, which many readers find faster and less
5+
// tiring. Letters only — punctuation, spaces and hyphens split words (so
6+
// "in-depth" becomes two fixations: "in" + "depth"), apostrophes don't
7+
// ("don't" stays one word).
8+
const WORD_RE = /\p{L}[\p{L}\p{M}']*/gu;
9+
10+
// Bold the first half of the word, rounding up. Matches the canonical look:
11+
// focusing→focu, brain→bra, understanding→underst, of→o, the→th.
12+
const boldLength = (len: number) => Math.ceil(len / 2);
13+
14+
export function bionic(text: string): ReactNode[] {
15+
const out: ReactNode[] = [];
16+
let last = 0;
17+
let key = 0;
18+
WORD_RE.lastIndex = 0;
19+
let m: RegExpExecArray | null;
20+
while ((m = WORD_RE.exec(text))) {
21+
if (m.index > last) out.push(text.slice(last, m.index));
22+
const word = m[0];
23+
const head = boldLength(word.length);
24+
out.push(
25+
<Fragment key={key++}>
26+
<b className="bionic-fix">{word.slice(0, head)}</b>
27+
{word.slice(head)}
28+
</Fragment>
29+
);
30+
last = m.index + word.length;
31+
}
32+
if (last < text.length) out.push(text.slice(last));
33+
return out;
34+
}

src/styles.css

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,27 @@ html, body {
7878
.fontsize button:disabled { opacity: 0.35; cursor: default; }
7979
.fontsize .small { font-size: 13px; }
8080
.fontsize .big { font-size: 18px; }
81+
82+
/* ---- Bionic Reading toggle ---- */
83+
.bionic-toggle {
84+
border: 1px solid var(--line);
85+
border-radius: 8px;
86+
background: #fff;
87+
color: var(--ink);
88+
cursor: pointer;
89+
padding: 8px 12px;
90+
font-size: 14px;
91+
white-space: nowrap;
92+
}
93+
.bionic-toggle b { font-weight: 700; }
94+
.bionic-toggle:hover { background: #f3f4f6; }
95+
.bionic-toggle.on {
96+
background: var(--accent);
97+
border-color: var(--accent);
98+
color: #fff;
99+
}
100+
/* The bolded fixation head; slightly heavier than surrounding text. */
101+
.bionic-fix { font-weight: 600; }
81102
.progress {
82103
max-width: 980px;
83104
margin: 0 auto;

0 commit comments

Comments
 (0)