From ba6e5899e5eba273949d1282aad95ed9014f715c Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 17 May 2026 21:30:11 +0800 Subject: [PATCH] feat(reader): RSVP CJK character mode and whole-word highlight (#4199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * i18n: extract RSVP CJK character mode and highlight word strings Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../public/locales/ar/translation.json | 4 +- .../public/locales/bn/translation.json | 4 +- .../public/locales/bo/translation.json | 4 +- .../public/locales/de/translation.json | 4 +- .../public/locales/el/translation.json | 4 +- .../public/locales/es/translation.json | 4 +- .../public/locales/fa/translation.json | 4 +- .../public/locales/fr/translation.json | 4 +- .../public/locales/he/translation.json | 4 +- .../public/locales/hi/translation.json | 4 +- .../public/locales/hu/translation.json | 4 +- .../public/locales/id/translation.json | 4 +- .../public/locales/it/translation.json | 4 +- .../public/locales/ja/translation.json | 4 +- .../public/locales/ko/translation.json | 4 +- .../public/locales/ms/translation.json | 4 +- .../public/locales/nl/translation.json | 4 +- .../public/locales/pl/translation.json | 4 +- .../public/locales/pt-BR/translation.json | 4 +- .../public/locales/pt/translation.json | 4 +- .../public/locales/ro/translation.json | 4 +- .../public/locales/ru/translation.json | 4 +- .../public/locales/si/translation.json | 4 +- .../public/locales/sl/translation.json | 4 +- .../public/locales/sv/translation.json | 4 +- .../public/locales/ta/translation.json | 4 +- .../public/locales/th/translation.json | 4 +- .../public/locales/tr/translation.json | 4 +- .../public/locales/uk/translation.json | 4 +- .../public/locales/uz/translation.json | 4 +- .../public/locales/vi/translation.json | 4 +- .../public/locales/zh-CN/translation.json | 4 +- .../public/locales/zh-TW/translation.json | 4 +- .../components/rsvp-overlay-context.test.tsx | 95 +++++++++++++++++++ .../services/rsvp-controller.test.ts | 75 ++++++++++++++- .../src/__tests__/services/rsvp-utils.test.ts | 42 ++++++++ .../reader/components/rsvp/RSVPOverlay.tsx | 88 +++++++++++++---- .../src/services/rsvp/RSVPController.ts | 94 +++++++++++++++++- apps/readest-app/src/services/rsvp/types.ts | 2 + apps/readest-app/src/services/rsvp/utils.ts | 41 ++++++-- 40 files changed, 509 insertions(+), 60 deletions(-) diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 0615a53f..fe28876a 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -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": "تمييز الكلمة" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index a3ba092f..b4ae3562 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -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": "শব্দ হাইলাইট" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index fa4856ec..5ea608f3 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -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": "ཐ་སྙད་གསལ་སྟོན།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 1f3e8d62..f91cfac7 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index f77a1454..12093962 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -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": "Επισήμανση λέξης" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 5cd0db55..a52ee896 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 00593752..32945b01 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -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": "برجسته‌سازی واژه" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index da95ed81..81ba2e92 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 9b1bd537..23a3c507 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -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": "הדגשת מילה" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 705cabd4..d56a76e0 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -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": "शब्द हाइलाइट करें" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 32a8f286..5e1af5e2 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 24b24e53..ae24a0c6 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 5ee098fb..3ac794f1 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index d246226c..115ca8dd 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -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": "単語を強調" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 7195f52e..9ab6d452 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -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": "단어 강조" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 572dfdb3..8232a8fc 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 3e1ee79e..1d617b3f 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 6d56497a..67c07489 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index f7537dc6..5e2de866 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 220465db..654f337e 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 72bc7957..fc7b0f3e 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index ad2c9151..7b6cce79 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -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": "Подсветка слова" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 17560cb9..57689be1 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -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": "වචනය උද්දීපනය" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index c0d8e8e9..4fdc9e77 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index b423434a..264a6d42 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 5b242554..28b7e17b 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -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": "சொல்லை முன்னிலைப்படுத்து" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 3116e4b7..e64450d4 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -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": "เน้นคำ" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 14fa2d59..fc32faac 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 7c34b2a4..8d387b5a 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -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": "Підсвічування слова" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index d02dfc5b..b69c62a9 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index d1256fcb..75d53fc5 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -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ừ" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 130db9a8..bd9cb8b5 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -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": "高亮整词" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 192be8f3..0ab52576 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -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": "高亮整詞" } diff --git a/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx b/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx index 972f0d17..19ede700 100644 --- a/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx +++ b/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx @@ -32,6 +32,8 @@ const buildState = (overrides: Partial = {}): 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(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts index 6bdafee7..db53dc96 100644 --- a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts @@ -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([ + '我', + '喜', + '欢', + '阅', + '读', + ]); + }); + }); }); diff --git a/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts b/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts index 5d23638f..64d14fcd 100644 --- a/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts +++ b/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts @@ -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); + }); + }); }); diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx index 0f339124..0ccef068 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx @@ -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 elements and freeze the UI @@ -131,6 +132,13 @@ const RSVPOverlay: React.FC = ({ } return 0; }); + const [highlightWholeWord, setHighlightWholeWord] = useState(() => { + try { + return localStorage.getItem(STORAGE_KEY_HIGHLIGHT_WORD) === '1'; + } catch { + return false; + } + }); const contextWordRef = useRef(null); const touchStartX = useRef(0); const touchStartY = useRef(0); @@ -314,6 +322,15 @@ const RSVPOverlay: React.FC = ({ } }, []); + 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 = ({ 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. - {wordBefore} - - - {orpChar} + {currentWord.text} - - {wordAfter} - - + ) : ( + <> + + {wordBefore} + + + {orpChar} + + + {wordAfter} + + + ) ) : ( {_('Ready')} )} @@ -907,6 +935,34 @@ const RSVPOverlay: React.FC = ({ /> + {/* CJK character mode — split CJK text per-character */} + {state.hasCJK && ( +
+ {_('Character Mode')} + controller.setCjkCharMode(e.target.checked)} + /> +
+ )} + + {/* CJK whole-word highlight — color and center the full word */} + {state.hasCJK && ( +
+ {_('Highlight Word')} + updateHighlightWholeWord(e.target.checked)} + /> +
+ )} + {/* ORP color */}
{_('Focus')} diff --git a/apps/readest-app/src/services/rsvp/RSVPController.ts b/apps/readest-app/src/services/rsvp/RSVPController.ts index 262f4f11..1a603563 100644 --- a/apps/readest-app/src/services/rsvp/RSVPController.ts +++ b/apps/readest-app/src/services/rsvp/RSVPController.ts @@ -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, ''); diff --git a/apps/readest-app/src/services/rsvp/types.ts b/apps/readest-app/src/services/rsvp/types.ts index c369de76..d59def51 100644 --- a/apps/readest-app/src/services/rsvp/types.ts +++ b/apps/readest-app/src/services/rsvp/types.ts @@ -16,6 +16,8 @@ export interface RsvpState { wpm: number; punctuationPauseMs: number; splitHyphens: boolean; + cjkCharMode: boolean; + hasCJK: boolean; progress: number; } diff --git a/apps/readest-app/src/services/rsvp/utils.ts b/apps/readest-app/src/services/rsvp/utils.ts index b092dc50..43ed7404 100644 --- a/apps/readest-app/src/services/rsvp/utils.ts +++ b/apps/readest-app/src/services/rsvp/utils.ts @@ -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); }