diff --git a/packages/app/src/components/chat/MessageList.tsx b/packages/app/src/components/chat/MessageList.tsx index 5c0328f4..330d1212 100644 --- a/packages/app/src/components/chat/MessageList.tsx +++ b/packages/app/src/components/chat/MessageList.tsx @@ -103,8 +103,8 @@ export function MessageList({ isStreaming && !!lastMsg && lastMsg.role === "assistant" && lastMsg.parts.length > 0; return ( -
-
+
+
{messages.map((msg, idx) => ( 0; return ( -
-
+
+
{hasQuotes && (
{quoteParts.map((q) => ( @@ -203,7 +203,7 @@ function MessageBubble({ message, onCitationClick, isStreaming, currentStep }: M
)} {textParts.length > 0 && ( -
+
{textParts.map((part) => { if (part.type === "text") { return {part.text}; @@ -242,7 +242,7 @@ function MessageBubble({ message, onCitationClick, isStreaming, currentStep }: M !isLastPartActiveToolCall; return ( -
+
{message.parts.map((part) => ( string; getVisibleTTSSegments: (alignCfi?: string | null) => Promise; + getSelectionTTSSegments: (selection: BookSelection) => Promise; getTTSSegmentContext: ( cfi: string, before?: number, @@ -715,6 +717,159 @@ export const FoliateViewer = forwardRef [ensureDesktopTTS], ); + const getSelectionTTSSegments = useCallback( + async (selection: BookSelection): Promise => { + const view = viewRef.current; + const range = selection.range; + const fallbackText = normalizeTTSSegmentText(selection.text); + const fallbackCfi = selection.cfi || ""; + const fallback = () => + fallbackText && fallbackCfi ? [{ text: fallbackText, cfi: fallbackCfi }] : []; + if (!view || !range || range.collapsed) return fallback(); + + await ensureDesktopTTS(); + + const doc = + range.startContainer.nodeType === Node.DOCUMENT_NODE + ? (range.startContainer as Document) + : range.startContainer.ownerDocument; + if (!doc) return fallback(); + const contents = view.renderer?.getContents?.() ?? []; + const content = contents.find( + (item: { doc?: Document; index?: number }) => item.doc === doc, + ); + const sectionIndex = selection.chapterIndex ?? content?.index ?? 0; + const lang = + doc.documentElement.lang || + doc.documentElement.getAttribute("xml:lang") || + doc.body.lang || + navigator.language || + "en"; + const root = + range.commonAncestorContainer.nodeType === Node.TEXT_NODE + ? range.commonAncestorContainer.parentElement + : range.commonAncestorContainer; + if (!root) return fallback(); + + const positionedNodes: Array<{ + node: Text; + start: number; + end: number; + nodeStart: number; + }> = []; + let selectionText = ""; + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode: (node) => { + if (!node.nodeValue?.trim()) return NodeFilter.FILTER_SKIP; + const parent = (node as Text).parentElement; + if (!parent) return NodeFilter.FILTER_ACCEPT; + const tag = parent.tagName.toLowerCase(); + if (tag === "script" || tag === "style") return NodeFilter.FILTER_REJECT; + if (parent.closest(".readany-translation")) return NodeFilter.FILTER_REJECT; + + const nodeRange = doc.createRange(); + try { + nodeRange.selectNodeContents(node); + if (range.compareBoundaryPoints(Range.END_TO_START, nodeRange) <= 0) { + return NodeFilter.FILTER_REJECT; + } + if (range.compareBoundaryPoints(Range.START_TO_END, nodeRange) >= 0) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } catch { + return NodeFilter.FILTER_REJECT; + } finally { + nodeRange.detach?.(); + } + }, + }); + + for ( + let textNode = walker.nextNode() as Text | null; + textNode; + textNode = walker.nextNode() as Text | null + ) { + const sourceText = textNode.nodeValue || ""; + const startOffset = + textNode === range.startContainer + ? Math.max(0, Math.min(sourceText.length, range.startOffset)) + : 0; + const endOffset = + textNode === range.endContainer + ? Math.max(0, Math.min(sourceText.length, range.endOffset)) + : sourceText.length; + if (endOffset <= startOffset) continue; + + const text = sourceText.slice(startOffset, endOffset); + const start = selectionText.length; + selectionText += text; + positionedNodes.push({ + node: textNode, + start, + end: selectionText.length, + nodeStart: startOffset, + }); + } + + if (!selectionText.trim() || positionedNodes.length === 0) return fallback(); + + const resolvePosition = (absoluteOffset: number, isEnd: boolean) => { + for (const item of positionedNodes) { + if (absoluteOffset < item.end || (isEnd && absoluteOffset <= item.end)) { + return { + node: item.node, + offset: Math.max( + 0, + Math.min( + item.node.nodeValue?.length ?? 0, + item.nodeStart + absoluteOffset - item.start, + ), + ), + }; + } + } + const last = positionedNodes[positionedNodes.length - 1]; + return { node: last.node, offset: last.node.nodeValue?.length ?? 0 }; + }; + + const seen = new Set(); + const segments: TTSSegmentDetail[] = []; + for (const segment of splitTextIntoTTSSegmentRanges(selectionText, lang)) { + const startPos = resolvePosition(segment.start, false); + const endPos = resolvePosition(segment.end, true); + if (!startPos || !endPos) continue; + + const segmentRange = doc.createRange(); + try { + segmentRange.setStart(startPos.node, startPos.offset); + segmentRange.setEnd(endPos.node, endPos.offset); + const cfi = view.getCFI(sectionIndex, segmentRange); + const identity = getTTSSegmentIdentity(cfi, segment.text); + if (cfi && !seen.has(identity)) { + seen.add(identity); + segments.push({ text: segment.text, cfi }); + } + } catch { + // skip segment if CFI resolution fails + } finally { + segmentRange.detach?.(); + } + } + + if (segments.length > 0) { + console.log("[FoliateViewer][TTS] selectionTTSSegments", { + count: segments.length, + firstText: segments[0]?.text || null, + }); + return segments; + } + + return fallback(); + }, + [ensureDesktopTTS], + ); + const getTTSSegmentContext = useCallback( async ( cfi: string, @@ -979,6 +1134,7 @@ export const FoliateViewer = forwardRef } }, getVisibleTTSSegments, + getSelectionTTSSegments, getTTSSegmentContext, setTTSHighlight: async (cfi: string | null, color?: string) => { ttsHighlightStateRef.current = { @@ -1198,7 +1354,7 @@ export const FoliateViewer = forwardRef : undefined, })); }, - [clearTTSHighlight, ensureDesktopTTS, getVisibleTTSSegments], + [clearTTSHighlight, ensureDesktopTTS, getSelectionTTSSegments, getVisibleTTSSegments], ); // --- Section load handler --- @@ -1760,12 +1916,23 @@ export const FoliateViewer = forwardRef if (!view) return null; const contents = view.renderer?.getContents?.(); - if (!contents?.[0]?.doc) return null; - - const doc = contents[0].doc as Document; - const sel = doc.getSelection(); - const range = getSelectionRange(sel); - if (!range) return null; + if (!contents?.length) return null; + + let doc: Document | null = null; + let sel: Selection | null = null; + let range: Range | null = null; + for (const content of contents) { + const contentDoc = content?.doc as Document | undefined; + const contentSelection = contentDoc?.getSelection(); + const contentRange = getSelectionRange(contentSelection); + if (contentDoc && contentSelection && contentRange) { + doc = contentDoc; + sel = contentSelection; + range = contentRange; + break; + } + } + if (!doc || !sel || !range) return null; const text = (sel?.toString() || "").trim(); if (!text) return null; @@ -1773,7 +1940,10 @@ export const FoliateViewer = forwardRef let cfi: string | undefined; let chapterIndex: number | undefined; try { - const index = contents[0].index; + const rangeDoc = range.startContainer.ownerDocument; + const content = + contents.find((item: { doc?: Document }) => item.doc === rangeDoc) ?? contents[0]; + const index = content.index; if (index !== undefined) { cfi = view.getCFI(index, range); chapterIndex = index; diff --git a/packages/app/src/components/reader/ReaderView.tsx b/packages/app/src/components/reader/ReaderView.tsx index b384d17e..02473fd0 100644 --- a/packages/app/src/components/reader/ReaderView.tsx +++ b/packages/app/src/components/reader/ReaderView.tsx @@ -189,6 +189,7 @@ function useAutoHideControls( delay = 2000, keepVisible = false, isDoublePage = false, + clickNavigationEnabled = true, ) { const [isVisible, setIsVisible] = useState(true); const timeoutRef = useRef | null>(null); @@ -246,6 +247,18 @@ function useAutoHideControls( const leftNavEnd = viewStartX + viewWidth * (isDoublePage ? 0.25 : 0.375); const rightNavStart = viewStartX + viewWidth * (isDoublePage ? 0.75 : 0.625); + if (!clickNavigationEnabled) { + setIsVisible((prev) => { + if (prev) { + clearTimer(); + return false; + } + showAndScheduleHide(); + return true; + }); + return; + } + if (clickScreenX > leftNavEnd && clickScreenX < rightNavStart) { // Middle zone: toggle toolbar setIsVisible((prev) => { @@ -272,7 +285,16 @@ function useAutoHideControls( window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); - }, [bookKey, clearTimer, containerRef, onNext, onPrev, showAndScheduleHide, isDoublePage]); + }, [ + bookKey, + clearTimer, + clickNavigationEnabled, + containerRef, + onNext, + onPrev, + showAndScheduleHide, + isDoublePage, + ]); // Mouse enter/leave handlers for toolbar area const handleMouseEnter = useCallback(() => { @@ -321,7 +343,6 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { const appTab = useAppStore((s) => s.tabs.find((t) => t.id === tabId)); const closeAppTab = useAppStore((s) => s.removeTab); const viewSettings = useSettingsStore((s) => s.readSettings); - const updateReadSettings = useSettingsStore((s) => s.updateReadSettings); const setProgress = useReaderStore((s) => s.setProgress); const setChapter = useReaderStore((s) => s.setChapter); const setSelectedText = useReaderStore((s) => s.setSelectedText); @@ -804,6 +825,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { 2000, keepControlsVisible, (viewSettings.paginatedLayout ?? "double") === "double", + viewSettings.viewMode !== "scroll", ); const toolbarVisible = controlsVisible || isToolbarPinned; const readingHeaderTitle = (readerTab?.chapterTitle || book?.meta.title || "").trim(); @@ -860,12 +882,6 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { }, 5000), ).current; - useEffect(() => { - if (viewSettings.viewMode === "scroll") { - updateReadSettings({ viewMode: "paginated" }); - } - }, [viewSettings.viewMode, updateReadSettings]); - // --- Load book on mount --- useEffect(() => { if (!book?.filePath || isInitializedRef.current) return; @@ -1243,7 +1259,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { (sel: BookSelection | null) => { setSelection(sel); if (sel) { - setSelectedText(tabId, sel.text, null); + setSelectedText(tabId, sel.text, sel.cfi ?? null); if (sel.rects.length > 0) { // SelectionPopover uses absolute positioning relative to containerRef const containerRect = containerRef.current?.getBoundingClientRect(); @@ -1561,20 +1577,27 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { void foliateRef.current?.setTTSHighlight(null); return; } - if (ttsSourceKind !== "page") { + if (ttsSourceKind !== "page" && ttsSourceKind !== "selection") { void foliateRef.current?.setTTSHighlight(null); return; } void foliateRef.current?.setTTSHighlight( - currentTTSSegment?.cfi || null, + currentTTSSegment?.cfi || (ttsSourceKind === "selection" ? ttsCurrentLocationCfi : null), "rgba(96, 165, 250, 0.35)", ); - }, [bookId, currentTTSSegment?.cfi, ttsCurrentBookId, ttsPlayState, ttsSourceKind]); + }, [ + bookId, + currentTTSSegment?.cfi, + ttsCurrentBookId, + ttsCurrentLocationCfi, + ttsPlayState, + ttsSourceKind, + ]); useEffect(() => { if (ttsCurrentBookId !== bookId) return; const targetCfi = - ttsSourceKind === "page" && + (ttsSourceKind === "page" || ttsSourceKind === "selection") && (ttsPlayState === "playing" || ttsPlayState === "paused" || ttsPlayState === "loading") ? currentTTSSegment?.cfi || ttsCurrentLocationCfi || null : readerTab?.currentCfi; @@ -1862,27 +1885,47 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { ]); const startSelectionTTS = useCallback( - (text: string) => { + async (text: string, selectionForCfi?: BookSelection | null) => { const normalized = text.trim(); if (!normalized) return; - const segments = splitNarrationText(normalized) - .filter(Boolean) - .map((segmentText) => ({ text: segmentText, cfi: null })); + const selectionSegments = + selectionForCfi && foliateRef.current + ? await foliateRef.current.getSelectionTTSSegments(selectionForCfi) + : []; + const fallbackCfi = + selectionForCfi?.cfi || + readerTab?.selectionCfi || + ttsCurrentLocationCfi || + readerTab?.currentCfi || + null; + const segments = selectionSegments.length + ? selectionSegments.map((segment) => ({ + text: segment.text.trim(), + cfi: segment.cfi || fallbackCfi, + })) + : splitNarrationText(normalized) + .filter(Boolean) + .map((segmentText) => ({ text: segmentText, cfi: fallbackCfi })); + const playableSegments = segments.filter((segment) => segment.text.length > 0); setTtsSourceKind("selection"); setTtsContinuousEnabled(false); setTtsLastText(normalized); - setTtsSegments(segments); + setTtsSegments(playableSegments); setTtsPrevPageSegments([]); setTtsFutureSegments([]); ttsLastTextRef.current = normalized; - ttsSegmentsRef.current = segments; + ttsSegmentsRef.current = playableSegments; ttsFutureSegmentsRef.current = []; ttsContinuousRef.current = false; ttsSetOnEnd(null); ttsSetCurrentBook(book?.meta.title ?? "", readerTab?.chapterTitle ?? "", bookId); - ttsSetCurrentLocation(readerTab?.selectionCfi || readerTab?.currentCfi || ""); + ttsSetCurrentLocation(playableSegments[0]?.cfi || fallbackCfi || ""); setShowTTS(true); - ttsPlay(segments.length > 0 ? segments.map((segment) => segment.text) : normalized); + ttsPlay( + playableSegments.length > 0 + ? playableSegments.map((segment) => segment.text) + : normalized, + ); }, [ ttsPlay, @@ -1893,6 +1936,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { readerTab?.chapterTitle, readerTab?.selectionCfi, readerTab?.currentCfi, + ttsCurrentLocationCfi, bookId, ], ); @@ -2049,17 +2093,31 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { // TTS: speak selected text (no auto page-turn) const handleSpeakSelection = useCallback(() => { - if (selection?.text) { - startSelectionTTS(selection.text); + const currentSelection = selection; + if (currentSelection?.text) { + void startSelectionTTS(currentSelection.text, currentSelection); } setSelection(null); }, [selection, startSelectionTTS]); const handleTTSReplay = useCallback(async () => { if (ttsSourceKind === "selection") { - const text = (ttsCurrentText || ttsLastText).trim(); + const replaySegments = ttsSegmentsRef.current.filter((segment) => segment.text.trim()); + if (replaySegments.length > 0) { + const text = replaySegments + .map((segment) => segment.text) + .join(" ") + .trim(); + setTtsSegments(replaySegments); + setTtsLastText(text); + ttsLastTextRef.current = text; + ttsSetCurrentLocation(replaySegments[0]?.cfi || ttsCurrentLocationCfi || ""); + ttsPlay(replaySegments.map((segment) => segment.text)); + return; + } + const text = ttsLastText.trim(); if (text) { - startSelectionTTS(text); + await startSelectionTTS(text); } return; } @@ -2069,8 +2127,10 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { startPageTTS, startSelectionTTS, ttsContinuousEnabled, - ttsCurrentText, + ttsCurrentLocationCfi, ttsLastText, + ttsPlay, + ttsSetCurrentLocation, ttsSourceKind, ]); @@ -2096,9 +2156,22 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { } if (ttsSourceKind === "selection") { - const text = (ttsCurrentText || ttsLastText).trim(); + const replaySegments = ttsSegmentsRef.current.filter((segment) => segment.text.trim()); + if (replaySegments.length > 0) { + const text = replaySegments + .map((segment) => segment.text) + .join(" ") + .trim(); + setTtsSegments(replaySegments); + setTtsLastText(text); + ttsLastTextRef.current = text; + ttsSetCurrentLocation(replaySegments[0]?.cfi || ttsCurrentLocationCfi || ""); + ttsPlay(replaySegments.map((segment) => segment.text)); + return; + } + const text = ttsLastText.trim(); if (text) { - startSelectionTTS(text); + await startSelectionTTS(text); } return; } @@ -2108,11 +2181,13 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) { startPageTTS, startSelectionTTS, ttsContinuousEnabled, - ttsCurrentText, + ttsCurrentLocationCfi, ttsLastText, ttsPause, + ttsPlay, ttsPlayState, ttsResume, + ttsSetCurrentLocation, ttsSourceKind, ]); diff --git a/packages/app/src/components/settings/ReadSettings.tsx b/packages/app/src/components/settings/ReadSettings.tsx index 9902d620..0aa59268 100644 --- a/packages/app/src/components/settings/ReadSettings.tsx +++ b/packages/app/src/components/settings/ReadSettings.tsx @@ -38,6 +38,22 @@ export function ReadSettingsPanel() {

{t("settings.readingNotice")}

+
+ {t("settings.viewMode")} + +
+
{t("settings.paginatedLayout")}