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:
@@ -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+ Reader’dan import qilish"
|
||||
"Import from Moon+ Reader": "Moon+ Reader’dan 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user