From 574e760303a954d64e243b70fc6c355245858b19 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Sat, 13 Jun 2026 15:56:29 +0800 Subject: [PATCH 01/22] fix(mobile): tame selection handle page advance --- packages/app-expo/assets/reader/reader.html | 53 +++++++------------ .../assets/reader/reader.template.html | 53 +++++++------------ 2 files changed, 40 insertions(+), 66 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 178d0f69..3baffa90 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -2801,33 +2801,27 @@ 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; + const point = dragPoint && Date.now() - dragPoint.updatedAt < 700 + ? dragPoint + : null; + if (!point) return null; + + let atNextBoundary = false; + let atPrevBoundary = false; try { if (selectionRange.compareBoundaryPoints(Range.END_TO_END, lastLocationRange) >= 0) { - return 'next'; + atNextBoundary = true; } if (selectionRange.compareBoundaryPoints(Range.START_TO_START, lastLocationRange) <= 0) { - return 'prev'; + atPrevBoundary = true; } } catch {} + if (!atNextBoundary && !atPrevBoundary) return null; try { - const edgeRect = getSelectionEndRect(selectionRange); const doc = selectionRange.endContainer && selectionRange.endContainer.ownerDocument ? selectionRange.endContainer.ownerDocument : null; @@ -2842,28 +2836,21 @@ ); 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 horizontalInset = Math.max(48, Math.min(96, viewportWidth * 0.12)); + const verticalInset = Math.max(56, Math.min(120, viewportHeight * 0.14)); 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'; + if (atNextBoundary && point.x <= horizontalInset) return 'next'; + if (atPrevBoundary && point.x >= viewportWidth - horizontalInset) return 'prev'; + if (atNextBoundary && inBottomBand && point.x <= viewportWidth * 0.20) return 'next'; + if (atPrevBoundary && inBottomBand && point.x >= viewportWidth * 0.80) 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'; + if (atNextBoundary && point.x >= viewportWidth - horizontalInset) return 'next'; + if (atPrevBoundary && point.x <= horizontalInset) return 'prev'; + if (atNextBoundary && inBottomBand && point.x >= viewportWidth * 0.80) return 'next'; + if (atPrevBoundary && inBottomBand && point.x <= viewportWidth * 0.20) return 'prev'; } return null; diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index 6ad30183..761c7b99 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -2801,33 +2801,27 @@ 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; + const point = dragPoint && Date.now() - dragPoint.updatedAt < 700 + ? dragPoint + : null; + if (!point) return null; + + let atNextBoundary = false; + let atPrevBoundary = false; try { if (selectionRange.compareBoundaryPoints(Range.END_TO_END, lastLocationRange) >= 0) { - return 'next'; + atNextBoundary = true; } if (selectionRange.compareBoundaryPoints(Range.START_TO_START, lastLocationRange) <= 0) { - return 'prev'; + atPrevBoundary = true; } } catch {} + if (!atNextBoundary && !atPrevBoundary) return null; try { - const edgeRect = getSelectionEndRect(selectionRange); const doc = selectionRange.endContainer && selectionRange.endContainer.ownerDocument ? selectionRange.endContainer.ownerDocument : null; @@ -2842,28 +2836,21 @@ ); 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 horizontalInset = Math.max(48, Math.min(96, viewportWidth * 0.12)); + const verticalInset = Math.max(56, Math.min(120, viewportHeight * 0.14)); 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'; + if (atNextBoundary && point.x <= horizontalInset) return 'next'; + if (atPrevBoundary && point.x >= viewportWidth - horizontalInset) return 'prev'; + if (atNextBoundary && inBottomBand && point.x <= viewportWidth * 0.20) return 'next'; + if (atPrevBoundary && inBottomBand && point.x >= viewportWidth * 0.80) 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'; + if (atNextBoundary && point.x >= viewportWidth - horizontalInset) return 'next'; + if (atPrevBoundary && point.x <= horizontalInset) return 'prev'; + if (atNextBoundary && inBottomBand && point.x >= viewportWidth * 0.80) return 'next'; + if (atPrevBoundary && inBottomBand && point.x <= viewportWidth * 0.20) return 'prev'; } return null; From cea24dcfce86973cf0e5917203d0719328a60d8f Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Sun, 14 Jun 2026 17:53:04 +0800 Subject: [PATCH 02/22] fix(mobile): restore intentional selection edge paging --- packages/app-expo/assets/reader/reader.html | 131 +++++++++++++++--- .../assets/reader/reader.template.html | 131 +++++++++++++++--- 2 files changed, 230 insertions(+), 32 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index b7f14652..452324f4 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -2463,6 +2463,9 @@ let preventScroll = null; let pointerUpCleanup = null; let selectionDragPoint = null; + let selectionChangeVersion = 0; + let selectionChangedAt = 0; + let lastAdvanceSelectionVersion = -1; let pageAdvanceLockUntil = 0; let lockedContainer = null; let lockedContainerStyles = null; @@ -2625,7 +2628,7 @@ startSelectionMonitor(); }); - function scheduleSelectionPageAdvance(direction) { + function scheduleSelectionPageAdvance(direction, selectionVersion) { if (!direction || !supportsCrossPageSelection()) return; if (Date.now() < pageAdvanceLockUntil) return; if (pendingAdvanceDirection === direction && pageDebounceTimer) return; @@ -2636,6 +2639,7 @@ pageDebounceTimer = setTimeout(async () => { const advanceDirection = pendingAdvanceDirection; cancelPendingPageAdvance(); + lastAdvanceSelectionVersion = selectionVersion; pageAdvanceLockUntil = Date.now() + 420; releasePreventScroll(); try { @@ -2687,10 +2691,15 @@ selectionRange, lastLocation.range, selectionDragPoint, + { + allowSelectionEdgeFallback: + selectionChangeVersion !== lastAdvanceSelectionVersion && + Date.now() - selectionChangedAt < 1400, + }, ); if (advanceDirection) { - scheduleSelectionPageAdvance(advanceDirection); + scheduleSelectionPageAdvance(advanceDirection, selectionChangeVersion); return true; } @@ -2728,6 +2737,8 @@ doc.addEventListener('selectionchange', () => { if (doc.__readany_isJiggling) return; if (iosHackPending) return; + selectionChangeVersion += 1; + selectionChangedAt = Date.now(); if (!supportsCrossPageSelection()) return; evaluateCrossPageSelection(); }); @@ -2818,13 +2829,35 @@ return (fallback || '').trim(); } - function getSelectionAdvanceIntent(selectionRange, lastLocationRange, dragPoint) { + function getCollapsedRangeRect(range, collapseToStart) { + if (!range) return null; + try { + const caretRange = range.cloneRange(); + caretRange.collapse(!!collapseToStart); + const rects = Array.from(caretRange.getClientRects()).filter(r => r.width >= 0 && r.height > 0); + return rects[collapseToStart ? 0 : rects.length - 1] || null; + } catch { + return null; + } + } + + function getSelectionEdgeRects(range) { + if (!range) return { start: null, end: null }; + const rects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0); + const bounding = range.getBoundingClientRect(); + return { + start: getCollapsedRangeRect(range, true) || rects[0] || bounding, + end: getCollapsedRangeRect(range, false) || rects[rects.length - 1] || bounding, + }; + } + + function getSelectionAdvanceIntent(selectionRange, lastLocationRange, dragPoint, options) { if (!selectionRange || !lastLocationRange) return null; + const opts = options || {}; const point = dragPoint && Date.now() - dragPoint.updatedAt < 700 ? dragPoint : null; - if (!point) return null; let atNextBoundary = false; let atPrevBoundary = false; @@ -2836,9 +2869,9 @@ atPrevBoundary = true; } } catch {} - if (!atNextBoundary && !atPrevBoundary) return null; try { + const edgeRects = getSelectionEdgeRects(selectionRange); const doc = selectionRange.endContainer && selectionRange.endContainer.ownerDocument ? selectionRange.endContainer.ownerDocument : null; @@ -2854,20 +2887,86 @@ if (viewportWidth <= 0 || viewportHeight <= 0) return null; const isRtl = !!(view.book && view.book.dir === 'rtl'); - const horizontalInset = Math.max(48, Math.min(96, viewportWidth * 0.12)); - const verticalInset = Math.max(56, Math.min(120, viewportHeight * 0.14)); - const inBottomBand = point.y >= viewportHeight - verticalInset; + const edgeInset = Math.max(28, Math.min(72, viewportWidth * 0.08)); + const verticalEdgeInset = Math.max(36, Math.min(96, viewportHeight * 0.10)); + const horizontalInset = Math.max(64, Math.min(128, viewportWidth * 0.16)); + const verticalInset = Math.max(80, Math.min(160, viewportHeight * 0.18)); + + const reachesNextVisualEdge = rect => !!rect && ( + isRtl + ? rect.left <= edgeInset || rect.bottom >= viewportHeight - verticalEdgeInset + : rect.right >= viewportWidth - edgeInset || rect.bottom >= viewportHeight - verticalEdgeInset + ); + const reachesPrevVisualEdge = rect => !!rect && ( + isRtl + ? rect.right >= viewportWidth - edgeInset || rect.top <= verticalEdgeInset + : rect.left <= edgeInset || rect.top <= verticalEdgeInset + ); + + atNextBoundary = atNextBoundary || reachesNextVisualEdge(edgeRects.end); + atPrevBoundary = atPrevBoundary || reachesPrevVisualEdge(edgeRects.start); + if (!atNextBoundary && !atPrevBoundary) return null; + + const fallbackPoint = (rect, direction) => { + if (!opts.allowSelectionEdgeFallback || !rect) return null; + if (direction === 'next') { + if (!reachesNextVisualEdge(rect)) return null; + if (isRtl) { + return { + x: rect.left <= edgeInset ? Math.max(0, rect.left) : 0, + y: rect.bottom >= viewportHeight - verticalEdgeInset ? viewportHeight : Math.min(viewportHeight, rect.bottom), + }; + } + return { + x: rect.right >= viewportWidth - edgeInset ? Math.min(viewportWidth, rect.right) : viewportWidth, + y: rect.bottom >= viewportHeight - verticalEdgeInset ? viewportHeight : Math.min(viewportHeight, rect.bottom), + }; + } + if (!reachesPrevVisualEdge(rect)) return null; + if (isRtl) { + return { + x: rect.right >= viewportWidth - edgeInset ? Math.min(viewportWidth, rect.right) : viewportWidth, + y: rect.top <= verticalEdgeInset ? 0 : Math.max(0, rect.top), + }; + } + return { + x: rect.left <= edgeInset ? Math.max(0, rect.left) : 0, + y: rect.top <= verticalEdgeInset ? 0 : Math.max(0, rect.top), + }; + }; + + const isInBottomBand = candidate => candidate && candidate.y >= viewportHeight - verticalInset; + const isInTopBand = candidate => candidate && candidate.y <= verticalInset; + const isInNextZone = candidate => { + if (!candidate) return false; + if (isRtl) { + return candidate.x <= horizontalInset || + (isInBottomBand(candidate) && candidate.x <= viewportWidth * 0.40); + } + return candidate.x >= viewportWidth - horizontalInset || + (isInBottomBand(candidate) && candidate.x >= viewportWidth * 0.40); + }; + const isInPrevZone = candidate => { + if (!candidate) return false; + if (isRtl) { + return candidate.x >= viewportWidth - horizontalInset || + (isInTopBand(candidate) && candidate.x >= viewportWidth * 0.60) || + (isInBottomBand(candidate) && candidate.x >= viewportWidth * 0.60); + } + return candidate.x <= horizontalInset || + (isInTopBand(candidate) && candidate.x <= viewportWidth * 0.40) || + (isInBottomBand(candidate) && candidate.x <= viewportWidth * 0.40); + }; + + const nextPoint = point || fallbackPoint(edgeRects.end, 'next'); + const prevPoint = point || fallbackPoint(edgeRects.start, 'prev'); if (isRtl) { - if (atNextBoundary && point.x <= horizontalInset) return 'next'; - if (atPrevBoundary && point.x >= viewportWidth - horizontalInset) return 'prev'; - if (atNextBoundary && inBottomBand && point.x <= viewportWidth * 0.20) return 'next'; - if (atPrevBoundary && inBottomBand && point.x >= viewportWidth * 0.80) return 'prev'; + if (atNextBoundary && isInNextZone(nextPoint)) return 'next'; + if (atPrevBoundary && isInPrevZone(prevPoint)) return 'prev'; } else { - if (atNextBoundary && point.x >= viewportWidth - horizontalInset) return 'next'; - if (atPrevBoundary && point.x <= horizontalInset) return 'prev'; - if (atNextBoundary && inBottomBand && point.x >= viewportWidth * 0.80) return 'next'; - if (atPrevBoundary && inBottomBand && point.x <= viewportWidth * 0.20) return 'prev'; + if (atNextBoundary && isInNextZone(nextPoint)) return 'next'; + if (atPrevBoundary && isInPrevZone(prevPoint)) return 'prev'; } return null; diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index 3189387d..f4b470bd 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -2463,6 +2463,9 @@ let preventScroll = null; let pointerUpCleanup = null; let selectionDragPoint = null; + let selectionChangeVersion = 0; + let selectionChangedAt = 0; + let lastAdvanceSelectionVersion = -1; let pageAdvanceLockUntil = 0; let lockedContainer = null; let lockedContainerStyles = null; @@ -2625,7 +2628,7 @@ startSelectionMonitor(); }); - function scheduleSelectionPageAdvance(direction) { + function scheduleSelectionPageAdvance(direction, selectionVersion) { if (!direction || !supportsCrossPageSelection()) return; if (Date.now() < pageAdvanceLockUntil) return; if (pendingAdvanceDirection === direction && pageDebounceTimer) return; @@ -2636,6 +2639,7 @@ pageDebounceTimer = setTimeout(async () => { const advanceDirection = pendingAdvanceDirection; cancelPendingPageAdvance(); + lastAdvanceSelectionVersion = selectionVersion; pageAdvanceLockUntil = Date.now() + 420; releasePreventScroll(); try { @@ -2687,10 +2691,15 @@ selectionRange, lastLocation.range, selectionDragPoint, + { + allowSelectionEdgeFallback: + selectionChangeVersion !== lastAdvanceSelectionVersion && + Date.now() - selectionChangedAt < 1400, + }, ); if (advanceDirection) { - scheduleSelectionPageAdvance(advanceDirection); + scheduleSelectionPageAdvance(advanceDirection, selectionChangeVersion); return true; } @@ -2728,6 +2737,8 @@ doc.addEventListener('selectionchange', () => { if (doc.__readany_isJiggling) return; if (iosHackPending) return; + selectionChangeVersion += 1; + selectionChangedAt = Date.now(); if (!supportsCrossPageSelection()) return; evaluateCrossPageSelection(); }); @@ -2818,13 +2829,35 @@ return (fallback || '').trim(); } - function getSelectionAdvanceIntent(selectionRange, lastLocationRange, dragPoint) { + function getCollapsedRangeRect(range, collapseToStart) { + if (!range) return null; + try { + const caretRange = range.cloneRange(); + caretRange.collapse(!!collapseToStart); + const rects = Array.from(caretRange.getClientRects()).filter(r => r.width >= 0 && r.height > 0); + return rects[collapseToStart ? 0 : rects.length - 1] || null; + } catch { + return null; + } + } + + function getSelectionEdgeRects(range) { + if (!range) return { start: null, end: null }; + const rects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0); + const bounding = range.getBoundingClientRect(); + return { + start: getCollapsedRangeRect(range, true) || rects[0] || bounding, + end: getCollapsedRangeRect(range, false) || rects[rects.length - 1] || bounding, + }; + } + + function getSelectionAdvanceIntent(selectionRange, lastLocationRange, dragPoint, options) { if (!selectionRange || !lastLocationRange) return null; + const opts = options || {}; const point = dragPoint && Date.now() - dragPoint.updatedAt < 700 ? dragPoint : null; - if (!point) return null; let atNextBoundary = false; let atPrevBoundary = false; @@ -2836,9 +2869,9 @@ atPrevBoundary = true; } } catch {} - if (!atNextBoundary && !atPrevBoundary) return null; try { + const edgeRects = getSelectionEdgeRects(selectionRange); const doc = selectionRange.endContainer && selectionRange.endContainer.ownerDocument ? selectionRange.endContainer.ownerDocument : null; @@ -2854,20 +2887,86 @@ if (viewportWidth <= 0 || viewportHeight <= 0) return null; const isRtl = !!(view.book && view.book.dir === 'rtl'); - const horizontalInset = Math.max(48, Math.min(96, viewportWidth * 0.12)); - const verticalInset = Math.max(56, Math.min(120, viewportHeight * 0.14)); - const inBottomBand = point.y >= viewportHeight - verticalInset; + const edgeInset = Math.max(28, Math.min(72, viewportWidth * 0.08)); + const verticalEdgeInset = Math.max(36, Math.min(96, viewportHeight * 0.10)); + const horizontalInset = Math.max(64, Math.min(128, viewportWidth * 0.16)); + const verticalInset = Math.max(80, Math.min(160, viewportHeight * 0.18)); + + const reachesNextVisualEdge = rect => !!rect && ( + isRtl + ? rect.left <= edgeInset || rect.bottom >= viewportHeight - verticalEdgeInset + : rect.right >= viewportWidth - edgeInset || rect.bottom >= viewportHeight - verticalEdgeInset + ); + const reachesPrevVisualEdge = rect => !!rect && ( + isRtl + ? rect.right >= viewportWidth - edgeInset || rect.top <= verticalEdgeInset + : rect.left <= edgeInset || rect.top <= verticalEdgeInset + ); + + atNextBoundary = atNextBoundary || reachesNextVisualEdge(edgeRects.end); + atPrevBoundary = atPrevBoundary || reachesPrevVisualEdge(edgeRects.start); + if (!atNextBoundary && !atPrevBoundary) return null; + + const fallbackPoint = (rect, direction) => { + if (!opts.allowSelectionEdgeFallback || !rect) return null; + if (direction === 'next') { + if (!reachesNextVisualEdge(rect)) return null; + if (isRtl) { + return { + x: rect.left <= edgeInset ? Math.max(0, rect.left) : 0, + y: rect.bottom >= viewportHeight - verticalEdgeInset ? viewportHeight : Math.min(viewportHeight, rect.bottom), + }; + } + return { + x: rect.right >= viewportWidth - edgeInset ? Math.min(viewportWidth, rect.right) : viewportWidth, + y: rect.bottom >= viewportHeight - verticalEdgeInset ? viewportHeight : Math.min(viewportHeight, rect.bottom), + }; + } + if (!reachesPrevVisualEdge(rect)) return null; + if (isRtl) { + return { + x: rect.right >= viewportWidth - edgeInset ? Math.min(viewportWidth, rect.right) : viewportWidth, + y: rect.top <= verticalEdgeInset ? 0 : Math.max(0, rect.top), + }; + } + return { + x: rect.left <= edgeInset ? Math.max(0, rect.left) : 0, + y: rect.top <= verticalEdgeInset ? 0 : Math.max(0, rect.top), + }; + }; + + const isInBottomBand = candidate => candidate && candidate.y >= viewportHeight - verticalInset; + const isInTopBand = candidate => candidate && candidate.y <= verticalInset; + const isInNextZone = candidate => { + if (!candidate) return false; + if (isRtl) { + return candidate.x <= horizontalInset || + (isInBottomBand(candidate) && candidate.x <= viewportWidth * 0.40); + } + return candidate.x >= viewportWidth - horizontalInset || + (isInBottomBand(candidate) && candidate.x >= viewportWidth * 0.40); + }; + const isInPrevZone = candidate => { + if (!candidate) return false; + if (isRtl) { + return candidate.x >= viewportWidth - horizontalInset || + (isInTopBand(candidate) && candidate.x >= viewportWidth * 0.60) || + (isInBottomBand(candidate) && candidate.x >= viewportWidth * 0.60); + } + return candidate.x <= horizontalInset || + (isInTopBand(candidate) && candidate.x <= viewportWidth * 0.40) || + (isInBottomBand(candidate) && candidate.x <= viewportWidth * 0.40); + }; + + const nextPoint = point || fallbackPoint(edgeRects.end, 'next'); + const prevPoint = point || fallbackPoint(edgeRects.start, 'prev'); if (isRtl) { - if (atNextBoundary && point.x <= horizontalInset) return 'next'; - if (atPrevBoundary && point.x >= viewportWidth - horizontalInset) return 'prev'; - if (atNextBoundary && inBottomBand && point.x <= viewportWidth * 0.20) return 'next'; - if (atPrevBoundary && inBottomBand && point.x >= viewportWidth * 0.80) return 'prev'; + if (atNextBoundary && isInNextZone(nextPoint)) return 'next'; + if (atPrevBoundary && isInPrevZone(prevPoint)) return 'prev'; } else { - if (atNextBoundary && point.x >= viewportWidth - horizontalInset) return 'next'; - if (atPrevBoundary && point.x <= horizontalInset) return 'prev'; - if (atNextBoundary && inBottomBand && point.x >= viewportWidth * 0.80) return 'next'; - if (atPrevBoundary && inBottomBand && point.x <= viewportWidth * 0.20) return 'prev'; + if (atNextBoundary && isInNextZone(nextPoint)) return 'next'; + if (atPrevBoundary && isInPrevZone(prevPoint)) return 'prev'; } return null; From ca519b349c53c5448e9a08335cf61044f0e94c28 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Sun, 14 Jun 2026 18:17:36 +0800 Subject: [PATCH 03/22] fix(mobile): enable selection edge paging in default pagination --- packages/app-expo/assets/reader/reader.html | 81 +++++++++++++------ .../assets/reader/reader.template.html | 81 +++++++++++++------ 2 files changed, 110 insertions(+), 52 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 452324f4..6b17963a 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -2570,12 +2570,16 @@ } function supportsCrossPageSelection() { + const renderer = view && view.renderer; + const flow = renderer && renderer.getAttribute + ? renderer.getAttribute('flow') + : null; return !!( view && !view.isFixedLayout && - view.renderer && - view.renderer.getAttribute && - view.renderer.getAttribute('flow') === 'paginated' + renderer && + flow !== 'scrolled' && + renderer.scrolled !== true ); } @@ -2886,25 +2890,50 @@ ); if (viewportWidth <= 0 || viewportHeight <= 0) return null; + const renderer = view && view.renderer; + const visibleRange = renderer && renderer.scrolled !== true && typeof pickPaginatedVisibleRange === 'function' + ? pickPaginatedVisibleRange(doc, renderer) + : null; + const pageWidth = visibleRange + ? Math.max(1, visibleRange.right - visibleRange.left) + : viewportWidth; + const pageHeight = viewportHeight; + const toPageRect = rect => { + if (!rect) return null; + if (!visibleRange) return rect; + return { + left: rect.left - visibleRange.left, + right: rect.right - visibleRange.left, + top: rect.top, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }; + }; + const pageEdgeRects = { + start: toPageRect(edgeRects.start), + end: toPageRect(edgeRects.end), + }; + const isRtl = !!(view.book && view.book.dir === 'rtl'); - const edgeInset = Math.max(28, Math.min(72, viewportWidth * 0.08)); - const verticalEdgeInset = Math.max(36, Math.min(96, viewportHeight * 0.10)); - const horizontalInset = Math.max(64, Math.min(128, viewportWidth * 0.16)); - const verticalInset = Math.max(80, Math.min(160, viewportHeight * 0.18)); + const edgeInset = Math.max(28, Math.min(72, pageWidth * 0.08)); + const verticalEdgeInset = Math.max(36, Math.min(96, pageHeight * 0.10)); + const horizontalInset = Math.max(64, Math.min(128, pageWidth * 0.16)); + const verticalInset = Math.max(80, Math.min(160, pageHeight * 0.18)); const reachesNextVisualEdge = rect => !!rect && ( isRtl - ? rect.left <= edgeInset || rect.bottom >= viewportHeight - verticalEdgeInset - : rect.right >= viewportWidth - edgeInset || rect.bottom >= viewportHeight - verticalEdgeInset + ? rect.left <= edgeInset || rect.bottom >= pageHeight - verticalEdgeInset + : rect.right >= pageWidth - edgeInset || rect.bottom >= pageHeight - verticalEdgeInset ); const reachesPrevVisualEdge = rect => !!rect && ( isRtl - ? rect.right >= viewportWidth - edgeInset || rect.top <= verticalEdgeInset + ? rect.right >= pageWidth - edgeInset || rect.top <= verticalEdgeInset : rect.left <= edgeInset || rect.top <= verticalEdgeInset ); - atNextBoundary = atNextBoundary || reachesNextVisualEdge(edgeRects.end); - atPrevBoundary = atPrevBoundary || reachesPrevVisualEdge(edgeRects.start); + atNextBoundary = atNextBoundary || reachesNextVisualEdge(pageEdgeRects.end); + atPrevBoundary = atPrevBoundary || reachesPrevVisualEdge(pageEdgeRects.start); if (!atNextBoundary && !atPrevBoundary) return null; const fallbackPoint = (rect, direction) => { @@ -2914,18 +2943,18 @@ if (isRtl) { return { x: rect.left <= edgeInset ? Math.max(0, rect.left) : 0, - y: rect.bottom >= viewportHeight - verticalEdgeInset ? viewportHeight : Math.min(viewportHeight, rect.bottom), + y: rect.bottom >= pageHeight - verticalEdgeInset ? pageHeight : Math.min(pageHeight, rect.bottom), }; } return { - x: rect.right >= viewportWidth - edgeInset ? Math.min(viewportWidth, rect.right) : viewportWidth, - y: rect.bottom >= viewportHeight - verticalEdgeInset ? viewportHeight : Math.min(viewportHeight, rect.bottom), + x: rect.right >= pageWidth - edgeInset ? Math.min(pageWidth, rect.right) : pageWidth, + y: rect.bottom >= pageHeight - verticalEdgeInset ? pageHeight : Math.min(pageHeight, rect.bottom), }; } if (!reachesPrevVisualEdge(rect)) return null; if (isRtl) { return { - x: rect.right >= viewportWidth - edgeInset ? Math.min(viewportWidth, rect.right) : viewportWidth, + x: rect.right >= pageWidth - edgeInset ? Math.min(pageWidth, rect.right) : pageWidth, y: rect.top <= verticalEdgeInset ? 0 : Math.max(0, rect.top), }; } @@ -2941,25 +2970,25 @@ if (!candidate) return false; if (isRtl) { return candidate.x <= horizontalInset || - (isInBottomBand(candidate) && candidate.x <= viewportWidth * 0.40); + (isInBottomBand(candidate) && candidate.x <= pageWidth * 0.40); } - return candidate.x >= viewportWidth - horizontalInset || - (isInBottomBand(candidate) && candidate.x >= viewportWidth * 0.40); + return candidate.x >= pageWidth - horizontalInset || + (isInBottomBand(candidate) && candidate.x >= pageWidth * 0.40); }; const isInPrevZone = candidate => { if (!candidate) return false; if (isRtl) { - return candidate.x >= viewportWidth - horizontalInset || - (isInTopBand(candidate) && candidate.x >= viewportWidth * 0.60) || - (isInBottomBand(candidate) && candidate.x >= viewportWidth * 0.60); + return candidate.x >= pageWidth - horizontalInset || + (isInTopBand(candidate) && candidate.x >= pageWidth * 0.60) || + (isInBottomBand(candidate) && candidate.x >= pageWidth * 0.60); } return candidate.x <= horizontalInset || - (isInTopBand(candidate) && candidate.x <= viewportWidth * 0.40) || - (isInBottomBand(candidate) && candidate.x <= viewportWidth * 0.40); + (isInTopBand(candidate) && candidate.x <= pageWidth * 0.40) || + (isInBottomBand(candidate) && candidate.x <= pageWidth * 0.40); }; - const nextPoint = point || fallbackPoint(edgeRects.end, 'next'); - const prevPoint = point || fallbackPoint(edgeRects.start, 'prev'); + const nextPoint = point || fallbackPoint(pageEdgeRects.end, 'next'); + const prevPoint = point || fallbackPoint(pageEdgeRects.start, 'prev'); if (isRtl) { if (atNextBoundary && isInNextZone(nextPoint)) return 'next'; diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index f4b470bd..ab191a72 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -2570,12 +2570,16 @@ } function supportsCrossPageSelection() { + const renderer = view && view.renderer; + const flow = renderer && renderer.getAttribute + ? renderer.getAttribute('flow') + : null; return !!( view && !view.isFixedLayout && - view.renderer && - view.renderer.getAttribute && - view.renderer.getAttribute('flow') === 'paginated' + renderer && + flow !== 'scrolled' && + renderer.scrolled !== true ); } @@ -2886,25 +2890,50 @@ ); if (viewportWidth <= 0 || viewportHeight <= 0) return null; + const renderer = view && view.renderer; + const visibleRange = renderer && renderer.scrolled !== true && typeof pickPaginatedVisibleRange === 'function' + ? pickPaginatedVisibleRange(doc, renderer) + : null; + const pageWidth = visibleRange + ? Math.max(1, visibleRange.right - visibleRange.left) + : viewportWidth; + const pageHeight = viewportHeight; + const toPageRect = rect => { + if (!rect) return null; + if (!visibleRange) return rect; + return { + left: rect.left - visibleRange.left, + right: rect.right - visibleRange.left, + top: rect.top, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }; + }; + const pageEdgeRects = { + start: toPageRect(edgeRects.start), + end: toPageRect(edgeRects.end), + }; + const isRtl = !!(view.book && view.book.dir === 'rtl'); - const edgeInset = Math.max(28, Math.min(72, viewportWidth * 0.08)); - const verticalEdgeInset = Math.max(36, Math.min(96, viewportHeight * 0.10)); - const horizontalInset = Math.max(64, Math.min(128, viewportWidth * 0.16)); - const verticalInset = Math.max(80, Math.min(160, viewportHeight * 0.18)); + const edgeInset = Math.max(28, Math.min(72, pageWidth * 0.08)); + const verticalEdgeInset = Math.max(36, Math.min(96, pageHeight * 0.10)); + const horizontalInset = Math.max(64, Math.min(128, pageWidth * 0.16)); + const verticalInset = Math.max(80, Math.min(160, pageHeight * 0.18)); const reachesNextVisualEdge = rect => !!rect && ( isRtl - ? rect.left <= edgeInset || rect.bottom >= viewportHeight - verticalEdgeInset - : rect.right >= viewportWidth - edgeInset || rect.bottom >= viewportHeight - verticalEdgeInset + ? rect.left <= edgeInset || rect.bottom >= pageHeight - verticalEdgeInset + : rect.right >= pageWidth - edgeInset || rect.bottom >= pageHeight - verticalEdgeInset ); const reachesPrevVisualEdge = rect => !!rect && ( isRtl - ? rect.right >= viewportWidth - edgeInset || rect.top <= verticalEdgeInset + ? rect.right >= pageWidth - edgeInset || rect.top <= verticalEdgeInset : rect.left <= edgeInset || rect.top <= verticalEdgeInset ); - atNextBoundary = atNextBoundary || reachesNextVisualEdge(edgeRects.end); - atPrevBoundary = atPrevBoundary || reachesPrevVisualEdge(edgeRects.start); + atNextBoundary = atNextBoundary || reachesNextVisualEdge(pageEdgeRects.end); + atPrevBoundary = atPrevBoundary || reachesPrevVisualEdge(pageEdgeRects.start); if (!atNextBoundary && !atPrevBoundary) return null; const fallbackPoint = (rect, direction) => { @@ -2914,18 +2943,18 @@ if (isRtl) { return { x: rect.left <= edgeInset ? Math.max(0, rect.left) : 0, - y: rect.bottom >= viewportHeight - verticalEdgeInset ? viewportHeight : Math.min(viewportHeight, rect.bottom), + y: rect.bottom >= pageHeight - verticalEdgeInset ? pageHeight : Math.min(pageHeight, rect.bottom), }; } return { - x: rect.right >= viewportWidth - edgeInset ? Math.min(viewportWidth, rect.right) : viewportWidth, - y: rect.bottom >= viewportHeight - verticalEdgeInset ? viewportHeight : Math.min(viewportHeight, rect.bottom), + x: rect.right >= pageWidth - edgeInset ? Math.min(pageWidth, rect.right) : pageWidth, + y: rect.bottom >= pageHeight - verticalEdgeInset ? pageHeight : Math.min(pageHeight, rect.bottom), }; } if (!reachesPrevVisualEdge(rect)) return null; if (isRtl) { return { - x: rect.right >= viewportWidth - edgeInset ? Math.min(viewportWidth, rect.right) : viewportWidth, + x: rect.right >= pageWidth - edgeInset ? Math.min(pageWidth, rect.right) : pageWidth, y: rect.top <= verticalEdgeInset ? 0 : Math.max(0, rect.top), }; } @@ -2941,25 +2970,25 @@ if (!candidate) return false; if (isRtl) { return candidate.x <= horizontalInset || - (isInBottomBand(candidate) && candidate.x <= viewportWidth * 0.40); + (isInBottomBand(candidate) && candidate.x <= pageWidth * 0.40); } - return candidate.x >= viewportWidth - horizontalInset || - (isInBottomBand(candidate) && candidate.x >= viewportWidth * 0.40); + return candidate.x >= pageWidth - horizontalInset || + (isInBottomBand(candidate) && candidate.x >= pageWidth * 0.40); }; const isInPrevZone = candidate => { if (!candidate) return false; if (isRtl) { - return candidate.x >= viewportWidth - horizontalInset || - (isInTopBand(candidate) && candidate.x >= viewportWidth * 0.60) || - (isInBottomBand(candidate) && candidate.x >= viewportWidth * 0.60); + return candidate.x >= pageWidth - horizontalInset || + (isInTopBand(candidate) && candidate.x >= pageWidth * 0.60) || + (isInBottomBand(candidate) && candidate.x >= pageWidth * 0.60); } return candidate.x <= horizontalInset || - (isInTopBand(candidate) && candidate.x <= viewportWidth * 0.40) || - (isInBottomBand(candidate) && candidate.x <= viewportWidth * 0.40); + (isInTopBand(candidate) && candidate.x <= pageWidth * 0.40) || + (isInBottomBand(candidate) && candidate.x <= pageWidth * 0.40); }; - const nextPoint = point || fallbackPoint(edgeRects.end, 'next'); - const prevPoint = point || fallbackPoint(edgeRects.start, 'prev'); + const nextPoint = point || fallbackPoint(pageEdgeRects.end, 'next'); + const prevPoint = point || fallbackPoint(pageEdgeRects.start, 'prev'); if (isRtl) { if (atNextBoundary && isInNextZone(nextPoint)) return 'next'; From 1b79fba1bdb845afd6d3b41df9c5197ccf4931c9 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Sun, 14 Jun 2026 18:45:37 +0800 Subject: [PATCH 04/22] fix(mobile): defer selection paging to foliate --- packages/app-expo/assets/reader/reader.html | 453 +----------------- .../assets/reader/reader.template.html | 453 +----------------- .../app-expo/src/screens/ReaderScreen.tsx | 6 - 3 files changed, 48 insertions(+), 864 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 6b17963a..a0693d73 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -2456,134 +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 selectionChangeVersion = 0; - let selectionChangedAt = 0; - let lastAdvanceSelectionVersion = -1; - 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() { - const renderer = view && view.renderer; - const flow = renderer && renderer.getAttribute - ? renderer.getAttribute('flow') - : null; - return !!( - view && - !view.isFixedLayout && - renderer && - flow !== 'scrolled' && - renderer.scrolled !== true - ); - } - doc.addEventListener('touchend', () => { + if (!isIOSLike) return; setTimeout(() => { if (iosHackPending) return; const sel = doc.getSelection(); @@ -2609,155 +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, selectionVersion) { - 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(); - lastAdvanceSelectionVersion = selectionVersion; - 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, - { - allowSelectionEdgeFallback: - selectionChangeVersion !== lastAdvanceSelectionVersion && - Date.now() - selectionChangedAt < 1400, - }, - ); - - if (advanceDirection) { - scheduleSelectionPageAdvance(advanceDirection, selectionChangeVersion); - 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; - selectionChangeVersion += 1; - selectionChangedAt = Date.now(); - 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 }); } @@ -2833,188 +2610,6 @@ return (fallback || '').trim(); } - function getCollapsedRangeRect(range, collapseToStart) { - if (!range) return null; - try { - const caretRange = range.cloneRange(); - caretRange.collapse(!!collapseToStart); - const rects = Array.from(caretRange.getClientRects()).filter(r => r.width >= 0 && r.height > 0); - return rects[collapseToStart ? 0 : rects.length - 1] || null; - } catch { - return null; - } - } - - function getSelectionEdgeRects(range) { - if (!range) return { start: null, end: null }; - const rects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0); - const bounding = range.getBoundingClientRect(); - return { - start: getCollapsedRangeRect(range, true) || rects[0] || bounding, - end: getCollapsedRangeRect(range, false) || rects[rects.length - 1] || bounding, - }; - } - - function getSelectionAdvanceIntent(selectionRange, lastLocationRange, dragPoint, options) { - if (!selectionRange || !lastLocationRange) return null; - - const opts = options || {}; - const point = dragPoint && Date.now() - dragPoint.updatedAt < 700 - ? dragPoint - : null; - - let atNextBoundary = false; - let atPrevBoundary = false; - try { - if (selectionRange.compareBoundaryPoints(Range.END_TO_END, lastLocationRange) >= 0) { - atNextBoundary = true; - } - if (selectionRange.compareBoundaryPoints(Range.START_TO_START, lastLocationRange) <= 0) { - atPrevBoundary = true; - } - } catch {} - - try { - const edgeRects = getSelectionEdgeRects(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 renderer = view && view.renderer; - const visibleRange = renderer && renderer.scrolled !== true && typeof pickPaginatedVisibleRange === 'function' - ? pickPaginatedVisibleRange(doc, renderer) - : null; - const pageWidth = visibleRange - ? Math.max(1, visibleRange.right - visibleRange.left) - : viewportWidth; - const pageHeight = viewportHeight; - const toPageRect = rect => { - if (!rect) return null; - if (!visibleRange) return rect; - return { - left: rect.left - visibleRange.left, - right: rect.right - visibleRange.left, - top: rect.top, - bottom: rect.bottom, - width: rect.width, - height: rect.height, - }; - }; - const pageEdgeRects = { - start: toPageRect(edgeRects.start), - end: toPageRect(edgeRects.end), - }; - - const isRtl = !!(view.book && view.book.dir === 'rtl'); - const edgeInset = Math.max(28, Math.min(72, pageWidth * 0.08)); - const verticalEdgeInset = Math.max(36, Math.min(96, pageHeight * 0.10)); - const horizontalInset = Math.max(64, Math.min(128, pageWidth * 0.16)); - const verticalInset = Math.max(80, Math.min(160, pageHeight * 0.18)); - - const reachesNextVisualEdge = rect => !!rect && ( - isRtl - ? rect.left <= edgeInset || rect.bottom >= pageHeight - verticalEdgeInset - : rect.right >= pageWidth - edgeInset || rect.bottom >= pageHeight - verticalEdgeInset - ); - const reachesPrevVisualEdge = rect => !!rect && ( - isRtl - ? rect.right >= pageWidth - edgeInset || rect.top <= verticalEdgeInset - : rect.left <= edgeInset || rect.top <= verticalEdgeInset - ); - - atNextBoundary = atNextBoundary || reachesNextVisualEdge(pageEdgeRects.end); - atPrevBoundary = atPrevBoundary || reachesPrevVisualEdge(pageEdgeRects.start); - if (!atNextBoundary && !atPrevBoundary) return null; - - const fallbackPoint = (rect, direction) => { - if (!opts.allowSelectionEdgeFallback || !rect) return null; - if (direction === 'next') { - if (!reachesNextVisualEdge(rect)) return null; - if (isRtl) { - return { - x: rect.left <= edgeInset ? Math.max(0, rect.left) : 0, - y: rect.bottom >= pageHeight - verticalEdgeInset ? pageHeight : Math.min(pageHeight, rect.bottom), - }; - } - return { - x: rect.right >= pageWidth - edgeInset ? Math.min(pageWidth, rect.right) : pageWidth, - y: rect.bottom >= pageHeight - verticalEdgeInset ? pageHeight : Math.min(pageHeight, rect.bottom), - }; - } - if (!reachesPrevVisualEdge(rect)) return null; - if (isRtl) { - return { - x: rect.right >= pageWidth - edgeInset ? Math.min(pageWidth, rect.right) : pageWidth, - y: rect.top <= verticalEdgeInset ? 0 : Math.max(0, rect.top), - }; - } - return { - x: rect.left <= edgeInset ? Math.max(0, rect.left) : 0, - y: rect.top <= verticalEdgeInset ? 0 : Math.max(0, rect.top), - }; - }; - - const isInBottomBand = candidate => candidate && candidate.y >= viewportHeight - verticalInset; - const isInTopBand = candidate => candidate && candidate.y <= verticalInset; - const isInNextZone = candidate => { - if (!candidate) return false; - if (isRtl) { - return candidate.x <= horizontalInset || - (isInBottomBand(candidate) && candidate.x <= pageWidth * 0.40); - } - return candidate.x >= pageWidth - horizontalInset || - (isInBottomBand(candidate) && candidate.x >= pageWidth * 0.40); - }; - const isInPrevZone = candidate => { - if (!candidate) return false; - if (isRtl) { - return candidate.x >= pageWidth - horizontalInset || - (isInTopBand(candidate) && candidate.x >= pageWidth * 0.60) || - (isInBottomBand(candidate) && candidate.x >= pageWidth * 0.60); - } - return candidate.x <= horizontalInset || - (isInTopBand(candidate) && candidate.x <= pageWidth * 0.40) || - (isInBottomBand(candidate) && candidate.x <= pageWidth * 0.40); - }; - - const nextPoint = point || fallbackPoint(pageEdgeRects.end, 'next'); - const prevPoint = point || fallbackPoint(pageEdgeRects.start, 'prev'); - - if (isRtl) { - if (atNextBoundary && isInNextZone(nextPoint)) return 'next'; - if (atPrevBoundary && isInPrevZone(prevPoint)) return 'prev'; - } else { - if (atNextBoundary && isInNextZone(nextPoint)) return 'next'; - if (atPrevBoundary && isInPrevZone(prevPoint)) 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/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index ab191a72..2d64cfaf 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -2456,134 +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 selectionChangeVersion = 0; - let selectionChangedAt = 0; - let lastAdvanceSelectionVersion = -1; - 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() { - const renderer = view && view.renderer; - const flow = renderer && renderer.getAttribute - ? renderer.getAttribute('flow') - : null; - return !!( - view && - !view.isFixedLayout && - renderer && - flow !== 'scrolled' && - renderer.scrolled !== true - ); - } - doc.addEventListener('touchend', () => { + if (!isIOSLike) return; setTimeout(() => { if (iosHackPending) return; const sel = doc.getSelection(); @@ -2609,155 +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, selectionVersion) { - 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(); - lastAdvanceSelectionVersion = selectionVersion; - 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, - { - allowSelectionEdgeFallback: - selectionChangeVersion !== lastAdvanceSelectionVersion && - Date.now() - selectionChangedAt < 1400, - }, - ); - - if (advanceDirection) { - scheduleSelectionPageAdvance(advanceDirection, selectionChangeVersion); - 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; - selectionChangeVersion += 1; - selectionChangedAt = Date.now(); - 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 }); } @@ -2833,188 +2610,6 @@ return (fallback || '').trim(); } - function getCollapsedRangeRect(range, collapseToStart) { - if (!range) return null; - try { - const caretRange = range.cloneRange(); - caretRange.collapse(!!collapseToStart); - const rects = Array.from(caretRange.getClientRects()).filter(r => r.width >= 0 && r.height > 0); - return rects[collapseToStart ? 0 : rects.length - 1] || null; - } catch { - return null; - } - } - - function getSelectionEdgeRects(range) { - if (!range) return { start: null, end: null }; - const rects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0); - const bounding = range.getBoundingClientRect(); - return { - start: getCollapsedRangeRect(range, true) || rects[0] || bounding, - end: getCollapsedRangeRect(range, false) || rects[rects.length - 1] || bounding, - }; - } - - function getSelectionAdvanceIntent(selectionRange, lastLocationRange, dragPoint, options) { - if (!selectionRange || !lastLocationRange) return null; - - const opts = options || {}; - const point = dragPoint && Date.now() - dragPoint.updatedAt < 700 - ? dragPoint - : null; - - let atNextBoundary = false; - let atPrevBoundary = false; - try { - if (selectionRange.compareBoundaryPoints(Range.END_TO_END, lastLocationRange) >= 0) { - atNextBoundary = true; - } - if (selectionRange.compareBoundaryPoints(Range.START_TO_START, lastLocationRange) <= 0) { - atPrevBoundary = true; - } - } catch {} - - try { - const edgeRects = getSelectionEdgeRects(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 renderer = view && view.renderer; - const visibleRange = renderer && renderer.scrolled !== true && typeof pickPaginatedVisibleRange === 'function' - ? pickPaginatedVisibleRange(doc, renderer) - : null; - const pageWidth = visibleRange - ? Math.max(1, visibleRange.right - visibleRange.left) - : viewportWidth; - const pageHeight = viewportHeight; - const toPageRect = rect => { - if (!rect) return null; - if (!visibleRange) return rect; - return { - left: rect.left - visibleRange.left, - right: rect.right - visibleRange.left, - top: rect.top, - bottom: rect.bottom, - width: rect.width, - height: rect.height, - }; - }; - const pageEdgeRects = { - start: toPageRect(edgeRects.start), - end: toPageRect(edgeRects.end), - }; - - const isRtl = !!(view.book && view.book.dir === 'rtl'); - const edgeInset = Math.max(28, Math.min(72, pageWidth * 0.08)); - const verticalEdgeInset = Math.max(36, Math.min(96, pageHeight * 0.10)); - const horizontalInset = Math.max(64, Math.min(128, pageWidth * 0.16)); - const verticalInset = Math.max(80, Math.min(160, pageHeight * 0.18)); - - const reachesNextVisualEdge = rect => !!rect && ( - isRtl - ? rect.left <= edgeInset || rect.bottom >= pageHeight - verticalEdgeInset - : rect.right >= pageWidth - edgeInset || rect.bottom >= pageHeight - verticalEdgeInset - ); - const reachesPrevVisualEdge = rect => !!rect && ( - isRtl - ? rect.right >= pageWidth - edgeInset || rect.top <= verticalEdgeInset - : rect.left <= edgeInset || rect.top <= verticalEdgeInset - ); - - atNextBoundary = atNextBoundary || reachesNextVisualEdge(pageEdgeRects.end); - atPrevBoundary = atPrevBoundary || reachesPrevVisualEdge(pageEdgeRects.start); - if (!atNextBoundary && !atPrevBoundary) return null; - - const fallbackPoint = (rect, direction) => { - if (!opts.allowSelectionEdgeFallback || !rect) return null; - if (direction === 'next') { - if (!reachesNextVisualEdge(rect)) return null; - if (isRtl) { - return { - x: rect.left <= edgeInset ? Math.max(0, rect.left) : 0, - y: rect.bottom >= pageHeight - verticalEdgeInset ? pageHeight : Math.min(pageHeight, rect.bottom), - }; - } - return { - x: rect.right >= pageWidth - edgeInset ? Math.min(pageWidth, rect.right) : pageWidth, - y: rect.bottom >= pageHeight - verticalEdgeInset ? pageHeight : Math.min(pageHeight, rect.bottom), - }; - } - if (!reachesPrevVisualEdge(rect)) return null; - if (isRtl) { - return { - x: rect.right >= pageWidth - edgeInset ? Math.min(pageWidth, rect.right) : pageWidth, - y: rect.top <= verticalEdgeInset ? 0 : Math.max(0, rect.top), - }; - } - return { - x: rect.left <= edgeInset ? Math.max(0, rect.left) : 0, - y: rect.top <= verticalEdgeInset ? 0 : Math.max(0, rect.top), - }; - }; - - const isInBottomBand = candidate => candidate && candidate.y >= viewportHeight - verticalInset; - const isInTopBand = candidate => candidate && candidate.y <= verticalInset; - const isInNextZone = candidate => { - if (!candidate) return false; - if (isRtl) { - return candidate.x <= horizontalInset || - (isInBottomBand(candidate) && candidate.x <= pageWidth * 0.40); - } - return candidate.x >= pageWidth - horizontalInset || - (isInBottomBand(candidate) && candidate.x >= pageWidth * 0.40); - }; - const isInPrevZone = candidate => { - if (!candidate) return false; - if (isRtl) { - return candidate.x >= pageWidth - horizontalInset || - (isInTopBand(candidate) && candidate.x >= pageWidth * 0.60) || - (isInBottomBand(candidate) && candidate.x >= pageWidth * 0.60); - } - return candidate.x <= horizontalInset || - (isInTopBand(candidate) && candidate.x <= pageWidth * 0.40) || - (isInBottomBand(candidate) && candidate.x <= pageWidth * 0.40); - }; - - const nextPoint = point || fallbackPoint(pageEdgeRects.end, 'next'); - const prevPoint = point || fallbackPoint(pageEdgeRects.start, 'prev'); - - if (isRtl) { - if (atNextBoundary && isInNextZone(nextPoint)) return 'next'; - if (atPrevBoundary && isInPrevZone(prevPoint)) return 'prev'; - } else { - if (atNextBoundary && isInNextZone(nextPoint)) return 'next'; - if (atPrevBoundary && isInPrevZone(prevPoint)) 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 ( From 63c5e5efc4bc0a521dd34f9c167bff3d5457804e Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Sun, 14 Jun 2026 19:06:49 +0800 Subject: [PATCH 05/22] fix(reader): smooth mobile selection auto paging --- packages/app-expo/assets/reader/reader.html | 54 ++++++++-------- packages/foliate-js/paginator.js | 70 ++++++++++++++++++--- 2 files changed, 88 insertions(+), 36 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index a0693d73..4750d2dd 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4517,8 +4517,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index 1ff48cf7..30d4a2e8 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -14,6 +14,31 @@ 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) + } + } +} + // 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. @@ -1231,20 +1256,47 @@ export class Paginator extends HTMLElement { else setSelectionTo(this.#anchor, -1) } }) - const checkPointerSelection = debounce((range, sel) => { + let pointerSelectionTurn = null + const checkPointerSelection = throttle((range, sel) => { 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 turn = backward && selRange.compareBoundaryPoints(Range.START_TO_START, range) < 0 + ? this.prev() + : !backward && selRange.compareBoundaryPoints(Range.END_TO_END, range) > 0 + ? this.next() + : null + if (turn) { + pointerSelectionTurn = Promise.resolve(turn) + .finally(() => setTimeout(() => { + pointerSelectionTurn = null + }, 80)) + } + }, 220) this.addEventListener('load', ({ detail: { doc } }) => { let isPointerSelecting = false - doc.addEventListener('pointerdown', () => isPointerSelecting = true) - doc.addEventListener('pointerup', () => isPointerSelecting = false) + let pointerSelectionActiveUntil = 0 + const markPointerSelecting = () => { + isPointerSelecting = true + pointerSelectionActiveUntil = Date.now() + 1200 + } + const clearPointerSelecting = () => setTimeout(() => { + if (Date.now() >= pointerSelectionActiveUntil) isPointerSelecting = false + }, 160) + doc.addEventListener('selectstart', markPointerSelecting) + doc.addEventListener('pointerdown', markPointerSelecting) + doc.addEventListener('pointermove', () => { + if (doc.getSelection()?.type === 'Range') markPointerSelecting() + }) + doc.addEventListener('pointerup', clearPointerSelecting) + doc.addEventListener('touchstart', markPointerSelecting) + doc.addEventListener('touchmove', () => { + if (doc.getSelection()?.type === 'Range') markPointerSelecting() + }, { passive: true }) + doc.addEventListener('touchend', clearPointerSelecting) + doc.addEventListener('touchcancel', clearPointerSelecting) let isKeyboardSelecting = false doc.addEventListener('keydown', () => isKeyboardSelecting = true) doc.addEventListener('keyup', () => isKeyboardSelecting = false) @@ -1254,7 +1306,7 @@ export class Paginator extends HTMLElement { if (!range) return const sel = doc.getSelection() if (!sel.rangeCount) return - if (isPointerSelecting && sel.type === 'Range') + if ((isPointerSelecting || Date.now() < pointerSelectionActiveUntil) && sel.type === 'Range') checkPointerSelection(range, sel) else if (isKeyboardSelecting) { const selRange = sel.getRangeAt(0).cloneRange() From a58737a6ece5f18e6861a8ad950b2635407197de Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Sun, 14 Jun 2026 21:35:10 +0800 Subject: [PATCH 06/22] fix(reader): trigger selection paging at page edges --- packages/app-expo/assets/reader/reader.html | 46 +++++------ packages/foliate-js/paginator.js | 92 +++++++++++++++++---- 2 files changed, 100 insertions(+), 38 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 4750d2dd..b93c54f2 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4517,8 +4517,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index 30d4a2e8..adc5a8df 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -263,10 +263,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) => { @@ -1257,17 +1278,14 @@ export class Paginator extends HTMLElement { } }) let pointerSelectionTurn = null - const checkPointerSelection = throttle((range, sel) => { + 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) - const turn = backward && selRange.compareBoundaryPoints(Range.START_TO_START, range) < 0 - ? this.prev() - : !backward && selRange.compareBoundaryPoints(Range.END_TO_END, range) > 0 - ? this.next() - : null + const turnDirection = this.#getPointerSelectionTurn(range, selRange, backward, allowEdgeTurn) + const turn = turnDirection < 0 ? this.prev() : turnDirection > 0 ? this.next() : null if (turn) { pointerSelectionTurn = Promise.resolve(turn) .finally(() => setTimeout(() => { @@ -1278,20 +1296,25 @@ export class Paginator extends HTMLElement { this.addEventListener('load', ({ detail: { doc } }) => { let isPointerSelecting = false let pointerSelectionActiveUntil = 0 + let pointerSelectionChangeCount = 0 const markPointerSelecting = () => { isPointerSelecting = true pointerSelectionActiveUntil = Date.now() + 1200 } + const beginPointerSelecting = () => { + pointerSelectionChangeCount = 0 + markPointerSelecting() + } const clearPointerSelecting = () => setTimeout(() => { if (Date.now() >= pointerSelectionActiveUntil) isPointerSelecting = false }, 160) - doc.addEventListener('selectstart', markPointerSelecting) - doc.addEventListener('pointerdown', markPointerSelecting) + doc.addEventListener('selectstart', beginPointerSelecting) + doc.addEventListener('pointerdown', beginPointerSelecting) doc.addEventListener('pointermove', () => { if (doc.getSelection()?.type === 'Range') markPointerSelecting() }) doc.addEventListener('pointerup', clearPointerSelecting) - doc.addEventListener('touchstart', markPointerSelecting) + doc.addEventListener('touchstart', beginPointerSelecting) doc.addEventListener('touchmove', () => { if (doc.getSelection()?.type === 'Range') markPointerSelecting() }, { passive: true }) @@ -1306,8 +1329,10 @@ export class Paginator extends HTMLElement { if (!range) return const sel = doc.getSelection() if (!sel.rangeCount) return - if ((isPointerSelecting || Date.now() < pointerSelectionActiveUntil) && sel.type === 'Range') - checkPointerSelection(range, sel) + if ((isPointerSelecting || Date.now() < pointerSelectionActiveUntil) && sel.type === 'Range') { + pointerSelectionChangeCount += 1 + checkPointerSelection(range, sel, pointerSelectionChangeCount > 1) + } else if (isKeyboardSelecting) { const selRange = sel.getRangeAt(0).cloneRange() const backward = selectionIsBackward(sel) @@ -1967,6 +1992,43 @@ 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 -1 + if (!backward && selectionRange.compareBoundaryPoints(Range.END_TO_END, visibleRange) > 0) + return 1 + } catch {} + if (!allowEdgeTurn) return 0 + + const doc = selectionRange.commonAncestorContainer?.ownerDocument + ?? selectionRange.startContainer?.ownerDocument + ?? selectionRange.endContainer?.ownerDocument + const entry = [...this.#views].find(([, view]) => view.document === doc) + if (!entry) return 0 + + const [index, view] = entry + const rect = getRangeEdgeRect(selectionRange, backward) + if (!rect) return 0 + + 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(32, Math.min(96, this.size * 0.12)) + + if (backward && mapped.left <= visibleStart + edgeInset) return -1 + if (!backward && mapped.right >= visibleEnd - edgeInset) return 1 + + if (!this.#vertical) { + const blockSize = this.#container.getBoundingClientRect().height + const verticalInset = Math.max(48, Math.min(140, blockSize * 0.16)) + if (backward && rect.top <= verticalInset) return -1 + if (!backward && rect.bottom >= blockSize - verticalInset) return 1 + } + + return 0 + } async #scrollToRect(rect, reason) { if (this.scrolled) { // rect is in iframe-local coordinates; add view offset From 12e7d5f6e726a7937880760cd06f6a914cd04664 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Sun, 14 Jun 2026 23:07:38 +0800 Subject: [PATCH 07/22] fix(reader): require dwell before edge selection paging --- packages/app-expo/assets/reader/reader.html | 32 ++++---- packages/foliate-js/paginator.js | 82 ++++++++++++++++----- 2 files changed, 79 insertions(+), 35 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index b93c54f2..19a59bb5 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4517,8 +4517,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index adc5a8df..3de72b61 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -39,6 +39,8 @@ const throttle = (f, wait) => { } } +const SELECTION_EDGE_TURN_DWELL_MS = 500 + // 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. @@ -1278,21 +1280,55 @@ export class Paginator extends HTMLElement { } }) let pointerSelectionTurn = null + let pointerSelectionEdgeCandidate = null + 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 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) - const turnDirection = this.#getPointerSelectionTurn(range, selRange, backward, allowEdgeTurn) - const turn = turnDirection < 0 ? this.prev() : turnDirection > 0 ? this.next() : null - if (turn) { - pointerSelectionTurn = Promise.resolve(turn) - .finally(() => setTimeout(() => { - pointerSelectionTurn = null - }, 80)) + 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 + if (pointerSelectionEdgeCandidate?.direction === direction) return + + clearPointerSelectionEdgeCandidate() + const doc = sel.anchorNode?.ownerDocument ?? sel.focusNode?.ownerDocument + pointerSelectionEdgeCandidate = { + direction, + timer: setTimeout(() => { + pointerSelectionEdgeCandidate = null + 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) + }, SELECTION_EDGE_TURN_DWELL_MS), } - }, 220) + }, 120) this.addEventListener('load', ({ detail: { doc } }) => { let isPointerSelecting = false let pointerSelectionActiveUntil = 0 @@ -1303,10 +1339,14 @@ export class Paginator extends HTMLElement { } const beginPointerSelecting = () => { pointerSelectionChangeCount = 0 + clearPointerSelectionEdgeCandidate() markPointerSelecting() } const clearPointerSelecting = () => setTimeout(() => { - if (Date.now() >= pointerSelectionActiveUntil) isPointerSelecting = false + if (Date.now() >= pointerSelectionActiveUntil) { + isPointerSelecting = false + clearPointerSelectionEdgeCandidate() + } }, 160) doc.addEventListener('selectstart', beginPointerSelecting) doc.addEventListener('pointerdown', beginPointerSelecting) @@ -1995,21 +2035,21 @@ export class Paginator extends HTMLElement { #getPointerSelectionTurn(visibleRange, selectionRange, backward, allowEdgeTurn) { try { if (backward && selectionRange.compareBoundaryPoints(Range.START_TO_START, visibleRange) < 0) - return -1 + return { direction: -1, edge: false } if (!backward && selectionRange.compareBoundaryPoints(Range.END_TO_END, visibleRange) > 0) - return 1 + return { direction: 1, edge: false } } catch {} - if (!allowEdgeTurn) return 0 + 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 0 + if (!entry) return { direction: 0, edge: false } const [index, view] = entry const rect = getRangeEdgeRect(selectionRange, backward) - if (!rect) return 0 + if (!rect) return { direction: 0, edge: false } const viewOffset = this.#getViewOffset(index) const visibleStart = this.#renderedStart - viewOffset @@ -2017,17 +2057,21 @@ export class Paginator extends HTMLElement { const mapped = this.#getRectMapper(view)(rect) const edgeInset = Math.max(32, Math.min(96, this.size * 0.12)) - if (backward && mapped.left <= visibleStart + edgeInset) return -1 - if (!backward && mapped.right >= visibleEnd - edgeInset) return 1 + if (backward && mapped.left <= visibleStart + edgeInset) + return { direction: -1, edge: true } + if (!backward && mapped.right >= visibleEnd - edgeInset) + return { direction: 1, edge: true } if (!this.#vertical) { const blockSize = this.#container.getBoundingClientRect().height const verticalInset = Math.max(48, Math.min(140, blockSize * 0.16)) - if (backward && rect.top <= verticalInset) return -1 - if (!backward && rect.bottom >= blockSize - verticalInset) return 1 + if (backward && rect.top <= verticalInset) + return { direction: -1, edge: true } + if (!backward && rect.bottom >= blockSize - verticalInset) + return { direction: 1, edge: true } } - return 0 + return { direction: 0, edge: false } } async #scrollToRect(rect, reason) { if (this.scrolled) { From fd5dcd2e4676349d9afc6a3a516a04cf3ea48b01 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Mon, 15 Jun 2026 00:24:02 +0800 Subject: [PATCH 08/22] fix(reader): require active dwell for selection edge paging --- packages/app-expo/assets/reader/reader.html | 54 +++++------ packages/foliate-js/paginator.js | 99 +++++++++++++++------ 2 files changed, 101 insertions(+), 52 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 19a59bb5..cc148b51 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4517,8 +4517,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index 3de72b61..7d277664 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -39,7 +39,7 @@ const throttle = (f, wait) => { } } -const SELECTION_EDGE_TURN_DWELL_MS = 500 +const SELECTION_EDGE_TURN_DWELL_MS = 700 // Transforms ALL children of the container so multi-view layouts // animate as a unified whole. Extra elements (e.g. background) are @@ -1281,6 +1281,8 @@ export class Paginator extends HTMLElement { }) let pointerSelectionTurn = null let pointerSelectionEdgeCandidate = null + let lastPointerSelectionPoint = null + let pointerSelectionPressed = false const clearPointerSelectionEdgeCandidate = () => { if (pointerSelectionEdgeCandidate?.timer) clearTimeout(pointerSelectionEdgeCandidate.timer) pointerSelectionEdgeCandidate = null @@ -1293,6 +1295,24 @@ export class Paginator extends HTMLElement { pointerSelectionTurn = null }, 80)) } + 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(), + } + if (pointerSelectionEdgeCandidate && + !this.#pointerPointMatchesSelectionEdge(lastPointerSelectionPoint, pointerSelectionEdgeCandidate.direction)) + clearPointerSelectionEdgeCandidate() + } + const isPressedSelectionEvent = e => + e?.type === 'pointerdown' || + e?.type === 'touchstart' || + e?.type === 'touchmove' || + (e?.type === 'pointermove' && e.buttons > 0) const checkPointerSelection = throttle((range, sel, allowEdgeTurn) => { if (this.#navigationLocked) return if (pointerSelectionTurn) return @@ -1311,6 +1331,11 @@ export class Paginator extends HTMLElement { } const direction = turnIntent.direction + if (!pointerSelectionPressed || + !this.#pointerPointMatchesSelectionEdge(lastPointerSelectionPoint, direction)) { + clearPointerSelectionEdgeCandidate() + return + } if (pointerSelectionEdgeCandidate?.direction === direction) return clearPointerSelectionEdgeCandidate() @@ -1319,12 +1344,15 @@ export class Paginator extends HTMLElement { direction, timer: setTimeout(() => { pointerSelectionEdgeCandidate = null + if (!pointerSelectionPressed) return + if (!this.#pointerPointMatchesSelectionEdge(lastPointerSelectionPoint, direction)) return 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) + if (currentIntent.edge && currentIntent.direction === direction && + this.#pointerPointMatchesSelectionEdge(lastPointerSelectionPoint, direction)) runPointerSelectionTurn(direction) }, SELECTION_EDGE_TURN_DWELL_MS), } @@ -1333,30 +1361,34 @@ export class Paginator extends HTMLElement { let isPointerSelecting = false let pointerSelectionActiveUntil = 0 let pointerSelectionChangeCount = 0 - const markPointerSelecting = () => { + const markPointerSelecting = e => { + if (isPressedSelectionEvent(e)) pointerSelectionPressed = true + rememberPointerSelectionPoint(e, doc) isPointerSelecting = true pointerSelectionActiveUntil = Date.now() + 1200 } - const beginPointerSelecting = () => { + const beginPointerSelecting = e => { pointerSelectionChangeCount = 0 clearPointerSelectionEdgeCandidate() - markPointerSelecting() + markPointerSelecting(e) + } + const clearPointerSelecting = () => { + pointerSelectionPressed = false + lastPointerSelectionPoint = null + clearPointerSelectionEdgeCandidate() + setTimeout(() => { + if (Date.now() >= pointerSelectionActiveUntil) isPointerSelecting = false + }, 160) } - const clearPointerSelecting = () => setTimeout(() => { - if (Date.now() >= pointerSelectionActiveUntil) { - isPointerSelecting = false - clearPointerSelectionEdgeCandidate() - } - }, 160) doc.addEventListener('selectstart', beginPointerSelecting) doc.addEventListener('pointerdown', beginPointerSelecting) - doc.addEventListener('pointermove', () => { - if (doc.getSelection()?.type === 'Range') markPointerSelecting() + doc.addEventListener('pointermove', e => { + if (doc.getSelection()?.type === 'Range') markPointerSelecting(e) }) doc.addEventListener('pointerup', clearPointerSelecting) doc.addEventListener('touchstart', beginPointerSelecting) - doc.addEventListener('touchmove', () => { - if (doc.getSelection()?.type === 'Range') markPointerSelecting() + doc.addEventListener('touchmove', e => { + if (doc.getSelection()?.type === 'Range') markPointerSelecting(e) }, { passive: true }) doc.addEventListener('touchend', clearPointerSelecting) doc.addEventListener('touchcancel', clearPointerSelecting) @@ -2055,24 +2087,41 @@ export class Paginator extends HTMLElement { const visibleStart = this.#renderedStart - viewOffset const visibleEnd = this.#renderedEnd - viewOffset const mapped = this.#getRectMapper(view)(rect) - const edgeInset = Math.max(32, Math.min(96, this.size * 0.12)) + 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 } - if (!this.#vertical) { - const blockSize = this.#container.getBoundingClientRect().height - const verticalInset = Math.max(48, Math.min(140, blockSize * 0.16)) - if (backward && rect.top <= verticalInset) - return { direction: -1, edge: true } - if (!backward && rect.bottom >= blockSize - verticalInset) - return { direction: 1, edge: true } - } - return { direction: 0, edge: false } } + #pointerPointMatchesSelectionEdge(point, direction) { + if (!point || !direction || Date.now() - point.updatedAt > SELECTION_EDGE_TURN_DWELL_MS + 450) + return false + const width = point.doc?.defaultView?.innerWidth + || point.doc?.documentElement?.clientWidth + || this.#container.getBoundingClientRect().width + const height = point.doc?.defaultView?.innerHeight + || point.doc?.documentElement?.clientHeight + || this.#container.getBoundingClientRect().height + if (width <= 0 || height <= 0) return false + + const inlineInset = Math.max(16, Math.min(36, (this.#vertical ? height : width) * 0.06)) + if (this.#vertical) { + return direction < 0 + ? point.y <= inlineInset + : point.y >= height - inlineInset + } + if (this.#rtl) { + return direction < 0 + ? point.x >= width - inlineInset + : point.x <= inlineInset + } + return direction < 0 + ? point.x <= inlineInset + : point.x >= width - inlineInset + } async #scrollToRect(rect, reason) { if (this.scrolled) { // rect is in iframe-local coordinates; add view offset From 18bdf7c790abab9770c1c772c791d89679143576 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Mon, 15 Jun 2026 00:44:53 +0800 Subject: [PATCH 09/22] fix(reader): require stable edge dwell for selection paging --- packages/app-expo/assets/reader/reader.html | 46 ++++----- packages/foliate-js/paginator.js | 104 +++++++++++++++----- 2 files changed, 104 insertions(+), 46 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index cc148b51..117ee025 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4517,8 +4517,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index 7d277664..d39d4eab 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -39,7 +39,8 @@ const throttle = (f, wait) => { } } -const SELECTION_EDGE_TURN_DWELL_MS = 700 +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 @@ -1282,7 +1283,8 @@ export class Paginator extends HTMLElement { let pointerSelectionTurn = null let pointerSelectionEdgeCandidate = null let lastPointerSelectionPoint = null - let pointerSelectionPressed = false + let pointerSelectionNodeId = 0 + const pointerSelectionNodeIds = new WeakMap() const clearPointerSelectionEdgeCandidate = () => { if (pointerSelectionEdgeCandidate?.timer) clearTimeout(pointerSelectionEdgeCandidate.timer) pointerSelectionEdgeCandidate = null @@ -1295,6 +1297,60 @@ export class Paginator extends HTMLElement { 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 pointerSelectionPointAllowsEdgeTurn = direction => + !lastPointerSelectionPoint || + this.#pointerPointMatchesSelectionEdge(lastPointerSelectionPoint, direction) + const refreshPointerSelectionEdgeCandidate = direction => { + const candidate = pointerSelectionEdgeCandidate + if (!candidate) return null + if (direction && candidate.direction !== direction) { + clearPointerSelectionEdgeCandidate() + return null + } + if (!pointerSelectionPointAllowsEdgeTurn(candidate.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 @@ -1304,15 +1360,8 @@ export class Paginator extends HTMLElement { doc, updatedAt: Date.now(), } - if (pointerSelectionEdgeCandidate && - !this.#pointerPointMatchesSelectionEdge(lastPointerSelectionPoint, pointerSelectionEdgeCandidate.direction)) - clearPointerSelectionEdgeCandidate() + refreshPointerSelectionEdgeCandidate() } - const isPressedSelectionEvent = e => - e?.type === 'pointerdown' || - e?.type === 'touchstart' || - e?.type === 'touchmove' || - (e?.type === 'pointermove' && e.buttons > 0) const checkPointerSelection = throttle((range, sel, allowEdgeTurn) => { if (this.#navigationLocked) return if (pointerSelectionTurn) return @@ -1331,38 +1380,48 @@ export class Paginator extends HTMLElement { } const direction = turnIntent.direction - if (!pointerSelectionPressed || - !this.#pointerPointMatchesSelectionEdge(lastPointerSelectionPoint, direction)) { + if (!pointerSelectionPointAllowsEdgeTurn(direction)) { clearPointerSelectionEdgeCandidate() return } - if (pointerSelectionEdgeCandidate?.direction === direction) return + 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, - timer: setTimeout(() => { - pointerSelectionEdgeCandidate = null - if (!pointerSelectionPressed) return - if (!this.#pointerPointMatchesSelectionEdge(lastPointerSelectionPoint, direction)) return + point: point ? { x: point.x, y: point.y } : null, + pointUpdatedAt: point?.updatedAt ?? 0, + rangeKey, + stableSince: Date.now(), + run: () => { + if (!pointerSelectionPointAllowsEdgeTurn(direction)) return 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 && - this.#pointerPointMatchesSelectionEdge(lastPointerSelectionPoint, direction)) + if (currentIntent.edge && currentIntent.direction === direction) runPointerSelectionTurn(direction) - }, SELECTION_EDGE_TURN_DWELL_MS), + }, } + schedulePointerSelectionEdgeCandidate(pointerSelectionEdgeCandidate) }, 120) this.addEventListener('load', ({ detail: { doc } }) => { let isPointerSelecting = false let pointerSelectionActiveUntil = 0 let pointerSelectionChangeCount = 0 const markPointerSelecting = e => { - if (isPressedSelectionEvent(e)) pointerSelectionPressed = true rememberPointerSelectionPoint(e, doc) isPointerSelecting = true pointerSelectionActiveUntil = Date.now() + 1200 @@ -1373,7 +1432,6 @@ export class Paginator extends HTMLElement { markPointerSelecting(e) } const clearPointerSelecting = () => { - pointerSelectionPressed = false lastPointerSelectionPoint = null clearPointerSelectionEdgeCandidate() setTimeout(() => { @@ -2097,7 +2155,7 @@ export class Paginator extends HTMLElement { return { direction: 0, edge: false } } #pointerPointMatchesSelectionEdge(point, direction) { - if (!point || !direction || Date.now() - point.updatedAt > SELECTION_EDGE_TURN_DWELL_MS + 450) + if (!point || !direction || Date.now() - point.updatedAt > SELECTION_EDGE_TURN_DWELL_MS + 2000) return false const width = point.doc?.defaultView?.innerWidth || point.doc?.documentElement?.clientWidth From 4bcb4122fba3fdbba6deba0fd41673c6074bffd3 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Mon, 15 Jun 2026 00:57:22 +0800 Subject: [PATCH 10/22] fix(reader): restore Android selection edge paging --- packages/app-expo/assets/reader/reader.html | 46 +++++++++---------- packages/foliate-js/paginator.js | 50 +++------------------ 2 files changed, 30 insertions(+), 66 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 117ee025..cd3e4c12 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4517,8 +4517,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index d39d4eab..2e9a8ac6 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -1313,9 +1313,6 @@ export class Paginator extends HTMLElement { : '' const pointerPointMoved = (a, b) => !a || !b || Math.hypot(a.x - b.x, a.y - b.y) > SELECTION_EDGE_POINTER_MOVE_TOLERANCE - const pointerSelectionPointAllowsEdgeTurn = direction => - !lastPointerSelectionPoint || - this.#pointerPointMatchesSelectionEdge(lastPointerSelectionPoint, direction) const refreshPointerSelectionEdgeCandidate = direction => { const candidate = pointerSelectionEdgeCandidate if (!candidate) return null @@ -1323,10 +1320,6 @@ export class Paginator extends HTMLElement { clearPointerSelectionEdgeCandidate() return null } - if (!pointerSelectionPointAllowsEdgeTurn(candidate.direction)) { - clearPointerSelectionEdgeCandidate() - return null - } const point = lastPointerSelectionPoint if (point && point.updatedAt !== candidate.pointUpdatedAt) { if (pointerPointMoved(candidate.point, point)) { @@ -1380,10 +1373,6 @@ export class Paginator extends HTMLElement { } const direction = turnIntent.direction - if (!pointerSelectionPointAllowsEdgeTurn(direction)) { - clearPointerSelectionEdgeCandidate() - return - } const rangeKey = getPointerSelectionRangeKey(selRange) const existingCandidate = refreshPointerSelectionEdgeCandidate(direction) if (existingCandidate) { @@ -1405,7 +1394,6 @@ export class Paginator extends HTMLElement { rangeKey, stableSince: Date.now(), run: () => { - if (!pointerSelectionPointAllowsEdgeTurn(direction)) return const currentSel = doc?.getSelection?.() if (!currentSel || currentSel.type !== 'Range' || !currentSel.rangeCount) return const currentRange = currentSel.getRangeAt(0) @@ -1421,6 +1409,7 @@ export class Paginator extends HTMLElement { let isPointerSelecting = false let pointerSelectionActiveUntil = 0 let pointerSelectionChangeCount = 0 + const touchSelectionHandles = globalThis.navigator?.maxTouchPoints > 0 const markPointerSelecting = e => { rememberPointerSelectionPoint(e, doc) isPointerSelecting = true @@ -1459,16 +1448,17 @@ export class Paginator extends HTMLElement { if (!range) return const sel = doc.getSelection() if (!sel.rangeCount) return - if ((isPointerSelecting || Date.now() < pointerSelectionActiveUntil) && sel.type === 'Range') { - pointerSelectionChangeCount += 1 - checkPointerSelection(range, sel, pointerSelectionChangeCount > 1) - } - 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 @@ -2154,32 +2144,6 @@ export class Paginator extends HTMLElement { return { direction: 0, edge: false } } - #pointerPointMatchesSelectionEdge(point, direction) { - if (!point || !direction || Date.now() - point.updatedAt > SELECTION_EDGE_TURN_DWELL_MS + 2000) - return false - const width = point.doc?.defaultView?.innerWidth - || point.doc?.documentElement?.clientWidth - || this.#container.getBoundingClientRect().width - const height = point.doc?.defaultView?.innerHeight - || point.doc?.documentElement?.clientHeight - || this.#container.getBoundingClientRect().height - if (width <= 0 || height <= 0) return false - - const inlineInset = Math.max(16, Math.min(36, (this.#vertical ? height : width) * 0.06)) - if (this.#vertical) { - return direction < 0 - ? point.y <= inlineInset - : point.y >= height - inlineInset - } - if (this.#rtl) { - return direction < 0 - ? point.x >= width - inlineInset - : point.x <= inlineInset - } - return direction < 0 - ? point.x <= inlineInset - : point.x >= width - inlineInset - } async #scrollToRect(rect, reason) { if (this.scrolled) { // rect is in iframe-local coordinates; add view offset From 34b1793ad43ceda75dc5e68d2bbd1839eb1aa220 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Mon, 15 Jun 2026 23:18:05 +0800 Subject: [PATCH 11/22] fix(reader): prevent repeated selection edge turns --- packages/app-expo/assets/reader/reader.html | 36 +++++++++--------- packages/foliate-js/paginator.js | 42 +++++++++------------ 2 files changed, 36 insertions(+), 42 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index cd3e4c12..c2a47730 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4517,7 +4517,7 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index 2e9a8ac6..1c53d3e2 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -1282,9 +1282,8 @@ export class Paginator extends HTMLElement { }) let pointerSelectionTurn = null let pointerSelectionEdgeCandidate = null + let lastPointerSelectionTurn = null let lastPointerSelectionPoint = null - let pointerSelectionNodeId = 0 - const pointerSelectionNodeIds = new WeakMap() const clearPointerSelectionEdgeCandidate = () => { if (pointerSelectionEdgeCandidate?.timer) clearTimeout(pointerSelectionEdgeCandidate.timer) pointerSelectionEdgeCandidate = null @@ -1297,20 +1296,6 @@ export class Paginator extends HTMLElement { 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 => { @@ -1353,6 +1338,9 @@ export class Paginator extends HTMLElement { doc, updatedAt: Date.now(), } + if (lastPointerSelectionTurn && + lastPointerSelectionTurn.pointUpdatedAt !== lastPointerSelectionPoint.updatedAt) + lastPointerSelectionTurn = null refreshPointerSelectionEdgeCandidate() } const checkPointerSelection = throttle((range, sel, allowEdgeTurn) => { @@ -1363,24 +1351,26 @@ export class Paginator extends HTMLElement { const backward = selectionIsBackward(sel) const turnIntent = this.#getPointerSelectionTurn(range, selRange, backward, allowEdgeTurn) if (!turnIntent.direction) { + lastPointerSelectionTurn = null clearPointerSelectionEdgeCandidate() return } if (!turnIntent.edge) { + lastPointerSelectionTurn = null clearPointerSelectionEdgeCandidate() runPointerSelectionTurn(turnIntent.direction) return } const direction = turnIntent.direction - const rangeKey = getPointerSelectionRangeKey(selRange) + if (lastPointerSelectionTurn && + lastPointerSelectionTurn.direction === direction && + lastPointerSelectionTurn.pointUpdatedAt === (lastPointerSelectionPoint?.updatedAt ?? 0)) { + clearPointerSelectionEdgeCandidate() + return + } const existingCandidate = refreshPointerSelectionEdgeCandidate(direction) if (existingCandidate) { - if (existingCandidate.rangeKey !== rangeKey) { - existingCandidate.rangeKey = rangeKey - existingCandidate.stableSince = Date.now() - schedulePointerSelectionEdgeCandidate(existingCandidate) - } return } @@ -1391,7 +1381,6 @@ export class Paginator extends HTMLElement { direction, point: point ? { x: point.x, y: point.y } : null, pointUpdatedAt: point?.updatedAt ?? 0, - rangeKey, stableSince: Date.now(), run: () => { const currentSel = doc?.getSelection?.() @@ -1399,8 +1388,13 @@ export class Paginator extends HTMLElement { const currentRange = currentSel.getRangeAt(0) const currentBackward = selectionIsBackward(currentSel) const currentIntent = this.#getPointerSelectionTurn(range, currentRange, currentBackward, true) - if (currentIntent.edge && currentIntent.direction === direction) + if (currentIntent.edge && currentIntent.direction === direction) { + lastPointerSelectionTurn = { + direction, + pointUpdatedAt: lastPointerSelectionPoint?.updatedAt ?? 0, + } runPointerSelectionTurn(direction) + } }, } schedulePointerSelectionEdgeCandidate(pointerSelectionEdgeCandidate) From 90b780ac492ee8ef27ea16ee28689f8cb02318c2 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Mon, 15 Jun 2026 23:52:05 +0800 Subject: [PATCH 12/22] fix(reader): add cooldown after edge selection turn --- packages/app-expo/assets/reader/reader.html | 42 ++++++++++----------- packages/foliate-js/paginator.js | 20 ++++------ 2 files changed, 28 insertions(+), 34 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index c2a47730..5fe1f83a 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4517,8 +4517,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index 1c53d3e2..963aa547 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -41,6 +41,7 @@ const throttle = (f, wait) => { const SELECTION_EDGE_TURN_DWELL_MS = 1000 const SELECTION_EDGE_POINTER_MOVE_TOLERANCE = 12 +const SELECTION_EDGE_TURN_COOLDOWN_MS = 1200 // Transforms ALL children of the container so multi-view layouts // animate as a unified whole. Extra elements (e.g. background) are @@ -1282,7 +1283,8 @@ export class Paginator extends HTMLElement { }) let pointerSelectionTurn = null let pointerSelectionEdgeCandidate = null - let lastPointerSelectionTurn = null + let lastPointerSelectionTurnAt = 0 + let lastPointerSelectionTurnDirection = 0 let lastPointerSelectionPoint = null const clearPointerSelectionEdgeCandidate = () => { if (pointerSelectionEdgeCandidate?.timer) clearTimeout(pointerSelectionEdgeCandidate.timer) @@ -1338,9 +1340,6 @@ export class Paginator extends HTMLElement { doc, updatedAt: Date.now(), } - if (lastPointerSelectionTurn && - lastPointerSelectionTurn.pointUpdatedAt !== lastPointerSelectionPoint.updatedAt) - lastPointerSelectionTurn = null refreshPointerSelectionEdgeCandidate() } const checkPointerSelection = throttle((range, sel, allowEdgeTurn) => { @@ -1351,21 +1350,18 @@ export class Paginator extends HTMLElement { const backward = selectionIsBackward(sel) const turnIntent = this.#getPointerSelectionTurn(range, selRange, backward, allowEdgeTurn) if (!turnIntent.direction) { - lastPointerSelectionTurn = null clearPointerSelectionEdgeCandidate() return } if (!turnIntent.edge) { - lastPointerSelectionTurn = null clearPointerSelectionEdgeCandidate() runPointerSelectionTurn(turnIntent.direction) return } const direction = turnIntent.direction - if (lastPointerSelectionTurn && - lastPointerSelectionTurn.direction === direction && - lastPointerSelectionTurn.pointUpdatedAt === (lastPointerSelectionPoint?.updatedAt ?? 0)) { + if (lastPointerSelectionTurnDirection === direction && + Date.now() - lastPointerSelectionTurnAt < SELECTION_EDGE_TURN_COOLDOWN_MS) { clearPointerSelectionEdgeCandidate() return } @@ -1389,10 +1385,8 @@ export class Paginator extends HTMLElement { const currentBackward = selectionIsBackward(currentSel) const currentIntent = this.#getPointerSelectionTurn(range, currentRange, currentBackward, true) if (currentIntent.edge && currentIntent.direction === direction) { - lastPointerSelectionTurn = { - direction, - pointUpdatedAt: lastPointerSelectionPoint?.updatedAt ?? 0, - } + lastPointerSelectionTurnDirection = direction + lastPointerSelectionTurnAt = Date.now() runPointerSelectionTurn(direction) } }, From 31ad2ff6e5c565f06aef163112aa782c10a7ef13 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Tue, 16 Jun 2026 00:13:29 +0800 Subject: [PATCH 13/22] fix(reader): consume edge selection turns once --- packages/app-expo/assets/reader/reader.html | 42 ++++++++++----------- packages/foliate-js/paginator.js | 19 +++++++--- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 5fe1f83a..5465f643 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4517,8 +4517,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index 963aa547..5a8e23f0 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -41,7 +41,6 @@ const throttle = (f, wait) => { const SELECTION_EDGE_TURN_DWELL_MS = 1000 const SELECTION_EDGE_POINTER_MOVE_TOLERANCE = 12 -const SELECTION_EDGE_TURN_COOLDOWN_MS = 1200 // Transforms ALL children of the container so multi-view layouts // animate as a unified whole. Extra elements (e.g. background) are @@ -1283,9 +1282,13 @@ export class Paginator extends HTMLElement { }) let pointerSelectionTurn = null let pointerSelectionEdgeCandidate = null - let lastPointerSelectionTurnAt = 0 let lastPointerSelectionTurnDirection = 0 + let lastPointerSelectionTurnNeedsExit = false let lastPointerSelectionPoint = null + const resetPointerSelectionTurnLock = () => { + lastPointerSelectionTurnDirection = 0 + lastPointerSelectionTurnNeedsExit = false + } const clearPointerSelectionEdgeCandidate = () => { if (pointerSelectionEdgeCandidate?.timer) clearTimeout(pointerSelectionEdgeCandidate.timer) pointerSelectionEdgeCandidate = null @@ -1350,18 +1353,20 @@ export class Paginator extends HTMLElement { const backward = selectionIsBackward(sel) const turnIntent = this.#getPointerSelectionTurn(range, selRange, backward, allowEdgeTurn) if (!turnIntent.direction) { + resetPointerSelectionTurnLock() clearPointerSelectionEdgeCandidate() return } if (!turnIntent.edge) { + resetPointerSelectionTurnLock() clearPointerSelectionEdgeCandidate() runPointerSelectionTurn(turnIntent.direction) return } const direction = turnIntent.direction - if (lastPointerSelectionTurnDirection === direction && - Date.now() - lastPointerSelectionTurnAt < SELECTION_EDGE_TURN_COOLDOWN_MS) { + if (lastPointerSelectionTurnNeedsExit && + lastPointerSelectionTurnDirection === direction) { clearPointerSelectionEdgeCandidate() return } @@ -1386,7 +1391,7 @@ export class Paginator extends HTMLElement { const currentIntent = this.#getPointerSelectionTurn(range, currentRange, currentBackward, true) if (currentIntent.edge && currentIntent.direction === direction) { lastPointerSelectionTurnDirection = direction - lastPointerSelectionTurnAt = Date.now() + lastPointerSelectionTurnNeedsExit = true runPointerSelectionTurn(direction) } }, @@ -1405,6 +1410,7 @@ export class Paginator extends HTMLElement { } const beginPointerSelecting = e => { pointerSelectionChangeCount = 0 + resetPointerSelectionTurnLock() clearPointerSelectionEdgeCandidate() markPointerSelecting(e) } @@ -1447,6 +1453,9 @@ export class Paginator extends HTMLElement { pointerSelectionChangeCount += 1 checkPointerSelection(range, sel, pointerSelectionChangeCount > 1 || touchSelectionHandles) } + else if (sel.type !== 'Range' || sel.isCollapsed) { + resetPointerSelectionTurnLock() + } }) doc.addEventListener('focusin', e => { if (this.scrolled) return null From 9364f122a389eab78bca2466104bdf768b183796 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Tue, 16 Jun 2026 00:21:22 +0800 Subject: [PATCH 14/22] fix(reader): widen edge selection trigger zone --- packages/app-expo/assets/reader/reader.html | 42 ++++++++++----------- packages/foliate-js/paginator.js | 23 ++++------- 2 files changed, 28 insertions(+), 37 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 5465f643..3e6355af 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4517,8 +4517,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index 5a8e23f0..93ab85aa 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -40,7 +40,8 @@ const throttle = (f, wait) => { } const SELECTION_EDGE_TURN_DWELL_MS = 1000 -const SELECTION_EDGE_POINTER_MOVE_TOLERANCE = 12 +const SELECTION_EDGE_POINTER_MOVE_TOLERANCE = 18 +const SELECTION_EDGE_TURN_COOLDOWN_MS = 900 // Transforms ALL children of the container so multi-view layouts // animate as a unified whole. Extra elements (e.g. background) are @@ -1282,13 +1283,9 @@ export class Paginator extends HTMLElement { }) let pointerSelectionTurn = null let pointerSelectionEdgeCandidate = null + let lastPointerSelectionTurnAt = 0 let lastPointerSelectionTurnDirection = 0 - let lastPointerSelectionTurnNeedsExit = false let lastPointerSelectionPoint = null - const resetPointerSelectionTurnLock = () => { - lastPointerSelectionTurnDirection = 0 - lastPointerSelectionTurnNeedsExit = false - } const clearPointerSelectionEdgeCandidate = () => { if (pointerSelectionEdgeCandidate?.timer) clearTimeout(pointerSelectionEdgeCandidate.timer) pointerSelectionEdgeCandidate = null @@ -1353,20 +1350,18 @@ export class Paginator extends HTMLElement { const backward = selectionIsBackward(sel) const turnIntent = this.#getPointerSelectionTurn(range, selRange, backward, allowEdgeTurn) if (!turnIntent.direction) { - resetPointerSelectionTurnLock() clearPointerSelectionEdgeCandidate() return } if (!turnIntent.edge) { - resetPointerSelectionTurnLock() clearPointerSelectionEdgeCandidate() runPointerSelectionTurn(turnIntent.direction) return } const direction = turnIntent.direction - if (lastPointerSelectionTurnNeedsExit && - lastPointerSelectionTurnDirection === direction) { + if (lastPointerSelectionTurnDirection === direction && + Date.now() - lastPointerSelectionTurnAt < SELECTION_EDGE_TURN_COOLDOWN_MS) { clearPointerSelectionEdgeCandidate() return } @@ -1391,7 +1386,7 @@ export class Paginator extends HTMLElement { const currentIntent = this.#getPointerSelectionTurn(range, currentRange, currentBackward, true) if (currentIntent.edge && currentIntent.direction === direction) { lastPointerSelectionTurnDirection = direction - lastPointerSelectionTurnNeedsExit = true + lastPointerSelectionTurnAt = Date.now() runPointerSelectionTurn(direction) } }, @@ -1410,7 +1405,6 @@ export class Paginator extends HTMLElement { } const beginPointerSelecting = e => { pointerSelectionChangeCount = 0 - resetPointerSelectionTurnLock() clearPointerSelectionEdgeCandidate() markPointerSelecting(e) } @@ -1453,9 +1447,6 @@ export class Paginator extends HTMLElement { pointerSelectionChangeCount += 1 checkPointerSelection(range, sel, pointerSelectionChangeCount > 1 || touchSelectionHandles) } - else if (sel.type !== 'Range' || sel.isCollapsed) { - resetPointerSelectionTurnLock() - } }) doc.addEventListener('focusin', e => { if (this.scrolled) return null @@ -2132,7 +2123,7 @@ export class Paginator extends HTMLElement { 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)) + const edgeInset = Math.max(24, Math.min(96, this.size * 0.12)) if (backward && mapped.left <= visibleStart + edgeInset) return { direction: -1, edge: true } From 78da4bfc5dcb1bdff722183b1d0281420b824280 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Tue, 16 Jun 2026 00:29:47 +0800 Subject: [PATCH 15/22] fix(reader): add selection paging debug logs --- packages/app-expo/assets/reader/reader.html | 50 +++---- packages/foliate-js/paginator.js | 153 ++++++++++++++++++-- 2 files changed, 169 insertions(+), 34 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 3e6355af..9e61cc3c 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4517,8 +4517,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index 93ab85aa..006fead7 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -1286,15 +1286,32 @@ export class Paginator extends HTMLElement { let lastPointerSelectionTurnAt = 0 let lastPointerSelectionTurnDirection = 0 let lastPointerSelectionPoint = null + const debugSelectionPaging = (event, detail = {}) => { + try { + console.log('[SelectionPaging]', event, JSON.stringify(detail)) + } catch { + console.log('[SelectionPaging]', event, detail) + } + } const clearPointerSelectionEdgeCandidate = () => { + if (pointerSelectionEdgeCandidate) + debugSelectionPaging('candidate-clear', { + direction: pointerSelectionEdgeCandidate.direction, + age: Math.round(Date.now() - pointerSelectionEdgeCandidate.stableSince), + }) if (pointerSelectionEdgeCandidate?.timer) clearTimeout(pointerSelectionEdgeCandidate.timer) pointerSelectionEdgeCandidate = null } const runPointerSelectionTurn = direction => { - if (pointerSelectionTurn || !direction) return + if (pointerSelectionTurn || !direction) { + debugSelectionPaging('turn-skip', { direction, reason: pointerSelectionTurn ? 'in-flight' : 'no-direction' }) + return + } + debugSelectionPaging('turn-run', { direction }) const turn = direction < 0 ? this.prev() : this.next() pointerSelectionTurn = Promise.resolve(turn) .finally(() => setTimeout(() => { + debugSelectionPaging('turn-ready', { direction }) pointerSelectionTurn = null }, 80)) } @@ -1304,12 +1321,21 @@ export class Paginator extends HTMLElement { const candidate = pointerSelectionEdgeCandidate if (!candidate) return null if (direction && candidate.direction !== direction) { + debugSelectionPaging('candidate-direction-mismatch', { + current: candidate.direction, + next: direction, + }) clearPointerSelectionEdgeCandidate() return null } const point = lastPointerSelectionPoint if (point && point.updatedAt !== candidate.pointUpdatedAt) { if (pointerPointMoved(candidate.point, point)) { + debugSelectionPaging('candidate-reset-moving-point', { + direction: candidate.direction, + from: candidate.point, + to: { x: point.x, y: point.y }, + }) candidate.point = { x: point.x, y: point.y } candidate.stableSince = Date.now() } @@ -1320,14 +1346,30 @@ export class Paginator extends HTMLElement { const schedulePointerSelectionEdgeCandidate = candidate => { if (candidate.timer) clearTimeout(candidate.timer) const delay = Math.max(0, SELECTION_EDGE_TURN_DWELL_MS - (Date.now() - candidate.stableSince)) + debugSelectionPaging('candidate-schedule', { + direction: candidate.direction, + delay: Math.round(delay), + stableAge: Math.round(Date.now() - candidate.stableSince), + }) candidate.timer = setTimeout(() => { const currentCandidate = refreshPointerSelectionEdgeCandidate(candidate.direction) - if (currentCandidate !== candidate) return + if (currentCandidate !== candidate) { + debugSelectionPaging('candidate-abort-replaced', { direction: candidate.direction }) + return + } if (Date.now() - candidate.stableSince < SELECTION_EDGE_TURN_DWELL_MS) { + debugSelectionPaging('candidate-reschedule-not-stable', { + direction: candidate.direction, + stableAge: Math.round(Date.now() - candidate.stableSince), + }) schedulePointerSelectionEdgeCandidate(candidate) return } pointerSelectionEdgeCandidate = null + debugSelectionPaging('candidate-fire', { + direction: candidate.direction, + stableAge: Math.round(Date.now() - candidate.stableSince), + }) candidate.run() }, delay) } @@ -1340,15 +1382,41 @@ export class Paginator extends HTMLElement { doc, updatedAt: Date.now(), } + debugSelectionPaging('point', { + type: e?.type, + x: Math.round(lastPointerSelectionPoint.x), + y: Math.round(lastPointerSelectionPoint.y), + }) refreshPointerSelectionEdgeCandidate() } const checkPointerSelection = throttle((range, sel, allowEdgeTurn) => { - if (this.#navigationLocked) return - if (pointerSelectionTurn) return - if (!sel.rangeCount) return + if (this.#navigationLocked) { + debugSelectionPaging('check-skip', { reason: 'navigation-locked' }) + return + } + if (pointerSelectionTurn) { + debugSelectionPaging('check-skip', { reason: 'turn-in-flight' }) + return + } + if (!sel.rangeCount) { + debugSelectionPaging('check-skip', { reason: 'no-range' }) + return + } const selRange = sel.getRangeAt(0) const backward = selectionIsBackward(sel) const turnIntent = this.#getPointerSelectionTurn(range, selRange, backward, allowEdgeTurn) + debugSelectionPaging('check', { + allowEdgeTurn, + backward, + direction: turnIntent.direction, + edge: turnIntent.edge, + reason: turnIntent.reason, + edgeInset: turnIntent.edgeInset, + mappedLeft: turnIntent.mappedLeft, + mappedRight: turnIntent.mappedRight, + visibleStart: turnIntent.visibleStart, + visibleEnd: turnIntent.visibleEnd, + }) if (!turnIntent.direction) { clearPointerSelectionEdgeCandidate() return @@ -1362,11 +1430,20 @@ export class Paginator extends HTMLElement { const direction = turnIntent.direction if (lastPointerSelectionTurnDirection === direction && Date.now() - lastPointerSelectionTurnAt < SELECTION_EDGE_TURN_COOLDOWN_MS) { + debugSelectionPaging('check-skip', { + reason: 'cooldown', + direction, + remaining: Math.round(SELECTION_EDGE_TURN_COOLDOWN_MS - (Date.now() - lastPointerSelectionTurnAt)), + }) clearPointerSelectionEdgeCandidate() return } const existingCandidate = refreshPointerSelectionEdgeCandidate(direction) if (existingCandidate) { + debugSelectionPaging('candidate-existing', { + direction, + stableAge: Math.round(Date.now() - existingCandidate.stableSince), + }) return } @@ -1380,17 +1457,48 @@ export class Paginator extends HTMLElement { stableSince: Date.now(), run: () => { const currentSel = doc?.getSelection?.() - if (!currentSel || currentSel.type !== 'Range' || !currentSel.rangeCount) return + if (!currentSel || currentSel.type !== 'Range' || !currentSel.rangeCount) { + debugSelectionPaging('run-abort', { + reason: 'selection-invalid', + hasSelection: !!currentSel, + type: currentSel?.type, + rangeCount: currentSel?.rangeCount, + }) + return + } const currentRange = currentSel.getRangeAt(0) const currentBackward = selectionIsBackward(currentSel) const currentIntent = this.#getPointerSelectionTurn(range, currentRange, currentBackward, true) + debugSelectionPaging('run-check', { + direction, + currentDirection: currentIntent.direction, + currentEdge: currentIntent.edge, + reason: currentIntent.reason, + edgeInset: currentIntent.edgeInset, + mappedLeft: currentIntent.mappedLeft, + mappedRight: currentIntent.mappedRight, + visibleStart: currentIntent.visibleStart, + visibleEnd: currentIntent.visibleEnd, + }) if (currentIntent.edge && currentIntent.direction === direction) { lastPointerSelectionTurnDirection = direction lastPointerSelectionTurnAt = Date.now() runPointerSelectionTurn(direction) + } else { + debugSelectionPaging('run-abort', { + reason: 'edge-or-direction-changed', + direction, + currentDirection: currentIntent.direction, + currentEdge: currentIntent.edge, + }) } }, } + debugSelectionPaging('candidate-create', { + direction, + point: pointerSelectionEdgeCandidate.point, + pointUpdatedAt: pointerSelectionEdgeCandidate.pointUpdatedAt, + }) schedulePointerSelectionEdgeCandidate(pointerSelectionEdgeCandidate) }, 120) this.addEventListener('load', ({ detail: { doc } }) => { @@ -1431,11 +1539,30 @@ export class Paginator extends HTMLElement { doc.addEventListener('keydown', () => isKeyboardSelecting = true) doc.addEventListener('keyup', () => isKeyboardSelecting = false) doc.addEventListener('selectionchange', () => { - if (this.scrolled) return + if (this.scrolled) { + debugSelectionPaging('selectionchange-skip', { reason: 'scrolled' }) + return + } const range = this.#lastVisibleRange - if (!range) return + if (!range) { + debugSelectionPaging('selectionchange-skip', { reason: 'no-last-visible-range' }) + return + } const sel = doc.getSelection() - if (!sel.rangeCount) return + if (!sel.rangeCount) { + debugSelectionPaging('selectionchange-skip', { reason: 'no-range-count', type: sel.type }) + return + } + debugSelectionPaging('selectionchange', { + type: sel.type, + rangeCount: sel.rangeCount, + isPointerSelecting, + activeFor: Math.round(pointerSelectionActiveUntil - Date.now()), + touchSelectionHandles, + changeCount: pointerSelectionChangeCount, + isKeyboardSelecting, + textLength: sel.toString?.()?.trim?.()?.length, + }) if (isKeyboardSelecting) { const selRange = sel.getRangeAt(0).cloneRange() const backward = selectionIsBackward(sel) @@ -1446,6 +1573,14 @@ export class Paginator extends HTMLElement { (isPointerSelecting || Date.now() < pointerSelectionActiveUntil || touchSelectionHandles)) { pointerSelectionChangeCount += 1 checkPointerSelection(range, sel, pointerSelectionChangeCount > 1 || touchSelectionHandles) + } else { + debugSelectionPaging('selectionchange-skip', { + reason: 'not-pointer-selection', + type: sel.type, + isPointerSelecting, + activeFor: Math.round(pointerSelectionActiveUntil - Date.now()), + touchSelectionHandles, + }) } }) doc.addEventListener('focusin', e => { From 8e4faa62ce1324faee86c25acb74014443ee8b0c Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Tue, 16 Jun 2026 01:06:04 +0800 Subject: [PATCH 16/22] fix(reader): bridge selection paging diagnostics --- packages/app-expo/assets/reader/reader.html | 34 ++++++++++--------- .../assets/reader/reader.template.html | 2 ++ .../app-expo/src/screens/ReaderScreen.tsx | 1 + packages/foliate-js/paginator.js | 20 +++++++++-- 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 9e61cc3c..c80b282d 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -171,6 +171,8 @@ function postToRN(type, data) { if (RN) RN.postMessage(JSON.stringify({ type, ...data })); } + window.__READANY_READER_BUILD_ID = 'selection-paging-debug'; + postToRN('debug', { message: '[ReaderBuild] ' + window.__READANY_READER_BUILD_ID }); // State let view = null; @@ -4517,7 +4519,7 @@ diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index 2d64cfaf..3d8ca846 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -171,6 +171,8 @@ function postToRN(type, data) { if (RN) RN.postMessage(JSON.stringify({ type, ...data })); } + window.__READANY_READER_BUILD_ID = 'selection-paging-debug'; + postToRN('debug', { message: '[ReaderBuild] ' + window.__READANY_READER_BUILD_ID }); // State let view = null; diff --git a/packages/app-expo/src/screens/ReaderScreen.tsx b/packages/app-expo/src/screens/ReaderScreen.tsx index d585aed8..bbb5fc79 100644 --- a/packages/app-expo/src/screens/ReaderScreen.tsx +++ b/packages/app-expo/src/screens/ReaderScreen.tsx @@ -1496,6 +1496,7 @@ export function ReaderScreen({ route, navigation }: Props) { }} javaScriptEnabled domStorageEnabled + cacheEnabled={false} allowFileAccess allowFileAccessFromFileURLs allowUniversalAccessFromFileURLs diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index 006fead7..07020802 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -1287,10 +1287,26 @@ export class Paginator extends HTMLElement { let lastPointerSelectionTurnDirection = 0 let lastPointerSelectionPoint = null const debugSelectionPaging = (event, detail = {}) => { + const serialize = value => { + try { + return JSON.stringify(value) + } catch { + return String(value) + } + } + const message = `[SelectionPaging] ${event} ${serialize(detail)}` + try { + console.log(message) + } catch { + // ignore console failures in embedded WebViews + } try { - console.log('[SelectionPaging]', event, JSON.stringify(detail)) + globalThis.ReactNativeWebView?.postMessage(JSON.stringify({ + type: 'debug', + message, + })) } catch { - console.log('[SelectionPaging]', event, detail) + // ignore bridge failures outside React Native WebView } } const clearPointerSelectionEdgeCandidate = () => { From 6fc349755bc0594f3cbc45991a44954e23fdf704 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Tue, 16 Jun 2026 01:12:13 +0800 Subject: [PATCH 17/22] fix(reader): use touch edge for selection paging --- packages/app-expo/assets/reader/reader.html | 30 ++++----- packages/foliate-js/paginator.js | 69 ++++++++++++++++++--- 2 files changed, 75 insertions(+), 24 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index c80b282d..4f4e5eb4 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4519,8 +4519,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index 07020802..bee18ac7 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -1420,14 +1420,23 @@ export class Paginator extends HTMLElement { } const selRange = sel.getRangeAt(0) const backward = selectionIsBackward(sel) - const turnIntent = this.#getPointerSelectionTurn(range, selRange, backward, allowEdgeTurn) + const turnIntent = this.#getPointerSelectionTurn( + range, + selRange, + backward, + allowEdgeTurn, + lastPointerSelectionPoint, + ) debugSelectionPaging('check', { allowEdgeTurn, backward, direction: turnIntent.direction, edge: turnIntent.edge, reason: turnIntent.reason, + edgeSource: turnIntent.edgeSource, edgeInset: turnIntent.edgeInset, + pointerEdgeInset: turnIntent.pointerEdgeInset, + pointerMapped: turnIntent.pointerMapped, mappedLeft: turnIntent.mappedLeft, mappedRight: turnIntent.mappedRight, visibleStart: turnIntent.visibleStart, @@ -1484,13 +1493,22 @@ export class Paginator extends HTMLElement { } const currentRange = currentSel.getRangeAt(0) const currentBackward = selectionIsBackward(currentSel) - const currentIntent = this.#getPointerSelectionTurn(range, currentRange, currentBackward, true) + const currentIntent = this.#getPointerSelectionTurn( + range, + currentRange, + currentBackward, + true, + lastPointerSelectionPoint, + ) debugSelectionPaging('run-check', { direction, currentDirection: currentIntent.direction, currentEdge: currentIntent.edge, reason: currentIntent.reason, + edgeSource: currentIntent.edgeSource, edgeInset: currentIntent.edgeInset, + pointerEdgeInset: currentIntent.pointerEdgeInset, + pointerMapped: currentIntent.pointerMapped, mappedLeft: currentIntent.mappedLeft, mappedRight: currentIntent.mappedRight, visibleStart: currentIntent.visibleStart, @@ -2251,37 +2269,70 @@ export class Paginator extends HTMLElement { ? ({ top, bottom }) => ({ left: top, right: bottom }) : f => f } - #getPointerSelectionTurn(visibleRange, selectionRange, backward, allowEdgeTurn) { + #getPointerSelectionTurn(visibleRange, selectionRange, backward, allowEdgeTurn, pointerPoint = null) { 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 } + if (!allowEdgeTurn) return { direction: 0, edge: false, reason: 'edge-turn-disabled' } 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 } + if (!entry) return { direction: 0, edge: false, reason: 'view-not-found' } const [index, view] = entry const rect = getRangeEdgeRect(selectionRange, backward) - if (!rect) return { direction: 0, edge: false } + if (!rect) return { direction: 0, edge: false, reason: 'range-edge-not-found' } 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(24, Math.min(96, this.size * 0.12)) + const pointerEdgeInset = Math.max(edgeInset, Math.min(96, this.size * 0.22)) + const base = { + edgeInset: Math.round(edgeInset), + pointerEdgeInset: Math.round(pointerEdgeInset), + mappedLeft: Math.round(mapped.left), + mappedRight: Math.round(mapped.right), + visibleStart: Math.round(visibleStart), + visibleEnd: Math.round(visibleEnd), + } if (backward && mapped.left <= visibleStart + edgeInset) - return { direction: -1, edge: true } + return { ...base, direction: -1, edge: true, edgeSource: 'range' } if (!backward && mapped.right >= visibleEnd - edgeInset) - return { direction: 1, edge: true } + return { ...base, direction: 1, edge: true, edgeSource: 'range' } + + if (pointerPoint?.doc === doc && typeof pointerPoint.x === 'number' && typeof pointerPoint.y === 'number') { + const pointerMapped = this.#getRectMapper(view)({ + left: pointerPoint.x, + right: pointerPoint.x, + top: pointerPoint.y, + bottom: pointerPoint.y, + }) + const pointerDetail = { + ...base, + pointerMapped: Math.round(pointerMapped.left), + } + if (backward && pointerMapped.left <= visibleStart + pointerEdgeInset) + return { ...pointerDetail, direction: -1, edge: true, edgeSource: 'pointer' } + if (!backward && pointerMapped.right >= visibleEnd - pointerEdgeInset) + return { ...pointerDetail, direction: 1, edge: true, edgeSource: 'pointer' } + + return { + ...pointerDetail, + direction: 0, + edge: false, + reason: 'not-at-edge', + } + } - return { direction: 0, edge: false } + return { ...base, direction: 0, edge: false, reason: 'not-at-edge' } } async #scrollToRect(rect, reason) { if (this.scrolled) { From 038f373656e1ab89a8fb246f4e8ffb76790b7b6e Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Tue, 16 Jun 2026 01:20:13 +0800 Subject: [PATCH 18/22] fix(reader): widen touch selection edge window --- packages/app-expo/assets/reader/reader.html | 2 +- packages/foliate-js/paginator.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 4f4e5eb4..0982787a 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4868,7 +4868,7 @@
- `,b(this,Ve,r(this,ir).getElementById("top")),b(this,Ts,r(this,ir).getElementById("background")),b(this,St,r(this,ir).getElementById("container")),b(this,ma,r(this,ir).getElementById("header")),b(this,ol,r(this,ir).getElementById("footer")),r(this,cf).observe(r(this,St));let e=lC(()=>{if(this.scrolled&&!r(this,nn)){if(r(this,Ls))return;r(this,Zc)?b(this,Zc,!1):w(this,T,nl).call(this,"scroll")}else this.scrolled||w(this,T,nl).call(this,"container-scroll")},250);r(this,St).addEventListener("scroll",()=>{if(r(this,nn)||this.dispatchEvent(new Event("scroll")),!this.scrolled&&!r(this,nn)&&w(this,T,Ei).call(this),!this.noPreload&&!this.noContinuousScroll&&!r(this,We)&&!r(this,Ls)&&(this.size>0?Math.floor((r(this,T,Ys)-r(this,T,je))/this.size):0)<5){let x=r(this,T,Bt),S=x[x.length-1]?.[0];if(S!=null){let E=w(this,T,Le).call(this,1,S);E!=null&&!r(this,st).has(E)&&w(this,T,fa).call(this,E)&&(b(this,We,!0),w(this,T,pa).call(this,E).finally(()=>{b(this,We,!1),this.dispatchEvent(new Event("stabilized"))}))}}if(this.scrolled&&!this.noPreload&&!this.noContinuousScroll&&!r(this,We)&&!r(this,Ls)&&(this.size>0?Math.floor(r(this,T,ee)/this.size):0)<5){let S=r(this,T,Bt)[0]?.[0];if(S!=null){let E=w(this,T,Le).call(this,-1,S);E!=null&&!r(this,st).has(E)&&w(this,T,fa).call(this,E)&&(b(this,We,!0),w(this,T,pa).call(this,E).finally(()=>{b(this,We,!1),this.dispatchEvent(new Event("stabilized"))}))}}e()});let s={passive:!1};this.addEventListener("touchstart",w(this,T,Xw).bind(this),s),this.addEventListener("touchmove",w(this,T,qw).bind(this),s),this.addEventListener("touchend",w(this,T,Yw).bind(this)),this.addEventListener("load",({detail:{doc:y}})=>{y.addEventListener("touchstart",w(this,T,Xw).bind(this),s),y.addEventListener("touchmove",w(this,T,qw).bind(this),s),y.addEventListener("touchend",w(this,T,Yw).bind(this))}),this.addEventListener("relocate",({detail:y})=>{y.reason==="selection"?jg(r(this,ks),0):y.reason==="navigation"&&(r(this,ks)===1?jg(y.range,1):typeof r(this,ks)=="number"?jg(y.range,-1):jg(r(this,ks),-1))});let i=null,n=null,a=0,o=0,c=null,h=(y,_={})=>{let S=`[SelectionPaging] ${y} ${(E=>{try{return JSON.stringify(E)}catch{return String(E)}})(_)}`;try{console.log(S)}catch{}try{globalThis.ReactNativeWebView?.postMessage(JSON.stringify({type:"debug",message:S}))}catch{}},d=()=>{n&&h("candidate-clear",{direction:n.direction,age:Math.round(Date.now()-n.stableSince)}),n?.timer&&clearTimeout(n.timer),n=null},u=y=>{if(i||!y){h("turn-skip",{direction:y,reason:i?"in-flight":"no-direction"});return}h("turn-run",{direction:y});let _=y<0?this.prev():this.next();i=Promise.resolve(_).finally(()=>setTimeout(()=>{h("turn-ready",{direction:y}),i=null},80))},f=(y,_)=>!y||!_||Math.hypot(y.x-_.x,y.y-_.y)>18,p=y=>{let _=n;if(!_)return null;if(y&&_.direction!==y)return h("candidate-direction-mismatch",{current:_.direction,next:y}),d(),null;let x=c;return x&&x.updatedAt!==_.pointUpdatedAt&&(f(_.point,x)&&(h("candidate-reset-moving-point",{direction:_.direction,from:_.point,to:{x:x.x,y:x.y}}),_.point={x:x.x,y:x.y},_.stableSince=Date.now()),_.pointUpdatedAt=x.updatedAt),_},m=y=>{y.timer&&clearTimeout(y.timer);let _=Math.max(0,1e3-(Date.now()-y.stableSince));h("candidate-schedule",{direction:y.direction,delay:Math.round(_),stableAge:Math.round(Date.now()-y.stableSince)}),y.timer=setTimeout(()=>{if(p(y.direction)!==y){h("candidate-abort-replaced",{direction:y.direction});return}if(Date.now()-y.stableSince<1e3){h("candidate-reschedule-not-stable",{direction:y.direction,stableAge:Math.round(Date.now()-y.stableSince)}),m(y);return}n=null,h("candidate-fire",{direction:y.direction,stableAge:Math.round(Date.now()-y.stableSince)}),y.run()},_)},g=(y,_)=>{let x=y?.touches?.[0]??y?.changedTouches?.[0]??y;typeof x?.clientX!="number"||typeof x?.clientY!="number"||(c={x:x.clientX,y:x.clientY,doc:_,updatedAt:Date.now()},h("point",{type:y?.type,x:Math.round(c.x),y:Math.round(c.y)}),p())},v=cC((y,_,x)=>{if(r(this,rr)){h("check-skip",{reason:"navigation-locked"});return}if(i){h("check-skip",{reason:"turn-in-flight"});return}if(!_.rangeCount){h("check-skip",{reason:"no-range"});return}let S=_.getRangeAt(0),E=Hw(_),C=w(this,T,Kw).call(this,y,S,E,x,c);if(h("check",{allowEdgeTurn:x,backward:E,direction:C.direction,edge:C.edge,reason:C.reason,edgeSource:C.edgeSource,edgeInset:C.edgeInset,pointerEdgeInset:C.pointerEdgeInset,pointerMapped:C.pointerMapped,mappedLeft:C.mappedLeft,mappedRight:C.mappedRight,visibleStart:C.visibleStart,visibleEnd:C.visibleEnd}),!C.direction){d();return}if(!C.edge){d(),u(C.direction);return}let R=C.direction;if(o===R&&Date.now()-a<900){h("check-skip",{reason:"cooldown",direction:R,remaining:Math.round(900-(Date.now()-a))}),d();return}let k=p(R);if(k){h("candidate-existing",{direction:R,stableAge:Math.round(Date.now()-k.stableSince)});return}d();let M=_.anchorNode?.ownerDocument??_.focusNode?.ownerDocument,L=c;n={direction:R,point:L?{x:L.x,y:L.y}:null,pointUpdatedAt:L?.updatedAt??0,stableSince:Date.now(),run:()=>{let P=M?.getSelection?.();if(!P||P.type!=="Range"||!P.rangeCount){h("run-abort",{reason:"selection-invalid",hasSelection:!!P,type:P?.type,rangeCount:P?.rangeCount});return}let I=P.getRangeAt(0),B=Hw(P),O=w(this,T,Kw).call(this,y,I,B,!0,c);h("run-check",{direction:R,currentDirection:O.direction,currentEdge:O.edge,reason:O.reason,edgeSource:O.edgeSource,edgeInset:O.edgeInset,pointerEdgeInset:O.pointerEdgeInset,pointerMapped:O.pointerMapped,mappedLeft:O.mappedLeft,mappedRight:O.mappedRight,visibleStart:O.visibleStart,visibleEnd:O.visibleEnd}),O.edge&&O.direction===R?(o=R,a=Date.now(),u(R)):h("run-abort",{reason:"edge-or-direction-changed",direction:R,currentDirection:O.direction,currentEdge:O.edge})}},h("candidate-create",{direction:R,point:n.point,pointUpdatedAt:n.pointUpdatedAt}),m(n)},120);this.addEventListener("load",({detail:{doc:y}})=>{let _=!1,x=0,S=0,E=globalThis.navigator?.maxTouchPoints>0,C=L=>{g(L,y),_=!0,x=Date.now()+1200},R=L=>{S=0,d(),C(L)},k=()=>{c=null,d(),setTimeout(()=>{Date.now()>=x&&(_=!1)},160)};y.addEventListener("selectstart",R),y.addEventListener("pointerdown",R),y.addEventListener("pointermove",L=>{y.getSelection()?.type==="Range"&&C(L)}),y.addEventListener("pointerup",k),y.addEventListener("touchstart",R),y.addEventListener("touchmove",L=>{y.getSelection()?.type==="Range"&&C(L)},{passive:!0}),y.addEventListener("touchend",k),y.addEventListener("touchcancel",k);let M=!1;y.addEventListener("keydown",()=>M=!0),y.addEventListener("keyup",()=>M=!1),y.addEventListener("selectionchange",()=>{if(this.scrolled){h("selectionchange-skip",{reason:"scrolled"});return}let L=r(this,ff);if(!L){h("selectionchange-skip",{reason:"no-last-visible-range"});return}let P=y.getSelection();if(!P.rangeCount){h("selectionchange-skip",{reason:"no-range-count",type:P.type});return}if(h("selectionchange",{type:P.type,rangeCount:P.rangeCount,isPointerSelecting:_,activeFor:Math.round(x-Date.now()),touchSelectionHandles:E,changeCount:S,isKeyboardSelecting:M,textLength:P.toString?.()?.trim?.()?.length}),M){let I=P.getRangeAt(0).cloneRange();Hw(P)||I.collapse(),w(this,T,il).call(this,I)}else P.type==="Range"&&(_||Date.now()1||E)):h("selectionchange-skip",{reason:"not-pointer-selection",type:P.type,isPointerSelecting:_,activeFor:Math.round(x-Date.now()),touchSelectionHandles:E})}),y.addEventListener("focusin",L=>{if(this.scrolled)return null;r(this,St)&&r(this,St).contains(L.target)&&requestAnimationFrame(()=>w(this,T,il).call(this,L.target))})}),b(this,eh,()=>{r(this,T,Vt)&&w(this,T,Ei).call(this)}),r(this,uf).addEventListener("change",r(this,eh))}get primaryIndex(){return r(this,dt)}setAttribute(e,s){e==="flow"&&this.scrolled&&String(s)!=="scrolled"&&r(this,st).size>0&&w(this,T,w_).call(this),super.setAttribute(e,s)}attributeChangedCallback(e,s,i){switch(e){case"flow":this.render();break;case"gap":case"margin":r(this,Ve).style.setProperty("--_margin-top",i),r(this,Ve).style.setProperty("--_margin-right",i),r(this,Ve).style.setProperty("--_margin-bottom",i),r(this,Ve).style.setProperty("--_margin-left",i),this.render();break;case"margin-top":case"margin-bottom":case"margin-left":case"margin-right":case"max-block-size":case"max-column-count":r(this,Ve).style.setProperty("--_"+e,i),this.render();break;case"max-inline-size":r(this,Ve).style.setProperty("--_"+e,i),this.render();break;case"no-continuous-scroll":if(this.noContinuousScroll)for(let[n]of r(this,st))n!==r(this,dt)&&w(this,T,ua).call(this,n);break}}open(e){this.bookDir=e.dir,this.sections=e.sections,e.transformTarget?.addEventListener("data",({detail:s})=>{s.type==="text/css"&&(s.data=Promise.resolve(s.data).then(i=>i.replace(/([{\s;])-epub-/gi,"$1").replace(/(\d*\.?\d+)vw/gi,(n,a)=>`${parseFloat(a)*innerWidth/100}px`).replace(/(\d*\.?\d+)vh/gi,(n,a)=>`${parseFloat(a)*innerHeight/100}px`).replace(/page-break-(after|before|inside)\s*:/gi,(n,a)=>`-webkit-column-break-${a}:`).replace(/break-(after|before|inside)\s*:\s*(avoid-)?page/gi,(n,a,o)=>`break-${a}: ${o??""}column`)))})}render(){if(r(this,st).size===0||!r(this,T,Vt))return;b(this,Ls,!0);let s=w(this,T,Gw).call(this,{vertical:r(this,At),rtl:r(this,nr)});for(let[,i]of r(this,st))i.document&&i.render(s);w(this,T,il).call(this,r(this,ks)),b(this,Ls,!1),this.dispatchEvent(new Event("stabilized"))}get scrolled(){return this.getAttribute("flow")==="scrolled"}get navigationLocked(){return r(this,rr)}set navigationLocked(e){b(this,rr,!!e)}get noPreload(){return this.hasAttribute("no-preload")}get noBackground(){return this.hasAttribute("no-background")}get noContinuousScroll(){return this.scrolled&&this.hasAttribute("no-continuous-scroll")}get scrollProp(){let{scrolled:e}=this;return r(this,At)?e?"scrollLeft":"scrollTop":e?"scrollTop":"scrollLeft"}get sideProp(){let{scrolled:e}=this;return r(this,At)?e?"width":"height":e?"height":"width"}get size(){return r(this,St).getBoundingClientRect()[this.sideProp]}get viewSize(){let e=r(this,T,Vt);return e?e.element.getBoundingClientRect()[this.sideProp]:0}get start(){return r(this,T,ee)-w(this,T,Ks).call(this,r(this,dt))}get end(){return r(this,T,je)-w(this,T,Ks).call(this,r(this,dt))}get page(){return Math.floor((this.start+this.end)/2/this.size)}get pages(){let e=r(this,T,Vt);if(!e)return 0;let s=e.element.getBoundingClientRect()[this.sideProp];return Math.round(s/this.size)}get containerPosition(){return r(this,St)[this.scrollProp]}get isOverflowX(){return!1}get isOverflowY(){return!1}set containerPosition(e){r(this,St)[this.scrollProp]=e}set scrollLocked(e){b(this,pf,e)}scrollBy(e,s){let i=r(this,At)?s:e,[n,a,o]=r(this,ar),c=r(this,nr),h=c?n-o:n-a,d=c?n+a:n+o;this.containerPosition=Math.max(h,Math.min(d,this.containerPosition+i))}snap(e,s,i,n,a){let o=r(this,At)?s:e,c=r(this,At)?n/a:i/a,h=Math.abs(e)*2>Math.abs(s),d=r(this,At)?!h:h,[u,f,p]=r(this,ar),m=this.size,g=r(this,T,ee),v=r(this,T,je),y=r(this,T,qc),_=Math.abs(u)-f,x=Math.abs(u)+p,S=this.hasAttribute("animated")&&!this.hasAttribute("eink"),C=(S?o:c)*(r(this,nr)?-m:m)*(d?1:0),R=isNaN(C)?0:S?C*2:C*10,k=Math.floor(Math.max(_,Math.min(x,(g+v)/2+R))/m),M=k<0?-1:k>=y?1:null,L=()=>{if(!M)return;let P=r(this,T,Bt),I=M<0?P[0]?.[0]??r(this,dt):P[P.length-1]?.[0]??r(this,dt);return w(this,T,Wg).call(this,{index:w(this,T,Le).call(this,M,I),anchor:M<0?()=>1:()=>0})};if(M)return L();w(this,T,Kc).call(this,k,"snap")}async scrollToAnchor(e,s,i){return w(this,T,il).call(this,e,s?"selection":"navigation",i)}async goTo(e){if(r(this,sn))return;let s=await e;if(w(this,T,Vg).call(this,s.index))return w(this,T,Wg).call(this,s)}get atStart(){let s=r(this,T,Bt)[0]?.[0]??r(this,dt);return this.scrolled?w(this,T,Le).call(this,-1,s)==null&&r(this,T,ee)<=0:w(this,T,Le).call(this,-1,s)==null&&r(this,T,Xc)<=0}get atEnd(){let e=r(this,T,Bt),s=e[e.length-1]?.[0]??r(this,dt);return this.scrolled?w(this,T,Le).call(this,1,s)==null&&r(this,T,Ys)-r(this,T,je)<=2:w(this,T,Le).call(this,1,s)==null&&r(this,T,Xc)>=r(this,T,qc)-1}async prev(e){return await w(this,T,sy).call(this,-1,e)}async next(e){return await w(this,T,sy).call(this,1,e)}async pan(e,s){r(this,sn)||(b(this,sn,!0),this.scrollBy(e,s),b(this,sn,!1))}prevSection(){return this.goTo({index:w(this,T,Le).call(this,-1)})}nextSection(){return this.goTo({index:w(this,T,Le).call(this,1)})}firstSection(){let e=this.sections.findIndex(s=>s.linear!=="no");return this.goTo({index:e})}lastSection(){let e=this.sections.findLastIndex(s=>s.linear!=="no");return this.goTo({index:e})}getContents(){let e=[];for(let[s,i]of r(this,T,Bt))i.document&&e.push({index:s,overlayer:i.overlayer,doc:i.document});return e}setStyles(e){b(this,Qc,e);for(let[,i]of r(this,st)){let n=r(this,th).get(i.document);if(!n)continue;let[a,o]=n;if(Array.isArray(e)){let[c,h]=e;a.textContent=c,o.textContent=h}else o.textContent=e;i.document?.fonts?.ready?.then(()=>i.expand())}r(this,T,Vt)&&requestAnimationFrame(()=>w(this,T,Ei).call(this))}focusView(){r(this,T,Vt)?.document?.defaultView?.focus()}showLoupe(e,s,{isVertical:i,color:n,gap:a,margin:o,radius:c,magnification:h}){r(this,T,Vt)?.showLoupe(e,s,{isVertical:i,color:n,gap:a,margin:o,radius:c,magnification:h})}hideLoupe(){r(this,T,Vt)?.hideLoupe()}destroyLoupe(){r(this,T,Vt)?.destroyLoupe()}destroy(){r(this,cf).unobserve(this),w(this,T,Ww).call(this),r(this,uf).removeEventListener("change",r(this,eh))}};ir=new WeakMap,cf=new WeakMap,Ve=new WeakMap,Ts=new WeakMap,St=new WeakMap,ma=new WeakMap,ol=new WeakMap,st=new WeakMap,dt=new WeakMap,At=new WeakMap,nr=new WeakMap,hf=new WeakMap,df=new WeakMap,ks=new WeakMap,Zc=new WeakMap,sn=new WeakMap,rr=new WeakMap,Qc=new WeakMap,th=new WeakMap,uf=new WeakMap,eh=new WeakMap,ar=new WeakMap,sh=new WeakMap,ll=new WeakMap,ff=new WeakMap,pf=new WeakMap,nn=new WeakMap,We=new WeakMap,rn=new WeakMap,Ls=new WeakMap,gf=new WeakMap,ih=new WeakMap,or=new WeakMap,T=new WeakSet,Vt=function(){return r(this,st).get(r(this,dt))},Bt=function(){return[...r(this,st).entries()].sort(([e],[s])=>e-s)},w_=function(){r(this,st).size>1&&w(this,T,Qw).call(this);let e=w(this,T,Zw).call(this);e?.range&&!e.range.collapsed&&b(this,ks,e.range)},Vw=function(e){let s=r(this,st).get(e);s&&(s.destroy(),r(this,St).removeChild(s.element),r(this,st).delete(e));let i=new jw({container:this,onExpand:()=>{r(this,We)||r(this,Ls)||this.scrolled||r(this,dt)===e&&w(this,T,il).call(this,r(this,ks))}});r(this,st).set(e,i);let n=r(this,T,Bt),a=n.findIndex(([c])=>c===e),o=n[a+1];return o?r(this,St).insertBefore(i.element,o[1].element):r(this,St).append(i.element),w(this,T,Gc).call(this),i},Gc=function(){let e=r(this,St).getBoundingClientRect();for(let[s,i]of r(this,st))s===r(this,dt)||g_(i.element.getBoundingClientRect(),e)?i.element.removeAttribute("aria-hidden"):i.element.setAttribute("aria-hidden","true")},ua=function(e){let s=r(this,st).get(e);s&&(s.destroy(),r(this,St).removeChild(s.element),r(this,st).delete(e),this.sections[e]?.unload?.())},Ww=function(){for(let[e]of r(this,st))w(this,T,ua).call(this,e)},y_=function(e){for(let[s]of r(this,st))e.has(s)||w(this,T,ua).call(this,s)},fa=function(e){return r(this,or).has(e)?r(this,or).get(e)===r(this,At):!0},Ei=function(e){let s=r(this,T,Vt)?.document;if(!s?.documentElement||this.noBackground)return;let i=s.defaultView.getComputedStyle(s.documentElement),n=i.getPropertyValue("--theme-bg-color"),a=i.getPropertyValue("--override-color")==="true",o=i.getPropertyValue("--bg-texture-id"),c=i.getPropertyValue("color-scheme")==="dark",h=i.backgroundColor,d=n||h||"",u=!!o&&o!=="none";r(this,Ve).style.setProperty("--_scrollbar-track-bg",u?"transparent":d||"transparent");let f=M=>{if(!M)return d;if(n){let L=M.split(/\s(?=(?:url|rgb|hsl|#[0-9a-fA-F]{3,6}))/);return(c||a)&&(o==="none"||!o)&&(L[0]=n),L.join(" ")}return M};r(this,Ts).style.background="";for(let[,M]of r(this,T,Bt))M.element.style.background="";if(this.scrolled){r(this,Ts).innerHTML="",r(this,Ts).style.display="",r(this,Ts).style.background=u?"":d;for(let[,M]of r(this,T,Bt)){let L=f(M.docBackground);M.element.style.background=Uw(L,u)}return}let p=r(this,Ts).getBoundingClientRect(),m=r(this,St).getBoundingClientRect(),g=r(this,At)?"top":"left",v=p[this.sideProp],y=m[g]-p[g],_=Math.abs(e??r(this,T,ee)),x=r(this,T,Bt).map(([,M])=>({size:M.element.getBoundingClientRect()[this.sideProp],bg:Uw(f(M.docBackground),u)})),S=m_(x,_,v,y,this.size);r(this,Ts).innerHTML="",r(this,Ts).style.display="",r(this,Ts).style.background=u?"":d;let E=r(this,At)?"top":"left",C=r(this,At)?"height":"width",R=r(this,At)?"left":"top",k=r(this,At)?"width":"height";for(let{start:M,size:L,bg:P}of S){let I=document.createElement("div");I.style.position="absolute",I.style[E]=`${M}px`,I.style[C]=`${L}px`,I.style[R]="0",I.style[k]="100%",I.style.background=P,I.style.backgroundAttachment="initial",r(this,Ts).appendChild(I)}},Gw=function({vertical:e,rtl:s}){if(r(this,gf)&&e!==r(this,At))for(let[L]of r(this,st))L!==r(this,dt)&&w(this,T,ua).call(this,L);b(this,At,e),b(this,nr,s),r(this,Ve).classList.toggle("vertical",e),r(this,St).classList.toggle("vertical",e);let i=getComputedStyle(r(this,Ve)),n=parseFloat(i.getPropertyValue("--_max-inline-size")),a=parseInt(i.getPropertyValue("--_max-column-count-spread")),o=parseFloat(i.getPropertyValue("--_margin-top")),c=parseFloat(i.getPropertyValue("--_margin-right")),h=parseFloat(i.getPropertyValue("--_margin-bottom")),d=parseFloat(i.getPropertyValue("--_margin-left"));b(this,hf,o),b(this,df,h);let u=this.getAttribute("flow"),f=this.getBoundingClientRect(),p=e?f.height:f.width,m=u==="scrolled"?1:Math.min(a+(e?1:0),Math.ceil(Math.floor(p)/Math.floor(n)));r(this,Ve).style.setProperty("--_column-count",m);let{width:g,height:v}=r(this,St).getBoundingClientRect(),y=e?v:g,_=parseFloat(i.getPropertyValue("--_gap"))/100,x=-_/(_-1)*y;if(u==="scrolled"){this.setAttribute("dir",e?"rtl":"ltr"),r(this,Ve).style.padding="0";let L=n;this.heads=null,this.feet=null,r(this,ma).replaceChildren(),r(this,ol).replaceChildren(),this.columnCount=1,w(this,T,Ei).call(this);let P={width:g,height:v,flow:u,marginTop:o,marginRight:c,marginBottom:h,marginLeft:d,gap:x,columnWidth:L,columnCount:1};return b(this,ih,P),P}let S=e?y/m-o*1.5-h*1.5:y/m-x-c/2-d/2;this.setAttribute("dir",s?"rtl":"ltr"),this.columnCount=m,w(this,T,Ei).call(this);let E=e?Math.min(2,Math.ceil(Math.floor(g)/Math.floor(n))):m,C={gridTemplateColumns:`repeat(${E}, 1fr)`,gap:`${x}px`,direction:this.bookDir==="rtl"?"rtl":"ltr"};Object.assign(r(this,ma).style,C),Object.assign(r(this,ol).style,C);let R=f_(E,"head"),k=f_(E,"foot");this.heads=R.map(L=>L.children[0]),this.feet=k.map(L=>L.children[0]),r(this,ma).replaceChildren(...R),r(this,ol).replaceChildren(...k);let M={width:g,height:v,marginTop:o,marginRight:c,marginBottom:h,marginLeft:d,gap:x,columnWidth:S,columnCount:m};return b(this,ih,M),M},Ys=function(){if(r(this,st).size===0)return 0;let e=0;for(let[,s]of r(this,st))e+=s.element.getBoundingClientRect()[this.sideProp];return e},ee=function(){return Math.abs(r(this,St)[this.scrollProp])},je=function(){return r(this,T,ee)+this.size},Xc=function(){return Math.floor((r(this,T,ee)+r(this,T,je))/2/this.size)},qc=function(){return Math.round(r(this,T,Ys)/this.size)},Xw=function(e){if(r(this,rr))return;let s=this.getContents?.()??[];for(let{doc:a}of s){let o=a?.getSelection?.();if(o&&!o.isCollapsed&&o.toString().trim())return}let i=e.changedTouches[0];b(this,sh,{x:i?.screenX,y:i?.screenY,t:e.timeStamp,vx:0,xy:0,dx:0,dy:0,dt:0,startX:i?.screenX,startY:i?.screenY,didPreventDefault:!1});let n=r(this,T,Vt);n?.element&&(n.element.style.willChange="transform")},qw=function(e){let s=r(this,sh);if(r(this,rr)||!s)return;let i=this.getContents?.()??[];for(let{doc:m}of i){let g=m?.getSelection?.();if(g&&!g.isCollapsed&&g.toString().trim())return}if(s.pinched||(s.pinched=globalThis.visualViewport.scale>1,this.scrolled||s.pinched)||this.hasAttribute("no-swipe"))return;if(e.touches.length>1){r(this,ll)&&e.preventDefault();return}let n=e.changedTouches[0],a=n.touchType==="stylus",o=Math.abs(n.screenX-(s.startX??n.screenX)),c=Math.abs(n.screenY-(s.startY??n.screenY));if(!s.axisLocked&&(o>10||c>10)&&(c>o*1.3?(s.axisLocked="y",s.aborted=!0):s.axisLocked="x"),s.aborted||(!a&&(o>10||c>10||s.didPreventDefault)&&(e.preventDefault(),s.didPreventDefault=!0),r(this,pf)))return;let h=n.screenX,d=n.screenY,u=s.x-h,f=s.y-d,p=e.timeStamp-s.t;s.x=h,s.y=d,s.t=e.timeStamp,s.vx=u/p,s.vy=f/p,s.dx+=u,s.dy+=f,s.dt+=p,b(this,ll,!0),!(!this.hasAttribute("animated")||this.hasAttribute("eink"))&&(!r(this,At)&&Math.abs(s.dx)>=Math.abs(s.dy)&&!this.hasAttribute("eink")&&(!a||Math.abs(u)>1)?this.scrollBy(u,0):r(this,At)&&Math.abs(s.dx)1)&&this.scrollBy(0,f))},Yw=function(){r(this,ll)&&(b(this,ll,!1),!(this.scrolled||r(this,rr))&&(this.hasAttribute("no-swipe")||requestAnimationFrame(()=>{if(globalThis.visualViewport.scale===1){let{vx:e,vy:s,dx:i,dy:n,dt:a}=r(this,sh);this.snap(e,s,i,n,a)}})))},sl=function(e){if(this.scrolled){let i=e?e.element.getBoundingClientRect()[this.sideProp]:r(this,T,Ys),n=r(this,hf),a=r(this,df);return r(this,At)?({left:o,right:c})=>({left:i-c-n,right:i-o-a}):({top:o,bottom:c})=>({left:o-n,right:c-a})}let s=r(this,T,qc)*this.size;return r(this,nr)?({left:i,right:n})=>({left:s-n,right:s-i}):r(this,At)?({top:i,bottom:n})=>({left:i,right:n}):i=>i},Kw=function(e,s,i,n,a=null){try{if(i&&s.compareBoundaryPoints(Range.START_TO_START,e)<0)return{direction:-1,edge:!1};if(!i&&s.compareBoundaryPoints(Range.END_TO_END,e)>0)return{direction:1,edge:!1}}catch{}if(!n)return{direction:0,edge:!1,reason:"edge-turn-disabled"};let o=s.commonAncestorContainer?.ownerDocument??s.startContainer?.ownerDocument??s.endContainer?.ownerDocument,c=[...r(this,st)].find(([,x])=>x.document===o);if(!c)return{direction:0,edge:!1,reason:"view-not-found"};let[h,d]=c,u=wC(s,i);if(!u)return{direction:0,edge:!1,reason:"range-edge-not-found"};let f=w(this,T,Ks).call(this,h),p=r(this,T,ee)-f,m=r(this,T,je)-f,g=w(this,T,sl).call(this,d)(u),v=Math.max(24,Math.min(96,this.size*.12)),y=Math.max(v,Math.min(96,this.size*.22)),_={edgeInset:Math.round(v),pointerEdgeInset:Math.round(y),mappedLeft:Math.round(g.left),mappedRight:Math.round(g.right),visibleStart:Math.round(p),visibleEnd:Math.round(m)};if(i&&g.left<=p+v)return{..._,direction:-1,edge:!0,edgeSource:"range"};if(!i&&g.right>=m-v)return{..._,direction:1,edge:!0,edgeSource:"range"};if(a?.doc===o&&typeof a.x=="number"&&typeof a.y=="number"){let x=w(this,T,sl).call(this,d)({left:a.x,right:a.x,top:a.y,bottom:a.y}),S={..._,pointerMapped:Math.round(x.left)};return i&&x.left<=p+y?{...S,direction:-1,edge:!0,edgeSource:"pointer"}:!i&&x.right>=m-y?{...S,direction:1,edge:!0,edgeSource:"pointer"}:{...S,direction:0,edge:!1,reason:"not-at-edge"}}return{..._,direction:0,edge:!1,reason:"not-at-edge"}},v_=async function(e,s){if(this.scrolled){let o=w(this,T,sl).call(this)(e).left-3,c=w(this,T,Ks).call(this,r(this,dt));return w(this,T,Yc).call(this,c+o,s)}let i=w(this,T,sl).call(this)(e).left,a=w(this,T,Ks).call(this,r(this,dt))+i;return w(this,T,Kc).call(this,Math.floor(a/this.size+.01),s)},Yc=async function(e,s,i){let{size:n}=this;if(this.containerPosition===e){b(this,ar,[e,this.atStart?0:n,this.atEnd?0:n]),w(this,T,nl).call(this,s);return}if(this.scrolled&&r(this,At)&&(e=-e),(s==="snap"||i)&&this.hasAttribute("animated")&&!this.hasAttribute("eink")){let a=this.containerPosition;if(b(this,nn,!0),r(this,T,Ys)>2e4)return uC(a,e,300,dC,o=>{r(this,St)[this.scrollProp]=o,this.scrolled||w(this,T,Ei).call(this)}).then(()=>{b(this,nn,!1),b(this,ar,[e,this.atStart?0:n,this.atEnd?0:n]),w(this,T,nl).call(this,s)});if(!this.scrolled){w(this,T,Ei).call(this,a);let o=r(this,St).children[0],c=()=>{if(!r(this,nn))return;let h=o&&getComputedStyle(o).transform,d=h&&h!=="none"?new DOMMatrix(h)[r(this,At)?"m42":"m41"]:0;w(this,T,Ei).call(this,a-d),requestAnimationFrame(c)};requestAnimationFrame(c)}return hC(r(this,St),this.scrollProp,a,e,300).then(()=>{b(this,nn,!1),b(this,ar,[e,this.atStart?0:n,this.atEnd?0:n]),w(this,T,nl).call(this,s)})}else this.containerPosition=e,b(this,ar,[e,this.atStart?0:n,this.atEnd?0:n]),w(this,T,nl).call(this,s)},Kc=async function(e,s,i){let n=this.size*(r(this,nr)?-e:e);return w(this,T,Yc).call(this,n,s,i)},il=async function(e,s="anchor",i=!1){b(this,ks,e);let n=fC(e)?.getClientRects?.();if(n){let u=Array.from(n).find(f=>f.width>0&&f.height>0&&f.x>=0&&f.y>=0)||n[0];if(!u)return;if(await w(this,T,v_).call(this,u,s),s==="navigation"){let f=e.focus?e:void 0;!f&&e.startContainer&&(f=e.startContainer,f.nodeType===Node.TEXT_NODE&&(f=f.parentElement)),f&&f.focus&&(f.tabIndex=-1,f.style.outline="none",f.focus({preventScroll:!0}))}return}if(this.scrolled){let u=w(this,T,Ks).call(this,r(this,dt)),f=r(this,T,Vt),p=f?f.element.getBoundingClientRect()[this.sideProp]:r(this,T,Ys);await w(this,T,Yc).call(this,u+e*p,s,i);return}let a=r(this,T,Vt);if(!a)return;let o=w(this,T,Jw).call(this,r(this,dt)),c=a.contentPages;if(!c)return;let h=Math.round(e*(c-1)),d=Math.floor(h/this.columnCount);await w(this,T,Kc).call(this,o+d,s,i)},Ks=function(e){let s=0;for(let[i,n]of r(this,T,Bt)){if(i===e)return s;s+=n.element.getBoundingClientRect()[this.sideProp]}return s},Jw=function(e){return Math.floor(w(this,T,Ks).call(this,e)/this.size+.01)},Zw=function(){let e=r(this,T,Vt);if(!e?.document)return;let s=w(this,T,Ks).call(this,r(this,dt));if(this.scrolled){for(let[n,a]of r(this,T,Bt)){if(!a.document)continue;let o=w(this,T,Ks).call(this,n),c=a.element.getBoundingClientRect()[this.sideProp];if(o+c<=r(this,T,ee)||o>=r(this,T,je))continue;let h=u_(a.document,r(this,T,ee)-o,r(this,T,je)-o,w(this,T,sl).call(this,a));if(h&&!h.collapsed)return{range:h,index:n}}return}let i=u_(e.document,r(this,T,ee)-s,r(this,T,je)-s,w(this,T,sl).call(this,e));return i?{range:i,index:r(this,dt)}:void 0},Qw=function(){if(r(this,st).size<=1)return;let e=r(this,T,ee),s=0;for(let[i,n]of r(this,T,Bt)){let a=n.element.getBoundingClientRect()[this.sideProp];if(e0?Math.floor((r(this,T,Ys)-r(this,T,je))/e):0)>=s));){let o=r(this,T,Bt),c=o[o.length-1]?.[0];if(c==null)break;let h=w(this,T,Le).call(this,1,c);if(h==null||!w(this,T,fa).call(this,h)||(await w(this,T,pa).call(this,h),!r(this,st).has(h)))break}await new Promise(a=>requestAnimationFrame(a))}finally{b(this,We,!1),this.dispatchEvent(new Event("stabilized"))}}},nl=function(e){r(this,st).size>1&&e!=="anchor"&&e!=="navigation"&&(w(this,T,Qw).call(this),w(this,T,Gc).call(this));let{range:s,index:i}=w(this,T,Zw).call(this)||{};if(!s)return;b(this,ff,s),e!=="selection"&&e!=="navigation"&&e!=="anchor"?b(this,ks,s):b(this,Zc,!0);let n=i??r(this,dt),a=r(this,T,Vt),o={reason:e,range:s,index:n};if(this.scrolled){let c=w(this,T,Ks).call(this,n),h=a?a.element.getBoundingClientRect()[this.sideProp]:r(this,T,Ys);o.fraction=h>0?Math.max(0,Math.min(1,(r(this,T,ee)-c)/h)):0}else if(r(this,T,qc)>0&&a){let c=r(this,T,Xc),h=w(this,T,Jw).call(this,n),d=a.contentPages;r(this,ma).style.visibility=c>0?"visible":"hidden";let u=c-h,f=u*this.columnCount;if(o.fraction=d>0?Math.max(0,Math.min(1,f/d)):0,o.size=d>0?this.columnCount/d:1,e==="container-scroll"&&u===0)return}this.scrolled||w(this,T,Ei).call(this),this.dispatchEvent(new CustomEvent("relocate",{detail:o}))},__=async function(e){b(this,Ls,!0),r(this,St).style.opacity="0";let{index:s,src:i,data:n,anchor:a,onLoad:o,select:c}=await e;b(this,dt,s),w(this,T,Gc).call(this);let h=r(this,T,Vt)?.document?.hasFocus();if(i){let f=w(this,T,Vw).call(this,s),p=g=>{if(g.head){let v=g.createElement("style");g.head.prepend(v);let y=g.createElement("style");g.head.append(y),this.sections[s].spineProperties?.forEach(_=>g.documentElement.setAttribute("data-"+_,"")),r(this,th).set(g,[v,y])}o?.({doc:g,index:s,primary:!0})},m=w(this,T,Gw).bind(this);if(await f.load(i,n,p,m),f.document){let g=tf(f.document);r(this,or).set(s,g.vertical)}this.dispatchEvent(new CustomEvent("create-overlayer",{detail:{doc:f.document,index:s,attach:g=>f.overlayer=g}}))}let d=r(this,T,Vt);if(!this.noPreload&&!this.noContinuousScroll&&d&&(d.contentPages>0&&d.contentPages{b(this,Ls,!1)})},pa=async function(e){if(r(this,st).has(e)||!w(this,T,Vg).call(this,e))return;let s=this.sections[e];if(!s||s.linear==="no")return;let i=r(this,T,Bt)[0]?.[0],n=this.scrolled&&i!=null&&e{if(p.head){let m=p.createElement("style");p.head.prepend(m);let g=p.createElement("style");p.head.append(g),s.spineProperties?.forEach(v=>p.documentElement.setAttribute("data-"+v,"")),r(this,th).set(p,[m,g])}this.setStyles(r(this,Qc)),this.dispatchEvent(new CustomEvent("load",{detail:{doc:p,index:e,primary:!1}}))},u=r(this,ih),f=()=>u;if(await h.load(o,c,d,f),h.document){let p=tf(h.document);if(r(this,or).set(e,p.vertical),p.vertical!==r(this,At)){w(this,T,ua).call(this,e);return}}if(n){let p=h.element.getBoundingClientRect()[this.sideProp],m=a+p-r(this,T,ee);Math.abs(m)>.5&&(this.containerPosition+=(r(this,At)?-1:1)*m)}this.dispatchEvent(new CustomEvent("create-overlayer",{detail:{doc:h.document,index:e,attach:p=>h.overlayer=p}}))}catch(o){console.warn(o),console.warn(new Error(`Failed to load adjacent section ${e}`))}},ty=async function({reanchor:e=!0}={}){if(!(this.noPreload||this.noContinuousScroll||r(this,We))){b(this,We,!0);try{let{size:s}=this;if(!s)return;let i=5,n=8,a=r(this,T,Vt);if(a&&a.contentPages>0&&a.contentPages=r(this,dt)){let d=w(this,T,Le).call(this,-1,h);d!=null&&w(this,T,fa).call(this,d)&&await w(this,T,pa).call(this,d)}}let o=0;for(;r(this,st).size=i));){let h=r(this,T,Bt),d=h[h.length-1]?.[0];if(d==null)break;let u=w(this,T,Le).call(this,1,d);if(u==null||!w(this,T,fa).call(this,u)||(await w(this,T,pa).call(this,u),!r(this,st).has(u)))break}e&&w(this,T,il).call(this,r(this,ks))}finally{b(this,We,!1),this.dispatchEvent(new Event("stabilized"))}}},ey=function(){let{size:e}=this;if(!e)return;let s=e*10,i=r(this,T,je);for(let[n,a]of r(this,T,Bt)){if(n<=r(this,dt))continue;w(this,T,Ks).call(this,n)-i>s&&w(this,T,ua).call(this,n)}},Vg=function(e){return e>=0&&e<=this.sections.length-1},Wg=async function({index:e,anchor:s,select:i}){let n=!1;if(r(this,st).has(e)){let a=r(this,st).get(e);if(a?.document){let{vertical:o}=tf(a.document);n=o!==r(this,At)}}else r(this,or).has(e)&&(n=r(this,or).get(e)!==r(this,At));if(r(this,st).has(e)&&!n){b(this,Ls,!0);let a=!this.scrolled||this.noContinuousScroll;a&&(r(this,St).style.opacity="0");let o=r(this,T,Vt)?.document?.hasFocus();if(b(this,dt,e),w(this,T,Gc).call(this),w(this,T,ey).call(this),this.noContinuousScroll)for(let[f]of r(this,st))f!==e&&w(this,T,ua).call(this,f);let c=r(this,T,Vt),h=(typeof s=="function"?s(c.document):s)??0,d=c&&c.contentPages>0&&c.contentPages{if(this.noPreload||this.noContinuousScroll||!(d||this.scrolled))return;let f=r(this,T,Bt)[0]?.[0];if(f==null)return;let p=w(this,T,Le).call(this,-1,f);p!=null&&w(this,T,fa).call(this,p)&&await w(this,T,pa).call(this,p)};this.scrolled||await u(),await this.scrollToAnchor(h,i),this.scrolled&&await u(),a&&(r(this,St).style.opacity="1"),o&&this.focusView(),b(this,rn,w(this,T,ty).call(this)),r(this,rn).then(()=>{b(this,Ls,!1)})}else{if(n)w(this,T,Ww).call(this);else{let c=new Set([e]);if(!this.noContinuousScroll)for(let[h]of r(this,st))Math.abs(h-e)<=2&&c.add(h);w(this,T,y_).call(this,c)}let a=r(this,dt),o=c=>{a>=0&&!r(this,st).has(a)&&this.sections[a]?.unload?.(),this.setStyles(r(this,Qc)),this.dispatchEvent(new CustomEvent("load",{detail:c}))};await w(this,T,__).call(this,Promise.resolve(this.sections[e].load()).then(async c=>{let h=await this.sections[e].loadContent?.();return{index:e,src:c,data:h,anchor:s,onLoad:o,select:i}}).catch(c=>(console.warn(c),console.warn(new Error(`Failed to load section ${e}`)),{})))}},x_=function(e){if(r(this,st).size===0)return!0;if(this.scrolled)return r(this,T,ee)>0?w(this,T,Yc).call(this,Math.max(0,r(this,T,ee)-(e??this.size)),null,!0):!this.atStart;if(this.atStart)return;let s=r(this,T,Xc)-1;return s<0?!0:w(this,T,Kc).call(this,s,"page",!0)},S_=function(e){if(r(this,st).size===0)return!0;if(this.scrolled)return r(this,T,Ys)-r(this,T,je)>2?w(this,T,Yc).call(this,Math.min(r(this,T,Ys),e?r(this,T,ee)+e:r(this,T,je)),null,!0):!this.atEnd;if(this.atEnd)return;let s=r(this,T,Xc)+1,i=r(this,T,qc);return s>=i?!0:w(this,T,Kc).call(this,s,"page",!0)},Le=function(e,s){s===void 0&&(s=r(this,dt));for(let i=s+e;w(this,T,Vg).call(this,i);i+=e)if(this.sections[i]?.linear!=="no")return i},sy=async function(e,s){if(r(this,sn))return;b(this,sn,!0);let i=e===-1,n=await(i?w(this,T,x_).call(this,s):w(this,T,S_).call(this,s));if(n){r(this,rn)&&await r(this,rn);let a=r(this,T,Bt),o=i?a[0]?.[0]??r(this,dt):a[a.length-1]?.[0]??r(this,dt);await w(this,T,Wg).call(this,{index:w(this,T,Le).call(this,e,o),anchor:i?()=>1:()=>0})}(n||!this.hasAttribute("animated"))&&await oC(100),b(this,sn,!1)},H(ef,"observedAttributes",["flow","gap","margin","margin-top","margin-bottom","margin-left","margin-right","max-inline-size","max-block-size","max-column-count","no-preload","no-background","no-continuous-scroll"]);customElements.get("foliate-paginator")||customElements.define("foliate-paginator",ef)});var R_={};Ms(R_,{search:()=>L_,searchMatcher:()=>_C});var T_,k_,vC,AC,L_,_C,M_=os(()=>{"use strict";T_=l=>l.replace(/\s+/g," "),k_=(l,{startIndex:t,startOffset:e,endIndex:s,endOffset:i})=>{let n=l[t],a=l[s],o=n===a?n.slice(e,i):n.slice(e)+l.slice(n+1,a).join("")+a.slice(0,i),c=T_(n.slice(0,e)).trimStart(),h=T_(a.slice(i)).trimEnd(),d=c.length<50?"":"\u2026",u=h.length<50?"":"\u2026",f=`${d}${c.slice(-50)}`,p=`${h.slice(0,50)}${u}`;return{pre:f,match:o,post:p}},vC=function*(l,t,e={}){let{locales:s="en",sensitivity:i}=e,n=i==="variant",a=l.join(""),o=n?a:a.toLocaleLowerCase(s),c=n?t:t.toLocaleLowerCase(s),h=c.length,d=-1,u=-1,f=0;do if(d=o.indexOf(c,d+1),d>-1){for(;f<=d;)f+=l[++u].length;let p=u,m=d-(f-l[u].length),g=d+h;for(;f<=g;)f+=l[++u].length;let v=u,y=g-(f-l[u].length),_={startIndex:p,startOffset:m,endIndex:v,endOffset:y};yield{range:_,excerpt:k_(l,_)}}while(d>-1)},AC=function*(l,t,e={}){let{locales:s="en",granularity:i="word",sensitivity:n="base"}=e,a,o;try{a=new Intl.Segmenter(s,{usage:"search",granularity:i}),o=new Intl.Collator(s,{sensitivity:n})}catch(f){console.warn(f),a=new Intl.Segmenter("en",{usage:"search",granularity:i}),o=new Intl.Collator("en",{sensitivity:n})}let c=Array.from(a.segment(t)).length,h=[],d=0,u=a.segment(l[d])[Symbol.iterator]();t:for(;dp.segment).join("");if(o.compare(t,f)===0){let p=d,m=h[h.length-1],g=m.index+m.segment.length,v=h[0].strIndex,y=h[0].index,_={startIndex:v,startOffset:y,endIndex:p,endOffset:g};yield{range:_,excerpt:k_(l,_)}}h.shift()}},L_=(l,t,e)=>{let{granularity:s="grapheme",sensitivity:i="base"}=e;return!Intl?.Segmenter||s==="grapheme"&&(i==="variant"||i==="accent")?vC(l,t,e):AC(l,t,e)},_C=(l,t)=>{let{defaultLocale:e,matchCase:s,matchDiacritics:i,matchWholeWords:n,acceptNode:a}=t;return function*(o,c){let h=l(o,function*(d,u){for(let f of L_(d,c,{locales:o.body.lang||o.documentElement.lang||e||"en",granularity:n?"word":"grapheme",sensitivity:i&&s?"variant":i&&!s?"accent":!i&&s?"case":"base"})){let{startIndex:p,startOffset:m,endIndex:g,endOffset:v}=f.range;f.range=u(p,m,g,v),yield f}},a);for(let d of h)yield d}}});var B_={};Ms(B_,{TTS:()=>ny});function*TC(l,t,e){for(let s of O_(l)){let{entries:i}=D_(s,t,"sentence",e);for(let[,n]of i)iy(n)||(yield n)}}function*O_(l){let t,e=l.createTreeWalker(l.body,NodeFilter.SHOW_ELEMENT);for(let s=e.nextNode();s;s=e.nextNode()){let i=s.tagName.toLowerCase();xC.has(i)&&(t&&(t.setEndBefore(s),iy(t)||(yield t)),t=l.createRange(),t.setStart(s,0))}t||(t=l.createRange(),t.setStart(l.body.firstChild??l.body,0)),t.setEndAfter(l.body.lastChild??l.body),iy(t)||(yield t)}var rs,xC,P_,I_,SC,EC,D_,N_,iy,CC,ut,lr,Re,Ge,Kg,F_,Yg,Ci,Zs,rh,Ti,wa,Qs,bf,gt,qg,nh,ry,mf,ba,ay,ny,z_=os(()=>{"use strict";rs={XML:"http://www.w3.org/XML/1998/namespace",SSML:"http://www.w3.org/2001/10/synthesis"},xC=new Set(["article","aside","audio","blockquote","caption","details","dialog","div","dl","dt","dd","figure","footer","form","figcaption","h1","h2","h3","h4","h5","h6","header","hgroup","hr","li","main","math","nav","ol","p","pre","section","tr"]),P_=l=>{let t=l.lang||l?.getAttributeNS?.(rs.XML,"lang");return t||(l.parentElement?P_(l.parentElement):null)},I_=l=>{let t=l?.getAttributeNS?.(rs.XML,"lang");return t||(l.parentElement?I_(l.parentElement):null)},SC=(l="en",t="word")=>{let e=new Intl.Segmenter(l,{granularity:t}),s=t==="word";return function*(i,n){let a=i.join(""),o=0,c=-1,h=0;for(let{index:d,segment:u,isWordLike:f}of e.segment(a)){if(s&&!f)continue;for(;h<=d;)h+=i[++c].length;let p=c,m=d-(h-i[c].length),g=d+u.length-1;if(g{let e=document.implementation.createDocument(rs.SSML,"speak"),{lang:s}=t;s&&e.documentElement.setAttributeNS(rs.XML,"lang",s);let i=(n,a,o)=>{if(!n)return;if(n.nodeType===3)return e.createTextNode(n.textContent);if(n.nodeType===4)return e.createCDATASection(n.textContent);if(n.nodeType!==1)return;let c,h=n.nodeName.toLowerCase();if(h==="rt"||h==="rp")return;h==="foliate-mark"?(c=e.createElementNS(rs.SSML,"mark"),c.setAttribute("name",n.dataset.name)):h==="br"?c=e.createElementNS(rs.SSML,"break"):(h==="em"||h==="strong")&&(c=e.createElementNS(rs.SSML,"emphasis"));let d=n.lang||n.getAttributeNS(rs.XML,"lang");d&&(c||(c=e.createElementNS(rs.SSML,"lang")),c.setAttributeNS(rs.XML,"lang",d));let u=n.getAttributeNS(rs.SSML,"alphabet")||o;if(!c){let p=n.getAttributeNS(rs.SSML,"ph");p&&(c=e.createElementNS(rs.SSML,"phoneme"),u&&c.setAttribute("alphabet",u),c.setAttribute("ph",p))}c||(c=a);let f=n.firstChild;for(;f;){let p=i(f,c,u);p&&c!==p&&c.append(p),f=f.nextSibling}return c};return i(l.firstChild,e.documentElement,t.alphabet),e},D_=(l,t,e,s)=>{let i=P_(l.commonAncestorContainer),n=I_(l.commonAncestorContainer),a=SC(i,e),o=l.cloneContents(),c=[...t(l,a,s)],h=[...t(o,a,s)];for(let[u,f]of h){let p=document.createElement("foliate-mark");p.dataset.name=u,f.insertNode(p)}let d=EC(o,{lang:i,alphabet:n});return{entries:c,ssml:d}},N_=l=>{let t=l.cloneContents(),s=(t.ownerDocument||document).createTreeWalker(t,NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT|NodeFilter.SHOW_CDATA_SECTION,{acceptNode:n=>{if(n.nodeType===1){let a=n.nodeName.toLowerCase();return a==="rt"||a==="rp"||a==="script"||a==="style"?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_SKIP}return NodeFilter.FILTER_ACCEPT}}),i="";for(let n=s.nextNode();n;n=s.nextNode())i+=n.nodeValue||"";return i},iy=l=>!N_(l).trim(),CC=l=>N_(l).replace(/\s+/g," ").trim();Yg=class{constructor(t,e=s=>s){A(this,Kg);A(this,ut,[]);A(this,lr);A(this,Re,-1);A(this,Ge);b(this,lr,t),b(this,Ge,e)}current(){if(r(this,ut)[r(this,Re)])return r(this,Ge).call(this,r(this,ut)[r(this,Re)])}first(){return r(this,ut)[0]?(b(this,Re,0),r(this,Ge).call(this,r(this,ut)[0])):this.next()}last(){for(let e of r(this,lr))r(this,ut).push(e);let t=r(this,ut).length-1;if(r(this,ut)[t])return b(this,Re,t),r(this,Ge).call(this,r(this,ut)[t])}prev(){let t=r(this,Re)-1;if(r(this,ut)[t])return b(this,Re,t),r(this,Ge).call(this,r(this,ut)[t])}next(){let t=r(this,Re)+1;if(r(this,ut)[t])return b(this,Re,t),r(this,Ge).call(this,r(this,ut)[t]);for(;;){let{done:e,value:s}=r(this,lr).next();if(e)break;if(r(this,ut).push(s),r(this,ut)[t])return b(this,Re,t),r(this,Ge).call(this,r(this,ut)[t])}}prepare(){let t=r(this,Re)+1;if(r(this,ut)[t])return r(this,Ge).call(this,r(this,ut)[t]);for(;;){let{done:e,value:s}=r(this,lr).next();if(e)break;if(r(this,ut).push(s),r(this,ut)[t])return r(this,Ge).call(this,r(this,ut)[t])}}peek(t=1,e=1){if(t<=0)return[];let s=Math.max(r(this,Re)+e,0),i=[],n=s+t;for(let a=s;at(s));if(e>-1)return b(this,Re,e),r(this,Ge).call(this,r(this,ut)[e]);for(;;){let{done:s,value:i}=r(this,lr).next();if(s)break;if(r(this,ut).push(i),t(i))return b(this,Re,r(this,ut).length-1),r(this,Ge).call(this,i)}}};ut=new WeakMap,lr=new WeakMap,Re=new WeakMap,Ge=new WeakMap,Kg=new WeakSet,F_=function(t){for(;r(this,ut)[t]==null;){let{done:e,value:s}=r(this,lr).next();if(e||(r(this,ut).push(s),r(this,ut).length-1>=t))break}return r(this,ut)[t]};ny=class{constructor(t,e,s,i,n,a){A(this,gt);A(this,Ci);A(this,Zs);A(this,rh);A(this,Ti);A(this,wa);A(this,Qs);A(this,bf,new XMLSerializer);this.doc=t;let o=null,c=null,h="word";typeof s=="function"?(c=s,typeof i=="function"?(b(this,Qs,i),h=typeof n=="string"?n:"word"):h=typeof i=="string"?i:"word"):(o=s??null,c=typeof i=="function"?i:null,typeof n=="function"&&b(this,Qs,n),h=typeof a=="string"?a:"word"),this.highlight=c||(()=>null),b(this,Ci,new Yg(O_(t),d=>{let{entries:u,ssml:f}=D_(d,e,h,o);return b(this,rh,new Map(u)),[f,d]})),b(this,Zs,new Yg(TC(t,e,o),d=>[CC(d),d]))}start(){b(this,Ti,null);let[t,e]=r(this,Ci).first()??[];return w(this,gt,nh).call(this,e),t?w(this,gt,ba).call(this,t,s=>w(this,gt,qg).call(this,s,r(this,Ti))):this.next()}resume(){let[t]=r(this,Ci).current()??[];return t?w(this,gt,ba).call(this,t,e=>w(this,gt,qg).call(this,e,r(this,Ti))):this.next()}prev(t){b(this,Ti,null);let[e,s]=r(this,Ci).prev()??[];return w(this,gt,nh).call(this,s),t&&s&&this.highlight(s.cloneRange()),w(this,gt,ba).call(this,e)}next(t){b(this,Ti,null);let[e,s]=r(this,Ci).next()??[];return w(this,gt,nh).call(this,s),t&&s&&this.highlight(s.cloneRange()),w(this,gt,ba).call(this,e)}end(){b(this,Ti,null);let[t,e]=r(this,Ci).last()??[];return w(this,gt,nh).call(this,e),t?w(this,gt,ba).call(this,t):this.next()}prepare(){let[t]=r(this,Ci).prepare()??[];return w(this,gt,ba).call(this,t)}from(t){b(this,Ti,null);let[e]=r(this,Ci).find(i=>t.compareBoundaryPoints(Range.END_TO_START,i)<=0);r(this,Zs).find(i=>t.compareBoundaryPoints(Range.END_TO_START,i)<=0);let s;for(let[i,n]of r(this,rh).entries())if(t.compareBoundaryPoints(Range.START_TO_START,n)<=0){s=i;break}return w(this,gt,ba).call(this,e,i=>w(this,gt,qg).call(this,i,s))}setMark(t){let e=r(this,rh).get(t);e&&(b(this,Ti,t),b(this,wa,e.cloneRange()),w(this,gt,nh).call(this,e),this.highlight(e.cloneRange()))}currentDetail(){return w(this,gt,mf).call(this,w(this,gt,ry).call(this))}collectDetails(t=1,{includeCurrent:e=!1,offset:s=1}={}){if(!Number.isFinite(t)||t<=0)return[];let i=[];if(e){let o=w(this,gt,mf).call(this,w(this,gt,ry).call(this));o&&i.push(o)}let n=t-i.length;if(n<=0)return i;let a=r(this,Zs).peek(n,s);for(let o of a){let c=w(this,gt,mf).call(this,o);c&&i.push(c)}return i}alignCfi(t){return w(this,gt,ay).call(this,t,{highlight:!1})}highlightCfi(t){return w(this,gt,ay).call(this,t,{highlight:!0})}getLastRange(){return r(this,wa)?.cloneRange?.()??null}};Ci=new WeakMap,Zs=new WeakMap,rh=new WeakMap,Ti=new WeakMap,wa=new WeakMap,Qs=new WeakMap,bf=new WeakMap,gt=new WeakSet,qg=function(t,e){return e?t.querySelector(`mark[name="${CSS.escape(e)}"]`):null},nh=function(t){if(t){if(r(this,Qs)){let e=r(this,Qs).call(this,t.cloneRange());if(e){r(this,Zs).find(s=>r(this,Qs).call(this,s.cloneRange())===e);return}}r(this,Zs).find(e=>e.compareBoundaryPoints(Range.START_TO_START,t)===0&&e.compareBoundaryPoints(Range.END_TO_END,t)===0)}},ry=function(){return r(this,Zs).current()??r(this,Zs).first()??r(this,Zs).next()},mf=function(t,{highlight:e=!1}={}){if(!t)return null;let[s,i]=t;if(!s||!i)return null;let n=null;if(e&&i.cloneRange){let a=i.cloneRange();n=this.highlight(a)??null,b(this,wa,a.cloneRange?a.cloneRange():a)}return!n&&r(this,Qs)&&i.cloneRange&&(n=r(this,Qs).call(this,i.cloneRange())),!r(this,wa)&&i.cloneRange&&b(this,wa,i.cloneRange()),{text:s,cfi:n}},ba=function(t,e){if(!t)return;if(!e)return r(this,bf).serializeToString(t);let s=document.implementation.createDocument(rs.SSML,"speak");s.documentElement.replaceWith(s.importNode(t.documentElement,!0));let i=e(s)?.previousSibling;for(;i;){let n=i.previousSibling??i.parentNode?.previousSibling;i.parentNode.removeChild(i),i=n}return r(this,bf).serializeToString(s)},ay=function(t,{highlight:e=!1}={}){if(!t||!r(this,Qs))return null;let s=r(this,Zs).find(i=>r(this,Qs).call(this,i.cloneRange())===t);return w(this,gt,mf).call(this,s,{highlight:e})}});Sf();var Ot=l=>document.createElementNS("http://www.w3.org/2000/svg",l),_a,dh,dr,ls,uh,Li,ur=class{constructor(){A(this,_a,Ot("svg"));A(this,dh,Ot("defs"));A(this,dr,Ot("g"));A(this,ls,null);A(this,uh,`foliate-overlayer-hole-${Math.random().toString(36).slice(2)}`);A(this,Li,new Map);Object.assign(r(this,_a).style,{position:"absolute",top:"0",left:"0",width:"100%",height:"100%",pointerEvents:"none"}),r(this,_a).append(r(this,dh),r(this,dr))}get element(){return r(this,_a)}add(t,e,s,i){r(this,Li).has(t)&&this.remove(t);let n=typeof e=="function"?e(r(this,_a).getRootNode()):e,a=n.getClientRects(),o=s(a,i);r(this,dr).append(o),r(this,Li).set(t,{range:n,draw:s,options:i,element:o,rects:a})}remove(t){r(this,Li).has(t)&&(r(this,Li).get(t).element.remove(),r(this,Li).delete(t))}redraw(){for(let t of r(this,Li).values()){let{range:e,draw:s,options:i,element:n}=t;n.remove();let a=e.getClientRects(),o=s(a,i);r(this,dr).append(o),t.element=o,t.rects=a}}setHole(t,e,s,i,n=0){if(!r(this,ls)){let a=Ot("mask");a.id=r(this,uh),a.setAttribute("maskUnits","userSpaceOnUse"),a.setAttribute("x","0"),a.setAttribute("y","0"),a.setAttribute("width","100%"),a.setAttribute("height","100%");let o=Ot("rect");o.setAttribute("x","0"),o.setAttribute("y","0"),o.setAttribute("width","100%"),o.setAttribute("height","100%"),o.setAttribute("fill","white");let c=Ot("rect");c.setAttribute("fill","black"),a.append(o,c),r(this,dh).append(a),r(this,dr).setAttribute("mask",`url(#${r(this,uh)})`),b(this,ls,{mask:a,hole:c})}r(this,ls).hole.setAttribute("x",t),r(this,ls).hole.setAttribute("y",e),r(this,ls).hole.setAttribute("width",s),r(this,ls).hole.setAttribute("height",i),r(this,ls).hole.setAttribute("rx",n),r(this,ls).hole.setAttribute("ry",n)}clearHole(){r(this,dr).removeAttribute("mask"),r(this,ls)?.mask.remove(),b(this,ls,null)}hitTest({x:t,y:e}){let s=Array.from(r(this,Li).entries());for(let i=s.length-1;i>=0;i--){let[n,a]=s[i];for(let{left:o,top:c,right:h,bottom:d}of a.rects)if(c<=e&&o<=t&&d>e&&h>t)return[n,a.range]}return[]}static underline(t,e={}){let{color:s="red",width:i=2,writingMode:n}=e,a=Ot("g");if(a.setAttribute("fill",s),n==="vertical-rl"||n==="vertical-lr")for(let{right:o,top:c,height:h}of t){let d=Ot("rect");d.setAttribute("x",o-i),d.setAttribute("y",c),d.setAttribute("height",h),d.setAttribute("width",i),a.append(d)}else for(let{left:o,bottom:c,width:h}of t){let d=Ot("rect");d.setAttribute("x",o),d.setAttribute("y",c-i),d.setAttribute("height",i),d.setAttribute("width",h),a.append(d)}return a}static strikethrough(t,e={}){let{color:s="red",width:i=2,writingMode:n}=e,a=Ot("g");if(a.setAttribute("fill",s),n==="vertical-rl"||n==="vertical-lr")for(let{right:o,left:c,top:h,height:d}of t){let u=Ot("rect");u.setAttribute("x",(o+c)/2),u.setAttribute("y",h),u.setAttribute("height",d),u.setAttribute("width",i),a.append(u)}else for(let{left:o,top:c,bottom:h,width:d}of t){let u=Ot("rect");u.setAttribute("x",o),u.setAttribute("y",(c+h)/2),u.setAttribute("height",i),u.setAttribute("width",d),a.append(u)}return a}static squiggly(t,e={}){let{color:s="red",width:i=2,writingMode:n}=e,a=Ot("g");a.setAttribute("fill","none"),a.setAttribute("stroke",s),a.setAttribute("stroke-width",i);let o=i*1.5;if(n==="vertical-rl"||n==="vertical-lr")for(let{right:c,top:h,height:d}of t){let u=Ot("path"),f=Math.round(d/o/1.5),p=d/f,m=Array.from({length:f},(g,v)=>`l${v%2?-o:o} ${p}`).join("");u.setAttribute("d",`M${c} ${h}${m}`),a.append(u)}else for(let{left:c,bottom:h,width:d}of t){let u=Ot("path"),f=Math.round(d/o/1.5),p=d/f,m=Array.from({length:f},(g,v)=>`l${p} ${v%2?o:-o}`).join("");u.setAttribute("d",`M${c} ${h}${m}`),a.append(u)}return a}static highlight(t,e={}){let{color:s="red"}=e,i=Ot("g");i.setAttribute("fill",s),i.style.opacity="var(--overlayer-highlight-opacity, .3)",i.style.mixBlendMode="var(--overlayer-highlight-blend-mode, normal)";for(let{left:n,top:a,height:o,width:c}of t){let h=Ot("rect");h.setAttribute("x",n),h.setAttribute("y",a),h.setAttribute("height",o),h.setAttribute("width",c),i.append(h)}return i}static outline(t,e={}){let{color:s="red",width:i=3,radius:n=3}=e,a=Ot("g");a.setAttribute("fill","none"),a.setAttribute("stroke",s),a.setAttribute("stroke-width",i);for(let{left:o,top:c,height:h,width:d}of t){let u=Ot("rect");u.setAttribute("x",o),u.setAttribute("y",c),u.setAttribute("height",h),u.setAttribute("width",d),u.setAttribute("rx",n),a.append(u)}return a}static arrow(t,e={}){let{color:s="red",size:i=20,animated:n=!0,autoHide:a=!0,hideDelay:o=5e3,offset:c=10}=e,h=Ot("g"),d=t[0];if(!d)return h;let u=Math.min(i,d.height*.8),f=d.top+d.height/2,p=d.left-c-u,m=Ot("path"),g=`M ${p+u} ${f} + `,b(this,Ve,r(this,ir).getElementById("top")),b(this,Ts,r(this,ir).getElementById("background")),b(this,St,r(this,ir).getElementById("container")),b(this,ma,r(this,ir).getElementById("header")),b(this,ol,r(this,ir).getElementById("footer")),r(this,cf).observe(r(this,St));let e=lC(()=>{if(this.scrolled&&!r(this,nn)){if(r(this,Ls))return;r(this,Zc)?b(this,Zc,!1):w(this,T,nl).call(this,"scroll")}else this.scrolled||w(this,T,nl).call(this,"container-scroll")},250);r(this,St).addEventListener("scroll",()=>{if(r(this,nn)||this.dispatchEvent(new Event("scroll")),!this.scrolled&&!r(this,nn)&&w(this,T,Ei).call(this),!this.noPreload&&!this.noContinuousScroll&&!r(this,We)&&!r(this,Ls)&&(this.size>0?Math.floor((r(this,T,Ys)-r(this,T,je))/this.size):0)<5){let x=r(this,T,Bt),S=x[x.length-1]?.[0];if(S!=null){let E=w(this,T,Le).call(this,1,S);E!=null&&!r(this,st).has(E)&&w(this,T,fa).call(this,E)&&(b(this,We,!0),w(this,T,pa).call(this,E).finally(()=>{b(this,We,!1),this.dispatchEvent(new Event("stabilized"))}))}}if(this.scrolled&&!this.noPreload&&!this.noContinuousScroll&&!r(this,We)&&!r(this,Ls)&&(this.size>0?Math.floor(r(this,T,ee)/this.size):0)<5){let S=r(this,T,Bt)[0]?.[0];if(S!=null){let E=w(this,T,Le).call(this,-1,S);E!=null&&!r(this,st).has(E)&&w(this,T,fa).call(this,E)&&(b(this,We,!0),w(this,T,pa).call(this,E).finally(()=>{b(this,We,!1),this.dispatchEvent(new Event("stabilized"))}))}}e()});let s={passive:!1};this.addEventListener("touchstart",w(this,T,Xw).bind(this),s),this.addEventListener("touchmove",w(this,T,qw).bind(this),s),this.addEventListener("touchend",w(this,T,Yw).bind(this)),this.addEventListener("load",({detail:{doc:y}})=>{y.addEventListener("touchstart",w(this,T,Xw).bind(this),s),y.addEventListener("touchmove",w(this,T,qw).bind(this),s),y.addEventListener("touchend",w(this,T,Yw).bind(this))}),this.addEventListener("relocate",({detail:y})=>{y.reason==="selection"?jg(r(this,ks),0):y.reason==="navigation"&&(r(this,ks)===1?jg(y.range,1):typeof r(this,ks)=="number"?jg(y.range,-1):jg(r(this,ks),-1))});let i=null,n=null,a=0,o=0,c=null,h=(y,_={})=>{let S=`[SelectionPaging] ${y} ${(E=>{try{return JSON.stringify(E)}catch{return String(E)}})(_)}`;try{console.log(S)}catch{}try{globalThis.ReactNativeWebView?.postMessage(JSON.stringify({type:"debug",message:S}))}catch{}},d=()=>{n&&h("candidate-clear",{direction:n.direction,age:Math.round(Date.now()-n.stableSince)}),n?.timer&&clearTimeout(n.timer),n=null},u=y=>{if(i||!y){h("turn-skip",{direction:y,reason:i?"in-flight":"no-direction"});return}h("turn-run",{direction:y});let _=y<0?this.prev():this.next();i=Promise.resolve(_).finally(()=>setTimeout(()=>{h("turn-ready",{direction:y}),i=null},80))},f=(y,_)=>!y||!_||Math.hypot(y.x-_.x,y.y-_.y)>18,p=y=>{let _=n;if(!_)return null;if(y&&_.direction!==y)return h("candidate-direction-mismatch",{current:_.direction,next:y}),d(),null;let x=c;return x&&x.updatedAt!==_.pointUpdatedAt&&(f(_.point,x)&&(h("candidate-reset-moving-point",{direction:_.direction,from:_.point,to:{x:x.x,y:x.y}}),_.point={x:x.x,y:x.y},_.stableSince=Date.now()),_.pointUpdatedAt=x.updatedAt),_},m=y=>{y.timer&&clearTimeout(y.timer);let _=Math.max(0,1e3-(Date.now()-y.stableSince));h("candidate-schedule",{direction:y.direction,delay:Math.round(_),stableAge:Math.round(Date.now()-y.stableSince)}),y.timer=setTimeout(()=>{if(p(y.direction)!==y){h("candidate-abort-replaced",{direction:y.direction});return}if(Date.now()-y.stableSince<1e3){h("candidate-reschedule-not-stable",{direction:y.direction,stableAge:Math.round(Date.now()-y.stableSince)}),m(y);return}n=null,h("candidate-fire",{direction:y.direction,stableAge:Math.round(Date.now()-y.stableSince)}),y.run()},_)},g=(y,_)=>{let x=y?.touches?.[0]??y?.changedTouches?.[0]??y;typeof x?.clientX!="number"||typeof x?.clientY!="number"||(c={x:x.clientX,y:x.clientY,doc:_,updatedAt:Date.now()},h("point",{type:y?.type,x:Math.round(c.x),y:Math.round(c.y)}),p())},v=cC((y,_,x)=>{if(r(this,rr)){h("check-skip",{reason:"navigation-locked"});return}if(i){h("check-skip",{reason:"turn-in-flight"});return}if(!_.rangeCount){h("check-skip",{reason:"no-range"});return}let S=_.getRangeAt(0),E=Hw(_),C=w(this,T,Kw).call(this,y,S,E,x,c);if(h("check",{allowEdgeTurn:x,backward:E,direction:C.direction,edge:C.edge,reason:C.reason,edgeSource:C.edgeSource,edgeInset:C.edgeInset,pointerEdgeInset:C.pointerEdgeInset,pointerMapped:C.pointerMapped,mappedLeft:C.mappedLeft,mappedRight:C.mappedRight,visibleStart:C.visibleStart,visibleEnd:C.visibleEnd}),!C.direction){d();return}if(!C.edge){d(),u(C.direction);return}let R=C.direction;if(o===R&&Date.now()-a<900){h("check-skip",{reason:"cooldown",direction:R,remaining:Math.round(900-(Date.now()-a))}),d();return}let k=p(R);if(k){h("candidate-existing",{direction:R,stableAge:Math.round(Date.now()-k.stableSince)});return}d();let M=_.anchorNode?.ownerDocument??_.focusNode?.ownerDocument,L=c;n={direction:R,point:L?{x:L.x,y:L.y}:null,pointUpdatedAt:L?.updatedAt??0,stableSince:Date.now(),run:()=>{let P=M?.getSelection?.();if(!P||P.type!=="Range"||!P.rangeCount){h("run-abort",{reason:"selection-invalid",hasSelection:!!P,type:P?.type,rangeCount:P?.rangeCount});return}let I=P.getRangeAt(0),B=Hw(P),O=w(this,T,Kw).call(this,y,I,B,!0,c);h("run-check",{direction:R,currentDirection:O.direction,currentEdge:O.edge,reason:O.reason,edgeSource:O.edgeSource,edgeInset:O.edgeInset,pointerEdgeInset:O.pointerEdgeInset,pointerMapped:O.pointerMapped,mappedLeft:O.mappedLeft,mappedRight:O.mappedRight,visibleStart:O.visibleStart,visibleEnd:O.visibleEnd}),O.edge&&O.direction===R?(o=R,a=Date.now(),u(R)):h("run-abort",{reason:"edge-or-direction-changed",direction:R,currentDirection:O.direction,currentEdge:O.edge})}},h("candidate-create",{direction:R,point:n.point,pointUpdatedAt:n.pointUpdatedAt}),m(n)},120);this.addEventListener("load",({detail:{doc:y}})=>{let _=!1,x=0,S=0,E=globalThis.navigator?.maxTouchPoints>0,C=L=>{g(L,y),_=!0,x=Date.now()+1200},R=L=>{S=0,d(),C(L)},k=()=>{c=null,d(),setTimeout(()=>{Date.now()>=x&&(_=!1)},160)};y.addEventListener("selectstart",R),y.addEventListener("pointerdown",R),y.addEventListener("pointermove",L=>{y.getSelection()?.type==="Range"&&C(L)}),y.addEventListener("pointerup",k),y.addEventListener("touchstart",R),y.addEventListener("touchmove",L=>{y.getSelection()?.type==="Range"&&C(L)},{passive:!0}),y.addEventListener("touchend",k),y.addEventListener("touchcancel",k);let M=!1;y.addEventListener("keydown",()=>M=!0),y.addEventListener("keyup",()=>M=!1),y.addEventListener("selectionchange",()=>{if(this.scrolled){h("selectionchange-skip",{reason:"scrolled"});return}let L=r(this,ff);if(!L){h("selectionchange-skip",{reason:"no-last-visible-range"});return}let P=y.getSelection();if(!P.rangeCount){h("selectionchange-skip",{reason:"no-range-count",type:P.type});return}if(h("selectionchange",{type:P.type,rangeCount:P.rangeCount,isPointerSelecting:_,activeFor:Math.round(x-Date.now()),touchSelectionHandles:E,changeCount:S,isKeyboardSelecting:M,textLength:P.toString?.()?.trim?.()?.length}),M){let I=P.getRangeAt(0).cloneRange();Hw(P)||I.collapse(),w(this,T,il).call(this,I)}else P.type==="Range"&&(_||Date.now()1||E)):h("selectionchange-skip",{reason:"not-pointer-selection",type:P.type,isPointerSelecting:_,activeFor:Math.round(x-Date.now()),touchSelectionHandles:E})}),y.addEventListener("focusin",L=>{if(this.scrolled)return null;r(this,St)&&r(this,St).contains(L.target)&&requestAnimationFrame(()=>w(this,T,il).call(this,L.target))})}),b(this,eh,()=>{r(this,T,Vt)&&w(this,T,Ei).call(this)}),r(this,uf).addEventListener("change",r(this,eh))}get primaryIndex(){return r(this,dt)}setAttribute(e,s){e==="flow"&&this.scrolled&&String(s)!=="scrolled"&&r(this,st).size>0&&w(this,T,w_).call(this),super.setAttribute(e,s)}attributeChangedCallback(e,s,i){switch(e){case"flow":this.render();break;case"gap":case"margin":r(this,Ve).style.setProperty("--_margin-top",i),r(this,Ve).style.setProperty("--_margin-right",i),r(this,Ve).style.setProperty("--_margin-bottom",i),r(this,Ve).style.setProperty("--_margin-left",i),this.render();break;case"margin-top":case"margin-bottom":case"margin-left":case"margin-right":case"max-block-size":case"max-column-count":r(this,Ve).style.setProperty("--_"+e,i),this.render();break;case"max-inline-size":r(this,Ve).style.setProperty("--_"+e,i),this.render();break;case"no-continuous-scroll":if(this.noContinuousScroll)for(let[n]of r(this,st))n!==r(this,dt)&&w(this,T,ua).call(this,n);break}}open(e){this.bookDir=e.dir,this.sections=e.sections,e.transformTarget?.addEventListener("data",({detail:s})=>{s.type==="text/css"&&(s.data=Promise.resolve(s.data).then(i=>i.replace(/([{\s;])-epub-/gi,"$1").replace(/(\d*\.?\d+)vw/gi,(n,a)=>`${parseFloat(a)*innerWidth/100}px`).replace(/(\d*\.?\d+)vh/gi,(n,a)=>`${parseFloat(a)*innerHeight/100}px`).replace(/page-break-(after|before|inside)\s*:/gi,(n,a)=>`-webkit-column-break-${a}:`).replace(/break-(after|before|inside)\s*:\s*(avoid-)?page/gi,(n,a,o)=>`break-${a}: ${o??""}column`)))})}render(){if(r(this,st).size===0||!r(this,T,Vt))return;b(this,Ls,!0);let s=w(this,T,Gw).call(this,{vertical:r(this,At),rtl:r(this,nr)});for(let[,i]of r(this,st))i.document&&i.render(s);w(this,T,il).call(this,r(this,ks)),b(this,Ls,!1),this.dispatchEvent(new Event("stabilized"))}get scrolled(){return this.getAttribute("flow")==="scrolled"}get navigationLocked(){return r(this,rr)}set navigationLocked(e){b(this,rr,!!e)}get noPreload(){return this.hasAttribute("no-preload")}get noBackground(){return this.hasAttribute("no-background")}get noContinuousScroll(){return this.scrolled&&this.hasAttribute("no-continuous-scroll")}get scrollProp(){let{scrolled:e}=this;return r(this,At)?e?"scrollLeft":"scrollTop":e?"scrollTop":"scrollLeft"}get sideProp(){let{scrolled:e}=this;return r(this,At)?e?"width":"height":e?"height":"width"}get size(){return r(this,St).getBoundingClientRect()[this.sideProp]}get viewSize(){let e=r(this,T,Vt);return e?e.element.getBoundingClientRect()[this.sideProp]:0}get start(){return r(this,T,ee)-w(this,T,Ks).call(this,r(this,dt))}get end(){return r(this,T,je)-w(this,T,Ks).call(this,r(this,dt))}get page(){return Math.floor((this.start+this.end)/2/this.size)}get pages(){let e=r(this,T,Vt);if(!e)return 0;let s=e.element.getBoundingClientRect()[this.sideProp];return Math.round(s/this.size)}get containerPosition(){return r(this,St)[this.scrollProp]}get isOverflowX(){return!1}get isOverflowY(){return!1}set containerPosition(e){r(this,St)[this.scrollProp]=e}set scrollLocked(e){b(this,pf,e)}scrollBy(e,s){let i=r(this,At)?s:e,[n,a,o]=r(this,ar),c=r(this,nr),h=c?n-o:n-a,d=c?n+a:n+o;this.containerPosition=Math.max(h,Math.min(d,this.containerPosition+i))}snap(e,s,i,n,a){let o=r(this,At)?s:e,c=r(this,At)?n/a:i/a,h=Math.abs(e)*2>Math.abs(s),d=r(this,At)?!h:h,[u,f,p]=r(this,ar),m=this.size,g=r(this,T,ee),v=r(this,T,je),y=r(this,T,qc),_=Math.abs(u)-f,x=Math.abs(u)+p,S=this.hasAttribute("animated")&&!this.hasAttribute("eink"),C=(S?o:c)*(r(this,nr)?-m:m)*(d?1:0),R=isNaN(C)?0:S?C*2:C*10,k=Math.floor(Math.max(_,Math.min(x,(g+v)/2+R))/m),M=k<0?-1:k>=y?1:null,L=()=>{if(!M)return;let P=r(this,T,Bt),I=M<0?P[0]?.[0]??r(this,dt):P[P.length-1]?.[0]??r(this,dt);return w(this,T,Wg).call(this,{index:w(this,T,Le).call(this,M,I),anchor:M<0?()=>1:()=>0})};if(M)return L();w(this,T,Kc).call(this,k,"snap")}async scrollToAnchor(e,s,i){return w(this,T,il).call(this,e,s?"selection":"navigation",i)}async goTo(e){if(r(this,sn))return;let s=await e;if(w(this,T,Vg).call(this,s.index))return w(this,T,Wg).call(this,s)}get atStart(){let s=r(this,T,Bt)[0]?.[0]??r(this,dt);return this.scrolled?w(this,T,Le).call(this,-1,s)==null&&r(this,T,ee)<=0:w(this,T,Le).call(this,-1,s)==null&&r(this,T,Xc)<=0}get atEnd(){let e=r(this,T,Bt),s=e[e.length-1]?.[0]??r(this,dt);return this.scrolled?w(this,T,Le).call(this,1,s)==null&&r(this,T,Ys)-r(this,T,je)<=2:w(this,T,Le).call(this,1,s)==null&&r(this,T,Xc)>=r(this,T,qc)-1}async prev(e){return await w(this,T,sy).call(this,-1,e)}async next(e){return await w(this,T,sy).call(this,1,e)}async pan(e,s){r(this,sn)||(b(this,sn,!0),this.scrollBy(e,s),b(this,sn,!1))}prevSection(){return this.goTo({index:w(this,T,Le).call(this,-1)})}nextSection(){return this.goTo({index:w(this,T,Le).call(this,1)})}firstSection(){let e=this.sections.findIndex(s=>s.linear!=="no");return this.goTo({index:e})}lastSection(){let e=this.sections.findLastIndex(s=>s.linear!=="no");return this.goTo({index:e})}getContents(){let e=[];for(let[s,i]of r(this,T,Bt))i.document&&e.push({index:s,overlayer:i.overlayer,doc:i.document});return e}setStyles(e){b(this,Qc,e);for(let[,i]of r(this,st)){let n=r(this,th).get(i.document);if(!n)continue;let[a,o]=n;if(Array.isArray(e)){let[c,h]=e;a.textContent=c,o.textContent=h}else o.textContent=e;i.document?.fonts?.ready?.then(()=>i.expand())}r(this,T,Vt)&&requestAnimationFrame(()=>w(this,T,Ei).call(this))}focusView(){r(this,T,Vt)?.document?.defaultView?.focus()}showLoupe(e,s,{isVertical:i,color:n,gap:a,margin:o,radius:c,magnification:h}){r(this,T,Vt)?.showLoupe(e,s,{isVertical:i,color:n,gap:a,margin:o,radius:c,magnification:h})}hideLoupe(){r(this,T,Vt)?.hideLoupe()}destroyLoupe(){r(this,T,Vt)?.destroyLoupe()}destroy(){r(this,cf).unobserve(this),w(this,T,Ww).call(this),r(this,uf).removeEventListener("change",r(this,eh))}};ir=new WeakMap,cf=new WeakMap,Ve=new WeakMap,Ts=new WeakMap,St=new WeakMap,ma=new WeakMap,ol=new WeakMap,st=new WeakMap,dt=new WeakMap,At=new WeakMap,nr=new WeakMap,hf=new WeakMap,df=new WeakMap,ks=new WeakMap,Zc=new WeakMap,sn=new WeakMap,rr=new WeakMap,Qc=new WeakMap,th=new WeakMap,uf=new WeakMap,eh=new WeakMap,ar=new WeakMap,sh=new WeakMap,ll=new WeakMap,ff=new WeakMap,pf=new WeakMap,nn=new WeakMap,We=new WeakMap,rn=new WeakMap,Ls=new WeakMap,gf=new WeakMap,ih=new WeakMap,or=new WeakMap,T=new WeakSet,Vt=function(){return r(this,st).get(r(this,dt))},Bt=function(){return[...r(this,st).entries()].sort(([e],[s])=>e-s)},w_=function(){r(this,st).size>1&&w(this,T,Qw).call(this);let e=w(this,T,Zw).call(this);e?.range&&!e.range.collapsed&&b(this,ks,e.range)},Vw=function(e){let s=r(this,st).get(e);s&&(s.destroy(),r(this,St).removeChild(s.element),r(this,st).delete(e));let i=new jw({container:this,onExpand:()=>{r(this,We)||r(this,Ls)||this.scrolled||r(this,dt)===e&&w(this,T,il).call(this,r(this,ks))}});r(this,st).set(e,i);let n=r(this,T,Bt),a=n.findIndex(([c])=>c===e),o=n[a+1];return o?r(this,St).insertBefore(i.element,o[1].element):r(this,St).append(i.element),w(this,T,Gc).call(this),i},Gc=function(){let e=r(this,St).getBoundingClientRect();for(let[s,i]of r(this,st))s===r(this,dt)||g_(i.element.getBoundingClientRect(),e)?i.element.removeAttribute("aria-hidden"):i.element.setAttribute("aria-hidden","true")},ua=function(e){let s=r(this,st).get(e);s&&(s.destroy(),r(this,St).removeChild(s.element),r(this,st).delete(e),this.sections[e]?.unload?.())},Ww=function(){for(let[e]of r(this,st))w(this,T,ua).call(this,e)},y_=function(e){for(let[s]of r(this,st))e.has(s)||w(this,T,ua).call(this,s)},fa=function(e){return r(this,or).has(e)?r(this,or).get(e)===r(this,At):!0},Ei=function(e){let s=r(this,T,Vt)?.document;if(!s?.documentElement||this.noBackground)return;let i=s.defaultView.getComputedStyle(s.documentElement),n=i.getPropertyValue("--theme-bg-color"),a=i.getPropertyValue("--override-color")==="true",o=i.getPropertyValue("--bg-texture-id"),c=i.getPropertyValue("color-scheme")==="dark",h=i.backgroundColor,d=n||h||"",u=!!o&&o!=="none";r(this,Ve).style.setProperty("--_scrollbar-track-bg",u?"transparent":d||"transparent");let f=M=>{if(!M)return d;if(n){let L=M.split(/\s(?=(?:url|rgb|hsl|#[0-9a-fA-F]{3,6}))/);return(c||a)&&(o==="none"||!o)&&(L[0]=n),L.join(" ")}return M};r(this,Ts).style.background="";for(let[,M]of r(this,T,Bt))M.element.style.background="";if(this.scrolled){r(this,Ts).innerHTML="",r(this,Ts).style.display="",r(this,Ts).style.background=u?"":d;for(let[,M]of r(this,T,Bt)){let L=f(M.docBackground);M.element.style.background=Uw(L,u)}return}let p=r(this,Ts).getBoundingClientRect(),m=r(this,St).getBoundingClientRect(),g=r(this,At)?"top":"left",v=p[this.sideProp],y=m[g]-p[g],_=Math.abs(e??r(this,T,ee)),x=r(this,T,Bt).map(([,M])=>({size:M.element.getBoundingClientRect()[this.sideProp],bg:Uw(f(M.docBackground),u)})),S=m_(x,_,v,y,this.size);r(this,Ts).innerHTML="",r(this,Ts).style.display="",r(this,Ts).style.background=u?"":d;let E=r(this,At)?"top":"left",C=r(this,At)?"height":"width",R=r(this,At)?"left":"top",k=r(this,At)?"width":"height";for(let{start:M,size:L,bg:P}of S){let I=document.createElement("div");I.style.position="absolute",I.style[E]=`${M}px`,I.style[C]=`${L}px`,I.style[R]="0",I.style[k]="100%",I.style.background=P,I.style.backgroundAttachment="initial",r(this,Ts).appendChild(I)}},Gw=function({vertical:e,rtl:s}){if(r(this,gf)&&e!==r(this,At))for(let[L]of r(this,st))L!==r(this,dt)&&w(this,T,ua).call(this,L);b(this,At,e),b(this,nr,s),r(this,Ve).classList.toggle("vertical",e),r(this,St).classList.toggle("vertical",e);let i=getComputedStyle(r(this,Ve)),n=parseFloat(i.getPropertyValue("--_max-inline-size")),a=parseInt(i.getPropertyValue("--_max-column-count-spread")),o=parseFloat(i.getPropertyValue("--_margin-top")),c=parseFloat(i.getPropertyValue("--_margin-right")),h=parseFloat(i.getPropertyValue("--_margin-bottom")),d=parseFloat(i.getPropertyValue("--_margin-left"));b(this,hf,o),b(this,df,h);let u=this.getAttribute("flow"),f=this.getBoundingClientRect(),p=e?f.height:f.width,m=u==="scrolled"?1:Math.min(a+(e?1:0),Math.ceil(Math.floor(p)/Math.floor(n)));r(this,Ve).style.setProperty("--_column-count",m);let{width:g,height:v}=r(this,St).getBoundingClientRect(),y=e?v:g,_=parseFloat(i.getPropertyValue("--_gap"))/100,x=-_/(_-1)*y;if(u==="scrolled"){this.setAttribute("dir",e?"rtl":"ltr"),r(this,Ve).style.padding="0";let L=n;this.heads=null,this.feet=null,r(this,ma).replaceChildren(),r(this,ol).replaceChildren(),this.columnCount=1,w(this,T,Ei).call(this);let P={width:g,height:v,flow:u,marginTop:o,marginRight:c,marginBottom:h,marginLeft:d,gap:x,columnWidth:L,columnCount:1};return b(this,ih,P),P}let S=e?y/m-o*1.5-h*1.5:y/m-x-c/2-d/2;this.setAttribute("dir",s?"rtl":"ltr"),this.columnCount=m,w(this,T,Ei).call(this);let E=e?Math.min(2,Math.ceil(Math.floor(g)/Math.floor(n))):m,C={gridTemplateColumns:`repeat(${E}, 1fr)`,gap:`${x}px`,direction:this.bookDir==="rtl"?"rtl":"ltr"};Object.assign(r(this,ma).style,C),Object.assign(r(this,ol).style,C);let R=f_(E,"head"),k=f_(E,"foot");this.heads=R.map(L=>L.children[0]),this.feet=k.map(L=>L.children[0]),r(this,ma).replaceChildren(...R),r(this,ol).replaceChildren(...k);let M={width:g,height:v,marginTop:o,marginRight:c,marginBottom:h,marginLeft:d,gap:x,columnWidth:S,columnCount:m};return b(this,ih,M),M},Ys=function(){if(r(this,st).size===0)return 0;let e=0;for(let[,s]of r(this,st))e+=s.element.getBoundingClientRect()[this.sideProp];return e},ee=function(){return Math.abs(r(this,St)[this.scrollProp])},je=function(){return r(this,T,ee)+this.size},Xc=function(){return Math.floor((r(this,T,ee)+r(this,T,je))/2/this.size)},qc=function(){return Math.round(r(this,T,Ys)/this.size)},Xw=function(e){if(r(this,rr))return;let s=this.getContents?.()??[];for(let{doc:a}of s){let o=a?.getSelection?.();if(o&&!o.isCollapsed&&o.toString().trim())return}let i=e.changedTouches[0];b(this,sh,{x:i?.screenX,y:i?.screenY,t:e.timeStamp,vx:0,xy:0,dx:0,dy:0,dt:0,startX:i?.screenX,startY:i?.screenY,didPreventDefault:!1});let n=r(this,T,Vt);n?.element&&(n.element.style.willChange="transform")},qw=function(e){let s=r(this,sh);if(r(this,rr)||!s)return;let i=this.getContents?.()??[];for(let{doc:m}of i){let g=m?.getSelection?.();if(g&&!g.isCollapsed&&g.toString().trim())return}if(s.pinched||(s.pinched=globalThis.visualViewport.scale>1,this.scrolled||s.pinched)||this.hasAttribute("no-swipe"))return;if(e.touches.length>1){r(this,ll)&&e.preventDefault();return}let n=e.changedTouches[0],a=n.touchType==="stylus",o=Math.abs(n.screenX-(s.startX??n.screenX)),c=Math.abs(n.screenY-(s.startY??n.screenY));if(!s.axisLocked&&(o>10||c>10)&&(c>o*1.3?(s.axisLocked="y",s.aborted=!0):s.axisLocked="x"),s.aborted||(!a&&(o>10||c>10||s.didPreventDefault)&&(e.preventDefault(),s.didPreventDefault=!0),r(this,pf)))return;let h=n.screenX,d=n.screenY,u=s.x-h,f=s.y-d,p=e.timeStamp-s.t;s.x=h,s.y=d,s.t=e.timeStamp,s.vx=u/p,s.vy=f/p,s.dx+=u,s.dy+=f,s.dt+=p,b(this,ll,!0),!(!this.hasAttribute("animated")||this.hasAttribute("eink"))&&(!r(this,At)&&Math.abs(s.dx)>=Math.abs(s.dy)&&!this.hasAttribute("eink")&&(!a||Math.abs(u)>1)?this.scrollBy(u,0):r(this,At)&&Math.abs(s.dx)1)&&this.scrollBy(0,f))},Yw=function(){r(this,ll)&&(b(this,ll,!1),!(this.scrolled||r(this,rr))&&(this.hasAttribute("no-swipe")||requestAnimationFrame(()=>{if(globalThis.visualViewport.scale===1){let{vx:e,vy:s,dx:i,dy:n,dt:a}=r(this,sh);this.snap(e,s,i,n,a)}})))},sl=function(e){if(this.scrolled){let i=e?e.element.getBoundingClientRect()[this.sideProp]:r(this,T,Ys),n=r(this,hf),a=r(this,df);return r(this,At)?({left:o,right:c})=>({left:i-c-n,right:i-o-a}):({top:o,bottom:c})=>({left:o-n,right:c-a})}let s=r(this,T,qc)*this.size;return r(this,nr)?({left:i,right:n})=>({left:s-n,right:s-i}):r(this,At)?({top:i,bottom:n})=>({left:i,right:n}):i=>i},Kw=function(e,s,i,n,a=null){try{if(i&&s.compareBoundaryPoints(Range.START_TO_START,e)<0)return{direction:-1,edge:!1};if(!i&&s.compareBoundaryPoints(Range.END_TO_END,e)>0)return{direction:1,edge:!1}}catch{}if(!n)return{direction:0,edge:!1,reason:"edge-turn-disabled"};let o=s.commonAncestorContainer?.ownerDocument??s.startContainer?.ownerDocument??s.endContainer?.ownerDocument,c=[...r(this,st)].find(([,x])=>x.document===o);if(!c)return{direction:0,edge:!1,reason:"view-not-found"};let[h,d]=c,u=wC(s,i);if(!u)return{direction:0,edge:!1,reason:"range-edge-not-found"};let f=w(this,T,Ks).call(this,h),p=r(this,T,ee)-f,m=r(this,T,je)-f,g=w(this,T,sl).call(this,d)(u),v=Math.max(48,Math.min(120,this.size*.18)),y=Math.max(v,Math.min(140,this.size*.3)),_={edgeInset:Math.round(v),pointerEdgeInset:Math.round(y),mappedLeft:Math.round(g.left),mappedRight:Math.round(g.right),visibleStart:Math.round(p),visibleEnd:Math.round(m)};if(i&&g.left<=p+v)return{..._,direction:-1,edge:!0,edgeSource:"range"};if(!i&&g.right>=m-v)return{..._,direction:1,edge:!0,edgeSource:"range"};if(a?.doc===o&&typeof a.x=="number"&&typeof a.y=="number"){let x=w(this,T,sl).call(this,d)({left:a.x,right:a.x,top:a.y,bottom:a.y}),S={..._,pointerMapped:Math.round(x.left)};return i&&x.left<=p+y?{...S,direction:-1,edge:!0,edgeSource:"pointer"}:!i&&x.right>=m-y?{...S,direction:1,edge:!0,edgeSource:"pointer"}:{...S,direction:0,edge:!1,reason:"not-at-edge"}}return{..._,direction:0,edge:!1,reason:"not-at-edge"}},v_=async function(e,s){if(this.scrolled){let o=w(this,T,sl).call(this)(e).left-3,c=w(this,T,Ks).call(this,r(this,dt));return w(this,T,Yc).call(this,c+o,s)}let i=w(this,T,sl).call(this)(e).left,a=w(this,T,Ks).call(this,r(this,dt))+i;return w(this,T,Kc).call(this,Math.floor(a/this.size+.01),s)},Yc=async function(e,s,i){let{size:n}=this;if(this.containerPosition===e){b(this,ar,[e,this.atStart?0:n,this.atEnd?0:n]),w(this,T,nl).call(this,s);return}if(this.scrolled&&r(this,At)&&(e=-e),(s==="snap"||i)&&this.hasAttribute("animated")&&!this.hasAttribute("eink")){let a=this.containerPosition;if(b(this,nn,!0),r(this,T,Ys)>2e4)return uC(a,e,300,dC,o=>{r(this,St)[this.scrollProp]=o,this.scrolled||w(this,T,Ei).call(this)}).then(()=>{b(this,nn,!1),b(this,ar,[e,this.atStart?0:n,this.atEnd?0:n]),w(this,T,nl).call(this,s)});if(!this.scrolled){w(this,T,Ei).call(this,a);let o=r(this,St).children[0],c=()=>{if(!r(this,nn))return;let h=o&&getComputedStyle(o).transform,d=h&&h!=="none"?new DOMMatrix(h)[r(this,At)?"m42":"m41"]:0;w(this,T,Ei).call(this,a-d),requestAnimationFrame(c)};requestAnimationFrame(c)}return hC(r(this,St),this.scrollProp,a,e,300).then(()=>{b(this,nn,!1),b(this,ar,[e,this.atStart?0:n,this.atEnd?0:n]),w(this,T,nl).call(this,s)})}else this.containerPosition=e,b(this,ar,[e,this.atStart?0:n,this.atEnd?0:n]),w(this,T,nl).call(this,s)},Kc=async function(e,s,i){let n=this.size*(r(this,nr)?-e:e);return w(this,T,Yc).call(this,n,s,i)},il=async function(e,s="anchor",i=!1){b(this,ks,e);let n=fC(e)?.getClientRects?.();if(n){let u=Array.from(n).find(f=>f.width>0&&f.height>0&&f.x>=0&&f.y>=0)||n[0];if(!u)return;if(await w(this,T,v_).call(this,u,s),s==="navigation"){let f=e.focus?e:void 0;!f&&e.startContainer&&(f=e.startContainer,f.nodeType===Node.TEXT_NODE&&(f=f.parentElement)),f&&f.focus&&(f.tabIndex=-1,f.style.outline="none",f.focus({preventScroll:!0}))}return}if(this.scrolled){let u=w(this,T,Ks).call(this,r(this,dt)),f=r(this,T,Vt),p=f?f.element.getBoundingClientRect()[this.sideProp]:r(this,T,Ys);await w(this,T,Yc).call(this,u+e*p,s,i);return}let a=r(this,T,Vt);if(!a)return;let o=w(this,T,Jw).call(this,r(this,dt)),c=a.contentPages;if(!c)return;let h=Math.round(e*(c-1)),d=Math.floor(h/this.columnCount);await w(this,T,Kc).call(this,o+d,s,i)},Ks=function(e){let s=0;for(let[i,n]of r(this,T,Bt)){if(i===e)return s;s+=n.element.getBoundingClientRect()[this.sideProp]}return s},Jw=function(e){return Math.floor(w(this,T,Ks).call(this,e)/this.size+.01)},Zw=function(){let e=r(this,T,Vt);if(!e?.document)return;let s=w(this,T,Ks).call(this,r(this,dt));if(this.scrolled){for(let[n,a]of r(this,T,Bt)){if(!a.document)continue;let o=w(this,T,Ks).call(this,n),c=a.element.getBoundingClientRect()[this.sideProp];if(o+c<=r(this,T,ee)||o>=r(this,T,je))continue;let h=u_(a.document,r(this,T,ee)-o,r(this,T,je)-o,w(this,T,sl).call(this,a));if(h&&!h.collapsed)return{range:h,index:n}}return}let i=u_(e.document,r(this,T,ee)-s,r(this,T,je)-s,w(this,T,sl).call(this,e));return i?{range:i,index:r(this,dt)}:void 0},Qw=function(){if(r(this,st).size<=1)return;let e=r(this,T,ee),s=0;for(let[i,n]of r(this,T,Bt)){let a=n.element.getBoundingClientRect()[this.sideProp];if(e0?Math.floor((r(this,T,Ys)-r(this,T,je))/e):0)>=s));){let o=r(this,T,Bt),c=o[o.length-1]?.[0];if(c==null)break;let h=w(this,T,Le).call(this,1,c);if(h==null||!w(this,T,fa).call(this,h)||(await w(this,T,pa).call(this,h),!r(this,st).has(h)))break}await new Promise(a=>requestAnimationFrame(a))}finally{b(this,We,!1),this.dispatchEvent(new Event("stabilized"))}}},nl=function(e){r(this,st).size>1&&e!=="anchor"&&e!=="navigation"&&(w(this,T,Qw).call(this),w(this,T,Gc).call(this));let{range:s,index:i}=w(this,T,Zw).call(this)||{};if(!s)return;b(this,ff,s),e!=="selection"&&e!=="navigation"&&e!=="anchor"?b(this,ks,s):b(this,Zc,!0);let n=i??r(this,dt),a=r(this,T,Vt),o={reason:e,range:s,index:n};if(this.scrolled){let c=w(this,T,Ks).call(this,n),h=a?a.element.getBoundingClientRect()[this.sideProp]:r(this,T,Ys);o.fraction=h>0?Math.max(0,Math.min(1,(r(this,T,ee)-c)/h)):0}else if(r(this,T,qc)>0&&a){let c=r(this,T,Xc),h=w(this,T,Jw).call(this,n),d=a.contentPages;r(this,ma).style.visibility=c>0?"visible":"hidden";let u=c-h,f=u*this.columnCount;if(o.fraction=d>0?Math.max(0,Math.min(1,f/d)):0,o.size=d>0?this.columnCount/d:1,e==="container-scroll"&&u===0)return}this.scrolled||w(this,T,Ei).call(this),this.dispatchEvent(new CustomEvent("relocate",{detail:o}))},__=async function(e){b(this,Ls,!0),r(this,St).style.opacity="0";let{index:s,src:i,data:n,anchor:a,onLoad:o,select:c}=await e;b(this,dt,s),w(this,T,Gc).call(this);let h=r(this,T,Vt)?.document?.hasFocus();if(i){let f=w(this,T,Vw).call(this,s),p=g=>{if(g.head){let v=g.createElement("style");g.head.prepend(v);let y=g.createElement("style");g.head.append(y),this.sections[s].spineProperties?.forEach(_=>g.documentElement.setAttribute("data-"+_,"")),r(this,th).set(g,[v,y])}o?.({doc:g,index:s,primary:!0})},m=w(this,T,Gw).bind(this);if(await f.load(i,n,p,m),f.document){let g=tf(f.document);r(this,or).set(s,g.vertical)}this.dispatchEvent(new CustomEvent("create-overlayer",{detail:{doc:f.document,index:s,attach:g=>f.overlayer=g}}))}let d=r(this,T,Vt);if(!this.noPreload&&!this.noContinuousScroll&&d&&(d.contentPages>0&&d.contentPages{b(this,Ls,!1)})},pa=async function(e){if(r(this,st).has(e)||!w(this,T,Vg).call(this,e))return;let s=this.sections[e];if(!s||s.linear==="no")return;let i=r(this,T,Bt)[0]?.[0],n=this.scrolled&&i!=null&&e{if(p.head){let m=p.createElement("style");p.head.prepend(m);let g=p.createElement("style");p.head.append(g),s.spineProperties?.forEach(v=>p.documentElement.setAttribute("data-"+v,"")),r(this,th).set(p,[m,g])}this.setStyles(r(this,Qc)),this.dispatchEvent(new CustomEvent("load",{detail:{doc:p,index:e,primary:!1}}))},u=r(this,ih),f=()=>u;if(await h.load(o,c,d,f),h.document){let p=tf(h.document);if(r(this,or).set(e,p.vertical),p.vertical!==r(this,At)){w(this,T,ua).call(this,e);return}}if(n){let p=h.element.getBoundingClientRect()[this.sideProp],m=a+p-r(this,T,ee);Math.abs(m)>.5&&(this.containerPosition+=(r(this,At)?-1:1)*m)}this.dispatchEvent(new CustomEvent("create-overlayer",{detail:{doc:h.document,index:e,attach:p=>h.overlayer=p}}))}catch(o){console.warn(o),console.warn(new Error(`Failed to load adjacent section ${e}`))}},ty=async function({reanchor:e=!0}={}){if(!(this.noPreload||this.noContinuousScroll||r(this,We))){b(this,We,!0);try{let{size:s}=this;if(!s)return;let i=5,n=8,a=r(this,T,Vt);if(a&&a.contentPages>0&&a.contentPages=r(this,dt)){let d=w(this,T,Le).call(this,-1,h);d!=null&&w(this,T,fa).call(this,d)&&await w(this,T,pa).call(this,d)}}let o=0;for(;r(this,st).size=i));){let h=r(this,T,Bt),d=h[h.length-1]?.[0];if(d==null)break;let u=w(this,T,Le).call(this,1,d);if(u==null||!w(this,T,fa).call(this,u)||(await w(this,T,pa).call(this,u),!r(this,st).has(u)))break}e&&w(this,T,il).call(this,r(this,ks))}finally{b(this,We,!1),this.dispatchEvent(new Event("stabilized"))}}},ey=function(){let{size:e}=this;if(!e)return;let s=e*10,i=r(this,T,je);for(let[n,a]of r(this,T,Bt)){if(n<=r(this,dt))continue;w(this,T,Ks).call(this,n)-i>s&&w(this,T,ua).call(this,n)}},Vg=function(e){return e>=0&&e<=this.sections.length-1},Wg=async function({index:e,anchor:s,select:i}){let n=!1;if(r(this,st).has(e)){let a=r(this,st).get(e);if(a?.document){let{vertical:o}=tf(a.document);n=o!==r(this,At)}}else r(this,or).has(e)&&(n=r(this,or).get(e)!==r(this,At));if(r(this,st).has(e)&&!n){b(this,Ls,!0);let a=!this.scrolled||this.noContinuousScroll;a&&(r(this,St).style.opacity="0");let o=r(this,T,Vt)?.document?.hasFocus();if(b(this,dt,e),w(this,T,Gc).call(this),w(this,T,ey).call(this),this.noContinuousScroll)for(let[f]of r(this,st))f!==e&&w(this,T,ua).call(this,f);let c=r(this,T,Vt),h=(typeof s=="function"?s(c.document):s)??0,d=c&&c.contentPages>0&&c.contentPages{if(this.noPreload||this.noContinuousScroll||!(d||this.scrolled))return;let f=r(this,T,Bt)[0]?.[0];if(f==null)return;let p=w(this,T,Le).call(this,-1,f);p!=null&&w(this,T,fa).call(this,p)&&await w(this,T,pa).call(this,p)};this.scrolled||await u(),await this.scrollToAnchor(h,i),this.scrolled&&await u(),a&&(r(this,St).style.opacity="1"),o&&this.focusView(),b(this,rn,w(this,T,ty).call(this)),r(this,rn).then(()=>{b(this,Ls,!1)})}else{if(n)w(this,T,Ww).call(this);else{let c=new Set([e]);if(!this.noContinuousScroll)for(let[h]of r(this,st))Math.abs(h-e)<=2&&c.add(h);w(this,T,y_).call(this,c)}let a=r(this,dt),o=c=>{a>=0&&!r(this,st).has(a)&&this.sections[a]?.unload?.(),this.setStyles(r(this,Qc)),this.dispatchEvent(new CustomEvent("load",{detail:c}))};await w(this,T,__).call(this,Promise.resolve(this.sections[e].load()).then(async c=>{let h=await this.sections[e].loadContent?.();return{index:e,src:c,data:h,anchor:s,onLoad:o,select:i}}).catch(c=>(console.warn(c),console.warn(new Error(`Failed to load section ${e}`)),{})))}},x_=function(e){if(r(this,st).size===0)return!0;if(this.scrolled)return r(this,T,ee)>0?w(this,T,Yc).call(this,Math.max(0,r(this,T,ee)-(e??this.size)),null,!0):!this.atStart;if(this.atStart)return;let s=r(this,T,Xc)-1;return s<0?!0:w(this,T,Kc).call(this,s,"page",!0)},S_=function(e){if(r(this,st).size===0)return!0;if(this.scrolled)return r(this,T,Ys)-r(this,T,je)>2?w(this,T,Yc).call(this,Math.min(r(this,T,Ys),e?r(this,T,ee)+e:r(this,T,je)),null,!0):!this.atEnd;if(this.atEnd)return;let s=r(this,T,Xc)+1,i=r(this,T,qc);return s>=i?!0:w(this,T,Kc).call(this,s,"page",!0)},Le=function(e,s){s===void 0&&(s=r(this,dt));for(let i=s+e;w(this,T,Vg).call(this,i);i+=e)if(this.sections[i]?.linear!=="no")return i},sy=async function(e,s){if(r(this,sn))return;b(this,sn,!0);let i=e===-1,n=await(i?w(this,T,x_).call(this,s):w(this,T,S_).call(this,s));if(n){r(this,rn)&&await r(this,rn);let a=r(this,T,Bt),o=i?a[0]?.[0]??r(this,dt):a[a.length-1]?.[0]??r(this,dt);await w(this,T,Wg).call(this,{index:w(this,T,Le).call(this,e,o),anchor:i?()=>1:()=>0})}(n||!this.hasAttribute("animated"))&&await oC(100),b(this,sn,!1)},H(ef,"observedAttributes",["flow","gap","margin","margin-top","margin-bottom","margin-left","margin-right","max-inline-size","max-block-size","max-column-count","no-preload","no-background","no-continuous-scroll"]);customElements.get("foliate-paginator")||customElements.define("foliate-paginator",ef)});var R_={};Ms(R_,{search:()=>L_,searchMatcher:()=>_C});var T_,k_,vC,AC,L_,_C,M_=os(()=>{"use strict";T_=l=>l.replace(/\s+/g," "),k_=(l,{startIndex:t,startOffset:e,endIndex:s,endOffset:i})=>{let n=l[t],a=l[s],o=n===a?n.slice(e,i):n.slice(e)+l.slice(n+1,a).join("")+a.slice(0,i),c=T_(n.slice(0,e)).trimStart(),h=T_(a.slice(i)).trimEnd(),d=c.length<50?"":"\u2026",u=h.length<50?"":"\u2026",f=`${d}${c.slice(-50)}`,p=`${h.slice(0,50)}${u}`;return{pre:f,match:o,post:p}},vC=function*(l,t,e={}){let{locales:s="en",sensitivity:i}=e,n=i==="variant",a=l.join(""),o=n?a:a.toLocaleLowerCase(s),c=n?t:t.toLocaleLowerCase(s),h=c.length,d=-1,u=-1,f=0;do if(d=o.indexOf(c,d+1),d>-1){for(;f<=d;)f+=l[++u].length;let p=u,m=d-(f-l[u].length),g=d+h;for(;f<=g;)f+=l[++u].length;let v=u,y=g-(f-l[u].length),_={startIndex:p,startOffset:m,endIndex:v,endOffset:y};yield{range:_,excerpt:k_(l,_)}}while(d>-1)},AC=function*(l,t,e={}){let{locales:s="en",granularity:i="word",sensitivity:n="base"}=e,a,o;try{a=new Intl.Segmenter(s,{usage:"search",granularity:i}),o=new Intl.Collator(s,{sensitivity:n})}catch(f){console.warn(f),a=new Intl.Segmenter("en",{usage:"search",granularity:i}),o=new Intl.Collator("en",{sensitivity:n})}let c=Array.from(a.segment(t)).length,h=[],d=0,u=a.segment(l[d])[Symbol.iterator]();t:for(;dp.segment).join("");if(o.compare(t,f)===0){let p=d,m=h[h.length-1],g=m.index+m.segment.length,v=h[0].strIndex,y=h[0].index,_={startIndex:v,startOffset:y,endIndex:p,endOffset:g};yield{range:_,excerpt:k_(l,_)}}h.shift()}},L_=(l,t,e)=>{let{granularity:s="grapheme",sensitivity:i="base"}=e;return!Intl?.Segmenter||s==="grapheme"&&(i==="variant"||i==="accent")?vC(l,t,e):AC(l,t,e)},_C=(l,t)=>{let{defaultLocale:e,matchCase:s,matchDiacritics:i,matchWholeWords:n,acceptNode:a}=t;return function*(o,c){let h=l(o,function*(d,u){for(let f of L_(d,c,{locales:o.body.lang||o.documentElement.lang||e||"en",granularity:n?"word":"grapheme",sensitivity:i&&s?"variant":i&&!s?"accent":!i&&s?"case":"base"})){let{startIndex:p,startOffset:m,endIndex:g,endOffset:v}=f.range;f.range=u(p,m,g,v),yield f}},a);for(let d of h)yield d}}});var B_={};Ms(B_,{TTS:()=>ny});function*TC(l,t,e){for(let s of O_(l)){let{entries:i}=D_(s,t,"sentence",e);for(let[,n]of i)iy(n)||(yield n)}}function*O_(l){let t,e=l.createTreeWalker(l.body,NodeFilter.SHOW_ELEMENT);for(let s=e.nextNode();s;s=e.nextNode()){let i=s.tagName.toLowerCase();xC.has(i)&&(t&&(t.setEndBefore(s),iy(t)||(yield t)),t=l.createRange(),t.setStart(s,0))}t||(t=l.createRange(),t.setStart(l.body.firstChild??l.body,0)),t.setEndAfter(l.body.lastChild??l.body),iy(t)||(yield t)}var rs,xC,P_,I_,SC,EC,D_,N_,iy,CC,ut,lr,Re,Ge,Kg,F_,Yg,Ci,Zs,rh,Ti,wa,Qs,bf,gt,qg,nh,ry,mf,ba,ay,ny,z_=os(()=>{"use strict";rs={XML:"http://www.w3.org/XML/1998/namespace",SSML:"http://www.w3.org/2001/10/synthesis"},xC=new Set(["article","aside","audio","blockquote","caption","details","dialog","div","dl","dt","dd","figure","footer","form","figcaption","h1","h2","h3","h4","h5","h6","header","hgroup","hr","li","main","math","nav","ol","p","pre","section","tr"]),P_=l=>{let t=l.lang||l?.getAttributeNS?.(rs.XML,"lang");return t||(l.parentElement?P_(l.parentElement):null)},I_=l=>{let t=l?.getAttributeNS?.(rs.XML,"lang");return t||(l.parentElement?I_(l.parentElement):null)},SC=(l="en",t="word")=>{let e=new Intl.Segmenter(l,{granularity:t}),s=t==="word";return function*(i,n){let a=i.join(""),o=0,c=-1,h=0;for(let{index:d,segment:u,isWordLike:f}of e.segment(a)){if(s&&!f)continue;for(;h<=d;)h+=i[++c].length;let p=c,m=d-(h-i[c].length),g=d+u.length-1;if(g{let e=document.implementation.createDocument(rs.SSML,"speak"),{lang:s}=t;s&&e.documentElement.setAttributeNS(rs.XML,"lang",s);let i=(n,a,o)=>{if(!n)return;if(n.nodeType===3)return e.createTextNode(n.textContent);if(n.nodeType===4)return e.createCDATASection(n.textContent);if(n.nodeType!==1)return;let c,h=n.nodeName.toLowerCase();if(h==="rt"||h==="rp")return;h==="foliate-mark"?(c=e.createElementNS(rs.SSML,"mark"),c.setAttribute("name",n.dataset.name)):h==="br"?c=e.createElementNS(rs.SSML,"break"):(h==="em"||h==="strong")&&(c=e.createElementNS(rs.SSML,"emphasis"));let d=n.lang||n.getAttributeNS(rs.XML,"lang");d&&(c||(c=e.createElementNS(rs.SSML,"lang")),c.setAttributeNS(rs.XML,"lang",d));let u=n.getAttributeNS(rs.SSML,"alphabet")||o;if(!c){let p=n.getAttributeNS(rs.SSML,"ph");p&&(c=e.createElementNS(rs.SSML,"phoneme"),u&&c.setAttribute("alphabet",u),c.setAttribute("ph",p))}c||(c=a);let f=n.firstChild;for(;f;){let p=i(f,c,u);p&&c!==p&&c.append(p),f=f.nextSibling}return c};return i(l.firstChild,e.documentElement,t.alphabet),e},D_=(l,t,e,s)=>{let i=P_(l.commonAncestorContainer),n=I_(l.commonAncestorContainer),a=SC(i,e),o=l.cloneContents(),c=[...t(l,a,s)],h=[...t(o,a,s)];for(let[u,f]of h){let p=document.createElement("foliate-mark");p.dataset.name=u,f.insertNode(p)}let d=EC(o,{lang:i,alphabet:n});return{entries:c,ssml:d}},N_=l=>{let t=l.cloneContents(),s=(t.ownerDocument||document).createTreeWalker(t,NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT|NodeFilter.SHOW_CDATA_SECTION,{acceptNode:n=>{if(n.nodeType===1){let a=n.nodeName.toLowerCase();return a==="rt"||a==="rp"||a==="script"||a==="style"?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_SKIP}return NodeFilter.FILTER_ACCEPT}}),i="";for(let n=s.nextNode();n;n=s.nextNode())i+=n.nodeValue||"";return i},iy=l=>!N_(l).trim(),CC=l=>N_(l).replace(/\s+/g," ").trim();Yg=class{constructor(t,e=s=>s){A(this,Kg);A(this,ut,[]);A(this,lr);A(this,Re,-1);A(this,Ge);b(this,lr,t),b(this,Ge,e)}current(){if(r(this,ut)[r(this,Re)])return r(this,Ge).call(this,r(this,ut)[r(this,Re)])}first(){return r(this,ut)[0]?(b(this,Re,0),r(this,Ge).call(this,r(this,ut)[0])):this.next()}last(){for(let e of r(this,lr))r(this,ut).push(e);let t=r(this,ut).length-1;if(r(this,ut)[t])return b(this,Re,t),r(this,Ge).call(this,r(this,ut)[t])}prev(){let t=r(this,Re)-1;if(r(this,ut)[t])return b(this,Re,t),r(this,Ge).call(this,r(this,ut)[t])}next(){let t=r(this,Re)+1;if(r(this,ut)[t])return b(this,Re,t),r(this,Ge).call(this,r(this,ut)[t]);for(;;){let{done:e,value:s}=r(this,lr).next();if(e)break;if(r(this,ut).push(s),r(this,ut)[t])return b(this,Re,t),r(this,Ge).call(this,r(this,ut)[t])}}prepare(){let t=r(this,Re)+1;if(r(this,ut)[t])return r(this,Ge).call(this,r(this,ut)[t]);for(;;){let{done:e,value:s}=r(this,lr).next();if(e)break;if(r(this,ut).push(s),r(this,ut)[t])return r(this,Ge).call(this,r(this,ut)[t])}}peek(t=1,e=1){if(t<=0)return[];let s=Math.max(r(this,Re)+e,0),i=[],n=s+t;for(let a=s;at(s));if(e>-1)return b(this,Re,e),r(this,Ge).call(this,r(this,ut)[e]);for(;;){let{done:s,value:i}=r(this,lr).next();if(s)break;if(r(this,ut).push(i),t(i))return b(this,Re,r(this,ut).length-1),r(this,Ge).call(this,i)}}};ut=new WeakMap,lr=new WeakMap,Re=new WeakMap,Ge=new WeakMap,Kg=new WeakSet,F_=function(t){for(;r(this,ut)[t]==null;){let{done:e,value:s}=r(this,lr).next();if(e||(r(this,ut).push(s),r(this,ut).length-1>=t))break}return r(this,ut)[t]};ny=class{constructor(t,e,s,i,n,a){A(this,gt);A(this,Ci);A(this,Zs);A(this,rh);A(this,Ti);A(this,wa);A(this,Qs);A(this,bf,new XMLSerializer);this.doc=t;let o=null,c=null,h="word";typeof s=="function"?(c=s,typeof i=="function"?(b(this,Qs,i),h=typeof n=="string"?n:"word"):h=typeof i=="string"?i:"word"):(o=s??null,c=typeof i=="function"?i:null,typeof n=="function"&&b(this,Qs,n),h=typeof a=="string"?a:"word"),this.highlight=c||(()=>null),b(this,Ci,new Yg(O_(t),d=>{let{entries:u,ssml:f}=D_(d,e,h,o);return b(this,rh,new Map(u)),[f,d]})),b(this,Zs,new Yg(TC(t,e,o),d=>[CC(d),d]))}start(){b(this,Ti,null);let[t,e]=r(this,Ci).first()??[];return w(this,gt,nh).call(this,e),t?w(this,gt,ba).call(this,t,s=>w(this,gt,qg).call(this,s,r(this,Ti))):this.next()}resume(){let[t]=r(this,Ci).current()??[];return t?w(this,gt,ba).call(this,t,e=>w(this,gt,qg).call(this,e,r(this,Ti))):this.next()}prev(t){b(this,Ti,null);let[e,s]=r(this,Ci).prev()??[];return w(this,gt,nh).call(this,s),t&&s&&this.highlight(s.cloneRange()),w(this,gt,ba).call(this,e)}next(t){b(this,Ti,null);let[e,s]=r(this,Ci).next()??[];return w(this,gt,nh).call(this,s),t&&s&&this.highlight(s.cloneRange()),w(this,gt,ba).call(this,e)}end(){b(this,Ti,null);let[t,e]=r(this,Ci).last()??[];return w(this,gt,nh).call(this,e),t?w(this,gt,ba).call(this,t):this.next()}prepare(){let[t]=r(this,Ci).prepare()??[];return w(this,gt,ba).call(this,t)}from(t){b(this,Ti,null);let[e]=r(this,Ci).find(i=>t.compareBoundaryPoints(Range.END_TO_START,i)<=0);r(this,Zs).find(i=>t.compareBoundaryPoints(Range.END_TO_START,i)<=0);let s;for(let[i,n]of r(this,rh).entries())if(t.compareBoundaryPoints(Range.START_TO_START,n)<=0){s=i;break}return w(this,gt,ba).call(this,e,i=>w(this,gt,qg).call(this,i,s))}setMark(t){let e=r(this,rh).get(t);e&&(b(this,Ti,t),b(this,wa,e.cloneRange()),w(this,gt,nh).call(this,e),this.highlight(e.cloneRange()))}currentDetail(){return w(this,gt,mf).call(this,w(this,gt,ry).call(this))}collectDetails(t=1,{includeCurrent:e=!1,offset:s=1}={}){if(!Number.isFinite(t)||t<=0)return[];let i=[];if(e){let o=w(this,gt,mf).call(this,w(this,gt,ry).call(this));o&&i.push(o)}let n=t-i.length;if(n<=0)return i;let a=r(this,Zs).peek(n,s);for(let o of a){let c=w(this,gt,mf).call(this,o);c&&i.push(c)}return i}alignCfi(t){return w(this,gt,ay).call(this,t,{highlight:!1})}highlightCfi(t){return w(this,gt,ay).call(this,t,{highlight:!0})}getLastRange(){return r(this,wa)?.cloneRange?.()??null}};Ci=new WeakMap,Zs=new WeakMap,rh=new WeakMap,Ti=new WeakMap,wa=new WeakMap,Qs=new WeakMap,bf=new WeakMap,gt=new WeakSet,qg=function(t,e){return e?t.querySelector(`mark[name="${CSS.escape(e)}"]`):null},nh=function(t){if(t){if(r(this,Qs)){let e=r(this,Qs).call(this,t.cloneRange());if(e){r(this,Zs).find(s=>r(this,Qs).call(this,s.cloneRange())===e);return}}r(this,Zs).find(e=>e.compareBoundaryPoints(Range.START_TO_START,t)===0&&e.compareBoundaryPoints(Range.END_TO_END,t)===0)}},ry=function(){return r(this,Zs).current()??r(this,Zs).first()??r(this,Zs).next()},mf=function(t,{highlight:e=!1}={}){if(!t)return null;let[s,i]=t;if(!s||!i)return null;let n=null;if(e&&i.cloneRange){let a=i.cloneRange();n=this.highlight(a)??null,b(this,wa,a.cloneRange?a.cloneRange():a)}return!n&&r(this,Qs)&&i.cloneRange&&(n=r(this,Qs).call(this,i.cloneRange())),!r(this,wa)&&i.cloneRange&&b(this,wa,i.cloneRange()),{text:s,cfi:n}},ba=function(t,e){if(!t)return;if(!e)return r(this,bf).serializeToString(t);let s=document.implementation.createDocument(rs.SSML,"speak");s.documentElement.replaceWith(s.importNode(t.documentElement,!0));let i=e(s)?.previousSibling;for(;i;){let n=i.previousSibling??i.parentNode?.previousSibling;i.parentNode.removeChild(i),i=n}return r(this,bf).serializeToString(s)},ay=function(t,{highlight:e=!1}={}){if(!t||!r(this,Qs))return null;let s=r(this,Zs).find(i=>r(this,Qs).call(this,i.cloneRange())===t);return w(this,gt,mf).call(this,s,{highlight:e})}});Sf();var Ot=l=>document.createElementNS("http://www.w3.org/2000/svg",l),_a,dh,dr,ls,uh,Li,ur=class{constructor(){A(this,_a,Ot("svg"));A(this,dh,Ot("defs"));A(this,dr,Ot("g"));A(this,ls,null);A(this,uh,`foliate-overlayer-hole-${Math.random().toString(36).slice(2)}`);A(this,Li,new Map);Object.assign(r(this,_a).style,{position:"absolute",top:"0",left:"0",width:"100%",height:"100%",pointerEvents:"none"}),r(this,_a).append(r(this,dh),r(this,dr))}get element(){return r(this,_a)}add(t,e,s,i){r(this,Li).has(t)&&this.remove(t);let n=typeof e=="function"?e(r(this,_a).getRootNode()):e,a=n.getClientRects(),o=s(a,i);r(this,dr).append(o),r(this,Li).set(t,{range:n,draw:s,options:i,element:o,rects:a})}remove(t){r(this,Li).has(t)&&(r(this,Li).get(t).element.remove(),r(this,Li).delete(t))}redraw(){for(let t of r(this,Li).values()){let{range:e,draw:s,options:i,element:n}=t;n.remove();let a=e.getClientRects(),o=s(a,i);r(this,dr).append(o),t.element=o,t.rects=a}}setHole(t,e,s,i,n=0){if(!r(this,ls)){let a=Ot("mask");a.id=r(this,uh),a.setAttribute("maskUnits","userSpaceOnUse"),a.setAttribute("x","0"),a.setAttribute("y","0"),a.setAttribute("width","100%"),a.setAttribute("height","100%");let o=Ot("rect");o.setAttribute("x","0"),o.setAttribute("y","0"),o.setAttribute("width","100%"),o.setAttribute("height","100%"),o.setAttribute("fill","white");let c=Ot("rect");c.setAttribute("fill","black"),a.append(o,c),r(this,dh).append(a),r(this,dr).setAttribute("mask",`url(#${r(this,uh)})`),b(this,ls,{mask:a,hole:c})}r(this,ls).hole.setAttribute("x",t),r(this,ls).hole.setAttribute("y",e),r(this,ls).hole.setAttribute("width",s),r(this,ls).hole.setAttribute("height",i),r(this,ls).hole.setAttribute("rx",n),r(this,ls).hole.setAttribute("ry",n)}clearHole(){r(this,dr).removeAttribute("mask"),r(this,ls)?.mask.remove(),b(this,ls,null)}hitTest({x:t,y:e}){let s=Array.from(r(this,Li).entries());for(let i=s.length-1;i>=0;i--){let[n,a]=s[i];for(let{left:o,top:c,right:h,bottom:d}of a.rects)if(c<=e&&o<=t&&d>e&&h>t)return[n,a.range]}return[]}static underline(t,e={}){let{color:s="red",width:i=2,writingMode:n}=e,a=Ot("g");if(a.setAttribute("fill",s),n==="vertical-rl"||n==="vertical-lr")for(let{right:o,top:c,height:h}of t){let d=Ot("rect");d.setAttribute("x",o-i),d.setAttribute("y",c),d.setAttribute("height",h),d.setAttribute("width",i),a.append(d)}else for(let{left:o,bottom:c,width:h}of t){let d=Ot("rect");d.setAttribute("x",o),d.setAttribute("y",c-i),d.setAttribute("height",i),d.setAttribute("width",h),a.append(d)}return a}static strikethrough(t,e={}){let{color:s="red",width:i=2,writingMode:n}=e,a=Ot("g");if(a.setAttribute("fill",s),n==="vertical-rl"||n==="vertical-lr")for(let{right:o,left:c,top:h,height:d}of t){let u=Ot("rect");u.setAttribute("x",(o+c)/2),u.setAttribute("y",h),u.setAttribute("height",d),u.setAttribute("width",i),a.append(u)}else for(let{left:o,top:c,bottom:h,width:d}of t){let u=Ot("rect");u.setAttribute("x",o),u.setAttribute("y",(c+h)/2),u.setAttribute("height",i),u.setAttribute("width",d),a.append(u)}return a}static squiggly(t,e={}){let{color:s="red",width:i=2,writingMode:n}=e,a=Ot("g");a.setAttribute("fill","none"),a.setAttribute("stroke",s),a.setAttribute("stroke-width",i);let o=i*1.5;if(n==="vertical-rl"||n==="vertical-lr")for(let{right:c,top:h,height:d}of t){let u=Ot("path"),f=Math.round(d/o/1.5),p=d/f,m=Array.from({length:f},(g,v)=>`l${v%2?-o:o} ${p}`).join("");u.setAttribute("d",`M${c} ${h}${m}`),a.append(u)}else for(let{left:c,bottom:h,width:d}of t){let u=Ot("path"),f=Math.round(d/o/1.5),p=d/f,m=Array.from({length:f},(g,v)=>`l${p} ${v%2?o:-o}`).join("");u.setAttribute("d",`M${c} ${h}${m}`),a.append(u)}return a}static highlight(t,e={}){let{color:s="red"}=e,i=Ot("g");i.setAttribute("fill",s),i.style.opacity="var(--overlayer-highlight-opacity, .3)",i.style.mixBlendMode="var(--overlayer-highlight-blend-mode, normal)";for(let{left:n,top:a,height:o,width:c}of t){let h=Ot("rect");h.setAttribute("x",n),h.setAttribute("y",a),h.setAttribute("height",o),h.setAttribute("width",c),i.append(h)}return i}static outline(t,e={}){let{color:s="red",width:i=3,radius:n=3}=e,a=Ot("g");a.setAttribute("fill","none"),a.setAttribute("stroke",s),a.setAttribute("stroke-width",i);for(let{left:o,top:c,height:h,width:d}of t){let u=Ot("rect");u.setAttribute("x",o),u.setAttribute("y",c),u.setAttribute("height",h),u.setAttribute("width",d),u.setAttribute("rx",n),a.append(u)}return a}static arrow(t,e={}){let{color:s="red",size:i=20,animated:n=!0,autoHide:a=!0,hideDelay:o=5e3,offset:c=10}=e,h=Ot("g"),d=t[0];if(!d)return h;let u=Math.min(i,d.height*.8),f=d.top+d.height/2,p=d.left-c-u,m=Ot("path"),g=`M ${p+u} ${f} L ${p} ${f-u/2} L ${p+u*.3} ${f} L ${p} ${f+u/2} diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index bee18ac7..46c8ff37 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -2292,8 +2292,8 @@ export class Paginator extends HTMLElement { const visibleStart = this.#renderedStart - viewOffset const visibleEnd = this.#renderedEnd - viewOffset const mapped = this.#getRectMapper(view)(rect) - const edgeInset = Math.max(24, Math.min(96, this.size * 0.12)) - const pointerEdgeInset = Math.max(edgeInset, Math.min(96, this.size * 0.22)) + const edgeInset = Math.max(48, Math.min(120, this.size * 0.18)) + const pointerEdgeInset = Math.max(edgeInset, Math.min(140, this.size * 0.3)) const base = { edgeInset: Math.round(edgeInset), pointerEdgeInset: Math.round(pointerEdgeInset), From 1ff1ad0e12934aad0594ba81bd6bb98d75060396 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Tue, 16 Jun 2026 01:26:17 +0800 Subject: [PATCH 19/22] fix(reader): tolerate selection handle drift near edge --- packages/app-expo/assets/reader/reader.html | 24 ++++----- packages/foliate-js/paginator.js | 59 +++++++++++++++++---- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 0982787a..08df22cb 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4519,7 +4519,7 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index ff9cab0c..e069cd7e 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -1286,6 +1286,10 @@ export class Paginator extends HTMLElement { let lastPointerSelectionTurnAt = 0 let lastPointerSelectionTurnDirection = 0 let lastPointerSelectionPoint = null + let isPointerSelecting = false + let pointerSelectionActiveUntil = 0 + let pointerSelectionChangeCount = 0 + const touchSelectionHandles = globalThis.navigator?.maxTouchPoints > 0 const debugSelectionPaging = (event, detail = {}) => { const serialize = value => { try { @@ -1334,6 +1338,29 @@ export class Paginator extends HTMLElement { } const pointerPointMoved = (a, b) => !a || !b || Math.hypot(a.x - b.x, a.y - b.y) > SELECTION_EDGE_POINTER_MOVE_TOLERANCE + let pointerSelectionPollTimer = null + const clearPointerSelectionPoll = () => { + if (pointerSelectionPollTimer) clearTimeout(pointerSelectionPollTimer) + pointerSelectionPollTimer = null + } + const schedulePointerSelectionPoll = () => { + clearPointerSelectionPoll() + pointerSelectionPollTimer = setTimeout(() => { + pointerSelectionPollTimer = null + const sel = this.#primaryView?.document?.getSelection?.() + const range = this.#lastVisibleRange + if (!sel || !range || sel.type !== 'Range' || !sel.rangeCount) return + if (!isPointerSelecting && Date.now() >= pointerSelectionActiveUntil && !touchSelectionHandles) return + checkPointerSelection( + range, + sel, + pointerSelectionChangeCount > 1 || touchSelectionHandles, + isPointerSelecting || Date.now() < pointerSelectionActiveUntil || touchSelectionHandles, + ) + if (pointerSelectionEdgeCandidate || isPointerSelecting || Date.now() < pointerSelectionActiveUntil || touchSelectionHandles) + schedulePointerSelectionPoll() + }, 240) + } const refreshPointerSelectionEdgeCandidate = direction => { const candidate = pointerSelectionEdgeCandidate if (!candidate) return null @@ -1406,7 +1433,7 @@ export class Paginator extends HTMLElement { }) refreshPointerSelectionEdgeCandidate() } - const checkPointerSelection = throttle((range, sel, allowEdgeTurn) => { + const checkPointerSelection = throttle((range, sel, allowEdgeTurn, keepEdgeCandidate) => { if (this.#navigationLocked) { debugSelectionPaging('check-skip', { reason: 'navigation-locked' }) return @@ -1445,28 +1472,19 @@ export class Paginator extends HTMLElement { }) if (!turnIntent.direction) { if (pointerSelectionEdgeCandidate && + keepEdgeCandidate && (turnIntent.reason === 'not-at-edge' || turnIntent.reason === 'range-edge-not-found')) { const direction = pointerSelectionEdgeCandidate.direction - const edgeSlop = Math.max(24, Math.min(56, this.size * 0.14)) - const stillNearTrailingEdge = direction > 0 && - typeof turnIntent.mappedRight === 'number' && - turnIntent.mappedRight >= turnIntent.visibleEnd - turnIntent.edgeInset - edgeSlop - const stillNearLeadingEdge = direction < 0 && - typeof turnIntent.mappedLeft === 'number' && - turnIntent.mappedLeft <= turnIntent.visibleStart + turnIntent.edgeInset + edgeSlop - if (stillNearTrailingEdge || stillNearLeadingEdge) { - debugSelectionPaging('candidate-keep-near-edge', { - direction, - stableAge: Math.round(Date.now() - pointerSelectionEdgeCandidate.stableSince), - edgeSlop: Math.round(edgeSlop), - mappedLeft: turnIntent.mappedLeft, - mappedRight: turnIntent.mappedRight, - visibleStart: turnIntent.visibleStart, - visibleEnd: turnIntent.visibleEnd, - edgeInset: turnIntent.edgeInset, - }) - return - } + debugSelectionPaging('candidate-keep-range-drift', { + direction, + stableAge: Math.round(Date.now() - pointerSelectionEdgeCandidate.stableSince), + mappedLeft: turnIntent.mappedLeft, + mappedRight: turnIntent.mappedRight, + visibleStart: turnIntent.visibleStart, + visibleEnd: turnIntent.visibleEnd, + edgeInset: turnIntent.edgeInset, + }) + return } clearPointerSelectionEdgeCandidate('no-direction') return @@ -1528,41 +1546,15 @@ export class Paginator extends HTMLElement { }) return } - const currentRange = currentSel.getRangeAt(0) - const currentBackward = selectionIsBackward(currentSel) - const currentIntent = this.#getPointerSelectionTurn( - range, - currentRange, - currentBackward, - true, - lastPointerSelectionPoint, - ) debugSelectionPaging('run-check', { direction, - currentDirection: currentIntent.direction, - currentEdge: currentIntent.edge, - reason: currentIntent.reason, - edgeSource: currentIntent.edgeSource, - edgeInset: currentIntent.edgeInset, - pointerEdgeInset: currentIntent.pointerEdgeInset, - pointerMapped: currentIntent.pointerMapped, - mappedLeft: currentIntent.mappedLeft, - mappedRight: currentIntent.mappedRight, - visibleStart: currentIntent.visibleStart, - visibleEnd: currentIntent.visibleEnd, + reason: 'candidate-dwelled', + type: currentSel.type, + rangeCount: currentSel.rangeCount, }) - if (currentIntent.edge && currentIntent.direction === direction) { - lastPointerSelectionTurnDirection = direction - lastPointerSelectionTurnAt = Date.now() - runPointerSelectionTurn(direction) - } else { - debugSelectionPaging('run-abort', { - reason: 'edge-or-direction-changed', - direction, - currentDirection: currentIntent.direction, - currentEdge: currentIntent.edge, - }) - } + lastPointerSelectionTurnDirection = direction + lastPointerSelectionTurnAt = Date.now() + runPointerSelectionTurn(direction) }, } debugSelectionPaging('candidate-create', { @@ -1573,10 +1565,6 @@ export class Paginator extends HTMLElement { schedulePointerSelectionEdgeCandidate(pointerSelectionEdgeCandidate) }, 120) this.addEventListener('load', ({ detail: { doc } }) => { - let isPointerSelecting = false - let pointerSelectionActiveUntil = 0 - let pointerSelectionChangeCount = 0 - const touchSelectionHandles = globalThis.navigator?.maxTouchPoints > 0 const markPointerSelecting = e => { rememberPointerSelectionPoint(e, doc) isPointerSelecting = true @@ -1586,10 +1574,12 @@ export class Paginator extends HTMLElement { pointerSelectionChangeCount = 0 clearPointerSelectionEdgeCandidate('selection-start') markPointerSelecting(e) + schedulePointerSelectionPoll() } const clearPointerSelecting = () => { lastPointerSelectionPoint = null clearPointerSelectionEdgeCandidate('selection-end') + clearPointerSelectionPoll() setTimeout(() => { if (Date.now() >= pointerSelectionActiveUntil) isPointerSelecting = false }, 160) @@ -1643,7 +1633,15 @@ export class Paginator extends HTMLElement { else if (sel.type === 'Range' && (isPointerSelecting || Date.now() < pointerSelectionActiveUntil || touchSelectionHandles)) { pointerSelectionChangeCount += 1 - checkPointerSelection(range, sel, pointerSelectionChangeCount > 1 || touchSelectionHandles) + checkPointerSelection( + range, + sel, + pointerSelectionChangeCount > 1 || touchSelectionHandles, + isPointerSelecting + || Date.now() < pointerSelectionActiveUntil + || touchSelectionHandles, + ) + schedulePointerSelectionPoll() } else { debugSelectionPaging('selectionchange-skip', { reason: 'not-pointer-selection', From eb702ad37eeff100951b57251d43db9835b3f6cf Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Tue, 16 Jun 2026 01:45:48 +0800 Subject: [PATCH 21/22] fix(reader): stabilize Android selection edge paging --- packages/app-expo/assets/reader/reader.html | 26 ++++---- packages/foliate-js/paginator.js | 71 ++++++++++----------- 2 files changed, 45 insertions(+), 52 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index d30405a8..2c6c7cca 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4519,8 +4519,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index e069cd7e..e0a496f3 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -41,6 +41,7 @@ const throttle = (f, wait) => { const SELECTION_EDGE_TURN_DWELL_MS = 1000 const SELECTION_EDGE_POINTER_MOVE_TOLERANCE = 18 +const SELECTION_EDGE_RANGE_MOVE_TOLERANCE = 24 const SELECTION_EDGE_TURN_COOLDOWN_MS = 900 // Transforms ALL children of the container so multi-view layouts @@ -1361,7 +1362,7 @@ export class Paginator extends HTMLElement { schedulePointerSelectionPoll() }, 240) } - const refreshPointerSelectionEdgeCandidate = direction => { + const refreshPointerSelectionEdgeCandidate = (direction, turnIntent = null) => { const candidate = pointerSelectionEdgeCandidate if (!candidate) return null if (direction && candidate.direction !== direction) { @@ -1385,6 +1386,20 @@ export class Paginator extends HTMLElement { } candidate.pointUpdatedAt = point.updatedAt } + if (turnIntent && Number.isFinite(turnIntent.edgePosition)) { + if (Number.isFinite(candidate.edgePosition) && + Math.abs(candidate.edgePosition - turnIntent.edgePosition) > SELECTION_EDGE_RANGE_MOVE_TOLERANCE) { + debugSelectionPaging('candidate-reset-moving-edge', { + direction: candidate.direction, + from: Math.round(candidate.edgePosition), + to: Math.round(turnIntent.edgePosition), + }) + candidate.edgePosition = turnIntent.edgePosition + candidate.stableSince = Date.now() + } else if (!Number.isFinite(candidate.edgePosition)) { + candidate.edgePosition = turnIntent.edgePosition + } + } return candidate } const schedulePointerSelectionEdgeCandidate = candidate => { @@ -1467,41 +1482,15 @@ export class Paginator extends HTMLElement { pointerMapped: turnIntent.pointerMapped, mappedLeft: turnIntent.mappedLeft, mappedRight: turnIntent.mappedRight, + edgePosition: turnIntent.edgePosition, visibleStart: turnIntent.visibleStart, visibleEnd: turnIntent.visibleEnd, }) if (!turnIntent.direction) { - if (pointerSelectionEdgeCandidate && - keepEdgeCandidate && - (turnIntent.reason === 'not-at-edge' || turnIntent.reason === 'range-edge-not-found')) { - const direction = pointerSelectionEdgeCandidate.direction - debugSelectionPaging('candidate-keep-range-drift', { - direction, - stableAge: Math.round(Date.now() - pointerSelectionEdgeCandidate.stableSince), - mappedLeft: turnIntent.mappedLeft, - mappedRight: turnIntent.mappedRight, - visibleStart: turnIntent.visibleStart, - visibleEnd: turnIntent.visibleEnd, - edgeInset: turnIntent.edgeInset, - }) - return - } clearPointerSelectionEdgeCandidate('no-direction') return } if (!turnIntent.edge) { - if (pointerSelectionEdgeCandidate && pointerSelectionEdgeCandidate.direction === turnIntent.direction) { - debugSelectionPaging('candidate-keep-not-edge', { - direction: turnIntent.direction, - stableAge: Math.round(Date.now() - pointerSelectionEdgeCandidate.stableSince), - reason: turnIntent.reason, - mappedLeft: turnIntent.mappedLeft, - mappedRight: turnIntent.mappedRight, - visibleStart: turnIntent.visibleStart, - visibleEnd: turnIntent.visibleEnd, - }) - return - } clearPointerSelectionEdgeCandidate('crossed-visible-range') runPointerSelectionTurn(turnIntent.direction) return @@ -1518,11 +1507,12 @@ export class Paginator extends HTMLElement { clearPointerSelectionEdgeCandidate('cooldown') return } - const existingCandidate = refreshPointerSelectionEdgeCandidate(direction) + const existingCandidate = refreshPointerSelectionEdgeCandidate(direction, turnIntent) if (existingCandidate) { debugSelectionPaging('candidate-existing', { direction, stableAge: Math.round(Date.now() - existingCandidate.stableSince), + edgePosition: Math.round(existingCandidate.edgePosition), }) return } @@ -1534,6 +1524,7 @@ export class Paginator extends HTMLElement { direction, point: point ? { x: point.x, y: point.y } : null, pointUpdatedAt: point?.updatedAt ?? 0, + edgePosition: turnIntent.edgePosition, stableSince: Date.now(), run: () => { const currentSel = doc?.getSelection?.() @@ -1561,6 +1552,8 @@ export class Paginator extends HTMLElement { direction, point: pointerSelectionEdgeCandidate.point, pointUpdatedAt: pointerSelectionEdgeCandidate.pointUpdatedAt, + edgePosition: Math.round(pointerSelectionEdgeCandidate.edgePosition), + edgeSource: turnIntent.edgeSource, }) schedulePointerSelectionEdgeCandidate(pointerSelectionEdgeCandidate) }, 120) @@ -2327,12 +2320,12 @@ export class Paginator extends HTMLElement { const visibleStart = this.#renderedStart - viewOffset const visibleEnd = this.#renderedEnd - viewOffset const mapped = this.#getRectMapper(view)(rect) - // Android selection handles can report the range edge 60-80px inside - // the visual page edge while the handle itself is being held at the - // boundary. Use a wider range edge band, then rely on the 1s dwell - // candidate to avoid accidental page turns. - const edgeInset = Math.max(72, Math.min(140, this.size * 0.24)) - const pointerEdgeInset = Math.max(edgeInset, Math.min(140, this.size * 0.3)) + // Android WebView does not reliably emit pointermove/touchmove while + // dragging native text-selection handles. The range edge can lag well + // inside the visual page edge, so use a generous handle band and require + // a stable 1s dwell before turning the page. + const edgeInset = Math.max(112, Math.min(180, this.size * 0.34)) + const pointerEdgeInset = Math.max(edgeInset, Math.min(180, this.size * 0.38)) const base = { edgeInset: Math.round(edgeInset), pointerEdgeInset: Math.round(pointerEdgeInset), @@ -2343,9 +2336,9 @@ export class Paginator extends HTMLElement { } if (backward && mapped.left <= visibleStart + edgeInset) - return { ...base, direction: -1, edge: true, edgeSource: 'range' } + return { ...base, direction: -1, edge: true, edgeSource: 'range', edgePosition: mapped.left } if (!backward && mapped.right >= visibleEnd - edgeInset) - return { ...base, direction: 1, edge: true, edgeSource: 'range' } + return { ...base, direction: 1, edge: true, edgeSource: 'range', edgePosition: mapped.right } if (pointerPoint?.doc === doc && typeof pointerPoint.x === 'number' && typeof pointerPoint.y === 'number') { const pointerMapped = this.#getRectMapper(view)({ @@ -2359,9 +2352,9 @@ export class Paginator extends HTMLElement { pointerMapped: Math.round(pointerMapped.left), } if (backward && pointerMapped.left <= visibleStart + pointerEdgeInset) - return { ...pointerDetail, direction: -1, edge: true, edgeSource: 'pointer' } + return { ...pointerDetail, direction: -1, edge: true, edgeSource: 'pointer', edgePosition: pointerMapped.left } if (!backward && pointerMapped.right >= visibleEnd - pointerEdgeInset) - return { ...pointerDetail, direction: 1, edge: true, edgeSource: 'pointer' } + return { ...pointerDetail, direction: 1, edge: true, edgeSource: 'pointer', edgePosition: pointerMapped.right } return { ...pointerDetail, From 81d89fd36e363ef3f9769fb8d7266f4b8eccb4cf Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Tue, 16 Jun 2026 01:57:20 +0800 Subject: [PATCH 22/22] revert(reader): restore foliate selection paging --- packages/app-expo/assets/reader/reader.html | 52 +-- packages/foliate-js/paginator.js | 493 +------------------- 2 files changed, 47 insertions(+), 498 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 2c6c7cca..9b97ba8d 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4519,8 +4519,8 @@ diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index e0a496f3..1ff48cf7 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -14,36 +14,6 @@ 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 = 18 -const SELECTION_EDGE_RANGE_MOVE_TOLERANCE = 24 -const SELECTION_EDGE_TURN_COOLDOWN_MS = 900 - // 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. @@ -268,31 +238,10 @@ const getVisibleRange = (doc, start, end, mapRect) => { } const selectionIsBackward = sel => { - 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 range = document.createRange() + range.setStart(sel.anchorNode, sel.anchorOffset) + range.setEnd(sel.focusNode, sel.focusOffset) + return range.collapsed } const setSelectionTo = (target, collapse) => { @@ -1282,368 +1231,37 @@ export class Paginator extends HTMLElement { else setSelectionTo(this.#anchor, -1) } }) - let pointerSelectionTurn = null - let pointerSelectionEdgeCandidate = null - let lastPointerSelectionTurnAt = 0 - let lastPointerSelectionTurnDirection = 0 - let lastPointerSelectionPoint = null - let isPointerSelecting = false - let pointerSelectionActiveUntil = 0 - let pointerSelectionChangeCount = 0 - const touchSelectionHandles = globalThis.navigator?.maxTouchPoints > 0 - const debugSelectionPaging = (event, detail = {}) => { - const serialize = value => { - try { - return JSON.stringify(value) - } catch { - return String(value) - } - } - const message = `[SelectionPaging] ${event} ${serialize(detail)}` - try { - console.log(message) - } catch { - // ignore console failures in embedded WebViews - } - try { - globalThis.ReactNativeWebView?.postMessage(JSON.stringify({ - type: 'debug', - message, - })) - } catch { - // ignore bridge failures outside React Native WebView - } - } - const clearPointerSelectionEdgeCandidate = reason => { - if (pointerSelectionEdgeCandidate) - debugSelectionPaging('candidate-clear', { - reason, - direction: pointerSelectionEdgeCandidate.direction, - age: Math.round(Date.now() - pointerSelectionEdgeCandidate.stableSince), - }) - if (pointerSelectionEdgeCandidate?.timer) clearTimeout(pointerSelectionEdgeCandidate.timer) - pointerSelectionEdgeCandidate = null - } - const runPointerSelectionTurn = direction => { - if (pointerSelectionTurn || !direction) { - debugSelectionPaging('turn-skip', { direction, reason: pointerSelectionTurn ? 'in-flight' : 'no-direction' }) - return - } - debugSelectionPaging('turn-run', { direction }) - const turn = direction < 0 ? this.prev() : this.next() - pointerSelectionTurn = Promise.resolve(turn) - .finally(() => setTimeout(() => { - debugSelectionPaging('turn-ready', { direction }) - pointerSelectionTurn = null - }, 80)) - } - const pointerPointMoved = (a, b) => !a || !b || - Math.hypot(a.x - b.x, a.y - b.y) > SELECTION_EDGE_POINTER_MOVE_TOLERANCE - let pointerSelectionPollTimer = null - const clearPointerSelectionPoll = () => { - if (pointerSelectionPollTimer) clearTimeout(pointerSelectionPollTimer) - pointerSelectionPollTimer = null - } - const schedulePointerSelectionPoll = () => { - clearPointerSelectionPoll() - pointerSelectionPollTimer = setTimeout(() => { - pointerSelectionPollTimer = null - const sel = this.#primaryView?.document?.getSelection?.() - const range = this.#lastVisibleRange - if (!sel || !range || sel.type !== 'Range' || !sel.rangeCount) return - if (!isPointerSelecting && Date.now() >= pointerSelectionActiveUntil && !touchSelectionHandles) return - checkPointerSelection( - range, - sel, - pointerSelectionChangeCount > 1 || touchSelectionHandles, - isPointerSelecting || Date.now() < pointerSelectionActiveUntil || touchSelectionHandles, - ) - if (pointerSelectionEdgeCandidate || isPointerSelecting || Date.now() < pointerSelectionActiveUntil || touchSelectionHandles) - schedulePointerSelectionPoll() - }, 240) - } - const refreshPointerSelectionEdgeCandidate = (direction, turnIntent = null) => { - const candidate = pointerSelectionEdgeCandidate - if (!candidate) return null - if (direction && candidate.direction !== direction) { - debugSelectionPaging('candidate-direction-mismatch', { - current: candidate.direction, - next: direction, - }) - clearPointerSelectionEdgeCandidate('direction-mismatch') - return null - } - const point = lastPointerSelectionPoint - if (point && point.updatedAt !== candidate.pointUpdatedAt) { - if (pointerPointMoved(candidate.point, point)) { - debugSelectionPaging('candidate-reset-moving-point', { - direction: candidate.direction, - from: candidate.point, - to: { x: point.x, y: point.y }, - }) - candidate.point = { x: point.x, y: point.y } - candidate.stableSince = Date.now() - } - candidate.pointUpdatedAt = point.updatedAt - } - if (turnIntent && Number.isFinite(turnIntent.edgePosition)) { - if (Number.isFinite(candidate.edgePosition) && - Math.abs(candidate.edgePosition - turnIntent.edgePosition) > SELECTION_EDGE_RANGE_MOVE_TOLERANCE) { - debugSelectionPaging('candidate-reset-moving-edge', { - direction: candidate.direction, - from: Math.round(candidate.edgePosition), - to: Math.round(turnIntent.edgePosition), - }) - candidate.edgePosition = turnIntent.edgePosition - candidate.stableSince = Date.now() - } else if (!Number.isFinite(candidate.edgePosition)) { - candidate.edgePosition = turnIntent.edgePosition - } - } - 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)) - debugSelectionPaging('candidate-schedule', { - direction: candidate.direction, - delay: Math.round(delay), - stableAge: Math.round(Date.now() - candidate.stableSince), - }) - candidate.timer = setTimeout(() => { - const currentCandidate = refreshPointerSelectionEdgeCandidate(candidate.direction) - if (currentCandidate !== candidate) { - debugSelectionPaging('candidate-abort-replaced', { direction: candidate.direction }) - return - } - if (Date.now() - candidate.stableSince < SELECTION_EDGE_TURN_DWELL_MS) { - debugSelectionPaging('candidate-reschedule-not-stable', { - direction: candidate.direction, - stableAge: Math.round(Date.now() - candidate.stableSince), - }) - schedulePointerSelectionEdgeCandidate(candidate) - return - } - pointerSelectionEdgeCandidate = null - debugSelectionPaging('candidate-fire', { - direction: candidate.direction, - stableAge: Math.round(Date.now() - candidate.stableSince), - }) - 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(), - } - debugSelectionPaging('point', { - type: e?.type, - x: Math.round(lastPointerSelectionPoint.x), - y: Math.round(lastPointerSelectionPoint.y), - }) - refreshPointerSelectionEdgeCandidate() - } - const checkPointerSelection = throttle((range, sel, allowEdgeTurn, keepEdgeCandidate) => { - if (this.#navigationLocked) { - debugSelectionPaging('check-skip', { reason: 'navigation-locked' }) - return - } - if (pointerSelectionTurn) { - debugSelectionPaging('check-skip', { reason: 'turn-in-flight' }) - return - } - if (!sel.rangeCount) { - debugSelectionPaging('check-skip', { reason: 'no-range' }) - return - } + const checkPointerSelection = debounce((range, sel) => { + if (this.#navigationLocked) return + if (!sel.rangeCount) return const selRange = sel.getRangeAt(0) const backward = selectionIsBackward(sel) - const turnIntent = this.#getPointerSelectionTurn( - range, - selRange, - backward, - allowEdgeTurn, - lastPointerSelectionPoint, - ) - debugSelectionPaging('check', { - allowEdgeTurn, - backward, - direction: turnIntent.direction, - edge: turnIntent.edge, - reason: turnIntent.reason, - edgeSource: turnIntent.edgeSource, - edgeInset: turnIntent.edgeInset, - pointerEdgeInset: turnIntent.pointerEdgeInset, - pointerMapped: turnIntent.pointerMapped, - mappedLeft: turnIntent.mappedLeft, - mappedRight: turnIntent.mappedRight, - edgePosition: turnIntent.edgePosition, - visibleStart: turnIntent.visibleStart, - visibleEnd: turnIntent.visibleEnd, - }) - if (!turnIntent.direction) { - clearPointerSelectionEdgeCandidate('no-direction') - return - } - if (!turnIntent.edge) { - clearPointerSelectionEdgeCandidate('crossed-visible-range') - runPointerSelectionTurn(turnIntent.direction) - return - } - - const direction = turnIntent.direction - if (lastPointerSelectionTurnDirection === direction && - Date.now() - lastPointerSelectionTurnAt < SELECTION_EDGE_TURN_COOLDOWN_MS) { - debugSelectionPaging('check-skip', { - reason: 'cooldown', - direction, - remaining: Math.round(SELECTION_EDGE_TURN_COOLDOWN_MS - (Date.now() - lastPointerSelectionTurnAt)), - }) - clearPointerSelectionEdgeCandidate('cooldown') - return - } - const existingCandidate = refreshPointerSelectionEdgeCandidate(direction, turnIntent) - if (existingCandidate) { - debugSelectionPaging('candidate-existing', { - direction, - stableAge: Math.round(Date.now() - existingCandidate.stableSince), - edgePosition: Math.round(existingCandidate.edgePosition), - }) - return - } - - clearPointerSelectionEdgeCandidate('new-candidate') - 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, - edgePosition: turnIntent.edgePosition, - stableSince: Date.now(), - run: () => { - const currentSel = doc?.getSelection?.() - if (!currentSel || currentSel.type !== 'Range' || !currentSel.rangeCount) { - debugSelectionPaging('run-abort', { - reason: 'selection-invalid', - hasSelection: !!currentSel, - type: currentSel?.type, - rangeCount: currentSel?.rangeCount, - }) - return - } - debugSelectionPaging('run-check', { - direction, - reason: 'candidate-dwelled', - type: currentSel.type, - rangeCount: currentSel.rangeCount, - }) - lastPointerSelectionTurnDirection = direction - lastPointerSelectionTurnAt = Date.now() - runPointerSelectionTurn(direction) - }, - } - debugSelectionPaging('candidate-create', { - direction, - point: pointerSelectionEdgeCandidate.point, - pointUpdatedAt: pointerSelectionEdgeCandidate.pointUpdatedAt, - edgePosition: Math.round(pointerSelectionEdgeCandidate.edgePosition), - edgeSource: turnIntent.edgeSource, - }) - schedulePointerSelectionEdgeCandidate(pointerSelectionEdgeCandidate) - }, 120) + 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) this.addEventListener('load', ({ detail: { doc } }) => { - const markPointerSelecting = e => { - rememberPointerSelectionPoint(e, doc) - isPointerSelecting = true - pointerSelectionActiveUntil = Date.now() + 1200 - } - const beginPointerSelecting = e => { - pointerSelectionChangeCount = 0 - clearPointerSelectionEdgeCandidate('selection-start') - markPointerSelecting(e) - schedulePointerSelectionPoll() - } - const clearPointerSelecting = () => { - lastPointerSelectionPoint = null - clearPointerSelectionEdgeCandidate('selection-end') - clearPointerSelectionPoll() - 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 isPointerSelecting = false + doc.addEventListener('pointerdown', () => isPointerSelecting = true) + doc.addEventListener('pointerup', () => isPointerSelecting = false) let isKeyboardSelecting = false doc.addEventListener('keydown', () => isKeyboardSelecting = true) doc.addEventListener('keyup', () => isKeyboardSelecting = false) doc.addEventListener('selectionchange', () => { - if (this.scrolled) { - debugSelectionPaging('selectionchange-skip', { reason: 'scrolled' }) - return - } + if (this.scrolled) return const range = this.#lastVisibleRange - if (!range) { - debugSelectionPaging('selectionchange-skip', { reason: 'no-last-visible-range' }) - return - } + if (!range) return const sel = doc.getSelection() - if (!sel.rangeCount) { - debugSelectionPaging('selectionchange-skip', { reason: 'no-range-count', type: sel.type }) - return - } - debugSelectionPaging('selectionchange', { - type: sel.type, - rangeCount: sel.rangeCount, - isPointerSelecting, - activeFor: Math.round(pointerSelectionActiveUntil - Date.now()), - touchSelectionHandles, - changeCount: pointerSelectionChangeCount, - isKeyboardSelecting, - textLength: sel.toString?.()?.trim?.()?.length, - }) - if (isKeyboardSelecting) { + if (!sel.rangeCount) return + if (isPointerSelecting && sel.type === 'Range') + checkPointerSelection(range, sel) + else 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, - isPointerSelecting - || Date.now() < pointerSelectionActiveUntil - || touchSelectionHandles, - ) - schedulePointerSelectionPoll() - } else { - debugSelectionPaging('selectionchange-skip', { - reason: 'not-pointer-selection', - type: sel.type, - isPointerSelecting, - activeFor: Math.round(pointerSelectionActiveUntil - Date.now()), - touchSelectionHandles, - }) - } }) doc.addEventListener('focusin', e => { if (this.scrolled) return null @@ -2297,75 +1915,6 @@ export class Paginator extends HTMLElement { ? ({ top, bottom }) => ({ left: top, right: bottom }) : f => f } - #getPointerSelectionTurn(visibleRange, selectionRange, backward, allowEdgeTurn, pointerPoint = null) { - 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, reason: 'edge-turn-disabled' } - - 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, reason: 'view-not-found' } - - const [index, view] = entry - const rect = getRangeEdgeRect(selectionRange, backward) - if (!rect) return { direction: 0, edge: false, reason: 'range-edge-not-found' } - - const viewOffset = this.#getViewOffset(index) - const visibleStart = this.#renderedStart - viewOffset - const visibleEnd = this.#renderedEnd - viewOffset - const mapped = this.#getRectMapper(view)(rect) - // Android WebView does not reliably emit pointermove/touchmove while - // dragging native text-selection handles. The range edge can lag well - // inside the visual page edge, so use a generous handle band and require - // a stable 1s dwell before turning the page. - const edgeInset = Math.max(112, Math.min(180, this.size * 0.34)) - const pointerEdgeInset = Math.max(edgeInset, Math.min(180, this.size * 0.38)) - const base = { - edgeInset: Math.round(edgeInset), - pointerEdgeInset: Math.round(pointerEdgeInset), - mappedLeft: Math.round(mapped.left), - mappedRight: Math.round(mapped.right), - visibleStart: Math.round(visibleStart), - visibleEnd: Math.round(visibleEnd), - } - - if (backward && mapped.left <= visibleStart + edgeInset) - return { ...base, direction: -1, edge: true, edgeSource: 'range', edgePosition: mapped.left } - if (!backward && mapped.right >= visibleEnd - edgeInset) - return { ...base, direction: 1, edge: true, edgeSource: 'range', edgePosition: mapped.right } - - if (pointerPoint?.doc === doc && typeof pointerPoint.x === 'number' && typeof pointerPoint.y === 'number') { - const pointerMapped = this.#getRectMapper(view)({ - left: pointerPoint.x, - right: pointerPoint.x, - top: pointerPoint.y, - bottom: pointerPoint.y, - }) - const pointerDetail = { - ...base, - pointerMapped: Math.round(pointerMapped.left), - } - if (backward && pointerMapped.left <= visibleStart + pointerEdgeInset) - return { ...pointerDetail, direction: -1, edge: true, edgeSource: 'pointer', edgePosition: pointerMapped.left } - if (!backward && pointerMapped.right >= visibleEnd - pointerEdgeInset) - return { ...pointerDetail, direction: 1, edge: true, edgeSource: 'pointer', edgePosition: pointerMapped.right } - - return { - ...pointerDetail, - direction: 0, - edge: false, - reason: 'not-at-edge', - } - } - - return { ...base, direction: 0, edge: false, reason: 'not-at-edge' } - } async #scrollToRect(rect, reason) { if (this.scrolled) { // rect is in iframe-local coordinates; add view offset