diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 45d0662..d767c18 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -108,6 +108,35 @@ const stepToNextNode = (node: Node | null, root: Node): Node | null => { return null; }; +const stepToPreviousNode = (node: Node | null, root: Node): Node | null => { + if (!node) return null; + + if (node.previousSibling) { + let prev: Node | null = node.previousSibling; + while (prev?.lastChild) { + prev = prev.lastChild; + } + return prev; + } + + let current: Node | null = node.parentNode; + while (current) { + if (current === root) { + return null; + } + if (current.previousSibling) { + let prev: Node | null = current.previousSibling; + while (prev?.lastChild) { + prev = prev.lastChild; + } + return prev; + } + current = current.parentNode; + } + + return null; +}; + const getNextTextNode = (node: Node | null, root: Node): Text | null => { let next = stepToNextNode(node, root); while (next) { @@ -119,6 +148,31 @@ const getNextTextNode = (node: Node | null, root: Node): Text | null => { return null; }; +const getPreviousTextNode = (node: Node | null, root: Node): Text | null => { + let prev = stepToPreviousNode(node, root); + while (prev) { + if (prev.nodeType === Node.TEXT_NODE) { + return prev as Text; + } + prev = stepToPreviousNode(prev, root); + } + return null; +}; + +const getLastTextNodeInSubtree = (node: Node | null): Text | null => { + if (!node) return null; + if (node.nodeType === Node.TEXT_NODE) return node as Text; + + let child: Node | null = node.lastChild; + while (child) { + const nested = getLastTextNodeInSubtree(child); + if (nested) return nested; + child = child.previousSibling; + } + + return null; +}; + const collectContinuationFromRange = (range: Range | null | undefined, limit = EPUB_CONTINUATION_CHARS): string => { if (typeof window === 'undefined' || !range) { return ''; @@ -161,6 +215,66 @@ const collectContinuationFromRange = (range: Range | null | undefined, limit = E return parts.join(' ').replace(/\s+/g, ' ').trim(); }; +const collectLeadingContextFromRange = (range: Range | null | undefined, limit = EPUB_CONTINUATION_CHARS): string => { + if (typeof window === 'undefined' || !range) { + return ''; + } + + const root = range.commonAncestorContainer; + if (!root) { + return ''; + } + + const parts: string[] = []; + let remaining = limit; + + const prependFromTextNode = (textNode: Text, endOffset: number) => { + if (remaining <= 0) return; + const textContent = textNode.textContent || ''; + const safeEnd = Math.max(0, Math.min(endOffset, textContent.length)); + if (safeEnd <= 0) return; + const safeStart = Math.max(0, safeEnd - remaining); + const slice = textContent.slice(safeStart, safeEnd); + if (slice) { + parts.unshift(slice); + remaining -= slice.length; + } + }; + + let cursor: Node | null = null; + + if (range.startContainer.nodeType === Node.TEXT_NODE) { + const startText = range.startContainer as Text; + prependFromTextNode(startText, range.startOffset); + cursor = startText; + } else { + const startNode = range.startContainer; + let anchor: Node | null = null; + if (range.startOffset > 0) { + anchor = startNode.childNodes[range.startOffset - 1] ?? null; + } + if (!anchor) { + anchor = stepToPreviousNode(startNode, root); + } + + const anchorText = getLastTextNodeInSubtree(anchor); + if (anchorText) { + prependFromTextNode(anchorText, (anchorText.textContent || '').length); + cursor = anchorText; + } else { + cursor = anchor; + } + } + + let prevNode = getPreviousTextNode(cursor, root); + while (prevNode && remaining > 0) { + prependFromTextNode(prevNode, (prevNode.textContent || '').length); + prevNode = getPreviousTextNode(prevNode, root); + } + + return parts.join(' ').replace(/\s+/g, ' ').trim(); +}; + /** * Provider component for EPUB functionality * Manages the state and operations for EPUB document handling @@ -267,12 +381,14 @@ export function EPUBProvider({ children }: { children: ReactNode }) { return ''; } const textContent = range.toString().trim(); + const leadingPreview = collectLeadingContextFromRange(range); const continuationPreview = collectContinuationFromRange(range); if (smartSentenceSplitting) { setTTSText(textContent, { shouldPause, location: start.cfi, + previousText: leadingPreview, nextLocation: end.cfi, nextText: continuationPreview }); diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index c81dae3..d2b5899 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -237,10 +237,12 @@ export function PDFProvider({ children }: { children: ReactNode }) { }; const totalPages = currDocPages ?? currentPdf.numPages; + const prevPageNumber = currDocPageNumber > 1 ? currDocPageNumber - 1 : undefined; const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined; - const [text, nextText] = await Promise.all([ + const [text, prevText, nextText] = await Promise.all([ getPageText(currDocPageNumber), + prevPageNumber ? getPageText(prevPageNumber) : Promise.resolve(undefined), nextPageNumber ? getPageText(nextPageNumber, true) : Promise.resolve(undefined), ]); @@ -281,6 +283,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { setCurrDocText(text); setTTSText(text, { location: currDocPageNumber, + previousText: prevText, nextLocation: nextPageNumber, nextText: nextText, }); diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index a4b92c6..659aff5 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -100,6 +100,7 @@ interface SetTextOptions { location?: TTSLocation; nextLocation?: TTSLocation; nextText?: string; + previousText?: string; } type TTSSegmentPlaybackSource = { @@ -829,8 +830,22 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Apply or clear sentence continuation logic based on config let continuationCarried: string | undefined; + let strippedByPreviousContext = false; if (smartSentenceSplitting) { - if (isEPUB && epubContinuationRef.current) { + if (typeof normalizedOptions.previousText === 'string' && normalizedOptions.previousText.trim()) { + const previousMerge = mergeContinuation(normalizedOptions.previousText, workingText); + if (previousMerge?.carried) { + const { text: strippedText, removed } = stripContinuationPrefix(workingText, previousMerge.carried); + if (removed) { + workingText = strippedText; + strippedByPreviousContext = true; + } + } + } + + if (isEPUB && strippedByPreviousContext) { + epubContinuationRef.current = null; + } else if (isEPUB && epubContinuationRef.current) { const { text: strippedText, removed } = stripContinuationPrefix(workingText, epubContinuationRef.current); workingText = strippedText; if (removed) { @@ -840,12 +855,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (!isEPUB && normalizedOptions.location !== undefined) { const key = normalizeLocationKey(normalizedOptions.location); - const carried = continuationCarryRef.current.get(key); - if (carried) { - const { text: strippedText, removed } = stripContinuationPrefix(workingText, carried); - workingText = strippedText; - if (removed) { - continuationCarryRef.current.delete(key); + if (strippedByPreviousContext) { + continuationCarryRef.current.delete(key); + } else { + const carried = continuationCarryRef.current.get(key); + if (carried) { + const { text: strippedText, removed } = stripContinuationPrefix(workingText, carried); + workingText = strippedText; + if (removed) { + continuationCarryRef.current.delete(key); + } } } }