feat: rsvp speed reading (#3121)

* feat: add RSVP speed reading feature

Implements Rapid Serial Visual Presentation (RSVP) speed reading mode:

- Display words one at a time with ORP (Optimal Recognition Point) highlighting
- Adjustable WPM speed (100-1000) with keyboard/button controls
- Punctuation pause settings (25-200ms)
- Progress tracking with chapter navigation
- Context panel showing surrounding text when paused
- Keyboard shortcuts (Space, Escape, arrows) and touch gestures
- Chapter selector for quick navigation
- Respects current theme colors
- Persists settings (WPM, pause duration, position) per book

New files:
- services/rsvp/RSVPController.ts - Main controller with playback logic
- services/rsvp/types.ts - TypeScript interfaces
- components/rsvp/RSVPOverlay.tsx - Full-screen RSVP reader UI
- components/rsvp/RSVPControl.tsx - Integration component
- components/rsvp/RSVPStartDialog.tsx - Start position selection

Closes #3111

* fix(rsvp): use portal to fix overlay stacking context issue

- Render RSVP overlay and start dialog via React Portal to document.body
- This ensures the overlay appears above all other content regardless of
  parent component CSS transforms or stacking contexts
- Add fallback colors for theme values to ensure solid background

* fix(rsvp): improve UX with progress sync, sentence highlight, and better dialogs

- Fix start dialog transparency by using solid opaque background with proper
  fallback colors for both light and dark modes
- Increase context words from 50 to 100 words before/after current word
- Add progress sync on RSVP exit - navigates reader to the last word position
- Add temporary bright green sentence underline on exit (5 second duration)
  to easily locate where reading left off
- Helper function to expand word range to full sentence boundaries

* fix(rsvp): store full BookNote for proper annotation removal

The addAnnotation API requires the full BookNote object (including CFI)
when removing annotations, not just the ID. Changed tempHighlightIdRef
to tempHighlightRef to store the complete BookNote object.

* test(rsvp): add Playwright e2e tests for RSVP feature

- Set up Playwright test infrastructure with config
- Add comprehensive e2e tests for RSVP speed reading:
  - Opening RSVP from View menu
  - Start dialog options
  - Play/pause toggle
  - Speed adjustment
  - Skip navigation
  - Keyboard shortcuts
  - Progress bar
  - Chapter navigation
  - Accessibility tests
- Add test data attributes and ARIA labels to RSVPOverlay
- Add test scripts to package.json

* fix(rsvp): clarify start dialog option labels

Update the RSVP start dialog to use clearer language:
- "From Beginning" → "From Chapter Start" (since it starts from chapter beginning)
- "From Current Position" → "From Current Page" (starts from visible page)

* fix(rsvp): use correct theme colors from themeCode

The RSVP components were using incorrect palette key names (camelCase
like `base100` instead of hyphenated like `base-100`), causing the
fallback colors to always be used instead of the reader's actual theme.

Fix by using themeCode.bg, themeCode.fg, and themeCode.primary directly,
which are already resolved from the palette with correct keys.

* fix(rsvp): use theme accent color for sentence underline and persist until page change

- Change underline color from hardcoded green (#22c55e) to theme accent
  color (themeCode.primary), matching the ORP focal point highlight
- Remove 5-second timeout that auto-removed the underline
- Add cleanup on page navigation, new RSVP session start, and unmount
- Add removeRsvpHighlight helper function for consistent cleanup

* fix(rsvp): transition to next chapter when reaching end of section

When RSVP reached the end of a chapter, it would restart the current
chapter instead of moving to the next one. This happened because RSVP
extracts ALL words from the current section via renderer.getContents(),
so when words run out, the entire chapter is done.

- Always use view.renderer.nextSection() when RSVP exhausts words
- This moves to the next chapter instead of staying in the current one

* refactor: remove test files, unsure of use case for now

* chore: remove Playwright e2e test scripts from package.json

- Deleted e2e test scripts related to Playwright from package.json as they are no longer needed.
- Removed Playwright as a dev dependency to streamline the project.

* fix(rsvp): ensure CFI retrieval occurs before navigation

- Updated comments to clarify the necessity of obtaining CFI for both the navigation and sentence highlight before invoking the goTo() method, as it may re-render the document and invalidate Range objects.
- Introduced a new variable for sentence text to enhance readability and maintainability of the code.

* chore: sync pnpm-lock.yaml after removing @playwright/test

* style: format RSVP components

* fix: lint errors and timezone-aware date formatting

* i18n: support CJK text and add translations

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
boludo00 2026-02-01 09:22:24 -08:00 committed by GitHub
parent 9f7147f8f8
commit bbbd378f9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3214 additions and 113 deletions

1
.gitignore vendored
View file

@ -8,6 +8,7 @@
# testing
/coverage
.playwright-mcp
# next.js
/.next/

View file

@ -1020,5 +1020,39 @@
"{{count}} book(s) synced_two": "تمت مزامنة كتابين",
"{{count}} book(s) synced_few": "تمت مزامنة {{count}} كتب",
"{{count}} book(s) synced_many": "تمت مزامنة {{count}} كتاباً",
"{{count}} book(s) synced_other": "تمت مزامنة {{count}} كتاب"
"{{count}} book(s) synced_other": "تمت مزامنة {{count}} كتاب",
"Unable to start RSVP": "تعذر بدء RSVP",
"RSVP not supported for PDF": "RSVP غير مدعوم لملفات PDF",
"Close RSVP": "إغلاق RSVP",
"Select Chapter": "اختر الفصل",
"{{number}} WPM": "{{number}} كلمة في الدقيقة",
"Context": "السياق",
"Ready": "جاهز",
"Chapter Progress": "تقدم الفصل",
"words": "كلمات",
"{{time}} left": "متبقي {{time}}",
"Reading progress": "تقدم القراءة",
"Click to seek": "انقر للتمرير",
"Skip back 15 words": "تخطي للخلف 15 كلمة",
"Back 15 words (Shift+Left)": "للخلف 15 كلمة (Shift+اليسار)",
"Pause (Space)": "إيقاف مؤقت (المسافة)",
"Play (Space)": "تشغيل (المسافة)",
"Skip forward 15 words": "تخطي للأمام 15 كلمة",
"Forward 15 words (Shift+Right)": "للأمام 15 كلمة (Shift+اليمين)",
"Pause:": "إيقاف مؤقت:",
"Decrease speed": "تقليل السرعة",
"Slower (Left/Down)": "أبطأ (اليسار/الأسفل)",
"Current speed": "السرعة الحالية",
"Increase speed": "زيادة السرعة",
"Faster (Right/Up)": "أسرع (اليمين/الأعلى)",
"Start RSVP Reading": "بدء قراءة RSVP",
"Choose where to start reading": "اختر من أين تبدأ القراءة",
"From Chapter Start": "من بداية الفصل",
"Start reading from the beginning of the chapter": "بدء القراءة من بداية الفصل",
"Resume": "استئناف",
"Continue from where you left off": "المتابعة من حيث توقف",
"From Current Page": "من الصفحة الحالية",
"Start from where you are currently reading": "البدء من حيث تقرأ حالياً",
"From Selection": "من التحديد",
"Speed Reading Mode": "وضع القراءة السريعة"
}

View file

@ -972,5 +972,39 @@
"Paragraph Mode": "অনুচ্ছেদ মোড",
"Embedding Model": "এম্বেডিং মডেল",
"{{count}} book(s) synced_one": "{{count}}টি বই সিঙ্ক করা হয়েছে",
"{{count}} book(s) synced_other": "{{count}}টি বই সিঙ্ক করা হয়েছে"
"{{count}} book(s) synced_other": "{{count}}টি বই সিঙ্ক করা হয়েছে",
"Unable to start RSVP": "RSVP शुरू করতে অসমর্থ",
"RSVP not supported for PDF": "PDF-এর জন্য RSVP সমর্থিত নয়",
"Close RSVP": "RSVP বন্ধ করুন",
"Select Chapter": "অধ্যায় নির্বাচন করুন",
"{{number}} WPM": "{{number}} WPM",
"Context": "প্রসঙ্গ",
"Ready": "প্রস্তুত",
"Chapter Progress": "অধ্যায় অগ্রগতি",
"words": "শব্দ",
"{{time}} left": "{{time}} বাকি",
"Reading progress": "পড়ার অগ্রগতি",
"Click to seek": "খুঁজতে ক্লিক করুন",
"Skip back 15 words": "১৫ শব্দ পিছিয়ে যান",
"Back 15 words (Shift+Left)": "১৫ শব্দ পিছিয়ে যান (Shift+Left)",
"Pause (Space)": "বিরতি (Space)",
"Play (Space)": "চালান (Space)",
"Skip forward 15 words": "১৫ শব্দ এগিয়ে যান",
"Forward 15 words (Shift+Right)": "১৫ শব্দ এগিয়ে যান (Shift+Right)",
"Pause:": "বিরতি:",
"Decrease speed": "গতি কমান",
"Slower (Left/Down)": "ধীর (Left/Down)",
"Current speed": "বর্তমান গতি",
"Increase speed": "গতি বাড়ান",
"Faster (Right/Up)": "দ্রুত (Right/Up)",
"Start RSVP Reading": "RSVP পড়া শুরু করুন",
"Choose where to start reading": "কোথা থেকে পড়া শুরু করবেন তা চয়ন করুন",
"From Chapter Start": "অধ্যায় শুরু থেকে",
"Start reading from the beginning of the chapter": "অধ্যায়ের শুরু থেকে পড়া শুরু করুন",
"Resume": "পুনরায় শুরু করুন",
"Continue from where you left off": "যেখানে আপনি ছেড়েছিলেন সেখান থেকে চালিয়ে যান",
"From Current Page": "বর্তমান পৃষ্ঠা থেকে",
"Start from where you are currently reading": "আপনি বর্তমানে যেখানে পড়ছেন সেখান থেকে শুরু করুন",
"From Selection": "নির্বাচন থেকে",
"Speed Reading Mode": "দ্রুত পাঠ্য মোড"
}

View file

@ -960,5 +960,39 @@
"Exit Paragraph Mode": "དུམ་མཚམས་རྣམ་པ་ནས་ཕྱིར་ཐོན།",
"Paragraph Mode": "དུམ་མཚམས་རྣམ་པ།",
"Embedding Model": "གནས་སྒྲིག་དཔེ་གཞི།",
"{{count}} book(s) synced_other": "དེབ་ {{count}} མཉམ་འགྲིག་བྱས་ཟིན།"
"{{count}} book(s) synced_other": "དེབ་ {{count}} མཉམ་འགྲིག་བྱས་ཟིན།",
"Unable to start RSVP": "RSVP འགོ་འཛུགས་མ་ཐུབ།",
"RSVP not supported for PDF": "PDF ལ་ RSVP རྒྱབ་སྐྱོར་མེད།",
"Close RSVP": "RSVP ཁ་རྒྱག་པ།",
"Select Chapter": "ལེའུ་འདེམས་པ།",
"{{number}} WPM": "{{number}} WPM",
"Context": "བརྗོད་དོན།",
"Ready": "གྲ་སྒྲིག་ཡོད།",
"Chapter Progress": "ལེའུའི་འཕེལ་རིམ།",
"words": "ཚིག",
"{{time}} left": "དུས་ཚོད་ {{time}} ལྷག་ཡོད།",
"Reading progress": "ཀློག་པའི་འཕེལ་རིམ།",
"Click to seek": "གནས་ས་འཚོལ་བར་གནོན་པ།",
"Skip back 15 words": "ཚིག་ ༡༥ རྒྱབ་ལ་བཤུད་པ།",
"Back 15 words (Shift+Left)": "ཚིག་ ༡༥ རྒྱབ་ལ་བཤུད་པ། (Shift+Left)",
"Pause (Space)": "མཚམས་འཇོག་པ། (Space)",
"Play (Space)": "གཏོང་བ། (Space)",
"Skip forward 15 words": "ཚིག་ ༡༥ མདུན་ལ་བཤུད་པ།",
"Forward 15 words (Shift+Right)": "ཚིག་ ༡༥ མདུན་ལ་བཤུད་པ། (Shift+Right)",
"Pause:": "མཚམས་འཇོག་པ།",
"Decrease speed": "མགྱོགས་ཚད་འགོར་དུ་གཏོང་བ།",
"Slower (Left/Down)": "འགོར་བ། (Left/Down)",
"Current speed": "ད་ལྟའི་མགྱོགས་ཚད།",
"Increase speed": "མགྱོགས་ཚད་མྱུར་དུ་གཏོང་བ།",
"Faster (Right/Up)": "མྱུར་བ། (Right/Up)",
"Start RSVP Reading": "RSVP ཀློག་འགོ་འཛུགས་པ།",
"Choose where to start reading": "གང་ནས་ཀློག་འགོ་འཛུགས་མིན་འདེམས་པ།",
"From Chapter Start": "ལེའུའི་འགོ་ནས།",
"Start reading from the beginning of the chapter": "ལེའུ་འདིའི་འགོ་ནས་ཀློག་པ།",
"Resume": "མུ་མཐུད་པ།",
"Continue from where you left off": "མཚམས་བཞག་སའི་གནས་ནས་མུ་མཐུད་པ།",
"From Current Page": "ད་ལྟའི་ཤོག་ལྷེ་ནས།",
"Start from where you are currently reading": "ད་ལྟ་ཀློག་བཞིན་པའི་གནས་ནས་འགོ་འཛུགས་པ།",
"From Selection": "བདམས་པའི་ནང་དོན་ནས།",
"Speed Reading Mode": "མྱུར་ཀློག་རྣམ་པ།"
}

View file

@ -972,5 +972,39 @@
"Paragraph Mode": "Absatzmodus",
"Embedding Model": "Einbettungsmodell",
"{{count}} book(s) synced_one": "{{count}} Buch synchronisiert",
"{{count}} book(s) synced_other": "{{count}} Bücher synchronisiert"
"{{count}} book(s) synced_other": "{{count}} Bücher synchronisiert",
"Unable to start RSVP": "RSVP kann nicht gestartet werden",
"RSVP not supported for PDF": "RSVP wird für PDF nicht unterstützt",
"Close RSVP": "RSVP schließen",
"Select Chapter": "Kapitel auswählen",
"{{number}} WPM": "{{number}} WPM",
"Context": "Kontext",
"Ready": "Bereit",
"Chapter Progress": "Kapitelfortschritt",
"words": "Wörter",
"{{time}} left": "{{time}} übrig",
"Reading progress": "Lesefortschritt",
"Click to seek": "Klicken zum Suchen",
"Skip back 15 words": "15 Wörter zurück",
"Back 15 words (Shift+Left)": "15 Wörter zurück (Shift+Links)",
"Pause (Space)": "Pause (Leertaste)",
"Play (Space)": "Abspielen (Leertaste)",
"Skip forward 15 words": "15 Wörter vor",
"Forward 15 words (Shift+Right)": "15 Wörter vor (Shift+Rechts)",
"Pause:": "Pause:",
"Decrease speed": "Geschwindigkeit verringern",
"Slower (Left/Down)": "Langsamer (Links/Unten)",
"Current speed": "Aktuelle Geschwindigkeit",
"Increase speed": "Geschwindigkeit erhöhen",
"Faster (Right/Up)": "Schneller (Rechts/Oben)",
"Start RSVP Reading": "RSVP-Lesen starten",
"Choose where to start reading": "Wählen Sie, wo das Lesen beginnen soll",
"From Chapter Start": "Vom Kapitelanfang",
"Start reading from the beginning of the chapter": "Ab dem Anfang des Kapitels lesen",
"Resume": "Fortsetzen",
"Continue from where you left off": "Dort fortfahren, wo Sie aufgehört haben",
"From Current Page": "Von der aktuellen Seite",
"Start from where you are currently reading": "Dort beginnen, wo Sie gerade lesen",
"From Selection": "Von der Auswahl",
"Speed Reading Mode": "Schnelllesemodus"
}

View file

@ -972,5 +972,39 @@
"Paragraph Mode": "Λειτουργία παραγράφου",
"Embedding Model": "Μοντέλο ενσωμάτωσης",
"{{count}} book(s) synced_one": "{{count}} βιβλίο συγχρονίστηκε",
"{{count}} book(s) synced_other": "{{count}} βιβλία συγχρονίστηκαν"
"{{count}} book(s) synced_other": "{{count}} βιβλία συγχρονίστηκαν",
"Unable to start RSVP": "Αδυναμία έναρξης RSVP",
"RSVP not supported for PDF": "Το RSVP δεν υποστηρίζεται για PDF",
"Close RSVP": "Κλείσιμο RSVP",
"Select Chapter": "Επιλογή Κεφαλαίου",
"{{number}} WPM": "{{number}} WPM",
"Context": "Πλαίσιο",
"Ready": "Έτοιμο",
"Chapter Progress": "Πρόοδος Κεφαλαίου",
"words": "λέξεις",
"{{time}} left": "{{time}} απομένουν",
"Reading progress": "Πρόοδος ανάγνωσης",
"Click to seek": "Κάντε κλικ για αναζήτηση",
"Skip back 15 words": "Επιστροφή 15 λέξεων",
"Back 15 words (Shift+Left)": "Πίσω 15 λέξεις (Shift+Αριστερά)",
"Pause (Space)": "Παύση (Διάστημα)",
"Play (Space)": "Αναπαραγωγή (Διάστημα)",
"Skip forward 15 words": "Προώθηση 15 λέξεων",
"Forward 15 words (Shift+Right)": "Εμπρός 15 λέξεις (Shift+Δεξιά)",
"Pause:": "Παύση:",
"Decrease speed": "Μείωση ταχύτητας",
"Slower (Left/Down)": "Πιο αργά (Αριστερά/Κάτω)",
"Current speed": "Τρέχουσα ταχύτητα",
"Increase speed": "Αύξηση ταχύτητας",
"Faster (Right/Up)": "Πιο γρήγορα (Δεξιά/Πάνω)",
"Start RSVP Reading": "Έναρξη ανάγνωσης RSVP",
"Choose where to start reading": "Επιλέξτε από πού θα ξεκινήσετε την ανάγνωση",
"From Chapter Start": "Από την αρχή του κεφαλαίου",
"Start reading from the beginning of the chapter": "Ξεκινήστε την ανάγνωση από την αρχή του κεφαλαίου",
"Resume": "Συνέχεια",
"Continue from where you left off": "Συνεχίστε από εκεί που σταματήσατε",
"From Current Page": "Από την τρέχουσα σελίδα",
"Start from where you are currently reading": "Ξεκινήστε από εκεί που διαβάζετε αυτή τη στιγμή",
"From Selection": "Από την επιλογή",
"Speed Reading Mode": "Λειτουργία ταχείας ανάγνωσης"
}

View file

@ -984,5 +984,39 @@
"Embedding Model": "Modelo de incrustación",
"{{count}} book(s) synced_one": "{{count}} libro sincronizado",
"{{count}} book(s) synced_many": "{{count}} libros sincronizados",
"{{count}} book(s) synced_other": "{{count}} libros sincronizados"
"{{count}} book(s) synced_other": "{{count}} libros sincronizados",
"Unable to start RSVP": "No se puede iniciar RSVP",
"RSVP not supported for PDF": "RSVP no es compatible con PDF",
"Close RSVP": "Cerrar RSVP",
"Select Chapter": "Seleccionar capítulo",
"{{number}} WPM": "{{number}} PPM",
"Context": "Contexto",
"Ready": "Listo",
"Chapter Progress": "Progreso del capítulo",
"words": "palabras",
"{{time}} left": "faltan {{time}}",
"Reading progress": "Progreso de lectura",
"Click to seek": "Hacer clic para buscar",
"Skip back 15 words": "Retroceder 15 palabras",
"Back 15 words (Shift+Left)": "Retroceder 15 palabras (Shift+Izquierda)",
"Pause (Space)": "Pausa (Espacio)",
"Play (Space)": "Reproducir (Espacio)",
"Skip forward 15 words": "Adelantar 15 palabras",
"Forward 15 words (Shift+Right)": "Adelantar 15 palabras (Shift+Derecha)",
"Pause:": "Pausa:",
"Decrease speed": "Disminuir velocidad",
"Slower (Left/Down)": "Más lento (Izquierda/Abajo)",
"Current speed": "Velocidad actual",
"Increase speed": "Aumentar velocidad",
"Faster (Right/Up)": "Más rápido (Derecha/Arriba)",
"Start RSVP Reading": "Iniciar lectura RSVP",
"Choose where to start reading": "Elegir dónde empezar a leer",
"From Chapter Start": "Desde el inicio del capítulo",
"Start reading from the beginning of the chapter": "Empezar a leer desde el principio del capítulo",
"Resume": "Reanudar",
"Continue from where you left off": "Continuar desde donde lo dejaste",
"From Current Page": "Desde la página actual",
"Start from where you are currently reading": "Empezar desde donde estás leyendo actualmente",
"From Selection": "Desde la selección",
"Speed Reading Mode": "Modo de lectura rápida"
}

View file

@ -972,5 +972,39 @@
"Paragraph Mode": "حالت بند",
"Embedding Model": "مدل جاسازی",
"{{count}} book(s) synced_one": "{{count}} کتاب همگام شد",
"{{count}} book(s) synced_other": "{{count}} کتاب همگام شدند"
"{{count}} book(s) synced_other": "{{count}} کتاب همگام شدند",
"Unable to start RSVP": "قادر به شروع RSVP نیست",
"RSVP not supported for PDF": "RSVP برای PDF پشتیبانی نمی‌شود",
"Close RSVP": "بستن RSVP",
"Select Chapter": "انتخاب فصل",
"{{number}} WPM": "{{number}} کلمه در دقیقه",
"Context": "زمینه",
"Ready": "آماده",
"Chapter Progress": "پیشرفت فصل",
"words": "کلمات",
"{{time}} left": "{{time}} باقی‌مانده",
"Reading progress": "پیشرفت مطالعه",
"Click to seek": "برای جستجو کلیک کنید",
"Skip back 15 words": "۱۵ کلمه به عقب",
"Back 15 words (Shift+Left)": "۱۵ کلمه به عقب (Shift+Left)",
"Pause (Space)": "توقف (Space)",
"Play (Space)": "پخش (Space)",
"Skip forward 15 words": "۱۵ کلمه به جلو",
"Forward 15 words (Shift+Right)": "۱۵ کلمه به جلو (Shift+Right)",
"Pause:": "توقف:",
"Decrease speed": "کاهش سرعت",
"Slower (Left/Down)": "کندتر (Left/Down)",
"Current speed": "سرعت فعلی",
"Increase speed": "افزایش سرعت",
"Faster (Right/Up)": "سریع‌تر (Right/Up)",
"Start RSVP Reading": "شروع مطالعه RSVP",
"Choose where to start reading": "انتخاب کنید از کجا مطالعه شروع شود",
"From Chapter Start": "از ابتدای فصل",
"Start reading from the beginning of the chapter": "شروع مطالعه از ابتدای فصل",
"Resume": "ادامه",
"Continue from where you left off": "ادامه از جایی که متوقف شدید",
"From Current Page": "از صفحه فعلی",
"Start from where you are currently reading": "شروع از جایی که در حال مطالعه هستید",
"From Selection": "از انتخاب",
"Speed Reading Mode": "حالت مطالعه سریع"
}

View file

@ -984,5 +984,39 @@
"Embedding Model": "Modèle d'incorporation",
"{{count}} book(s) synced_one": "{{count}} livre synchronisé",
"{{count}} book(s) synced_many": "{{count}} livres synchronisés",
"{{count}} book(s) synced_other": "{{count}} livres synchronisés"
"{{count}} book(s) synced_other": "{{count}} livres synchronisés",
"Unable to start RSVP": "Impossible de démarrer RSVP",
"RSVP not supported for PDF": "RSVP non pris en charge pour PDF",
"Close RSVP": "Fermer RSVP",
"Select Chapter": "Sélectionner le chapitre",
"{{number}} WPM": "{{number}} MPM",
"Context": "Contexte",
"Ready": "Prêt",
"Chapter Progress": "Progression du chapitre",
"words": "mots",
"{{time}} left": "{{time}} restant",
"Reading progress": "Progression de la lecture",
"Click to seek": "Cliquer pour chercher",
"Skip back 15 words": "Reculer de 15 mots",
"Back 15 words (Shift+Left)": "Reculer de 15 mots (Shift+Left)",
"Pause (Space)": "Pause (Espace)",
"Play (Space)": "Lecture (Espace)",
"Skip forward 15 words": "Avancer de 15 mots",
"Forward 15 words (Shift+Right)": "Avancer de 15 mots (Shift+Right)",
"Pause:": "Pause :",
"Decrease speed": "Diminuer la vitesse",
"Slower (Left/Down)": "Plus lent (Gauche/Bas)",
"Current speed": "Vitesse actuelle",
"Increase speed": "Augmenter la vitesse",
"Faster (Right/Up)": "Plus rapide (Droite/Haut)",
"Start RSVP Reading": "Démarrer la lecture RSVP",
"Choose where to start reading": "Choisir où commencer la lecture",
"From Chapter Start": "Depuis le début du chapitre",
"Start reading from the beginning of the chapter": "Commencer la lecture au début du chapitre",
"Resume": "Reprendre",
"Continue from where you left off": "Continuer là où vous vous êtes arrêté",
"From Current Page": "Depuis la page actuelle",
"Start from where you are currently reading": "Commencer là où vous lisez actuellement",
"From Selection": "Depuis la sélection",
"Speed Reading Mode": "Mode lecture rapide"
}

View file

@ -972,5 +972,39 @@
"Paragraph Mode": "पैराग्राफ मोड",
"Embedding Model": "एंबेडिंग मॉडल",
"{{count}} book(s) synced_one": "{{count}} पुस्तक सिंक की गई",
"{{count}} book(s) synced_other": "{{count}} पुस्तकें सिंक की गईं"
"{{count}} book(s) synced_other": "{{count}} पुस्तकें सिंक की गईं",
"Unable to start RSVP": "RSVP शुरू करने में असमर्थ",
"RSVP not supported for PDF": "PDF के लिए RSVP समर्थित नहीं है",
"Close RSVP": "RSVP बंद करें",
"Select Chapter": "अध्याय चुनें",
"{{number}} WPM": "{{number}} WPM",
"Context": "संदर्भ",
"Ready": "तैयार",
"Chapter Progress": "अध्याय प्रगति",
"words": "शब्द",
"{{time}} left": "{{time}} शेष",
"Reading progress": "पठन प्रगति",
"Click to seek": "सीक करने के लिए क्लिक करें",
"Skip back 15 words": "15 शब्द पीछे",
"Back 15 words (Shift+Left)": "15 शब्द पीछे (Shift+Left)",
"Pause (Space)": "विराम (Space)",
"Play (Space)": "चलाएं (Space)",
"Skip forward 15 words": "15 शब्द आगे",
"Forward 15 words (Shift+Right)": "15 शब्द आगे (Shift+Right)",
"Pause:": "विराम:",
"Decrease speed": "गति कम करें",
"Slower (Left/Down)": "धीमी (Left/Down)",
"Current speed": "वर्तमान गति",
"Increase speed": "गति बढ़ाएं",
"Faster (Right/Up)": "तेज़ (Right/Up)",
"Start RSVP Reading": "RSVP पठन शुरू करें",
"Choose where to start reading": "चुनें कि कहाँ से पढ़ना शुरू करना है",
"From Chapter Start": "अध्याय के आरंभ से",
"Start reading from the beginning of the chapter": "अध्याय की शुरुआत से पढ़ना शुरू करें",
"Resume": "फिर से शुरू करें",
"Continue from where you left off": "वहीं से जारी रखें जहाँ आपने छोड़ा था",
"From Current Page": "वर्तमान पृष्ठ से",
"Start from where you are currently reading": "जहाँ आप अभी पढ़ रहे हैं वहीं से शुरू करें",
"From Selection": "चयन से",
"Speed Reading Mode": "त्वरित पठन मोड"
}

View file

@ -960,5 +960,39 @@
"Exit Paragraph Mode": "Keluar dari Mode Paragraf",
"Paragraph Mode": "Mode Paragraf",
"Embedding Model": "Model Penyematan",
"{{count}} book(s) synced_other": "{{count}} buku telah disinkronkan"
"{{count}} book(s) synced_other": "{{count}} buku telah disinkronkan",
"Unable to start RSVP": "Tidak dapat memulai RSVP",
"RSVP not supported for PDF": "RSVP tidak didukung untuk PDF",
"Close RSVP": "Tutup RSVP",
"Select Chapter": "Pilih Bab",
"{{number}} WPM": "{{number}} WPM",
"Context": "Konteks",
"Ready": "Siap",
"Chapter Progress": "Kemajuan Bab",
"words": "kata",
"{{time}} left": "{{time}} tersisa",
"Reading progress": "Kemajuan membaca",
"Click to seek": "Klik untuk mencari",
"Skip back 15 words": "Lompat mundur 15 kata",
"Back 15 words (Shift+Left)": "Mundur 15 kata (Shift+Kiri)",
"Pause (Space)": "Jeda (Spasi)",
"Play (Space)": "Putar (Spasi)",
"Skip forward 15 words": "Lompat maju 15 kata",
"Forward 15 words (Shift+Right)": "Maju 15 kata (Shift+Kanan)",
"Pause:": "Jeda:",
"Decrease speed": "Kurangi kecepatan",
"Slower (Left/Down)": "Lebih lambat (Kiri/Bawah)",
"Current speed": "Kecepatan saat ini",
"Increase speed": "Tambah kecepatan",
"Faster (Right/Up)": "Lebih cepat (Kanan/Atas)",
"Start RSVP Reading": "Mulai Membaca RSVP",
"Choose where to start reading": "Pilih tempat untuk mulai membaca",
"From Chapter Start": "Dari Awal Bab",
"Start reading from the beginning of the chapter": "Mulai membaca dari awal bab",
"Resume": "Lanjutkan",
"Continue from where you left off": "Lanjutkan dari tempat Anda berhenti",
"From Current Page": "Dari Halaman Saat Ini",
"Start from where you are currently reading": "Mulai dari tempat Anda sedang membaca",
"From Selection": "Dari Pilihan",
"Speed Reading Mode": "Mode Baca Cepat"
}

View file

@ -984,5 +984,39 @@
"Embedding Model": "Modello di incorporazione",
"{{count}} book(s) synced_one": "{{count}} libro sincronizzato",
"{{count}} book(s) synced_many": "{{count}} libri sincronizzati",
"{{count}} book(s) synced_other": "{{count}} libri sincronizzati"
"{{count}} book(s) synced_other": "{{count}} libri sincronizzati",
"Unable to start RSVP": "Impossibile avviare RSVP",
"RSVP not supported for PDF": "RSVP non supportato per PDF",
"Close RSVP": "Chiudi RSVP",
"Select Chapter": "Seleziona capitolo",
"{{number}} WPM": "{{number}} PPM",
"Context": "Contesto",
"Ready": "Pronto",
"Chapter Progress": "Progresso capitolo",
"words": "parole",
"{{time}} left": "{{time}} rimanenti",
"Reading progress": "Progresso lettura",
"Click to seek": "Clicca per cercare",
"Skip back 15 words": "Indietro di 15 parole",
"Back 15 words (Shift+Left)": "Indietro di 15 parole (Shift+Sinistra)",
"Pause (Space)": "Pausa (Spazio)",
"Play (Space)": "Riproduci (Spazio)",
"Skip forward 15 words": "Avanti di 15 parole",
"Forward 15 words (Shift+Right)": "Avanti di 15 parole (Shift+Destra)",
"Pause:": "Pausa:",
"Decrease speed": "Diminuisci velocità",
"Slower (Left/Down)": "Più lento (Sinistra/Giù)",
"Current speed": "Velocità attuale",
"Increase speed": "Aumenta velocità",
"Faster (Right/Up)": "Più veloce (Destra/Su)",
"Start RSVP Reading": "Avvia lettura RSVP",
"Choose where to start reading": "Scegli dove iniziare a leggere",
"From Chapter Start": "Dall'inizio del capitolo",
"Start reading from the beginning of the chapter": "Inizia a leggere dall'inizio del capitolo",
"Resume": "Riprendi",
"Continue from where you left off": "Continua da dove avevi interrotto",
"From Current Page": "Dalla pagina corrente",
"Start from where you are currently reading": "Inizia da dove stai leggendo",
"From Selection": "Dalla selezione",
"Speed Reading Mode": "Modalità lettura veloce"
}

View file

@ -960,5 +960,39 @@
"Exit Paragraph Mode": "段落モードを終了",
"Paragraph Mode": "段落モード",
"Embedding Model": "埋め込みモデル",
"{{count}} book(s) synced_other": "{{count}} 冊の本が同期されました"
"{{count}} book(s) synced_other": "{{count}} 冊の本が同期されました",
"Unable to start RSVP": "RSVPを起動できません",
"RSVP not supported for PDF": "PDFはRSVPに対応していません",
"Close RSVP": "RSVPを閉じる",
"Select Chapter": "章を選択",
"{{number}} WPM": "{{number}} WPM",
"Context": "コンテキスト",
"Ready": "準備完了",
"Chapter Progress": "章の進捗",
"words": "単語",
"{{time}} left": "残り {{time}}",
"Reading progress": "読書の進捗",
"Click to seek": "クリックしてシーク",
"Skip back 15 words": "15単語戻る",
"Back 15 words (Shift+Left)": "15単語戻る (Shift+左)",
"Pause (Space)": "一時停止 (Space)",
"Play (Space)": "再生 (Space)",
"Skip forward 15 words": "15単語進む",
"Forward 15 words (Shift+Right)": "15単語進む (Shift+右)",
"Pause:": "一時停止:",
"Decrease speed": "速度を下げる",
"Slower (Left/Down)": "低速 (左/下)",
"Current speed": "現在の速度",
"Increase speed": "速度を上げる",
"Faster (Right/Up)": "高速 (右/上)",
"Start RSVP Reading": "RSVP読書を開始",
"Choose where to start reading": "読書を開始する場所を選択",
"From Chapter Start": "章の最初から",
"Start reading from the beginning of the chapter": "この章の最初から読み直す",
"Resume": "再開",
"Continue from where you left off": "中断した場所から再開する",
"From Current Page": "現在のページから",
"Start from where you are currently reading": "現在読んでいる場所から開始する",
"From Selection": "選択範囲から",
"Speed Reading Mode": "速読モード"
}

View file

@ -960,5 +960,39 @@
"Exit Paragraph Mode": "단락 모드 종료",
"Paragraph Mode": "단락 모드",
"Embedding Model": "임베딩 모델",
"{{count}} book(s) synced_other": "{{count}}권의 책이 동기화되었습니다"
"{{count}} book(s) synced_other": "{{count}}권의 책이 동기화되었습니다",
"Unable to start RSVP": "RSVP를 시작할 수 없습니다",
"RSVP not supported for PDF": "PDF는 RSVP를 지원하지 않습니다",
"Close RSVP": "RSVP 닫기",
"Select Chapter": "챕터 선택",
"{{number}} WPM": "{{number}} WPM",
"Context": "문맥",
"Ready": "준비됨",
"Chapter Progress": "챕터 진행 상황",
"words": "단어",
"{{time}} left": "{{time}} 남음",
"Reading progress": "독서 진행 상황",
"Click to seek": "클릭하여 탐색",
"Skip back 15 words": "15단어 뒤로",
"Back 15 words (Shift+Left)": "15단어 뒤로 (Shift+왼쪽 화살표)",
"Pause (Space)": "일시정지 (스페이스바)",
"Play (Space)": "재생 (스페이스바)",
"Skip forward 15 words": "15단어 앞으로",
"Forward 15 words (Shift+Right)": "15단어 앞으로 (Shift+오른쪽 화살표)",
"Pause:": "일시정지:",
"Decrease speed": "속도 줄이기",
"Slower (Left/Down)": "느리게 (왼쪽/아래 화살표)",
"Current speed": "현재 속도",
"Increase speed": "속도 높이기",
"Faster (Right/Up)": "빠르게 (오른쪽/위 화살표)",
"Start RSVP Reading": "RSVP 독서 시작",
"Choose where to start reading": "독서를 시작할 위치 선택",
"From Chapter Start": "챕터 시작부터",
"Start reading from the beginning of the chapter": "챕터 처음부터 다시 시작",
"Resume": "재개",
"Continue from where you left off": "중단한 지점부터 계속 읽기",
"From Current Page": "현재 페이지부터",
"Start from where you are currently reading": "현재 읽고 있는 위치부터 시작",
"From Selection": "선택 범위부터",
"Speed Reading Mode": "속독 모드"
}

View file

@ -960,5 +960,39 @@
"Exit Paragraph Mode": "Keluar dari Mod Perenggan",
"Paragraph Mode": "Mod Perenggan",
"Embedding Model": "Model Benaman",
"{{count}} book(s) synced_other": "{{count}} buku telah disegerakkan"
"{{count}} book(s) synced_other": "{{count}} buku telah disegerakkan",
"Unable to start RSVP": "Tidak dapat memulakan RSVP",
"RSVP not supported for PDF": "RSVP tidak disokong untuk PDF",
"Close RSVP": "Tutup RSVP",
"Select Chapter": "Pilih Bab",
"{{number}} WPM": "{{number}} WPM",
"Context": "Konteks",
"Ready": "Sedia",
"Chapter Progress": "Kemajuan Bab",
"words": "perkataan",
"{{time}} left": "{{time}} tinggal",
"Reading progress": "Kemajuan membaca",
"Click to seek": "Klik untuk cari",
"Skip back 15 words": "Langkau belakang 15 perkataan",
"Back 15 words (Shift+Left)": "Belakang 15 perkataan (Shift+Kiri)",
"Pause (Space)": "Jeda (Ruang)",
"Play (Space)": "Main (Ruang)",
"Skip forward 15 words": "Langkau depan 15 perkataan",
"Forward 15 words (Shift+Right)": "Depan 15 perkataan (Shift+Kanan)",
"Pause:": "Jeda:",
"Decrease speed": "Kurangkan kelajuan",
"Slower (Left/Down)": "Lebih perlahan (Kiri/Bawah)",
"Current speed": "Kelajuan semasa",
"Increase speed": "Tingkatkan kelajuan",
"Faster (Right/Up)": "Lebih cepat (Kanan/Atas)",
"Start RSVP Reading": "Mulakan Pembacaan RSVP",
"Choose where to start reading": "Pilih tempat untuk mula membaca",
"From Chapter Start": "Dari Permulaan Bab",
"Start reading from the beginning of the chapter": "Mula membaca dari awal bab",
"Resume": "Sambung",
"Continue from where you left off": "Sambung dari tempat anda berhenti",
"From Current Page": "Dari Halaman Semasa",
"Start from where you are currently reading": "Mula dari tempat anda sedang membaca",
"From Selection": "Dari Pilihan",
"Speed Reading Mode": "Mod Bacaan Pantas"
}

View file

@ -972,5 +972,39 @@
"Paragraph Mode": "Alineamodus",
"Embedding Model": "Insluitmodel",
"{{count}} book(s) synced_one": "{{count}} boek gesynchroniseerd",
"{{count}} book(s) synced_other": "{{count}} boeken gesynchroniseerd"
"{{count}} book(s) synced_other": "{{count}} boeken gesynchroniseerd",
"Unable to start RSVP": "Kan RSVP niet starten",
"RSVP not supported for PDF": "RSVP niet ondersteund voor PDF",
"Close RSVP": "RSVP sluiten",
"Select Chapter": "Hoofdstuk selecteren",
"{{number}} WPM": "{{number}} WPM",
"Context": "Context",
"Ready": "Klaar",
"Chapter Progress": "Hoofdstukvoortgang",
"words": "woorden",
"{{time}} left": "{{time}} over",
"Reading progress": "Leesvoortgang",
"Click to seek": "Klik om te zoeken",
"Skip back 15 words": "15 woorden terug",
"Back 15 words (Shift+Left)": "15 woorden terug (Shift+Links)",
"Pause (Space)": "Pauze (Spatie)",
"Play (Space)": "Afspelen (Spatie)",
"Skip forward 15 words": "15 woorden vooruit",
"Forward 15 words (Shift+Right)": "15 woorden vooruit (Shift+Rechts)",
"Pause:": "Pauze:",
"Decrease speed": "Snelheid verlagen",
"Slower (Left/Down)": "Langzamer (Links/Omlaag)",
"Current speed": "Huidige snelheid",
"Increase speed": "Snelheid verhogen",
"Faster (Right/Up)": "Sneller (Rechts/Omhoog)",
"Start RSVP Reading": "Start RSVP-lezen",
"Choose where to start reading": "Kies waar u wilt beginnen met lezen",
"From Chapter Start": "Vanaf begin hoofdstuk",
"Start reading from the beginning of the chapter": "Begin met lezen aan het begin van het hoofdstuk",
"Resume": "Hervatten",
"Continue from where you left off": "Ga verder waar u was gebleven",
"From Current Page": "Vanaf huidige pagina",
"Start from where you are currently reading": "Begin waar u momenteel leest",
"From Selection": "Vanaf selectie",
"Speed Reading Mode": "Snelle leesmodus"
}

View file

@ -996,5 +996,39 @@
"{{count}} book(s) synced_one": "{{count}} książka zsynchronizowana",
"{{count}} book(s) synced_few": "{{count}} książki zsynchronizowane",
"{{count}} book(s) synced_many": "{{count}} książek zsynchronizowanych",
"{{count}} book(s) synced_other": "{{count}} książek zsynchronizowanych"
"{{count}} book(s) synced_other": "{{count}} książek zsynchronizowanych",
"Unable to start RSVP": "Nie można uruchomić RSVP",
"RSVP not supported for PDF": "RSVP nie jest obsługiwane dla PDF",
"Close RSVP": "Zamknij RSVP",
"Select Chapter": "Wybierz rozdział",
"{{number}} WPM": "{{number}} WPM",
"Context": "Kontekst",
"Ready": "Gotowy",
"Chapter Progress": "Postęp rozdziału",
"words": "słowa",
"{{time}} left": "pozostało {{time}}",
"Reading progress": "Postęp czytania",
"Click to seek": "Kliknij, aby wyszukać",
"Skip back 15 words": "Cofnij o 15 słów",
"Back 15 words (Shift+Left)": "Cofnij o 15 słów (Shift+Lewo)",
"Pause (Space)": "Pauze (Spacja)",
"Play (Space)": "Odtwarzaj (Spacja)",
"Skip forward 15 words": "Naprzód o 15 słów",
"Forward 15 words (Shift+Right)": "Naprzód o 15 słów (Shift+Prawo)",
"Pause:": "Pauza:",
"Decrease speed": "Zmniejsz prędkość",
"Slower (Left/Down)": "Wolniej (Lewo/Dół)",
"Current speed": "Aktualna prędkość",
"Increase speed": "Zwiększ prędkość",
"Faster (Right/Up)": "Szybciej (Prawo/Góra)",
"Start RSVP Reading": "Uruchom czytanie RSVP",
"Choose where to start reading": "Wybierz, gdzie zacząć czytać",
"From Chapter Start": "Od początku rozdziału",
"Start reading from the beginning of the chapter": "Zacznij czytać od początku rozdziału",
"Resume": "Wznów",
"Continue from where you left off": "Kontynuuj od miejsca, w którym przerwałeś",
"From Current Page": "Od bieżącej strony",
"Start from where you are currently reading": "Zacznij od miejsca, w którym aktualnie czytasz",
"From Selection": "Z zaznaczenia",
"Speed Reading Mode": "Tryb szybkiego czytania"
}

View file

@ -984,5 +984,39 @@
"Embedding Model": "Modelo de incorporação",
"{{count}} book(s) synced_one": "{{count}} livro sincronizado",
"{{count}} book(s) synced_many": "{{count}} livros sincronizados",
"{{count}} book(s) synced_other": "{{count}} livros sincronizados"
"{{count}} book(s) synced_other": "{{count}} livros sincronizados",
"Unable to start RSVP": "Não foi possível iniciar o RSVP",
"RSVP not supported for PDF": "RSVP não suportado para PDF",
"Close RSVP": "Fechar RSVP",
"Select Chapter": "Selecionar capítulo",
"{{number}} WPM": "{{number}} PPM",
"Context": "Contexto",
"Ready": "Pronto",
"Chapter Progress": "Progresso do capítulo",
"words": "palavras",
"{{time}} left": "{{time}} restante",
"Reading progress": "Progresso de leitura",
"Click to seek": "Clique para navegar",
"Skip back 15 words": "Voltar 15 palavras",
"Back 15 words (Shift+Left)": "Voltar 15 palavras (Shift+Esquerda)",
"Pause (Space)": "Pausar (Espaço)",
"Play (Space)": "Reproduzir (Espaço)",
"Skip forward 15 words": "Avançar 15 palavras",
"Forward 15 words (Shift+Right)": "Avançar 15 palavras (Shift+Direita)",
"Pause:": "Pausa:",
"Decrease speed": "Diminuir velocidade",
"Slower (Left/Down)": "Mais lento (Esquerda/Baixo)",
"Current speed": "Velocidade atual",
"Increase speed": "Aumentar velocidade",
"Faster (Right/Up)": "Mais rápido (Direita/Cima)",
"Start RSVP Reading": "Iniciar leitura RSVP",
"Choose where to start reading": "Escolha onde começar a ler",
"From Chapter Start": "Do início do capítulo",
"Start reading from the beginning of the chapter": "Começar a ler do início do capítulo",
"Resume": "Retomar",
"Continue from where you left off": "Continuar de onde parou",
"From Current Page": "Da página atual",
"Start from where you are currently reading": "Começar de onde está lendo no momento",
"From Selection": "Da seleção",
"Speed Reading Mode": "Modo de leitura rápida"
}

View file

@ -996,5 +996,39 @@
"{{count}} book(s) synced_one": "{{count}} книга синхронизирована",
"{{count}} book(s) synced_few": "{{count}} книги синхронизированы",
"{{count}} book(s) synced_many": "{{count}} книг синхронизировано",
"{{count}} book(s) synced_other": "{{count}} книг синхронизировано"
"{{count}} book(s) synced_other": "{{count}} книг синхронизировано",
"Unable to start RSVP": "Не удалось запустить RSVP",
"RSVP not supported for PDF": "RSVP не поддерживается для PDF",
"Close RSVP": "Закрыть RSVP",
"Select Chapter": "Выбрать главу",
"{{number}} WPM": "{{number}} WPM",
"Context": "Контекст",
"Ready": "Готово",
"Chapter Progress": "Прогресс главы",
"words": "слов",
"{{time}} left": "осталось {{time}}",
"Reading progress": "Прогресс чтения",
"Click to seek": "Нажмите для поиска",
"Skip back 15 words": "Назад на 15 слов",
"Back 15 words (Shift+Left)": "Назад на 15 слов (Shift+Влево)",
"Pause (Space)": "Пауза (Пробел)",
"Play (Space)": "Воспроизведение (Пробел)",
"Skip forward 15 words": "Вперед на 15 слов",
"Forward 15 words (Shift+Right)": "Вперед на 15 слов (Shift+Вправо)",
"Pause:": "Пауза:",
"Decrease speed": "Уменьшить скорость",
"Slower (Left/Down)": "Медленнее (Влево/Вниз)",
"Current speed": "Текущая скорость",
"Increase speed": "Увеличить скорость",
"Faster (Right/Up)": "Быстрее (Вправо/Вверх)",
"Start RSVP Reading": "Начать чтение RSVP",
"Choose where to start reading": "Выберите, где начать чтение",
"From Chapter Start": "С начала главы",
"Start reading from the beginning of the chapter": "Начать чтение с начала главы",
"Resume": "Продолжить",
"Continue from where you left off": "Продолжить с того места, где вы остановились",
"From Current Page": "С текущей страницы",
"Start from where you are currently reading": "Начать с того места, где вы сейчас читаете",
"From Selection": "Из выбранного",
"Speed Reading Mode": "Режим быстрого чтения"
}

View file

@ -972,5 +972,39 @@
"Paragraph Mode": "ඡේද ප්‍රකාරය",
"Embedding Model": "එබ්බවීමේ ආකෘතිය",
"{{count}} book(s) synced_one": "{{count}} පොතක් සමමුහුර්ත කරන ලදී",
"{{count}} book(s) synced_other": "{{count}} පොත් සමමුහුර්ත කරන ලදී"
"{{count}} book(s) synced_other": "{{count}} පොත් සමමුහුර්ත කරන ලදී",
"Unable to start RSVP": "RSVP ආරම්භ කිරීමට නොහැක",
"RSVP not supported for PDF": "PDF සඳහා RSVP සහාය නොදක්වයි",
"Close RSVP": "RSVP වසන්න",
"Select Chapter": "පරිච්ඡේදය තෝරන්න",
"{{number}} WPM": "{{number}} WPM",
"Context": "සන්දර්භය",
"Ready": "සූදානම්",
"Chapter Progress": "පරිච්ඡේදයේ ප්‍රගතිය",
"words": "වචන",
"{{time}} left": "{{time}} ඉතිරිව ඇත",
"Reading progress": "කියවීමේ ප්‍රගතිය",
"Click to seek": "සොයා බැලීමට ක්ලික් කරන්න",
"Skip back 15 words": "වචන 15ක් පසුපසට",
"Back 15 words (Shift+Left)": "වචන 15ක් පසුපසට (Shift+Left)",
"Pause (Space)": "විරාමය (Space)",
"Play (Space)": "ධාවනය (Space)",
"Skip forward 15 words": "වචන 15ක් ඉදිරියට",
"Forward 15 words (Shift+Right)": "වචන 15ක් ඉදිරියට (Shift+Right)",
"Pause:": "විරාමය:",
"Decrease speed": "වේගය අඩු කරන්න",
"Slower (Left/Down)": "මන්දගාමී (Left/Down)",
"Current speed": "වත්මන් වේගය",
"Increase speed": "වේගය වැඩි කරන්න",
"Faster (Right/Up)": "වේගවත් (Right/Up)",
"Start RSVP Reading": "RSVP කියවීම ආරම්භ කරන්න",
"Choose where to start reading": "කියවීම ආරම්භ කළ යුතු ස්ථානය තෝරන්න",
"From Chapter Start": "පරිච්ඡේදයේ ආරම්භයේ සිට",
"Start reading from the beginning of the chapter": "පරිච්ඡේදයේ ආරම්භයේ සිට කියවීම ආරම්භ කරන්න",
"Resume": "නැවත ආරම්භ කරන්න",
"Continue from where you left off": "ඔබ නතර කළ තැන සිට දිගටම කරගෙන යන්න",
"From Current Page": "වත්මන් පිටුවේ සිට",
"Start from where you are currently reading": "ඔබ දැනට කියවන තැනින් ආරම්භ කරන්න",
"From Selection": "තේරීමෙන්",
"Speed Reading Mode": "වේගයෙන් කියවීමේ ක්‍රමය"
}

View file

@ -972,5 +972,39 @@
"Paragraph Mode": "Styckeläge",
"Embedding Model": "Inbäddningsmodell",
"{{count}} book(s) synced_one": "{{count}} bok synkroniserad",
"{{count}} book(s) synced_other": "{{count}} böcker synkroniserade"
"{{count}} book(s) synced_other": "{{count}} böcker synkroniserade",
"Unable to start RSVP": "Kunde inte starta RSVP",
"RSVP not supported for PDF": "RSVP stöds inte för PDF",
"Close RSVP": "Stäng RSVP",
"Select Chapter": "Välj kapitel",
"{{number}} WPM": "{{number}} ord/min",
"Context": "Sammanhang",
"Ready": "Klar",
"Chapter Progress": "Kapitelframsteg",
"words": "ord",
"{{time}} left": "{{time}} kvar",
"Reading progress": "Läsningens framsteg",
"Click to seek": "Klicka för att söka",
"Skip back 15 words": "Hoppa bakåt 15 ord",
"Back 15 words (Shift+Left)": "Bakåt 15 ord (Shift+Vänster)",
"Pause (Space)": "Pausa (Mellanslag)",
"Play (Space)": "Spela (Mellanslag)",
"Skip forward 15 words": "Hoppa framåt 15 ord",
"Forward 15 words (Shift+Right)": "Framåt 15 ord (Shift+Höger)",
"Pause:": "Paus:",
"Decrease speed": "Sänk hastigheten",
"Slower (Left/Down)": "Långsammare (Vänster/Nedåt)",
"Current speed": "Nuvarande hastighet",
"Increase speed": "Höj hastigheten",
"Faster (Right/Up)": "Snabbare (Höger/Uppåt)",
"Start RSVP Reading": "Starta RSVP-läsning",
"Choose where to start reading": "Välj var du vill börja läsa",
"From Chapter Start": "Från början av kapitlet",
"Start reading from the beginning of the chapter": "Börja läsa från början av kapitlet",
"Resume": "Återuppta",
"Continue from where you left off": "Fortsätt där du slutade",
"From Current Page": "Från den aktuella sidan",
"Start from where you are currently reading": "Börja där du läser just nu",
"From Selection": "Från markeringen",
"Speed Reading Mode": "Snabbläsningsläge"
}

View file

@ -972,5 +972,39 @@
"Paragraph Mode": "பத்தி பயன்முறை",
"Embedding Model": "உட்பொதி மாதிரி",
"{{count}} book(s) synced_one": "{{count}} புத்தகம் ஒத்திசைக்கப்பட்டது",
"{{count}} book(s) synced_other": "{{count}} புத்தகங்கள் ஒத்திசைக்கப்பட்டன"
"{{count}} book(s) synced_other": "{{count}} புத்தகங்கள் ஒத்திசைக்கப்பட்டன",
"Unable to start RSVP": "RSVP-ஐத் தொடங்க முடியவில்லை",
"RSVP not supported for PDF": "PDF-க்கு RSVP ஆதரவு இல்லை",
"Close RSVP": "RSVP-ஐ மூடு",
"Select Chapter": "அத்தியாயத்தைத் தேர்ந்தெடு",
"{{number}} WPM": "{{number}} WPM",
"Context": "சூழல்",
"Ready": "தயார்",
"Chapter Progress": "அத்தியாய முன்னேற்றம்",
"words": "வார்த்தைகள்",
"{{time}} left": "{{time}} மீதமுள்ளது",
"Reading progress": "வாசிப்பு முன்னேற்றம்",
"Click to seek": "தேட கிளிக் செய்க",
"Skip back 15 words": "15 வார்த்தைகள் பின்னால் செல்",
"Back 15 words (Shift+Left)": "15 வார்த்தைகள் பின்னால் செல் (Shift+Left)",
"Pause (Space)": "நிறுத்து (Space)",
"Play (Space)": "இயக்கு (Space)",
"Skip forward 15 words": "15 வார்த்தைகள் முன்னால் செல்",
"Forward 15 words (Shift+Right)": "15 வார்த்தைகள் முன்னால் செல் (Shift+Right)",
"Pause:": "நிறுத்து:",
"Decrease speed": "வேகத்தைக் குறை",
"Slower (Left/Down)": "மெதுவாக (Left/Down)",
"Current speed": "தற்போதைய வேகம்",
"Increase speed": "வேகத்தை அதிகரி",
"Faster (Right/Up)": "வேகமாக (Right/Up)",
"Start RSVP Reading": "RSVP வாசிப்பைத் தொடங்கு",
"Choose where to start reading": "எங்கிருந்து வாசிக்கத் தொடங்க வேண்டும் என்பதைத் தேர்வு செய்க",
"From Chapter Start": "அத்தியாயத்தின் தொடக்கத்திலிருந்து",
"Start reading from the beginning of the chapter": "அத்தியாயத்தின் தொடக்கத்திலிருந்து வாசிக்கத் தொடங்கு",
"Resume": "தொடரவும்",
"Continue from where you left off": "நீங்கள் விட்ட இடத்திலிருந்து தொடரவும்",
"From Current Page": "தற்போதைய பக்கத்திலிருந்து",
"Start from where you are currently reading": "நீங்கள் தற்போது வாசிக்கும் இடத்திலிருந்து தொடங்கு",
"From Selection": "தேர்விலிருந்து",
"Speed Reading Mode": "வேக வாசிப்பு முறை"
}

View file

@ -960,5 +960,39 @@
"Exit Paragraph Mode": "ออกจากโหมดพารากราฟ",
"Paragraph Mode": "โหมดพารากราฟ",
"Embedding Model": "โมเดลการฝังตัว",
"{{count}} book(s) synced_other": "ซิงค์หนังสือ {{count}} เล่มแล้ว"
"{{count}} book(s) synced_other": "ซิงค์หนังสือ {{count}} เล่มแล้ว",
"Unable to start RSVP": "ไม่สามารถเริ่ม RSVP ได้",
"RSVP not supported for PDF": "ไม่รองรับ RSVP สำหรับ PDF",
"Close RSVP": "ปิด RSVP",
"Select Chapter": "เลือกบท",
"{{number}} WPM": "{{number}} WPM",
"Context": "บริบท",
"Ready": "พร้อม",
"Chapter Progress": "ความคืบหน้าของบท",
"words": "คำ",
"{{time}} left": "เหลือ {{time}}",
"Reading progress": "ความคืบหน้าการอ่าน",
"Click to seek": "คลิกเพื่อค้นหาตำแหน่ง",
"Skip back 15 words": "ย้อนกลับ 15 คำ",
"Back 15 words (Shift+Left)": "ย้อนกลับ 15 คำ (Shift+ซ้าย)",
"Pause (Space)": "หยุดชั่วคราว (Space)",
"Play (Space)": "เล่น (Space)",
"Skip forward 15 words": "ไปข้างหน้า 15 คำ",
"Forward 15 words (Shift+Right)": "ไปข้างหน้า 15 คำ (Shift+ขวา)",
"Pause:": "หยุดชั่วคราว:",
"Decrease speed": "ลดความเร็ว",
"Slower (Left/Down)": "ช้าลง (ซ้าย/ลง)",
"Current speed": "ความเร็วปัจจุบัน",
"Increase speed": "เพิ่มความเร็ว",
"Faster (Right/Up)": "เร็วขึ้น (ขวา/ขึ้น)",
"Start RSVP Reading": "เริ่มการอ่านแบบ RSVP",
"Choose where to start reading": "เลือกจุดที่ต้องการเริ่มอ่าน",
"From Chapter Start": "จากจุดเริ่มต้นของบท",
"Start reading from the beginning of the chapter": "เริ่มอ่านใหม่ตั้งแต่ต้นบทนี้",
"Resume": "อ่านต่อ",
"Continue from where you left off": "อ่านต่อจากจุดที่ค้างไว้",
"From Current Page": "จากหน้าปัจจุบัน",
"Start from where you are currently reading": "เริ่มจากจุดที่คุณกำลังอ่านอยู่ตอนนี้",
"From Selection": "จากส่วนที่เลือก",
"Speed Reading Mode": "โหมดการอ่านเร็ว"
}

View file

@ -972,5 +972,39 @@
"Paragraph Mode": "Paragraf modu",
"Embedding Model": "Gömme Modeli",
"{{count}} book(s) synced_one": "{{count}} kitap senkronize edildi",
"{{count}} book(s) synced_other": "{{count}} kitap senkronize edildi"
"{{count}} book(s) synced_other": "{{count}} kitap senkronize edildi",
"Unable to start RSVP": "RSVP başlatılamadı",
"RSVP not supported for PDF": "PDF için RSVP desteklenmiyor",
"Close RSVP": "RSVP'yi Kapat",
"Select Chapter": "Bölüm Seç",
"{{number}} WPM": "{{number}} Kelime/Dakika",
"Context": "Bağlam",
"Ready": "Hazır",
"Chapter Progress": "Bölüm İlerlemesi",
"words": "kelime",
"{{time}} left": "{{time}} kaldı",
"Reading progress": "Okuma ilerlemesi",
"Click to seek": "Konum aramak için tıkla",
"Skip back 15 words": "15 kelime geri atla",
"Back 15 words (Shift+Left)": "15 kelime geri (Shift+Sol)",
"Pause (Space)": "Duraklat (Boşluk)",
"Play (Space)": "Oynat (Boşluk)",
"Skip forward 15 words": "15 kelime ileri atla",
"Forward 15 words (Shift+Right)": "15 kelime ileri (Shift+Sağ)",
"Pause:": "Duraklat:",
"Decrease speed": "Hızı azalt",
"Slower (Left/Down)": "Daha yavaş (Sol/Aşağı)",
"Current speed": "Mevcut hız",
"Increase speed": "Hızı artır",
"Faster (Right/Up)": "Daha hızlı (Sağ/Yukarı)",
"Start RSVP Reading": "RSVP Okumasını Başlat",
"Choose where to start reading": "Nereden okumaya başlayacağınızı seçin",
"From Chapter Start": "Bölüm Başından",
"Start reading from the beginning of the chapter": "Bölümün başından itibaren okumaya başla",
"Resume": "Devam Et",
"Continue from where you left off": "Kaldığınız yerden devam edin",
"From Current Page": "Mevcut Sayfadan",
"Start from where you are currently reading": "Şu anda okuduğunuz yerden başlayın",
"From Selection": "Seçimden",
"Speed Reading Mode": "Hızlı Okuma Modu"
}

View file

@ -996,5 +996,39 @@
"{{count}} book(s) synced_one": "{{count}} книга синхронізована",
"{{count}} book(s) synced_few": "{{count}} книги синхронізовані",
"{{count}} book(s) synced_many": "{{count}} книг синхронізовано",
"{{count}} book(s) synced_other": "{{count}} книг синхронізовано"
"{{count}} book(s) synced_other": "{{count}} книг синхронізовано",
"Unable to start RSVP": "Не вдалося запустити RSVP",
"RSVP not supported for PDF": "RSVP не підтримується для PDF",
"Close RSVP": "Закрити RSVP",
"Select Chapter": "Вибрати розділ",
"{{number}} WPM": "{{number}} WPM",
"Context": "Контекст",
"Ready": "Готово",
"Chapter Progress": "Прогрес розділу",
"words": "слів",
"{{time}} left": "залишилося {{time}}",
"Reading progress": "Прогрес читання",
"Click to seek": "Натисніть для пошуку",
"Skip back 15 words": "Назад на 15 слів",
"Back 15 words (Shift+Left)": "Назад на 15 слів (Shift+Вліво)",
"Pause (Space)": "Пауза (Пробіл)",
"Play (Space)": "Відтворити (Пробіл)",
"Skip forward 15 words": "Вперед на 15 слів",
"Forward 15 words (Shift+Right)": "Вперед на 15 слів (Shift+Вправо)",
"Pause:": "Пауза:",
"Decrease speed": "Зменшити швидкість",
"Slower (Left/Down)": "Повільніше (Вліво/Вниз)",
"Current speed": "Поточна швидкість",
"Increase speed": "Збільшити швидкість",
"Faster (Right/Up)": "Швидше (Вправо/Вгору)",
"Start RSVP Reading": "Почати читання RSVP",
"Choose where to start reading": "Виберіть, де почати читання",
"From Chapter Start": "З початку розділу",
"Start reading from the beginning of the chapter": "Почати читання з початку розділу",
"Resume": "Продовжити",
"Continue from where you left off": "Продовжити з того місця, де ви зупинилися",
"From Current Page": "З поточної сторінки",
"Start from where you are currently reading": "Почати з того місця, де ви зараз читаєте",
"From Selection": "З виділеного",
"Speed Reading Mode": "Режим швидкого читання"
}

View file

@ -960,5 +960,39 @@
"Exit Paragraph Mode": "Thoát chế độ đoạn văn",
"Paragraph Mode": "Chế độ đoạn văn",
"Embedding Model": "Mô hình nhúng",
"{{count}} book(s) synced_other": "Đã đồng bộ {{count}} cuốn sách"
"{{count}} book(s) synced_other": "Đã đồng bộ {{count}} cuốn sách",
"Unable to start RSVP": "Không thể bắt đầu RSVP",
"RSVP not supported for PDF": "RSVP không được hỗ trợ cho PDF",
"Close RSVP": "Đóng RSVP",
"Select Chapter": "Chọn chương",
"{{number}} WPM": "{{number}} WPM",
"Context": "Ngữ cảnh",
"Ready": "Sẵn sàng",
"Chapter Progress": "Tiến độ chương",
"words": "từ",
"{{time}} left": "còn lại {{time}}",
"Reading progress": "Tiến độ đọc",
"Click to seek": "Nhấp để tua",
"Skip back 15 words": "Lùi lại 15 từ",
"Back 15 words (Shift+Left)": "Lùi lại 15 từ (Shift+Trái)",
"Pause (Space)": "Tạm dừng (Khoảng trắng)",
"Play (Space)": "Phát (Khoảng trắng)",
"Skip forward 15 words": "Tiến tới 15 từ",
"Forward 15 words (Shift+Right)": "Tiến tới 15 từ (Shift+Phải)",
"Pause:": "Tạm dừng:",
"Decrease speed": "Giảm tốc độ",
"Slower (Left/Down)": "Chậm hơn (Trái/Xuống)",
"Current speed": "Tốc độ hiện tại",
"Increase speed": "Tăng tốc độ",
"Faster (Right/Up)": "Nhanh hơn (Phải/Lên)",
"Start RSVP Reading": "Bắt đầu đọc RSVP",
"Choose where to start reading": "Chọn nơi để bắt đầu đọc",
"From Chapter Start": "Từ đầu chương",
"Start reading from the beginning of the chapter": "Bắt đầu đọc lại từ đầu chương này",
"Resume": "Tiếp tục",
"Continue from where you left off": "Tiếp tục từ nơi bạn đã dừng lại",
"From Current Page": "Từ trang hiện tại",
"Start from where you are currently reading": "Bắt đầu từ nơi bạn hiện đang đọc",
"From Selection": "Từ phần đã chọn",
"Speed Reading Mode": "Chế độ đọc nhanh"
}

View file

@ -960,5 +960,39 @@
"Exit Paragraph Mode": "退出段落模式",
"Paragraph Mode": "段落模式",
"Embedding Model": "嵌入模型",
"{{count}} book(s) synced_other": "{{count}} 本书已同步"
"{{count}} book(s) synced_other": "{{count}} 本书已同步",
"Unable to start RSVP": "无法启动 RSVP",
"RSVP not supported for PDF": "PDF 不支持 RSVP",
"Close RSVP": "关闭 RSVP",
"Select Chapter": "选择章节",
"{{number}} WPM": "{{number}} WPM",
"Context": "上下文",
"Ready": "准备就绪",
"Chapter Progress": "章节进度",
"words": "词",
"{{time}} left": "剩余 {{time}}",
"Reading progress": "阅读进度",
"Click to seek": "点击跳转",
"Skip back 15 words": "回退 15 词",
"Back 15 words (Shift+Left)": "回退 15 词 (Shift+Left)",
"Pause (Space)": "暂停 (空格)",
"Play (Space)": "播放 (空格)",
"Skip forward 15 words": "前进 15 词",
"Forward 15 words (Shift+Right)": "前进 15 词 (Shift+Right)",
"Pause:": "暂停:",
"Decrease speed": "减速",
"Slower (Left/Down)": "较慢 (左/下)",
"Current speed": "当前速度",
"Increase speed": "加速",
"Faster (Right/Up)": "较快 (右/上)",
"Start RSVP Reading": "开始 RSVP 阅读",
"Choose where to start reading": "选择阅读起始位置",
"From Chapter Start": "从章节开始",
"Start reading from the beginning of the chapter": "重新从本章节开始阅读",
"Resume": "继续",
"Continue from where you left off": "从上次离开的地方继续",
"From Current Page": "从当前页",
"Start from where you are currently reading": "从当前正在阅读的位置开始",
"From Selection": "从选中内容",
"Speed Reading Mode": "快读模式"
}

View file

@ -960,5 +960,39 @@
"Exit Paragraph Mode": "退出段落模式",
"Paragraph Mode": "段落模式",
"Embedding Model": "嵌入模型",
"{{count}} book(s) synced_other": "{{count}} 本書已同步"
"{{count}} book(s) synced_other": "{{count}} 本書已同步",
"Unable to start RSVP": "無法啟動 RSVP",
"RSVP not supported for PDF": "PDF 不支援 RSVP",
"Close RSVP": "關閉 RSVP",
"Select Chapter": "選擇章節",
"{{number}} WPM": "{{number}} WPM",
"Context": "上下文",
"Ready": "準備就緒",
"Chapter Progress": "章節進度",
"words": "詞",
"{{time}} left": "剩餘 {{time}}",
"Reading progress": "閱讀進度",
"Click to seek": "點擊跳轉",
"Skip back 15 words": "回退 15 詞",
"Back 15 words (Shift+Left)": "回退 15 詞 (Shift+Left)",
"Pause (Space)": "暫停 (空格)",
"Play (Space)": "播放 (空格)",
"Skip forward 15 words": "前進 15 詞",
"Forward 15 words (Shift+Right)": "前進 15 詞 (Shift+Right)",
"Pause:": "暫停:",
"Decrease speed": "減速",
"Slower (Left/Down)": "較慢 (左/下)",
"Current speed": "當前速度",
"Increase speed": "加速",
"Faster (Right/Up)": "較快 (右/上)",
"Start RSVP Reading": "開始 RSVP 閱讀",
"Choose where to start reading": "選擇閱讀起始位置",
"From Chapter Start": "從章節開始",
"Start reading from the beginning of the chapter": "重新從本章節開始閱讀",
"Resume": "繼續",
"Continue from where you left off": "從上次離開的地方繼續",
"From Current Page": "從當前頁",
"Start from where you are currently reading": "從當前正在閱讀的位置開始",
"From Selection": "從選取內容",
"Speed Reading Mode": "快讀模式"
}

View file

@ -93,6 +93,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
}
};
const handleStartRSVP = () => {
setIsDropdownOpen?.(false);
eventDispatcher.dispatch('rsvp-start', { bookKey });
};
useEffect(() => {
if (isScrolledMode === viewSettings!.scrolled) return;
viewSettings!.scrolled = isScrolledMode;
@ -270,6 +275,8 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
disabled={bookData.isFixedLayout}
/>
<hr aria-hidden='true' className='border-base-300 my-1' />
<MenuItem
label={_('Paragraph Mode')}
shortcut='Shift+P'
@ -278,6 +285,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
disabled={bookData.isFixedLayout}
/>
<MenuItem
label={_('Speed Reading Mode')}
onClick={handleStartRSVP}
disabled={bookData.isFixedLayout}
/>
<hr aria-hidden='true' className='border-base-300 my-1' />
<MenuItem

View file

@ -13,6 +13,7 @@ import { viewPagination } from '../../hooks/usePagination';
import MobileFooterBar from './MobileFooterBar';
import DesktopFooterBar from './DesktopFooterBar';
import TTSControl from '../tts/TTSControl';
import { RSVPControl } from '../rsvp';
const FooterBar: React.FC<FooterBarProps> = ({
bookKey,
@ -249,6 +250,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
)}
<TTSControl bookKey={bookKey} gridInsets={gridInsets} />
<RSVPControl bookKey={bookKey} />
</>
);
};

View file

@ -0,0 +1,423 @@
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useThemeStore } from '@/store/themeStore';
import { RSVPController, RsvpStartChoice, RsvpStopPosition } from '@/services/rsvp';
import { eventDispatcher } from '@/utils/event';
import { useTranslation } from '@/hooks/useTranslation';
import { BookNote } from '@/types/book';
import RSVPOverlay from './RSVPOverlay';
import RSVPStartDialog from './RSVPStartDialog';
interface RSVPControlProps {
bookKey: string;
}
// Helper to expand a range to include the full sentence
const expandRangeToSentence = (range: Range, doc: Document): Range => {
const sentenceRange = doc.createRange();
// Get the text content around the range
const container = range.commonAncestorContainer;
const parentElement =
container.nodeType === Node.TEXT_NODE ? container.parentElement : (container as Element);
if (!parentElement) return range;
// Get the full text of the parent paragraph/element
const fullText = parentElement.textContent || '';
const rangeText = range.toString();
// Find the position of our word in the parent text
const wordStart = fullText.indexOf(rangeText);
if (wordStart === -1) return range;
// Find sentence boundaries (. ! ? or start/end of text)
const sentenceEnders = /[.!?]/g;
let sentenceStart = 0;
let sentenceEnd = fullText.length;
// Find the sentence start (look backwards for sentence ender)
for (let i = wordStart - 1; i >= 0; i--) {
if (sentenceEnders.test(fullText[i]!)) {
sentenceStart = i + 1;
// Skip any whitespace after the sentence ender
while (sentenceStart < fullText.length && /\s/.test(fullText[sentenceStart]!)) {
sentenceStart++;
}
break;
}
}
// Find the sentence end (look forward for sentence ender)
for (let i = wordStart; i < fullText.length; i++) {
if (sentenceEnders.test(fullText[i]!)) {
sentenceEnd = i + 1;
break;
}
}
// Create a tree walker to find the text nodes
const walker = doc.createTreeWalker(parentElement, NodeFilter.SHOW_TEXT, null);
let currentOffset = 0;
let startNode: Text | null = null;
let startOffset = 0;
let endNode: Text | null = null;
let endOffset = 0;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const nodeLength = node.textContent?.length || 0;
if (!startNode && currentOffset + nodeLength > sentenceStart) {
startNode = node;
startOffset = sentenceStart - currentOffset;
}
if (currentOffset + nodeLength >= sentenceEnd) {
endNode = node;
endOffset = sentenceEnd - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (startNode && endNode) {
try {
sentenceRange.setStart(startNode, Math.max(0, startOffset));
sentenceRange.setEnd(endNode, Math.min(endOffset, endNode.textContent?.length || 0));
return sentenceRange;
} catch {
return range;
}
}
return range;
};
const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey }) => {
const _ = useTranslation();
const {
getView,
getProgress,
getViewSettings: _getViewSettings,
setProgress: _setProgress,
} = useReaderStore();
const { getBookData } = useBookDataStore();
const { themeCode } = useThemeStore();
const [isActive, setIsActive] = useState(false);
const [showStartDialog, setShowStartDialog] = useState(false);
const [startChoice, setStartChoice] = useState<RsvpStartChoice | null>(null);
const controllerRef = useRef<RSVPController | null>(null);
const tempHighlightRef = useRef<BookNote | null>(null);
// Helper to remove any existing RSVP highlight
const removeRsvpHighlight = useCallback(() => {
const view = getView(bookKey);
if (tempHighlightRef.current && view) {
try {
view.addAnnotation(tempHighlightRef.current, true);
} catch {
// Ignore errors when removing
}
}
tempHighlightRef.current = null;
}, [bookKey, getView]);
// Clean up controller and highlight on unmount
useEffect(() => {
return () => {
if (controllerRef.current) {
controllerRef.current.shutdown();
controllerRef.current = null;
}
// Remove any existing RSVP highlight when component unmounts
removeRsvpHighlight();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Listen for RSVP start events
useEffect(() => {
const handleRSVPStart = (event: CustomEvent) => {
const { bookKey: rsvpBookKey, selectionText } = event.detail;
if (bookKey !== rsvpBookKey) return;
handleStart(selectionText);
};
const handleRSVPStop = (event: CustomEvent) => {
const { bookKey: rsvpBookKey } = event.detail;
if (bookKey !== rsvpBookKey) return;
handleClose();
};
eventDispatcher.on('rsvp-start', handleRSVPStart);
eventDispatcher.on('rsvp-stop', handleRSVPStop);
return () => {
eventDispatcher.off('rsvp-start', handleRSVPStart);
eventDispatcher.off('rsvp-stop', handleRSVPStop);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
const handleStart = useCallback(
(selectionText?: string) => {
const view = getView(bookKey);
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey);
if (!view || !bookData || !bookData.book) {
eventDispatcher.dispatch('toast', {
message: _('Unable to start RSVP'),
type: 'error',
});
return;
}
// Remove any existing RSVP highlight when starting new session
removeRsvpHighlight();
// Check if format is supported (not PDF)
if (bookData.book.format === 'PDF') {
eventDispatcher.dispatch('toast', {
message: _('RSVP not supported for PDF'),
type: 'warning',
});
return;
}
// Create controller if not exists
if (!controllerRef.current) {
controllerRef.current = new RSVPController(view, bookKey);
}
const controller = controllerRef.current;
// Set current CFI for position tracking
if (progress?.location) {
controller.setCurrentCfi(progress.location);
}
// Handle start choice event
const handleStartChoice = (e: Event) => {
const choice = (e as CustomEvent<RsvpStartChoice>).detail;
setStartChoice(choice);
// If there's only one option (beginning), start directly
if (!choice.hasSavedPosition && !choice.hasSelection) {
controller.startFromBeginning();
setIsActive(true);
} else {
// Show dialog for user to choose
setShowStartDialog(true);
}
};
controller.addEventListener('rsvp-start-choice', handleStartChoice);
controller.requestStart(selectionText);
// Clean up listener after handling
setTimeout(() => {
controller.removeEventListener('rsvp-start-choice', handleStartChoice);
}, 100);
},
[_, bookKey, getBookData, getProgress, getView, removeRsvpHighlight],
);
const handleStartDialogSelect = useCallback(
(option: 'beginning' | 'saved' | 'current' | 'selection') => {
setShowStartDialog(false);
const controller = controllerRef.current;
if (!controller) return;
switch (option) {
case 'beginning':
controller.startFromBeginning();
break;
case 'saved':
controller.startFromSavedPosition();
break;
case 'current':
controller.startFromCurrentPosition();
break;
case 'selection':
if (startChoice?.selectionText) {
controller.startFromSelection(startChoice.selectionText);
}
break;
}
setIsActive(true);
},
[startChoice],
);
const handleClose = useCallback(() => {
const controller = controllerRef.current;
const view = getView(bookKey);
if (controller && view) {
// Listen for the stop event to get the position
const handleRsvpStop = (e: Event) => {
const stopPosition = (e as CustomEvent<RsvpStopPosition | null>).detail;
if (stopPosition?.range && typeof stopPosition.docIndex === 'number') {
try {
// Get the document from the renderer
const contents = view.renderer.getContents?.();
const content = contents?.find((c) => c.index === stopPosition.docIndex);
const doc = content?.doc;
if (doc && stopPosition.range) {
// Expand the range to include the full sentence
const sentenceRange = expandRangeToSentence(stopPosition.range, doc);
// Get CFI for navigation - MUST get this BEFORE navigating
const cfi = view.getCFI(stopPosition.docIndex, stopPosition.range);
// Get CFI for the sentence highlight - MUST get this BEFORE navigating
// because goTo() may re-render the document, invalidating the Range objects
const sentenceCfi = cfi ? view.getCFI(stopPosition.docIndex, sentenceRange) : null;
const sentenceText = sentenceRange.toString();
if (cfi) {
// Navigate to the position
view.goTo(cfi);
if (sentenceCfi) {
// Remove any previous RSVP highlight
removeRsvpHighlight();
// Create a persistent highlight for the sentence using theme accent color
const highlight: BookNote = {
id: `rsvp-temp-${Date.now()}`,
type: 'annotation',
cfi: sentenceCfi,
text: sentenceText,
style: 'underline',
color: themeCode.primary, // Use theme accent color (same as ORP focal point)
note: '',
createdAt: Date.now(),
updatedAt: Date.now(),
};
tempHighlightRef.current = highlight;
view.addAnnotation(highlight);
// Note: highlight persists until next page, reader close, or new RSVP session
}
}
}
} catch (err) {
console.warn('Failed to sync RSVP position:', err);
}
}
};
controller.addEventListener('rsvp-stop', handleRsvpStop);
controller.stop();
controller.removeEventListener('rsvp-stop', handleRsvpStop);
} else if (controller) {
controller.stop();
}
setIsActive(false);
setShowStartDialog(false);
}, [bookKey, getView, removeRsvpHighlight, themeCode.primary]);
const handleChapterSelect = useCallback(
(href: string) => {
const view = getView(bookKey);
if (!view) return;
// Navigate to chapter
view.goTo(href);
// Wait for navigation, then reload RSVP content
setTimeout(() => {
const controller = controllerRef.current;
if (controller) {
const progress = getProgress(bookKey);
if (progress?.location) {
controller.setCurrentCfi(progress.location);
}
controller.loadNextPageContent();
}
}, 500);
},
[bookKey, getProgress, getView],
);
const handleRequestNextPage = useCallback(async () => {
const view = getView(bookKey);
if (!view) return;
// Remove RSVP highlight when moving to next page
removeRsvpHighlight();
// RSVP extracts ALL words from the current section via renderer.getContents().
// When RSVP runs out of words and calls this function, it means the entire
// chapter/section has been read, so we need to go to the next section.
await view.renderer.nextSection?.();
// Wait for section change, then load new content
setTimeout(() => {
const controller = controllerRef.current;
if (controller) {
const progress = getProgress(bookKey);
if (progress?.location) {
controller.setCurrentCfi(progress.location);
}
controller.loadNextPageContent();
}
}, 500);
}, [bookKey, getProgress, getView, removeRsvpHighlight]);
// Get current chapter info
const progress = getProgress(bookKey);
const bookData = getBookData(bookKey);
const chapters = bookData?.bookDoc?.toc || [];
const currentChapterHref = progress?.sectionHref || null;
// Use portal to render overlay at body level to avoid stacking context issues
const portalContainer = typeof document !== 'undefined' ? document.body : null;
return (
<>
{/* Start dialog - render via portal */}
{showStartDialog &&
startChoice &&
portalContainer &&
createPortal(
<RSVPStartDialog
startChoice={startChoice}
onSelect={handleStartDialogSelect}
onClose={() => setShowStartDialog(false)}
/>,
portalContainer,
)}
{/* RSVP Overlay - render via portal */}
{isActive &&
controllerRef.current &&
portalContainer &&
createPortal(
<RSVPOverlay
controller={controllerRef.current}
chapters={chapters}
currentChapterHref={currentChapterHref}
onClose={handleClose}
onChapterSelect={handleChapterSelect}
onRequestNextPage={handleRequestNextPage}
/>,
portalContainer,
)}
</>
);
};
export default RSVPControl;

View file

@ -0,0 +1,576 @@
'use client';
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import clsx from 'clsx';
import { RsvpState, RsvpWord, RSVPController } from '@/services/rsvp';
import { useThemeStore } from '@/store/themeStore';
import { TOCItem } from '@/libs/document';
import {
IoClose,
IoPlay,
IoPause,
IoPlaySkipBack,
IoPlaySkipForward,
IoRemove,
IoAdd,
} from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
interface FlatChapter {
label: string;
href: string;
level: number;
}
interface RSVPOverlayProps {
controller: RSVPController;
chapters: TOCItem[];
currentChapterHref: string | null;
onClose: () => void;
onChapterSelect: (href: string) => void;
onRequestNextPage: () => void;
}
const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
controller,
chapters,
currentChapterHref,
onClose,
onChapterSelect,
onRequestNextPage,
}) => {
const _ = useTranslation();
const { themeCode, isDarkMode: _isDarkMode } = useThemeStore();
const [state, setState] = useState<RsvpState>(controller.currentState);
const [currentWord, setCurrentWord] = useState<RsvpWord | null>(controller.currentWord);
const [countdown, setCountdown] = useState<number | null>(controller.currentCountdown);
const [showChapterDropdown, setShowChapterDropdown] = useState(false);
const touchStartX = useRef(0);
const touchStartY = useRef(0);
const touchStartTime = useRef(0);
const SWIPE_THRESHOLD = 50;
const TAP_THRESHOLD = 10;
// Flatten chapters for dropdown
const flatChapters = useMemo(() => {
const flatten = (items: TOCItem[], level = 0): FlatChapter[] => {
const result: FlatChapter[] = [];
for (const item of items) {
result.push({ label: item.label || '', href: item.href || '', level });
if (item.subitems?.length) {
result.push(...flatten(item.subitems, level + 1));
}
}
return result;
};
return flatten(chapters);
}, [chapters]);
// Subscribe to controller events
useEffect(() => {
const handleStateChange = (e: Event) => {
const newState = (e as CustomEvent<RsvpState>).detail;
setState(newState);
setCurrentWord(controller.currentWord);
};
const handleCountdownChange = (e: Event) => {
setCountdown((e as CustomEvent<number | null>).detail);
};
const handleRequestNextPage = () => {
onRequestNextPage();
};
controller.addEventListener('rsvp-state-change', handleStateChange);
controller.addEventListener('rsvp-countdown-change', handleCountdownChange);
controller.addEventListener('rsvp-request-next-page', handleRequestNextPage);
return () => {
controller.removeEventListener('rsvp-state-change', handleStateChange);
controller.removeEventListener('rsvp-countdown-change', handleCountdownChange);
controller.removeEventListener('rsvp-request-next-page', handleRequestNextPage);
};
}, [controller, onRequestNextPage]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyboard = (event: KeyboardEvent) => {
if (!state.active) return;
switch (event.key) {
case ' ':
event.preventDefault();
controller.togglePlayPause();
break;
case 'Escape':
event.preventDefault();
onClose();
break;
case 'ArrowLeft':
event.preventDefault();
if (event.shiftKey) {
controller.skipBackward(15);
} else {
controller.decreaseSpeed();
}
break;
case 'ArrowRight':
event.preventDefault();
if (event.shiftKey) {
controller.skipForward(15);
} else {
controller.increaseSpeed();
}
break;
case 'ArrowUp':
event.preventDefault();
controller.increaseSpeed();
break;
case 'ArrowDown':
event.preventDefault();
controller.decreaseSpeed();
break;
}
};
document.addEventListener('keydown', handleKeyboard);
return () => document.removeEventListener('keydown', handleKeyboard);
}, [state.active, controller, onClose]);
// Word display helpers
const wordBefore = currentWord ? currentWord.text.substring(0, currentWord.orpIndex) : '';
const orpChar = currentWord ? currentWord.text.charAt(currentWord.orpIndex) : '';
const wordAfter = currentWord ? currentWord.text.substring(currentWord.orpIndex + 1) : '';
// Time remaining calculation
const getTimeRemaining = useCallback((): string | null => {
if (!state || state.words.length === 0) return null;
const wordsLeft = state.words.length - state.currentIndex;
const minutesLeft = wordsLeft / state.wpm;
if (minutesLeft < 1) {
const seconds = Math.ceil(minutesLeft * 60);
return `${seconds}s`;
} else if (minutesLeft < 60) {
const mins = Math.floor(minutesLeft);
const secs = Math.round((minutesLeft - mins) * 60);
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
} else {
const hours = Math.floor(minutesLeft / 60);
const mins = Math.round(minutesLeft % 60);
return `${hours}h ${mins}m`;
}
}, [state]);
// Context text helpers - show 100 words before and after
const getContextBefore = useCallback((): string => {
if (!state || state.words.length === 0) return '';
const startIndex = Math.max(0, state.currentIndex - 100);
return state.words
.slice(startIndex, state.currentIndex)
.map((w) => w.text)
.join(' ');
}, [state]);
const getContextAfter = useCallback((): string => {
if (!state || state.words.length === 0) return '';
const endIndex = Math.min(state.words.length, state.currentIndex + 101);
return state.words
.slice(state.currentIndex + 1, endIndex)
.map((w) => w.text)
.join(' ');
}, [state]);
// Chapter helpers
const getCurrentChapterLabel = useCallback((): string => {
if (!currentChapterHref) return 'Select Chapter';
const normalizedCurrent = currentChapterHref.split('#')[0]?.replace(/^\//, '') || '';
const chapter = flatChapters.find((c) => {
const normalizedHref = c.href.split('#')[0]?.replace(/^\//, '') || '';
return normalizedHref === normalizedCurrent;
});
return chapter?.label || 'Select Chapter';
}, [currentChapterHref, flatChapters]);
const isChapterActive = useCallback(
(href: string): boolean => {
if (!currentChapterHref) return false;
const normalizedCurrent = currentChapterHref.split('#')[0]?.replace(/^\//, '') || '';
const normalizedHref = href.split('#')[0]?.replace(/^\//, '') || '';
return normalizedHref === normalizedCurrent;
},
[currentChapterHref],
);
// Touch handlers
const handleTouchStart = (event: React.TouchEvent) => {
if (event.touches.length !== 1) return;
const touch = event.touches[0]!;
touchStartX.current = touch.clientX;
touchStartY.current = touch.clientY;
touchStartTime.current = Date.now();
};
const handleTouchEnd = (event: React.TouchEvent) => {
if (event.changedTouches.length !== 1) return;
const touch = event.changedTouches[0]!;
const deltaX = touch.clientX - touchStartX.current;
const deltaY = touch.clientY - touchStartY.current;
const duration = Date.now() - touchStartTime.current;
if (Math.abs(deltaX) > SWIPE_THRESHOLD && Math.abs(deltaX) > Math.abs(deltaY)) {
if (deltaX > 0) {
controller.decreaseSpeed();
} else {
controller.increaseSpeed();
}
return;
}
if (Math.abs(deltaX) < TAP_THRESHOLD && Math.abs(deltaY) < TAP_THRESHOLD && duration < 300) {
const target = event.target as HTMLElement;
if (target.closest('.rsvp-controls') || target.closest('.rsvp-header')) {
return;
}
const screenWidth = window.innerWidth;
const tapX = touch.clientX;
if (tapX < screenWidth * 0.25) {
controller.skipBackward(15);
} else if (tapX > screenWidth * 0.75) {
controller.skipForward(15);
} else {
controller.togglePlayPause();
}
}
};
// Progress bar click handler
const handleProgressBarClick = (event: React.MouseEvent) => {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const percentage = (clickX / rect.width) * 100;
const wasPlaying = state.playing;
if (wasPlaying) {
controller.pause();
}
controller.seekToPosition(percentage);
if (wasPlaying) {
setTimeout(() => controller.resume(), 50);
}
};
const handleChapterSelect = (href: string) => {
setShowChapterDropdown(false);
controller.pause();
onChapterSelect(href);
};
if (!state.active) return null;
// Use theme colors directly from themeCode (bg, fg, primary are already resolved from palette)
const bgColor = themeCode.bg;
const fgColor = themeCode.fg;
const accentColor = themeCode.primary;
return (
<div
data-testid='rsvp-overlay'
aria-label='RSVP Speed Reading Overlay'
className='fixed inset-0 z-[10000] flex select-none flex-col'
style={{
backgroundColor: bgColor,
color: fgColor,
// Ensure solid background - no transparency
backdropFilter: 'none',
// @ts-expect-error CSS custom properties
'--rsvp-accent': accentColor,
'--rsvp-fg': fgColor,
'--rsvp-bg': bgColor,
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Header */}
<div className='rsvp-header flex shrink-0 items-center justify-between gap-2 p-3 md:gap-4 md:p-4'>
<button
aria-label={_('Close RSVP')}
className='flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 md:h-11 md:w-11'
onClick={onClose}
title={_('Close')}
>
<IoClose className='h-5 w-5 md:h-6 md:w-6' />
</button>
{/* Chapter selector */}
<div className='relative mx-2 min-w-0 max-w-[200px] flex-1 md:mx-4 md:max-w-[400px]'>
<button
className='flex w-full cursor-pointer items-center justify-between gap-1 rounded-lg border border-gray-500/30 bg-gray-500/15 px-2 py-1.5 text-xs transition-colors hover:bg-gray-500/25 md:gap-2 md:px-3 md:py-2 md:text-sm'
onClick={() => setShowChapterDropdown(!showChapterDropdown)}
title={_('Select Chapter')}
>
<span className='overflow-hidden text-ellipsis whitespace-nowrap'>
{getCurrentChapterLabel()}
</span>
<svg
width='14'
height='14'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
className='shrink-0 md:h-4 md:w-4'
>
<path d='M6 9l6 6 6-6' />
</svg>
</button>
{showChapterDropdown && (
<div
className='absolute left-0 right-0 top-full z-[100] mt-1 max-h-[300px] overflow-y-auto rounded-lg border border-gray-500/30 shadow-lg'
style={{ backgroundColor: bgColor }}
>
{flatChapters.map((chapter, idx) => (
<button
key={`${chapter.href}-${idx}`}
className={clsx(
'block w-full cursor-pointer border-none bg-transparent px-3 py-2.5 text-left text-sm transition-colors hover:bg-gray-500/20',
isChapterActive(chapter.href) &&
'bg-[color-mix(in_srgb,var(--rsvp-accent)_20%,transparent)] font-semibold',
)}
style={{ paddingLeft: `${0.75 + chapter.level * 1}rem` }}
onClick={() => handleChapterSelect(chapter.href)}
>
{chapter.label}
</button>
))}
</div>
)}
</div>
<div className='shrink-0 text-sm font-medium opacity-70 md:text-base'>
{_('{{number}} WPM', { number: state.wpm })}
</div>
</div>
{/* Context panel (shown when paused) */}
{!state.playing && countdown === null && (
<div className='mx-3 max-h-[25vh] overflow-y-auto rounded-lg border border-gray-500/20 bg-gray-500/10 p-3 md:mx-4 md:max-h-[30vh] md:rounded-xl md:p-4'>
<div className='mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide opacity-60 md:mb-3'>
<svg
width='14'
height='14'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
className='md:h-4 md:w-4'
>
<path d='M4 6h16M4 12h16M4 18h10' />
</svg>
<span>{_('Context')}</span>
</div>
<div className='text-left text-base leading-relaxed md:text-lg'>
<span className='opacity-70'>{getContextBefore()} </span>
<span className='font-semibold' style={{ color: accentColor }}>
{currentWord?.text || ''}
</span>
<span className='opacity-70'> {getContextAfter()}</span>
</div>
</div>
)}
{/* Main content area */}
<div className='flex flex-1 flex-col items-center justify-center p-4 md:p-8'>
<div className='flex h-full w-full flex-col items-center justify-center'>
<div className='flex h-full w-full flex-col items-center'>
{/* Top guide line */}
<div className='w-px flex-1 bg-current opacity-30' />
{/* Word section */}
<div className='flex flex-col items-center justify-center'>
{/* Countdown */}
{countdown !== null && (
<div className='mb-2 flex items-center justify-center'>
<span
className='animate-pulse text-5xl font-bold sm:text-6xl md:text-7xl'
style={{ color: accentColor }}
>
{countdown}
</span>
</div>
)}
{/* Word display */}
<div className='relative flex min-h-16 w-full items-center justify-center whitespace-nowrap px-2 py-4 font-mono text-2xl font-medium tracking-wide sm:min-h-20 sm:px-4 sm:py-6 sm:text-3xl md:text-4xl lg:text-5xl'>
{currentWord ? (
<>
<span className='absolute right-[calc(50%+0.3em)] text-right opacity-60'>
{wordBefore}
</span>
<span className='relative z-10 font-bold' style={{ color: accentColor }}>
{orpChar}
</span>
<span className='absolute left-[calc(50%+0.3em)] text-left opacity-60'>
{wordAfter}
</span>
</>
) : (
<span className='italic opacity-30'>{_('Ready')}</span>
)}
</div>
</div>
{/* Bottom guide line */}
<div className='w-px flex-1 bg-current opacity-30' />
</div>
</div>
</div>
{/* Footer */}
<div className='rsvp-controls shrink-0 px-3 pb-6 pt-3 md:px-4 md:pb-8 md:pt-4'>
{/* Progress section */}
<div className='mb-3 flex flex-col gap-1.5 md:mb-4 md:gap-2'>
<div className='flex flex-col gap-1 text-xs sm:flex-row sm:items-center sm:justify-between'>
<span className='font-semibold uppercase tracking-wide opacity-70'>
{_('Chapter Progress')}
</span>
<span className='tabular-nums opacity-60'>
{(state.currentIndex + 1).toLocaleString()} / {state.words.length.toLocaleString()}{' '}
{_('words')}
{getTimeRemaining() && (
<span className='opacity-80'>
{' '}
· {_('{{time}} left', { time: getTimeRemaining() })}
</span>
)}
</span>
</div>
<div
role='slider'
tabIndex={0}
aria-label={_('Reading progress')}
aria-valuenow={Math.round(state.progress)}
aria-valuemin={0}
aria-valuemax={100}
className='relative h-2 cursor-pointer overflow-visible rounded bg-gray-500/30'
onClick={handleProgressBarClick}
onKeyDown={(e) => {
if (e.key === 'ArrowLeft') controller.skipBackward();
else if (e.key === 'ArrowRight') controller.skipForward();
}}
title={_('Click to seek')}
>
<div
className='absolute left-0 top-0 h-full rounded transition-[width] duration-100'
style={{ width: `${state.progress}%`, backgroundColor: accentColor }}
/>
<div
className='absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full shadow transition-[left] duration-100'
style={{ left: `${state.progress}%`, backgroundColor: accentColor }}
/>
</div>
</div>
{/* Controls */}
<div className='flex flex-col gap-3 md:flex-row md:items-center md:justify-between md:gap-4'>
{/* Playback controls - centered on mobile, middle on desktop */}
<div className='flex items-center justify-center gap-2 md:order-2 md:gap-4'>
<button
aria-label={_('Skip back 15 words')}
className='flex cursor-pointer items-center gap-1 rounded-full border-none bg-transparent px-2 py-1.5 transition-colors hover:bg-gray-500/20 active:scale-95 md:px-3 md:py-2'
onClick={() => controller.skipBackward(15)}
title={_('Back 15 words (Shift+Left)')}
>
<span className='text-xs font-semibold opacity-80'>15</span>
<IoPlaySkipBack className='h-5 w-5 md:h-6 md:w-6' />
</button>
<button
aria-label={state.playing ? _('Pause') : _('Play')}
className={clsx(
'flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-gray-500/15 transition-colors hover:bg-gray-500/25 active:scale-95 md:h-16 md:w-16',
state.playing ? '' : 'ps-1',
)}
onClick={() => controller.togglePlayPause()}
title={state.playing ? _('Pause (Space)') : _('Play (Space)')}
>
{state.playing ? (
<IoPause className='h-7 w-7 md:h-8 md:w-8' />
) : (
<IoPlay className='h-7 w-7 md:h-8 md:w-8' />
)}
</button>
<button
aria-label={_('Skip forward 15 words')}
className='flex cursor-pointer items-center gap-1 rounded-full border-none bg-transparent px-2 py-1.5 transition-colors hover:bg-gray-500/20 active:scale-95 md:px-3 md:py-2'
onClick={() => controller.skipForward(15)}
title={_('Forward 15 words (Shift+Right)')}
>
<IoPlaySkipForward className='h-5 w-5 md:h-6 md:w-6' />
<span className='text-xs font-semibold opacity-80'>15</span>
</button>
</div>
{/* Secondary controls row on mobile, split on desktop */}
<div className='flex items-center justify-between gap-4 md:contents'>
{/* Punctuation pause - left on desktop */}
<div className='flex items-center md:order-1 md:min-w-[140px] md:flex-1'>
<label className='flex cursor-pointer items-center gap-1.5 text-xs font-medium opacity-80 md:gap-2'>
<span className='hidden sm:inline'>{_('Pause:')}</span>
<span className='sm:hidden'>{_('Pause:')}</span>
<select
className='cursor-pointer rounded border border-gray-500/30 bg-gray-500/20 px-1.5 py-1 text-xs font-medium transition-colors hover:border-gray-500/40 hover:bg-gray-500/30 md:px-2'
style={{ color: 'inherit' }}
value={state.punctuationPauseMs}
onChange={(e) => controller.setPunctuationPause(parseInt(e.target.value, 10))}
>
{controller.getPunctuationPauseOptions().map((option) => (
<option key={option} value={option}>
{option}ms
</option>
))}
</select>
</label>
</div>
{/* Speed controls - right on desktop */}
<div className='flex items-center justify-end gap-1.5 md:order-3 md:min-w-[140px] md:flex-1 md:gap-2'>
<button
aria-label={_('Decrease speed')}
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95 md:h-10 md:w-10'
onClick={() => controller.decreaseSpeed()}
title={_('Slower (Left/Down)')}
>
<IoRemove className='h-4 w-4 md:h-5 md:w-5' />
</button>
<span
aria-label={_('Current speed')}
className='min-w-10 text-center text-sm font-medium md:min-w-12'
>
{state.wpm}
</span>
<button
aria-label={_('Increase speed')}
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95 md:h-10 md:w-10'
onClick={() => controller.increaseSpeed()}
title={_('Faster (Right/Up)')}
>
<IoAdd className='h-4 w-4 md:h-5 md:w-5' />
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default RSVPOverlay;

View file

@ -0,0 +1,146 @@
'use client';
import React from 'react';
import { RsvpStartChoice } from '@/services/rsvp';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { IoBookmark, IoPlayCircle, IoLocation, IoText } from 'react-icons/io5';
interface RSVPStartDialogProps {
startChoice: RsvpStartChoice;
onSelect: (option: 'beginning' | 'saved' | 'current' | 'selection') => void;
onClose: () => void;
}
const RSVPStartDialog: React.FC<RSVPStartDialogProps> = ({ startChoice, onSelect, onClose }) => {
const _ = useTranslation();
const { themeCode, isDarkMode } = useThemeStore();
// Use theme colors directly from themeCode (bg, fg, primary are already resolved from palette)
// For dialog, use a slightly different background using palette['base-200'] or darken/lighten the bg
const bgColor = themeCode.palette['base-200'] || themeCode.bg;
const fgColor = themeCode.fg;
const accentColor = themeCode.primary;
const backdropColor = isDarkMode ? 'rgba(0, 0, 0, 0.75)' : 'rgba(0, 0, 0, 0.6)';
return (
<div
role='presentation'
className='fixed inset-0 z-[10001] flex items-center justify-center'
style={{ backgroundColor: backdropColor }}
onClick={onClose}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */}
<div
className='mx-4 w-full max-w-md rounded-2xl p-6 shadow-2xl'
style={{ backgroundColor: bgColor, color: fgColor, opacity: 1 }}
onClick={(e) => e.stopPropagation()}
role='dialog'
aria-modal='true'
aria-labelledby='rsvp-dialog-title'
>
<h2 id='rsvp-dialog-title' className='mb-2 text-xl font-bold'>
{_('Start RSVP Reading')}
</h2>
<p className='mb-6 text-sm opacity-70'>{_('Choose where to start reading')}</p>
<div className='flex flex-col gap-3'>
{/* Start from beginning */}
<button
className='flex cursor-pointer items-center gap-4 rounded-xl border-none bg-gray-500/10 px-4 py-4 text-left transition-colors hover:bg-gray-500/20'
style={{ color: 'inherit' }}
onClick={() => onSelect('beginning')}
>
<div
className='flex h-10 w-10 items-center justify-center rounded-full'
style={{ backgroundColor: `${accentColor}20`, color: accentColor }}
>
<IoPlayCircle size={24} />
</div>
<div>
<div className='font-semibold'>{_('From Chapter Start')}</div>
<div className='text-sm opacity-60'>
{_('Start reading from the beginning of the chapter')}
</div>
</div>
</button>
{/* Resume from saved position */}
{startChoice.hasSavedPosition && (
<button
className='flex cursor-pointer items-center gap-4 rounded-xl border-none bg-gray-500/10 px-4 py-4 text-left transition-colors hover:bg-gray-500/20'
style={{ color: 'inherit' }}
onClick={() => onSelect('saved')}
>
<div
className='flex h-10 w-10 items-center justify-center rounded-full'
style={{ backgroundColor: `${accentColor}20`, color: accentColor }}
>
<IoBookmark size={24} />
</div>
<div>
<div className='font-semibold'>{_('Resume')}</div>
<div className='text-sm opacity-60'>{_('Continue from where you left off')}</div>
</div>
</button>
)}
{/* Start from current position */}
<button
className='flex cursor-pointer items-center gap-4 rounded-xl border-none bg-gray-500/10 px-4 py-4 text-left transition-colors hover:bg-gray-500/20'
style={{ color: 'inherit' }}
onClick={() => onSelect('current')}
>
<div
className='flex h-10 w-10 items-center justify-center rounded-full'
style={{ backgroundColor: `${accentColor}20`, color: accentColor }}
>
<IoLocation size={24} />
</div>
<div>
<div className='font-semibold'>{_('From Current Page')}</div>
<div className='text-sm opacity-60'>
{_('Start from where you are currently reading')}
</div>
</div>
</button>
{/* Start from selection */}
{startChoice.hasSelection && startChoice.selectionText && (
<button
className='flex cursor-pointer items-center gap-4 rounded-xl border-none bg-gray-500/10 px-4 py-4 text-left transition-colors hover:bg-gray-500/20'
style={{ color: 'inherit' }}
onClick={() => onSelect('selection')}
>
<div
className='flex h-10 w-10 items-center justify-center rounded-full'
style={{ backgroundColor: `${accentColor}20`, color: accentColor }}
>
<IoText size={24} />
</div>
<div>
<div className='font-semibold'>{_('From Selection')}</div>
<div className='max-w-[250px] truncate text-sm opacity-60'>
&quot;{startChoice.selectionText.substring(0, 50)}
{startChoice.selectionText.length > 50 ? '...' : ''}&quot;
</div>
</div>
</button>
)}
</div>
{/* Cancel button */}
<button
className='mt-6 w-full cursor-pointer rounded-xl border border-gray-500/30 bg-transparent px-4 py-3 font-medium transition-colors hover:bg-gray-500/10'
style={{ color: 'inherit' }}
onClick={onClose}
>
{_('Cancel')}
</button>
</div>
</div>
);
};
export default RSVPStartDialog;

View file

@ -0,0 +1,3 @@
export { default as RSVPControl } from './RSVPControl';
export { default as RSVPOverlay } from './RSVPOverlay';
export { default as RSVPStartDialog } from './RSVPStartDialog';

View file

@ -0,0 +1,710 @@
import { FoliateView } from '@/types/view';
import { RsvpWord, RsvpState, RsvpPosition, RsvpStopPosition, RsvpStartChoice } from './types';
import { containsCJK, splitTextIntoWords } from './utils';
const DEFAULT_WPM = 300;
const MIN_WPM = 100;
const MAX_WPM = 1000;
const WPM_STEP = 50;
const DEFAULT_PUNCTUATION_PAUSE_MS = 100;
const PUNCTUATION_PAUSE_OPTIONS = [25, 50, 75, 100, 125, 150, 175, 200];
const STORAGE_KEY_PREFIX = 'readest_rsvp_wpm_';
const PUNCTUATION_PAUSE_KEY_PREFIX = 'readest_rsvp_pause_';
const POSITION_KEY_PREFIX = 'readest_rsvp_pos_';
export class RSVPController extends EventTarget {
private view: FoliateView;
private bookKey: string;
private currentCfi: string | null = null;
private state: RsvpState = {
active: false,
playing: false,
words: [],
currentIndex: 0,
wpm: DEFAULT_WPM,
punctuationPauseMs: DEFAULT_PUNCTUATION_PAUSE_MS,
progress: 0,
resumedFromIndex: null,
};
private playbackTimer: ReturnType<typeof setTimeout> | null = null;
private countdownTimer: ReturnType<typeof setInterval> | null = null;
private pendingStartWordIndex: number | null = null;
private countdown: number | null = null;
constructor(view: FoliateView, bookKey: string) {
super();
this.view = view;
this.bookKey = bookKey;
this.loadSettings();
}
private loadSettings(): void {
const savedWpm = this.loadWpmFromStorage();
if (savedWpm) {
this.state.wpm = savedWpm;
}
const savedPause = this.loadPunctuationPauseFromStorage();
if (savedPause) {
this.state.punctuationPauseMs = savedPause;
}
}
get currentState(): RsvpState {
return { ...this.state };
}
get currentWord(): RsvpWord | null {
if (this.state.currentIndex >= 0 && this.state.currentIndex < this.state.words.length) {
return this.state.words[this.state.currentIndex]!;
}
return null;
}
get currentCountdown(): number | null {
return this.countdown;
}
getPunctuationPauseOptions(): number[] {
return PUNCTUATION_PAUSE_OPTIONS;
}
setPunctuationPause(pauseMs: number): void {
if (PUNCTUATION_PAUSE_OPTIONS.includes(pauseMs)) {
this.state.punctuationPauseMs = pauseMs;
this.savePunctuationPauseToStorage(pauseMs);
this.emitStateChange();
}
}
private loadPunctuationPauseFromStorage(): number | null {
const stored = localStorage.getItem(`${PUNCTUATION_PAUSE_KEY_PREFIX}${this.bookKey}`);
if (stored) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed) && PUNCTUATION_PAUSE_OPTIONS.includes(parsed)) {
return parsed;
}
}
return null;
}
private savePunctuationPauseToStorage(pauseMs: number): void {
localStorage.setItem(`${PUNCTUATION_PAUSE_KEY_PREFIX}${this.bookKey}`, pauseMs.toString());
}
setWpm(wpm: number): void {
const clampedWpm = Math.max(MIN_WPM, Math.min(MAX_WPM, wpm));
this.state.wpm = clampedWpm;
this.saveWpmToStorage(clampedWpm);
this.emitStateChange();
}
private loadWpmFromStorage(): number | null {
const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${this.bookKey}`);
if (stored) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed) && parsed >= MIN_WPM && parsed <= MAX_WPM) {
return parsed;
}
}
return null;
}
private saveWpmToStorage(wpm: number): void {
localStorage.setItem(`${STORAGE_KEY_PREFIX}${this.bookKey}`, wpm.toString());
}
setCurrentCfi(cfi: string | null): void {
this.currentCfi = cfi;
}
private loadPositionFromStorage(): RsvpPosition | null {
const stored = localStorage.getItem(`${POSITION_KEY_PREFIX}${this.bookKey}`);
if (stored) {
try {
return JSON.parse(stored) as RsvpPosition;
} catch {
return null;
}
}
return null;
}
private savePositionToStorage(): void {
if (!this.currentCfi) return;
if (this.state.words.length === 0) return;
const position: RsvpPosition = {
cfi: this.currentCfi,
wordIndex: this.state.currentIndex,
wordText: this.state.words[this.state.currentIndex]?.text || '',
};
localStorage.setItem(`${POSITION_KEY_PREFIX}${this.bookKey}`, JSON.stringify(position));
}
private clearPositionFromStorage(): void {
localStorage.removeItem(`${POSITION_KEY_PREFIX}${this.bookKey}`);
}
private extractBaseCfi(cfi: string): string {
const inner = cfi.replace(/^epubcfi\(/, '').replace(/\)$/, '');
const parts = inner.split(',');
let basePath = parts[0]!;
const match = basePath.match(/^(.*\][^\/]*)/);
if (match) {
basePath = match[1]!;
}
return basePath;
}
private isSameSection(cfi1: string | null, cfi2: string | null): boolean {
if (!cfi1 || !cfi2) return false;
const base1 = this.extractBaseCfi(cfi1);
const base2 = this.extractBaseCfi(cfi2);
return base1 === base2;
}
start(retryCount = 0): void {
const words = this.extractWordsWithRanges();
if (words.length === 0) {
if (retryCount < 3) {
setTimeout(() => this.start(retryCount + 1), 150 * (retryCount + 1));
return;
}
return;
}
let startIndex = 0;
let resumedFromIndex: number | null = null;
if (this.pendingStartWordIndex !== null && this.pendingStartWordIndex < words.length) {
startIndex = this.pendingStartWordIndex;
this.pendingStartWordIndex = null;
} else {
const savedPosition = this.loadPositionFromStorage();
if (savedPosition && this.isSameSection(savedPosition.cfi, this.currentCfi)) {
if (savedPosition.wordIndex < words.length) {
if (words[savedPosition.wordIndex]?.text === savedPosition.wordText) {
startIndex = savedPosition.wordIndex;
resumedFromIndex = savedPosition.wordIndex;
}
}
}
}
this.state = {
...this.state,
active: true,
playing: false,
words,
currentIndex: startIndex,
progress: (startIndex / words.length) * 100,
resumedFromIndex,
};
this.emitStateChange();
this.startCountdown(() => {
this.state.playing = true;
this.emitStateChange();
this.scheduleNextWord();
});
}
pause(): void {
this.clearTimer();
this.clearCountdown();
this.state.playing = false;
this.emitStateChange();
}
resume(): void {
if (!this.state.active) return;
this.startCountdown(() => {
this.state.playing = true;
this.emitStateChange();
this.scheduleNextWord();
});
}
private startCountdown(onComplete: () => void): void {
this.clearCountdown();
let count = 3;
this.countdown = count;
this.emitCountdownChange();
this.countdownTimer = setInterval(() => {
count--;
if (count > 0) {
this.countdown = count;
this.emitCountdownChange();
} else {
this.clearCountdown();
onComplete();
}
}, 800);
}
private clearCountdown(): void {
if (this.countdownTimer) {
clearInterval(this.countdownTimer);
this.countdownTimer = null;
}
this.countdown = null;
this.emitCountdownChange();
}
togglePlayPause(): void {
if (this.state.playing) {
this.pause();
} else {
this.resume();
}
}
stop(): void {
this.savePositionToStorage();
const stopPosition: RsvpStopPosition | null =
this.state.words.length > 0
? {
wordIndex: this.state.currentIndex,
totalWords: this.state.words.length,
text: this.state.words[this.state.currentIndex]?.text || '',
range: this.state.words[this.state.currentIndex]?.range,
docIndex: this.state.words[this.state.currentIndex]?.docIndex,
}
: null;
this.dispatchEvent(new CustomEvent('rsvp-stop', { detail: stopPosition }));
this.clearTimer();
this.clearCountdown();
this.state = {
...this.state,
active: false,
playing: false,
words: [],
currentIndex: 0,
progress: 0,
resumedFromIndex: null,
};
this.emitStateChange();
}
requestStart(selectionText?: string): void {
const words = this.extractWordsWithRanges();
const firstVisibleWordIndex = this.findFirstVisibleWordIndex(words);
const savedPosition = this.loadPositionFromStorage();
const hasSavedPosition = !!(
savedPosition && this.isSameSection(savedPosition.cfi, this.currentCfi)
);
const hasSelection = !!selectionText && selectionText.trim().length > 0;
const startChoice: RsvpStartChoice = {
hasSavedPosition,
hasSelection,
selectionText: selectionText?.trim(),
firstVisibleWordIndex,
};
this.dispatchEvent(new CustomEvent('rsvp-start-choice', { detail: startChoice }));
}
startFromBeginning(): void {
this.clearPositionFromStorage();
this.pendingStartWordIndex = null;
this.start();
}
startFromSavedPosition(): void {
this.pendingStartWordIndex = null;
this.start();
}
startFromCurrentPosition(): void {
this.clearPositionFromStorage();
const words = this.extractWordsWithRanges();
const firstVisibleIndex = this.findFirstVisibleWordIndex(words);
this.pendingStartWordIndex = firstVisibleIndex > 0 ? firstVisibleIndex : null;
this.start();
}
startFromSelection(selectionText: string): void {
this.clearPositionFromStorage();
const words = this.extractWordsWithRanges();
const selectionIndex = this.findWordIndexBySelection(words, selectionText);
this.pendingStartWordIndex = selectionIndex >= 0 ? selectionIndex : null;
this.start();
}
private findFirstVisibleWordIndex(words: RsvpWord[]): number {
if (words.length === 0) return 0;
try {
const renderer = this.view.renderer;
const scrollStart = renderer.start ?? 0;
const pageSize = renderer.size ?? 0;
if (pageSize > 0) {
const visibleStart = scrollStart - pageSize;
const visibleEnd = scrollStart;
for (let i = 0; i < words.length; i++) {
const word = words[i];
if (word?.range) {
try {
const rect = word.range.getBoundingClientRect();
if (rect.width > 0 && rect.left >= visibleStart && rect.left < visibleEnd) {
return i;
}
} catch {
continue;
}
}
}
}
} catch {
// Fall through to return 0
}
return 0;
}
private findWordIndexBySelection(words: RsvpWord[], selectionText: string): number {
if (!selectionText || words.length === 0) return -1;
const cleanSelection = selectionText.trim();
if (!cleanSelection) return -1;
const hasCJK = containsCJK(cleanSelection);
if (hasCJK) {
const selectionLower = cleanSelection.toLowerCase();
// Build a continuous text from words for matching
for (let i = 0; i < words.length; i++) {
let continuousText = '';
for (let j = i; j < Math.min(i + 20, words.length); j++) {
continuousText += words[j]!.text;
if (continuousText.toLowerCase().includes(selectionLower)) {
return i;
}
}
}
// Fallback: try to match first few characters
const firstChars = cleanSelection.slice(0, Math.min(3, cleanSelection.length)).toLowerCase();
for (let i = 0; i < words.length; i++) {
if (words[i]!.text.toLowerCase().includes(firstChars)) {
return i;
}
}
return -1;
}
const cleanSelectionLower = cleanSelection.toLowerCase();
const selectionWords = cleanSelectionLower.split(/\s+/);
if (selectionWords.length === 0) return -1;
const firstSelectionWord = selectionWords[0]!;
for (let i = 0; i < words.length; i++) {
const word = words[i]!;
const cleanWord = word.text.toLowerCase().replace(/[^\w]/g, '');
const cleanFirstWord = firstSelectionWord.replace(/[^\w]/g, '');
if (
cleanWord === cleanFirstWord ||
cleanWord.includes(cleanFirstWord) ||
cleanFirstWord.includes(cleanWord)
) {
if (selectionWords.length === 1) {
return i;
}
let matchCount = 1;
for (let j = 1; j < selectionWords.length && i + j < words.length; j++) {
const nextWord = words[i + j]!.text.toLowerCase().replace(/[^\w]/g, '');
const nextSelectionWord = selectionWords[j]!.replace(/[^\w]/g, '');
if (nextWord === nextSelectionWord || nextWord.includes(nextSelectionWord)) {
matchCount++;
} else {
break;
}
}
if (matchCount >= Math.ceil(selectionWords.length / 2)) {
return i;
}
}
}
return -1;
}
increaseSpeed(): void {
const newWpm = Math.min(MAX_WPM, this.state.wpm + WPM_STEP);
this.state.wpm = newWpm;
this.saveWpmToStorage(newWpm);
this.emitStateChange();
}
decreaseSpeed(): void {
const newWpm = Math.max(MIN_WPM, this.state.wpm - WPM_STEP);
this.state.wpm = newWpm;
this.saveWpmToStorage(newWpm);
this.emitStateChange();
}
skipForward(count: number = 10): void {
const newIndex = Math.min(this.state.words.length - 1, this.state.currentIndex + count);
this.state.currentIndex = newIndex;
this.state.progress = (newIndex / this.state.words.length) * 100;
this.emitStateChange();
}
skipBackward(count: number = 10): void {
const newIndex = Math.max(0, this.state.currentIndex - count);
this.state.currentIndex = newIndex;
this.state.progress = (newIndex / this.state.words.length) * 100;
this.emitStateChange();
}
seekToPosition(percentage: number): void {
if (this.state.words.length === 0) return;
const newIndex = Math.floor((percentage / 100) * this.state.words.length);
const clampedIndex = Math.max(0, Math.min(this.state.words.length - 1, newIndex));
this.state.currentIndex = clampedIndex;
this.state.progress = (clampedIndex / this.state.words.length) * 100;
this.emitStateChange();
}
loadNextPageContent(retryCount = 0): void {
this.clearPositionFromStorage();
const words = this.extractWordsWithRanges();
if (words.length === 0) {
if (retryCount < 3) {
setTimeout(() => this.loadNextPageContent(retryCount + 1), 200 * (retryCount + 1));
return;
}
this.pause();
return;
}
const wasPlaying = this.state.playing;
this.state = {
...this.state,
words,
currentIndex: 0,
progress: 0,
resumedFromIndex: null,
playing: false,
};
this.emitStateChange();
if (wasPlaying) {
this.startCountdown(() => {
this.state.playing = true;
this.emitStateChange();
this.scheduleNextWord();
});
}
}
private scheduleNextWord(): void {
this.clearTimer();
if (!this.state.playing || !this.state.active) return;
if (this.state.currentIndex >= this.state.words.length) {
this.dispatchEvent(new CustomEvent('rsvp-request-next-page'));
return;
}
const word = this.state.words[this.state.currentIndex]!;
const duration = this.getWordDisplayDuration(word, this.state.wpm);
this.playbackTimer = setTimeout(() => {
this.advanceToNextWord();
}, duration);
}
private advanceToNextWord(): void {
const newIndex = this.state.currentIndex + 1;
if (newIndex >= this.state.words.length) {
this.dispatchEvent(new CustomEvent('rsvp-request-next-page'));
return;
}
this.state.currentIndex = newIndex;
this.state.progress = (newIndex / this.state.words.length) * 100;
this.emitStateChange();
this.scheduleNextWord();
}
private clearTimer(): void {
if (this.playbackTimer) {
clearTimeout(this.playbackTimer);
this.playbackTimer = null;
}
}
private extractWordsWithRanges(): RsvpWord[] {
const renderer = this.view.renderer;
if (!renderer) return [];
const contents = renderer.getContents?.();
if (!contents || contents.length === 0) return [];
const allWords: RsvpWord[] = [];
for (const content of contents) {
const { doc, index: docIndex } = content as { doc: Document; index: number };
if (!doc?.body) continue;
const words = this.extractWordsFromElement(doc.body, doc, docIndex);
allWords.push(...words);
}
return allWords;
}
private extractWordsFromElement(
element: HTMLElement,
doc: Document,
docIndex: number,
): RsvpWord[] {
const excludeTags = new Set(['SCRIPT', 'STYLE', 'NAV', 'HEADER', 'FOOTER', 'ASIDE']);
const words: RsvpWord[] = [];
const walk = (node: Node): void => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
const nodeWords = splitTextIntoWords(text);
console.log('Extracted words from text node:', nodeWords);
let offset = 0;
for (const word of nodeWords) {
const wordStart = text.indexOf(word, offset);
if (wordStart === -1) continue;
try {
const range = doc.createRange();
range.setStart(node, wordStart);
range.setEnd(node, wordStart + word.length);
words.push({
text: word,
orpIndex: this.calculateORP(word),
pauseMultiplier: this.getPauseMultiplier(word),
range,
docIndex,
});
} catch {
words.push({
text: word,
orpIndex: this.calculateORP(word),
pauseMultiplier: this.getPauseMultiplier(word),
});
}
offset = wordStart + word.length;
}
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return;
}
const el = node as HTMLElement;
const tagName = el.tagName.toUpperCase();
if (excludeTags.has(tagName)) {
return;
}
const style = el.ownerDocument.defaultView?.getComputedStyle(el);
if (style?.display === 'none' || style?.visibility === 'hidden') {
return;
}
for (const child of Array.from(el.childNodes)) {
walk(child);
}
};
walk(element);
return words;
}
private calculateORP(word: string): number {
const hasCJK = containsCJK(word);
if (hasCJK) {
// For CJK characters, center the ORP since each character is more balanced
const len = word.length;
return Math.floor(len / 2);
}
const cleanWord = word.replace(/[^\w]/g, '');
const len = cleanWord.length;
if (len <= 1) return 0;
if (len <= 3) return 0;
if (len <= 5) return 1;
if (len <= 8) return 2;
return 3;
}
private getPauseMultiplier(word: string): number {
const hasCJK = containsCJK(word);
if (hasCJK) {
// CJK characters are information-dense, adjust pause based on character count
// With semantic segmentation, words can vary in length
const len = word.length;
if (len >= 5) return 1.4; // Longer compound words
if (len >= 4) return 1.3;
if (len >= 3) return 1.2;
if (len >= 2) return 1.0;
return 0.9; // Single characters
}
if (word.length > 12) return 1.3;
if (word.length > 8) return 1.1;
return 1.0;
}
private getWordDisplayDuration(word: RsvpWord, wpm: number): number {
const baseMs = 60000 / wpm;
let duration = baseMs * word.pauseMultiplier;
if (/[.!?,;:]$/.test(word.text)) {
duration += this.state.punctuationPauseMs;
}
return duration;
}
private emitStateChange(): void {
this.dispatchEvent(new CustomEvent('rsvp-state-change', { detail: this.currentState }));
}
private emitCountdownChange(): void {
this.dispatchEvent(new CustomEvent('rsvp-countdown-change', { detail: this.countdown }));
}
shutdown(): void {
this.stop();
this.clearPositionFromStorage();
this.currentCfi = null;
}
}

View file

@ -0,0 +1,2 @@
export * from './types';
export * from './RSVPController';

View file

@ -0,0 +1,39 @@
export interface RsvpWord {
text: string;
orpIndex: number;
pauseMultiplier: number;
range?: Range;
docIndex?: number;
}
export interface RsvpState {
active: boolean;
playing: boolean;
words: RsvpWord[];
currentIndex: number;
wpm: number;
punctuationPauseMs: number;
progress: number;
resumedFromIndex: number | null;
}
export interface RsvpPosition {
cfi: string;
wordIndex: number;
wordText: string;
}
export interface RsvpStopPosition {
wordIndex: number;
totalWords: number;
text: string;
range?: Range;
docIndex?: number;
}
export interface RsvpStartChoice {
hasSavedPosition: boolean;
hasSelection: boolean;
selectionText?: string;
firstVisibleWordIndex: number;
}

View file

@ -0,0 +1,243 @@
/**
* Utility functions for CJK (Chinese, Japanese, Korean) text processing
*/
/**
* Check if a character is a CJK character
*/
export function isCJK(char: string): boolean {
const code = char.charCodeAt(0);
return (
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
(code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
(code >= 0x20000 && code <= 0x2a6df) || // CJK Extension B
(code >= 0x2a700 && code <= 0x2b73f) || // CJK Extension C
(code >= 0x2b740 && code <= 0x2b81f) || // CJK Extension D
(code >= 0x2b820 && code <= 0x2ceaf) || // CJK Extension E
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
(code >= 0x3040 && code <= 0x309f) || // Hiragana
(code >= 0x30a0 && code <= 0x30ff) || // Katakana
(code >= 0xac00 && code <= 0xd7af) // Hangul Syllables
);
}
/**
* Check if text contains any CJK characters
*/
export function containsCJK(text: string): boolean {
for (let i = 0; i < text.length; i++) {
if (isCJK(text[i]!)) {
return true;
}
}
return false;
}
/**
* Check if text is CJK punctuation
*/
export function isCJKPunctuation(text: string): boolean {
// Check if text is CJK punctuation (single character or string)
// Includes: CJK symbols, full-width forms, and halfwidth variants
const cjkPunctuationPattern =
/[。!?,、;:""''()《》【】『』「」〈〉〔〕〖〗〘〙〚〛…—~·․‥⋯﹐﹑﹒﹔﹕﹖﹗﹙﹚﹛﹜﹝﹞!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~⦅⦆。「」、・\u3000-\u303F\uFF00-\uFFEF]/;
return cjkPunctuationPattern.test(text);
}
/**
* Detect the appropriate locale for text segmentation based on character ranges
*/
export function getSegmenterLocale(text: string): string | null {
// Detect which CJK language based on character ranges
for (const char of text) {
const code = char.charCodeAt(0);
// Japanese-specific characters
if ((code >= 0x3040 && code <= 0x309f) || (code >= 0x30a0 && code <= 0x30ff)) {
return 'ja';
}
// Korean Hangul
if (code >= 0xac00 && code <= 0xd7af) {
return 'ko';
}
// Chinese characters (most common CJK range)
if (code >= 0x4e00 && code <= 0x9fff) {
return 'zh';
}
}
return null;
}
/**
* Segment CJK text into words using Intl.Segmenter with punctuation attachment
*/
export function segmentCJKText(text: string): string[] {
// Try to use Intl.Segmenter for semantic word segmentation
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
try {
const locale = getSegmenterLocale(text) || 'zh';
const segmenter = new Intl.Segmenter(locale, { granularity: 'word' });
const segments = Array.from(segmenter.segment(text));
const words: string[] = [];
let i = 0;
while (i < segments.length) {
const segment = segments[i]!;
const segmentText = segment.segment;
// Only process actual words (skip pure whitespace)
if ((segment.isWordLike || containsCJK(segmentText)) && segmentText.trim()) {
let wordWithPunct = segmentText;
// Look ahead for trailing punctuation in the next segments
let j = i + 1;
while (j < segments.length) {
const nextSegment = segments[j]!;
const nextText = nextSegment.segment;
// If next segment is whitespace, skip it but continue looking
if (nextText.trim() === '') {
j++;
continue;
}
// If next segment is punctuation, attach it
if (isCJKPunctuation(nextText)) {
wordWithPunct += nextText;
j++;
} else {
// Stop at the next word
break;
}
}
words.push(wordWithPunct);
i = j; // Skip to after the punctuation we just processed
} else {
i++;
}
}
return words;
} catch (error) {
console.warn('Intl.Segmenter failed, falling back to simple segmentation:', error);
}
}
// Fallback: Simple character-based segmentation with punctuation
const words: string[] = [];
let currentWord = '';
for (let i = 0; i < text.length; i++) {
const char = text[i]!;
if (char.match(/\s/)) {
if (currentWord) {
words.push(currentWord);
currentWord = '';
}
} else if (isCJK(char)) {
currentWord += char;
// Group 2 characters for readability
if (currentWord.length >= 2) {
// Look ahead for punctuation
let j = i + 1;
while (j < text.length && isCJKPunctuation(text[j]!)) {
currentWord += text[j];
i = j;
j++;
}
words.push(currentWord);
currentWord = '';
}
} else if (isCJKPunctuation(char)) {
// Attach punctuation to current word
currentWord += char;
} else {
currentWord += char;
}
}
if (currentWord) {
words.push(currentWord);
}
return words.filter((w) => w.trim().length > 0);
}
/**
* Split text into words, handling both CJK and non-CJK text
*/
export function splitTextIntoWords(text: string): string[] {
const hasCJK = containsCJK(text);
if (!hasCJK) {
// Use space-based splitting for non-CJK text
return text.split(/(\s+)/).filter((w) => w.trim().length > 0);
}
// For CJK text, use semantic segmentation
const words: string[] = [];
let currentSegment = '';
let inCJKSequence = false;
for (let i = 0; i < text.length; i++) {
const char = text[i]!;
const charIsCJK = isCJK(char);
const charIsPunct = isCJKPunctuation(char);
if (charIsCJK) {
if (!inCJKSequence && currentSegment) {
// Push non-CJK segment
words.push(currentSegment);
currentSegment = '';
}
currentSegment += char;
inCJKSequence = true;
} else if (charIsPunct) {
// CJK punctuation should be kept with CJK segment
if (inCJKSequence) {
currentSegment += char;
// Don't change inCJKSequence, keep collecting
} else if (currentSegment) {
// Non-CJK text followed by punctuation
currentSegment += char;
} else {
// Standalone punctuation at start
currentSegment = char;
}
} else if (char.match(/\s/)) {
if (currentSegment) {
if (inCJKSequence) {
// Segment the CJK text (with any trailing punctuation)
words.push(...segmentCJKText(currentSegment));
} else {
words.push(currentSegment);
}
currentSegment = '';
}
inCJKSequence = false;
} else {
// Non-CJK, non-punctuation, non-whitespace character
if (inCJKSequence && currentSegment) {
// Segment the CJK text before continuing with non-CJK
words.push(...segmentCJKText(currentSegment));
currentSegment = '';
}
currentSegment += char;
inCJKSequence = false;
}
}
if (currentSegment) {
if (inCJKSequence) {
words.push(...segmentCJKText(currentSegment));
} else {
words.push(currentSegment);
}
}
return words.filter((w) => w.trim().length > 0);
}

View file

@ -30,6 +30,10 @@ env.addFilter('date', (value: number | string | undefined, format?: string) => {
if (value === undefined || value === null) return '';
let date: Date;
// Check if the input is a date-only string (YYYY-MM-DD) which gets parsed as UTC midnight
// In this case, we should use UTC methods to avoid timezone offset issues
const isDateOnlyString = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
if (typeof value === 'number') {
date = new Date(value);
} else if (typeof value === 'string') {
@ -45,37 +49,74 @@ env.addFilter('date', (value: number | string | undefined, format?: string) => {
}
// Convert Python strftime format to actual values
// Use UTC methods for date-only strings to maintain consistency
const getYear = () => (isDateOnlyString ? date.getUTCFullYear() : date.getFullYear());
const getMonth = () => (isDateOnlyString ? date.getUTCMonth() : date.getMonth());
const getDate = () => (isDateOnlyString ? date.getUTCDate() : date.getDate());
const getHours = () => (isDateOnlyString ? date.getUTCHours() : date.getHours());
const getMinutes = () => (isDateOnlyString ? date.getUTCMinutes() : date.getMinutes());
const getSeconds = () => (isDateOnlyString ? date.getUTCSeconds() : date.getSeconds());
const getDay = () => (isDateOnlyString ? date.getUTCDay() : date.getDay());
return format
.replace(/%Y/g, date.getFullYear().toString())
.replace(/%m/g, String(date.getMonth() + 1).padStart(2, '0'))
.replace(/%d/g, String(date.getDate()).padStart(2, '0'))
.replace(/%H/g, String(date.getHours()).padStart(2, '0'))
.replace(/%M/g, String(date.getMinutes()).padStart(2, '0'))
.replace(/%S/g, String(date.getSeconds()).padStart(2, '0'))
.replace(/%I/g, String(date.getHours() % 12 || 12).padStart(2, '0'))
.replace(/%p/g, date.getHours() >= 12 ? 'PM' : 'AM')
.replace(/%w/g, String(date.getDay()))
.replace(/%j/g, getDayOfYear(date).toString().padStart(3, '0'))
.replace(/%U/g, getWeekNumber(date).toString().padStart(2, '0'))
.replace(/%W/g, getWeekNumber(date, true).toString().padStart(2, '0'))
.replace(/%a/g, date.toLocaleDateString('en-US', { weekday: 'short' }))
.replace(/%A/g, date.toLocaleDateString('en-US', { weekday: 'long' }))
.replace(/%b/g, date.toLocaleDateString('en-US', { month: 'short' }))
.replace(/%B/g, date.toLocaleDateString('en-US', { month: 'long' }))
.replace(/%Y/g, getYear().toString())
.replace(/%m/g, String(getMonth() + 1).padStart(2, '0'))
.replace(/%d/g, String(getDate()).padStart(2, '0'))
.replace(/%H/g, String(getHours()).padStart(2, '0'))
.replace(/%M/g, String(getMinutes()).padStart(2, '0'))
.replace(/%S/g, String(getSeconds()).padStart(2, '0'))
.replace(/%I/g, String(getHours() % 12 || 12).padStart(2, '0'))
.replace(/%p/g, getHours() >= 12 ? 'PM' : 'AM')
.replace(/%w/g, String(getDay()))
.replace(/%j/g, getDayOfYear(date, isDateOnlyString).toString().padStart(3, '0'))
.replace(/%U/g, getWeekNumber(date, false, isDateOnlyString).toString().padStart(2, '0'))
.replace(/%W/g, getWeekNumber(date, true, isDateOnlyString).toString().padStart(2, '0'))
.replace(
/%a/g,
date.toLocaleDateString('en-US', {
weekday: 'short',
timeZone: isDateOnlyString ? 'UTC' : undefined,
}),
)
.replace(
/%A/g,
date.toLocaleDateString('en-US', {
weekday: 'long',
timeZone: isDateOnlyString ? 'UTC' : undefined,
}),
)
.replace(
/%b/g,
date.toLocaleDateString('en-US', {
month: 'short',
timeZone: isDateOnlyString ? 'UTC' : undefined,
}),
)
.replace(
/%B/g,
date.toLocaleDateString('en-US', {
month: 'long',
timeZone: isDateOnlyString ? 'UTC' : undefined,
}),
)
.replace(/%%/g, '%');
});
// Helper to get day of year
function getDayOfYear(date: Date): number {
const start = new Date(date.getFullYear(), 0, 0);
function getDayOfYear(date: Date, useUTC = false): number {
const year = useUTC ? date.getUTCFullYear() : date.getFullYear();
const start = useUTC ? new Date(Date.UTC(year, 0, 0)) : new Date(year, 0, 0);
const diff = date.getTime() - start.getTime();
const oneDay = 1000 * 60 * 60 * 24;
return Math.floor(diff / oneDay);
}
// Helper to get week number
function getWeekNumber(date: Date, mondayFirst = false): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
function getWeekNumber(date: Date, mondayFirst = false, useUTC = false): number {
const year = useUTC ? date.getUTCFullYear() : date.getFullYear();
const month = useUTC ? date.getUTCMonth() : date.getMonth();
const day = useUTC ? date.getUTCDate() : date.getDate();
const d = new Date(Date.UTC(year, month, day));
const dayNum = mondayFirst ? d.getUTCDay() || 7 : d.getUTCDay();
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));

View file

@ -82,7 +82,7 @@ importers:
version: 2.0.0
'@opennextjs/cloudflare':
specifier: ^1.15.1
version: 1.15.1(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.60.0)
version: 1.15.1(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.60.0)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@ -115,7 +115,7 @@ importers:
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@serwist/next':
specifier: ^9.4.2
version: 9.5.0(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(webpack@5.104.1)
version: 9.5.0(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(webpack@5.104.1)
'@stripe/react-stripe-js':
specifier: ^3.7.0
version: 3.10.0(@stripe/stripe-js@7.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@ -268,7 +268,7 @@ importers:
version: 5.1.6
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
version: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
nunjucks:
specifier: ^3.2.4
version: 3.2.4(chokidar@3.6.0)
@ -625,28 +625,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@ast-grep/napi-linux-arm64-musl@0.40.0':
resolution: {integrity: sha512-MS9qalLRjUnF2PCzuTKTvCMVSORYHxxe3Qa0+SSaVULsXRBmuy5C/b1FeWwMFnwNnC0uie3VDet31Zujwi8q6A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@ast-grep/napi-linux-x64-gnu@0.40.0':
resolution: {integrity: sha512-BeHZVMNXhM3WV3XE2yghO0fRxhMOt8BTN972p5piYEQUvKeSHmS8oeGcs6Ahgx5znBclqqqq37ZfioYANiTqJA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@ast-grep/napi-linux-x64-musl@0.40.0':
resolution: {integrity: sha512-rG1YujF7O+lszX8fd5u6qkFTuv4FwHXjWvt1CCvCxXwQLSY96LaCW88oVKg7WoEYQh54y++Fk57F+Wh9Gv9nVQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@ast-grep/napi-win32-arm64-msvc@0.40.0':
resolution: {integrity: sha512-9SqmnQqd4zTEUk6yx0TuW2ycZZs2+e569O/R0QnhSiQNpgwiJCYOe/yPS0BC9HkiaozQm6jjAcasWpFtz/dp+w==}
@ -1782,105 +1778,89 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@ -1967,35 +1947,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.88':
resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.88':
resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.88':
resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.88':
resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@0.1.88':
resolution: {integrity: sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==}
@ -2042,28 +2017,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
@ -2202,6 +2173,11 @@ packages:
resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==}
engines: {node: '>=14'}
'@playwright/test@1.58.1':
resolution: {integrity: sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==}
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@ -2753,79 +2729,66 @@ packages:
resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.56.0':
resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.56.0':
resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.56.0':
resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.56.0':
resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.56.0':
resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.56.0':
resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.56.0':
resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.56.0':
resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.56.0':
resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.56.0':
resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.56.0':
resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.56.0':
resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.56.0':
resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==}
@ -3379,35 +3342,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.9.6':
resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.9.6':
resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.9.6':
resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.9.6':
resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.9.6':
resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==}
@ -3828,49 +3786,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@ -5276,6 +5226,11 @@ packages:
resolution: {integrity: sha512-UTOY+59K6IA94tec8Wjqm0FSh5OVudGNB0NL/P6fB3HiE3bYOY3VYBGijsnOHNkQSwC1FKkU77pmq7xp9CskLw==}
engines: {node: '>=10.13.0'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -6586,6 +6541,16 @@ packages:
resolution: {integrity: sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ==}
engines: {node: '>= 0.4.0'}
playwright-core@1.58.1:
resolution: {integrity: sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.58.1:
resolution: {integrity: sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==}
engines: {node: '>=18'}
hasBin: true
points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@ -10221,7 +10186,7 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@opennextjs/aws@3.9.12(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))':
'@opennextjs/aws@3.9.12(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))':
dependencies:
'@ast-grep/napi': 0.40.0
'@aws-sdk/client-cloudfront': 3.398.0
@ -10237,7 +10202,7 @@ snapshots:
cookie: 1.1.1
esbuild: 0.25.4
express: 5.2.1
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
path-to-regexp: 6.3.0
urlpattern-polyfill: 10.1.0
yaml: 2.8.2
@ -10245,15 +10210,15 @@ snapshots:
- aws-crt
- supports-color
'@opennextjs/cloudflare@1.15.1(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.60.0)':
'@opennextjs/cloudflare@1.15.1(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.60.0)':
dependencies:
'@ast-grep/napi': 0.40.0
'@dotenvx/dotenvx': 1.31.0
'@opennextjs/aws': 3.9.12(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))
'@opennextjs/aws': 3.9.12(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))
cloudflare: 4.5.0
enquirer: 2.4.1
glob: 13.0.0
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
ts-tqdm: 0.8.6
wrangler: 4.60.0
yargs: 18.0.0
@ -10338,6 +10303,11 @@ snapshots:
'@opentelemetry/semantic-conventions@1.39.0': {}
'@playwright/test@1.58.1':
dependencies:
playwright: 1.58.1
optional: true
'@polka/url@1.0.0-next.29': {}
'@poppinss/colors@4.1.6':
@ -10945,7 +10915,7 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
'@serwist/next@9.5.0(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(webpack@5.104.1)':
'@serwist/next@9.5.0(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(webpack@5.104.1)':
dependencies:
'@serwist/build': 9.5.0(typescript@5.9.3)
'@serwist/utils': 9.5.0
@ -10954,7 +10924,7 @@ snapshots:
browserslist: 4.28.1
glob: 13.0.0
kolorist: 1.8.0
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
semver: 7.7.3
serwist: 9.5.0(typescript@5.9.3)
@ -13893,6 +13863,9 @@ snapshots:
- bare-abort-controller
- react-native-b4a
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@ -15262,7 +15235,7 @@ snapshots:
neo-async@2.6.2: {}
next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.1.6
'@swc/helpers': 0.5.15
@ -15282,6 +15255,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.1.6
'@next/swc-win32-x64-msvc': 16.1.6
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.58.1
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@ -15520,6 +15494,16 @@ snapshots:
pkginfo@0.4.1: {}
playwright-core@1.58.1:
optional: true
playwright@1.58.1:
dependencies:
playwright-core: 1.58.1
optionalDependencies:
fsevents: 2.3.2
optional: true
points-on-curve@0.2.0: {}
points-on-path@0.2.1: