feat: initial implementation of Focus RSVP speed reader

- Pure frontend SPA (Vite + React 19 + TypeScript + Tailwind CSS v4)
- Spritz ORP engine with absolute center crosshair gaze fixation
- Multilingual hybrid segmentation via Intl.Segmenter with punctuation attachment
- Adaptive breathing rhythm delay algorithm for natural comprehension
- IndexedDB local bookshelf, progress persistence, and client-side EPUB/TXT/MD parsing
- Curated sci-fi, philosophy, and classic speech samples
- Dark/OLED/Sepia/Slate/Nord themes and rich keyboard controls

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-20 08:28:20 +08:00
commit c6f541ed94
30 changed files with 4756 additions and 0 deletions

74
src/core/orp.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* Optimal Recognition Point (ORP) Calculation for Spritz RSVP Reading.
* Fixates the eye at ~30%-35% into the word where human ocular fixation naturally rests.
*/
const CJK_REGEX = /[一-龥぀-ヿ가-힯]/;
export function isCJKChar(char: string): boolean {
return CJK_REGEX.test(char);
}
export function isCJKWord(word: string): boolean {
return CJK_REGEX.test(word);
}
/**
* Calculates the 0-based index of the ORP character in cleanWord.
*/
export function calculateOrpIndex(cleanWord: string): number {
const len = cleanWord.length;
if (len <= 1) return 0;
if (isCJKWord(cleanWord)) {
// CJK characters have high semantic density per glyph
if (len === 2) return 0;
if (len <= 4) return 1;
if (len <= 6) return 2;
return Math.min(len - 1, Math.floor((len - 1) * 0.35));
}
// Standard Spritz ORP Table for alphabetic words
if (len <= 5) return 1;
if (len <= 9) return 2;
if (len <= 13) return 3;
return 4;
}
export interface SplitWord {
prefix: string;
pivot: string;
suffix: string;
orpIndex: number;
}
/**
* Splits a token into prefix, pivot (ORP character), and suffix.
* Handles leading punctuation (e.g. "“", "《", "(") and trailing punctuation.
*/
export function splitOrp(
coreWord: string,
leadingPunct: string = '',
trailingPunct: string = ''
): SplitWord {
if (!coreWord) {
return {
prefix: leadingPunct,
pivot: '',
suffix: trailingPunct,
orpIndex: 0,
};
}
const orpIndex = calculateOrpIndex(coreWord);
const prefix = leadingPunct + coreWord.slice(0, orpIndex);
const pivot = coreWord[orpIndex] || '';
const suffix = coreWord.slice(orpIndex + 1) + trailingPunct;
return {
prefix,
pivot,
suffix,
orpIndex,
};
}