diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 930f1e99..cd3e4c12 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -2456,127 +2456,31 @@ let selTimer = null; let iosHackPending = false; - let originalScrollLeft = 0; - let pageDebounceTimer = null; - let pendingAdvanceDirection = null; - let selectionMonitorTimer = null; - let preventScroll = null; - let pointerUpCleanup = null; - let selectionDragPoint = null; - let pageAdvanceLockUntil = 0; - let lockedContainer = null; - let lockedContainerStyles = null; + let selectionClearTimer = null; + const isIOSLike = (() => { + try { + return /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + } catch { + return false; + } + })(); function markSelectionInteraction(active) { doc.__readany_selection_interaction = !!active; } - function setSelectionDragPoint(x, y) { - selectionDragPoint = { - x, - y, - updatedAt: Date.now(), - }; - } - - function clearSelectionDragPoint() { - selectionDragPoint = null; - } - - function cancelPendingPageAdvance() { - if (pageDebounceTimer) { - clearTimeout(pageDebounceTimer); - pageDebounceTimer = null; - } - pendingAdvanceDirection = null; - } - - function startSelectionMonitor() { - if (selectionMonitorTimer || !supportsCrossPageSelection()) return; - selectionMonitorTimer = setInterval(() => { - try { - evaluateCrossPageSelection(); - } catch {} + function scheduleClearSelectionInteraction() { + if (selectionClearTimer) clearTimeout(selectionClearTimer); + selectionClearTimer = setTimeout(() => { + if (!getSelectionRange(doc.getSelection())) { + markSelectionInteraction(false); + } }, 120); } - function stopSelectionMonitor() { - if (!selectionMonitorTimer) return; - clearInterval(selectionMonitorTimer); - selectionMonitorTimer = null; - } - - function releasePreventScroll() { - const container = getPaginatedContainer(); - if (container && preventScroll) { - container.removeEventListener('scroll', preventScroll); - } - preventScroll = null; - - if (pointerUpCleanup) { - doc.removeEventListener('pointerup', pointerUpCleanup); - } - pointerUpCleanup = null; - } - - function applySelectionContainerLock(container) { - if (!container || lockedContainer === container) return; - - if (lockedContainer && lockedContainerStyles) { - lockedContainer.style.scrollSnapType = lockedContainerStyles.scrollSnapType; - lockedContainer.style.scrollBehavior = lockedContainerStyles.scrollBehavior; - lockedContainer.style.overscrollBehaviorX = lockedContainerStyles.overscrollBehaviorX; - } - - lockedContainer = container; - lockedContainerStyles = { - scrollSnapType: container.style.scrollSnapType, - scrollBehavior: container.style.scrollBehavior, - overscrollBehaviorX: container.style.overscrollBehaviorX, - overflowX: container.style.overflowX, - }; - - container.style.scrollSnapType = 'none'; - container.style.scrollBehavior = 'auto'; - container.style.overscrollBehaviorX = 'contain'; - container.style.overflowX = 'hidden'; - } - - function releaseSelectionContainerLock() { - if (!lockedContainer || !lockedContainerStyles) return; - lockedContainer.style.scrollSnapType = lockedContainerStyles.scrollSnapType; - lockedContainer.style.scrollBehavior = lockedContainerStyles.scrollBehavior; - lockedContainer.style.overscrollBehaviorX = lockedContainerStyles.overscrollBehaviorX; - lockedContainer.style.overflowX = lockedContainerStyles.overflowX; - lockedContainer = null; - lockedContainerStyles = null; - } - - function clearCrossPageSelectionState(options) { - const opts = options || {}; - cancelPendingPageAdvance(); - stopSelectionMonitor(); - releasePreventScroll(); - if (!opts.keepContainerLock) { - releaseSelectionContainerLock(); - } - clearSelectionDragPoint(); - if (!opts.keepInteraction) { - markSelectionInteraction(false); - } - } - - function supportsCrossPageSelection() { - return !!( - view && - !view.isFixedLayout && - view.renderer && - view.renderer.getAttribute && - view.renderer.getAttribute('flow') === 'paginated' - ); - } - doc.addEventListener('touchend', () => { + if (!isIOSLike) return; setTimeout(() => { if (iosHackPending) return; const sel = doc.getSelection(); @@ -2602,147 +2506,35 @@ selTimer = setTimeout(() => { const sel = doc.getSelection(); if (!sel || sel.isCollapsed || !sel.toString().trim()) { - if (!pendingAdvanceDirection && Date.now() >= pageAdvanceLockUntil) { - clearCrossPageSelectionState(); - } + scheduleClearSelectionInteraction(); postToRN('selectionCleared', {}); return; } if (!sel.rangeCount) return; const range = sel.getRangeAt(0); + markSelectionInteraction(true); emitSelection(doc, sel, range); }, 300); }); doc.addEventListener('selectstart', () => { - if (!supportsCrossPageSelection()) return; - const container = getPaginatedContainer(); - if (!container) return; - originalScrollLeft = container.scrollLeft; - clearCrossPageSelectionState({ keepInteraction: true, keepContainerLock: true }); - applySelectionContainerLock(container); markSelectionInteraction(true); - startSelectionMonitor(); }); - function scheduleSelectionPageAdvance(direction) { - if (!direction || !supportsCrossPageSelection()) return; - if (Date.now() < pageAdvanceLockUntil) return; - if (pendingAdvanceDirection === direction && pageDebounceTimer) return; - - cancelPendingPageAdvance(); - pendingAdvanceDirection = direction; - markSelectionInteraction(true); - pageDebounceTimer = setTimeout(async () => { - const advanceDirection = pendingAdvanceDirection; - cancelPendingPageAdvance(); - pageAdvanceLockUntil = Date.now() + 420; - releasePreventScroll(); - try { - if (advanceDirection === 'prev') await view.prev(); - else await view.next(); - await new Promise(resolve => setTimeout(resolve, 180)); - const nextContainer = getPaginatedContainer(); - if (nextContainer) { - originalScrollLeft = nextContainer.scrollLeft; - } - } catch {} - finally { - const activeSelectionRange = getSelectionRange(doc.getSelection()); - if (!activeSelectionRange) { - clearCrossPageSelectionState(); - } else { - clearSelectionDragPoint(); - releaseSelectionContainerLock(); - markSelectionInteraction(false); - } - } - }, 260); - } - - function evaluateCrossPageSelection() { - const lastLocation = view && view.lastLocation; - const selectionRange = getSelectionRange(doc.getSelection()); - const container = getPaginatedContainer(); - if (!container) { - clearCrossPageSelectionState(); - return false; - } - if (!lastLocation || !lastLocation.range) return false; - if (!selectionRange) { - if (!pendingAdvanceDirection && Date.now() >= pageAdvanceLockUntil) { - clearCrossPageSelectionState(); - } - return false; - } - - applySelectionContainerLock(container); - markSelectionInteraction(true); - - if (Date.now() < pageAdvanceLockUntil) { - return false; - } - - const advanceDirection = getSelectionAdvanceIntent( - selectionRange, - lastLocation.range, - selectionDragPoint, - ); - - if (advanceDirection) { - scheduleSelectionPageAdvance(advanceDirection); - return true; - } - - cancelPendingPageAdvance(); - releasePreventScroll(); - preventScroll = () => { - const activeSelectionRange = getSelectionRange(doc.getSelection()); - if (!activeSelectionRange) return; - container.scrollLeft = originalScrollLeft; - }; - - container.addEventListener('scroll', preventScroll); - pointerUpCleanup = () => { - clearCrossPageSelectionState(); - }; - doc.addEventListener('pointerup', pointerUpCleanup); - return false; - } - - doc.addEventListener('pointermove', (e) => { - if (!supportsCrossPageSelection()) return; - if (!doc.__readany_selection_interaction && !getSelectionRange(doc.getSelection())) return; - setSelectionDragPoint(e.clientX, e.clientY); - evaluateCrossPageSelection(); + doc.addEventListener('pointermove', () => { + if (getSelectionRange(doc.getSelection())) markSelectionInteraction(true); }, { passive: true }); - doc.addEventListener('touchmove', (e) => { - if (!supportsCrossPageSelection() || !e.touches || !e.touches.length) return; - if (!doc.__readany_selection_interaction && !getSelectionRange(doc.getSelection())) return; - const touch = e.touches[0]; - setSelectionDragPoint(touch.clientX, touch.clientY); - evaluateCrossPageSelection(); + doc.addEventListener('touchmove', () => { + if (getSelectionRange(doc.getSelection())) markSelectionInteraction(true); }, { passive: true, capture: true }); - doc.addEventListener('selectionchange', () => { - if (doc.__readany_isJiggling) return; - if (iosHackPending) return; - if (!supportsCrossPageSelection()) return; - evaluateCrossPageSelection(); - }); - doc.addEventListener('touchend', () => { - setTimeout(() => { - if (!getSelectionRange(doc.getSelection())) { - markSelectionInteraction(false); - clearCrossPageSelectionState(); - } - }, 80); + scheduleClearSelectionInteraction(); }, { passive: true }); doc.addEventListener('touchcancel', () => { - clearCrossPageSelectionState(); + scheduleClearSelectionInteraction(); }, { passive: true }); } @@ -2818,88 +2610,6 @@ return (fallback || '').trim(); } - function getSelectionEndRect(range) { - if (!range) return null; - try { - const caretRange = range.cloneRange(); - caretRange.collapse(false); - const caretRects = Array.from(caretRange.getClientRects()).filter(r => r.width >= 0 && r.height > 0); - if (caretRects.length) return caretRects[caretRects.length - 1]; - } catch {} - - const rects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0); - return rects[rects.length - 1] || range.getBoundingClientRect(); - } - - function getSelectionAdvanceIntent(selectionRange, lastLocationRange, dragPoint) { - if (!selectionRange || !lastLocationRange) return null; - - try { - if (selectionRange.compareBoundaryPoints(Range.END_TO_END, lastLocationRange) >= 0) { - return 'next'; - } - if (selectionRange.compareBoundaryPoints(Range.START_TO_START, lastLocationRange) <= 0) { - return 'prev'; - } - } catch {} - - try { - const edgeRect = getSelectionEndRect(selectionRange); - const doc = selectionRange.endContainer && selectionRange.endContainer.ownerDocument - ? selectionRange.endContainer.ownerDocument - : null; - const win = doc ? doc.defaultView : null; - const viewportWidth = Math.max( - (win && win.innerWidth) || 0, - (doc && doc.documentElement && doc.documentElement.clientWidth) || 0 - ); - const viewportHeight = Math.max( - (win && win.innerHeight) || 0, - (doc && doc.documentElement && doc.documentElement.clientHeight) || 0 - ); - if (viewportWidth <= 0 || viewportHeight <= 0) return null; - - const point = dragPoint && Date.now() - dragPoint.updatedAt < 1000 - ? dragPoint - : edgeRect - ? { x: edgeRect.right, y: edgeRect.bottom } - : null; - if (!point) return null; - - const isRtl = !!(view.book && view.book.dir === 'rtl'); - const horizontalInset = Math.max(132, Math.min(420, viewportWidth * 0.30)); - const verticalInset = Math.max(140, Math.min(360, viewportHeight * 0.28)); - const inBottomBand = point.y >= viewportHeight - verticalInset; - - if (isRtl) { - if (point.x <= horizontalInset) return 'next'; - if (dragPoint && point.x >= viewportWidth - horizontalInset) return 'prev'; - if (inBottomBand && point.x <= viewportWidth * 0.74) return 'next'; - if (dragPoint && inBottomBand && point.x >= viewportWidth * 0.88) return 'prev'; - } else { - if (point.x >= viewportWidth - horizontalInset) return 'next'; - if (dragPoint && point.x <= horizontalInset) return 'prev'; - if (inBottomBand && point.x >= viewportWidth * 0.26) return 'next'; - if (dragPoint && inBottomBand && point.x <= viewportWidth * 0.12) return 'prev'; - } - - return null; - } catch { - return null; - } - } - - function getPaginatedContainer() { - try { - if (!view || !view.shadowRoot) return null; - const paginator = view.shadowRoot.querySelector('foliate-paginator'); - if (!paginator || !paginator.shadowRoot) return null; - return paginator.shadowRoot.querySelector('#container'); - } catch { - return null; - } - } - function getVisibleTextSnippet(maxLen) { maxLen = maxLen || 80; try { @@ -4807,8 +4517,8 @@ diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index 591a52a8..2d64cfaf 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -2456,127 +2456,31 @@ let selTimer = null; let iosHackPending = false; - let originalScrollLeft = 0; - let pageDebounceTimer = null; - let pendingAdvanceDirection = null; - let selectionMonitorTimer = null; - let preventScroll = null; - let pointerUpCleanup = null; - let selectionDragPoint = null; - let pageAdvanceLockUntil = 0; - let lockedContainer = null; - let lockedContainerStyles = null; + let selectionClearTimer = null; + const isIOSLike = (() => { + try { + return /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + } catch { + return false; + } + })(); function markSelectionInteraction(active) { doc.__readany_selection_interaction = !!active; } - function setSelectionDragPoint(x, y) { - selectionDragPoint = { - x, - y, - updatedAt: Date.now(), - }; - } - - function clearSelectionDragPoint() { - selectionDragPoint = null; - } - - function cancelPendingPageAdvance() { - if (pageDebounceTimer) { - clearTimeout(pageDebounceTimer); - pageDebounceTimer = null; - } - pendingAdvanceDirection = null; - } - - function startSelectionMonitor() { - if (selectionMonitorTimer || !supportsCrossPageSelection()) return; - selectionMonitorTimer = setInterval(() => { - try { - evaluateCrossPageSelection(); - } catch {} + function scheduleClearSelectionInteraction() { + if (selectionClearTimer) clearTimeout(selectionClearTimer); + selectionClearTimer = setTimeout(() => { + if (!getSelectionRange(doc.getSelection())) { + markSelectionInteraction(false); + } }, 120); } - function stopSelectionMonitor() { - if (!selectionMonitorTimer) return; - clearInterval(selectionMonitorTimer); - selectionMonitorTimer = null; - } - - function releasePreventScroll() { - const container = getPaginatedContainer(); - if (container && preventScroll) { - container.removeEventListener('scroll', preventScroll); - } - preventScroll = null; - - if (pointerUpCleanup) { - doc.removeEventListener('pointerup', pointerUpCleanup); - } - pointerUpCleanup = null; - } - - function applySelectionContainerLock(container) { - if (!container || lockedContainer === container) return; - - if (lockedContainer && lockedContainerStyles) { - lockedContainer.style.scrollSnapType = lockedContainerStyles.scrollSnapType; - lockedContainer.style.scrollBehavior = lockedContainerStyles.scrollBehavior; - lockedContainer.style.overscrollBehaviorX = lockedContainerStyles.overscrollBehaviorX; - } - - lockedContainer = container; - lockedContainerStyles = { - scrollSnapType: container.style.scrollSnapType, - scrollBehavior: container.style.scrollBehavior, - overscrollBehaviorX: container.style.overscrollBehaviorX, - overflowX: container.style.overflowX, - }; - - container.style.scrollSnapType = 'none'; - container.style.scrollBehavior = 'auto'; - container.style.overscrollBehaviorX = 'contain'; - container.style.overflowX = 'hidden'; - } - - function releaseSelectionContainerLock() { - if (!lockedContainer || !lockedContainerStyles) return; - lockedContainer.style.scrollSnapType = lockedContainerStyles.scrollSnapType; - lockedContainer.style.scrollBehavior = lockedContainerStyles.scrollBehavior; - lockedContainer.style.overscrollBehaviorX = lockedContainerStyles.overscrollBehaviorX; - lockedContainer.style.overflowX = lockedContainerStyles.overflowX; - lockedContainer = null; - lockedContainerStyles = null; - } - - function clearCrossPageSelectionState(options) { - const opts = options || {}; - cancelPendingPageAdvance(); - stopSelectionMonitor(); - releasePreventScroll(); - if (!opts.keepContainerLock) { - releaseSelectionContainerLock(); - } - clearSelectionDragPoint(); - if (!opts.keepInteraction) { - markSelectionInteraction(false); - } - } - - function supportsCrossPageSelection() { - return !!( - view && - !view.isFixedLayout && - view.renderer && - view.renderer.getAttribute && - view.renderer.getAttribute('flow') === 'paginated' - ); - } - doc.addEventListener('touchend', () => { + if (!isIOSLike) return; setTimeout(() => { if (iosHackPending) return; const sel = doc.getSelection(); @@ -2602,147 +2506,35 @@ selTimer = setTimeout(() => { const sel = doc.getSelection(); if (!sel || sel.isCollapsed || !sel.toString().trim()) { - if (!pendingAdvanceDirection && Date.now() >= pageAdvanceLockUntil) { - clearCrossPageSelectionState(); - } + scheduleClearSelectionInteraction(); postToRN('selectionCleared', {}); return; } if (!sel.rangeCount) return; const range = sel.getRangeAt(0); + markSelectionInteraction(true); emitSelection(doc, sel, range); }, 300); }); doc.addEventListener('selectstart', () => { - if (!supportsCrossPageSelection()) return; - const container = getPaginatedContainer(); - if (!container) return; - originalScrollLeft = container.scrollLeft; - clearCrossPageSelectionState({ keepInteraction: true, keepContainerLock: true }); - applySelectionContainerLock(container); markSelectionInteraction(true); - startSelectionMonitor(); }); - function scheduleSelectionPageAdvance(direction) { - if (!direction || !supportsCrossPageSelection()) return; - if (Date.now() < pageAdvanceLockUntil) return; - if (pendingAdvanceDirection === direction && pageDebounceTimer) return; - - cancelPendingPageAdvance(); - pendingAdvanceDirection = direction; - markSelectionInteraction(true); - pageDebounceTimer = setTimeout(async () => { - const advanceDirection = pendingAdvanceDirection; - cancelPendingPageAdvance(); - pageAdvanceLockUntil = Date.now() + 420; - releasePreventScroll(); - try { - if (advanceDirection === 'prev') await view.prev(); - else await view.next(); - await new Promise(resolve => setTimeout(resolve, 180)); - const nextContainer = getPaginatedContainer(); - if (nextContainer) { - originalScrollLeft = nextContainer.scrollLeft; - } - } catch {} - finally { - const activeSelectionRange = getSelectionRange(doc.getSelection()); - if (!activeSelectionRange) { - clearCrossPageSelectionState(); - } else { - clearSelectionDragPoint(); - releaseSelectionContainerLock(); - markSelectionInteraction(false); - } - } - }, 260); - } - - function evaluateCrossPageSelection() { - const lastLocation = view && view.lastLocation; - const selectionRange = getSelectionRange(doc.getSelection()); - const container = getPaginatedContainer(); - if (!container) { - clearCrossPageSelectionState(); - return false; - } - if (!lastLocation || !lastLocation.range) return false; - if (!selectionRange) { - if (!pendingAdvanceDirection && Date.now() >= pageAdvanceLockUntil) { - clearCrossPageSelectionState(); - } - return false; - } - - applySelectionContainerLock(container); - markSelectionInteraction(true); - - if (Date.now() < pageAdvanceLockUntil) { - return false; - } - - const advanceDirection = getSelectionAdvanceIntent( - selectionRange, - lastLocation.range, - selectionDragPoint, - ); - - if (advanceDirection) { - scheduleSelectionPageAdvance(advanceDirection); - return true; - } - - cancelPendingPageAdvance(); - releasePreventScroll(); - preventScroll = () => { - const activeSelectionRange = getSelectionRange(doc.getSelection()); - if (!activeSelectionRange) return; - container.scrollLeft = originalScrollLeft; - }; - - container.addEventListener('scroll', preventScroll); - pointerUpCleanup = () => { - clearCrossPageSelectionState(); - }; - doc.addEventListener('pointerup', pointerUpCleanup); - return false; - } - - doc.addEventListener('pointermove', (e) => { - if (!supportsCrossPageSelection()) return; - if (!doc.__readany_selection_interaction && !getSelectionRange(doc.getSelection())) return; - setSelectionDragPoint(e.clientX, e.clientY); - evaluateCrossPageSelection(); + doc.addEventListener('pointermove', () => { + if (getSelectionRange(doc.getSelection())) markSelectionInteraction(true); }, { passive: true }); - doc.addEventListener('touchmove', (e) => { - if (!supportsCrossPageSelection() || !e.touches || !e.touches.length) return; - if (!doc.__readany_selection_interaction && !getSelectionRange(doc.getSelection())) return; - const touch = e.touches[0]; - setSelectionDragPoint(touch.clientX, touch.clientY); - evaluateCrossPageSelection(); + doc.addEventListener('touchmove', () => { + if (getSelectionRange(doc.getSelection())) markSelectionInteraction(true); }, { passive: true, capture: true }); - doc.addEventListener('selectionchange', () => { - if (doc.__readany_isJiggling) return; - if (iosHackPending) return; - if (!supportsCrossPageSelection()) return; - evaluateCrossPageSelection(); - }); - doc.addEventListener('touchend', () => { - setTimeout(() => { - if (!getSelectionRange(doc.getSelection())) { - markSelectionInteraction(false); - clearCrossPageSelectionState(); - } - }, 80); + scheduleClearSelectionInteraction(); }, { passive: true }); doc.addEventListener('touchcancel', () => { - clearCrossPageSelectionState(); + scheduleClearSelectionInteraction(); }, { passive: true }); } @@ -2818,88 +2610,6 @@ return (fallback || '').trim(); } - function getSelectionEndRect(range) { - if (!range) return null; - try { - const caretRange = range.cloneRange(); - caretRange.collapse(false); - const caretRects = Array.from(caretRange.getClientRects()).filter(r => r.width >= 0 && r.height > 0); - if (caretRects.length) return caretRects[caretRects.length - 1]; - } catch {} - - const rects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0); - return rects[rects.length - 1] || range.getBoundingClientRect(); - } - - function getSelectionAdvanceIntent(selectionRange, lastLocationRange, dragPoint) { - if (!selectionRange || !lastLocationRange) return null; - - try { - if (selectionRange.compareBoundaryPoints(Range.END_TO_END, lastLocationRange) >= 0) { - return 'next'; - } - if (selectionRange.compareBoundaryPoints(Range.START_TO_START, lastLocationRange) <= 0) { - return 'prev'; - } - } catch {} - - try { - const edgeRect = getSelectionEndRect(selectionRange); - const doc = selectionRange.endContainer && selectionRange.endContainer.ownerDocument - ? selectionRange.endContainer.ownerDocument - : null; - const win = doc ? doc.defaultView : null; - const viewportWidth = Math.max( - (win && win.innerWidth) || 0, - (doc && doc.documentElement && doc.documentElement.clientWidth) || 0 - ); - const viewportHeight = Math.max( - (win && win.innerHeight) || 0, - (doc && doc.documentElement && doc.documentElement.clientHeight) || 0 - ); - if (viewportWidth <= 0 || viewportHeight <= 0) return null; - - const point = dragPoint && Date.now() - dragPoint.updatedAt < 1000 - ? dragPoint - : edgeRect - ? { x: edgeRect.right, y: edgeRect.bottom } - : null; - if (!point) return null; - - const isRtl = !!(view.book && view.book.dir === 'rtl'); - const horizontalInset = Math.max(132, Math.min(420, viewportWidth * 0.30)); - const verticalInset = Math.max(140, Math.min(360, viewportHeight * 0.28)); - const inBottomBand = point.y >= viewportHeight - verticalInset; - - if (isRtl) { - if (point.x <= horizontalInset) return 'next'; - if (dragPoint && point.x >= viewportWidth - horizontalInset) return 'prev'; - if (inBottomBand && point.x <= viewportWidth * 0.74) return 'next'; - if (dragPoint && inBottomBand && point.x >= viewportWidth * 0.88) return 'prev'; - } else { - if (point.x >= viewportWidth - horizontalInset) return 'next'; - if (dragPoint && point.x <= horizontalInset) return 'prev'; - if (inBottomBand && point.x >= viewportWidth * 0.26) return 'next'; - if (dragPoint && inBottomBand && point.x <= viewportWidth * 0.12) return 'prev'; - } - - return null; - } catch { - return null; - } - } - - function getPaginatedContainer() { - try { - if (!view || !view.shadowRoot) return null; - const paginator = view.shadowRoot.querySelector('foliate-paginator'); - if (!paginator || !paginator.shadowRoot) return null; - return paginator.shadowRoot.querySelector('#container'); - } catch { - return null; - } - } - function getVisibleTextSnippet(maxLen) { maxLen = maxLen || 80; try { diff --git a/packages/app-expo/src/screens/ReaderScreen.tsx b/packages/app-expo/src/screens/ReaderScreen.tsx index db8b00f0..d585aed8 100644 --- a/packages/app-expo/src/screens/ReaderScreen.tsx +++ b/packages/app-expo/src/screens/ReaderScreen.tsx @@ -1334,12 +1334,6 @@ export function ReaderScreen({ route, navigation }: Props) { }; }, [bookId, currentCfi, goToCFISafely, loading, navigation, openTTS, webViewReady]); - // Lock navigation when selection is active - useEffect(() => { - if (!webViewReady) return; - bridge.setNavigationLocked(!!selection); - }, [webViewReady, selection]); - if (loading && !webViewReady && !readerHtmlUri) { return ( diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index 1ff48cf7..2e9a8ac6 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -14,6 +14,34 @@ const debounce = (f, wait, immediate) => { } } +const throttle = (f, wait) => { + let timeout + let lastArgs + let lastCall = 0 + const invoke = args => { + lastCall = Date.now() + timeout = null + lastArgs = null + f(...args) + } + return (...args) => { + lastArgs = args + const remaining = wait - (Date.now() - lastCall) + if (remaining <= 0 || remaining > wait) { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + invoke(lastArgs) + } else if (!timeout) { + timeout = setTimeout(() => invoke(lastArgs), remaining) + } + } +} + +const SELECTION_EDGE_TURN_DWELL_MS = 1000 +const SELECTION_EDGE_POINTER_MOVE_TOLERANCE = 12 + // Transforms ALL children of the container so multi-view layouts // animate as a unified whole. Extra elements (e.g. background) are // also transformed so they slide in sync with the content. @@ -238,10 +266,31 @@ const getVisibleRange = (doc, start, end, mapRect) => { } const selectionIsBackward = sel => { - const range = document.createRange() - range.setStart(sel.anchorNode, sel.anchorOffset) - range.setEnd(sel.focusNode, sel.focusOffset) - return range.collapsed + try { + const doc = sel.anchorNode?.ownerDocument ?? sel.focusNode?.ownerDocument ?? document + const range = doc.createRange() + range.setStart(sel.anchorNode, sel.anchorOffset) + range.setEnd(sel.focusNode, sel.focusOffset) + return range.collapsed + } catch { + return false + } +} + +const getRangeEdgeRect = (range, atStart) => { + if (!range) return null + try { + const edge = range.cloneRange() + edge.collapse(atStart) + const rects = [...edge.getClientRects()].filter(r => r.width >= 0 && r.height > 0) + if (rects.length) return rects[atStart ? 0 : rects.length - 1] + } catch {} + try { + const rects = [...range.getClientRects()].filter(r => r.width > 0 && r.height > 0) + return rects[atStart ? 0 : rects.length - 1] ?? range.getBoundingClientRect() + } catch { + return null + } } const setSelectionTo = (target, collapse) => { @@ -1231,20 +1280,165 @@ export class Paginator extends HTMLElement { else setSelectionTo(this.#anchor, -1) } }) - const checkPointerSelection = debounce((range, sel) => { + let pointerSelectionTurn = null + let pointerSelectionEdgeCandidate = null + let lastPointerSelectionPoint = null + let pointerSelectionNodeId = 0 + const pointerSelectionNodeIds = new WeakMap() + const clearPointerSelectionEdgeCandidate = () => { + if (pointerSelectionEdgeCandidate?.timer) clearTimeout(pointerSelectionEdgeCandidate.timer) + pointerSelectionEdgeCandidate = null + } + const runPointerSelectionTurn = direction => { + if (pointerSelectionTurn || !direction) return + const turn = direction < 0 ? this.prev() : this.next() + pointerSelectionTurn = Promise.resolve(turn) + .finally(() => setTimeout(() => { + pointerSelectionTurn = null + }, 80)) + } + const getPointerSelectionNodeId = node => { + if (!node) return 0 + if (!pointerSelectionNodeIds.has(node)) + pointerSelectionNodeIds.set(node, ++pointerSelectionNodeId) + return pointerSelectionNodeIds.get(node) + } + const getPointerSelectionRangeKey = range => range + ? [ + getPointerSelectionNodeId(range.startContainer), + range.startOffset, + getPointerSelectionNodeId(range.endContainer), + range.endOffset, + ].join(':') + : '' + const pointerPointMoved = (a, b) => !a || !b || + Math.hypot(a.x - b.x, a.y - b.y) > SELECTION_EDGE_POINTER_MOVE_TOLERANCE + const refreshPointerSelectionEdgeCandidate = direction => { + const candidate = pointerSelectionEdgeCandidate + if (!candidate) return null + if (direction && candidate.direction !== direction) { + clearPointerSelectionEdgeCandidate() + return null + } + const point = lastPointerSelectionPoint + if (point && point.updatedAt !== candidate.pointUpdatedAt) { + if (pointerPointMoved(candidate.point, point)) { + candidate.point = { x: point.x, y: point.y } + candidate.stableSince = Date.now() + } + candidate.pointUpdatedAt = point.updatedAt + } + return candidate + } + const schedulePointerSelectionEdgeCandidate = candidate => { + if (candidate.timer) clearTimeout(candidate.timer) + const delay = Math.max(0, SELECTION_EDGE_TURN_DWELL_MS - (Date.now() - candidate.stableSince)) + candidate.timer = setTimeout(() => { + const currentCandidate = refreshPointerSelectionEdgeCandidate(candidate.direction) + if (currentCandidate !== candidate) return + if (Date.now() - candidate.stableSince < SELECTION_EDGE_TURN_DWELL_MS) { + schedulePointerSelectionEdgeCandidate(candidate) + return + } + pointerSelectionEdgeCandidate = null + candidate.run() + }, delay) + } + const rememberPointerSelectionPoint = (e, doc) => { + const point = e?.touches?.[0] ?? e?.changedTouches?.[0] ?? e + if (typeof point?.clientX !== 'number' || typeof point?.clientY !== 'number') return + lastPointerSelectionPoint = { + x: point.clientX, + y: point.clientY, + doc, + updatedAt: Date.now(), + } + refreshPointerSelectionEdgeCandidate() + } + const checkPointerSelection = throttle((range, sel, allowEdgeTurn) => { if (this.#navigationLocked) return + if (pointerSelectionTurn) return if (!sel.rangeCount) return const selRange = sel.getRangeAt(0) const backward = selectionIsBackward(sel) - if (backward && selRange.compareBoundaryPoints(Range.START_TO_START, range) < 0) - this.prev() - else if (!backward && selRange.compareBoundaryPoints(Range.END_TO_END, range) > 0) - this.next() - }, 700) + const turnIntent = this.#getPointerSelectionTurn(range, selRange, backward, allowEdgeTurn) + if (!turnIntent.direction) { + clearPointerSelectionEdgeCandidate() + return + } + if (!turnIntent.edge) { + clearPointerSelectionEdgeCandidate() + runPointerSelectionTurn(turnIntent.direction) + return + } + + const direction = turnIntent.direction + const rangeKey = getPointerSelectionRangeKey(selRange) + const existingCandidate = refreshPointerSelectionEdgeCandidate(direction) + if (existingCandidate) { + if (existingCandidate.rangeKey !== rangeKey) { + existingCandidate.rangeKey = rangeKey + existingCandidate.stableSince = Date.now() + schedulePointerSelectionEdgeCandidate(existingCandidate) + } + return + } + + clearPointerSelectionEdgeCandidate() + const doc = sel.anchorNode?.ownerDocument ?? sel.focusNode?.ownerDocument + const point = lastPointerSelectionPoint + pointerSelectionEdgeCandidate = { + direction, + point: point ? { x: point.x, y: point.y } : null, + pointUpdatedAt: point?.updatedAt ?? 0, + rangeKey, + stableSince: Date.now(), + run: () => { + const currentSel = doc?.getSelection?.() + if (!currentSel || currentSel.type !== 'Range' || !currentSel.rangeCount) return + const currentRange = currentSel.getRangeAt(0) + const currentBackward = selectionIsBackward(currentSel) + const currentIntent = this.#getPointerSelectionTurn(range, currentRange, currentBackward, true) + if (currentIntent.edge && currentIntent.direction === direction) + runPointerSelectionTurn(direction) + }, + } + schedulePointerSelectionEdgeCandidate(pointerSelectionEdgeCandidate) + }, 120) this.addEventListener('load', ({ detail: { doc } }) => { let isPointerSelecting = false - doc.addEventListener('pointerdown', () => isPointerSelecting = true) - doc.addEventListener('pointerup', () => isPointerSelecting = false) + let pointerSelectionActiveUntil = 0 + let pointerSelectionChangeCount = 0 + const touchSelectionHandles = globalThis.navigator?.maxTouchPoints > 0 + const markPointerSelecting = e => { + rememberPointerSelectionPoint(e, doc) + isPointerSelecting = true + pointerSelectionActiveUntil = Date.now() + 1200 + } + const beginPointerSelecting = e => { + pointerSelectionChangeCount = 0 + clearPointerSelectionEdgeCandidate() + markPointerSelecting(e) + } + const clearPointerSelecting = () => { + lastPointerSelectionPoint = null + clearPointerSelectionEdgeCandidate() + setTimeout(() => { + if (Date.now() >= pointerSelectionActiveUntil) isPointerSelecting = false + }, 160) + } + doc.addEventListener('selectstart', beginPointerSelecting) + doc.addEventListener('pointerdown', beginPointerSelecting) + doc.addEventListener('pointermove', e => { + if (doc.getSelection()?.type === 'Range') markPointerSelecting(e) + }) + doc.addEventListener('pointerup', clearPointerSelecting) + doc.addEventListener('touchstart', beginPointerSelecting) + doc.addEventListener('touchmove', e => { + if (doc.getSelection()?.type === 'Range') markPointerSelecting(e) + }, { passive: true }) + doc.addEventListener('touchend', clearPointerSelecting) + doc.addEventListener('touchcancel', clearPointerSelecting) let isKeyboardSelecting = false doc.addEventListener('keydown', () => isKeyboardSelecting = true) doc.addEventListener('keyup', () => isKeyboardSelecting = false) @@ -1254,14 +1448,17 @@ export class Paginator extends HTMLElement { if (!range) return const sel = doc.getSelection() if (!sel.rangeCount) return - if (isPointerSelecting && sel.type === 'Range') - checkPointerSelection(range, sel) - else if (isKeyboardSelecting) { + if (isKeyboardSelecting) { const selRange = sel.getRangeAt(0).cloneRange() const backward = selectionIsBackward(sel) if (!backward) selRange.collapse() this.#scrollToAnchor(selRange) } + else if (sel.type === 'Range' && + (isPointerSelecting || Date.now() < pointerSelectionActiveUntil || touchSelectionHandles)) { + pointerSelectionChangeCount += 1 + checkPointerSelection(range, sel, pointerSelectionChangeCount > 1 || touchSelectionHandles) + } }) doc.addEventListener('focusin', e => { if (this.scrolled) return null @@ -1915,6 +2112,38 @@ export class Paginator extends HTMLElement { ? ({ top, bottom }) => ({ left: top, right: bottom }) : f => f } + #getPointerSelectionTurn(visibleRange, selectionRange, backward, allowEdgeTurn) { + try { + if (backward && selectionRange.compareBoundaryPoints(Range.START_TO_START, visibleRange) < 0) + return { direction: -1, edge: false } + if (!backward && selectionRange.compareBoundaryPoints(Range.END_TO_END, visibleRange) > 0) + return { direction: 1, edge: false } + } catch {} + if (!allowEdgeTurn) return { direction: 0, edge: false } + + const doc = selectionRange.commonAncestorContainer?.ownerDocument + ?? selectionRange.startContainer?.ownerDocument + ?? selectionRange.endContainer?.ownerDocument + const entry = [...this.#views].find(([, view]) => view.document === doc) + if (!entry) return { direction: 0, edge: false } + + const [index, view] = entry + const rect = getRangeEdgeRect(selectionRange, backward) + if (!rect) return { direction: 0, edge: false } + + const viewOffset = this.#getViewOffset(index) + const visibleStart = this.#renderedStart - viewOffset + const visibleEnd = this.#renderedEnd - viewOffset + const mapped = this.#getRectMapper(view)(rect) + const edgeInset = Math.max(18, Math.min(44, this.size * 0.07)) + + if (backward && mapped.left <= visibleStart + edgeInset) + return { direction: -1, edge: true } + if (!backward && mapped.right >= visibleEnd - edgeInset) + return { direction: 1, edge: true } + + return { direction: 0, edge: false } + } async #scrollToRect(rect, reason) { if (this.scrolled) { // rect is in iframe-local coordinates; add view offset