import React, { useEffect, useRef, useState } from 'react'; import { BookDoc, getDirection } from '@/libs/document'; import { BookConfig } from '@/types/book'; import { FoliateView, wrappedFoliateView } from '@/types/view'; import { useThemeStore } from '@/store/themeStore'; import { useReaderStore } from '@/store/readerStore'; import { useParallelViewStore } from '@/store/parallelViewStore'; import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents'; import { useFoliateEvents } from '../hooks/useFoliateEvents'; import { useProgressSync } from '../hooks/useProgressSync'; import { useProgressAutoSave } from '../hooks/useProgressAutoSave'; import { getStyles, mountAdditionalFonts, transformStylesheet } from '@/utils/style'; import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book'; import { useUICSS } from '@/hooks/useUICSS'; import { handleKeydown, handleMousedown, handleMouseup, handleClick, handleWheel, handleTouchStart, handleTouchMove, handleTouchEnd, } from '../utils/iframeEventHandlers'; import { getMaxInlineSize } from '@/utils/config'; import { getDirFromUILanguage } from '@/utils/rtl'; import { transformContent } from '@/services/transformService'; const FoliateViewer: React.FC<{ bookKey: string; bookDoc: BookDoc; config: BookConfig; }> = ({ bookKey, bookDoc, config }) => { const containerRef = useRef(null); const viewRef = useRef(null); const isViewCreated = useRef(false); const { getView, setView: setFoliateView, setProgress } = useReaderStore(); const { getViewSettings, setViewSettings } = useReaderStore(); const { getParallels } = useParallelViewStore(); const { themeCode, isDarkMode } = useThemeStore(); const viewSettings = getViewSettings(bookKey)!; const [toastMessage, setToastMessage] = useState(''); useEffect(() => { const timer = setTimeout(() => setToastMessage(''), 2000); return () => clearTimeout(timer); }, [toastMessage]); useUICSS(bookKey, viewSettings); useProgressSync(bookKey); useProgressAutoSave(bookKey); const progressRelocateHandler = (event: Event) => { const detail = (event as CustomEvent).detail; setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range); }; const docTransformHandler = (event: Event) => { const { detail } = event as CustomEvent; detail.data = Promise.resolve(detail.data) .then((data) => { const viewSettings = getViewSettings(bookKey); if (detail.type === 'text/css') return transformStylesheet(data); if (viewSettings && detail.type === 'application/xhtml+xml') { const ctx = { bookKey, viewSettings, content: data, transformers: ['punctuation'], }; return Promise.resolve(transformContent(ctx)); } return data; }) .catch((e) => { console.error(new Error(`Failed to load ${detail.name}`, { cause: e })); return ''; }); }; const docLoadHandler = (event: Event) => { const detail = (event as CustomEvent).detail; console.log('doc index loaded:', detail.index); if (detail.doc) { const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc); const viewSettings = getViewSettings(bookKey)!; viewSettings.vertical = writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false; viewSettings.rtl = writingDir?.rtl || getDirFromUILanguage() === 'rtl' || viewSettings.writingMode.includes('rl') || false; setViewSettings(bookKey, { ...viewSettings }); mountAdditionalFonts(detail.doc); if (!detail.doc.isEventListenersAdded) { detail.doc.isEventListenersAdded = true; detail.doc.addEventListener('keydown', handleKeydown.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)); 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)); } } }; const docRelocateHandler = (event: Event) => { const detail = (event as CustomEvent).detail; if (detail.reason !== 'scroll' && detail.reason !== 'page') return; if (detail.reason === 'scroll') { const renderer = viewRef.current?.renderer; const viewSettings = getViewSettings(bookKey)!; if (renderer && viewSettings.continuousScroll) { if (renderer.start <= 0) { viewRef.current?.prev(1); // sometimes viewSize has subpixel value that the end never reaches } else if (renderer.end + 1 >= renderer.viewSize) { viewRef.current?.next(1); } } } 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 }); } } }); } }; useTouchEvent(bookKey, viewRef); const { handleTurnPage } = useClickEvent(bookKey, viewRef, containerRef); useFoliateEvents(viewRef.current, { onLoad: docLoadHandler, onRelocate: progressRelocateHandler, onRendererRelocate: docRelocateHandler, }); useEffect(() => { if (viewRef.current && viewRef.current.renderer) { const viewSettings = getViewSettings(bookKey)!; viewRef.current.renderer.setStyles?.(getStyles(viewSettings)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [themeCode, isDarkMode]); useEffect(() => { if (isViewCreated.current) return; isViewCreated.current = true; 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}`; document.body.append(view); containerRef.current?.appendChild(view); 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; } } 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('data', docTransformHandler); view.renderer.setStyles?.(getStyles(viewSettings)); const isScrolled = viewSettings.scrolled!; const marginPx = viewSettings.marginPx!; const gapPercent = viewSettings.gapPercent!; const animated = viewSettings.animated!; const maxColumnCount = viewSettings.maxColumnCount!; const maxInlineSize = getMaxInlineSize(viewSettings); const maxBlockSize = viewSettings.maxBlockSize!; if (animated) { view.renderer.setAttribute('animated', ''); } else { view.renderer.removeAttribute('animated'); } view.renderer.setAttribute('flow', isScrolled ? 'scrolled' : 'paginated'); view.renderer.setAttribute('margin', `${marginPx}px`); view.renderer.setAttribute('gap', `${gapPercent}%`); view.renderer.setAttribute('max-column-count', maxColumnCount); view.renderer.setAttribute('max-inline-size', `${maxInlineSize}px`); view.renderer.setAttribute('max-block-size', `${maxBlockSize}px`); const lastLocation = config.location; if (lastLocation) { await view.init({ lastLocation }); } else { await view.goToFraction(0); } }; openBook(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <>
handleTurnPage(event)} ref={containerRef} /> ); }; export default FoliateViewer;