- {exitingParagraph && !isChangingSection && (
-
- )}
+
{activeParagraph ? (
-
+
) : isChangingSection ? (
) : null}
diff --git a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts
index cb0c37e5..cca2efa0 100644
--- a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts
+++ b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts
@@ -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;
diff --git a/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts b/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts
index b96f0e54..c12387db 100644
--- a/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts
+++ b/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts
@@ -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
=> {
@@ -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 () => {
diff --git a/apps/readest-app/src/utils/paragraphPresentation.ts b/apps/readest-app/src/utils/paragraphPresentation.ts
new file mode 100644
index 00000000..411e7299
--- /dev/null
+++ b/apps/readest-app/src/utils/paragraphPresentation.ts
@@ -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
+ | Partial
+ | 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 | 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 => {
+ 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;
+};