forked from akai/readest
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:
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user