Files
rangerhq-reader/ranger-reader.html
T
ranger 5e0ef5adf1 feat: initial release — Ranger Reader v1.1.0
Single-file HTML RSVP reader for academic papers and long-form text.
Released free / GPL v2+ as a replacement for subscription-based RSVP
readers (SwiftRead, etc.) that cost ~$120/year for the same core
functionality.

v1.1.0 ships with three reading modes:
- Word: classic RSVP with ORP focal-character coloring
- Sentence: chunked reading, auto-advancing on word-count timing
- Paragraph: skim mode

Other features: WPM slider 200-1500, drag-drop .txt loading,
localStorage persistence (text + WPM + mode + position), keyboard
shortcuts, dark-mode WP-tone palette, sentence splitter that
handles common academic abbreviations (Mr./Dr./e.g./i.e./et al./
Vol./Fig./decimals).

Single self-contained HTML file. No install, no dependencies,
no network calls. Runs in any modern browser, fully offline.
2026-05-27 02:29:17 +01:00

620 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>◈ Ranger Reader — RSVP for thesis papers</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #0e1a23;
--fg: #d6e1ea;
--accent: #2271b1;
--accent-hover: #135e96;
--orp: #ff5252;
--muted: #6b7c8a;
--panel: #1a2a36;
--border: #2a3a48;
--done: #00a32a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
.header {
padding: 14px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
flex-wrap: wrap;
}
.header h1 {
font-size: 16px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.5px;
}
.header .meta { color: var(--muted); flex: 1; }
.header .version { color: var(--muted); font-size: 11px; }
.mode-toggle {
display: inline-flex;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
.mode-toggle button {
background: transparent;
color: var(--muted);
border: none;
padding: 6px 12px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
border-right: 1px solid var(--border);
transition: background 0.15s, color 0.15s;
}
.mode-toggle button:last-child { border-right: none; }
.mode-toggle button:hover { color: var(--fg); }
.mode-toggle button.active {
background: var(--accent);
color: #fff;
}
.stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 60px 40px;
min-height: 320px;
position: relative;
}
.stage::before, .stage::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 80px;
height: 1px;
background: var(--border);
opacity: 0;
transition: opacity 0.2s;
}
.stage.mode-word::before { top: 35%; opacity: 1; }
.stage.mode-word::after { bottom: 35%; opacity: 1; }
.word-display {
font-family: ui-monospace, "SF Mono", Consolas, Menlo, monospace;
font-size: 56px;
font-weight: 500;
letter-spacing: 0.5px;
white-space: pre;
text-align: center;
line-height: 1.2;
min-height: 70px;
user-select: none;
}
.stage.mode-sentence .word-display {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 24px;
font-weight: 400;
letter-spacing: normal;
white-space: normal;
line-height: 1.55;
max-width: 760px;
min-height: 110px;
}
.stage.mode-paragraph .word-display {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 17px;
font-weight: 400;
letter-spacing: normal;
white-space: pre-wrap;
line-height: 1.65;
max-width: 880px;
min-height: 200px;
text-align: left;
}
.orp { color: var(--orp); }
.controls {
padding: 14px 24px;
border-top: 1px solid var(--border);
background: var(--panel);
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
button.ctrl {
background: var(--accent);
color: #fff;
border: none;
padding: 8px 14px;
border-radius: 4px;
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
button.ctrl:hover { background: var(--accent-hover); }
button.ctrl.secondary {
background: transparent;
border: 1px solid var(--border);
color: var(--fg);
}
button.ctrl.secondary:hover {
background: rgba(34, 113, 177, 0.12);
border-color: var(--accent);
}
.slider-group {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
}
.slider-group strong { color: var(--fg); min-width: 36px; text-align: right; }
input[type=range] {
width: 140px;
accent-color: var(--accent);
}
.input-area {
padding: 14px 24px;
border-top: 1px solid var(--border);
background: var(--panel);
}
textarea {
width: 100%;
height: 100px;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px;
font-family: inherit;
font-size: 13px;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(34, 113, 177, 0.25);
}
.progress {
flex: 1;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
min-width: 100px;
}
.progress-bar {
height: 100%;
background: var(--accent);
width: 0;
transition: width 0.2s;
}
.hint {
font-size: 11px;
color: var(--muted);
margin-top: 8px;
line-height: 1.6;
}
.kbd {
background: var(--bg);
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 3px;
padding: 1px 6px;
font-family: ui-monospace, monospace;
font-size: 10px;
color: var(--fg);
}
.done-state .word-display { color: var(--done); }
.dropzone-overlay {
position: fixed;
inset: 0;
background: rgba(34, 113, 177, 0.20);
border: 3px dashed var(--accent);
display: none;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--fg);
z-index: 1000;
pointer-events: none;
}
.dropzone-overlay.active { display: flex; }
</style>
</head>
<body>
<div class="header">
<h1>◈ Ranger Reader</h1>
<div class="mode-toggle" id="mode-toggle">
<button data-mode="word" class="active" title="Word-by-word RSVP (classic)">Word</button>
<button data-mode="sentence" title="One sentence at a time">Sentence</button>
<button data-mode="paragraph" title="One paragraph at a time">Paragraph</button>
</div>
<span class="meta" id="meta">Paste text below, drag a .txt file in, then press Play</span>
<span class="version">v1.1</span>
</div>
<div class="stage mode-word" id="stage">
<div class="word-display" id="word">ready</div>
</div>
<div class="controls">
<button class="ctrl" id="btn-play">▶ Play</button>
<button class="ctrl secondary" id="btn-back">◀ Back</button>
<button class="ctrl secondary" id="btn-forward">Forward ▶</button>
<button class="ctrl secondary" id="btn-reset">⟲ Reset</button>
<div class="slider-group">
<span>WPM</span>
<input type="range" id="wpm" min="200" max="1500" value="500" step="50">
<strong id="wpm-val">500</strong>
</div>
<div class="progress"><div class="progress-bar" id="progress"></div></div>
</div>
<div class="input-area">
<textarea id="text" placeholder="Paste the contents of one of your .txt files here, or drag a .txt file anywhere on the page..."></textarea>
<p class="hint">
<strong style="color:var(--fg)">Modes</strong>
<span class="kbd">1</span> Word (RSVP, fast),
<span class="kbd">2</span> Sentence (chunked, easier comprehension),
<span class="kbd">3</span> Paragraph (skim mode).
&nbsp;·&nbsp;
<span class="kbd">Space</span> play/pause ·
<span class="kbd"></span> <span class="kbd"></span> skip ·
<span class="kbd">+</span> <span class="kbd"></span> WPM ·
<span class="kbd">R</span> reset.
Text + WPM + mode + position auto-saved. Drag any <code>.txt</code> file onto the page.
</p>
</div>
<div class="dropzone-overlay" id="dropzone">Drop .txt file here</div>
<script>
(() => {
const $ = id => document.getElementById(id);
const display = $('word');
const meta = $('meta');
const wpmSlider = $('wpm');
const wpmVal = $('wpm-val');
const progress = $('progress');
const textarea = $('text');
const stage = $('stage');
const dropzone = $('dropzone');
const btnPlay = $('btn-play');
const btnBack = $('btn-back');
const btnFwd = $('btn-forward');
const btnReset = $('btn-reset');
const modeToggle = $('mode-toggle');
let mode = 'word'; // 'word' | 'sentence' | 'paragraph'
let chunks = []; // array of strings — one word, one sentence, or one paragraph each
let pos = 0;
let timer = null;
let playing = false;
const STORAGE_KEY = 'ranger-reader-v1';
// ─── Persistence ───────────────────────────────────────────────
function load() {
try {
const s = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
if (s.text) textarea.value = s.text;
if (s.wpm) { wpmSlider.value = s.wpm; wpmVal.textContent = s.wpm; }
if (s.mode && ['word','sentence','paragraph'].includes(s.mode)) mode = s.mode;
if (typeof s.pos === 'number') pos = s.pos;
applyMode();
updateChunks();
if (pos > 0 && pos < chunks.length) renderChunk(chunks[pos]);
} catch (e) { /* ignore */ }
}
function save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
text: textarea.value,
wpm: wpmSlider.value,
mode: mode,
pos: pos
}));
} catch (e) { /* ignore quota errors */ }
}
// ─── Text → chunks (mode-dependent) ────────────────────────────
function splitWords(text) {
return text.split(/\s+/).filter(w => w.length > 0);
}
function splitSentences(text) {
// Guard common abbreviations + decimal numbers before splitting on .!?
let t = text
.replace(/\b(Mr|Mrs|Ms|Dr|Prof|Sr|Jr|St|vs|cf|etc|Vol|No|Fig|Eq|Ref|e\.g|i\.e|et al)\./g, '$1<P>')
.replace(/(\d)\.(\d)/g, '$1<P>$2');
// Split where punctuation is followed by whitespace + capital letter or whitespace + end-of-string
const parts = t.split(/(?<=[.!?])\s+(?=[A-Z“"\(])/);
return parts
.map(s => s.replace(/<P>/g, '.').trim())
.filter(s => s.length > 0);
}
function splitParagraphs(text) {
// Paragraphs separated by blank line OR repeated newlines
return text.split(/\n\s*\n/)
.map(p => p.replace(/\s+/g, ' ').trim())
.filter(p => p.length > 0);
}
function updateChunks() {
const text = textarea.value;
if (mode === 'word') chunks = splitWords(text);
else if (mode === 'sentence') chunks = splitSentences(text);
else chunks = splitParagraphs(text);
if (pos >= chunks.length) pos = Math.max(0, chunks.length - 1);
updateMeta();
updateProgress();
}
// ─── Meta + progress ───────────────────────────────────────────
function chunkUnitLabel() {
return mode === 'word' ? 'words' : mode === 'sentence' ? 'sentences' : 'paragraphs';
}
function totalWordsFromCurrent() {
// Approximate words remaining for accurate time estimate, in any mode
let n = 0;
for (let i = pos; i < chunks.length; i++) n += splitWords(chunks[i]).length;
return n;
}
function updateMeta() {
if (!chunks.length) {
meta.textContent = 'Paste text below, drag a .txt file in, then press Play';
return;
}
const total = chunks.length;
const wpm = parseInt(wpmSlider.value);
const wordsRemaining = totalWordsFromCurrent();
const minsRemaining = Math.ceil(wordsRemaining / wpm);
const unit = chunkUnitLabel();
meta.textContent = `${pos + 1} / ${total} ${unit} · ~${minsRemaining} min remaining at ${wpm} WPM`;
}
function updateProgress() {
progress.style.width = chunks.length ? `${(pos / chunks.length) * 100}%` : '0%';
}
// ─── Rendering ─────────────────────────────────────────────────
// ORP: Optimal Recognition Point. Pinning the focal char keeps eyes still.
function orpIndex(word) {
const n = word.length;
if (n <= 1) return 0;
if (n <= 4) return 1;
if (n <= 9) return 2;
if (n <= 13) return 3;
return 4;
}
function esc(s) {
return (s || '').replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
}
function renderChunk(chunk) {
if (!chunk) { display.innerHTML = ''; return; }
if (mode === 'word') {
// Single word with ORP coloring
const i = Math.min(orpIndex(chunk), chunk.length - 1);
const before = chunk.slice(0, i);
const orp = chunk[i] || '';
const after = chunk.slice(i + 1);
display.innerHTML = `${esc(before)}<span class="orp">${esc(orp)}</span>${esc(after)}`;
} else {
// Sentence or paragraph — render as plain text (no ORP)
display.textContent = chunk;
}
}
// ─── Timing (mode-aware) ───────────────────────────────────────
// Pause longer at punctuation — gives the brain a beat to settle (word mode only)
function pauseMult(word) {
if (!word) return 1;
const last = word[word.length - 1];
if ('.!?'.includes(last)) return 2.5;
if (',;:'.includes(last)) return 1.7;
if (')]"”'.includes(last)) return 1.3;
return 1;
}
function chunkInterval(chunk) {
const wpm = parseInt(wpmSlider.value);
if (mode === 'word') {
const base = 60000 / wpm;
return base * pauseMult(chunk);
}
// Sentence / paragraph: time = words / WPM, with a comprehension multiplier
const wordCount = splitWords(chunk).length;
const baseMs = (wordCount / wpm) * 60000;
const multiplier = mode === 'sentence' ? 1.25 : 1.40; // brain needs more time on chunks
// Sensible bounds: 1.5s min, 12s max
return Math.max(1500, Math.min(12000, baseMs * multiplier));
}
function tick() {
if (pos >= chunks.length) {
stop();
display.textContent = '— done —';
stage.classList.add('done-state');
return;
}
const chunk = chunks[pos];
renderChunk(chunk);
updateMeta();
updateProgress();
save();
pos++;
timer = setTimeout(tick, chunkInterval(chunk));
}
function play() {
stage.classList.remove('done-state');
updateChunks();
if (!chunks.length) return;
if (pos >= chunks.length) pos = 0;
playing = true;
btnPlay.textContent = '⏸ Pause';
tick();
}
function stop() {
playing = false;
if (timer) { clearTimeout(timer); timer = null; }
btnPlay.textContent = '▶ Play';
}
function toggle() { playing ? stop() : play(); }
function skip(n) {
stop();
updateChunks();
pos = Math.max(0, Math.min(chunks.length - 1, pos + n));
if (chunks[pos]) renderChunk(chunks[pos]);
updateMeta();
updateProgress();
save();
}
// Skip distance: 1 in chunk modes, 10 in word mode
function skipDelta() { return mode === 'word' ? 10 : 1; }
function reset() {
stop();
stage.classList.remove('done-state');
pos = 0;
updateChunks();
if (chunks[0]) renderChunk(chunks[0]);
else display.textContent = 'ready';
updateMeta();
updateProgress();
save();
}
// ─── Mode switching ────────────────────────────────────────────
function applyMode() {
stage.className = `stage mode-${mode}`;
[...modeToggle.querySelectorAll('button')].forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
}
function setMode(newMode) {
if (newMode === mode) return;
stop();
mode = newMode;
pos = 0;
applyMode();
updateChunks();
if (chunks[0]) renderChunk(chunks[0]);
else display.textContent = 'ready';
updateMeta();
updateProgress();
save();
}
// ─── Wire up controls ─────────────────────────────────────────
modeToggle.addEventListener('click', e => {
const btn = e.target.closest('button[data-mode]');
if (btn) setMode(btn.dataset.mode);
});
btnPlay.addEventListener('click', toggle);
btnBack.addEventListener('click', () => skip(-skipDelta()));
btnFwd.addEventListener('click', () => skip(skipDelta()));
btnReset.addEventListener('click', reset);
wpmSlider.addEventListener('input', () => {
wpmVal.textContent = wpmSlider.value;
updateMeta();
save();
});
textarea.addEventListener('input', () => {
pos = 0;
stage.classList.remove('done-state');
updateChunks();
save();
});
// Keyboard shortcuts (ignore when focused in textarea)
document.addEventListener('keydown', (e) => {
if (e.target === textarea) return;
const k = e.key;
if (k === ' ') { e.preventDefault(); toggle(); }
else if (k === 'ArrowLeft') { e.preventDefault(); skip(-skipDelta()); }
else if (k === 'ArrowRight') { e.preventDefault(); skip(skipDelta()); }
else if (k === '1') { setMode('word'); }
else if (k === '2') { setMode('sentence'); }
else if (k === '3') { setMode('paragraph'); }
else if (k === '+' || k === '=') {
wpmSlider.value = Math.min(1500, parseInt(wpmSlider.value) + 50);
wpmVal.textContent = wpmSlider.value;
updateMeta();
save();
}
else if (k === '-' || k === '_') {
wpmSlider.value = Math.max(200, parseInt(wpmSlider.value) - 50);
wpmVal.textContent = wpmSlider.value;
updateMeta();
save();
}
else if (k === 'r' || k === 'R') { reset(); }
});
// ─── Drag-and-drop .txt files ─────────────────────────────────
let dragCounter = 0;
document.addEventListener('dragenter', e => {
e.preventDefault();
dragCounter++;
dropzone.classList.add('active');
});
document.addEventListener('dragleave', e => {
dragCounter--;
if (dragCounter <= 0) dropzone.classList.remove('active');
});
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => {
e.preventDefault();
dragCounter = 0;
dropzone.classList.remove('active');
const file = e.dataTransfer.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
textarea.value = ev.target.result;
pos = 0;
stage.classList.remove('done-state');
updateChunks();
save();
};
reader.readAsText(file);
});
// ─── Boot ─────────────────────────────────────────────────────
load();
})();
</script>
</body>
</html>