import { useEffect, useRef } from 'react'; import { useEnv } from '@/context/EnvContext'; import { useReaderStore } from '@/store/readerStore'; import { getOSPlatform } from '@/utils/misc'; import { eventDispatcher } from '@/utils/event'; import { isPointerInsideSelection, TextSelection } from '@/utils/sel'; export const useTextSelector = ( bookKey: string, setSelection: React.Dispatch>, getAnnotationText: (range: Range) => Promise, handleDismissPopup: () => void, ) => { const { appService } = useEnv(); const { getView, getViewSettings } = useReaderStore(); const view = getView(bookKey); const osPlatform = getOSPlatform(); const isPopuped = useRef(false); const isUpToPopup = useRef(false); const isTextSelected = useRef(false); const isTouchStarted = useRef(false); const selectionPosition = useRef(null); const lastPointerType = useRef('mouse'); const isValidSelection = (sel: Selection) => { return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0; }; const makeSelection = async (sel: Selection, index: number, rebuildRange = false) => { isTextSelected.current = true; isUpToPopup.current = true; const range = sel.getRangeAt(0); if (rebuildRange) { sel.removeAllRanges(); sel.addRange(range); } setSelection({ key: bookKey, text: await getAnnotationText(range), cfi: view?.getCFI(index, range), range, index, }); }; // FIXME: extremely hacky way to dismiss system selection tools on iOS const makeSelectionOnIOS = async (sel: Selection, index: number) => { isTextSelected.current = true; isUpToPopup.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), cfi: view?.getCFI(index, range), range, index, }); }, 30); }, 30); }; const handlePointerdown = (e: PointerEvent) => { lastPointerType.current = e.pointerType; }; const handlePointerup = (doc: Document, index: number, ev?: PointerEvent) => { // Available on iOS and Desktop, fired at touchend or mouseup // Note that on Android, we mock pointer events with native touch events const sel = doc.getSelection() as Selection; if (isValidSelection(sel)) { const isPointerInside = ev && isPointerInsideSelection(sel, ev); const isIOS = osPlatform === 'ios' || appService?.isIOSApp; const isAndroid = appService?.isAndroidApp; if (isPointerInside && isIOS) { makeSelectionOnIOS(sel, index); } else if (isPointerInside || isAndroid) { makeSelection(sel, index, true); } } }; const handleTouchStart = () => { isTouchStarted.current = true; }; const handleTouchEnd = () => { isTouchStarted.current = false; }; const handleSelectionchange = (doc: Document) => { // Available on iOS, Android and Desktop, fired when the selection is changed if (osPlatform !== 'android' || !appService?.isAndroidApp) return; const sel = doc.getSelection() as Selection; if (isValidSelection(sel)) { if (!selectionPosition.current) { selectionPosition.current = view?.renderer?.start || null; } } else { selectionPosition.current = null; } }; const handleScroll = () => { // Prevent the container from scrolling when text is selected in paginated mode // FIXME: this is a workaround for issue #873 // TODO: support text selection across pages if (osPlatform !== 'android' || !appService?.isAndroidApp) return; const viewSettings = getViewSettings(bookKey); if (viewSettings?.scrolled) return; if (isTextSelected.current && view?.renderer?.containerPosition && selectionPosition.current) { console.warn('Keep container position', selectionPosition.current); view.renderer.containerPosition = selectionPosition.current; } }; const handleShowPopup = (showPopup: boolean) => { setTimeout(() => { if (showPopup && !isPopuped.current) { isUpToPopup.current = false; } isPopuped.current = showPopup; }, 500); }; const handleUpToPopup = () => { isUpToPopup.current = true; }; const handleContextmenu = (event: Event) => { if (appService?.isMobile) { event.preventDefault(); event.stopPropagation(); return false; } else if (lastPointerType.current === 'touch' || lastPointerType.current === 'pen') { event.preventDefault(); event.stopPropagation(); return false; } return; }; useEffect(() => { const handleSingleClick = (): boolean => { if (isUpToPopup.current) { isUpToPopup.current = false; return true; } if (isTextSelected.current) { handleDismissPopup(); isTextSelected.current = false; view?.deselect(); return true; } if (isPopuped.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 { isTextSelected, handleScroll, handleTouchStart, handleTouchEnd, handlePointerdown, handlePointerup, handleSelectionchange, handleShowPopup, handleUpToPopup, handleContextmenu, }; };