diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 42168840..4a01d929 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -42,7 +42,6 @@ const FoliateViewer: React.FC<{ const { getViewSettings, setViewSettings } = useReaderStore(); const { getParallels } = useParallelViewStore(); const { themeCode, isDarkMode } = useThemeStore(); - const viewSettings = getViewSettings(bookKey)!; const [toastMessage, setToastMessage] = useState(''); useEffect(() => { @@ -50,7 +49,7 @@ const FoliateViewer: React.FC<{ return () => clearTimeout(timer); }, [toastMessage]); - useUICSS(bookKey, viewSettings); + useUICSS(bookKey); useProgressSync(bookKey); useProgressAutoSave(bookKey); @@ -170,6 +169,7 @@ const FoliateViewer: React.FC<{ const width = containerRect?.width || window.innerWidth; const height = containerRect?.height || window.innerHeight; + const viewSettings = getViewSettings(bookKey)!; const writingMode = viewSettings.writingMode; if (writingMode) { const settingsDir = getBookDirFromWritingMode(writingMode); diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index a68f477a..87dd9ef1 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect } from 'react'; import { FiSearch } from 'react-icons/fi'; import { FiCopy } from 'react-icons/fi'; import { PiHighlighterFill } from 'react-icons/pi'; @@ -22,16 +22,10 @@ import { useTranslation } from '@/hooks/useTranslation'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { useFoliateEvents } from '../../hooks/useFoliateEvents'; import { useNotesSync } from '../../hooks/useNotesSync'; -import { - getPopupPosition, - getPosition, - getTextFromRange, - Position, - TextSelection, -} from '@/utils/sel'; +import { useTextSelector } from '../../hooks/useTextSelector'; +import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel'; import { eventDispatcher } from '@/utils/event'; import { findTocItemBS } from '@/utils/toc'; -import { transformContent } from '@/services/transformService'; import { HIGHLIGHT_COLOR_HEX } from '@/services/constants'; import AnnotationPopup from './AnnotationPopup'; import WiktionaryPopup from './WiktionaryPopup'; @@ -54,15 +48,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const bookData = getBookData(bookKey)!; const view = getView(bookKey); const viewSettings = getViewSettings(bookKey)!; - const primaryLang = bookData.book?.primaryLanguage || 'en'; - const isShowingPopup = useRef(false); - const isTextSelected = useRef(false); - const isUpToShowPopup = useRef(false); - const isTouchstarted = useRef(false); - const selectionStartRef = useRef(null); - const selectionAnchorRef = useRef<{ node: Node; offset: number } | null>(null); - const [selection, setSelection] = useState(); + const [selection, setSelection] = useState(null); const [showAnnotPopup, setShowAnnotPopup] = useState(false); const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false); const [showWikipediaPopup, setShowWikipediaPopup] = useState(false); @@ -91,137 +78,41 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const annotPopupHeight = useResponsiveSize(44); const androidSelectionHandlerHeight = 0; + const handleDismissPopup = () => { + setSelection(null); + setShowAnnotPopup(false); + setShowWiktionaryPopup(false); + setShowWikipediaPopup(false); + setShowDeepLPopup(false); + }; + + const handleDismissPopupAndSelection = () => { + handleDismissPopup(); + view?.deselect(); + }; + + const { + handleScroll, + handlePointerup, + handleSelectionchange, + handleAnnotPopup, + handleShowAnnotation, + } = useTextSelector(bookKey, setSelection, handleDismissPopup); + const onLoad = (event: Event) => { const detail = (event as CustomEvent).detail; const { doc, index } = detail; - const isValidSelection = (sel: Selection) => { - return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0; - }; - const transformCtx = { - bookKey, - viewSettings: getViewSettings(bookKey)!, - content: '', - transformers: ['punctuation'], - reversePunctuationTransform: true, - }; - const getAnnotationText = (range: Range) => { - return getTextFromRange(range, primaryLang.startsWith('ja') ? ['rt'] : []); - }; - const makeSelection = async (sel: Selection, rebuildRange = false) => { - isTextSelected.current = true; - const range = sel.getRangeAt(0); - if (rebuildRange) { - sel.removeAllRanges(); - sel.addRange(range); - } - transformCtx['content'] = getAnnotationText(range); - setSelection({ key: bookKey, text: await transformContent(transformCtx), range, index }); - }; - // FIXME: extremely hacky way to dismiss system selection tools on iOS - const makeSelectionOnIOS = async (sel: Selection) => { - isTextSelected.current = true; - const range = sel.getRangeAt(0); - setTimeout(() => { - sel.removeAllRanges(); - setTimeout(async () => { - if (!isTextSelected.current) return; - sel.addRange(range); - transformCtx['content'] = getAnnotationText(range); - setSelection({ key: bookKey, text: await transformContent(transformCtx), range, index }); - }, 40); - }, 0); - }; - const handleSelectstart = () => { - selectionAnchorRef.current = null; - }; - const handleSelectionchange = () => { - // Available on iOS, Android and Desktop, fired when the selection is changed - // Ideally the popup only shows when the selection is done, - // but on Android no proper events are fired to notify selection done or I didn't find it, - // we make the popup show when the selection is changed - if (osPlatform === 'ios' || appService?.isIOSApp) return; - - const sel = doc.getSelection(); - if (isValidSelection(sel)) { - if (osPlatform === 'android' && isTouchstarted.current) { - if (!selectionAnchorRef.current) { - const range = sel.getRangeAt(0); - selectionAnchorRef.current = { - node: range.startContainer, - offset: range.startOffset, - }; - } - if (selectionAnchorRef.current) { - const currentRange = sel.getRangeAt(0); - if ( - currentRange.startContainer !== selectionAnchorRef.current.node || - currentRange.startOffset !== selectionAnchorRef.current.offset - ) { - const newRange = doc.createRange(); - newRange.setStart(selectionAnchorRef.current.node, selectionAnchorRef.current.offset); - newRange.setEnd(currentRange.endContainer, currentRange.endOffset); - - sel.removeAllRanges(); - sel.addRange(newRange); - } - } - makeSelection(sel, false); - } - } else if (!isUpToShowPopup.current) { - isTextSelected.current = false; - setShowAnnotPopup(false); - setShowWiktionaryPopup(false); - setShowWikipediaPopup(false); - setShowDeepLPopup(false); - } - }; - const handlePointerup = () => { - // Available on iOS and Desktop, fired when release the long press - // Note that on Android, pointerup event is fired after an additional touch event - const sel = doc.getSelection(); - if (isValidSelection(sel)) { - if (osPlatform === 'ios' || appService?.isIOSApp) { - makeSelectionOnIOS(sel); - } else { - makeSelection(sel, true); - } - } - }; - const handleTouchstart = () => { - // Available on iOS and Android for the initial touch event - isTouchstarted.current = true; - }; const handleTouchmove = () => { // Available on iOS, on Android not fired // To make the popup not to follow the selection setShowAnnotPopup(false); }; - const handleTouchend = () => { - // Available on iOS, on Android fired after an additional touch event - isTouchstarted.current = false; - }; - const handleScroll = () => { - // Prevent the container from scrolling when text is selected in paginated mode - // This is a workaround for the issue #873 - // TODO: support text selection across pages - if ( - !viewSettings?.scrolled && - isTextSelected.current && - view?.renderer?.containerPosition && - selectionStartRef.current - ) { - view.renderer.containerPosition = selectionStartRef.current; - } - }; if (bookData.book?.format !== 'PDF') { view?.renderer?.addEventListener('scroll', handleScroll); - detail.doc?.addEventListener('pointerup', handlePointerup); - detail.doc?.addEventListener('touchstart', handleTouchstart); detail.doc?.addEventListener('touchmove', handleTouchmove); - detail.doc?.addEventListener('touchend', handleTouchend); - detail.doc?.addEventListener('selectstart', handleSelectstart); - detail.doc?.addEventListener('selectionchange', handleSelectionchange); + detail.doc?.addEventListener('pointerup', () => handlePointerup(doc, index)); + detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc, index)); // Disable the default context menu on mobile devices, // although it should but doesn't work on iOS @@ -269,60 +160,22 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const annotation = annotations.find((annotation) => annotation.cfi === cfi); if (!annotation) return; const selection = { key: bookKey, annotated: true, text: annotation.text ?? '', range, index }; - isUpToShowPopup.current = true; setSelectedStyle(annotation.style!); setSelectedColor(annotation.color!); setSelection(selection); + handleShowAnnotation(); }; useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation }); - const handleDismissPopup = () => { - setSelection(null); - setShowAnnotPopup(false); - setShowWiktionaryPopup(false); - setShowWikipediaPopup(false); - setShowDeepLPopup(false); - isShowingPopup.current = false; - }; - - const handleDismissPopupAndSelection = () => { - handleDismissPopup(); - view?.deselect(); - isTextSelected.current = false; - }; - useEffect(() => { - if (isTextSelected.current && !selectionStartRef.current) { - selectionStartRef.current = view?.renderer?.start || null; - } else if (!isTextSelected.current) { - selectionStartRef.current = null; - selectionAnchorRef.current = null; - } + handleAnnotPopup(showAnnotPopup); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isTextSelected.current]); + }, [showAnnotPopup]); useEffect(() => { - const handleSingleClick = (): boolean => { - if (isUpToShowPopup.current) { - isUpToShowPopup.current = false; - return true; - } - if (isTextSelected.current) { - handleDismissPopupAndSelection(); - return true; - } - if (showAnnotPopup || isShowingPopup.current) { - handleDismissPopup(); - return true; - } - return false; - }; - - eventDispatcher.onSync('iframe-single-click', handleSingleClick); eventDispatcher.on('export-annotations', handleExportMarkdown); return () => { - eventDispatcher.offSync('iframe-single-click', handleSingleClick); eventDispatcher.off('export-annotations', handleExportMarkdown); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -342,7 +195,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { viewSettings.vertical ? annotPopupWidth : annotPopupHeight, popupPadding, ); - if (isTextSelected.current && annotPopupPos.dir === 'down' && osPlatform === 'android') { + if (annotPopupPos.dir === 'down' && osPlatform === 'android') { triangPos.point.y += androidSelectionHandlerHeight; annotPopupPos.point.y += androidSelectionHandlerHeight; } @@ -366,7 +219,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { setDictPopupPosition(dictPopupPos); setTranslatorPopupPosition(transPopupPos); setTrianglePosition(triangPos); - isShowingPopup.current = true; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selection, bookKey]); diff --git a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts new file mode 100644 index 00000000..1a0a5ba3 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts @@ -0,0 +1,158 @@ +import { useEffect, useRef } from 'react'; +import { useReaderStore } from '@/store/readerStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { getOSPlatform } from '@/utils/misc'; +import { eventDispatcher } from '@/utils/event'; +import { getTextFromRange, TextSelection } from '@/utils/sel'; +import { transformContent } from '@/services/transformService'; + +export const useTextSelector = ( + bookKey: string, + setSelection: React.Dispatch>, + handleDismissPopup: () => void, +) => { + const { getBookData } = useBookDataStore(); + const { getView, getViewSettings } = useReaderStore(); + const view = getView(bookKey); + const viewSettings = getViewSettings(bookKey)!; + const bookData = getBookData(bookKey)!; + const primaryLang = bookData.book?.primaryLanguage || 'en'; + const osPlatform = getOSPlatform(); + + const isTextSelected = useRef(false); + const isAnnotPopuped = useRef(false); + const isAnnotUpToPopup = useRef(false); + const selectionPosition = useRef(null); + + const isValidSelection = (sel: Selection) => { + return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0; + }; + + const transformCtx = { + bookKey, + viewSettings: getViewSettings(bookKey)!, + content: '', + transformers: ['punctuation'], + reversePunctuationTransform: true, + }; + const getAnnotationText = async (range: Range) => { + transformCtx['content'] = getTextFromRange(range, primaryLang.startsWith('ja') ? ['rt'] : []); + return await transformContent(transformCtx); + }; + const makeSelection = async (sel: Selection, index: number, rebuildRange = false) => { + isTextSelected.current = true; + const range = sel.getRangeAt(0); + if (rebuildRange) { + sel.removeAllRanges(); + sel.addRange(range); + } + setSelection({ key: bookKey, text: await getAnnotationText(range), range, index }); + }; + // FIXME: extremely hacky way to dismiss system selection tools on iOS + const makeSelectionOnIOS = async (sel: Selection, index: number) => { + isTextSelected.current = true; + const range = sel.getRangeAt(0); + setTimeout(() => { + sel.removeAllRanges(); + setTimeout(async () => { + if (!isTextSelected.current) return; + sel.addRange(range); + setSelection({ key: bookKey, text: await getAnnotationText(range), range, index }); + }, 40); + }, 0); + }; + const handleSelectionchange = (doc: Document, index: number) => { + // Available on iOS, Android and Desktop, fired when the selection is changed + // Ideally the popup only shows when the selection is done, + const sel = doc.getSelection() as Selection; + if (osPlatform === 'ios') return; + if (!isValidSelection(sel)) { + if (!isAnnotUpToPopup.current) { + handleDismissPopup(); + isTextSelected.current = false; + } + return; + } + + // On Android no proper events are fired to notify selection done, + // we make the popup show when the selection is changed + if (osPlatform === 'android') { + makeSelection(sel, index, false); + } + }; + const handlePointerup = (doc: Document, index: number) => { + // Available on iOS and Desktop, fired at touchend or mouseup + // Note that on Android, pointerup event is fired after an additional touch event + const sel = doc.getSelection() as Selection; + if (isValidSelection(sel)) { + if (osPlatform === 'ios') { + makeSelectionOnIOS(sel, index); + } else { + makeSelection(sel, index, true); + } + } + }; + const handleScroll = () => { + // Prevent the container from scrolling when text is selected in paginated mode + // This is a workaround for the issue #873 + // TODO: support text selection across pages + if (!viewSettings?.scrolled && view?.renderer?.containerPosition && selectionPosition.current) { + view.renderer.containerPosition = selectionPosition.current; + } + }; + + const handleAnnotPopup = (showAnnotPopup: boolean) => { + setTimeout( + () => { + isAnnotPopuped.current = showAnnotPopup; + }, + ['android', 'ios'].includes(osPlatform) ? 0 : 500, + ); + }; + + const handleShowAnnotation = () => { + isAnnotUpToPopup.current = true; + }; + + useEffect(() => { + if (isTextSelected.current && !selectionPosition.current) { + selectionPosition.current = view?.renderer?.start || null; + } else if (!isTextSelected.current) { + selectionPosition.current = null; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isTextSelected.current]); + + useEffect(() => { + const handleSingleClick = (): boolean => { + if (isAnnotUpToPopup.current) { + isAnnotUpToPopup.current = false; + return true; + } + if (isTextSelected.current) { + handleDismissPopup(); + isTextSelected.current = false; + return true; + } + if (isAnnotPopuped.current) { + handleDismissPopup(); + return true; + } + return false; + }; + + eventDispatcher.onSync('iframe-single-click', handleSingleClick); + return () => { + eventDispatcher.offSync('iframe-single-click', handleSingleClick); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + handleScroll, + handlePointerup, + handleSelectionchange, + handleAnnotPopup, + handleShowAnnotation, + }; +}; diff --git a/apps/readest-app/src/hooks/useUICSS.ts b/apps/readest-app/src/hooks/useUICSS.ts index e0d9ffa3..0859bf98 100644 --- a/apps/readest-app/src/hooks/useUICSS.ts +++ b/apps/readest-app/src/hooks/useUICSS.ts @@ -1,9 +1,11 @@ -import { ViewSettings } from '@/types/book'; import { useEffect, useState } from 'react'; +import { useReaderStore } from '@/store/readerStore'; // This hook allows you to inject custom CSS into the reader UI. // Note that the book content is rendered in an iframe, so UI CSS won't affect book rendering. -export const useUICSS = (bookKey: string, viewSettings: ViewSettings) => { +export const useUICSS = (bookKey: string) => { + const { getViewSettings } = useReaderStore(); + const viewSettings = getViewSettings(bookKey); const [styleElement, setStyleElement] = useState(null); useEffect(() => { diff --git a/packages/foliate-js b/packages/foliate-js index 9f54eedf..88edc258 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 9f54eedf96daf38ea8a42b30143217b1d1204c30 +Subproject commit 88edc25834afb32b80d0b4f573eaf41a996e0190