fix: preserve paragraph mode reading layouts and other UI/UX fixes (#3730)

* fix: paragraph mode

* test: paragraph mode

* test: remove paragraph mode

* fix: paragraph utils

* fix: paragraph hook

* fix: paragraph overlay

* fix: paragraph bar

* fix: paragraph control

* fix: paragraph shortcuts

* test: paragraph utils

* test: paragraph hook

* test: paragraph overlay

* test: paragraph shortcuts

* fix: paragraph overlay

* test: paragraph mode

* test: shortcuts

* test: remove overlay

* test: remove hook

* test: remove utils

* fix: paragraph overlay

* fix: paragraph overlay

* feat: paragraph overlay

* fix: vertical container sizing

* test: paragraph container

* fix: paragraph animation

* fix: paragraph text animation

* fix: remove container morph
This commit is contained in:
Mohammed Efaz
2026-04-03 00:56:38 +06:00
committed by GitHub
parent 05afaab5fd
commit 888f4afde9
8 changed files with 730 additions and 115 deletions
@@ -29,6 +29,9 @@ const currentViewSettings = {
defaultFontSize: 16,
lineHeight: 1.5,
readingRulerEnabled: true,
writingMode: 'horizontal-tb',
vertical: false,
rtl: false,
paragraphMode: { enabled: false },
};
@@ -120,6 +123,10 @@ describe('useBookShortcuts', () => {
vi.clearAllMocks();
shortcutState.actions = null;
currentViewSettings.readingRulerEnabled = true;
currentViewSettings.writingMode = 'horizontal-tb';
currentViewSettings.vertical = false;
currentViewSettings.rtl = false;
currentViewSettings.paragraphMode.enabled = false;
mockView.book.dir = 'ltr';
});
@@ -0,0 +1,283 @@
import React from 'react';
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ParagraphOverlay from '@/app/reader/components/paragraph/ParagraphOverlay';
import { useParagraphMode } from '@/app/reader/hooks/useParagraphMode';
import type { FoliateView } from '@/types/view';
import { eventDispatcher } from '@/utils/event';
import {
getParagraphActionForKey,
getParagraphActionForZone,
getParagraphPresentation,
} from '@/utils/paragraphPresentation';
const currentViewSettings = {
paragraphMode: { enabled: true },
writingMode: 'horizontal-tb',
vertical: false,
rtl: false,
};
const mockGetViewSettings = vi.fn(() => currentViewSettings);
const mockSetViewSettings = vi.fn();
const mockGetProgress = vi.fn(() => null);
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ envConfig: {}, appService: { hasSafeAreaInset: false } }),
}));
vi.mock('@/helpers/settings', () => ({
saveViewSettings: vi.fn(),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
getViewSettings: mockGetViewSettings,
setViewSettings: mockSetViewSettings,
getProgress: mockGetProgress,
}),
}));
global.ResizeObserver = class ResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
observe(target: Element) {
this.callback([{ target } as ResizeObserverEntry], this);
}
disconnect() {}
unobserve() {}
} as typeof ResizeObserver;
const createDoc = (body: string): Document =>
new DOMParser().parseFromString(`<html><body>${body}</body></html>`, 'text/html');
const attachDefaultView = (
doc: Document,
getComputedStyle: (element: Element) => CSSStyleDeclaration,
) => {
Object.defineProperty(doc, 'defaultView', {
value: { getComputedStyle },
configurable: true,
});
};
function createMockView(docs: Document[], initialPrimaryIndex: number) {
const contents = docs.map((doc, index) => ({ doc, index }));
const renderer = {
primaryIndex: initialPrimaryIndex,
getContents: vi.fn(() => contents),
nextSection: vi.fn(async () => {
renderer.primaryIndex = Math.min(renderer.primaryIndex + 1, contents.length - 1);
}),
prevSection: vi.fn(async () => {
renderer.primaryIndex = Math.max(renderer.primaryIndex - 1, 0);
}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
goTo: vi.fn(),
scrollToAnchor: vi.fn(),
};
const view = {
renderer,
resolveCFI: vi.fn(),
getCFI: vi.fn(() => 'epubcfi(/6/4!/4/2/1:0)'),
} as unknown as FoliateView;
return { view, renderer };
}
let hookApi: ReturnType<typeof useParagraphMode> | null = null;
const HookHarness = ({ view }: { view: React.RefObject<FoliateView | null> }) => {
hookApi = useParagraphMode({ bookKey: 'book-1', viewRef: view });
return null;
};
describe('paragraph mode', () => {
beforeEach(() => {
vi.clearAllMocks();
hookApi = null;
currentViewSettings.writingMode = 'horizontal-tb';
currentViewSettings.vertical = false;
currentViewSettings.rtl = false;
});
afterEach(() => {
cleanup();
});
it('preserves source presentation and navigation rules', () => {
const verticalDoc = createDoc('<p lang="ja">縦書きの段落です。</p>');
const verticalParagraph = verticalDoc.querySelector('p')!;
const verticalRange = verticalDoc.createRange();
verticalRange.selectNodeContents(verticalParagraph);
attachDefaultView(verticalDoc, (element: Element) => {
if (element === verticalParagraph || element === verticalDoc.body) {
return {
writingMode: 'vertical-rl',
direction: 'ltr',
textOrientation: 'upright',
unicodeBidi: 'plaintext',
textAlign: 'start',
} as CSSStyleDeclaration;
}
return {
writingMode: 'horizontal-tb',
direction: 'ltr',
} as CSSStyleDeclaration;
});
const arabicDoc = createDoc('<p dir="rtl">هذا نص عربي</p>');
const arabicParagraph = arabicDoc.querySelector('p')!;
const arabicRange = arabicDoc.createRange();
arabicRange.selectNodeContents(arabicParagraph);
attachDefaultView(
arabicDoc,
() =>
({
writingMode: 'horizontal-tb',
direction: 'rtl',
textAlign: 'start',
}) as CSSStyleDeclaration,
);
expect(getParagraphPresentation(verticalDoc, verticalRange)).toEqual(
expect.objectContaining({
lang: 'ja',
dir: 'ltr',
writingMode: 'vertical-rl',
vertical: true,
}),
);
expect(getParagraphPresentation(arabicDoc, arabicRange)).toEqual(
expect.objectContaining({
dir: 'rtl',
rtl: true,
}),
);
expect(getParagraphActionForZone('left', { rtl: true, vertical: false })).toBe('next');
expect(getParagraphActionForZone('top', { vertical: true, writingMode: 'vertical-rl' })).toBe(
'prev',
);
expect(getParagraphActionForKey('ArrowLeft', { rtl: true, vertical: false })).toBe('next');
expect(
getParagraphActionForKey('ArrowLeft', { vertical: true, writingMode: 'vertical-rl' }),
).toBe('next');
});
it('uses the active primary section when moving across chapter boundaries', async () => {
const previousChapterDoc = createDoc('<p>Old chapter ending</p>');
const nextChapterDoc = createDoc('<h1>Chapter 2</h1><p>First paragraph</p>');
const { view, renderer } = createMockView([previousChapterDoc, nextChapterDoc], 0);
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Old chapter ending');
});
await act(async () => {
await hookApi?.goToNextParagraph();
});
await waitFor(() => {
expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Chapter 2');
});
expect(renderer.nextSection).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(renderer.goTo).toHaveBeenLastCalledWith(expect.objectContaining({ index: 1 }));
});
});
it('renders preserved presentation and layout-aware click zones in the overlay', async () => {
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
const overlayBookKey = 'overlay-book';
const doc = createDoc('<p>مرحبا بالعالم</p>');
const paragraph = doc.querySelector('p')!;
const range = doc.createRange();
range.selectNodeContents(paragraph);
const { container } = render(
<ParagraphOverlay
bookKey={overlayBookKey}
dimOpacity={0.3}
viewSettings={{ writingMode: 'horizontal-tb', vertical: false, rtl: true } as never}
/>,
);
await act(async () => {
await eventDispatcher.dispatch('paragraph-focus', {
bookKey: overlayBookKey,
range,
presentation: {
lang: 'ja',
dir: 'ltr',
writingMode: 'vertical-rl',
textOrientation: 'upright',
vertical: true,
rtl: true,
},
});
});
const paragraphContent = await waitFor(() => {
const node = container.querySelector('.paragraph-content') as HTMLDivElement | null;
expect(node).not.toBeNull();
return node!;
});
expect(paragraphContent.getAttribute('lang')).toBe('ja');
expect(paragraphContent.style.writingMode).toBe('vertical-rl');
dispatchSpy.mockClear();
const contentArea = container.querySelector('.relative.flex') as HTMLDivElement;
vi.spyOn(contentArea, 'getBoundingClientRect').mockReturnValue({
width: 300,
height: 300,
top: 0,
left: 0,
right: 300,
bottom: 300,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);
fireEvent.click(contentArea, { clientX: 150, clientY: 20 });
await waitFor(() => {
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-prev', { bookKey: overlayBookKey });
});
await act(async () => {
await eventDispatcher.dispatch('paragraph-focus', {
bookKey: overlayBookKey,
range,
presentation: {
dir: 'rtl',
writingMode: 'horizontal-tb',
vertical: false,
rtl: true,
},
});
});
dispatchSpy.mockClear();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 320));
});
fireEvent.click(contentArea, { clientX: 40, clientY: 150 });
await waitFor(() => {
expect(dispatchSpy).toHaveBeenCalledWith('paragraph-next', { bookKey: overlayBookKey });
});
});
});
@@ -2,12 +2,20 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { MdChevronLeft, MdChevronRight, MdClose } from 'react-icons/md';
import {
MdChevronLeft,
MdChevronRight,
MdClose,
MdKeyboardArrowDown,
MdKeyboardArrowUp,
} from 'react-icons/md';
import { ViewSettings } from '@/types/book';
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';
import { getParagraphButtonDirections } from '@/utils/paragraphPresentation';
const INITIAL_SHOW_DURATION = 2500;
const HIDE_DELAY = 2000;
@@ -21,6 +29,7 @@ interface ParagraphBarProps {
onPrev: () => void;
onNext: () => void;
onClose: () => void;
viewSettings?: ViewSettings;
gridInsets: Insets;
}
@@ -41,12 +50,14 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
onPrev,
onNext,
onClose,
viewSettings,
gridInsets,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { hoveredBookKey } = useReaderStore();
const iconSize = useResponsiveSize(18);
const buttonDirections = getParagraphButtonDirections(viewSettings);
const [isBarVisible, setIsBarVisible] = useState(true);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -145,6 +156,18 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
const isVisible = isBarVisible && !isHiddenByHover;
const progress =
totalParagraphs > 0 ? Math.round(((currentIndex + 1) / totalParagraphs) * 100) : 0;
const PrevIcon =
buttonDirections.prev === 'up'
? MdKeyboardArrowUp
: buttonDirections.prev === 'right'
? MdChevronRight
: MdChevronLeft;
const NextIcon =
buttonDirections.next === 'down'
? MdKeyboardArrowDown
: buttonDirections.next === 'left'
? MdChevronLeft
: MdChevronRight;
return (
<>
@@ -201,7 +224,7 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
title={_('Previous Paragraph')}
aria-label={_('Previous Paragraph')}
>
<MdChevronLeft size={iconSize} />
<PrevIcon size={iconSize} />
</button>
<div className='bg-base-content/10 mx-1 h-4 w-px' />
@@ -244,7 +267,7 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
title={_('Next Paragraph')}
aria-label={_('Next Paragraph')}
>
<MdChevronRight size={iconSize} />
<NextIcon size={iconSize} />
</button>
<button
@@ -38,6 +38,7 @@ const ParagraphControl: React.FC<ParagraphControlProps> = ({ bookKey, viewRef, g
bookKey={bookKey}
dimOpacity={DIM_OPACITY}
viewSettings={viewSettings ?? undefined}
gridInsets={gridInsets}
onClose={toggleParagraphMode}
/>
<ParagraphBar
@@ -48,6 +49,7 @@ const ParagraphControl: React.FC<ParagraphControlProps> = ({ bookKey, viewRef, g
onPrev={goToPrevParagraph}
onNext={goToNextParagraph}
onClose={toggleParagraphMode}
viewSettings={viewSettings ?? undefined}
gridInsets={gridInsets}
/>
</>
@@ -3,48 +3,64 @@
import clsx from 'clsx';
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { ViewSettings } from '@/types/book';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { eventDispatcher } from '@/utils/event';
import {
getParagraphActionForKey,
getParagraphActionForZone,
getParagraphLayoutContext,
ParagraphPresentation,
} from '@/utils/paragraphPresentation';
interface ParagraphOverlayProps {
bookKey: string;
dimOpacity: number;
viewSettings?: ViewSettings;
gridInsets?: Insets;
onClose?: () => void;
}
interface ParagraphContent {
id: number;
html: string;
state: 'entering' | 'active' | 'exiting';
presentation: ParagraphPresentation;
}
const getParagraphTextAlign = (presentation: ParagraphPresentation) =>
presentation.textAlign || (presentation.vertical ? 'center' : undefined);
const AnimatedParagraph: React.FC<{
html: string;
state: 'entering' | 'active' | 'exiting';
presentation: ParagraphPresentation;
style: React.CSSProperties;
}> = ({ html, state, style }) => {
const contentRef = useRef<HTMLDivElement>(null);
}> = ({ html, presentation, style }) => {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const timer = requestAnimationFrame(() => setIsReady(true));
return () => cancelAnimationFrame(timer);
setIsReady(false);
const frame = requestAnimationFrame(() => setIsReady(true));
return () => cancelAnimationFrame(frame);
}, [html]);
const showContent = state === 'active' && isReady;
return (
<div
ref={contentRef}
lang={presentation.lang}
dir={presentation.dir}
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',
'paragraph-content text-base-content transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
presentation.vertical ? 'mx-auto w-auto max-w-none' : 'w-full',
isReady ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
)}
style={{ ...style, transformOrigin: 'center top' }}
style={{
...style,
direction: presentation.dir,
writingMode: presentation.writingMode as React.CSSProperties['writingMode'],
textOrientation: presentation.textOrientation as React.CSSProperties['textOrientation'],
unicodeBidi: presentation.unicodeBidi as React.CSSProperties['unicodeBidi'],
textAlign: getParagraphTextAlign(presentation) as React.CSSProperties['textAlign'],
transformOrigin: 'center top',
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
@@ -102,8 +118,10 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
bookKey,
dimOpacity,
viewSettings,
gridInsets = { top: 0, right: 0, bottom: 0, left: 0 },
onClose,
}) => {
const { appService } = useEnv();
const [paragraphs, setParagraphs] = useState<ParagraphContent[]>([]);
const [isVisible, setIsVisible] = useState(false);
const [isOverlayMounted, setIsOverlayMounted] = useState(false);
@@ -132,9 +150,62 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
letterSpacing: viewSettings.letterSpacing ? `${viewSettings.letterSpacing}px` : undefined,
wordSpacing: viewSettings.wordSpacing ? `${viewSettings.wordSpacing}px` : undefined,
fontWeight: viewSettings.fontWeight || 400,
WebkitFontSmoothing: 'antialiased',
fontKerning: 'normal',
textRendering: 'optimizeLegibility',
} as React.CSSProperties;
}, [viewSettings]);
const activePresentation = paragraphs[0]?.presentation ?? undefined;
const activeParagraph = paragraphs[0];
const layoutContext = useMemo(
() => getParagraphLayoutContext(activePresentation ?? viewSettings),
[activePresentation, viewSettings],
);
const frameStyle = useMemo(() => {
const topInset = appService?.hasSafeAreaInset ? gridInsets.top : 0;
const bottomInset = appService?.hasSafeAreaInset ? gridInsets.bottom * 0.33 : 0;
const viewportPadding = `clamp(1rem, 4vw, 2.5rem)`;
return {
boxSizing: 'border-box',
paddingBlock: layoutContext.vertical
? 'clamp(0.9rem, 2.4vh, 1.35rem)'
: 'clamp(1rem, 3vh, 1.75rem)',
paddingInline: layoutContext.vertical
? 'clamp(0.85rem, 2.8vw, 1.2rem)'
: 'clamp(1rem, 4vw, 2rem)',
inlineSize: layoutContext.vertical
? 'fit-content'
: `min(calc(100vw - (${viewportPadding} * 2)), 66ch)`,
blockSize: layoutContext.vertical ? 'fit-content' : undefined,
minInlineSize: layoutContext.vertical ? '5.25rem' : undefined,
maxInlineSize: layoutContext.vertical
? `min(calc(100dvh - ${topInset + bottomInset + 80}px), 24rem)`
: undefined,
maxBlockSize: layoutContext.vertical
? 'min(calc(100vw - 1.5rem), 28rem)'
: `min(calc(100dvh - ${topInset + bottomInset + 132}px), 38rem)`,
marginInline: 'auto',
} as React.CSSProperties;
}, [appService?.hasSafeAreaInset, gridInsets.bottom, gridInsets.top, layoutContext.vertical]);
const surfaceStyle = useMemo(
() =>
({
backgroundColor: 'oklch(var(--b1) / 0.14)',
}) as React.CSSProperties,
[],
);
const fallbackPresentation = useMemo(
(): ParagraphPresentation => ({
dir: layoutContext.rtl ? 'rtl' : 'ltr',
writingMode: layoutContext.writingMode,
vertical: layoutContext.vertical,
rtl: layoutContext.rtl,
}),
[layoutContext.rtl, layoutContext.vertical, layoutContext.writingMode],
);
const extractContent = useCallback((range: Range): string => {
try {
const fragment = range.cloneContents();
@@ -147,32 +218,16 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
}, []);
const addParagraph = useCallback(
(range: Range) => {
(range: Range, presentation?: ParagraphPresentation) => {
const html = extractContent(range);
if (!html) return;
const newId = ++paragraphIdCounter.current;
const nextPresentation = presentation ?? fallbackPresentation;
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);
setParagraphs([{ id: newId, html, presentation: nextPresentation }]);
},
[extractContent],
[extractContent, fallbackPresentation],
);
useEffect(() => {
@@ -181,6 +236,7 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
const handleFocus = (event: CustomEvent) => {
if (event.detail?.bookKey !== bookKey) return;
const range = event.detail?.range;
const presentation = event.detail?.presentation;
if (range) {
if (sectionChangeTimeoutId) {
clearTimeout(sectionChangeTimeoutId);
@@ -188,10 +244,8 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
}
setIsChangingSection(false);
setIsVisible(true);
requestAnimationFrame(() => {
setIsOverlayMounted(true);
requestAnimationFrame(() => addParagraph(range));
});
setIsOverlayMounted(true);
addParagraph(range, presentation);
}
};
@@ -212,12 +266,8 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
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 })));
setParagraphs([]);
setIsChangingSection(true);
sectionChangeTimeoutId = setTimeout(() => {
setParagraphs((prev) => prev.filter((p) => p.state !== 'exiting'));
sectionChangeTimeoutId = null;
}, 400);
};
eventDispatcher.on('paragraph-focus', handleFocus);
@@ -239,31 +289,28 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
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;
if (e.key === 'Escape' || e.key === 'Backspace') {
e.preventDefault();
onCloseRef.current?.();
return;
}
const action = getParagraphActionForKey(e.key, activePresentation ?? viewSettings);
if (action === 'next') {
e.preventDefault();
eventDispatcher.dispatch('paragraph-next', { bookKey });
return;
}
if (action === 'prev') {
e.preventDefault();
eventDispatcher.dispatch('paragraph-prev', { bookKey });
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [isVisible, bookKey]);
}, [activePresentation, bookKey, isVisible, viewSettings]);
useEffect(() => {
if (!isVisible) return;
@@ -297,13 +344,25 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
const touchEndX = moveEvent.touches[0]?.clientX ?? 0;
const diffY = touchStartY - touchEndY;
const diffX = touchStartX - touchEndX;
const horizontalAction =
diffX > 0
? getParagraphActionForZone('right', activePresentation ?? viewSettings)
: getParagraphActionForZone('left', activePresentation ?? viewSettings);
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 });
}
if (layoutContext.vertical && Math.abs(diffY) > Math.abs(diffX) && Math.abs(diffY) > 50) {
eventDispatcher.dispatch(diffY > 0 ? 'paragraph-next' : 'paragraph-prev', { bookKey });
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
} else if (
!layoutContext.vertical &&
Math.abs(diffX) > Math.abs(diffY) &&
Math.abs(diffX) > 50 &&
horizontalAction
) {
eventDispatcher.dispatch(
horizontalAction === 'next' ? 'paragraph-next' : 'paragraph-prev',
{ bookKey },
);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
}
@@ -317,7 +376,7 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchend', handleTouchEnd);
},
[bookKey],
[activePresentation, bookKey, layoutContext.vertical, viewSettings],
);
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
@@ -339,24 +398,38 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
}
lastTapTimeRef.current = now;
const containerWidth = contentRef.current?.offsetWidth ?? window.innerWidth;
const rect = contentRef.current?.getBoundingClientRect();
const clickX = e.clientX - (rect?.left ?? 0);
if (!rect) return;
if (clickX < containerWidth / 3) {
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
const zone = layoutContext.vertical
? clickY < rect.height / 3
? 'top'
: clickY > (rect.height * 2) / 3
? 'bottom'
: null
: clickX < rect.width / 3
? 'left'
: clickX > (rect.width * 2) / 3
? 'right'
: null;
const action = zone
? getParagraphActionForZone(zone, activePresentation ?? viewSettings)
: null;
if (action === 'prev') {
eventDispatcher.dispatch('paragraph-prev', { bookKey });
} else if (clickX > (containerWidth * 2) / 3) {
} else if (action === 'next') {
eventDispatcher.dispatch('paragraph-next', { bookKey });
}
},
[bookKey],
[activePresentation, bookKey, layoutContext.vertical, viewSettings],
);
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
@@ -375,6 +448,8 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
backgroundColor: `oklch(var(--b1) / ${Math.min(dimOpacity + 0.4, 0.92)})`,
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
paddingTop: appService?.hasSafeAreaInset ? `${gridInsets.top}px` : undefined,
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : undefined,
}}
onClick={handleBackdropClick}
onTouchStart={handleTouchStart}
@@ -383,29 +458,48 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
{/* 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'
className={clsx(
'relative flex w-full cursor-default flex-col items-center px-4 sm:px-6',
layoutContext.vertical ? 'justify-center py-2' : '',
)}
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 }}
/>
)}
<style>{`
.paragraph-content {
text-wrap: pretty;
}
.paragraph-content :is(h1, h2, h3, h4, h5, h6) {
line-height: 1.2;
text-wrap: balance;
margin-block-end: 0.45em;
}
.paragraph-content > :first-child {
margin-block-start: 0;
}
.paragraph-content > :last-child {
margin-block-end: 0;
}
`}</style>
{activeParagraph ? (
<AnimatedParagraph
key={activeParagraph.id}
html={activeParagraph.html}
state={activeParagraph.state}
style={contentStyle}
/>
<div
className={clsx(
'relative rounded-[2rem]',
layoutContext.vertical
? 'inline-flex items-center justify-center self-center overflow-visible'
: 'w-full overflow-auto',
)}
style={{ ...frameStyle, ...surfaceStyle }}
>
<AnimatedParagraph
key={activeParagraph.id}
html={activeParagraph.html}
presentation={activeParagraph.presentation}
style={contentStyle}
/>
</div>
) : isChangingSection ? (
<SectionTransitionIndicator isVisible={isChangingSection} direction={sectionDirection} />
) : null}
@@ -10,6 +10,7 @@ import { tauriHandleClose, tauriHandleToggleFullScreen, tauriQuitApp } from '@/u
import { eventDispatcher } from '@/utils/event';
import { setShortcutsDialogVisible } from '@/components/KeyboardShortcutsHelp';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import { getParagraphActionForKey } from '@/utils/paragraphPresentation';
import { viewPagination } from './usePagination';
import { getStyles } from '@/utils/style';
import useShortcuts from '@/hooks/useShortcuts';
@@ -65,7 +66,10 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
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 });
const action = getParagraphActionForKey('ArrowLeft', viewSettings);
eventDispatcher.dispatch(action === 'next' ? 'paragraph-next' : 'paragraph-prev', {
bookKey: sideBarBookKey,
});
return;
}
if (moveReadingRuler('left')) return;
@@ -76,7 +80,10 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
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 });
const action = getParagraphActionForKey('ArrowRight', viewSettings);
eventDispatcher.dispatch(action === 'prev' ? 'paragraph-prev' : 'paragraph-next', {
bookKey: sideBarBookKey,
});
return;
}
if (moveReadingRuler('right')) return;
@@ -88,7 +95,10 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
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 });
const action = getParagraphActionForKey('ArrowUp', viewSettings);
eventDispatcher.dispatch(action === 'next' ? 'paragraph-next' : 'paragraph-prev', {
bookKey: sideBarBookKey,
});
return;
}
if (moveReadingRuler('up')) return;
@@ -101,7 +111,10 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
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 });
const action = getParagraphActionForKey('ArrowDown', viewSettings);
eventDispatcher.dispatch(action === 'prev' ? 'paragraph-prev' : 'paragraph-next', {
bookKey: sideBarBookKey,
});
return;
}
if (moveReadingRuler('down')) return;
@@ -5,6 +5,7 @@ import { FoliateView } from '@/types/view';
import { eventDispatcher } from '@/utils/event';
import { saveViewSettings } from '@/helpers/settings';
import { ParagraphIterator } from '@/utils/paragraph';
import { getParagraphPresentation } from '@/utils/paragraphPresentation';
import { DEFAULT_PARAGRAPH_MODE_CONFIG } from '@/services/constants';
interface UseParagraphModeProps {
@@ -52,6 +53,17 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) =>
const paragraphConfig = getViewSettings(bookKey)?.paragraphMode ?? DEFAULT_PARAGRAPH_MODE_CONFIG;
const getPrimaryContent = useCallback(() => {
const view = viewRef.current;
if (!view) return null;
const contents = view.renderer.getContents();
if (contents.length === 0) return null;
const primaryIndex = view.renderer.primaryIndex;
return contents.find((content) => content.index === primaryIndex) ?? contents[0] ?? null;
}, [viewRef]);
const updateStateFromIterator = useCallback(
(isLoading = false) => {
const iterator = iteratorRef.current;
@@ -88,10 +100,9 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) =>
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] ?? {};
const content = getPrimaryContent();
const { doc, index } = content ?? {};
const docIndex = index ?? view.renderer.primaryIndex;
if (!doc) return false;
currentDocIndexRef.current = docIndex;
@@ -180,7 +191,7 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) =>
initPromiseRef.current = initPromise;
return initPromise;
}, [viewRef, getProgress, updateStateFromIterator]);
}, [getPrimaryContent, viewRef, getProgress, updateStateFromIterator]);
const focusCurrentParagraph = useCallback(async () => {
const view = viewRef.current;
@@ -195,6 +206,13 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) =>
if (focusResetTimerRef.current) {
clearTimeout(focusResetTimerRef.current);
}
const presentation = getParagraphPresentation(
range.startContainer.ownerDocument,
range,
getViewSettings(bookKeyRef.current),
);
isFocusingRef.current = true;
const docIndex = currentDocIndexRef.current;
const renderer = view.renderer as FoliateView['renderer'] & {
@@ -214,8 +232,9 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) =>
range,
index: iterator.currentIndex,
total: iterator.length,
presentation,
});
}, [viewRef]);
}, [getViewSettings, viewRef]);
const waitForNewSection = useCallback(
async (oldIndex: number | undefined, maxAttempts: number = 15): Promise<boolean> => {
@@ -223,15 +242,19 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) =>
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) {
const primaryContent = getPrimaryContent();
if (
primaryContent?.doc &&
view.renderer.primaryIndex >= 0 &&
view.renderer.primaryIndex !== oldIndex
) {
return true;
}
await new Promise((r) => setTimeout(r, 50 * (i + 1)));
}
return false;
},
[viewRef],
[getPrimaryContent, viewRef],
);
const goToNextParagraph = useCallback(async () => {
@@ -0,0 +1,170 @@
import { ViewSettings } from '@/types/book';
export type ParagraphNavAction = 'next' | 'prev';
export type ParagraphNavDirection = 'left' | 'right' | 'up' | 'down';
export type ParagraphNavZone = 'left' | 'right' | 'top' | 'bottom';
export interface ParagraphPresentation {
lang?: string;
dir: 'ltr' | 'rtl';
writingMode: string;
textOrientation?: string;
unicodeBidi?: string;
textAlign?: string;
vertical: boolean;
rtl: boolean;
}
type ParagraphLayoutSource =
| Pick<ViewSettings, 'vertical' | 'rtl' | 'writingMode'>
| Partial<ParagraphPresentation>
| null
| undefined;
const getRangeElement = (range: Range | null | undefined): Element | null => {
if (!range) return null;
const { startContainer, commonAncestorContainer } = range;
if (startContainer.nodeType === Node.ELEMENT_NODE) {
return startContainer as Element;
}
return (
(startContainer.parentElement ?? null) ||
(commonAncestorContainer.nodeType === Node.ELEMENT_NODE
? (commonAncestorContainer as Element)
: null)
);
};
const getClosestAttribute = (element: Element | null, attribute: string): string | undefined => {
const value = element?.closest?.(`[${attribute}]`)?.getAttribute(attribute) ?? undefined;
return value?.trim() || undefined;
};
const normalizeDirection = (direction?: string | null): 'ltr' | 'rtl' | undefined => {
if (direction === 'rtl') return 'rtl';
if (direction === 'ltr') return 'ltr';
return undefined;
};
const pickFirst = (...values: Array<string | null | undefined>): string | undefined => {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return undefined;
};
export const getParagraphLayoutContext = (source?: ParagraphLayoutSource) => {
const writingMode = source?.writingMode || 'horizontal-tb';
const vertical = source?.vertical || writingMode.includes('vertical') || false;
const rtl = source?.rtl || writingMode.endsWith('-rl') || false;
return {
writingMode,
vertical,
rtl,
};
};
export const getParagraphPresentation = (
doc: Document | null | undefined,
range: Range | null | undefined,
viewSettings?: ParagraphLayoutSource,
): ParagraphPresentation => {
const fallback = getParagraphLayoutContext(viewSettings);
const element = getRangeElement(range);
const body = doc?.body ?? null;
const root = doc?.documentElement ?? null;
const view = doc?.defaultView ?? null;
const elementStyle = element && view ? view.getComputedStyle(element) : null;
const bodyStyle = body && view ? view.getComputedStyle(body) : null;
const rootStyle = root && view ? view.getComputedStyle(root) : null;
const writingMode =
pickFirst(
elementStyle?.writingMode,
bodyStyle?.writingMode,
rootStyle?.writingMode,
fallback.writingMode,
) || 'horizontal-tb';
const vertical = writingMode.includes('vertical') || fallback.vertical;
const dir =
normalizeDirection(
pickFirst(
getClosestAttribute(element, 'dir'),
body?.dir,
root?.dir,
elementStyle?.direction,
bodyStyle?.direction,
rootStyle?.direction,
fallback.rtl ? 'rtl' : 'ltr',
),
) || 'ltr';
const rtl = dir === 'rtl' || writingMode.endsWith('-rl') || fallback.rtl;
return {
lang: pickFirst(getClosestAttribute(element, 'lang'), root?.lang, body?.lang),
dir,
writingMode,
textOrientation: pickFirst(elementStyle?.textOrientation, bodyStyle?.textOrientation),
unicodeBidi: pickFirst(elementStyle?.unicodeBidi, bodyStyle?.unicodeBidi),
textAlign: pickFirst(elementStyle?.textAlign, bodyStyle?.textAlign),
vertical,
rtl,
};
};
export const getParagraphButtonDirections = (
source?: ParagraphLayoutSource,
): Record<ParagraphNavAction, ParagraphNavDirection> => {
const layout = getParagraphLayoutContext(source);
if (layout.vertical) {
return { prev: 'up', next: 'down' };
}
return layout.rtl ? { prev: 'right', next: 'left' } : { prev: 'left', next: 'right' };
};
export const getParagraphActionForZone = (
zone: ParagraphNavZone,
source?: ParagraphLayoutSource,
): ParagraphNavAction | null => {
const layout = getParagraphLayoutContext(source);
if (layout.vertical) {
if (zone === 'top') return 'prev';
if (zone === 'bottom') return 'next';
return null;
}
if (zone === 'left') return layout.rtl ? 'next' : 'prev';
if (zone === 'right') return layout.rtl ? 'prev' : 'next';
return null;
};
export const getParagraphActionForKey = (
key: string,
source?: ParagraphLayoutSource,
): ParagraphNavAction | null => {
const layout = getParagraphLayoutContext(source);
if (key === ' ' || key.toLowerCase() === 'j') return 'next';
if (key.toLowerCase() === 'k') return 'prev';
if (layout.vertical) {
if (key === 'ArrowDown') return 'next';
if (key === 'ArrowUp') return 'prev';
if (key === 'ArrowLeft') return layout.writingMode.endsWith('-rl') ? 'next' : 'prev';
if (key === 'ArrowRight') return layout.writingMode.endsWith('-rl') ? 'prev' : 'next';
return null;
}
if (key === 'ArrowDown') return 'next';
if (key === 'ArrowUp') return 'prev';
if (key === 'ArrowLeft') return layout.rtl ? 'next' : 'prev';
if (key === 'ArrowRight') return layout.rtl ? 'prev' : 'next';
return null;
};