Files
readest/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx
T
Huang Xin cf41e7d50d feat(rsvp): apply reader font face/family settings to the RSVP word (#4519) (#4537)
RSVP displayed the focal word in a hardcoded monospace font, ignoring the
reader's configured font. Resolve the reader's body font-family (serif or
sans-serif chain, per the "Default Font" setting, including the chosen
typeface, CJK font, and any user-imported custom font) and apply it to the
RSVP word display.

Custom and additional fonts are already mounted in the top document where
the overlay renders, so the resolved family resolves the same typeface. The
monospace fallback is kept only when no font setting is available.

Extracts the font-family list building from getFontStyles into a shared
buildFontFamilyLists helper and exposes getBaseFontFamily for top-level UI.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:24:46 +02:00

571 lines
20 KiB
TypeScript

'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 { useSettingsStore } from '@/store/settingsStore';
import { useThemeStore } from '@/store/themeStore';
import {
RSVPController,
RsvpStartChoice,
RsvpStopPosition,
buildRsvpExitConfigUpdate,
} from '@/services/rsvp';
import { eventDispatcher } from '@/utils/event';
import { getBaseFontFamily } from '@/utils/style';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { BookNote, PageInfo } from '@/types/book';
import { TOCItem } from '@/libs/document';
import { Insets } from '@/types/misc';
import { initJieba } from '@/utils/jieba';
import RSVPOverlay from './RSVPOverlay';
import RSVPStartDialog from './RSVPStartDialog';
interface RSVPControlProps {
bookKey: string;
gridInsets: Insets;
}
// 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, gridInsets }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { getBookData, getConfig, setConfig, saveConfig } = 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);
// renderer.primaryIndex reverts after navigation (paginator #detectPrimaryView),
// so track RSVP's actual section and chapter href in stable refs instead.
const rsvpSectionRef = useRef<number>(-1);
const rsvpChapterHrefRef = useRef<string | 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) {
// Use stop() instead of shutdown() to preserve saved position across sessions
// shutdown() clears localStorage which loses the user's reading progress
controllerRef.current.stop();
controllerRef.current = null;
}
// Remove any existing RSVP highlight when component unmounts
removeRsvpHighlight();
rsvpSectionRef.current = -1;
rsvpChapterHrefRef.current = null;
};
// 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;
}
const primaryLanguage = bookData.book.primaryLanguage;
// Create controller if not exists
if (!controllerRef.current) {
controllerRef.current = new RSVPController(view, bookKey, primaryLanguage);
rsvpSectionRef.current = view.renderer.primaryIndex;
rsvpChapterHrefRef.current = progress?.sectionHref ?? null;
} else {
controllerRef.current.setPrimaryLanguage(primaryLanguage);
}
const controller = controllerRef.current;
// For Chinese books, preload jieba-wasm so that the synchronous word
// extractor can use it. Done before requestStart() so the loader has
// the dialog's interaction time to fetch ~3.8MB of WASM.
if (primaryLanguage?.toLowerCase().startsWith('zh')) {
initJieba().catch((e) => {
console.warn('Failed to initialize jieba-wasm; falling back to Intl.Segmenter:', e);
});
}
// Seed localStorage from cloud-synced BookConfig so a fresh cross-device
// rsvpPosition can override a stale local entry. seedPosition guards against
// a corrupt synced pair (rsvpPosition.cfi in a different chapter than location).
const config = getConfig(bookKey);
const configPos = config?.rsvpPosition;
if (configPos) {
controller.seedPosition(configPos, config?.location ?? progress?.location ?? null);
}
// 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 a saved position or selection, show dialog for user to choose
if (choice.hasSavedPosition || choice.hasSelection) {
setShowStartDialog(true);
} else {
// No saved position or selection - start from current page position
controller.startFromCurrentPosition();
setIsActive(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, getConfig, getProgress, getView, removeRsvpHighlight],
);
const handleStartDialogSelect = useCallback(
(option: 'beginning' | 'saved' | 'current' | 'selection') => {
setShowStartDialog(false);
const controller = controllerRef.current;
const view = getView(bookKey);
if (!controller) return;
// Handler for when we need to navigate to a different section for resume
const handleNavigateToResume = (e: Event) => {
const { cfi } = (e as CustomEvent<{ cfi: string }>).detail;
controller.removeEventListener('rsvp-navigate-to-resume', handleNavigateToResume);
if (view && cfi) {
// Navigate to the saved position's section
view.goTo(cfi);
// Wait for navigation, then start RSVP — start() handles word extraction
// and position recovery from storage directly, so loadNextPageContent()
// must not be called here (it would clear the saved position first)
setTimeout(() => {
const progress = getProgress(bookKey);
if (progress?.location) {
controller.setCurrentCfi(progress.location);
}
controller.start();
setIsActive(true);
}, 500);
}
};
switch (option) {
case 'beginning':
controller.startFromBeginning();
setIsActive(true);
break;
case 'saved':
// Listen for navigation event in case saved position is in different section
controller.addEventListener('rsvp-navigate-to-resume', handleNavigateToResume);
controller.startFromSavedPosition();
// If startFromSavedPosition started directly (same section), setIsActive
// If it emitted navigate event, the handler above will setIsActive after navigation
if (!controller.currentState.active) {
// Navigation event was emitted, don't set active yet
} else {
setIsActive(true);
}
// Clean up listener after a timeout if not used
setTimeout(() => {
controller.removeEventListener('rsvp-navigate-to-resume', handleNavigateToResume);
}, 1000);
break;
case 'current': {
// Refresh the CFI in case user scrolled since dialog opened
const currentProgress = getProgress(bookKey);
if (currentProgress?.location) {
controller.setCurrentCfi(currentProgress.location);
}
controller.startFromCurrentPosition();
setIsActive(true);
break;
}
case 'selection':
if (startChoice?.selectionText) {
controller.startFromSelection(startChoice.selectionText);
}
setIsActive(true);
break;
}
},
[bookKey, getProgress, getView, 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 && stopPosition.cfi) {
try {
// Navigate to the word's CFI position
view.goTo(stopPosition.cfi);
// Try to create a sentence highlight using the stored Range
if (typeof stopPosition.docIndex === 'number' && stopPosition.range) {
// Check if the original range is still valid
let rangeIsValid = false;
try {
const rangeText = stopPosition.range.toString();
rangeIsValid = rangeText === stopPosition.text;
} catch {
rangeIsValid = false;
}
if (rangeIsValid) {
// 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) {
// Expand the range to include the full sentence
const sentenceRange = expandRangeToSentence(stopPosition.range, doc);
const sentenceCfi = view.getCFI(stopPosition.docIndex, sentenceRange);
const sentenceText = sentenceRange.toString();
if (sentenceCfi) {
// Remove any previous RSVP highlight
removeRsvpHighlight();
// Create a persistent highlight for the sentence
const highlight: BookNote = {
id: `rsvp-temp-${Date.now()}`,
type: 'annotation',
cfi: sentenceCfi,
text: sentenceText,
style: 'underline',
color: themeCode.primary,
note: '',
createdAt: Date.now(),
updatedAt: Date.now(),
};
tempHighlightRef.current = highlight;
view.addAnnotation(highlight);
}
}
}
}
} 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();
}
// Persist RSVP position to BookConfig so it syncs to the cloud. Pin
// `location` to the RSVP word's CFI so the next normal-mode load resumes
// here instead of at a section boundary that a mid-RSVP relocate left
// behind in the auto-saved config.
const rsvpPosition = controller?.getStoredPosition();
if (rsvpPosition) {
const config = getConfig(bookKey);
if (config) {
const update = buildRsvpExitConfigUpdate(rsvpPosition);
setConfig(bookKey, update);
saveConfig(envConfig, bookKey, { ...config, ...update }, settings);
}
}
setIsActive(false);
setShowStartDialog(false);
}, [
bookKey,
envConfig,
getConfig,
getView,
removeRsvpHighlight,
saveConfig,
setConfig,
settings,
themeCode.primary,
]);
const handleChapterSelect = useCallback(
(href: string) => {
const view = getView(bookKey);
if (!view) return;
const onRelocate = (e: Event) => {
view.removeEventListener('relocate', onRelocate);
const detail = (e as CustomEvent).detail as { section?: PageInfo; tocItem?: TOCItem };
rsvpSectionRef.current = detail.section?.current ?? view.renderer.primaryIndex;
rsvpChapterHrefRef.current = detail.tocItem?.href ?? null;
const controller = controllerRef.current;
if (controller) {
const progress = getProgress(bookKey);
if (progress?.location) {
controller.setCurrentCfi(progress.location);
}
controller.loadNextPageContent();
}
};
view.addEventListener('relocate', onRelocate);
view.goTo(href);
},
[bookKey, getProgress, getView],
);
const handleRequestNextPage = useCallback(async () => {
const view = getView(bookKey);
if (!view) return;
removeRsvpHighlight();
if (view.renderer.atEnd) {
controllerRef.current?.pause();
return;
}
const indexBefore =
rsvpSectionRef.current >= 0 ? rsvpSectionRef.current : view.renderer.primaryIndex;
let cleanup: ReturnType<typeof setTimeout> | null = null;
const onRelocate = (e: Event) => {
const detail = (e as CustomEvent).detail as { section?: PageInfo; tocItem?: TOCItem };
const newIndex = detail.section?.current ?? view.renderer.primaryIndex;
if (newIndex === indexBefore) return; // revert relocate — keep waiting
view.removeEventListener('relocate', onRelocate);
if (cleanup) clearTimeout(cleanup);
const controller = controllerRef.current;
if (!controller) return;
rsvpSectionRef.current = newIndex;
rsvpChapterHrefRef.current = detail.tocItem?.href ?? null;
const progress = getProgress(bookKey);
if (progress?.location) {
controller.setCurrentCfi(progress.location);
}
controller.loadNextPageContent();
};
view.addEventListener('relocate', onRelocate);
cleanup = setTimeout(() => view.removeEventListener('relocate', onRelocate), 5000);
// Navigate directly to rsvpSectionRef.current + 1 rather than calling nextSection(),
// which uses renderer.primaryIndex internally. primaryIndex reverts to the previous
// section after navigation (#detectPrimaryView), so nextSection() would re-navigate
// to the already-current section and the onRelocate filter would discard the event.
await view.renderer.goTo({ index: rsvpSectionRef.current + 1 });
}, [bookKey, getProgress, getView, removeRsvpHighlight]);
// Get current chapter info
const progress = getProgress(bookKey);
const bookData = getBookData(bookKey);
const chapters = bookData?.bookDoc?.toc || [];
const currentChapterHref = rsvpChapterHrefRef.current ?? progress?.sectionHref ?? null;
// Mirror the reader's font face/family settings on the RSVP word. The overlay
// renders in the top document where the configured (and custom) fonts are
// already mounted, so the resolved family resolves the same typeface.
const viewSettings = getViewSettings(bookKey);
const fontFamily = viewSettings ? getBaseFontFamily(viewSettings) : undefined;
// 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
gridInsets={gridInsets}
controller={controllerRef.current}
chapters={chapters}
currentChapterHref={currentChapterHref}
fontFamily={fontFamily}
onClose={handleClose}
onChapterSelect={handleChapterSelect}
onRequestNextPage={handleRequestNextPage}
/>,
portalContainer,
)}
</>
);
};
export default RSVPControl;