feat(reader): RSVP CJK character mode and whole-word highlight (#4199)

* feat(reader): add RSVP CJK character mode and whole-word highlight, closes #4131

Add two CJK-only options to the RSVP overlay settings row:
- Character Mode: split CJK text per-character instead of by jieba/Intl
  word segmentation, restoring one-character-per-flash reading.
- Highlight Word: render a CJK word as a single centered, fully-colored
  span, fixing the focus-only highlight and even-length left-shift.

The focus point now skips trailing CJK punctuation so tokens like "是。"
highlight the character, not the punctuation. Both toggles appear only
for sections that contain CJK text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n: extract RSVP CJK character mode and highlight word strings

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-17 21:30:11 +08:00
committed by GitHub
parent 3620c61038
commit ba6e5899e5
40 changed files with 509 additions and 60 deletions
@@ -1562,5 +1562,7 @@
"Imported {{count}} annotations_many": "تم استيراد {{count}} تعليقًا",
"Imported {{count}} annotations_other": "تم استيراد {{count}} تعليق",
"No new annotations to import": "لا توجد تعليقات جديدة للاستيراد",
"Import from Moon+ Reader": "استيراد من Moon+ Reader"
"Import from Moon+ Reader": "استيراد من Moon+ Reader",
"Character Mode": "وضع الحروف",
"Highlight Word": "تمييز الكلمة"
}
@@ -1450,5 +1450,7 @@
"Imported {{count}} annotations_one": "{{count}}টি টীকা ইম্পোর্ট করা হয়েছে",
"Imported {{count}} annotations_other": "{{count}}টি টীকা ইম্পোর্ট করা হয়েছে",
"No new annotations to import": "ইম্পোর্ট করার জন্য কোনো নতুন টীকা নেই",
"Import from Moon+ Reader": "Moon+ Reader থেকে ইম্পোর্ট করুন"
"Import from Moon+ Reader": "Moon+ Reader থেকে ইম্পোর্ট করুন",
"Character Mode": "অক্ষর মোড",
"Highlight Word": "শব্দ হাইলাইট"
}
@@ -1422,5 +1422,7 @@
"No annotations could be located in this book.": "དཔེ་ཆ་འདིའི་ནང་མཆན་འགྲེལ་གང་ཡང་གནས་ས་ངོས་འཛིན་མ་ཐུབ།",
"Imported {{count}} annotations_other": "མཆན་འགྲེལ་ {{count}} ནང་འདྲེན་བྱས་ཟིན།",
"No new annotations to import": "ནང་འདྲེན་བྱ་རྒྱུའི་མཆན་འགྲེལ་གསར་པ་མེད།",
"Import from Moon+ Reader": "Moon+ Reader ནས་ནང་འདྲེན།"
"Import from Moon+ Reader": "Moon+ Reader ནས་ནང་འདྲེན།",
"Character Mode": "ཡིག་འབྲུའི་སྒྲིག་སྟངས།",
"Highlight Word": "ཐ་སྙད་གསལ་སྟོན།"
}
@@ -1450,5 +1450,7 @@
"Imported {{count}} annotations_one": "{{count}} Anmerkung importiert",
"Imported {{count}} annotations_other": "{{count}} Anmerkungen importiert",
"No new annotations to import": "Keine neuen Anmerkungen zum Importieren",
"Import from Moon+ Reader": "Aus Moon+ Reader importieren"
"Import from Moon+ Reader": "Aus Moon+ Reader importieren",
"Character Mode": "Zeichenmodus",
"Highlight Word": "Wort hervorheben"
}
@@ -1450,5 +1450,7 @@
"Imported {{count}} annotations_one": "Εισήχθη {{count}} σχόλιο",
"Imported {{count}} annotations_other": "Εισήχθησαν {{count}} σχόλια",
"No new annotations to import": "Δεν υπάρχουν νέα σχόλια για εισαγωγή",
"Import from Moon+ Reader": "Εισαγωγή από Moon+ Reader"
"Import from Moon+ Reader": "Εισαγωγή από Moon+ Reader",
"Character Mode": "Λειτουργία χαρακτήρων",
"Highlight Word": "Επισήμανση λέξης"
}
@@ -1478,5 +1478,7 @@
"Imported {{count}} annotations_many": "{{count}} anotaciones importadas",
"Imported {{count}} annotations_other": "{{count}} anotaciones importadas",
"No new annotations to import": "No hay anotaciones nuevas para importar",
"Import from Moon+ Reader": "Importar desde Moon+ Reader"
"Import from Moon+ Reader": "Importar desde Moon+ Reader",
"Character Mode": "Modo de caracteres",
"Highlight Word": "Resaltar palabra"
}
@@ -1450,5 +1450,7 @@
"Imported {{count}} annotations_one": "{{count}} یادداشت وارد شد",
"Imported {{count}} annotations_other": "{{count}} یادداشت وارد شد",
"No new annotations to import": "یادداشت جدیدی برای وارد کردن وجود ندارد",
"Import from Moon+ Reader": "وارد کردن از Moon+ Reader"
"Import from Moon+ Reader": "وارد کردن از Moon+ Reader",
"Character Mode": "حالت نویسه",
"Highlight Word": "برجسته‌سازی واژه"
}
@@ -1478,5 +1478,7 @@
"Imported {{count}} annotations_many": "{{count}} annotations importées",
"Imported {{count}} annotations_other": "{{count}} annotations importées",
"No new annotations to import": "Aucune nouvelle annotation à importer",
"Import from Moon+ Reader": "Importer depuis Moon+ Reader"
"Import from Moon+ Reader": "Importer depuis Moon+ Reader",
"Character Mode": "Mode caractère",
"Highlight Word": "Surligner le mot"
}
@@ -1478,5 +1478,7 @@
"Imported {{count}} annotations_two": "יובאו שתי הערות",
"Imported {{count}} annotations_other": "יובאו {{count}} הערות",
"No new annotations to import": "אין הערות חדשות לייבוא",
"Import from Moon+ Reader": "ייבוא מ-Moon+ Reader"
"Import from Moon+ Reader": "ייבוא מ-Moon+ Reader",
"Character Mode": "מצב תווים",
"Highlight Word": "הדגשת מילה"
}
@@ -1450,5 +1450,7 @@
"Imported {{count}} annotations_one": "{{count}} एनोटेशन आयात किया गया",
"Imported {{count}} annotations_other": "{{count}} एनोटेशन आयात किए गए",
"No new annotations to import": "आयात करने के लिए कोई नया एनोटेशन नहीं",
"Import from Moon+ Reader": "Moon+ Reader से आयात करें"
"Import from Moon+ Reader": "Moon+ Reader से आयात करें",
"Character Mode": "वर्ण मोड",
"Highlight Word": "शब्द हाइलाइट करें"
}
@@ -1450,5 +1450,7 @@
"Imported {{count}} annotations_one": "{{count}} jegyzet importálva",
"Imported {{count}} annotations_other": "{{count}} jegyzet importálva",
"No new annotations to import": "Nincs új importálható jegyzet",
"Import from Moon+ Reader": "Importálás Moon+ Reader programból"
"Import from Moon+ Reader": "Importálás Moon+ Reader programból",
"Character Mode": "Karakter mód",
"Highlight Word": "Szó kiemelése"
}
@@ -1422,5 +1422,7 @@
"No annotations could be located in this book.": "Tidak ada anotasi yang dapat ditemukan di buku ini.",
"Imported {{count}} annotations_other": "{{count}} anotasi diimpor",
"No new annotations to import": "Tidak ada anotasi baru untuk diimpor",
"Import from Moon+ Reader": "Impor dari Moon+ Reader"
"Import from Moon+ Reader": "Impor dari Moon+ Reader",
"Character Mode": "Mode Karakter",
"Highlight Word": "Sorot Kata"
}
@@ -1478,5 +1478,7 @@
"Imported {{count}} annotations_many": "{{count}} annotazioni importate",
"Imported {{count}} annotations_other": "{{count}} annotazioni importate",
"No new annotations to import": "Nessuna nuova annotazione da importare",
"Import from Moon+ Reader": "Importa da Moon+ Reader"
"Import from Moon+ Reader": "Importa da Moon+ Reader",
"Character Mode": "Modalità carattere",
"Highlight Word": "Evidenzia parola"
}
@@ -1422,5 +1422,7 @@
"No annotations could be located in this book.": "この本で注釈の位置を特定できませんでした。",
"Imported {{count}} annotations_other": "{{count}} 件の注釈をインポートしました",
"No new annotations to import": "インポートする新しい注釈はありません",
"Import from Moon+ Reader": "Moon+ Reader からインポート"
"Import from Moon+ Reader": "Moon+ Reader からインポート",
"Character Mode": "文字モード",
"Highlight Word": "単語を強調"
}
@@ -1422,5 +1422,7 @@
"No annotations could be located in this book.": "이 책에서 주석의 위치를 찾을 수 없습니다.",
"Imported {{count}} annotations_other": "주석 {{count}}개를 가져왔습니다",
"No new annotations to import": "가져올 새 주석이 없습니다",
"Import from Moon+ Reader": "Moon+ Reader에서 가져오기"
"Import from Moon+ Reader": "Moon+ Reader에서 가져오기",
"Character Mode": "글자 모드",
"Highlight Word": "단어 강조"
}
@@ -1422,5 +1422,7 @@
"No annotations could be located in this book.": "Tiada anotasi dapat dikesan dalam buku ini.",
"Imported {{count}} annotations_other": "{{count}} anotasi diimport",
"No new annotations to import": "Tiada anotasi baharu untuk diimport",
"Import from Moon+ Reader": "Import dari Moon+ Reader"
"Import from Moon+ Reader": "Import dari Moon+ Reader",
"Character Mode": "Mod Aksara",
"Highlight Word": "Serlah Perkataan"
}
@@ -1450,5 +1450,7 @@
"Imported {{count}} annotations_one": "{{count}} annotatie geïmporteerd",
"Imported {{count}} annotations_other": "{{count}} annotaties geïmporteerd",
"No new annotations to import": "Geen nieuwe annotaties om te importeren",
"Import from Moon+ Reader": "Importeren uit Moon+ Reader"
"Import from Moon+ Reader": "Importeren uit Moon+ Reader",
"Character Mode": "Tekenmodus",
"Highlight Word": "Woord markeren"
}
@@ -1506,5 +1506,7 @@
"Imported {{count}} annotations_many": "Zaimportowano {{count}} adnotacji",
"Imported {{count}} annotations_other": "Zaimportowano {{count}} adnotacji",
"No new annotations to import": "Brak nowych adnotacji do zaimportowania",
"Import from Moon+ Reader": "Importuj z Moon+ Reader"
"Import from Moon+ Reader": "Importuj z Moon+ Reader",
"Character Mode": "Tryb znaków",
"Highlight Word": "Podświetl słowo"
}
@@ -1478,5 +1478,7 @@
"Imported {{count}} annotations_many": "{{count}} anotações importadas",
"Imported {{count}} annotations_other": "{{count}} anotações importadas",
"No new annotations to import": "Não há novas anotações para importar",
"Import from Moon+ Reader": "Importar do Moon+ Reader"
"Import from Moon+ Reader": "Importar do Moon+ Reader",
"Character Mode": "Modo de caracteres",
"Highlight Word": "Destacar palavra"
}
@@ -1478,5 +1478,7 @@
"Imported {{count}} annotations_many": "{{count}} anotações importadas",
"Imported {{count}} annotations_other": "{{count}} anotações importadas",
"No new annotations to import": "Não há novas anotações para importar",
"Import from Moon+ Reader": "Importar do Moon+ Reader"
"Import from Moon+ Reader": "Importar do Moon+ Reader",
"Character Mode": "Modo de caracteres",
"Highlight Word": "Destacar palavra"
}
@@ -1478,5 +1478,7 @@
"Imported {{count}} annotations_few": "{{count}} adnotări importate",
"Imported {{count}} annotations_other": "{{count}} de adnotări importate",
"No new annotations to import": "Nicio adnotare nouă de importat",
"Import from Moon+ Reader": "Importă din Moon+ Reader"
"Import from Moon+ Reader": "Importă din Moon+ Reader",
"Character Mode": "Mod caracter",
"Highlight Word": "Evidențiere cuvânt"
}
@@ -1506,5 +1506,7 @@
"Imported {{count}} annotations_many": "Импортировано {{count}} аннотаций",
"Imported {{count}} annotations_other": "Импортировано {{count}} аннотаций",
"No new annotations to import": "Нет новых аннотаций для импорта",
"Import from Moon+ Reader": "Импорт из Moon+ Reader"
"Import from Moon+ Reader": "Импорт из Moon+ Reader",
"Character Mode": "Посимвольный режим",
"Highlight Word": "Подсветка слова"
}
@@ -1450,5 +1450,7 @@
"Imported {{count}} annotations_one": "සටහන් {{count}}ක් ආයාත කරන ලදී",
"Imported {{count}} annotations_other": "සටහන් {{count}}ක් ආයාත කරන ලදී",
"No new annotations to import": "ආයාත කිරීමට නව සටහන් නැත",
"Import from Moon+ Reader": "Moon+ Reader වෙතින් ආයාත කරන්න"
"Import from Moon+ Reader": "Moon+ Reader වෙතින් ආයාත කරන්න",
"Character Mode": "අක්ෂර ප්‍රකාරය",
"Highlight Word": "වචනය උද්දීපනය"
}
@@ -1506,5 +1506,7 @@
"Imported {{count}} annotations_few": "Uvožene {{count}} opombe",
"Imported {{count}} annotations_other": "Uvoženih {{count}} opomb",
"No new annotations to import": "Ni novih opomb za uvoz",
"Import from Moon+ Reader": "Uvozi iz Moon+ Reader"
"Import from Moon+ Reader": "Uvozi iz Moon+ Reader",
"Character Mode": "Znakovni način",
"Highlight Word": "Označi besedo"
}
@@ -1450,5 +1450,7 @@
"Imported {{count}} annotations_one": "{{count}} anteckning importerad",
"Imported {{count}} annotations_other": "{{count}} anteckningar importerade",
"No new annotations to import": "Inga nya anteckningar att importera",
"Import from Moon+ Reader": "Importera från Moon+ Reader"
"Import from Moon+ Reader": "Importera från Moon+ Reader",
"Character Mode": "Teckenläge",
"Highlight Word": "Markera ord"
}
@@ -1450,5 +1450,7 @@
"Imported {{count}} annotations_one": "{{count}} குறிப்பு இறக்குமதி செய்யப்பட்டது",
"Imported {{count}} annotations_other": "{{count}} குறிப்புகள் இறக்குமதி செய்யப்பட்டன",
"No new annotations to import": "இறக்குமதி செய்ய புதிய குறிப்புகள் இல்லை",
"Import from Moon+ Reader": "Moon+ Reader இலிருந்து இறக்குமதி செய்"
"Import from Moon+ Reader": "Moon+ Reader இலிருந்து இறக்குமதி செய்",
"Character Mode": "எழுத்து முறை",
"Highlight Word": "சொல்லை முன்னிலைப்படுத்து"
}
@@ -1422,5 +1422,7 @@
"No annotations could be located in this book.": "ไม่พบตำแหน่งของคำอธิบายประกอบในหนังสือเล่มนี้",
"Imported {{count}} annotations_other": "นำเข้าคำอธิบายประกอบ {{count}} รายการแล้ว",
"No new annotations to import": "ไม่มีคำอธิบายประกอบใหม่ให้นำเข้า",
"Import from Moon+ Reader": "นำเข้าจาก Moon+ Reader"
"Import from Moon+ Reader": "นำเข้าจาก Moon+ Reader",
"Character Mode": "โหมดอักขระ",
"Highlight Word": "เน้นคำ"
}
@@ -1450,5 +1450,7 @@
"Imported {{count}} annotations_one": "{{count}} açıklama içe aktarıldı",
"Imported {{count}} annotations_other": "{{count}} açıklama içe aktarıldı",
"No new annotations to import": "İçe aktarılacak yeni açıklama yok",
"Import from Moon+ Reader": "Moon+ Reader'dan içe aktar"
"Import from Moon+ Reader": "Moon+ Reader'dan içe aktar",
"Character Mode": "Karakter modu",
"Highlight Word": "Kelimeyi vurgula"
}
@@ -1506,5 +1506,7 @@
"Imported {{count}} annotations_many": "Імпортовано {{count}} анотацій",
"Imported {{count}} annotations_other": "Імпортовано {{count}} анотацій",
"No new annotations to import": "Немає нових анотацій для імпорту",
"Import from Moon+ Reader": "Імпорт із Moon+ Reader"
"Import from Moon+ Reader": "Імпорт із Moon+ Reader",
"Character Mode": "Посимвольний режим",
"Highlight Word": "Підсвічування слова"
}
@@ -1450,5 +1450,7 @@
"Imported {{count}} annotations_one": "{{count}} ta izoh import qilindi",
"Imported {{count}} annotations_other": "{{count}} ta izoh import qilindi",
"No new annotations to import": "Import qilish uchun yangi izohlar yoʻq",
"Import from Moon+ Reader": "Moon+ Readerdan import qilish"
"Import from Moon+ Reader": "Moon+ Readerdan import qilish",
"Character Mode": "Belgi rejimi",
"Highlight Word": "So'zni ajratish"
}
@@ -1422,5 +1422,7 @@
"No annotations could be located in this book.": "Không thể xác định vị trí chú thích nào trong cuốn sách này.",
"Imported {{count}} annotations_other": "Đã nhập {{count}} chú thích",
"No new annotations to import": "Không có chú thích mới để nhập",
"Import from Moon+ Reader": "Nhập từ Moon+ Reader"
"Import from Moon+ Reader": "Nhập từ Moon+ Reader",
"Character Mode": "Chế độ ký tự",
"Highlight Word": "Tô sáng từ"
}
@@ -1422,5 +1422,7 @@
"No annotations could be located in this book.": "无法在本书中定位任何注释。",
"Imported {{count}} annotations_other": "已导入 {{count}} 条注释",
"No new annotations to import": "没有可导入的新注释",
"Import from Moon+ Reader": "从 Moon+ Reader 导入"
"Import from Moon+ Reader": "从 Moon+ Reader 导入",
"Character Mode": "单字模式",
"Highlight Word": "高亮整词"
}
@@ -1422,5 +1422,7 @@
"No annotations could be located in this book.": "無法在本書中定位任何註解。",
"Imported {{count}} annotations_other": "已匯入 {{count}} 條註解",
"No new annotations to import": "沒有可匯入的新註解",
"Import from Moon+ Reader": "從 Moon+ Reader 匯入"
"Import from Moon+ Reader": "從 Moon+ Reader 匯入",
"Character Mode": "單字模式",
"Highlight Word": "高亮整詞"
}
@@ -32,6 +32,8 @@ const buildState = (overrides: Partial<RsvpState> = {}): RsvpState => ({
wpm: 300,
punctuationPauseMs: 100,
splitHyphens: false,
cjkCharMode: false,
hasCJK: false,
progress: 0,
...overrides,
});
@@ -60,6 +62,7 @@ const buildController = (state: RsvpState) => {
setWpm: vi.fn(),
setPunctuationPause: vi.fn(),
setSplitHyphens: vi.fn(),
setCjkCharMode: vi.fn(),
getWpmOptions: vi.fn(() => [100, 200, 300]),
getPunctuationPauseOptions: vi.fn(() => [25, 50, 100]),
addEventListener: vi.fn((type: string, listener: EventListener) => {
@@ -194,3 +197,95 @@ describe('RSVPOverlay — progress bar drag on mobile', () => {
expect(slider.style.touchAction).toBe('none');
});
});
describe('RSVPOverlay — CJK reading options', () => {
afterEach(() => {
cleanup();
localStorage.clear();
});
const openSettings = (container: HTMLElement) => {
fireEvent.click(container.querySelector('[aria-label="Settings"]') as HTMLElement);
};
test('shows Character Mode and Highlight Word toggles for CJK sections', () => {
const state = buildState({
words: [{ text: '喜欢', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
hasCJK: true,
});
const { container } = renderOverlay(state);
openSettings(container);
expect(container.querySelector('[data-testid="rsvp-char-mode-toggle"]')).not.toBeNull();
expect(container.querySelector('[data-testid="rsvp-highlight-word-toggle"]')).not.toBeNull();
});
test('hides CJK toggles when the section has no CJK text', () => {
const state = buildState({
words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
hasCJK: false,
});
const { container } = renderOverlay(state);
openSettings(container);
expect(container.querySelector('[data-testid="rsvp-char-mode-toggle"]')).toBeNull();
expect(container.querySelector('[data-testid="rsvp-highlight-word-toggle"]')).toBeNull();
});
test('toggling Character Mode calls controller.setCjkCharMode', () => {
const state = buildState({
words: [{ text: '喜欢', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
hasCJK: true,
});
const { container, controller } = renderOverlay(state);
openSettings(container);
fireEvent.click(
container.querySelector('[data-testid="rsvp-char-mode-toggle"]') as HTMLElement,
);
expect(controller.setCjkCharMode).toHaveBeenCalledWith(true);
});
test('renders the focus-letter layout for a CJK word by default', () => {
const state = buildState({
words: [{ text: '喜欢', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
hasCJK: true,
});
const { container } = renderOverlay(state);
expect(container.querySelector('.rsvp-word-orp')).not.toBeNull();
expect(container.querySelector('.rsvp-word-whole')).toBeNull();
});
test('renders a single centered span when Highlight Word is enabled', () => {
localStorage.setItem('readest_rsvp_cjk_highlight_word', '1');
const state = buildState({
words: [{ text: '喜欢', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
hasCJK: true,
});
const { container } = renderOverlay(state);
const whole = container.querySelector('.rsvp-word-whole');
expect(whole).not.toBeNull();
expect(whole!.textContent).toBe('喜欢');
expect(container.querySelector('.rsvp-word-orp')).toBeNull();
});
test('keeps the focus-letter layout for Latin words even with Highlight Word enabled', () => {
localStorage.setItem('readest_rsvp_cjk_highlight_word', '1');
const state = buildState({
words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
hasCJK: false,
});
const { container } = renderOverlay(state);
expect(container.querySelector('.rsvp-word-orp')).not.toBeNull();
expect(container.querySelector('.rsvp-word-whole')).toBeNull();
});
});
@@ -1,4 +1,4 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { RSVPController } from '@/services/rsvp/RSVPController';
import { FoliateView } from '@/types/view';
@@ -347,4 +347,77 @@ describe('RSVPController', () => {
expect(words[1]!.text).toBe('cat');
});
});
describe('CJK character mode', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
test('cjkCharMode defaults to false and hasCJK is false for Latin text', () => {
const view = createMockView(0, [makeDoc('Hello world')]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
expect(controller.currentState.cjkCharMode).toBe(false);
expect(controller.currentState.hasCJK).toBe(false);
});
test('hasCJK is true when the section contains CJK text', () => {
const view = createMockView(0, [makeDoc('你好世界')]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
expect(controller.currentState.hasCJK).toBe(true);
});
test('setCjkCharMode(true) re-segments the active section per-character', () => {
const view = createMockView(0, [makeDoc('我喜欢阅读')]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
controller.setCjkCharMode(true);
expect(controller.currentState.words.map((w) => w.text)).toEqual([
'我',
'喜',
'欢',
'阅',
'读',
]);
});
test('setCjkCharMode persists the choice to localStorage', () => {
const view = createMockView(0, [makeDoc('你好')]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.setCjkCharMode(true);
expect(localStorage.getItem('readest_rsvp_cjk_char_mode')).toBe('1');
});
test('keeps the focus character off trailing punctuation in char mode', () => {
const view = createMockView(0, [makeDoc('是。')]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.setCjkCharMode(true);
controller.start();
const word = controller.currentState.words[0]!;
expect(word.text).toBe('是。');
// The focus must land on 是 (index 0), not the trailing 。
expect(word.orpIndex).toBe(0);
});
test('char mode is restored from localStorage on construction', () => {
localStorage.setItem('readest_rsvp_cjk_char_mode', '1');
const view = createMockView(0, [makeDoc('我喜欢阅读')]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
expect(controller.currentState.cjkCharMode).toBe(true);
expect(controller.currentState.words.map((w) => w.text)).toEqual([
'我',
'喜',
'欢',
'阅',
'读',
]);
});
});
});
@@ -324,4 +324,46 @@ describe('rsvp/utils', () => {
expect(getHyphenParts('...')).toEqual(['...']);
});
});
describe('CJK character mode', () => {
test('segmentCJKText splits CJK text per-character when cjkCharMode is true', () => {
expect(segmentCJKText('你好世界', undefined, true)).toEqual(['你', '好', '世', '界']);
});
test('segmentCJKText attaches CJK punctuation to the preceding character in char mode', () => {
expect(segmentCJKText('你好。世界!', undefined, true)).toEqual(['你', '好。', '世', '界!']);
});
test('segmentCJKText keeps leading punctuation as its own token in char mode', () => {
expect(segmentCJKText('。你好', undefined, true)).toEqual(['。', '你', '好']);
});
test('segmentCJKText returns empty array for empty text in char mode', () => {
expect(segmentCJKText('', undefined, true)).toEqual([]);
});
test('splitTextIntoWords splits CJK per-character in char mode', () => {
expect(splitTextIntoWords('我喜欢阅读', undefined, true)).toEqual([
'我',
'喜',
'欢',
'阅',
'读',
]);
});
test('splitTextIntoWords leaves Latin words intact in char mode', () => {
expect(splitTextIntoWords('Hello 你好 World', undefined, true)).toEqual([
'Hello',
'你',
'好',
'World',
]);
});
test('splitTextIntoWords groups CJK characters when char mode is off', () => {
// Default segmentation should group multi-character words like 喜欢 / 阅读.
expect(splitTextIntoWords('我喜欢阅读').length).toBeLessThan(5);
});
});
});
@@ -64,6 +64,7 @@ const ORP_COLOR_OPTIONS = ['', '#EF4444', '#3B82F6', '#22C55E', '#F97316', '#A85
const STORAGE_KEY_FONT_SIZE = 'readest_rsvp_fontsize';
const STORAGE_KEY_ORP_COLOR = 'readest_rsvp_orp_color';
const STORAGE_KEY_CONTEXT = 'readest_rsvp_context';
const STORAGE_KEY_HIGHLIGHT_WORD = 'readest_rsvp_cjk_highlight_word';
// Context panel windowing — long sections (e.g. AZW3 chapters with 40k+ words)
// would otherwise render tens of thousands of <span> elements and freeze the UI
@@ -131,6 +132,13 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
}
return 0;
});
const [highlightWholeWord, setHighlightWholeWord] = useState(() => {
try {
return localStorage.getItem(STORAGE_KEY_HIGHLIGHT_WORD) === '1';
} catch {
return false;
}
});
const contextWordRef = useRef<HTMLSpanElement>(null);
const touchStartX = useRef(0);
const touchStartY = useRef(0);
@@ -314,6 +322,15 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
}
}, []);
const updateHighlightWholeWord = useCallback((value: boolean) => {
setHighlightWholeWord(value);
try {
localStorage.setItem(STORAGE_KEY_HIGHLIGHT_WORD, value ? '1' : '0');
} catch {
/* ignore */
}
}, []);
// Chapter helpers
const getCurrentChapterLabel = useCallback((): string => {
if (!effectiveChapterHref) return _('Select Chapter');
@@ -697,26 +714,37 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
style={{ fontSize: `${currentFontSize}rem`, letterSpacing: wordLetterSpacing }}
>
{currentWord ? (
<>
isCJKWord && highlightWholeWord ? (
// Whole-word mode: center the full CJK word and color every
// character, instead of anchoring a single focus character.
<span
className='rsvp-word-before absolute text-right opacity-60'
style={{ right: `calc(50% + ${wordSideOffset})` }}
>
{wordBefore}
</span>
<span
className='rsvp-word-orp relative z-10 font-bold'
className='rsvp-word-whole relative z-10 font-bold'
style={{ color: effectiveOrpColor }}
>
{orpChar}
{currentWord.text}
</span>
<span
className='rsvp-word-after absolute text-left opacity-60'
style={{ left: `calc(50% + ${wordSideOffset})` }}
>
{wordAfter}
</span>
</>
) : (
<>
<span
className='rsvp-word-before absolute text-right opacity-60'
style={{ right: `calc(50% + ${wordSideOffset})` }}
>
{wordBefore}
</span>
<span
className='rsvp-word-orp relative z-10 font-bold'
style={{ color: effectiveOrpColor }}
>
{orpChar}
</span>
<span
className='rsvp-word-after absolute text-left opacity-60'
style={{ left: `calc(50% + ${wordSideOffset})` }}
>
{wordAfter}
</span>
</>
)
) : (
<span className='italic opacity-30'>{_('Ready')}</span>
)}
@@ -907,6 +935,34 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
/>
</div>
{/* CJK character mode — split CJK text per-character */}
{state.hasCJK && (
<div className='config-item gap-2'>
<span className='opacity-50'>{_('Character Mode')}</span>
<input
type='checkbox'
data-testid='rsvp-char-mode-toggle'
className='toggle'
checked={state.cjkCharMode}
onChange={(e) => controller.setCjkCharMode(e.target.checked)}
/>
</div>
)}
{/* CJK whole-word highlight — color and center the full word */}
{state.hasCJK && (
<div className='config-item gap-2'>
<span className='opacity-50'>{_('Highlight Word')}</span>
<input
type='checkbox'
data-testid='rsvp-highlight-word-toggle'
className='toggle'
checked={highlightWholeWord}
onChange={(e) => updateHighlightWholeWord(e.target.checked)}
/>
</div>
)}
{/* ORP color */}
<div className='flex items-center gap-1.5'>
<span className='mr-0.5 font-medium opacity-50'>{_('Focus')}</span>
@@ -1,6 +1,6 @@
import { FoliateView } from '@/types/view';
import { RsvpWord, RsvpState, RsvpPosition, RsvpStopPosition, RsvpStartChoice } from './types';
import { containsCJK, splitTextIntoWords, getHyphenParts } from './utils';
import { containsCJK, isCJKPunctuation, splitTextIntoWords, getHyphenParts } from './utils';
import { compare as compareCFI } from 'foliate-js/epubcfi.js';
import { XCFI } from '@/utils/xcfi';
@@ -11,10 +11,12 @@ const WPM_STEP = 50;
const DEFAULT_PUNCTUATION_PAUSE_MS = 100;
const PUNCTUATION_PAUSE_OPTIONS = [25, 50, 75, 100, 125, 150, 175, 200];
const DEFAULT_SPLIT_HYPHENS = false;
const DEFAULT_CJK_CHAR_MODE = false;
const STORAGE_KEY_PREFIX = 'readest_rsvp_wpm_';
const PUNCTUATION_PAUSE_KEY_PREFIX = 'readest_rsvp_pause_';
const POSITION_KEY_PREFIX = 'readest_rsvp_pos_';
const SPLIT_HYPHENS_KEY = 'readest_rsvp_split_hyphens';
const CJK_CHAR_MODE_KEY = 'readest_rsvp_cjk_char_mode';
// Section-only CFI (no '!') sorts before any word CFI in that section.
const stripCfiPath = (cfi: string): string => cfi.replace(/!.*\)$/, ')');
@@ -34,6 +36,8 @@ export class RSVPController extends EventTarget {
wpm: DEFAULT_WPM,
punctuationPauseMs: DEFAULT_PUNCTUATION_PAUSE_MS,
splitHyphens: DEFAULT_SPLIT_HYPHENS,
cjkCharMode: DEFAULT_CJK_CHAR_MODE,
hasCJK: false,
progress: 0,
};
@@ -73,6 +77,10 @@ export class RSVPController extends EventTarget {
if (savedSplitHyphens !== null) {
this.state.splitHyphens = savedSplitHyphens;
}
const savedCjkCharMode = this.loadCjkCharModeFromStorage();
if (savedCjkCharMode !== null) {
this.state.cjkCharMode = savedCjkCharMode;
}
}
get currentState(): RsvpState {
@@ -185,6 +193,34 @@ export class RSVPController extends EventTarget {
return null;
}
setCjkCharMode(value: boolean): void {
if (this.state.cjkCharMode === value) return;
this.state.cjkCharMode = value;
try {
localStorage.setItem(CJK_CHAR_MODE_KEY, value ? '1' : '0');
} catch {
/* ignore */
}
// Char mode changes the segmentation result, so the cached words and the
// current section's word list both need to be rebuilt.
this.cachedWords = null;
if (this.state.active) {
this.reextractPreservingPosition();
} else {
this.emitStateChange();
}
}
private loadCjkCharModeFromStorage(): boolean | null {
try {
const stored = localStorage.getItem(CJK_CHAR_MODE_KEY);
if (stored !== null) return stored === '1';
} catch {
/* ignore */
}
return null;
}
setCurrentCfi(cfi: string | null): void {
this.currentCfi = cfi;
}
@@ -383,6 +419,7 @@ export class RSVPController extends EventTarget {
playing: true,
words,
currentIndex: clampedStart,
hasCJK: this.computeHasCJK(words),
};
this.emitStateChange();
@@ -665,6 +702,7 @@ export class RSVPController extends EventTarget {
words,
currentIndex: 0,
currentPartIndex: 0,
hasCJK: this.computeHasCJK(words),
};
this.emitStateChange();
@@ -677,6 +715,48 @@ export class RSVPController extends EventTarget {
}
}
// Re-segment the current section in place after a setting (e.g. char mode)
// changes the word list. Keeps the reader near the same spot by matching the
// previous word's range against the new word list.
private reextractPreservingPosition(): void {
this.clearTimer();
const prevWord = this.state.words[this.state.currentIndex];
const words = this.extractWordsWithRanges();
let newIndex = 0;
if (words.length > 0 && prevWord?.range && prevWord.docIndex !== undefined) {
for (let i = 0; i < words.length; i++) {
const word = words[i];
if (!word?.range || word.docIndex !== prevWord.docIndex) continue;
try {
if (word.range.compareBoundaryPoints(Range.START_TO_START, prevWord.range) >= 0) {
newIndex = i;
break;
}
} catch {
// Detached or cross-document range compare throws; skip.
}
}
}
this.state = {
...this.state,
words,
currentIndex: words.length > 0 ? Math.min(words.length - 1, Math.max(0, newIndex)) : 0,
currentPartIndex: 0,
hasCJK: this.computeHasCJK(words),
};
this.emitStateChange();
if (this.state.playing && words.length > 0) {
this.scheduleNextWord();
}
}
private computeHasCJK(words: RsvpWord[]): boolean {
return words.some((word) => containsCJK(word.text));
}
private scheduleNextWord(): void {
this.clearTimer();
@@ -766,7 +846,7 @@ export class RSVPController extends EventTarget {
const walk = (node: Node): void => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
const nodeWords = splitTextIntoWords(text, this.primaryLanguage);
const nodeWords = splitTextIntoWords(text, this.primaryLanguage, this.state.cjkCharMode);
let offset = 0;
for (const word of nodeWords) {
@@ -828,8 +908,14 @@ export class RSVPController extends EventTarget {
const hasCJK = containsCJK(word);
if (hasCJK) {
// For CJK characters, center the ORP since each character is more balanced
return Math.floor(word.length / 2);
// Center the ORP on a real character — never on trailing punctuation.
// Char mode emits tokens like "是。" where a naive length/2 would land
// the focus on the punctuation instead of the character.
let coreLength = word.length;
while (coreLength > 0 && isCJKPunctuation(word[coreLength - 1]!)) {
coreLength--;
}
return Math.floor(Math.max(coreLength, 1) / 2);
}
const cleanWord = word.replace(/[^\p{L}\p{N}_]/gu, '');
@@ -16,6 +16,8 @@ export interface RsvpState {
wpm: number;
punctuationPauseMs: number;
splitHyphens: boolean;
cjkCharMode: boolean;
hasCJK: boolean;
progress: number;
}
+35 -6
View File
@@ -77,8 +77,15 @@ export function getSegmenterLocale(text: string): string | null {
* If `language` starts with `zh` and jieba-wasm has been initialized
* (see `initJieba` in @/utils/jieba), use it for higher-quality Chinese
* segmentation.
*
* When `cjkCharMode` is true, segmentation is skipped entirely and the text is
* split into individual characters (see `segmentCJKByCharacter`).
*/
export function segmentCJKText(text: string, language?: string): string[] {
export function segmentCJKText(text: string, language?: string, cjkCharMode = false): string[] {
if (cjkCharMode) {
return segmentCJKByCharacter(text);
}
if (language?.toLowerCase().startsWith('zh') && isJiebaReady()) {
try {
return segmentWithJieba(text);
@@ -225,6 +232,26 @@ function segmentWithJieba(text: string): string[] {
return words;
}
/**
* Split a CJK run into individual characters. Trailing CJK punctuation is
* attached to the preceding character so downstream pause logic still sees
* punctuation at token boundaries; a leading punctuation run with no preceding
* character is emitted as its own token. Iterates by code point so characters
* outside the BMP (e.g. CJK Extension B) stay intact.
*/
function segmentCJKByCharacter(text: string): string[] {
const words: string[] = [];
for (const char of text) {
if (char.trim() === '') continue;
if (isCJKPunctuation(char) && words.length > 0) {
words[words.length - 1] = words[words.length - 1] + char;
} else {
words.push(char);
}
}
return words;
}
/**
* Split a token on em-dash () and en-dash (), keeping the dash attached to
* the preceding non-empty segment so downstream pause logic still sees it as
@@ -253,9 +280,11 @@ function splitOnLongDashes(token: string): string[] {
}
/**
* Split text into words, handling both CJK and non-CJK text
* Split text into words, handling both CJK and non-CJK text. When
* `cjkCharMode` is true, CJK runs are split per-character instead of by
* word segmentation; non-CJK text is unaffected.
*/
export function splitTextIntoWords(text: string, language?: string): string[] {
export function splitTextIntoWords(text: string, language?: string, cjkCharMode = false): string[] {
const hasCJK = containsCJK(text);
if (!hasCJK) {
@@ -301,7 +330,7 @@ export function splitTextIntoWords(text: string, language?: string): string[] {
if (currentSegment) {
if (inCJKSequence) {
// Segment the CJK text (with any trailing punctuation)
words.push(...segmentCJKText(currentSegment, language));
words.push(...segmentCJKText(currentSegment, language, cjkCharMode));
} else {
words.push(currentSegment);
}
@@ -312,7 +341,7 @@ export function splitTextIntoWords(text: string, language?: string): string[] {
// Non-CJK, non-punctuation, non-whitespace character
if (inCJKSequence && currentSegment) {
// Segment the CJK text before continuing with non-CJK
words.push(...segmentCJKText(currentSegment, language));
words.push(...segmentCJKText(currentSegment, language, cjkCharMode));
currentSegment = '';
}
currentSegment += char;
@@ -322,7 +351,7 @@ export function splitTextIntoWords(text: string, language?: string): string[] {
if (currentSegment) {
if (inCJKSequence) {
words.push(...segmentCJKText(currentSegment, language));
words.push(...segmentCJKText(currentSegment, language, cjkCharMode));
} else {
words.push(currentSegment);
}