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