diff --git a/README.md b/README.md index 76d495a..5152df5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # E-inn -[Pre-release](https://jroshthen1.github.io/e-inn-reader/) - E-inn is a basic EPUB reader supporting remote API imports and local file uploads. Books are stored in IndexedDB; settings and reading progress are retained via localStorage. Built as a single-page application with Vue.js, Vue Router, and epub.js for rendering. No accounts, sync, or advanced features, serves static assets. Minimal UI, lightweight, customizable interface. ## Running diff --git a/src/components/AnnotationModal.vue b/src/components/AnnotationModal.vue index 5e63f24..59c6e1f 100644 --- a/src/components/AnnotationModal.vue +++ b/src/components/AnnotationModal.vue @@ -48,7 +48,7 @@ - \ No newline at end of file diff --git a/src/components/AnnotationsPanel.vue b/src/components/AnnotationsPanel.vue index ace4d5b..095afcd 100644 --- a/src/components/AnnotationsPanel.vue +++ b/src/components/AnnotationsPanel.vue @@ -166,7 +166,7 @@ const truncateText = (text: string, maxLength: number): string => { .annotation-actions { display: flex; - justify-content: space-between; + gap: 0.5rem; margin-top: 0.5rem; } diff --git a/src/components/EpubView.vue b/src/components/EpubView.vue index a3739a2..139838e 100644 --- a/src/components/EpubView.vue +++ b/src/components/EpubView.vue @@ -29,10 +29,11 @@ import ePub from 'epubjs' import type { Book, Rendition, Contents } from 'epubjs' import { clickListener, -// swipListener, + swipListener, wheelListener, keyListener, - selectListener + selectListener, + touchListener } from '../utils/listeners/listener' interface Props { @@ -40,6 +41,7 @@ interface Props { location?: any // Current Page number | string | Rendition['location']['start'] tocChanged?: (toc: Book['navigation']['toc']) => void getRendition?: (rendition: Rendition) => void + handleTextSelected?: (cfiRange: string, contents: Contents) => void handleKeyPress?: () => void toggleBubble?: (type: string, rect?: any, text?: string, cfiRange?: string) => void // For custom selection epubInitOptions?: Book['settings'] @@ -54,6 +56,7 @@ const props = withDefaults(defineProps(), { const { tocChanged, getRendition, + handleTextSelected, handleKeyPress, toggleBubble, epubInitOptions, @@ -212,38 +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) - selectListener(iframe.document, rendition, toggleBubble) - + + // 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 = () => { @@ -361,7 +380,6 @@ defineExpose({ user-select: none; appearance: none; font-weight: bold; - touch-action: manipulation; } .arrow:hover { diff --git a/src/components/StylesButton.vue b/src/components/StylesButton.vue index b2a01a7..8a651e2 100644 --- a/src/components/StylesButton.vue +++ b/src/components/StylesButton.vue @@ -40,7 +40,6 @@ export default { position: absolute; top: 6px; right: 6px; - touch-action: manipulation; } .styles-button > div { diff --git a/src/composables/useAnnotations.ts b/src/composables/useAnnotations.ts deleted file mode 100644 index ae9a25d..0000000 --- a/src/composables/useAnnotations.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { ref, type Ref, nextTick } from 'vue'; -import type { Annotation, PendingAnnotation, AnnotationFormData } from '../types/annotations'; -import type Rendition from 'epubjs/types/rendition'; - -export function useAnnotations( - rendition: Ref, - currentHref: Ref, - accentColor: Ref -) { - const savedAnnotations = ref([]); - const pendingAnnotation = ref(null); - const showAnnotationModal = ref(false); - const showAnnotationsPanel = ref(false); - const annotationName = ref(''); - const annotationNote = ref(''); - const editingAnnotation = ref(null); - const currentBookId = ref(''); - const hasTextSelection = ref(false); - - // Device detection - const isMobileDevice = /iPad|iPhone|iPod|Android/i.test(navigator.userAgent); - - // Storage key helper - const getAnnotationStorageKey = (bookId: string): string => { - return `epub-annotations-${bookId}`; - }; - - // Load annotations from storage - const loadAnnotations = (bookId: string): void => { - try { - currentBookId.value = bookId; - const storageKey = getAnnotationStorageKey(bookId); - const stored = localStorage.getItem(storageKey); - - if (stored) { - const parsedAnnotations: Annotation[] = JSON.parse(stored); - savedAnnotations.value = parsedAnnotations.sort((a, b) => b.createdAt - a.createdAt); - } else { - savedAnnotations.value = []; - } - } catch (error) { - console.error('Error loading annotations:', error); - savedAnnotations.value = []; - } - }; - - const saveAnnotationsToStorage = (bookId: string): void => { - try { - const storageKey = getAnnotationStorageKey(bookId); - localStorage.setItem(storageKey, JSON.stringify(savedAnnotations.value)); - } catch (error) { - console.error('Error saving annotations:', error); - } - }; - - const generateAnnotationId = (): string => { - return `annotation-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - }; - - // Apply annotations to view - const applyAnnotationsToView = async (): Promise => { - if (!rendition.value || savedAnnotations.value.length === 0) return; - - try { - await nextTick(); - - setTimeout(() => { - savedAnnotations.value.forEach(annotation => { - try { - rendition.value?.annotations.highlight( - annotation.cfiRange, - { - id: annotation.id, - name: annotation.name, - note: annotation.note - }, - undefined, - 'saved-annotation', - { - fill: accentColor.value, - 'fill-opacity': '0.4', - 'mix-blend-mode': 'multiply', - stroke: accentColor.value, - 'stroke-width': '1px' - } - ); - } catch (error) { - console.warn('Failed to apply annotation:', annotation.id, error); - } - }); - }, 1000); - } catch (error) { - console.error('Error applying annotations:', error); - } - }; - - // Simple toggle selection bubble - const toggleSelectionBubble = (type: string, rect: any, text: string, cfiRange: string): void => { - if (type === 'selected' && text && text.length > 0) { - hasTextSelection.value = true; - - pendingAnnotation.value = { - cfiRange, - text, - contents: rendition.value?.getContents() || null - }; - - // Reset form fields - annotationName.value = ''; - annotationNote.value = ''; - editingAnnotation.value = null; - - } else if (type === 'cleared') { - hasTextSelection.value = false; - pendingAnnotation.value = null; - } - }; - - const createAnnotationFromSelection = (): void => { - console.log('Creating annotation from selection...'); - if (pendingAnnotation.value) { - showAnnotationModal.value = true; - } else { - console.warn('No pending annotation to create'); - } - }; - - // Handle annotation save from modal - const handleAnnotationSave = (formData?: AnnotationFormData): void => { - const bookId = currentBookId.value; - - if (!pendingAnnotation.value || !bookId || !formData) { - console.error('Missing data for annotation save:', { - pendingAnnotation: !!pendingAnnotation.value, - bookId: !!bookId, - formData: !!formData - }); - return; - } - - try { - const now = Date.now(); - - if (editingAnnotation.value) { - // Update existing annotation - const index = savedAnnotations.value.findIndex(a => a.id === editingAnnotation.value!.id); - if (index !== -1) { - savedAnnotations.value[index] = { - ...editingAnnotation.value, - name: formData.name, - note: formData.note || undefined, - updatedAt: now - }; - console.log('Updated existing annotation'); - } - } else { - // Create new annotation - const annotation: Annotation = { - id: generateAnnotationId(), - bookId: bookId, - cfiRange: pendingAnnotation.value.cfiRange, - text: pendingAnnotation.value.text, - name: formData.name, - note: formData.note || undefined, - createdAt: now, - updatedAt: now, - chapter: currentHref.value?.toString() - }; - - savedAnnotations.value.unshift(annotation); - console.log('Created new annotation:', annotation.name); - - // Apply visual highlight only if it's a real CFI - try { - rendition.value?.annotations.highlight( - annotation.cfiRange, - { - id: annotation.id, - name: annotation.name, - note: annotation.note - }, - undefined, - 'saved-annotation', - { - fill: accentColor.value, - 'fill-opacity': '0.4', - 'mix-blend-mode': 'multiply', - stroke: accentColor.value, - 'stroke-width': '1px' - } - ); - } catch (error) { - console.warn('Could not apply highlight immediately:', error); - } - } - - saveAnnotationsToStorage(bookId); - closeAnnotationModal(); - - // Show annotations panel after creating new annotation - if (!editingAnnotation.value) { - showAnnotationsPanel.value = true; - } - } catch (error) { - console.error('Error saving annotation:', error); - } - }; - - const closeAnnotationModal = (): void => { - showAnnotationModal.value = false; - pendingAnnotation.value = null; - annotationName.value = ''; - annotationNote.value = ''; - editingAnnotation.value = null; - hasTextSelection.value = false; - }; - - const goToAnnotation = (cfiRange: string): void => { - if (rendition.value) { - rendition.value.display(cfiRange); - } - }; - - const editAnnotation = (annotation: Annotation): void => { - editingAnnotation.value = annotation; - annotationName.value = annotation.name; - annotationNote.value = annotation.note || ''; - - pendingAnnotation.value = { - cfiRange: annotation.cfiRange, - text: annotation.text, - contents: null as any - }; - - showAnnotationModal.value = true; - }; - - const deleteAnnotation = (annotationId: string, bookId?: string): void => { - const actualBookId = bookId || currentBookId.value; - - if (confirm('Are you sure you want to delete this annotation?')) { - const index = savedAnnotations.value.findIndex(a => a.id === annotationId); - if (index !== -1) { - const annotation = savedAnnotations.value[index]; - - try { - rendition.value?.annotations.remove(annotation.cfiRange, 'saved-annotation'); - } catch (error) { - console.warn('Could not remove highlight:', error); - } - - savedAnnotations.value.splice(index, 1); - saveAnnotationsToStorage(actualBookId); - console.log('Deleted annotation:', annotation.name); - } - } - }; - - const toggleAnnotationsPanel = (): void => { - showAnnotationsPanel.value = !showAnnotationsPanel.value; - }; - - return { - savedAnnotations, - pendingAnnotation, - showAnnotationModal, - showAnnotationsPanel, - annotationName, - annotationNote, - editingAnnotation, - hasTextSelection, - - loadAnnotations, - applyAnnotationsToView, - toggleSelectionBubble, - createAnnotationFromSelection, - handleAnnotationSave, - closeAnnotationModal, - goToAnnotation, - editAnnotation, - deleteAnnotation, - toggleAnnotationsPanel, - }; -} \ No newline at end of file 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 bd5b22a..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} 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 diff --git a/src/views/ReaderView.vue b/src/views/ReaderView.vue index 6b3f4a9..df3b496 100644 --- a/src/views/ReaderView.vue +++ b/src/views/ReaderView.vue @@ -24,9 +24,7 @@
{{ t("reader.loading") }}
@@ -106,10 +104,9 @@ import AnnotationModal from "../components/AnnotationModal.vue"; import AnnotationsButton from "../components/AnnotationsButton.vue"; import TocComponent from "../components/TocComponent.vue"; import { useStyles } from "../composables/useStyles"; -import { useAnnotations } from "../composables/useAnnotations"; import { loadBookFromIndexedDB } from "../utils/utils"; import type { EpubFile } from "../types/epubFile"; -import type { AnnotationFormData } from "../types/annotations"; +import type { Annotation, PendingAnnotation, AnnotationFormData } from "../types/annotations"; // Import epub.js types import type Rendition from 'epubjs/types/rendition'; @@ -150,6 +147,22 @@ const showToc = ref(true); const epubRef = ref | null>(null); const currentHref = ref(null); +const selectionBubble = reactive({ + visible: false, + position: { left: '0px', top: '0px', width: '0px', height: '0px' }, + selectedText: '', + cfiRange: '' +}); + +// Annotation state +const savedAnnotations = ref([]); +const pendingAnnotation = ref(null); +const showAnnotationModal = ref(false); +const showAnnotationsPanel = ref(false); +const annotationName = ref(''); +const annotationNote = ref(''); +const editingAnnotation = ref(null); + // TOC related state const bookState = reactive({ toc: [] as Array, @@ -164,28 +177,6 @@ const { toggleStylesModal, rendition, setRendition, } = useStyles(); -// Annotations composable -const { - savedAnnotations, - pendingAnnotation, - showAnnotationModal, - showAnnotationsPanel, - annotationName, - annotationNote, - editingAnnotation, - hasTextSelection, - loadAnnotations, - applyAnnotationsToView, - toggleSelectionBubble, - handleAnnotationSave, - closeAnnotationModal, - goToAnnotation, - editAnnotation, - deleteAnnotation, - toggleAnnotationsPanel, - createAnnotationFromSelection, -} = useAnnotations(rendition, currentHref, accentColor); - const BookProgressManager = { saveProgress(bookId: string, cfi: string, extraData = {}) { try { @@ -212,6 +203,7 @@ const BookProgressManager = { if (!data) return null; const parsed = JSON.parse(data); + //console.log(`Progress loaded for book ${bookId}:`, parsed); return parsed; } catch (error) { console.error('Error loading book progress:', error); @@ -225,6 +217,38 @@ const BookProgressManager = { } }; +// The custom selection bubble toggle function to pass to EpubView +const toggleSelectionBubble = (type, rect, text, cfiRange) => { + if (type === 'selected' && text && text.length > 0) { + selectionBubble.visible = true; + selectionBubble.position = rect; + selectionBubble.selectedText = text; + selectionBubble.cfiRange = cfiRange; + + // Create pending annotation to be used when the user wants to save + pendingAnnotation.value = { + cfiRange, + text, + contents: rendition.value.getContents()[0] + }; + + // Show annotation modal directly + showAnnotationModal.value = true; + + // Clear any selection after capturing it + if (rendition.value) { + const contents = rendition.value.getContents(); + contents.forEach(content => { + if (content.window && content.window.getSelection) { + content.window.getSelection()?.removeAllRanges(); + } + }); + } + } else if (type === 'cleared') { + selectionBubble.visible = false; + } +}; + const loadBook = async (): Promise => { loading.value = true; error.value = null; @@ -252,10 +276,10 @@ const loadBook = async (): Promise => { const progress = BookProgressManager.loadProgress(bookId); if (progress && progress.cfi) { location.value = progress.cfi; + //console.log("Setting initial location from localStorage:", location.value); } - // Load annotations - loadAnnotations(bookId); + loadAnnotations(); } catch (err: unknown) { const errorMsg = err instanceof Error ? err.message : String(err); console.error("Error loading book:", err); @@ -267,8 +291,10 @@ const loadBook = async (): Promise => { // Handle location changes const locationChange = (epubcifi: string): void => { - // Skip saving the location on the first render + // Skip saving the location on the first render to prevent + // overriding our saved location if (!firstRenderDone.value) { + //console.log("## first render"); firstRenderDone.value = true; return; } @@ -285,30 +311,19 @@ const locationChange = (epubcifi: string): void => { } }; -const getRendition = (rendition: Rendition): void => { - setRendition(rendition); +const getRendition = (renditionObj: Rendition): void => { + setRendition(renditionObj); - rendition.on("relocated", (location: RelocatedEvent) => { + renditionObj.on("relocated", (location: RelocatedEvent) => { currentHref.value = location.start.href; }); - applyAnnotationsToView(); - - let annotationsApplied = true; - - rendition.on("rendered", async () => { - - if (!annotationsApplied) { - try { - annotationsApplied = true; - } catch (error) { - console.error("An error occurred while applying annotations:", error); - } - } + nextTick(() => { + applyAnnotationsToView(); }); // Get book metadata - const book: Book = rendition.book; + const book: Book = renditionObj.book; book.ready.then(() => { const meta = book.packaging?.metadata; if (!bookTitle.value && meta?.title) { @@ -318,10 +333,194 @@ const getRendition = (rendition: Rendition): void => { }); }; -// Wrapper for delete annotation to include bookId -const deleteAnnotationWrapper = (annotationId: string): void => { - const bookId = route.params.bookId as string; - deleteAnnotation(annotationId, bookId); +// Annotation storage functions +const getAnnotationStorageKey = (bookId: string): string => { + return `epub-annotations-${bookId}`; +}; + +const loadAnnotations = (): void => { + try { + const bookId = route.params.bookId as string; + const storageKey = getAnnotationStorageKey(bookId); + const stored = localStorage.getItem(storageKey); + + if (stored) { + const parsedAnnotations: Annotation[] = JSON.parse(stored); + savedAnnotations.value = parsedAnnotations.sort((a, b) => b.createdAt - a.createdAt); + } + } catch (error) { + console.error('Error loading annotations:', error); + savedAnnotations.value = []; + } +}; + +const saveAnnotationsToStorage = (): void => { + try { + const bookId = route.params.bookId as string; + const storageKey = getAnnotationStorageKey(bookId); + localStorage.setItem(storageKey, JSON.stringify(savedAnnotations.value)); + } catch (error) { + console.error('Error saving annotations:', error); + } +}; + +// Apply annotations to view +const applyAnnotationsToView = async (): Promise => { + if (!rendition.value || savedAnnotations.value.length === 0) return; + + try { + await nextTick(); + setTimeout(() => { + savedAnnotations.value.forEach(annotation => { + try { + rendition.value?.annotations.highlight( + annotation.cfiRange, + { + id: annotation.id, + name: annotation.name, + note: annotation.note + }, + undefined, + 'saved-annotation', + { + fill: accentColor.value, + 'fill-opacity': '0.4', + 'mix-blend-mode': 'multiply', + stroke: accentColor.value, + 'stroke-width': '1px' + } + ); + } catch (error) { + console.warn('Failed to apply annotation:', annotation.id, error); + } + }); + }, 500); + } catch (error) { + console.error('Error applying annotations:', error); + } +}; + +// Handle annotation save from modal +const handleAnnotationSave = (formData: AnnotationFormData): void => { + if (!pendingAnnotation.value) return; + + try { + const bookId = route.params.bookId as string; + const now = Date.now(); + + if (editingAnnotation.value) { + // Update existing annotation + const index = savedAnnotations.value.findIndex(a => a.id === editingAnnotation.value!.id); + if (index !== -1) { + savedAnnotations.value[index] = { + ...editingAnnotation.value, + name: formData.name, + note: formData.note || undefined, + updatedAt: now + }; + } + } else { + // Create new annotation + const annotation: Annotation = { + id: generateAnnotationId(), + bookId, + cfiRange: pendingAnnotation.value.cfiRange, + text: pendingAnnotation.value.text, + name: formData.name, + note: formData.note || undefined, + createdAt: now, + updatedAt: now, + chapter: currentHref.value?.toString() + }; + + savedAnnotations.value.unshift(annotation); + + // Add visual highlight + rendition.value?.annotations.highlight( + annotation.cfiRange, + { + id: annotation.id, + name: annotation.name, + note: annotation.note + }, + undefined, + 'saved-annotation', + { + fill: accentColor.value, + 'fill-opacity': '0.4', + 'mix-blend-mode': 'multiply', + stroke: accentColor.value, + 'stroke-width': '1px' + } + ); + } + + saveAnnotationsToStorage(); + closeAnnotationModal(); + + // Show the annotations panel after creating a new annotation + if (!editingAnnotation.value) { + showAnnotationsPanel.value = true; + } + } catch (error) { + console.error('Error saving annotation:', error); + } +}; + +// Close annotation modal +const closeAnnotationModal = (): void => { + showAnnotationModal.value = false; + pendingAnnotation.value = null; + annotationName.value = ''; + annotationNote.value = ''; + editingAnnotation.value = null; +}; + +// Generate annotation ID +const generateAnnotationId = (): string => { + return `annotation-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +}; + +// Go to annotation +const goToAnnotation = (cfiRange: string): void => { + if (rendition.value) { + rendition.value.display(cfiRange); + } +}; + +// Edit annotation +const editAnnotation = (annotation: Annotation): void => { + editingAnnotation.value = annotation; + annotationName.value = annotation.name; + annotationNote.value = annotation.note || ''; + + // Need to set a dummy pending annotation to make the modal work + pendingAnnotation.value = { + cfiRange: annotation.cfiRange, + text: annotation.text, + contents: null as any // This is fine as we're just editing + }; + + showAnnotationModal.value = true; +}; + +// Delete annotation +const deleteAnnotation = (annotationId: string): void => { + if (confirm('Are you sure you want to delete this annotation?')) { + const index = savedAnnotations.value.findIndex(a => a.id === annotationId); + if (index !== -1) { + const annotation = savedAnnotations.value[index]; + + try { + rendition.value?.annotations.remove(annotation.cfiRange, 'highlight'); + } catch (error) { + console.warn('Could not remove highlight:', error); + } + + savedAnnotations.value.splice(index, 1); + saveAnnotationsToStorage(); + } + } }; // Toggle TOC panel @@ -334,6 +533,11 @@ const toggleToc = (): void => { } }; +// Toggle annotations panel +const toggleAnnotationsPanel = () => { + showAnnotationsPanel.value = !showAnnotationsPanel.value; +}; + // Handle key events const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -379,8 +583,36 @@ const setLocation = ( expandedToc.value = !close; }; +const debugStoredLocation = () => { + const bookId = route.params.bookId as string; + const progressKey = `book-progress-${bookId}`; + const savedLocation = localStorage.getItem(progressKey); + + console.log('================ DEBUG INFO ================'); + console.log('Book ID:', bookId); + console.log('Progress key:', progressKey); + console.log('Saved location in localStorage:', savedLocation); + + // Check if the location format is valid + if (savedLocation) { + try { + const parsed = JSON.parse(savedLocation); + console.log('Parsed location:', parsed); + console.log('Is valid CFI format:', typeof parsed.cfi === 'string' && parsed.cfi.includes('epubcfi')); + } catch (e) { + console.log('Raw location string:', savedLocation); + console.log('Is valid CFI format:', savedLocation.includes('epubcfi')); + } + } + + console.log('========================================='); +}; + onMounted(() => { + // debugStoredLocation(); loadBook(); + + // Add keyboard shortcuts window.addEventListener('keydown', handleKeyDown); }); @@ -421,11 +653,9 @@ defineExpose({ // Annotation related savedAnnotations, goToAnnotation, - editAnnotation: editAnnotation, - deleteAnnotation: deleteAnnotationWrapper, - toggleAnnotationsPanel, - toggleSelectionBubble, - createAnnotationFromSelection + editAnnotation, + deleteAnnotation, + toggleAnnotationsPanel }); @@ -472,7 +702,6 @@ defineExpose({ top: 6px; left: 6px; z-index: 10; - touch-action: manipulation; } .toc-button-bar { @@ -510,7 +739,6 @@ defineExpose({ top: 10px; white-space: nowrap; z-index: 5; - touch-action: manipulation; } .reader-view {