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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user