perf(rsvp): windowed context, extraction caching and lazy CFI for sections with thousands of words, closes #3953 (#3984)
* perf(rsvp): windowed context, extraction caching and lazy CFI for sections with thousands of words, closes #3953 * i18n: update translations
This commit is contained in:
@@ -1282,5 +1282,7 @@
|
||||
"Metadata Hash": "تجزئة البيانات الوصفية",
|
||||
"Meta Hash": "تجزئة البيانات الوصفية",
|
||||
"Computed Hash": "التجزئة المحسوبة",
|
||||
"Identifiers": "المعرفات"
|
||||
"Identifiers": "المعرفات",
|
||||
"Read (Stream)": "قراءة (بث)",
|
||||
"Failed to start stream": "فشل بدء البث"
|
||||
}
|
||||
|
||||
@@ -1214,5 +1214,7 @@
|
||||
"Metadata Hash": "মেটাডেটা হ্যাশ",
|
||||
"Meta Hash": "মেটা হ্যাশ",
|
||||
"Computed Hash": "গণিত হ্যাশ",
|
||||
"Identifiers": "শনাক্তকারী"
|
||||
"Identifiers": "শনাক্তকারী",
|
||||
"Read (Stream)": "পড়ুন (স্ট্রিম)",
|
||||
"Failed to start stream": "স্ট্রিম শুরু করতে ব্যর্থ"
|
||||
}
|
||||
|
||||
@@ -1197,5 +1197,7 @@
|
||||
"Metadata Hash": "དཔྱད་གཞིའི་ཧེཤི",
|
||||
"Meta Hash": "དཔྱད་གཞིའི་ཧེཤི",
|
||||
"Computed Hash": "རྩིས་པའི་ཧེཤི",
|
||||
"Identifiers": "ངོས་འཛིན་བྱེད་མཁན"
|
||||
"Identifiers": "ངོས་འཛིན་བྱེད་མཁན",
|
||||
"Read (Stream)": "ཀློག་པ། (རྒྱུན་སྤེལ།)",
|
||||
"Failed to start stream": "རྒྱུན་སྤེལ་འགོ་འཛུགས་མ་ཐུབ།"
|
||||
}
|
||||
|
||||
@@ -1214,5 +1214,7 @@
|
||||
"Metadata Hash": "Metadaten-Hash",
|
||||
"Meta Hash": "Meta-Hash",
|
||||
"Computed Hash": "Berechneter Hash",
|
||||
"Identifiers": "Bezeichner"
|
||||
"Identifiers": "Bezeichner",
|
||||
"Read (Stream)": "Lesen (Stream)",
|
||||
"Failed to start stream": "Stream konnte nicht gestartet werden"
|
||||
}
|
||||
|
||||
@@ -1214,5 +1214,7 @@
|
||||
"Metadata Hash": "Hash Μεταδεδομένων",
|
||||
"Meta Hash": "Meta Hash",
|
||||
"Computed Hash": "Υπολογισμένο Hash",
|
||||
"Identifiers": "Αναγνωριστικά"
|
||||
"Identifiers": "Αναγνωριστικά",
|
||||
"Read (Stream)": "Ανάγνωση (Ροή)",
|
||||
"Failed to start stream": "Αποτυχία έναρξης ροής"
|
||||
}
|
||||
|
||||
@@ -1231,5 +1231,7 @@
|
||||
"Metadata Hash": "Hash de Metadatos",
|
||||
"Meta Hash": "Meta Hash",
|
||||
"Computed Hash": "Hash Calculado",
|
||||
"Identifiers": "Identificadores"
|
||||
"Identifiers": "Identificadores",
|
||||
"Read (Stream)": "Leer (Streaming)",
|
||||
"Failed to start stream": "No se pudo iniciar el streaming"
|
||||
}
|
||||
|
||||
@@ -1214,5 +1214,7 @@
|
||||
"Metadata Hash": "هش متادیتا",
|
||||
"Meta Hash": "متا هش",
|
||||
"Computed Hash": "هش محاسبهشده",
|
||||
"Identifiers": "شناسهها"
|
||||
"Identifiers": "شناسهها",
|
||||
"Read (Stream)": "خواندن (پخش)",
|
||||
"Failed to start stream": "شروع پخش ناموفق بود"
|
||||
}
|
||||
|
||||
@@ -1231,5 +1231,7 @@
|
||||
"Metadata Hash": "Hachage des métadonnées",
|
||||
"Meta Hash": "Hachage Meta",
|
||||
"Computed Hash": "Hachage calculé",
|
||||
"Identifiers": "Identifiants"
|
||||
"Identifiers": "Identifiants",
|
||||
"Read (Stream)": "Lire (Flux)",
|
||||
"Failed to start stream": "Échec du démarrage du flux"
|
||||
}
|
||||
|
||||
@@ -1231,5 +1231,7 @@
|
||||
"Metadata Hash": "גיבוב מטא-נתונים",
|
||||
"Meta Hash": "גיבוב מטא",
|
||||
"Computed Hash": "גיבוב מחושב",
|
||||
"Identifiers": "מזהים"
|
||||
"Identifiers": "מזהים",
|
||||
"Read (Stream)": "קרא (זרם)",
|
||||
"Failed to start stream": "הפעלת הזרם נכשלה"
|
||||
}
|
||||
|
||||
@@ -1214,5 +1214,7 @@
|
||||
"Metadata Hash": "मेटाडेटा हैश",
|
||||
"Meta Hash": "मेटा हैश",
|
||||
"Computed Hash": "परिकलित हैश",
|
||||
"Identifiers": "पहचानकर्ता"
|
||||
"Identifiers": "पहचानकर्ता",
|
||||
"Read (Stream)": "पढ़ें (स्ट्रीम)",
|
||||
"Failed to start stream": "स्ट्रीम प्रारंभ करने में विफल"
|
||||
}
|
||||
|
||||
@@ -1214,5 +1214,7 @@
|
||||
"Metadata Hash": "Metaadat-hash",
|
||||
"Meta Hash": "Meta-hash",
|
||||
"Computed Hash": "Számított hash",
|
||||
"Identifiers": "Azonosítók"
|
||||
"Identifiers": "Azonosítók",
|
||||
"Read (Stream)": "Olvasás (Stream)",
|
||||
"Failed to start stream": "Nem sikerült elindítani a streamet"
|
||||
}
|
||||
|
||||
@@ -1197,5 +1197,7 @@
|
||||
"Metadata Hash": "Hash Metadata",
|
||||
"Meta Hash": "Meta Hash",
|
||||
"Computed Hash": "Hash Terhitung",
|
||||
"Identifiers": "Pengenal"
|
||||
"Identifiers": "Pengenal",
|
||||
"Read (Stream)": "Baca (Stream)",
|
||||
"Failed to start stream": "Gagal memulai stream"
|
||||
}
|
||||
|
||||
@@ -1231,5 +1231,7 @@
|
||||
"Metadata Hash": "Hash Metadati",
|
||||
"Meta Hash": "Meta Hash",
|
||||
"Computed Hash": "Hash Calcolato",
|
||||
"Identifiers": "Identificatori"
|
||||
"Identifiers": "Identificatori",
|
||||
"Read (Stream)": "Leggi (Stream)",
|
||||
"Failed to start stream": "Avvio dello stream non riuscito"
|
||||
}
|
||||
|
||||
@@ -1197,5 +1197,7 @@
|
||||
"Metadata Hash": "メタデータハッシュ",
|
||||
"Meta Hash": "メタハッシュ",
|
||||
"Computed Hash": "計算されたハッシュ",
|
||||
"Identifiers": "識別子"
|
||||
"Identifiers": "識別子",
|
||||
"Read (Stream)": "読む(ストリーム)",
|
||||
"Failed to start stream": "ストリームの開始に失敗しました"
|
||||
}
|
||||
|
||||
@@ -1197,5 +1197,7 @@
|
||||
"Metadata Hash": "메타데이터 해시",
|
||||
"Meta Hash": "메타 해시",
|
||||
"Computed Hash": "계산된 해시",
|
||||
"Identifiers": "식별자"
|
||||
"Identifiers": "식별자",
|
||||
"Read (Stream)": "읽기 (스트림)",
|
||||
"Failed to start stream": "스트림을 시작할 수 없습니다"
|
||||
}
|
||||
|
||||
@@ -1197,5 +1197,7 @@
|
||||
"Metadata Hash": "Cincang Metadata",
|
||||
"Meta Hash": "Cincang Meta",
|
||||
"Computed Hash": "Cincang Dikira",
|
||||
"Identifiers": "Pengenal"
|
||||
"Identifiers": "Pengenal",
|
||||
"Read (Stream)": "Baca (Strim)",
|
||||
"Failed to start stream": "Gagal memulakan strim"
|
||||
}
|
||||
|
||||
@@ -1214,5 +1214,7 @@
|
||||
"Metadata Hash": "Metadata-hash",
|
||||
"Meta Hash": "Meta-hash",
|
||||
"Computed Hash": "Berekende hash",
|
||||
"Identifiers": "Identificatoren"
|
||||
"Identifiers": "Identificatoren",
|
||||
"Read (Stream)": "Lezen (Stream)",
|
||||
"Failed to start stream": "Kan stream niet starten"
|
||||
}
|
||||
|
||||
@@ -1248,5 +1248,7 @@
|
||||
"Metadata Hash": "Skrót metadanych",
|
||||
"Meta Hash": "Meta-skrót",
|
||||
"Computed Hash": "Obliczony skrót",
|
||||
"Identifiers": "Identyfikatory"
|
||||
"Identifiers": "Identyfikatory",
|
||||
"Read (Stream)": "Czytaj (Strumień)",
|
||||
"Failed to start stream": "Nie udało się uruchomić strumienia"
|
||||
}
|
||||
|
||||
@@ -1231,5 +1231,7 @@
|
||||
"Metadata Hash": "Hash de Metadados",
|
||||
"Meta Hash": "Meta Hash",
|
||||
"Computed Hash": "Hash Calculado",
|
||||
"Identifiers": "Identificadores"
|
||||
"Identifiers": "Identificadores",
|
||||
"Read (Stream)": "Ler (Streaming)",
|
||||
"Failed to start stream": "Falha ao iniciar o streaming"
|
||||
}
|
||||
|
||||
@@ -1231,5 +1231,7 @@
|
||||
"Metadata Hash": "Hash metadate",
|
||||
"Meta Hash": "Meta hash",
|
||||
"Computed Hash": "Hash calculat",
|
||||
"Identifiers": "Identificatori"
|
||||
"Identifiers": "Identificatori",
|
||||
"Read (Stream)": "Citește (Flux)",
|
||||
"Failed to start stream": "Eroare la pornirea fluxului"
|
||||
}
|
||||
|
||||
@@ -1248,5 +1248,7 @@
|
||||
"Metadata Hash": "Хеш метаданных",
|
||||
"Meta Hash": "Мета-хеш",
|
||||
"Computed Hash": "Вычисленный хеш",
|
||||
"Identifiers": "Идентификаторы"
|
||||
"Identifiers": "Идентификаторы",
|
||||
"Read (Stream)": "Читать (Поток)",
|
||||
"Failed to start stream": "Не удалось запустить поток"
|
||||
}
|
||||
|
||||
@@ -1214,5 +1214,7 @@
|
||||
"Metadata Hash": "පාරදත්ත හෑෂ්",
|
||||
"Meta Hash": "මෙටා හෑෂ්",
|
||||
"Computed Hash": "ගණනය කළ හෑෂ්",
|
||||
"Identifiers": "හඳුනාගැනීම්"
|
||||
"Identifiers": "හඳුනාගැනීම්",
|
||||
"Read (Stream)": "කියවන්න (ස්ට්රීම්)",
|
||||
"Failed to start stream": "ස්ට්රීම් ආරම්භ කිරීම අසාර්ථකයි"
|
||||
}
|
||||
|
||||
@@ -1248,5 +1248,7 @@
|
||||
"Metadata Hash": "Hash metapodatkov",
|
||||
"Meta Hash": "Meta hash",
|
||||
"Computed Hash": "Izračunani hash",
|
||||
"Identifiers": "Identifikatorji"
|
||||
"Identifiers": "Identifikatorji",
|
||||
"Read (Stream)": "Beri (pretok)",
|
||||
"Failed to start stream": "Pretoka ni bilo mogoče zagnati"
|
||||
}
|
||||
|
||||
@@ -1214,5 +1214,7 @@
|
||||
"Metadata Hash": "Metadata-hash",
|
||||
"Meta Hash": "Meta-hash",
|
||||
"Computed Hash": "Beräknat hash",
|
||||
"Identifiers": "Identifierare"
|
||||
"Identifiers": "Identifierare",
|
||||
"Read (Stream)": "Läs (ström)",
|
||||
"Failed to start stream": "Det gick inte att starta strömmen"
|
||||
}
|
||||
|
||||
@@ -1214,5 +1214,7 @@
|
||||
"Metadata Hash": "மெட்டாடேட்டா ஹாஷ்",
|
||||
"Meta Hash": "மெட்டா ஹாஷ்",
|
||||
"Computed Hash": "கணக்கிடப்பட்ட ஹாஷ்",
|
||||
"Identifiers": "அடையாளங்காட்டிகள்"
|
||||
"Identifiers": "அடையாளங்காட்டிகள்",
|
||||
"Read (Stream)": "படிக்க (ஸ்ட்ரீம்)",
|
||||
"Failed to start stream": "ஸ்ட்ரீமைத் தொடங்க முடியவில்லை"
|
||||
}
|
||||
|
||||
@@ -1197,5 +1197,7 @@
|
||||
"Metadata Hash": "แฮชเมตาดาต้า",
|
||||
"Meta Hash": "แฮชเมตา",
|
||||
"Computed Hash": "แฮชที่คำนวณ",
|
||||
"Identifiers": "ตัวระบุ"
|
||||
"Identifiers": "ตัวระบุ",
|
||||
"Read (Stream)": "อ่าน (สตรีม)",
|
||||
"Failed to start stream": "ไม่สามารถเริ่มสตรีมได้"
|
||||
}
|
||||
|
||||
@@ -1214,5 +1214,7 @@
|
||||
"Metadata Hash": "Üst Veri Hash'i",
|
||||
"Meta Hash": "Meta Hash",
|
||||
"Computed Hash": "Hesaplanan Hash",
|
||||
"Identifiers": "Tanımlayıcılar"
|
||||
"Identifiers": "Tanımlayıcılar",
|
||||
"Read (Stream)": "Oku (Akış)",
|
||||
"Failed to start stream": "Akış başlatılamadı"
|
||||
}
|
||||
|
||||
@@ -1248,5 +1248,7 @@
|
||||
"Metadata Hash": "Хеш метаданих",
|
||||
"Meta Hash": "Мета-хеш",
|
||||
"Computed Hash": "Обчислений хеш",
|
||||
"Identifiers": "Ідентифікатори"
|
||||
"Identifiers": "Ідентифікатори",
|
||||
"Read (Stream)": "Читати (Потік)",
|
||||
"Failed to start stream": "Не вдалося запустити потік"
|
||||
}
|
||||
|
||||
@@ -1197,5 +1197,7 @@
|
||||
"Metadata Hash": "Hash Siêu Dữ Liệu",
|
||||
"Meta Hash": "Meta Hash",
|
||||
"Computed Hash": "Hash Đã Tính",
|
||||
"Identifiers": "Định Danh"
|
||||
"Identifiers": "Định Danh",
|
||||
"Read (Stream)": "Đọc (Stream)",
|
||||
"Failed to start stream": "Không thể bắt đầu stream"
|
||||
}
|
||||
|
||||
@@ -1197,5 +1197,7 @@
|
||||
"Metadata Hash": "元数据哈希",
|
||||
"Meta Hash": "元哈希",
|
||||
"Computed Hash": "计算的哈希",
|
||||
"Identifiers": "标识符"
|
||||
"Identifiers": "标识符",
|
||||
"Read (Stream)": "阅读(流式)",
|
||||
"Failed to start stream": "启动流式传输失败"
|
||||
}
|
||||
|
||||
@@ -1197,5 +1197,7 @@
|
||||
"Metadata Hash": "中繼資料雜湊",
|
||||
"Meta Hash": "中繼雜湊",
|
||||
"Computed Hash": "計算所得雜湊",
|
||||
"Identifiers": "識別碼"
|
||||
"Identifiers": "識別碼",
|
||||
"Read (Stream)": "閱讀(串流)",
|
||||
"Failed to start stream": "無法啟動串流"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { render, cleanup, fireEvent } from '@testing-library/react';
|
||||
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import RSVPOverlay from '@/app/reader/components/rsvp/RSVPOverlay';
|
||||
import type { RSVPController, RsvpState } from '@/services/rsvp';
|
||||
|
||||
beforeAll(() => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
}
|
||||
});
|
||||
|
||||
vi.mock('@/hooks/useTranslation', () => ({
|
||||
useTranslation: () => (s: string) => s,
|
||||
}));
|
||||
|
||||
vi.mock('@/store/themeStore', () => ({
|
||||
useThemeStore: () => ({
|
||||
themeCode: { primary: '#000', bg: '#fff', fg: '#111' },
|
||||
isDarkMode: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const buildState = (overrides: Partial<RsvpState> = {}): RsvpState => ({
|
||||
active: true,
|
||||
playing: false,
|
||||
words: [],
|
||||
currentIndex: 0,
|
||||
currentPartIndex: 0,
|
||||
wpm: 300,
|
||||
punctuationPauseMs: 100,
|
||||
splitHyphens: false,
|
||||
progress: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildController = (state: RsvpState) => {
|
||||
const listeners = new Map<string, EventListener[]>();
|
||||
const controller = {
|
||||
get currentState() {
|
||||
return state;
|
||||
},
|
||||
get currentDisplayWord() {
|
||||
return state.words[state.currentIndex] ?? null;
|
||||
},
|
||||
get currentCountdown() {
|
||||
return null;
|
||||
},
|
||||
seekToIndex: vi.fn(),
|
||||
seekToPosition: vi.fn(),
|
||||
skipBackward: vi.fn(),
|
||||
skipForward: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
togglePlayPause: vi.fn(),
|
||||
decreaseSpeed: vi.fn(),
|
||||
increaseSpeed: vi.fn(),
|
||||
setWpm: vi.fn(),
|
||||
setPunctuationPause: vi.fn(),
|
||||
setSplitHyphens: vi.fn(),
|
||||
getWpmOptions: vi.fn(() => [100, 200, 300]),
|
||||
getPunctuationPauseOptions: vi.fn(() => [25, 50, 100]),
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (!listeners.has(type)) listeners.set(type, []);
|
||||
listeners.get(type)!.push(listener);
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
return controller;
|
||||
};
|
||||
|
||||
const renderOverlay = (state: RsvpState) => {
|
||||
const controller = buildController(state);
|
||||
const result = render(
|
||||
<RSVPOverlay
|
||||
gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }}
|
||||
controller={controller as unknown as RSVPController}
|
||||
chapters={[]}
|
||||
currentChapterHref={null}
|
||||
onClose={vi.fn()}
|
||||
onChapterSelect={vi.fn()}
|
||||
onRequestNextPage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
return { ...result, controller };
|
||||
};
|
||||
|
||||
describe('RSVPOverlay — context panel performance', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
test('renders a bounded number of word buttons for a large word list', () => {
|
||||
const words = Array.from({ length: 5000 }, (_, i) => ({
|
||||
text: `word${i}`,
|
||||
orpIndex: 0,
|
||||
pauseMultiplier: 1,
|
||||
}));
|
||||
const state = buildState({ words, currentIndex: 2500 });
|
||||
|
||||
const { container } = renderOverlay(state);
|
||||
|
||||
const buttons = container.querySelectorAll('[data-rsvp-word-button]');
|
||||
// Without windowing this would be 5000; with windowing it should be far fewer.
|
||||
expect(buttons.length).toBeLessThan(2000);
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('clicking a windowed word seeks to that word', () => {
|
||||
const words = Array.from({ length: 200 }, (_, i) => ({
|
||||
text: `w${i}`,
|
||||
orpIndex: 0,
|
||||
pauseMultiplier: 1,
|
||||
}));
|
||||
const state = buildState({ words, currentIndex: 100 });
|
||||
|
||||
const { container, controller } = renderOverlay(state);
|
||||
|
||||
const target = container.querySelector('[data-rsvp-word-index="90"]');
|
||||
expect(target).not.toBeNull();
|
||||
fireEvent.click(target!);
|
||||
expect(controller.seekToIndex).toHaveBeenCalledWith(90);
|
||||
});
|
||||
|
||||
test('the current word is rendered with the highlight ref', () => {
|
||||
const words = Array.from({ length: 100 }, (_, i) => ({
|
||||
text: `w${i}`,
|
||||
orpIndex: 0,
|
||||
pauseMultiplier: 1,
|
||||
}));
|
||||
const state = buildState({ words, currentIndex: 42 });
|
||||
|
||||
const { container } = renderOverlay(state);
|
||||
|
||||
const current = container.querySelector('[data-rsvp-word-index="42"]');
|
||||
expect(current).not.toBeNull();
|
||||
// current word should not be a button (not clickable)
|
||||
expect(current!.getAttribute('role')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import clsx from 'clsx';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { RsvpState, RSVPController } from '@/services/rsvp';
|
||||
import { containsCJK } from '@/services/rsvp/utils';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
import {
|
||||
@@ -26,6 +27,36 @@ interface FlatChapter {
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface ContextWordProps {
|
||||
text: string;
|
||||
wordIndex: number;
|
||||
isCurrent: boolean;
|
||||
currentRef?: React.Ref<HTMLSpanElement>;
|
||||
orpColor?: string;
|
||||
}
|
||||
|
||||
const ContextWord = React.memo(function ContextWord({
|
||||
text,
|
||||
wordIndex,
|
||||
isCurrent,
|
||||
currentRef,
|
||||
orpColor,
|
||||
}: ContextWordProps) {
|
||||
return (
|
||||
<span
|
||||
ref={currentRef}
|
||||
data-rsvp-word-button=''
|
||||
data-rsvp-word-index={wordIndex}
|
||||
role={isCurrent ? undefined : 'button'}
|
||||
tabIndex={isCurrent ? undefined : 0}
|
||||
className={isCurrent ? undefined : 'cursor-pointer opacity-70 hover:opacity-100'}
|
||||
style={isCurrent && orpColor ? { color: orpColor } : undefined}
|
||||
>
|
||||
{text}{' '}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
// Display settings
|
||||
const FONT_SIZE_OPTIONS = [1.25, 1.5, 1.875, 2.25, 3, 3.75, 4.25, 5, 6, 8];
|
||||
const DEFAULT_FONT_SIZE_INDEX = 4;
|
||||
@@ -34,6 +65,13 @@ const STORAGE_KEY_FONT_SIZE = 'readest_rsvp_fontsize';
|
||||
const STORAGE_KEY_ORP_COLOR = 'readest_rsvp_orp_color';
|
||||
const STORAGE_KEY_CONTEXT = 'readest_rsvp_context';
|
||||
|
||||
// Context panel windowing — long sections (e.g. AZW3 chapters with 40k+ words)
|
||||
// would otherwise render tens of thousands of <span> elements and freeze the UI
|
||||
// for many seconds on each section load.
|
||||
const CONTEXT_CHUNK_SIZE = 50;
|
||||
const CONTEXT_WINDOW_BEFORE = 200;
|
||||
const CONTEXT_WINDOW_AFTER = 1000;
|
||||
|
||||
interface RSVPOverlayProps {
|
||||
gridInsets: Insets;
|
||||
controller: RSVPController;
|
||||
@@ -202,6 +240,9 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
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) : '';
|
||||
const isCJKWord = currentWord ? containsCJK(currentWord.text) : false;
|
||||
const wordLetterSpacing = undefined;
|
||||
const wordSideOffset = isCJKWord ? '0.5em' : '0.3em';
|
||||
|
||||
// Time remaining calculation
|
||||
const getTimeRemaining = useCallback((): string | null => {
|
||||
@@ -342,12 +383,53 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleWordClick = (wordIndex: number) => {
|
||||
const wasPlaying = state.playing;
|
||||
if (wasPlaying) controller.pause();
|
||||
controller.seekToIndex(wordIndex);
|
||||
if (wasPlaying) setTimeout(() => controller.resume(), 50);
|
||||
};
|
||||
const handleWordClick = useCallback(
|
||||
(wordIndex: number) => {
|
||||
const wasPlaying = state.playing;
|
||||
if (wasPlaying) controller.pause();
|
||||
controller.seekToIndex(wordIndex);
|
||||
if (wasPlaying) setTimeout(() => controller.resume(), 50);
|
||||
},
|
||||
[state.playing, controller],
|
||||
);
|
||||
|
||||
const handleContextClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = (event.target as HTMLElement).closest<HTMLElement>('[data-rsvp-word-index]');
|
||||
if (!target) return;
|
||||
if (target.getAttribute('role') !== 'button') return;
|
||||
const idx = parseInt(target.getAttribute('data-rsvp-word-index') || '', 10);
|
||||
if (Number.isNaN(idx)) return;
|
||||
handleWordClick(idx);
|
||||
},
|
||||
[handleWordClick],
|
||||
);
|
||||
|
||||
const handleContextKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
const target = (event.target as HTMLElement).closest<HTMLElement>('[data-rsvp-word-index]');
|
||||
if (!target) return;
|
||||
if (target.getAttribute('role') !== 'button') return;
|
||||
const idx = parseInt(target.getAttribute('data-rsvp-word-index') || '', 10);
|
||||
if (Number.isNaN(idx)) return;
|
||||
event.preventDefault();
|
||||
handleWordClick(idx);
|
||||
},
|
||||
[handleWordClick],
|
||||
);
|
||||
|
||||
const contextWindow = useMemo(() => {
|
||||
const len = state.words.length;
|
||||
if (len === 0) return { start: 0, end: 0 };
|
||||
const chunkStart = Math.floor(state.currentIndex / CONTEXT_CHUNK_SIZE) * CONTEXT_CHUNK_SIZE;
|
||||
const start = Math.max(0, chunkStart - CONTEXT_WINDOW_BEFORE);
|
||||
const end = Math.min(len, chunkStart + CONTEXT_CHUNK_SIZE + CONTEXT_WINDOW_AFTER);
|
||||
return { start, end };
|
||||
}, [state.currentIndex, state.words.length]);
|
||||
|
||||
const hasMoreBefore = contextWindow.start > 0;
|
||||
const hasMoreAfter = contextWindow.end < state.words.length;
|
||||
|
||||
const getProgressBarPercentage = (clientX: number, target: HTMLElement): number => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
@@ -553,33 +635,28 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
onTouchEnd={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='text-left text-base leading-relaxed md:text-lg'>
|
||||
{state.words.map((w, i) => {
|
||||
const wordIndex = i;
|
||||
<div
|
||||
data-testid='rsvp-context-panel'
|
||||
className='text-left text-base leading-relaxed md:text-lg'
|
||||
onClick={handleContextClick}
|
||||
onKeyDown={handleContextKeyDown}
|
||||
>
|
||||
{hasMoreBefore && <span className='opacity-30'>… </span>}
|
||||
{state.words.slice(contextWindow.start, contextWindow.end).map((w, i) => {
|
||||
const wordIndex = contextWindow.start + i;
|
||||
const isCurrent = wordIndex === state.currentIndex;
|
||||
return (
|
||||
<span
|
||||
<ContextWord
|
||||
key={wordIndex}
|
||||
ref={isCurrent ? contextWordRef : undefined}
|
||||
role={isCurrent ? undefined : 'button'}
|
||||
tabIndex={isCurrent ? undefined : 0}
|
||||
className={
|
||||
isCurrent ? undefined : 'cursor-pointer opacity-70 hover:opacity-100'
|
||||
}
|
||||
style={isCurrent ? { color: effectiveOrpColor } : undefined}
|
||||
onClick={isCurrent ? undefined : () => handleWordClick(wordIndex)}
|
||||
onKeyDown={
|
||||
isCurrent
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') handleWordClick(wordIndex);
|
||||
}
|
||||
}
|
||||
>
|
||||
{w.text}{' '}
|
||||
</span>
|
||||
text={w.text}
|
||||
wordIndex={wordIndex}
|
||||
isCurrent={isCurrent}
|
||||
currentRef={isCurrent ? contextWordRef : undefined}
|
||||
orpColor={isCurrent ? effectiveOrpColor : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{hasMoreAfter && <span className='opacity-30'>…</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -609,11 +686,14 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
{/* Word display */}
|
||||
<div
|
||||
className='rsvp-word relative flex min-h-16 w-full items-center justify-center whitespace-nowrap px-2 py-2 font-mono font-medium leading-none tracking-wide sm:min-h-20 sm:px-4 sm:py-4'
|
||||
style={{ fontSize: `${currentFontSize}rem` }}
|
||||
style={{ fontSize: `${currentFontSize}rem`, letterSpacing: wordLetterSpacing }}
|
||||
>
|
||||
{currentWord ? (
|
||||
<>
|
||||
<span className='rsvp-word-before absolute right-[calc(50%+0.3em)] text-right opacity-60'>
|
||||
<span
|
||||
className='rsvp-word-before absolute text-right opacity-60'
|
||||
style={{ right: `calc(50% + ${wordSideOffset})` }}
|
||||
>
|
||||
{wordBefore}
|
||||
</span>
|
||||
<span
|
||||
@@ -622,7 +702,10 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
>
|
||||
{orpChar}
|
||||
</span>
|
||||
<span className='rsvp-word-after absolute left-[calc(50%+0.3em)] text-left opacity-60'>
|
||||
<span
|
||||
className='rsvp-word-after absolute text-left opacity-60'
|
||||
style={{ left: `calc(50% + ${wordSideOffset})` }}
|
||||
>
|
||||
{wordAfter}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -37,6 +37,7 @@ export class RSVPController extends EventTarget {
|
||||
private countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pendingStartWordIndex: number | null = null;
|
||||
private countdown: number | null = null;
|
||||
private cachedWords: { docIndex: number; doc: Document; words: RsvpWord[] } | null = null;
|
||||
|
||||
constructor(view: FoliateView, bookKey: string) {
|
||||
super();
|
||||
@@ -194,7 +195,7 @@ export class RSVPController extends EventTarget {
|
||||
const currentWord = this.state.words[this.state.currentIndex];
|
||||
if (!currentWord) return;
|
||||
|
||||
const cfi = currentWord.cfi || this.currentCfi;
|
||||
const cfi = this.getCfiForWord(currentWord) || this.currentCfi;
|
||||
if (!cfi) return;
|
||||
|
||||
const position: RsvpPosition = {
|
||||
@@ -235,25 +236,87 @@ export class RSVPController extends EventTarget {
|
||||
}
|
||||
|
||||
private findWordIndexByCfi(words: RsvpWord[], targetCfi: string): number {
|
||||
// First try exact CFI match
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
if (words[i]?.cfi === targetCfi) return i;
|
||||
}
|
||||
|
||||
// Find the first word at or after the target position using CFI compare
|
||||
const targetSpineIndex = this.getSpineIndex(targetCfi);
|
||||
if (targetSpineIndex < 0) return -1;
|
||||
|
||||
// Resolve target CFI to a Range in the section's document so we can
|
||||
// find the matching word by range comparison (O(1) per check) rather
|
||||
// than by per-word CFI generation, which dominates extract cost on
|
||||
// long sections.
|
||||
const targetRange = this.resolveCfiToRange(targetCfi, targetSpineIndex);
|
||||
if (targetRange) {
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
if (!word?.range) continue;
|
||||
if (word.docIndex !== targetSpineIndex) continue;
|
||||
try {
|
||||
if (word.range.compareBoundaryPoints(Range.START_TO_START, targetRange) >= 0) {
|
||||
return i;
|
||||
}
|
||||
} catch {
|
||||
// Cross-document range compare throws; skip.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: per-word CFI compare (slow path, used when the CFI cannot
|
||||
// be resolved to a range — e.g. fixed-layout pages).
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
if (!word?.cfi) continue;
|
||||
if (this.getSpineIndex(word.cfi) !== targetSpineIndex) continue;
|
||||
if (compareCFI(word.cfi, targetCfi) >= 0) return i;
|
||||
if (!word?.range || word.docIndex === undefined) continue;
|
||||
let wordCfi: string | undefined;
|
||||
try {
|
||||
wordCfi = this.view.getCFI(word.docIndex, word.range);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!wordCfi) continue;
|
||||
if (this.getSpineIndex(wordCfi) !== targetSpineIndex) continue;
|
||||
if (compareCFI(wordCfi, targetCfi) >= 0) return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private resolveCfiToRange(cfi: string, spineIndex: number): Range | null {
|
||||
try {
|
||||
const renderer = this.view.renderer;
|
||||
const contents = renderer?.getContents?.();
|
||||
if (!contents) return null;
|
||||
const target = (contents as Array<{ doc: Document; index: number }>).find(
|
||||
(c) => c.index === spineIndex,
|
||||
);
|
||||
if (!target) return null;
|
||||
const resolved = (
|
||||
this.view as unknown as {
|
||||
resolveCFI?: (cfi: string) => { index: number; anchor?: (doc: Document) => unknown };
|
||||
}
|
||||
).resolveCFI?.(cfi);
|
||||
if (!resolved || resolved.index !== spineIndex || typeof resolved.anchor !== 'function') {
|
||||
return null;
|
||||
}
|
||||
const anchor = resolved.anchor(target.doc);
|
||||
if (anchor instanceof Range) return anchor;
|
||||
if (anchor && anchor instanceof target.doc.defaultView!.Node) {
|
||||
const range = target.doc.createRange();
|
||||
range.selectNode(anchor as Node);
|
||||
return range;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getCfiForWord(word: RsvpWord | undefined): string | undefined {
|
||||
if (!word?.range || word.docIndex === undefined) return undefined;
|
||||
try {
|
||||
return this.view.getCFI(word.docIndex, word.range);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
start(retryCount = 0): void {
|
||||
const words = this.extractWordsWithRanges();
|
||||
if (words.length === 0) {
|
||||
@@ -363,7 +426,7 @@ export class RSVPController extends EventTarget {
|
||||
text: currentWord?.text || '',
|
||||
range: currentWord?.range,
|
||||
docIndex: currentWord?.docIndex,
|
||||
cfi: currentWord?.cfi,
|
||||
cfi: this.getCfiForWord(currentWord),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -651,7 +714,17 @@ export class RSVPController extends EventTarget {
|
||||
const { doc, index: docIndex } = primary as { doc: Document; index: number };
|
||||
if (!doc?.body) return [];
|
||||
|
||||
return this.extractWordsFromElement(doc.body, doc, docIndex);
|
||||
if (
|
||||
this.cachedWords &&
|
||||
this.cachedWords.docIndex === docIndex &&
|
||||
this.cachedWords.doc === doc
|
||||
) {
|
||||
return this.cachedWords.words;
|
||||
}
|
||||
|
||||
const words = this.extractWordsFromElement(doc.body, doc, docIndex);
|
||||
this.cachedWords = { docIndex, doc, words };
|
||||
return words;
|
||||
}
|
||||
|
||||
private extractWordsFromElement(
|
||||
@@ -677,22 +750,15 @@ export class RSVPController extends EventTarget {
|
||||
range.setStart(node, wordStart);
|
||||
range.setEnd(node, wordStart + word.length);
|
||||
|
||||
// Generate CFI for this word for position tracking
|
||||
let cfi: string | undefined;
|
||||
try {
|
||||
cfi = this.view.getCFI(docIndex, range);
|
||||
} catch {
|
||||
// CFI generation failed, will fall back to word index
|
||||
cfi = undefined;
|
||||
}
|
||||
|
||||
// CFI is computed lazily — see savePositionToStorage(),
|
||||
// stop(), and findWordIndexByCfi(). At 45k+ words/section,
|
||||
// eager generation dominates extract time.
|
||||
words.push({
|
||||
text: word,
|
||||
orpIndex: this.calculateORP(word),
|
||||
pauseMultiplier: this.getPauseMultiplier(word),
|
||||
range,
|
||||
docIndex,
|
||||
cfi,
|
||||
});
|
||||
} catch {
|
||||
words.push({
|
||||
@@ -790,5 +856,6 @@ export class RSVPController extends EventTarget {
|
||||
this.stop();
|
||||
this.clearPositionFromStorage();
|
||||
this.currentCfi = null;
|
||||
this.cachedWords = null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user