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
This commit is contained in:
Mohammed Efaz
2026-01-30 05:24:23 +01:00
committed by GitHub
parent c1460f4b85
commit 4ce1ebe477
12 changed files with 1568 additions and 2 deletions
@@ -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}
/>
<ParagraphControl bookKey={bookKey} viewRef={viewRef} gridInsets={gridInsets} />
{!docLoaded.current && loading && <Spinner loading={true} />}
{syncState === 'conflict' && conflictDetails && (
<KOSyncConflictResolver
@@ -47,6 +47,9 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ 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<ViewMenuProps> = ({ 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<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
disabled={bookData.isFixedLayout}
/>
<MenuItem
label={_('Paragraph Mode')}
shortcut='Shift+P'
Icon={isParagraphMode ? MdCheck : undefined}
onClick={toggleParagraphMode}
disabled={bookData.isFixedLayout}
/>
<hr aria-hidden='true' className='border-base-300 my-1' />
<MenuItem
@@ -0,0 +1,270 @@
'use client';
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { MdChevronLeft, MdChevronRight, MdClose } from 'react-icons/md';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTranslation } from '@/hooks/useTranslation';
const INITIAL_SHOW_DURATION = 2500;
const HIDE_DELAY = 2000;
const TRIGGER_ZONE_HEIGHT = 100;
interface ParagraphBarProps {
bookKey: string;
currentIndex: number;
totalParagraphs: number;
isLoading?: boolean;
onPrev: () => void;
onNext: () => void;
onClose: () => void;
gridInsets: Insets;
}
const AnimatedNumber: React.FC<{ value: number | string }> = ({ value }) => (
<span
key={value}
className='inline-block animate-[subtle-slide-up_0.2s_ease-out] text-sm font-medium tabular-nums'
>
{value}
</span>
);
const ParagraphBar: React.FC<ParagraphBarProps> = ({
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<ReturnType<typeof setTimeout> | 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 (
<>
<style jsx global>{`
@keyframes subtle-slide-up {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
<div
className={clsx(
'absolute bottom-6 left-1/2 z-50 -translate-x-1/2',
'transition-[opacity,filter,transform] duration-200 ease-out',
isVisible
? 'pointer-events-auto translate-y-0 scale-100 opacity-100 blur-0'
: 'pointer-events-none translate-y-4 scale-90 opacity-0 blur-sm',
)}
style={{
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
}}
onMouseEnter={() => {
isInTriggerZoneRef.current = true;
showBar(false);
}}
onMouseLeave={() => {
isInTriggerZoneRef.current = false;
startHideTimer();
}}
>
<div
className={clsx(
'flex items-center gap-1 rounded-full px-3 py-1.5',
'bg-base-300 text-base-content',
'border-base-content/10 border',
'shadow-sm backdrop-blur-md',
'transition-all duration-200 ease-out',
)}
>
<button
onClick={onPrev}
disabled={isLoading}
className={clsx(
'flex items-center justify-center rounded-full p-1.5',
'transition-all duration-200 ease-out',
'hover:bg-base-content/10 active:scale-90',
isLoading && 'pointer-events-none opacity-50',
)}
title={_('Previous Paragraph')}
aria-label={_('Previous Paragraph')}
>
<MdChevronLeft size={iconSize} />
</button>
<div className='bg-base-content/10 mx-1 h-4 w-px' />
<div className='flex items-center gap-2 px-1'>
{isLoading ? (
<div className='flex min-w-[3rem] items-center justify-center gap-2'>
<span className='loading loading-dots loading-sm text-base-content/50' />
<span className='text-base-content/50 text-sm'>{_('Loading')}</span>
</div>
) : (
<>
<div className='flex min-w-[3rem] items-center justify-center gap-1'>
<AnimatedNumber value={currentIndex + 1} />
<span className='text-base-content/30 text-sm'>/</span>
<span className='text-sm font-medium tabular-nums'>{totalParagraphs}</span>
</div>
<span className='text-base-content/40 text-sm'></span>
<div className='flex min-w-[2.5rem] items-center justify-center gap-0.5'>
<AnimatedNumber value={progress} />
<span className='text-sm font-medium'>%</span>
</div>
</>
)}
</div>
<div className='bg-base-content/10 mx-1 h-4 w-px' />
<button
onClick={onNext}
disabled={isLoading}
className={clsx(
'flex items-center justify-center rounded-full p-1.5',
'transition-all duration-200 ease-out',
'hover:bg-base-content/10 active:scale-90',
isLoading && 'pointer-events-none opacity-50',
)}
title={_('Next Paragraph')}
aria-label={_('Next Paragraph')}
>
<MdChevronRight size={iconSize} />
</button>
<button
onClick={onClose}
disabled={isLoading}
className={clsx(
'flex items-center justify-center rounded-full p-1.5',
'transition-all duration-200 ease-out',
'hover:bg-base-content/10 active:scale-90',
isLoading && 'pointer-events-none opacity-50',
)}
title={_('Exit Paragraph Mode')}
aria-label={_('Exit Paragraph Mode')}
>
<MdClose size={iconSize} />
</button>
</div>
</div>
</>
);
};
export default ParagraphBar;
@@ -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<FoliateView | null>;
gridInsets: Insets;
}
const ParagraphControl: React.FC<ParagraphControlProps> = ({ 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 (
<>
<ParagraphOverlay
bookKey={bookKey}
dimOpacity={DIM_OPACITY}
viewSettings={viewSettings ?? undefined}
onClose={toggleParagraphMode}
/>
<ParagraphBar
bookKey={bookKey}
currentIndex={paragraphState.currentIndex}
totalParagraphs={paragraphState.totalParagraphs}
isLoading={paragraphState.isLoading}
onPrev={goToPrevParagraph}
onNext={goToNextParagraph}
onClose={toggleParagraphMode}
gridInsets={gridInsets}
/>
</>
);
};
export default ParagraphControl;
@@ -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<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const timer = requestAnimationFrame(() => setIsReady(true));
return () => cancelAnimationFrame(timer);
}, [html]);
const showContent = state === 'active' && isReady;
return (
<div
ref={contentRef}
className={clsx(
'paragraph-content text-base-content w-full',
'duration-400 transition-all ease-out',
state === 'entering' && 'translate-y-4 opacity-0',
state === 'active' &&
(showContent ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0'),
state === 'exiting' && '-translate-y-8 opacity-0',
)}
style={{ ...style, transformOrigin: 'center top' }}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
};
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 (
<div
className={clsx(
'flex w-full items-center justify-center',
'duration-400 transition-all ease-out',
isReady ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0',
)}
>
<div className='flex items-center gap-3'>
<div className='flex items-center gap-1.5'>
{[0, 1, 2].map((i) => (
<div
key={i}
className='bg-base-content/30 h-1.5 w-1.5 rounded-full'
style={{
animation: 'pulse 800ms ease-in-out infinite',
animationDelay: `${i * 150}ms`,
}}
/>
))}
</div>
<span className='text-base-content/40 text-base font-medium'>
{direction === 'next' ? 'Next chapter' : 'Previous chapter'}
</span>
</div>
</div>
);
};
const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
bookKey,
dimOpacity,
viewSettings,
onClose,
}) => {
const [paragraphs, setParagraphs] = useState<ParagraphContent[]>([]);
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<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(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<typeof setTimeout> | 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
<div
ref={containerRef}
role='dialog'
aria-modal='true'
aria-label='Paragraph reading mode'
tabIndex={-1}
className={clsx(
'fixed inset-0 z-40',
'flex flex-col items-center justify-center',
'transition-opacity duration-300 ease-out',
isOverlayMounted ? 'opacity-100' : 'opacity-0',
)}
style={{
backgroundColor: `oklch(var(--b1) / ${Math.min(dimOpacity + 0.4, 0.92)})`,
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
onClick={handleBackdropClick}
onTouchStart={handleTouchStart}
onKeyDown={(e) => e.stopPropagation()}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
ref={contentRef}
className='relative flex w-full max-w-3xl cursor-default flex-col items-center px-8'
onClick={handleContentClick}
>
{exitingParagraph && !isChangingSection && (
<div
key={exitingParagraph.id}
className={clsx(
'paragraph-content text-base-content/20 absolute w-full',
'duration-400 transition-all ease-out',
'-translate-y-12 scale-95 opacity-0',
)}
style={contentStyle}
dangerouslySetInnerHTML={{ __html: exitingParagraph.html }}
/>
)}
{activeParagraph ? (
<AnimatedParagraph
key={activeParagraph.id}
html={activeParagraph.html}
state={activeParagraph.state}
style={contentStyle}
/>
) : isChangingSection ? (
<SectionTransitionIndicator isVisible={isChangingSection} direction={sectionDirection} />
) : null}
</div>
</div>
);
};
export default ParagraphOverlay;
@@ -0,0 +1,3 @@
export { default as ParagraphControl } from './ParagraphControl';
export { default as ParagraphBar } from './ParagraphBar';
export { default as ParagraphOverlay } from './ParagraphOverlay';
@@ -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,
@@ -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<FoliateView | null>;
}
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<ParagraphIterator | null>(null);
const currentDocIndexRef = useRef<number | undefined>(undefined);
const isProcessingRef = useRef(false);
const isFocusingRef = useRef(false);
const focusResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const bookKeyRef = useRef(bookKey);
const pendingNavigationRef = useRef<'next' | 'prev' | null>(null);
const initPromiseRef = useRef<Promise<boolean> | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | 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<ParagraphState>({
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<boolean> => {
if (isProcessingRef.current) {
return initPromiseRef.current ?? false;
}
isProcessingRef.current = true;
setParagraphState((prev) => ({ ...prev, isLoading: true }));
const initPromise = (async (): Promise<boolean> => {
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<void>;
};
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<boolean> => {
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,
};
};
@@ -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'],
@@ -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,
+7 -1
View File
@@ -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<BookFormat> = 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;
+243
View File
@@ -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<void> =>
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<ParagraphIterator> {
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<Range | null> {
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();
}
}
}