From 8d52773c5f428c85ff2905257e9d50063d5b378a Mon Sep 17 00:00:00 2001 From: jrosh Date: Fri, 30 May 2025 12:30:06 +0200 Subject: [PATCH] combine select and swipe listeners, fix phone select --- src/components/EpubView.vue | 58 ++++---- src/utils/listeners/listener.ts | 2 + src/utils/listeners/select.ts | 173 ++++++++++++++++++---- src/utils/listeners/touch.ts | 248 ++++++++++++++++++++++++++++++++ 4 files changed, 431 insertions(+), 50 deletions(-) create mode 100644 src/utils/listeners/touch.ts diff --git a/src/components/EpubView.vue b/src/components/EpubView.vue index 7a885c2..139838e 100644 --- a/src/components/EpubView.vue +++ b/src/components/EpubView.vue @@ -32,7 +32,8 @@ import { swipListener, wheelListener, keyListener, - selectListener + selectListener, + touchListener } from '../utils/listeners/listener' interface Props { @@ -214,45 +215,54 @@ const registerEvents = () => { if (rendition) { rendition.on('rendered', (section, iframe) => { // Focus the iframe - iframe?.iframe?.contentWindow.focus() + iframe?.iframe?.contentWindow.focus(); // Register interaction listeners if (!epubOptions?.flow?.includes('scrolled')) { - wheelListener(iframe.document, flipPage) + wheelListener(iframe.document, flipPage); } - swipListener(iframe.document, flipPage) - keyListener(iframe.document, flipPage) - // Register your custom selection listener if toggleBubble is provided - if (toggleBubble) { - selectListener(iframe.document, rendition, toggleBubble) - } else if (handleTextSelected) { - // If no toggleBubble but handleTextSelected exists, use the built-in selection event - rendition.on('selected', handleTextSelected) - } + // Use the unified touch handler instead of separate listeners + touchListener( + iframe.document, + rendition, + flipPage, // Navigation function + toggleBubble || handleTextSelected // Selection function + ); + + // Register regular click listener for non-touch interactions + clickListener(iframe.document, rendition, flipPage); + + // If no unified handler, fall back to separate listeners + // swipListener(iframe.document, flipPage); + // if (toggleBubble) { + // selectListener(iframe.document, rendition, toggleBubble); + // } else if (handleTextSelected) { + // rendition.on('selected', handleTextSelected); + // } // Mark first content as displayed for location restoration if (!loadingState.firstContentDisplayed) { - loadingState.firstContentDisplayed = true + loadingState.firstContentDisplayed = true; } - }) + }); - // Location change tracking - rendition.on('locationChanged', onLocationChange) + // Other event handlers remain the same + rendition.on('locationChanged', onLocationChange); - rendition.on('relocated', (location: any) => { - // console.log('Book relocated to:', location) - }) + rendition.on('relocated', (location) => { + console.log('Book relocated to:', location); + }); - rendition.on('displayError', (err: any) => { - console.error('Display error:', err) - }) + rendition.on('displayError', (err) => { + console.error('Display error:', err); + }); if (handleKeyPress) { - rendition.on('keypress', handleKeyPress) + rendition.on('keypress', handleKeyPress); } } -} +}; // Function to apply saved location const applyPendingLocation = () => { diff --git a/src/utils/listeners/listener.ts b/src/utils/listeners/listener.ts index a754f50..a8e0f74 100644 --- a/src/utils/listeners/listener.ts +++ b/src/utils/listeners/listener.ts @@ -3,10 +3,12 @@ import keyListener from './key' import wheelListener from './wheel' import swipListener from './swip' import selectListener from './select' +import touchListener from './touch' export { clickListener, keyListener, wheelListener, swipListener, selectListener, + touchListener } diff --git a/src/utils/listeners/select.ts b/src/utils/listeners/select.ts index e8545a3..383a3d3 100644 --- a/src/utils/listeners/select.ts +++ b/src/utils/listeners/select.ts @@ -1,38 +1,159 @@ +// src/utils/listeners/selectListener.js + /** + * Enhanced selection listener with touch support * @param {Document} document - The document object to add event * @param {Object} rendition - The EPUBJS rendition - * @param {Function} fb - The listener function + * @param {Function} fn - The listener function */ export default function selectListener(document, rendition, fn) { - document.addEventListener('mousedown', () => { - document.getSelection().removeAllRanges() - fn('cleared') - }) + // Track touch selection state + let touchStarted = false; + let touchSelection = false; + let touchStartTime = 0; + const LONG_PRESS_DURATION = 500; // ms + const TOUCH_MOVE_THRESHOLD = 10; // pixels + let startX = 0; + let startY = 0; + + // Check if we're on a mobile device + const isMobileDevice = () => { + return (('ontouchstart' in window) || + (navigator.maxTouchPoints > 0) || + (navigator.msMaxTouchPoints > 0)); + }; + + // Check if we're on iOS + const isIOS = () => { + return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + }; + + // Clear selection handler + const clearSelection = () => { + if (document.getSelection) { + document.getSelection().removeAllRanges(); + } + fn('cleared'); + }; + + // Process text selection + const processSelection = (e) => { + if (e.ignore) return; + e.ignore = true; + + const selection = document.getSelection(); + const text = selection.toString(); + + if (text === '') return; + + try { + const range = selection.getRangeAt(0); + const [contents] = rendition.getContents(); + const cfiRange = contents.cfiFromRange(range); + + const SelectionReact = range.getBoundingClientRect(); + const viewRect = rendition.manager.container.getBoundingClientRect(); + + let react = { + left: `${ + viewRect.x + SelectionReact.x - (rendition.manager.scrollLeft || 0) + }px`, + top: `${viewRect.y + SelectionReact.y}px`, + width: `${SelectionReact.width}px`, + height: `${SelectionReact.height}px`, + }; + + fn('selected', react, text, cfiRange); + } catch (error) { + console.error('Error processing selection:', error); + } + }; + + // Mouse events for desktop + document.addEventListener('mousedown', clearSelection); document.addEventListener('mouseup', (e) => { - if (e.ignore) return - e.ignore = true + processSelection(e); + }); - const selection = document.getSelection() - const text = selection.toString() + // Only add touch events if we're on a touch device + if (isMobileDevice()) { + // Touch events for mobile + document.addEventListener('touchstart', (e) => { + touchStarted = true; + touchSelection = false; + touchStartTime = Date.now(); + + // Store start position for determining if it's a tap or selection attempt + if (e.touches && e.touches[0]) { + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + } + }, { passive: true }); - if (text === '') return - const range = selection.getRangeAt(0) + document.addEventListener('touchmove', (e) => { + if (!touchStarted) return; + + // Check if this might be a selection attempt (not just a tap) + if (e.touches && e.touches[0]) { + const moveX = Math.abs(e.touches[0].clientX - startX); + const moveY = Math.abs(e.touches[0].clientY - startY); + + // If user has moved finger more than threshold, might be trying to select + if (moveX > TOUCH_MOVE_THRESHOLD || moveY > TOUCH_MOVE_THRESHOLD) { + touchSelection = true; + } + } + }, { passive: true }); - const [contents] = rendition.getContents() - const cfiRange = contents.cfiFromRange(range) + document.addEventListener('touchend', (e) => { + if (!touchStarted) return; + touchStarted = false; + + const touchDuration = Date.now() - touchStartTime; + + // Check if it was a long press or a deliberate selection movement + if (touchDuration > LONG_PRESS_DURATION || touchSelection) { + // Delay processing to allow the browser to complete the selection + setTimeout(() => { + processSelection(e); + }, 50); + } else { + // It was a quick tap, clear selection + clearSelection(); + } + }); - const SelectionReact = range.getBoundingClientRect() - const viewRect = rendition.manager.container.getBoundingClientRect() - - let react = { - left: `${ - viewRect.x + SelectionReact.x - (rendition.manager.scrollLeft || 0) - }px`, - top: `${viewRect.y + SelectionReact.y}px`, - width: `${SelectionReact.width}px`, - height: `${SelectionReact.height}px`, + // Handle context menu long press on mobile + document.addEventListener('contextmenu', (e) => { + // This event fires on long-press on many mobile browsers + // Prevent the default context menu + if (isMobileDevice()) { + e.preventDefault(); + } + + // Delay to let the browser create the selection + setTimeout(() => { + processSelection(e); + }, 50); + }); + + // Add specific handling for iOS + if (isIOS()) { + // iOS sometimes needs additional help with text selection + document.addEventListener('selectionchange', () => { + // Only process if we're in a touch selection operation + if (touchSelection || (Date.now() - touchStartTime) > LONG_PRESS_DURATION) { + // Delay to let selection complete + setTimeout(() => { + const selection = document.getSelection(); + if (selection && selection.toString().length > 0) { + const e = { ignore: false }; + processSelection(e); + } + }, 100); + } + }); } - fn('selected', react, text, cfiRange) - }) -} + } +} \ No newline at end of file diff --git a/src/utils/listeners/touch.ts b/src/utils/listeners/touch.ts new file mode 100644 index 0000000..05cc44e --- /dev/null +++ b/src/utils/listeners/touch.ts @@ -0,0 +1,248 @@ +/** + * Unified touch handler that supports both swipe navigation and text selection + * @param {Document} document - The document to add event listeners to + * @param {Object} rendition - The EPUBJS rendition + * @param {function} navigationFn - Function to call for navigation (swipe) + * @param {function} selectionFn - Function to call for text selection + */ + +type epubEvent = TouchEvent & { ignore?: boolean }; +type Direction = 'next' | 'prev' | 'up' | 'down'; + +export default function touchHandler( + document: Document, + rendition: any, + navigationFn: (direction: Direction) => void, + selectionFn: (type: string, rect?: any, text?: string, cfiRange?: string) => void +) { + // State tracking + let touchState = { + startX: 0, + startY: 0, + startTime: 0, + isLongPress: false, + isSelectionAttempt: false, + lastTapTime: 0, + moveCount: 0, + lastMoveX: 0, + lastMoveY: 0 + }; + + // Constants for gesture detection + const SWIPE = { + threshold: 50, // Min distance for swipe + restraint: 200, // Max perpendicular movement + allowedTime: 500 // Max time for swipe + }; + + const SELECTION = { + longPressDuration: 500, // Time for long press + moveThreshold: 10, // Movement to trigger selection mode + selectionDelay: 50 // Delay to process selection after touch + }; + + // Clear selection + const clearSelection = () => { + if (document.getSelection) { + document.getSelection()?.removeAllRanges(); + } + selectionFn('cleared'); + }; + + // Process text selection + const processSelection = () => { + const selection = document.getSelection(); + if (!selection) return; + + const text = selection.toString(); + if (text === '') return; + + try { + const range = selection.getRangeAt(0); + const [contents] = rendition.getContents(); + const cfiRange = contents.cfiFromRange(range); + + const selectionRect = range.getBoundingClientRect(); + const viewRect = rendition.manager.container.getBoundingClientRect(); + + const rect = { + left: `${ + viewRect.x + selectionRect.x - (rendition.manager.scrollLeft || 0) + }px`, + top: `${viewRect.y + selectionRect.y}px`, + width: `${selectionRect.width}px`, + height: `${selectionRect.height}px`, + }; + + selectionFn('selected', rect, text, cfiRange); + } catch (error) { + console.error('Error processing selection:', error); + } + }; + + // Check if device is iOS + const isIOS = () => { + return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + }; + + // Device-specific adjustments + if (isIOS()) { + SELECTION.longPressDuration = 400; + SELECTION.selectionDelay = 100; + } + + // Touch start event + document.addEventListener('touchstart', (e: epubEvent) => { + if (e.ignore) return; + e.ignore = true; + + // Get initial touch position + if (e.touches && e.touches[0]) { + touchState.startX = e.touches[0].pageX; + touchState.startY = e.touches[0].pageY; + touchState.lastMoveX = touchState.startX; + touchState.lastMoveY = touchState.startY; + touchState.startTime = Date.now(); + touchState.isLongPress = false; + touchState.isSelectionAttempt = false; + touchState.moveCount = 0; + + // Handle long press with timeout + const longPressTimeout = setTimeout(() => { + // If finger hasn't moved much, trigger long press + const currentX = touchState.lastMoveX; + const currentY = touchState.lastMoveY; + const moveX = Math.abs(currentX - touchState.startX); + const moveY = Math.abs(currentY - touchState.startY); + + if (moveX < SELECTION.moveThreshold && moveY < SELECTION.moveThreshold) { + touchState.isLongPress = true; + } + }, SELECTION.longPressDuration); + + // Store the timeout in a property to clear it if needed + (document as any)._longPressTimeout = longPressTimeout; + } + }, { passive: true }); + + // Touch move event + document.addEventListener('touchmove', (e: epubEvent) => { + if (e.ignore) return; + e.ignore = true; + + if (e.touches && e.touches[0]) { + touchState.lastMoveX = e.touches[0].pageX; + touchState.lastMoveY = e.touches[0].pageY; + touchState.moveCount++; + + // Calculate movement + const moveX = Math.abs(touchState.lastMoveX - touchState.startX); + const moveY = Math.abs(touchState.lastMoveY - touchState.startY); + + // Determine if this might be a selection attempt + if (moveX > SELECTION.moveThreshold || moveY > SELECTION.moveThreshold) { + // Clear long press timeout if significant movement + if ((document as any)._longPressTimeout) { + clearTimeout((document as any)._longPressTimeout); + (document as any)._longPressTimeout = null; + } + + // If moved a lot horizontally relative to vertically, might be a swipe + // If moved a lot vertically or in a pattern, might be selection + if ((moveY > moveX * 1.5) || touchState.moveCount > 5) { + touchState.isSelectionAttempt = true; + } + } + } + }, { passive: true }); + + // Touch end event + document.addEventListener('touchend', (e: epubEvent) => { + if (e.ignore) return; + e.ignore = true; + + // Clear long press timeout if it's still active + if ((document as any)._longPressTimeout) { + clearTimeout((document as any)._longPressTimeout); + (document as any)._longPressTimeout = null; + } + + // Calculate touch stats + const touchEndTime = Date.now(); + const elapsedTime = touchEndTime - touchState.startTime; + + // Get distance traveled + const distX = e.changedTouches[0].pageX - touchState.startX; + const distY = e.changedTouches[0].pageY - touchState.startY; + + // Handle selection if it was a long press or selection attempt + if (touchState.isLongPress || touchState.isSelectionAttempt) { + // Delay slightly to let the browser finish selection + setTimeout(() => { + processSelection(); + }, SELECTION.selectionDelay); + return; + } + + // Handle swipe if it wasn't a selection attempt + if (elapsedTime <= SWIPE.allowedTime) { + // Horizontal swipe + if (Math.abs(distX) >= SWIPE.threshold && Math.abs(distY) <= SWIPE.restraint) { + // If dist traveled is negative, it indicates right swipe + navigationFn(distX < 0 ? 'next' : 'prev'); + } + // Vertical swipe + else if (Math.abs(distY) >= SWIPE.threshold && Math.abs(distX) <= SWIPE.restraint) { + // If dist traveled is negative, it indicates up swipe + navigationFn(distY < 0 ? 'up' : 'down'); + } + // Tap - convert to click for regular interaction + else { + clearSelection(); + + // Convert tap to click + document.dispatchEvent( + new MouseEvent('click', { + clientX: touchState.startX, + clientY: touchState.startY, + }) + ); + } + } + }, { passive: false }); + + // Handle context menu (long press on many mobile browsers) + document.addEventListener('contextmenu', (e) => { + // Only handle on mobile devices + if ('ontouchstart' in window) { + e.preventDefault(); + // Delay to let the browser create the selection + setTimeout(() => { + processSelection(); + }, SELECTION.selectionDelay); + } + }); + + // Add specific handling for iOS + if (isIOS()) { + // iOS sometimes needs additional help with text selection + document.addEventListener('selectionchange', () => { + // Only process if it might be from a touch operation + if (touchState.isLongPress || touchState.isSelectionAttempt) { + setTimeout(() => { + const selection = document.getSelection(); + if (selection && selection.toString().length > 0) { + processSelection(); + } + }, SELECTION.selectionDelay); + } + }); + } + + // Regular mouse events for desktop + document.addEventListener('mousedown', clearSelection); + + document.addEventListener('mouseup', () => { + processSelection(); + }); +} \ No newline at end of file