cf41e7d50d
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>
571 lines
20 KiB
TypeScript
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;
|