From 4ce1ebe4770064bd313d71297a94ba275ed1e93c Mon Sep 17 00:00:00 2001 From: Mohammed Efaz Date: Fri, 30 Jan 2026 05:24:23 +0100 Subject: [PATCH] feat: paragraph by paragraph reading mode (#3096) * feat: add index * feat: add bottom nav bar * feat: add paragraph iterator * feat: add para mode shortcut * feat: add paragraph control into foliateviewer * feat: add paragraph mode toggle to view menu * feat: add paragraph bar for navigation controls * feat: add paragraph control wrapper component * feat: add pargraph overlay for the focused display * feat: integrate paragraph mode into keyboard navigation * feat: add paragraph mode state management hook * feat: add paragraph mode type definition * feat: add default paragraph mode config * fix: replace previous storage system with sytem one and fix sync issues * fix: format --- .../app/reader/components/FoliateViewer.tsx | 2 + .../src/app/reader/components/ViewMenu.tsx | 16 + .../components/paragraph/ParagraphBar.tsx | 270 +++++++++ .../components/paragraph/ParagraphControl.tsx | 57 ++ .../components/paragraph/ParagraphOverlay.tsx | 417 ++++++++++++++ .../app/reader/components/paragraph/index.ts | 3 + .../src/app/reader/hooks/useBookShortcuts.ts | 36 +- .../src/app/reader/hooks/useParagraphMode.ts | 512 ++++++++++++++++++ apps/readest-app/src/helpers/shortcuts.ts | 1 + apps/readest-app/src/services/constants.ts | 5 + apps/readest-app/src/types/book.ts | 8 +- apps/readest-app/src/utils/paragraph.ts | 243 +++++++++ 12 files changed, 1568 insertions(+), 2 deletions(-) create mode 100644 apps/readest-app/src/app/reader/components/paragraph/ParagraphBar.tsx create mode 100644 apps/readest-app/src/app/reader/components/paragraph/ParagraphControl.tsx create mode 100644 apps/readest-app/src/app/reader/components/paragraph/ParagraphOverlay.tsx create mode 100644 apps/readest-app/src/app/reader/components/paragraph/index.ts create mode 100644 apps/readest-app/src/app/reader/hooks/useParagraphMode.ts create mode 100644 apps/readest-app/src/utils/paragraph.ts diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 631db7bc..61f3f37f 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -61,6 +61,7 @@ import { isCJKLang } from '@/utils/lang'; import { getLocale } from '@/utils/misc'; import Spinner from '@/components/Spinner'; import KOSyncConflictResolver from './KOSyncResolver'; +import { ParagraphControl } from './paragraph'; declare global { interface Window { @@ -513,6 +514,7 @@ const FoliateViewer: React.FC<{ {...mouseHandlers} {...touchHandlers} /> + {!docLoaded.current && loading && } {syncState === 'conflict' && conflictDetails && ( = ({ bookKey, setIsDropdownOpen }) => { const { themeMode, isDarkMode, setThemeMode } = useThemeStore(); const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled); + const [isParagraphMode, setParagraphMode] = useState( + viewSettings?.paragraphMode?.enabled ?? false, + ); const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!); const [zoomMode, setZoomMode] = useState(viewSettings!.zoomMode!); const [spreadMode, setSpreadMode] = useState(viewSettings!.spreadMode!); @@ -59,6 +62,11 @@ const ViewMenu: React.FC = ({ bookKey, setIsDropdownOpen }) => { const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL)); const resetZoom = () => setZoomLevel(100); const toggleScrolledMode = () => setScrolledMode(!isScrolledMode); + const toggleParagraphMode = () => { + setParagraphMode(!isParagraphMode); + eventDispatcher.dispatch('toggle-paragraph-mode', { bookKey }); + setIsDropdownOpen?.(false); + }; const openFontLayoutMenu = () => { setIsDropdownOpen?.(false); @@ -262,6 +270,14 @@ const ViewMenu: React.FC = ({ bookKey, setIsDropdownOpen }) => { disabled={bookData.isFixedLayout} /> + + void; + onNext: () => void; + onClose: () => void; + gridInsets: Insets; +} + +const AnimatedNumber: React.FC<{ value: number | string }> = ({ value }) => ( + + {value} + +); + +const ParagraphBar: React.FC = ({ + bookKey, + currentIndex, + totalParagraphs, + isLoading, + onPrev, + onNext, + onClose, + gridInsets, +}) => { + const _ = useTranslation(); + const { appService } = useEnv(); + const { hoveredBookKey } = useReaderStore(); + const iconSize = useResponsiveSize(18); + + const [isBarVisible, setIsBarVisible] = useState(true); + const hideTimerRef = useRef | null>(null); + const isInTriggerZoneRef = useRef(false); + const isMountedRef = useRef(true); + + const clearHideTimer = useCallback(() => { + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }, []); + + const startHideTimer = useCallback( + (delay: number = HIDE_DELAY) => { + clearHideTimer(); + hideTimerRef.current = setTimeout(() => { + if (isMountedRef.current && !isInTriggerZoneRef.current) { + setIsBarVisible(false); + } + }, delay); + }, + [clearHideTimer], + ); + + const showBar = useCallback( + (autoHide: boolean = true) => { + setIsBarVisible(true); + if (autoHide && !isInTriggerZoneRef.current) { + startHideTimer(); + } else { + clearHideTimer(); + } + }, + [startHideTimer, clearHideTimer], + ); + + const checkTriggerZone = useCallback( + (clientY: number) => { + const viewportHeight = window.innerHeight; + const isInZone = clientY >= viewportHeight - TRIGGER_ZONE_HEIGHT; + + if (isInZone !== isInTriggerZoneRef.current) { + isInTriggerZoneRef.current = isInZone; + + if (isInZone) { + showBar(false); + } else { + startHideTimer(); + } + } + }, + [showBar, startHideTimer], + ); + + useEffect(() => { + isMountedRef.current = true; + startHideTimer(INITIAL_SHOW_DURATION); + + return () => { + isMountedRef.current = false; + clearHideTimer(); + }; + }, [startHideTimer, clearHideTimer]); + + useEffect(() => { + let rafId: number | null = null; + let lastMoveTime = 0; + const throttleMs = 50; + + const handleMouseMove = (e: MouseEvent) => { + const now = Date.now(); + if (now - lastMoveTime < throttleMs) return; + lastMoveTime = now; + + if (rafId) cancelAnimationFrame(rafId); + + rafId = requestAnimationFrame(() => { + checkTriggerZone(e.clientY); + + if (!isInTriggerZoneRef.current) { + showBar(true); + } + }); + }; + + window.addEventListener('mousemove', handleMouseMove, { passive: true }); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + if (rafId) cancelAnimationFrame(rafId); + }; + }, [checkTriggerZone, showBar]); + + const isHiddenByHover = hoveredBookKey === bookKey; + const isVisible = isBarVisible && !isHiddenByHover; + const progress = + totalParagraphs > 0 ? Math.round(((currentIndex + 1) / totalParagraphs) * 100) : 0; + + return ( + <> + +
{ + isInTriggerZoneRef.current = true; + showBar(false); + }} + onMouseLeave={() => { + isInTriggerZoneRef.current = false; + startHideTimer(); + }} + > +
+ + +
+ +
+ {isLoading ? ( +
+ + {_('Loading')} +
+ ) : ( + <> +
+ + / + {totalParagraphs} +
+ + + +
+ + % +
+ + )} +
+ +
+ + + + +
+
+ + ); +}; + +export default ParagraphBar; diff --git a/apps/readest-app/src/app/reader/components/paragraph/ParagraphControl.tsx b/apps/readest-app/src/app/reader/components/paragraph/ParagraphControl.tsx new file mode 100644 index 00000000..05b89ec1 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/paragraph/ParagraphControl.tsx @@ -0,0 +1,57 @@ +'use client'; + +import React from 'react'; +import { FoliateView } from '@/types/view'; +import { Insets } from '@/types/misc'; +import { useReaderStore } from '@/store/readerStore'; +import { useParagraphMode } from '../../hooks/useParagraphMode'; +import ParagraphBar from './ParagraphBar'; +import ParagraphOverlay from './ParagraphOverlay'; + +const DIM_OPACITY = 0.3; + +interface ParagraphControlProps { + bookKey: string; + viewRef: React.RefObject; + gridInsets: Insets; +} + +const ParagraphControl: React.FC = ({ bookKey, viewRef, gridInsets }) => { + const { getViewSettings } = useReaderStore(); + const viewSettings = getViewSettings(bookKey); + + const { + paragraphState, + paragraphConfig, + toggleParagraphMode, + goToNextParagraph, + goToPrevParagraph, + } = useParagraphMode({ bookKey, viewRef }); + + if (!paragraphConfig?.enabled) { + return null; + } + + return ( + <> + + + + ); +}; + +export default ParagraphControl; diff --git a/apps/readest-app/src/app/reader/components/paragraph/ParagraphOverlay.tsx b/apps/readest-app/src/app/reader/components/paragraph/ParagraphOverlay.tsx new file mode 100644 index 00000000..1bc5a527 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/paragraph/ParagraphOverlay.tsx @@ -0,0 +1,417 @@ +'use client'; + +import clsx from 'clsx'; +import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import { ViewSettings } from '@/types/book'; +import { eventDispatcher } from '@/utils/event'; + +interface ParagraphOverlayProps { + bookKey: string; + dimOpacity: number; + viewSettings?: ViewSettings; + onClose?: () => void; +} + +interface ParagraphContent { + id: number; + html: string; + state: 'entering' | 'active' | 'exiting'; +} + +const AnimatedParagraph: React.FC<{ + html: string; + state: 'entering' | 'active' | 'exiting'; + style: React.CSSProperties; +}> = ({ html, state, style }) => { + const contentRef = useRef(null); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + const timer = requestAnimationFrame(() => setIsReady(true)); + return () => cancelAnimationFrame(timer); + }, [html]); + + const showContent = state === 'active' && isReady; + + return ( +
+ ); +}; + +const SectionTransitionIndicator: React.FC<{ + isVisible: boolean; + direction: 'next' | 'prev'; +}> = ({ isVisible, direction }) => { + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + if (!isVisible) return undefined; + const timer = requestAnimationFrame(() => { + setTimeout(() => setIsReady(true), 30); + }); + return () => { + cancelAnimationFrame(timer); + setIsReady(false); + }; + }, [isVisible]); + + if (!isVisible) return null; + + return ( +
+
+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ + {direction === 'next' ? 'Next chapter' : 'Previous chapter'} + +
+
+ ); +}; + +const ParagraphOverlay: React.FC = ({ + bookKey, + dimOpacity, + viewSettings, + onClose, +}) => { + const [paragraphs, setParagraphs] = useState([]); + const [isVisible, setIsVisible] = useState(false); + const [isOverlayMounted, setIsOverlayMounted] = useState(false); + const [isChangingSection, setIsChangingSection] = useState(false); + const [sectionDirection, setSectionDirection] = useState<'next' | 'prev'>('next'); + const paragraphIdCounter = useRef(0); + const containerRef = useRef(null); + const contentRef = useRef(null); + const lastScrollTime = useRef(0); + const onCloseRef = useRef(onClose); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + const contentStyle = useMemo(() => { + if (!viewSettings) return {}; + const defaultFontFamily = + viewSettings.defaultFont?.toLowerCase() === 'serif' + ? `"${viewSettings.serifFont}", serif` + : `"${viewSettings.sansSerifFont}", sans-serif`; + return { + fontFamily: defaultFontFamily, + fontSize: `${viewSettings.defaultFontSize || 16}px`, + lineHeight: viewSettings.lineHeight || 1.6, + letterSpacing: viewSettings.letterSpacing ? `${viewSettings.letterSpacing}px` : undefined, + wordSpacing: viewSettings.wordSpacing ? `${viewSettings.wordSpacing}px` : undefined, + fontWeight: viewSettings.fontWeight || 400, + } as React.CSSProperties; + }, [viewSettings]); + + const extractContent = useCallback((range: Range): string => { + try { + const fragment = range.cloneContents(); + const tempDiv = document.createElement('div'); + tempDiv.appendChild(fragment); + return tempDiv.innerHTML; + } catch { + return ''; + } + }, []); + + const addParagraph = useCallback( + (range: Range) => { + const html = extractContent(range); + if (!html) return; + + const newId = ++paragraphIdCounter.current; + + setParagraphs((prev) => { + const updated = prev + .filter((p) => p.state !== 'exiting') + .map((p) => ({ ...p, state: p.state === 'active' ? ('exiting' as const) : p.state })); + return [...updated, { id: newId, html, state: 'entering' as const }]; + }); + + requestAnimationFrame(() => { + setTimeout(() => { + setParagraphs((prev) => + prev.map((p) => (p.id === newId ? { ...p, state: 'active' as const } : p)), + ); + }, 30); + }); + + setTimeout(() => { + setParagraphs((prev) => prev.filter((p) => p.state !== 'exiting')); + }, 450); + }, + [extractContent], + ); + + useEffect(() => { + let sectionChangeTimeoutId: ReturnType | null = null; + + const handleFocus = (event: CustomEvent) => { + if (event.detail?.bookKey !== bookKey) return; + const range = event.detail?.range; + if (range) { + if (sectionChangeTimeoutId) { + clearTimeout(sectionChangeTimeoutId); + sectionChangeTimeoutId = null; + } + setIsChangingSection(false); + setIsVisible(true); + requestAnimationFrame(() => { + setIsOverlayMounted(true); + requestAnimationFrame(() => addParagraph(range)); + }); + } + }; + + const handleDisabled = (event: CustomEvent) => { + if (event.detail?.bookKey !== bookKey) return; + if (sectionChangeTimeoutId) { + clearTimeout(sectionChangeTimeoutId); + sectionChangeTimeoutId = null; + } + setIsOverlayMounted(false); + setIsChangingSection(false); + setTimeout(() => { + setIsVisible(false); + setParagraphs([]); + }, 300); + }; + + const handleSectionChanging = (event: CustomEvent) => { + if (event.detail?.bookKey !== bookKey) return; + setSectionDirection(event.detail?.direction || 'next'); + setParagraphs((prev) => prev.map((p) => ({ ...p, state: 'exiting' as const }))); + setIsChangingSection(true); + sectionChangeTimeoutId = setTimeout(() => { + setParagraphs((prev) => prev.filter((p) => p.state !== 'exiting')); + sectionChangeTimeoutId = null; + }, 400); + }; + + eventDispatcher.on('paragraph-focus', handleFocus); + eventDispatcher.on('paragraph-mode-disabled', handleDisabled); + eventDispatcher.on('paragraph-section-changing', handleSectionChanging); + + return () => { + if (sectionChangeTimeoutId) clearTimeout(sectionChangeTimeoutId); + eventDispatcher.off('paragraph-focus', handleFocus); + eventDispatcher.off('paragraph-mode-disabled', handleDisabled); + eventDispatcher.off('paragraph-section-changing', handleSectionChanging); + }; + }, [bookKey, addParagraph]); + + useEffect(() => { + if (!isVisible) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.stopPropagation(); + e.stopImmediatePropagation(); + + switch (e.key) { + case 'Escape': + case 'Backspace': + e.preventDefault(); + onCloseRef.current?.(); + break; + case 'ArrowDown': + case 'ArrowRight': + case ' ': + case 'j': + e.preventDefault(); + eventDispatcher.dispatch('paragraph-next', { bookKey }); + break; + case 'ArrowUp': + case 'ArrowLeft': + case 'k': + e.preventDefault(); + eventDispatcher.dispatch('paragraph-prev', { bookKey }); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [isVisible, bookKey]); + + useEffect(() => { + if (!isVisible) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const now = Date.now(); + if (now - lastScrollTime.current < 150) return; + lastScrollTime.current = now; + + if (e.deltaY > 0) { + eventDispatcher.dispatch('paragraph-next', { bookKey }); + } else if (e.deltaY < 0) { + eventDispatcher.dispatch('paragraph-prev', { bookKey }); + } + }; + + window.addEventListener('wheel', handleWheel, { passive: false, capture: true }); + return () => window.removeEventListener('wheel', handleWheel, true); + }, [isVisible, bookKey]); + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + const touchStartY = e.touches[0]?.clientY ?? 0; + const touchStartX = e.touches[0]?.clientX ?? 0; + + const handleTouchMove = (moveEvent: TouchEvent) => { + const touchEndY = moveEvent.touches[0]?.clientY ?? 0; + const touchEndX = moveEvent.touches[0]?.clientX ?? 0; + const diffY = touchStartY - touchEndY; + const diffX = touchStartX - touchEndX; + + if (Math.abs(diffY) > Math.abs(diffX) && Math.abs(diffY) > 50) { + if (diffY > 0) { + eventDispatcher.dispatch('paragraph-next', { bookKey }); + } else { + eventDispatcher.dispatch('paragraph-prev', { bookKey }); + } + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + } + }; + + const handleTouchEnd = () => { + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + }; + + document.addEventListener('touchmove', handleTouchMove); + document.addEventListener('touchend', handleTouchEnd); + }, + [bookKey], + ); + + const handleBackdropClick = useCallback((e: React.MouseEvent) => { + if (e.target === containerRef.current) { + onCloseRef.current?.(); + } + }, []); + + const lastTapTimeRef = useRef(0); + const handleContentClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + + const now = Date.now(); + if (now - lastTapTimeRef.current < 300) { + onCloseRef.current?.(); + lastTapTimeRef.current = 0; + return; + } + lastTapTimeRef.current = now; + + const containerWidth = contentRef.current?.offsetWidth ?? window.innerWidth; + const rect = contentRef.current?.getBoundingClientRect(); + const clickX = e.clientX - (rect?.left ?? 0); + + if (clickX < containerWidth / 3) { + eventDispatcher.dispatch('paragraph-prev', { bookKey }); + } else if (clickX > (containerWidth * 2) / 3) { + eventDispatcher.dispatch('paragraph-next', { bookKey }); + } + }, + [bookKey], + ); + + if (!isVisible) return null; + + const activeParagraph = paragraphs.find((p) => p.state === 'active' || p.state === 'entering'); + const exitingParagraph = paragraphs.find((p) => p.state === 'exiting'); + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
e.stopPropagation()} + > + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+ {exitingParagraph && !isChangingSection && ( +
+ )} + + {activeParagraph ? ( + + ) : isChangingSection ? ( + + ) : null} +
+
+ ); +}; + +export default ParagraphOverlay; diff --git a/apps/readest-app/src/app/reader/components/paragraph/index.ts b/apps/readest-app/src/app/reader/components/paragraph/index.ts new file mode 100644 index 00000000..c46a922d --- /dev/null +++ b/apps/readest-app/src/app/reader/components/paragraph/index.ts @@ -0,0 +1,3 @@ +export { default as ParagraphControl } from './ParagraphControl'; +export { default as ParagraphBar } from './ParagraphBar'; +export { default as ParagraphOverlay } from './ParagraphOverlay'; diff --git a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts index 70a86033..2bd3ad2b 100644 --- a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts +++ b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useReaderStore } from '@/store/readerStore'; import { useNotebookStore } from '@/store/notebookStore'; import { isTauriAppPlatform } from '@/services/environment'; @@ -27,6 +27,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = const { toggleNotebook } = useNotebookStore(); const { getNextBookKey } = useBooksManager(); const { open: openCommandPalette } = useCommandPalette(); + const lastParagraphToggleRef = useRef(0); const viewSettings = getViewSettings(sideBarBookKey ?? ''); const fontSize = viewSettings?.defaultFontSize ?? 16; const lineHeight = viewSettings?.lineHeight ?? 1.6; @@ -48,17 +49,32 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = const goLeft = () => { const viewSettings = getViewSettings(sideBarBookKey ?? ''); + // If paragraph mode is enabled, navigate to previous paragraph instead + if (viewSettings?.paragraphMode?.enabled && sideBarBookKey) { + eventDispatcher.dispatch('paragraph-prev', { bookKey: sideBarBookKey }); + return; + } viewPagination(getView(sideBarBookKey), viewSettings, 'left', 'pan', distance); }; const goRight = () => { const viewSettings = getViewSettings(sideBarBookKey ?? ''); + // If paragraph mode is enabled, navigate to next paragraph instead + if (viewSettings?.paragraphMode?.enabled && sideBarBookKey) { + eventDispatcher.dispatch('paragraph-next', { bookKey: sideBarBookKey }); + return; + } viewPagination(getView(sideBarBookKey), viewSettings, 'right', 'pan', distance); }; const goUp = (event?: KeyboardEvent | MessageEvent) => { const view = getView(sideBarBookKey); const viewSettings = getViewSettings(sideBarBookKey ?? ''); + // If paragraph mode is enabled, navigate to previous paragraph instead + if (viewSettings?.paragraphMode?.enabled && sideBarBookKey) { + eventDispatcher.dispatch('paragraph-prev', { bookKey: sideBarBookKey }); + return; + } if (view?.renderer.scrolled && event instanceof MessageEvent) return; viewPagination(view, viewSettings, 'up', 'pan', distance); }; @@ -66,6 +82,11 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = const goDown = (event?: KeyboardEvent | MessageEvent) => { const view = getView(sideBarBookKey); const viewSettings = getViewSettings(sideBarBookKey ?? ''); + // If paragraph mode is enabled, navigate to next paragraph instead + if (viewSettings?.paragraphMode?.enabled && sideBarBookKey) { + eventDispatcher.dispatch('paragraph-next', { bookKey: sideBarBookKey }); + return; + } if (view?.renderer.scrolled && event instanceof MessageEvent) return; viewPagination(view, viewSettings, 'down', 'pan', distance); }; @@ -213,6 +234,18 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = eventDispatcher.dispatch('toggle-bookmark', { bookKey: sideBarBookKey }); }; + const toggleParagraphMode = (event?: KeyboardEvent | MessageEvent) => { + if (!sideBarBookKey) return false; + if (event instanceof KeyboardEvent && event.repeat) return true; + + const now = Date.now(); + if (now - lastParagraphToggleRef.current < 300) return true; + lastParagraphToggleRef.current = now; + + eventDispatcher.dispatch('toggle-paragraph-mode', { bookKey: sideBarBookKey }); + return true; + }; + useEffect(() => { eventDispatcher.on('zoom-in', handleZoomIn); eventDispatcher.on('zoom-out', handleZoomOut); @@ -232,6 +265,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = onToggleNotebook: toggleNotebook, onToggleScrollMode: toggleScrollMode, onToggleBookmark: toggleBookmark, + onToggleParagraphMode: toggleParagraphMode, onOpenFontLayoutSettings: () => setSettingsDialogOpen(true), onShowSearchBar: showSearchBar, onToggleFullscreen: toggleFullscreen, diff --git a/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts b/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts new file mode 100644 index 00000000..177dfbc4 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts @@ -0,0 +1,512 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useReaderStore } from '@/store/readerStore'; +import { useEnv } from '@/context/EnvContext'; +import { FoliateView } from '@/types/view'; +import { eventDispatcher } from '@/utils/event'; +import { saveViewSettings } from '@/helpers/settings'; +import { ParagraphIterator } from '@/utils/paragraph'; +import { DEFAULT_PARAGRAPH_MODE_CONFIG } from '@/services/constants'; + +interface UseParagraphModeProps { + bookKey: string; + viewRef: React.RefObject; +} + +export interface ParagraphState { + isActive: boolean; + isLoading: boolean; + currentIndex: number; + totalParagraphs: number; + currentRange: Range | null; +} + +export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => { + const { envConfig } = useEnv(); + const { getViewSettings, setViewSettings, getProgress } = useReaderStore(); + + const iteratorRef = useRef(null); + const currentDocIndexRef = useRef(undefined); + const isProcessingRef = useRef(false); + const isFocusingRef = useRef(false); + const focusResetTimerRef = useRef | null>(null); + const bookKeyRef = useRef(bookKey); + const pendingNavigationRef = useRef<'next' | 'prev' | null>(null); + const initPromiseRef = useRef | null>(null); + const debounceTimerRef = useRef | null>(null); + const isFirstMountRef = useRef(true); + const toggleInFlightRef = useRef(false); + const lastParagraphRef = useRef<{ + progressLocation: string; + paragraphCfi: string; + docIndex: number; + } | null>(null); + bookKeyRef.current = bookKey; + + const [paragraphState, setParagraphState] = useState({ + isActive: false, + isLoading: false, + currentIndex: -1, + totalParagraphs: 0, + currentRange: null, + }); + + const paragraphConfig = getViewSettings(bookKey)?.paragraphMode ?? DEFAULT_PARAGRAPH_MODE_CONFIG; + + const updateStateFromIterator = useCallback( + (isLoading = false) => { + const iterator = iteratorRef.current; + if (!iterator) { + setParagraphState({ + isActive: paragraphConfig.enabled, + isLoading, + currentIndex: -1, + totalParagraphs: 0, + currentRange: null, + }); + return; + } + setParagraphState({ + isActive: paragraphConfig.enabled, + isLoading, + currentIndex: iterator.currentIndex, + totalParagraphs: iterator.length, + currentRange: iterator.current(), + }); + }, + [paragraphConfig.enabled], + ); + + const initIterator = useCallback(async (): Promise => { + if (isProcessingRef.current) { + return initPromiseRef.current ?? false; + } + isProcessingRef.current = true; + setParagraphState((prev) => ({ ...prev, isLoading: true })); + + const initPromise = (async (): Promise => { + try { + const view = viewRef.current; + if (!view) return false; + + const contents = view.renderer.getContents(); + if (contents.length === 0) return false; + + const { doc, index: docIndex } = contents[0] ?? {}; + if (!doc) return false; + + currentDocIndexRef.current = docIndex; + + await new Promise((r) => requestAnimationFrame(r)); + iteratorRef.current = await ParagraphIterator.createAsync(doc); + + const pendingNav = pendingNavigationRef.current; + pendingNavigationRef.current = null; + + if (pendingNav === 'next') { + iteratorRef.current.first(); + updateStateFromIterator(false); + return true; + } else if (pendingNav === 'prev') { + iteratorRef.current.last(); + updateStateFromIterator(false); + return true; + } + + const progress = getProgress(bookKeyRef.current); + const progressRange = progress?.range; + const progressLocation = progress?.location; + const isSameDoc = progressRange?.startContainer?.ownerDocument === doc; + const lastParagraph = lastParagraphRef.current; + + const resolveRangeFromLocation = (): Range | null => { + if (!progressLocation) return null; + try { + const resolved = view.resolveCFI(progressLocation); + if (!resolved || resolved.index !== docIndex) return null; + const anchor = resolved.anchor(doc); + if (anchor instanceof Range) return anchor; + if (anchor) { + const range = doc.createRange(); + range.selectNodeContents(anchor); + return range; + } + } catch { + return null; + } + return null; + }; + + const resolveRangeFromLastParagraph = (): Range | null => { + if (!lastParagraph || !progressLocation) return null; + if (lastParagraph.progressLocation !== progressLocation) return null; + if (lastParagraph.docIndex !== docIndex) return null; + try { + const resolved = view.resolveCFI(lastParagraph.paragraphCfi); + if (!resolved || resolved.index !== docIndex) return null; + const anchor = resolved.anchor(doc); + if (anchor instanceof Range) return anchor; + if (anchor) { + const range = doc.createRange(); + range.selectNodeContents(anchor); + return range; + } + } catch { + return null; + } + return null; + }; + + const targetRange = + resolveRangeFromLastParagraph() ?? + (isSameDoc ? progressRange : resolveRangeFromLocation()); + + if (targetRange && iteratorRef.current) { + try { + await iteratorRef.current.findByRangeAsync(targetRange); + } catch { + iteratorRef.current.first(); + } + } else { + iteratorRef.current.first(); + } + + updateStateFromIterator(false); + return true; + } finally { + isProcessingRef.current = false; + initPromiseRef.current = null; + } + })(); + + initPromiseRef.current = initPromise; + return initPromise; + }, [viewRef, getProgress, updateStateFromIterator]); + + const focusCurrentParagraph = useCallback(async () => { + const view = viewRef.current; + const iterator = iteratorRef.current; + if (!view || !iterator) return; + + const range = iterator.current(); + if (!range) return; + + await new Promise((r) => requestAnimationFrame(r)); + + if (focusResetTimerRef.current) { + clearTimeout(focusResetTimerRef.current); + } + isFocusingRef.current = true; + const docIndex = currentDocIndexRef.current; + const renderer = view.renderer as FoliateView['renderer'] & { + goTo?: (target: { index: number; anchor: Range }) => Promise; + }; + if (docIndex !== undefined && renderer.goTo) { + renderer.goTo({ index: docIndex, anchor: range }); + } else { + view.renderer.scrollToAnchor(range); + } + focusResetTimerRef.current = setTimeout(() => { + isFocusingRef.current = false; + }, 200); + + eventDispatcher.dispatch('paragraph-focus', { + bookKey: bookKeyRef.current, + range, + index: iterator.currentIndex, + total: iterator.length, + }); + }, [viewRef]); + + const waitForNewSection = useCallback( + async (oldIndex: number | undefined, maxAttempts: number = 15): Promise => { + const view = viewRef.current; + if (!view) return false; + + for (let i = 0; i < maxAttempts; i++) { + const contents = view.renderer.getContents(); + if (contents.length > 0 && contents[0]?.doc && contents[0]?.index !== oldIndex) { + return true; + } + await new Promise((r) => setTimeout(r, 50 * (i + 1))); + } + return false; + }, + [viewRef], + ); + + const goToNextParagraph = useCallback(async () => { + const iterator = iteratorRef.current; + const view = viewRef.current; + if (!iterator || !view) return false; + + const range = iterator.next(); + if (range) { + updateStateFromIterator(); + focusCurrentParagraph(); + return true; + } + + const oldSectionIndex = currentDocIndexRef.current; + pendingNavigationRef.current = 'next'; + iteratorRef.current = null; + + eventDispatcher.dispatch('paragraph-section-changing', { + bookKey: bookKeyRef.current, + direction: 'next', + }); + + try { + await view.renderer.nextSection?.(); + const newSectionReady = await waitForNewSection(oldSectionIndex); + + if (!newSectionReady) { + pendingNavigationRef.current = null; + pendingNavigationRef.current = 'prev'; + await initIterator(); + focusCurrentParagraph(); + return false; + } + + const success = await initIterator(); + if (success) { + focusCurrentParagraph(); + } + return success; + } catch (e) { + console.warn('[ParagraphMode] Section navigation failed:', e); + pendingNavigationRef.current = null; + await initIterator(); + focusCurrentParagraph(); + return false; + } + }, [viewRef, updateStateFromIterator, focusCurrentParagraph, initIterator, waitForNewSection]); + + const goToPrevParagraph = useCallback(async () => { + const iterator = iteratorRef.current; + const view = viewRef.current; + if (!iterator || !view) return false; + + const range = iterator.prev(); + if (range) { + updateStateFromIterator(); + focusCurrentParagraph(); + return true; + } + + const oldSectionIndex = currentDocIndexRef.current; + pendingNavigationRef.current = 'prev'; + iteratorRef.current = null; + + eventDispatcher.dispatch('paragraph-section-changing', { + bookKey: bookKeyRef.current, + direction: 'prev', + }); + + try { + await view.renderer.prevSection?.(); + const newSectionReady = await waitForNewSection(oldSectionIndex); + + if (!newSectionReady) { + pendingNavigationRef.current = null; + pendingNavigationRef.current = 'next'; + await initIterator(); + focusCurrentParagraph(); + return false; + } + + const success = await initIterator(); + if (success) { + focusCurrentParagraph(); + } + return success; + } catch (e) { + console.warn('[ParagraphMode] Section navigation failed:', e); + pendingNavigationRef.current = null; + await initIterator(); + focusCurrentParagraph(); + return false; + } + }, [viewRef, updateStateFromIterator, focusCurrentParagraph, initIterator, waitForNewSection]); + + const goToParagraph = useCallback( + (index: number) => { + const iterator = iteratorRef.current; + if (!iterator) return false; + + const range = iterator.goTo(index); + if (range) { + updateStateFromIterator(); + focusCurrentParagraph(); + return true; + } + return false; + }, + [updateStateFromIterator, focusCurrentParagraph], + ); + + const toggleParagraphMode = useCallback(async () => { + const settings = getViewSettings(bookKeyRef.current); + if (!settings) return; + if (toggleInFlightRef.current) return; + + toggleInFlightRef.current = true; + try { + const currentConfig = settings.paragraphMode ?? DEFAULT_PARAGRAPH_MODE_CONFIG; + const newEnabled = !currentConfig.enabled; + const newConfig = { ...currentConfig, enabled: newEnabled }; + + if (newEnabled) { + setViewSettings(bookKeyRef.current, { ...settings, paragraphMode: newConfig }); + saveViewSettings(envConfig, bookKeyRef.current, 'paragraphMode', newConfig, true, false); + + const success = await initIterator(); + if (success) { + await focusCurrentParagraph(); + } + } else { + setViewSettings(bookKeyRef.current, { ...settings, paragraphMode: newConfig }); + saveViewSettings(envConfig, bookKeyRef.current, 'paragraphMode', newConfig, true, false); + + const view = viewRef.current; + const iterator = iteratorRef.current; + if (view && iterator) { + const range = iterator.current(); + if (range) { + const progressLocation = getProgress(bookKeyRef.current)?.location; + const docIndex = currentDocIndexRef.current; + if (progressLocation && docIndex !== undefined) { + const paragraphCfi = view.getCFI(docIndex, range); + lastParagraphRef.current = { + progressLocation, + paragraphCfi, + docIndex, + }; + } + view.renderer.scrollToAnchor(range); + } + } + eventDispatcher.dispatch('paragraph-mode-disabled', { bookKey: bookKeyRef.current }); + iteratorRef.current = null; + updateStateFromIterator(); + } + } finally { + toggleInFlightRef.current = false; + } + }, [ + getViewSettings, + setViewSettings, + getProgress, + envConfig, + initIterator, + focusCurrentParagraph, + viewRef, + updateStateFromIterator, + ]); + + useEffect(() => { + if (!isFirstMountRef.current) return; + isFirstMountRef.current = false; + + if (paragraphConfig.enabled && !iteratorRef.current && !isProcessingRef.current) { + const init = async () => { + const success = await initIterator(); + if (success) { + await focusCurrentParagraph(); + } + }; + const timer = setTimeout(init, 100); + return () => clearTimeout(timer); + } + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + const executeRelocateHandler = async () => { + if ( + paragraphConfig.enabled && + !isProcessingRef.current && + !pendingNavigationRef.current && + !iteratorRef.current + ) { + await initIterator(); + } + }; + + const handleRelocate = () => { + if (isFocusingRef.current) { + isFocusingRef.current = false; + return; + } + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = setTimeout(executeRelocateHandler, 100); + }; + + view.renderer.addEventListener('relocate', handleRelocate); + return () => { + view.renderer.removeEventListener('relocate', handleRelocate); + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [viewRef, paragraphConfig.enabled, initIterator]); + + useEffect(() => { + const handleToggle = (event: CustomEvent) => { + if (event.detail?.bookKey === bookKeyRef.current) { + toggleParagraphMode(); + } + }; + + const handleNext = (event: CustomEvent) => { + if (event.detail?.bookKey === bookKeyRef.current && paragraphConfig.enabled) { + goToNextParagraph(); + } + }; + + const handlePrev = (event: CustomEvent) => { + if (event.detail?.bookKey === bookKeyRef.current && paragraphConfig.enabled) { + goToPrevParagraph(); + } + }; + + eventDispatcher.on('toggle-paragraph-mode', handleToggle); + eventDispatcher.on('paragraph-next', handleNext); + eventDispatcher.on('paragraph-prev', handlePrev); + + return () => { + eventDispatcher.off('toggle-paragraph-mode', handleToggle); + eventDispatcher.off('paragraph-next', handleNext); + eventDispatcher.off('paragraph-prev', handlePrev); + }; + }, [toggleParagraphMode, goToNextParagraph, goToPrevParagraph, paragraphConfig.enabled]); + + useEffect(() => { + return () => { + if (focusResetTimerRef.current) { + clearTimeout(focusResetTimerRef.current); + } + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + iteratorRef.current = null; + initPromiseRef.current = null; + }; + }, []); + + return { + paragraphState, + paragraphConfig, + toggleParagraphMode, + goToNextParagraph, + goToPrevParagraph, + goToParagraph, + focusCurrentParagraph, + initIterator, + }; +}; diff --git a/apps/readest-app/src/helpers/shortcuts.ts b/apps/readest-app/src/helpers/shortcuts.ts index 829592f8..cd92dee2 100644 --- a/apps/readest-app/src/helpers/shortcuts.ts +++ b/apps/readest-app/src/helpers/shortcuts.ts @@ -7,6 +7,7 @@ const DEFAULT_SHORTCUTS = { onToggleSelectMode: ['shift+s'], onToggleBookmark: ['ctrl+d', 'cmd+d'], onToggleTTS: ['t'], + onToggleParagraphMode: ['shift+p'], onHighlightSelection: ['ctrl+h', 'cmd+h'], onUnderlineSelection: ['ctrl+u', 'cmd+u'], onAnnotateSelection: ['ctrl+n', 'cmd+n'], diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 2be1f09b..75e14414 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -7,6 +7,7 @@ import { BookStyle, HighlightColor, NoteExportConfig, + ParagraphModeConfig, ReadingRulerColor, ScreenConfig, TranslatorConfig, @@ -302,6 +303,10 @@ export const DEFAULT_SCREEN_CONFIG: ScreenConfig = { screenOrientation: 'auto', }; +export const DEFAULT_PARAGRAPH_MODE_CONFIG: ParagraphModeConfig = { + enabled: false, +}; + export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = { scope: 'book', matchCase: false, diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index d11fbbbe..384cbd13 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -20,6 +20,10 @@ export type HighlightStyle = 'highlight' | 'underline' | 'squiggly'; export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet' | string; export type ReadingRulerColor = 'transparent' | 'yellow' | 'green' | 'blue' | 'rose'; +export interface ParagraphModeConfig { + enabled: boolean; +} + export const FIXED_LAYOUT_FORMATS: Set = new Set(['PDF', 'CBZ']); export interface Book { @@ -287,7 +291,9 @@ export interface ViewSettings TranslatorConfig, ScreenConfig, ProofreadRulesConfig, - AnnotatorConfig {} + AnnotatorConfig { + paragraphMode?: ParagraphModeConfig; +} export interface BookProgress { location: string; diff --git a/apps/readest-app/src/utils/paragraph.ts b/apps/readest-app/src/utils/paragraph.ts new file mode 100644 index 00000000..65c8c862 --- /dev/null +++ b/apps/readest-app/src/utils/paragraph.ts @@ -0,0 +1,243 @@ +const blockTags = new Set([ + 'article', + 'aside', + 'blockquote', + 'caption', + 'details', + 'div', + 'dl', + 'dt', + 'dd', + 'figure', + 'footer', + 'figcaption', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'header', + 'hgroup', + 'li', + 'main', + 'nav', + 'ol', + 'p', + 'pre', + 'section', + 'tr', +]); + +const MAX_BLOCKS = 5000; + +const rangeHasContent = (range: Range): boolean => { + try { + const container = range.commonAncestorContainer; + if (container.nodeType === Node.TEXT_NODE) { + return (container.textContent?.trim().length ?? 0) > 0; + } + if (container.nodeType === Node.ELEMENT_NODE) { + return (container as Element).textContent?.trim().length !== 0; + } + return true; + } catch { + return false; + } +}; + +const hasDirectText = (node: Element): boolean => + Array.from(node.childNodes).some( + (child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim(), + ); + +const hasBlockChild = (node: Element): boolean => + Array.from(node.children).some((child) => blockTags.has(child.tagName.toLowerCase())); + +const yieldToMain = (): Promise => + new Promise((resolve) => { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => resolve()); + } else { + setTimeout(resolve, 0); + } + }); + +export class ParagraphIterator { + #blocks: Range[] = []; + #index = -1; + + private constructor(blocks: Range[]) { + this.#blocks = blocks; + } + + static async createAsync(doc: Document, batchSize = 50): Promise { + if (!doc?.body) { + return new ParagraphIterator([]); + } + + const blocks: Range[] = []; + let last: Range | null = null; + let count = 0; + let processed = 0; + + const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT); + + for (let node = walker.nextNode(); node && count < MAX_BLOCKS; node = walker.nextNode()) { + processed++; + + if (processed % batchSize === 0) { + await yieldToMain(); + } + + const element = node as Element; + const name = element.tagName?.toLowerCase(); + if (name && blockTags.has(name)) { + if (hasBlockChild(element) && !hasDirectText(element)) { + continue; + } + if (last) { + try { + last.setEndBefore(node); + if (rangeHasContent(last)) { + blocks.push(last); + count++; + } + } catch { + // ignore invalid ranges + } + } + try { + last = doc.createRange(); + last.setStart(node, 0); + } catch { + last = null; + } + } + } + + if (count >= MAX_BLOCKS) { + console.warn('ParagraphIterator: Maximum block limit reached'); + return new ParagraphIterator(blocks); + } + + if (!last) { + try { + last = doc.createRange(); + const startNode = doc.body.firstChild ?? doc.body; + last.setStart(startNode, 0); + } catch { + return new ParagraphIterator(blocks); + } + } + + try { + const endNode = doc.body.lastChild ?? doc.body; + last.setEndAfter(endNode); + if (rangeHasContent(last)) { + blocks.push(last); + } + } catch { + // ignore + } + + return new ParagraphIterator(blocks); + } + + get length(): number { + return this.#blocks.length; + } + + get currentIndex(): number { + return this.#index; + } + + current(): Range | null { + return this.#blocks[this.#index] ?? null; + } + + first(): Range | null { + if (this.#blocks.length === 0) return null; + this.#index = 0; + return this.#blocks[0] ?? null; + } + + last(): Range | null { + if (this.#blocks.length === 0) return null; + this.#index = this.#blocks.length - 1; + return this.#blocks[this.#index] ?? null; + } + + next(): Range | null { + const newIndex = this.#index + 1; + if (newIndex < this.#blocks.length) { + this.#index = newIndex; + return this.#blocks[newIndex] ?? null; + } + return null; + } + + prev(): Range | null { + const newIndex = this.#index - 1; + if (newIndex >= 0) { + this.#index = newIndex; + return this.#blocks[newIndex] ?? null; + } + return null; + } + + goTo(index: number): Range | null { + if (index >= 0 && index < this.#blocks.length) { + this.#index = index; + return this.#blocks[index] ?? null; + } + return null; + } + + findByNode(targetNode: Node | null): Range | null { + if (!targetNode) return this.first(); + + for (let i = 0; i < this.#blocks.length; i++) { + const block = this.#blocks[i]; + try { + if (block?.intersectsNode(targetNode)) { + this.#index = i; + return block; + } + } catch { + continue; + } + } + return this.first(); + } + + async findByRangeAsync(targetRange: Range | null, batchSize = 50): Promise { + if (!targetRange) return this.first(); + + for (let i = 0; i < this.#blocks.length; i++) { + if (i > 0 && i % batchSize === 0) { + await yieldToMain(); + } + + const block = this.#blocks[i]; + if (!block) continue; + + try { + const startToEnd = block.compareBoundaryPoints(Range.START_TO_END, targetRange); + const endToStart = block.compareBoundaryPoints(Range.END_TO_START, targetRange); + if (startToEnd >= 0 && endToStart <= 0) { + this.#index = i; + return block; + } + } catch { + continue; + } + } + + try { + return this.findByNode(targetRange.startContainer); + } catch { + return this.first(); + } + } +}