75f1fafe9f
* feat(reader): slide and page curl turn animations (#555) Add an Animation Style setting (Push, Slide, Page Curl) next to the Paging Animation switch. Slide moves the turning page over the still previous or next page like the Apple Books slide; Page Curl folds it open in 3D so the page underneath is partially visible as it turns. Both styles track the finger: the page follows a horizontal drag and commits past halfway or on a flick, or settles back. The page header and footer stay in place while the page turns. The styles layer a View Transitions snapshot of the outgoing page over the live, stationary incoming page, since the pages of one section live in a single iframe and can never be on screen twice. They work for all writing modes including vertical-rl, and on engines without the View Transitions API (older WebViews) the paginator falls back to the existing push animation, so all platforms keep working page turns. The paginator changes live in the foliate-js submodule; this bumps the pointer, wires viewSettings.pageTurnStyle to the renderer turn-style attribute, and adds browser tests covering slide layering, curl, vertical-rl, finger tracking with commit and revert, and the push fallback. Fixes #555 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add WebGL page curl renderer for mesh turn animations (#555) Grid mesh deformed around a cylinder: content past the fold wraps over and lands mirrored on top with a whitened page back, transparent where the page has curled away. Corner grabs start as a steep diagonal pinch that straightens as the turn completes so the whole page clears by the end. Groundwork for the Tauri mesh curl; capture and orchestration land separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(native-bridge): capture webview region as PNG on macOS and iOS (#555) New capture_webview_region plugin command returns a binary PNG snapshot of the calling webview (tauri::ipc::Response, no JSON overhead) for the mesh page-curl texture. macOS goes through WKWebView takeSnapshotWithConfiguration via with_webview on the main thread with a 500ms timeout; iOS snapshots in Swift and hands the PNG across the JSON-only plugin boundary base64-encoded, decoded back to bytes in mobile.rs. Windows, Linux, and Android reject for now so the JS side falls back to the CSS curl. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): drive the mesh page curl on Tauri platforms (#555) Wire the WebGL curl renderer and the native webview capture into page turns. A MeshCurlTurn controller runs the pipeline per turn: snapshot the content box, overlay the captured page drawn flat, turn the live view instantly underneath (the paginator's animated paths all gate on the animated attribute), then curl the capture away. Backward turns mirror the fold to the spine edge, matching the layered VT curl's old-page-recedes choreography. useMeshPageCurl wraps the view's prev/next so taps, keys, and wheel turns all curl, and registers a touch interceptor (between the reading ruler and the fixed-layout swipe) that scrubs the curl from the finger, committing past halfway or on a flick and otherwise un-curling and turning back under the overlay. The paginator stays out of the way via no-swipe while the mesh is active; if the native capture ever fails the session falls back to the paginator's CSS arc-fold curl and the shared applyPageTurnAttributes helper restores turn-style. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(build): restore iOS builds on Xcode 26.2 with a vendored swift-rs Swift 6.2's driver no longer honors swift-rs 1.0.7's cross-compilation style (swift build --arch <host> with per-swiftc -target overrides and an inherited SDKROOT): plugin sources compile against the wrong platform's Swift overlays and fail with baffling errors like type 'Bundle' has no member 'main' and extra argument 'privacy' in call. Upstream swift-rs is unmaintained, so vendor it under packages/swift-rs via a crates-io patch and build with SPM's first-class --triple/--sdk flags instead, dropping the leaked SDKROOT so the host-targeted manifest compile stays clean. Artifacts land in the unversioned-triple directory now, so the link search path follows. With --triple, SPM enforces the deployment floor declared in Package.swift (the old override bypassed it): bump native-bridge to iOS 15.0, matching the app's deployment target, since StoreKit's Storefront is used unguarded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(native-bridge): capture webview region on Android via PixelCopy (#555) Implements the Android side of capture_webview_region so the mesh page curl works there too. The Kotlin command scales the CSS-pixel rect by the display density, offsets it by the webview's window position, and reads the pixels back from the window surface with PixelCopy (API 26+, the app's minSdk), which includes the hardware-accelerated WebView that View.draw would miss. PNG encoding runs off the main thread and the result crosses the JSON plugin boundary base64-encoded, decoded back to bytes in mobile.rs like iOS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): right the upside-down page curl on iOS (#555) The renderer oriented its texture with UNPACK_FLIP_Y_WEBGL, which WebKit ignores for ImageBitmap uploads: on iOS the captured page rendered upside down, and the mirrored page back read as rotated 180 degrees instead of the ink-through-paper horizontal mirror Apple Books shows. Upload unflipped and sample page coordinates directly so no pixel-store flag is involved. The page texture in the browser test was only horizontally asymmetric, which is how the flip slipped through; it now uses four quadrants fed through the production PNG-blob-to-ImageBitmap path and pins the vertical orientation. Verified red/green by running the suite on Playwright WebKit, which reproduces the iOS behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): curl the whole page including header, footer, and margins (#555) The mesh curl captured only the margin-inset content box, leaving the running header, footer, and page margins static while just the text column turned. A physical page turn takes the whole sheet with it, as Apple Books does, so the capture and overlay now span the full reader cell. The overlay mounts above the in-cell header (z-10) and footer, so the static copies never show through the turning page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): gate layered View Transition turns and slide from a capture instead (#555) iOS 18 WebKit ships document.startViewTransition but crashes the WebContent process when a page-turn transition snapshots the reader, so the mere presence of the API is not enough for the layered slide/curl turns. Require nested view-transition groups (Chrome/WebView 140+) as the marker of a mature engine before setting turn-style on the renderer. Engines that fail the check no longer lose the slide on Tauri: the mesh curl's capture pipeline generalizes to CapturedPageTurn and now also drives a flat slide overlay (capture the outgoing page, turn instantly underneath, translate the captured page out toward the spine, mirrored for backward turns), clipped to the content box with an edge shadow like the VT slide. On the web, engines without full support fall back to push and the Slide/Page Curl options are hidden from the Animation Style select; a synced slide/curl setting from another device reads as Push there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): make the Android page curl start instantly (#555) The Android capture encoded a full-density PNG: 1080x2400 on a 3x Xiaomi 13 took ~1.5s per turn, so the page sat frozen long enough to read as the curl not working at all. Encode JPEG instead (the page is opaque) and cap the destination bitmap at 2x CSS pixels - PixelCopy scales into a smaller bitmap for free and the moving page stays sharp. Measured on device over CDP: the capture invoke drops from 1550ms to 34ms and the curl overlay mounts 132ms after the tap. The JS side stops hardcoding an image/png blob type and lets the decoder sniff the platform's actual format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(reader): encode iOS page-curl captures as capped JPEG (#555) Apply the Android speedup to iOS: encode the snapshot as JPEG (the page is opaque) off the main thread, and cap it at 2x CSS pixels via WKSnapshotConfiguration.snapshotWidth on 3x screens, cutting both the encode time and the base64 payload crossing the JSON plugin boundary. The JS side already sniffs the image format from the bytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
975 lines
38 KiB
TypeScript
975 lines
38 KiB
TypeScript
import clsx from 'clsx';
|
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { useSearchParams } from 'next/navigation';
|
|
import { convertBlobUrlToDataUrl, BookDoc, getDirection } from '@/libs/document';
|
|
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
|
|
import { BookConfig, PageInfo } from '@/types/book';
|
|
import { FoliateView, wrappedFoliateView } from '@/types/view';
|
|
import { Insets } from '@/types/misc';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useThemeStore } from '@/store/themeStore';
|
|
import { useReaderStore } from '@/store/readerStore';
|
|
import { useBookDataStore } from '@/store/bookDataStore';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useCustomFontStore } from '@/store/customFontStore';
|
|
import { useParallelViewStore } from '@/store/parallelViewStore';
|
|
import { useMouseEvent, useTouchEvent, useOpenMediaEvent } from '../hooks/useIframeEvents';
|
|
import { useCapturedTurn, applyPageTurnAttributes } from '../hooks/useCapturedTurn';
|
|
import { useBrightnessGesture } from '../hooks/useBrightnessGesture';
|
|
import BrightnessOverlay from './BrightnessOverlay';
|
|
import { usePagination, viewPagination } from '../hooks/usePagination';
|
|
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
|
import { useProgressSync } from '../hooks/useProgressSync';
|
|
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
|
|
import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
|
|
import { useAutoFocus } from '@/hooks/useAutoFocus';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useEinkMode } from '@/hooks/useEinkMode';
|
|
import { useKOSync } from '../hooks/useKOSync';
|
|
import { useFileSync } from '../hooks/useFileSync';
|
|
import {
|
|
applyFixedlayoutStyles,
|
|
applyImageStyle,
|
|
applyScrollbarStyle,
|
|
applyScrollModeClass,
|
|
applyThemeModeClass,
|
|
applyTranslationStyle,
|
|
getStyles,
|
|
getThemeCode,
|
|
keepTextAlignment,
|
|
transformStylesheet,
|
|
} from '@/utils/style';
|
|
import { applyScrollableStyle, applyTableTouchScroll } from '@/utils/scrollable';
|
|
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
|
|
import { layoutWarichu, relayoutWarichu } from '@/utils/warichu';
|
|
import { refreshSectionGlosses } from '@/app/reader/utils/wordlensSection';
|
|
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
|
|
import { getIndexFromCfi } from '@/utils/cfi';
|
|
import { useUICSS } from '@/hooks/useUICSS';
|
|
import {
|
|
handleKeydown,
|
|
handleKeyup,
|
|
handleMousedown,
|
|
handleMouseup,
|
|
handleClick,
|
|
handleWheel,
|
|
handleTouchStart,
|
|
handleTouchMove,
|
|
handleTouchEnd,
|
|
addLongPressListeners,
|
|
} from '../utils/iframeEventHandlers';
|
|
import { getMaxInlineSize } from '@/utils/config';
|
|
import { getDirFromUILanguage } from '@/utils/rtl';
|
|
import { isTauriAppPlatform } from '@/services/environment';
|
|
import { TransformContext } from '@/services/transformers/types';
|
|
import { transformContent } from '@/services/transformService';
|
|
import { lockScreenOrientation } from '@/utils/bridge';
|
|
import { useTextTranslation } from '../hooks/useTextTranslation';
|
|
import { useBookCoverAutoSave } from '../hooks/useAutoSaveBookCover';
|
|
import { useDiscordPresence } from '@/hooks/useDiscordPresence';
|
|
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
|
|
import { getViewInsets } from '@/utils/insets';
|
|
import { handleA11yNavigation } from '@/utils/a11y';
|
|
import { isCJKLang } from '@/utils/lang';
|
|
import { getLocale } from '@/utils/misc';
|
|
import { isMetered } from '@/utils/network';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { isFontType } from '@/utils/font';
|
|
import { getScrollGapAttr } from '@/utils/webtoon';
|
|
import { ParagraphControl } from './paragraph';
|
|
import Spinner from '@/components/Spinner';
|
|
import KOSyncConflictResolver from './KOSyncResolver';
|
|
import ImageViewer from './ImageViewer';
|
|
import TableViewer from './TableViewer';
|
|
|
|
declare global {
|
|
interface Window {
|
|
eval(script: string): void;
|
|
}
|
|
}
|
|
|
|
const FoliateViewer: React.FC<{
|
|
bookKey: string;
|
|
bookDoc: BookDoc;
|
|
config: BookConfig;
|
|
gridInsets: Insets;
|
|
contentInsets: Insets;
|
|
}> = ({ bookKey, bookDoc, config, gridInsets, contentInsets: insets }) => {
|
|
const _ = useTranslation();
|
|
const searchParams = useSearchParams();
|
|
const { appService, envConfig } = useEnv();
|
|
const { themeCode, isDarkMode } = useThemeStore();
|
|
const { settings } = useSettingsStore();
|
|
const { loadFont, loadCustomFonts, getLoadedFonts, getAvailableFonts } = useCustomFontStore();
|
|
// Per-field selectors — see store/readerProgressStore.ts header for the
|
|
// "destructure-subscribes-the-whole-store" rationale.
|
|
const getView = useReaderStore((s) => s.getView);
|
|
const setFoliateView = useReaderStore((s) => s.setView);
|
|
const setViewInited = useReaderStore((s) => s.setViewInited);
|
|
const setProgress = useReaderStore((s) => s.setProgress);
|
|
const setPreviewMode = useReaderStore((s) => s.setPreviewMode);
|
|
const getViewState = useReaderStore((s) => s.getViewState);
|
|
const getProgress = useReaderStore((s) => s.getProgress);
|
|
const getViewSettings = useReaderStore((s) => s.getViewSettings);
|
|
const setViewSettings = useReaderStore((s) => s.setViewSettings);
|
|
const getParallels = useParallelViewStore((s) => s.getParallels);
|
|
const getBookData = useBookDataStore((s) => s.getBookData);
|
|
const { applyBackgroundTexture } = useBackgroundTexture();
|
|
const { applyEinkMode } = useEinkMode();
|
|
const { registerBrightnessListeners, overlayVisible, overlayLevel } =
|
|
useBrightnessGesture(bookKey);
|
|
const bookData = getBookData(bookKey);
|
|
const viewState = getViewState(bookKey);
|
|
const viewSettings = getViewSettings(bookKey);
|
|
|
|
const viewRef = useRef<FoliateView | null>(null);
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const isViewCreated = useRef(false);
|
|
const doubleClickDisabled = useRef(!!viewSettings?.disableDoubleClick);
|
|
const [toastMessage, setToastMessage] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [scrollMargins, setScrollMargins] = useState({ top: 0, bottom: 0 });
|
|
const docLoaded = useRef(false);
|
|
|
|
useAutoFocus<HTMLDivElement>({ ref: containerRef });
|
|
|
|
useDiscordPresence(
|
|
bookData?.book || null,
|
|
!!viewState?.isPrimary,
|
|
settings.discordRichPresenceEnabled,
|
|
);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setToastMessage(''), 2000);
|
|
return () => clearTimeout(timer);
|
|
}, [toastMessage]);
|
|
|
|
useUICSS(bookKey);
|
|
useProgressSync(bookKey);
|
|
useProgressAutoSave(bookKey);
|
|
useBookCoverAutoSave(bookKey);
|
|
const { syncState, conflictDetails, resolveWithLocal, resolveWithRemote } = useKOSync(bookKey);
|
|
useFileSync(bookKey);
|
|
useTextTranslation(bookKey, viewRef.current);
|
|
|
|
// Coalesce setProgress writes within a single animation frame.
|
|
//
|
|
// Why: foliate fires `relocate` multiple times during a swipe burst
|
|
// (one per snap step / intermediate stabilize). Each call ends up in
|
|
// `setProgress`, which writes to readerProgressStore + bookDataStore.
|
|
// Even after we split progress into its own store, running the writes
|
|
// back-to-back on the same frame is still wasted work — only the
|
|
// last detail in the burst is what the user sees on screen.
|
|
//
|
|
// Earlier this used requestIdleCallback to defer the commit further,
|
|
// but profiling on Android showed Fire Idle Callback ballooning to
|
|
// 2.0+ seconds of total time per ~28 s session: rIC backed up under
|
|
// sustained pressure and dumped the whole queue into the post-swipe
|
|
// pause, producing exactly the "feels sluggish right after I let go"
|
|
// jank we were trying to fix. rAF runs once per frame, gets scheduled
|
|
// by the browser's normal vsync loop, and doesn't accumulate when
|
|
// the page is busy — which is the behaviour we want here.
|
|
const pendingRelocateRef = useRef<CustomEvent | null>(null);
|
|
const relocateRafRef = useRef<number | null>(null);
|
|
const cancelRelocateScheduled = useCallback(() => {
|
|
const id = relocateRafRef.current;
|
|
if (id == null) return;
|
|
relocateRafRef.current = null;
|
|
cancelAnimationFrame(id);
|
|
}, []);
|
|
const commitRelocate = useCallback(() => {
|
|
relocateRafRef.current = null;
|
|
const event = pendingRelocateRef.current;
|
|
pendingRelocateRef.current = null;
|
|
if (!event) return;
|
|
const detail = event.detail;
|
|
const atEnd = viewRef.current?.renderer.atEnd || false;
|
|
const { current, next, total } = detail.location as PageInfo;
|
|
const currentPage = atEnd && total > 0 ? total - 1 : current;
|
|
const pageInfo = { current: currentPage, next, total };
|
|
setProgress(
|
|
bookKey,
|
|
detail.cfi,
|
|
detail.tocItem,
|
|
detail.pageItem,
|
|
detail.section,
|
|
pageInfo,
|
|
detail.time,
|
|
detail.range,
|
|
detail.fraction,
|
|
);
|
|
}, [bookKey, setProgress]);
|
|
|
|
const progressRelocateHandler = (event: Event) => {
|
|
// Always stash the latest detail; if another rAF is already pending
|
|
// it'll pick this up and the intermediate states are skipped.
|
|
pendingRelocateRef.current = event as CustomEvent;
|
|
// requestAnimationFrame is paused while the WebView is backgrounded, so the
|
|
// rAF-coalesced commit below would never run during background TTS - which
|
|
// freezes book.progress (and readerProgressStore, and the home-screen
|
|
// widget that reads them). Commit synchronously when hidden so progress
|
|
// stays current. The page-follow relocate still fires; only the commit was
|
|
// being deferred.
|
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
|
if (relocateRafRef.current != null) {
|
|
cancelAnimationFrame(relocateRafRef.current);
|
|
relocateRafRef.current = null;
|
|
}
|
|
commitRelocate();
|
|
return;
|
|
}
|
|
if (relocateRafRef.current != null) return;
|
|
relocateRafRef.current = requestAnimationFrame(commitRelocate);
|
|
};
|
|
|
|
useEffect(() => {
|
|
// On unmount: flush any pending commit synchronously before tearing
|
|
// down — otherwise the last page-turn before the user closes the
|
|
// book could be lost. Then cancel the scheduled handle to be safe
|
|
// against double-fire.
|
|
return () => {
|
|
if (pendingRelocateRef.current) {
|
|
try {
|
|
commitRelocate();
|
|
} catch {
|
|
// Tearing down — last-effort save shouldn't crash the unmount
|
|
}
|
|
}
|
|
cancelRelocateScheduled();
|
|
pendingRelocateRef.current = null;
|
|
};
|
|
}, [cancelRelocateScheduled, commitRelocate]);
|
|
|
|
const getDocTransformHandler = ({ width, height }: { width: number; height: number }) => {
|
|
return (event: Event) => {
|
|
const { detail } = event as CustomEvent;
|
|
detail.data = Promise.resolve(detail.data)
|
|
.then((data) => {
|
|
const viewSettings = getViewSettings(bookKey);
|
|
const bookData = getBookData(bookKey);
|
|
if (viewSettings && detail.type === 'text/css')
|
|
return transformStylesheet(data, width, height, viewSettings.vertical);
|
|
const isHtml = detail.type === 'application/xhtml+xml' || detail.type === 'text/html';
|
|
if (viewSettings && bookData && isHtml) {
|
|
const ctx: TransformContext = {
|
|
bookKey,
|
|
viewSettings,
|
|
width,
|
|
height,
|
|
isFixedLayout: bookData.isFixedLayout,
|
|
primaryLanguage: bookData.book?.primaryLanguage,
|
|
userLocale: getLocale(),
|
|
content: data,
|
|
sectionHref: detail.name,
|
|
transformers: [
|
|
'style',
|
|
'punctuation',
|
|
'footnote',
|
|
'whitespace',
|
|
'language',
|
|
'sanitizer',
|
|
'simplecc',
|
|
'nbsp',
|
|
'proofread',
|
|
'warichu',
|
|
],
|
|
};
|
|
return Promise.resolve(transformContent(ctx));
|
|
}
|
|
return data;
|
|
})
|
|
.catch((e) => {
|
|
console.error(new Error(`Failed to load ${detail.name}`, { cause: e }));
|
|
return '';
|
|
});
|
|
};
|
|
};
|
|
|
|
const skipToReadingPosition = useCallback(() => {
|
|
const view = getView(bookKey);
|
|
const progress = getProgress(bookKey);
|
|
if (view && progress) {
|
|
view.renderer.scrollToAnchor?.(progress.range);
|
|
}
|
|
}, [getView, getProgress, bookKey]);
|
|
|
|
const skipToNextSection = useCallback(() => {
|
|
const view = getView(bookKey);
|
|
const viewSettings = getViewSettings(bookKey);
|
|
viewPagination(view, viewSettings, 'down', 'section');
|
|
}, [bookKey]);
|
|
|
|
const docLoadHandler = (event: Event) => {
|
|
docLoaded.current = true;
|
|
if (bookDoc.rendition?.layout === 'pre-paginated') {
|
|
setLoading(false); // Fixed layout doesn't emit 'stabilized' event
|
|
}
|
|
const detail = (event as CustomEvent).detail;
|
|
console.log('doc index loaded:', detail.index);
|
|
if (detail.doc) {
|
|
const renderer = viewRef.current?.renderer;
|
|
const writingDir = renderer?.setStyles && getDirection(detail.doc);
|
|
const viewSettings = getViewSettings(bookKey)!;
|
|
const bookData = getBookData(bookKey)!;
|
|
|
|
const newVertical =
|
|
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
|
|
const newRtl =
|
|
writingDir?.rtl ||
|
|
getDirFromUILanguage() === 'rtl' ||
|
|
viewSettings.writingMode.includes('rl') ||
|
|
false;
|
|
if (viewSettings.vertical !== newVertical || viewSettings.rtl !== newRtl) {
|
|
viewSettings.vertical = newVertical;
|
|
viewSettings.rtl = newRtl;
|
|
setViewSettings(bookKey, { ...viewSettings });
|
|
}
|
|
|
|
if (!bookData?.isFixedLayout) {
|
|
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
|
|
}
|
|
|
|
getLoadedFonts().forEach((font) => {
|
|
mountCustomFont(detail.doc, font);
|
|
});
|
|
|
|
if (bookDoc.rendition?.layout === 'pre-paginated') {
|
|
applyFixedlayoutStyles(detail.doc, viewSettings);
|
|
const themeCode = getThemeCode();
|
|
if (bookData.book?.format === 'PDF' && themeCode && renderer) {
|
|
renderer.pageColors = viewSettings.applyThemeToPDF
|
|
? {
|
|
background: themeCode.bg,
|
|
foreground: themeCode.fg,
|
|
}
|
|
: undefined;
|
|
}
|
|
}
|
|
|
|
applyImageStyle(detail.doc);
|
|
applyScrollableStyle(detail.doc);
|
|
applyTableTouchScroll(detail.doc);
|
|
applyThemeModeClass(detail.doc, isDarkMode);
|
|
applyScrollModeClass(detail.doc, viewSettings.scrolled || false);
|
|
applyScrollbarStyle(document, viewSettings.hideScrollbar || false);
|
|
keepTextAlignment(detail.doc);
|
|
handleA11yNavigation(viewRef.current, detail.doc, {
|
|
skipToLastPosCallback: skipToReadingPosition,
|
|
skipToLastPosLabel: _('Skip to last reading position'),
|
|
skipToNextSectionCallback: skipToNextSection,
|
|
skipToNextSectionLabel: _('End of this section. Continue to the next.'),
|
|
});
|
|
|
|
// Inline scripts in tauri platforms are not executed by default
|
|
if (viewSettings.allowScript && isTauriAppPlatform()) {
|
|
evalInlineScripts(detail.doc);
|
|
}
|
|
|
|
// only call on load if we have highlighting turned on.
|
|
if (viewSettings.codeHighlighting) {
|
|
manageSyntaxHighlighting(detail.doc, viewSettings);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
const sectionIndex = detail.index;
|
|
const booknotes = config.booknotes || [];
|
|
booknotes
|
|
.filter(
|
|
(item) =>
|
|
!item.deletedAt &&
|
|
item.type === 'annotation' &&
|
|
item.style &&
|
|
getIndexFromCfi(item.cfi) === sectionIndex,
|
|
)
|
|
.map((annotation) => {
|
|
try {
|
|
viewRef.current?.addAnnotation(annotation);
|
|
} catch (err) {
|
|
console.warn('Failed to add annotation', { annotation, error: err });
|
|
}
|
|
});
|
|
}, 100);
|
|
|
|
if (!detail.doc.isEventListenersAdded) {
|
|
// listened events in iframes are posted to the main window
|
|
// and then used by useMouseEvent and useTouchEvent
|
|
// and more gesture events can be detected in the iframeEventHandlers
|
|
detail.doc.isEventListenersAdded = true;
|
|
detail.doc.addEventListener('keydown', handleKeydown.bind(null, bookKey));
|
|
detail.doc.addEventListener('keyup', handleKeyup.bind(null, bookKey));
|
|
detail.doc.addEventListener('mousedown', handleMousedown.bind(null, bookKey));
|
|
detail.doc.addEventListener('mouseup', handleMouseup.bind(null, bookKey));
|
|
detail.doc.addEventListener(
|
|
'click',
|
|
handleClick.bind(null, bookKey, doubleClickDisabled, !!bookData?.isFixedLayout),
|
|
);
|
|
detail.doc.addEventListener('wheel', handleWheel.bind(null, bookKey));
|
|
detail.doc.addEventListener('touchstart', handleTouchStart.bind(null, bookKey));
|
|
detail.doc.addEventListener('touchmove', handleTouchMove.bind(null, bookKey));
|
|
detail.doc.addEventListener('touchend', handleTouchEnd.bind(null, bookKey));
|
|
addLongPressListeners(bookKey, detail.doc);
|
|
registerBrightnessListeners(detail.doc);
|
|
}
|
|
}
|
|
};
|
|
|
|
const evalInlineScripts = (doc: Document) => {
|
|
if (doc.defaultView && doc.defaultView.frameElement) {
|
|
const iframe = doc.defaultView.frameElement as HTMLIFrameElement;
|
|
const scripts = doc.querySelectorAll('script:not([src])');
|
|
scripts.forEach((script, index) => {
|
|
const scriptContent = script.textContent || script.innerHTML;
|
|
try {
|
|
console.warn('Evaluating inline scripts in iframe');
|
|
iframe.contentWindow?.eval(scriptContent);
|
|
} catch (error) {
|
|
console.error(`Error executing iframe script ${index + 1}:`, error);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// Build the Word Lens refresh context: gate silent auto-download on the global
|
|
// toggle AND a best-effort metered-connection check, and show a single
|
|
// "Downloading…" toast on the first progress tick (the per-percent progress
|
|
// lives in the Word Lens settings panel). `wordLensToastShownRef` de-dupes the
|
|
// toast across the multiple section docs a refresh pass touches.
|
|
const wordLensToastShownRef = useRef(false);
|
|
const buildWordLensCtx = (bookLang?: string | null) => {
|
|
// Read the live setting (not the first-render `settings` snapshot closed over
|
|
// by the empty-deps `stabilizedHandler`) so toggling Auto-download mid-session
|
|
// takes effect on the next section refresh.
|
|
const liveSettings = useSettingsStore.getState().settings;
|
|
const allowDownload =
|
|
(liveSettings.globalReadSettings.wordLensAutoDownload ?? true) && !isMetered();
|
|
return {
|
|
appService: appService!,
|
|
bookLang,
|
|
appLang: getLocale().split('-')[0] || 'en',
|
|
allowDownload,
|
|
onProgress: () => {
|
|
if (wordLensToastShownRef.current) return;
|
|
wordLensToastShownRef.current = true;
|
|
eventDispatcher.dispatch('toast', {
|
|
type: 'info',
|
|
message: _('Downloading Word Lens data…'),
|
|
});
|
|
},
|
|
};
|
|
};
|
|
|
|
const stabilizedHandler = useCallback(() => {
|
|
setLoading(false);
|
|
// Layout/relayout warichu after paginator has set column-width via columnize()
|
|
const contents = viewRef.current?.renderer?.getContents?.() || [];
|
|
const vs = getViewSettings(bookKey);
|
|
const bookLang = getBookData(bookKey)?.book?.primaryLanguage;
|
|
// Fixed-layout (pre-paginated) books have no reflow room; injecting ruby
|
|
// would overflow their fixed boxes, so skip Word Lens glosses there.
|
|
const isFixedLayout = bookDoc.rendition?.layout === 'pre-paginated';
|
|
for (const { doc } of contents) {
|
|
if (doc) {
|
|
const hasPending = doc.querySelectorAll('.warichu-pending').length > 0;
|
|
const hasExisting = doc.querySelectorAll('.warichu-head').length > 0;
|
|
if (hasPending) {
|
|
layoutWarichu(doc);
|
|
} else if (hasExisting) {
|
|
relayoutWarichu(doc);
|
|
}
|
|
if (vs && appService && !isFixedLayout) {
|
|
void refreshSectionGlosses(doc, vs, buildWordLensCtx(bookLang));
|
|
}
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const docRelocateHandler = (event: Event) => {
|
|
const detail = (event as CustomEvent).detail;
|
|
if (detail.reason !== 'scroll' && detail.reason !== 'page') return;
|
|
|
|
// First user-initiated navigation after a deep-link landing — promote
|
|
// the preview into the real reading position. Subsequent progress writes
|
|
// can flow normally.
|
|
setPreviewMode(bookKey, false);
|
|
|
|
const parallelViews = getParallels(bookKey);
|
|
if (parallelViews && parallelViews.size > 0) {
|
|
parallelViews.forEach((key) => {
|
|
if (key !== bookKey) {
|
|
const target = getView(key)?.renderer;
|
|
if (target) {
|
|
target.goTo?.({ index: detail.index, anchor: detail.fraction });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const { handlePageFlip } = usePagination(bookKey, viewRef, containerRef);
|
|
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip);
|
|
const touchHandlers = useTouchEvent(bookKey);
|
|
|
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
|
const [selectedTableHtml, setSelectedTableHtml] = useState<string | null>(null);
|
|
const [imageList, setImageList] = useState<{ src: string; cfi: string | null }[]>([]);
|
|
const [currentImageIndex, setCurrentImageIndex] = useState<number>(0);
|
|
|
|
const handleImagePress = useCallback(async (src: string) => {
|
|
try {
|
|
// Get all images from the current document
|
|
const docs = viewRef.current?.renderer.getContents();
|
|
const allImages: { src: string; cfi: string | null }[] = [];
|
|
|
|
docs?.forEach(({ doc, index }) => {
|
|
const elements = doc.querySelectorAll('img, svg');
|
|
elements.forEach((el) => {
|
|
if (index === undefined) return;
|
|
if (el.localName === 'img') {
|
|
const img = el as HTMLImageElement;
|
|
if (img.src && img.parentNode) {
|
|
const range = doc.createRange();
|
|
range.selectNodeContents(img);
|
|
const cfi = viewRef.current?.getCFI(index, range) || null;
|
|
allImages.push({ src: img.src, cfi });
|
|
}
|
|
} else if (el.localName === 'svg') {
|
|
const svg = el as unknown as SVGSVGElement;
|
|
const svgImage = svg.querySelector('image');
|
|
const href =
|
|
svgImage?.getAttribute('href') ||
|
|
svgImage?.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
|
|
if (href) {
|
|
const range = doc.createRange();
|
|
range.selectNodeContents(svg);
|
|
const cfi = viewRef.current?.getCFI(index, range) || null;
|
|
allImages.push({ src: href, cfi });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Find the index of the pressed image
|
|
const index = allImages.findIndex((img) => img.src === src);
|
|
|
|
setImageList(allImages);
|
|
setCurrentImageIndex(index >= 0 ? index : 0);
|
|
|
|
const dataUrl = await convertBlobUrlToDataUrl(src);
|
|
setSelectedImage(dataUrl);
|
|
} catch (error) {
|
|
console.error('Failed to load image:', error);
|
|
}
|
|
}, []);
|
|
|
|
const handleTablePress = useCallback((html: string) => {
|
|
setSelectedTableHtml(html);
|
|
}, []);
|
|
|
|
const handlePreviousImage = useCallback(async () => {
|
|
if (currentImageIndex > 0 && imageList.length > 0) {
|
|
const newIndex = currentImageIndex - 1;
|
|
setCurrentImageIndex(newIndex);
|
|
try {
|
|
const { src, cfi } = imageList[newIndex]!;
|
|
const dataUrl = await convertBlobUrlToDataUrl(src);
|
|
setSelectedImage(dataUrl);
|
|
if (cfi && viewRef.current) {
|
|
viewRef.current?.goTo(cfi);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load previous image:', error);
|
|
}
|
|
}
|
|
}, [currentImageIndex, imageList]);
|
|
|
|
const handleNextImage = useCallback(async () => {
|
|
if (currentImageIndex < imageList.length - 1 && imageList.length > 0) {
|
|
const newIndex = currentImageIndex + 1;
|
|
setCurrentImageIndex(newIndex);
|
|
try {
|
|
const { src, cfi } = imageList[newIndex]!;
|
|
const dataUrl = await convertBlobUrlToDataUrl(src);
|
|
setSelectedImage(dataUrl);
|
|
if (cfi && viewRef.current) {
|
|
viewRef.current?.goTo(cfi);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load next image:', error);
|
|
}
|
|
}
|
|
}, [currentImageIndex, imageList]);
|
|
|
|
const handleCloseImage = useCallback(() => {
|
|
setSelectedImage(null);
|
|
setImageList([]);
|
|
setCurrentImageIndex(0);
|
|
}, []);
|
|
|
|
useOpenMediaEvent(bookKey, handleImagePress, handleTablePress);
|
|
|
|
useCapturedTurn(bookKey, viewRef);
|
|
|
|
useFoliateEvents(viewRef.current, {
|
|
onLoad: docLoadHandler,
|
|
onStabilized: stabilizedHandler,
|
|
onRelocate: progressRelocateHandler,
|
|
onRendererRelocate: docRelocateHandler,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (isViewCreated.current) return;
|
|
isViewCreated.current = true;
|
|
|
|
setTimeout(() => setLoading(true), 200);
|
|
|
|
const openBook = async () => {
|
|
console.log('Opening book', bookKey);
|
|
await import('foliate-js/view.js');
|
|
const view = wrappedFoliateView(document.createElement('foliate-view') as FoliateView);
|
|
view.id = `foliate-view-${bookKey}`;
|
|
containerRef.current?.appendChild(view);
|
|
|
|
const viewSettings = getViewSettings(bookKey)!;
|
|
const writingMode = viewSettings.writingMode;
|
|
if (writingMode) {
|
|
const settingsDir = getBookDirFromWritingMode(writingMode);
|
|
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
|
|
if (settingsDir !== 'auto') {
|
|
bookDoc.dir = settingsDir;
|
|
} else if (languageDir !== 'auto') {
|
|
bookDoc.dir = languageDir;
|
|
}
|
|
}
|
|
|
|
if (bookDoc.rendition?.layout === 'pre-paginated' && bookDoc.sections) {
|
|
bookDoc.rendition.spread = viewSettings.spreadMode;
|
|
const coverSide = bookDoc.dir === 'rtl' ? 'right' : 'left';
|
|
bookDoc.sections[0]!.pageSpread = viewSettings.keepCoverSpread ? '' : coverSide;
|
|
}
|
|
|
|
await view.open(bookDoc);
|
|
// make sure we can listen renderer events after opening book
|
|
viewRef.current = view;
|
|
setFoliateView(bookKey, view);
|
|
|
|
const { book } = view;
|
|
|
|
book.transformTarget?.addEventListener('load', async (event: Event) => {
|
|
const { detail } = event as CustomEvent<{
|
|
isScript: boolean;
|
|
type: string;
|
|
href: string;
|
|
url?: string;
|
|
allow?: boolean;
|
|
}>;
|
|
if (detail.isScript) {
|
|
detail.allow = viewSettings.allowScript ?? false;
|
|
}
|
|
if (isFontType(detail.type) && detail.href?.startsWith('fonts/')) {
|
|
const fontFileName = detail.href.split('/').pop()?.toLowerCase();
|
|
getAvailableFonts().forEach(async (font) => {
|
|
const customFontFileName = font.path.split('/').pop()?.toLowerCase();
|
|
if (fontFileName && fontFileName === customFontFileName) {
|
|
if (!font.loaded) {
|
|
const loadedFont = await loadFont(envConfig, font.id);
|
|
font.blobUrl = loadedFont?.blobUrl;
|
|
}
|
|
if (font.blobUrl) {
|
|
detail.url = font.blobUrl;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
const viewWidth = appService?.isMobile ? screen.width : window.innerWidth;
|
|
const viewHeight = appService?.isMobile ? screen.height : window.innerHeight;
|
|
const width = viewWidth - insets.left - insets.right;
|
|
const height = viewHeight - insets.top - insets.bottom;
|
|
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
|
|
view.renderer.setStyles?.(getStyles(viewSettings, undefined, getLoadedFonts()));
|
|
applyTranslationStyle(viewSettings);
|
|
|
|
doubleClickDisabled.current = viewSettings.disableDoubleClick!;
|
|
const animated = viewSettings.animated!;
|
|
const eink = viewSettings.isEink!;
|
|
const maxColumnCount = viewSettings.maxColumnCount!;
|
|
const maxInlineSize = getMaxInlineSize(viewSettings);
|
|
const maxBlockSize = viewSettings.maxBlockSize!;
|
|
const screenOrientation = viewSettings.screenOrientation!;
|
|
if (appService?.isMobileApp) {
|
|
await lockScreenOrientation({ orientation: screenOrientation });
|
|
}
|
|
if (animated) {
|
|
view.renderer.setAttribute('animated', '');
|
|
} else {
|
|
view.renderer.removeAttribute('animated');
|
|
}
|
|
applyPageTurnAttributes(view, viewSettings, bookDoc.rendition?.layout === 'pre-paginated');
|
|
// iOS WebKit composites large/persistent page layers without the Android
|
|
// high-DPR Blink freeze, so opt this renderer into the GPU-accelerated
|
|
// page-turn path (persistent compositor layers + no main-thread
|
|
// rafAnimateScroll fallback) to keep 120Hz ProMotion turns smooth
|
|
// (readest#4768).
|
|
if (appService?.isIOSApp) {
|
|
view.renderer.setAttribute('gpu-composite', '');
|
|
}
|
|
if (appService?.isAndroidApp) {
|
|
if (eink) {
|
|
view.renderer.setAttribute('eink', '');
|
|
} else {
|
|
view.renderer.removeAttribute('eink');
|
|
}
|
|
applyEinkMode(eink);
|
|
}
|
|
if (bookDoc?.rendition?.layout === 'pre-paginated') {
|
|
view.renderer.setAttribute('zoom', viewSettings.zoomMode);
|
|
view.renderer.setAttribute('spread', viewSettings.spreadMode);
|
|
view.renderer.setAttribute('scale-factor', viewSettings.zoomLevel);
|
|
view.renderer.setAttribute('scroll-gap', getScrollGapAttr(viewSettings.webtoonMode));
|
|
} else {
|
|
view.renderer.setAttribute('max-column-count', maxColumnCount);
|
|
view.renderer.setAttribute('max-inline-size', `${maxInlineSize}px`);
|
|
view.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
|
|
}
|
|
applyMarginAndGap();
|
|
|
|
// If the URL carries ?cfi=... (e.g. opened from a deep link / annotation
|
|
// export link), use it as the initial location instead of the saved one.
|
|
// Only applies to the primary book — first id in the route's `ids` —
|
|
// so parallel views don't all jump to the same CFI.
|
|
const cfiParam = searchParams?.get('cfi');
|
|
const idsParam =
|
|
searchParams?.get('ids') ?? window.location.pathname.split('/reader/')[1] ?? '';
|
|
const primaryId = idsParam.split(BOOK_IDS_SEPARATOR).filter(Boolean)[0];
|
|
const thisId = bookKey.split('-')[0];
|
|
const overrideLocation = cfiParam && primaryId === thisId ? cfiParam : null;
|
|
|
|
const lastLocation = overrideLocation ?? config.location;
|
|
if (lastLocation) {
|
|
await view.init({ lastLocation });
|
|
} else {
|
|
await view.goToFraction(0);
|
|
}
|
|
setViewInited(bookKey, true);
|
|
|
|
// The reader is showing a deep-link target, not the user's actual reading
|
|
// position. Mark the view as a preview so progress writers (auto-save,
|
|
// cloud sync, kosync) skip until the user takes a reading action. The
|
|
// flag clears on the first user-initiated relocate (page / scroll) in
|
|
// docRelocateHandler below.
|
|
if (overrideLocation) {
|
|
setPreviewMode(bookKey, true);
|
|
}
|
|
};
|
|
|
|
openBook();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const applyMarginAndGap = () => {
|
|
const viewSettings = getViewSettings(bookKey)!;
|
|
const viewState = getViewState(bookKey);
|
|
const viewInsets = getViewInsets(viewSettings);
|
|
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
|
|
const showDoubleBorderHeader = showDoubleBorder && viewSettings.showHeader;
|
|
const showDoubleBorderFooter = showDoubleBorder && viewSettings.showFooter;
|
|
const showTopHeader = viewSettings.showHeader && !viewSettings.vertical;
|
|
const showBottomFooter = viewSettings.showFooter && !viewSettings.vertical;
|
|
const moreTopInset = showTopHeader ? Math.max(0, 16 - insets.top) : 0;
|
|
const ttsBarHeight =
|
|
viewState?.ttsEnabled && viewSettings.showTTSBar ? 52 + gridInsets.bottom * 0.33 : 0;
|
|
const moreBottomInset = showBottomFooter
|
|
? Math.max(0, Math.max(ttsBarHeight, 16) - insets.bottom)
|
|
: Math.max(0, ttsBarHeight);
|
|
const moreRightInset = showDoubleBorderHeader ? 32 : 0;
|
|
const moreLeftInset = showDoubleBorderFooter ? 32 : 0;
|
|
const topMargin = (showTopHeader ? insets.top : viewInsets.top) + moreTopInset;
|
|
const rightMargin = insets.right + moreRightInset;
|
|
const bottomMargin = (showBottomFooter ? insets.bottom : viewInsets.bottom) + moreBottomInset;
|
|
const leftMargin = insets.left + moreLeftInset;
|
|
viewRef.current?.renderer.setAttribute('margin-top', `${topMargin}px`);
|
|
viewRef.current?.renderer.setAttribute('margin-right', `${rightMargin}px`);
|
|
viewRef.current?.renderer.setAttribute('margin-bottom', `${bottomMargin}px`);
|
|
viewRef.current?.renderer.setAttribute('margin-left', `${leftMargin}px`);
|
|
|
|
if (viewSettings.scrolled) {
|
|
const headerVisible = showTopHeader;
|
|
const footerVisible = showBottomFooter;
|
|
const safeBottomPadding = appService?.hasSafeAreaInset ? gridInsets.bottom * 0.33 : 0;
|
|
const footerBarHeight = safeBottomPadding + viewSettings.marginBottomPx;
|
|
const scrollTop = headerVisible ? gridInsets.top + viewSettings.marginTopPx : 0;
|
|
const scrollBottom = footerVisible ? Math.max(footerBarHeight, ttsBarHeight) : ttsBarHeight;
|
|
setScrollMargins({ top: scrollTop, bottom: scrollBottom });
|
|
} else {
|
|
setScrollMargins({ top: 0, bottom: 0 });
|
|
}
|
|
viewRef.current?.renderer.setAttribute('gap', `${viewSettings.gapPercent}%`);
|
|
if (viewSettings.scrolled) {
|
|
viewRef.current?.renderer.setAttribute('flow', 'scrolled');
|
|
if (viewSettings.noContinuousScroll) {
|
|
viewRef.current?.renderer.setAttribute('no-continuous-scroll', '');
|
|
} else {
|
|
viewRef.current?.renderer.removeAttribute('no-continuous-scroll');
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (viewRef.current && viewRef.current.renderer) {
|
|
const renderer = viewRef.current.renderer;
|
|
const viewSettings = getViewSettings(bookKey)!;
|
|
viewRef.current.renderer.setStyles?.(getStyles(viewSettings, undefined, getLoadedFonts()));
|
|
const docs = viewRef.current.renderer.getContents();
|
|
docs.forEach(({ doc }) => {
|
|
if (bookDoc.rendition?.layout === 'pre-paginated') {
|
|
applyFixedlayoutStyles(doc, viewSettings);
|
|
}
|
|
applyThemeModeClass(doc, isDarkMode);
|
|
applyScrollModeClass(doc, viewSettings.scrolled || false);
|
|
applyScrollbarStyle(document, viewSettings.hideScrollbar || false);
|
|
});
|
|
|
|
if (bookData?.book?.format === 'PDF' && themeCode && renderer) {
|
|
renderer.pageColors = viewSettings.applyThemeToPDF
|
|
? {
|
|
background: themeCode.bg,
|
|
foreground: themeCode.fg,
|
|
}
|
|
: undefined;
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
themeCode,
|
|
isDarkMode,
|
|
viewSettings?.scrolled,
|
|
viewSettings?.overrideColor,
|
|
viewSettings?.invertImgColorInDark,
|
|
viewSettings?.applyThemeToPDF,
|
|
viewSettings?.contrast,
|
|
viewSettings?.hideScrollbar,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const contents = viewRef.current?.renderer?.getContents?.() || [];
|
|
const vs = getViewSettings(bookKey);
|
|
if (!vs || !appService) return;
|
|
const bookLang = getBookData(bookKey)?.book?.primaryLanguage;
|
|
const isFixedLayout = bookDoc.rendition?.layout === 'pre-paginated';
|
|
if (isFixedLayout) return;
|
|
// A settings change is the moment a fresh download may start; let the
|
|
// one-time "Downloading…" toast fire again for it.
|
|
wordLensToastShownRef.current = false;
|
|
for (const { doc } of contents) {
|
|
if (doc) void refreshSectionGlosses(doc, vs, buildWordLensCtx(bookLang));
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [viewSettings?.wordLensEnabled, viewSettings?.wordLensLevel, viewSettings?.wordLensHintLang]);
|
|
|
|
useEffect(() => {
|
|
const mountCustomFonts = async () => {
|
|
await loadCustomFonts(envConfig);
|
|
getLoadedFonts().forEach((font) => {
|
|
mountCustomFont(document, font);
|
|
const docs = viewRef.current?.renderer.getContents();
|
|
docs?.forEach(({ doc }) => mountCustomFont(doc, font));
|
|
});
|
|
};
|
|
if (settings.customFonts) {
|
|
mountCustomFonts();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [settings.customFonts, envConfig]);
|
|
|
|
useEffect(() => {
|
|
if (!viewSettings) return;
|
|
applyBackgroundTexture(envConfig, viewSettings);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
viewSettings?.backgroundTextureId,
|
|
viewSettings?.backgroundOpacity,
|
|
viewSettings?.backgroundSize,
|
|
applyBackgroundTexture,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (viewRef.current && viewRef.current.renderer) {
|
|
doubleClickDisabled.current = !!viewSettings?.disableDoubleClick;
|
|
}
|
|
}, [viewSettings?.disableDoubleClick]);
|
|
|
|
useEffect(() => {
|
|
if (viewRef.current && viewRef.current.renderer && viewSettings) {
|
|
applyMarginAndGap();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
insets.top,
|
|
insets.right,
|
|
insets.bottom,
|
|
insets.left,
|
|
viewSettings?.doubleBorder,
|
|
viewSettings?.showHeader,
|
|
viewSettings?.showFooter,
|
|
viewSettings?.showTTSBar,
|
|
viewSettings?.scrolled,
|
|
viewSettings?.noContinuousScroll,
|
|
viewState?.ttsEnabled,
|
|
]);
|
|
|
|
return (
|
|
<>
|
|
{selectedImage && (
|
|
<ImageViewer
|
|
gridInsets={gridInsets}
|
|
src={selectedImage}
|
|
onClose={handleCloseImage}
|
|
onPrevious={currentImageIndex > 0 ? handlePreviousImage : undefined}
|
|
onNext={currentImageIndex < imageList.length - 1 ? handleNextImage : undefined}
|
|
/>
|
|
)}
|
|
{selectedTableHtml && (
|
|
<TableViewer
|
|
gridInsets={gridInsets}
|
|
html={selectedTableHtml}
|
|
isDarkMode={isDarkMode}
|
|
onClose={() => setSelectedTableHtml(null)}
|
|
/>
|
|
)}
|
|
<div
|
|
ref={containerRef}
|
|
role='main'
|
|
aria-label={_('Book Content')}
|
|
className={clsx(
|
|
'foliate-viewer absolute h-[100%] w-[100%] focus:outline-none',
|
|
viewState?.loading && 'bg-base-100',
|
|
)}
|
|
style={{
|
|
paddingTop: scrollMargins.top,
|
|
paddingBottom: scrollMargins.bottom,
|
|
}}
|
|
{...mouseHandlers}
|
|
{...touchHandlers}
|
|
/>
|
|
<BrightnessOverlay visible={overlayVisible} level={overlayLevel} />
|
|
<ParagraphControl bookKey={bookKey} viewRef={viewRef} gridInsets={gridInsets} />
|
|
{((!docLoaded.current && loading) || viewState?.loading) && (
|
|
<div className='absolute left-0 top-0 z-10 flex h-full w-full items-center justify-center'>
|
|
<Spinner loading={true} />
|
|
</div>
|
|
)}
|
|
{syncState === 'conflict' && conflictDetails && (
|
|
<KOSyncConflictResolver
|
|
details={conflictDetails}
|
|
onResolveWithLocal={resolveWithLocal}
|
|
onResolveWithRemote={resolveWithRemote}
|
|
onClose={resolveWithLocal}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default FoliateViewer;
|