Skip to content

Commit c39e685

Browse files
committed
Add URL hash jumping by position and sentence
1 parent 69e4fde commit c39e685

4 files changed

Lines changed: 55 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
### 2026-04-25: 1.3.0
2+
3+
* Jump to word position by changing URL hash to `#pos=N`
4+
* Jump to sentence by changing URL hash to `#sentence=N`
5+
16
### 2026-03-28: 1.2.0
27

38
* Store book text in IndexedDB to fix localStorage quota exceeded on iOS

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ⚡ Speed reader
22

3-
![Version](https://img.shields.io/badge/version-1.2.0-blue?style=for-the-badge)
3+
![Version](https://img.shields.io/badge/version-1.3.0-blue?style=for-the-badge)
44
![React](https://img.shields.io/badge/React-61DAFB?style=for-the-badge&logo=react&logoColor=black)
55
![Vite](https://img.shields.io/badge/Vite-646CFF?style=for-the-badge&logo=vite&logoColor=white)
66
![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "speed-reader",
33
"private": true,
4-
"version": "1.2.0",
4+
"version": "1.3.0",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src/App.jsx

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,36 @@ function parseText(text) {
287287
return { words, breaks };
288288
}
289289

290+
// Word indices where sentences start (0 is always first sentence)
291+
function getSentenceStarts(words) {
292+
const starts = [0];
293+
for (let i = 0; i < words.length - 1; i++) {
294+
if (/[.!?]["')\]]?$/.test(words[i])) starts.push(i + 1);
295+
}
296+
return starts;
297+
}
298+
299+
// Resolve URL hash params (pos=N or sentence=N) to a word index
300+
function resolveHashIndex(hashStr, words) {
301+
if (!hashStr) return null;
302+
const params = new URLSearchParams(hashStr.replace(/^#/, ""));
303+
const sentenceParam = params.get("sentence");
304+
if (sentenceParam != null) {
305+
const n = parseInt(sentenceParam, 10);
306+
if (!isNaN(n) && n >= 1) {
307+
const starts = getSentenceStarts(words);
308+
const idx = starts[Math.min(n - 1, starts.length - 1)];
309+
return idx ?? 0;
310+
}
311+
}
312+
const posParam = params.get("pos");
313+
if (posParam != null) {
314+
const n = parseInt(posParam, 10);
315+
if (!isNaN(n) && n >= 0) return n;
316+
}
317+
return null;
318+
}
319+
290320
// Spritz ORP algorithm - position where the eye naturally fixates
291321
// Based on Optimal Viewing Position research (20-35% from left)
292322
function getORPIndex(wordLength) {
@@ -548,14 +578,8 @@ function App() {
548578
setWords(parsed.words);
549579
setParagraphBreaks(parsed.breaks);
550580
// Restore position: URL hash (captured before effects) > IndexedDB > 0
551-
let pos = 0;
552-
if (initialUrlHash) {
553-
const params = new URLSearchParams(initialUrlHash.slice(1));
554-
const urlPos = parseInt(params.get("pos"), 10);
555-
if (!isNaN(urlPos) && urlPos >= 0) pos = urlPos;
556-
} else if (typeof savedPos === "number") {
557-
pos = savedPos;
558-
}
581+
let pos = resolveHashIndex(initialUrlHash, parsed.words);
582+
if (pos == null) pos = typeof savedPos === "number" ? savedPos : 0;
559583
const clamped = Math.min(Math.max(0, pos), Math.max(0, parsed.words.length - 1));
560584
_setCurrentIndex(clamped);
561585
}
@@ -728,6 +752,22 @@ function App() {
728752
}
729753
}, [currentIndex, words.length, idbLoaded]);
730754

755+
// React to manual URL hash changes (jump by typing #pos=N or #sentence=N)
756+
useEffect(() => {
757+
if (!idbLoaded || words.length === 0) return;
758+
const onHashChange = () => {
759+
const target = resolveHashIndex(window.location.hash, words);
760+
if (target == null) return;
761+
const clamped = Math.min(Math.max(0, target), words.length - 1);
762+
if (clamped !== currentIndex) {
763+
setIsPlaying(false);
764+
_setCurrentIndex(clamped);
765+
}
766+
};
767+
window.addEventListener("hashchange", onHashChange);
768+
return () => window.removeEventListener("hashchange", onHashChange);
769+
}, [idbLoaded, words, currentIndex]);
770+
731771
const getBaseDelay = useCallback(() => {
732772
return (60 / wpm) * 1000;
733773
}, [wpm]);

0 commit comments

Comments
 (0)