diff --git a/README.md b/README.md index 5152df5..76d495a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 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 59c6e1f..5e63f24 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 095afcd..ace4d5b 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; - gap: 0.5rem; + justify-content: space-between; margin-top: 0.5rem; } diff --git a/src/components/EpubView.vue b/src/components/EpubView.vue index 139838e..a3739a2 100644 --- a/src/components/EpubView.vue +++ b/src/components/EpubView.vue @@ -29,11 +29,10 @@ import ePub from 'epubjs' import type { Book, Rendition, Contents } from 'epubjs' import { clickListener, - swipListener, +// swipListener, wheelListener, keyListener, - selectListener, - touchListener + selectListener } from '../utils/listeners/listener' interface Props { @@ -41,7 +40,6 @@ 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'] @@ -56,7 +54,6 @@ const props = withDefaults(defineProps(), { const { tocChanged, getRendition, - handleTextSelected, handleKeyPress, toggleBubble, epubInitOptions, @@ -215,54 +212,38 @@ 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) } - - // 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); - // } - + //swipListener(iframe.document, flipPage) + keyListener(iframe.document, flipPage) + selectListener(iframe.document, rendition, toggleBubble) + // Mark first content as displayed for location restoration if (!loadingState.firstContentDisplayed) { - loadingState.firstContentDisplayed = true; + loadingState.firstContentDisplayed = true } - }); + }) - // Other event handlers remain the same - rendition.on('locationChanged', onLocationChange); + // Location change tracking + rendition.on('locationChanged', onLocationChange) - rendition.on('relocated', (location) => { - console.log('Book relocated to:', location); - }); + rendition.on('relocated', (location: any) => { + // console.log('Book relocated to:', location) + }) - rendition.on('displayError', (err) => { - console.error('Display error:', err); - }); + rendition.on('displayError', (err: any) => { + console.error('Display error:', err) + }) if (handleKeyPress) { - rendition.on('keypress', handleKeyPress); + rendition.on('keypress', handleKeyPress) } } -}; +} // Function to apply saved location const applyPendingLocation = () => { @@ -380,6 +361,7 @@ 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 8a651e2..b2a01a7 100644 --- a/src/components/StylesButton.vue +++ b/src/components/StylesButton.vue @@ -40,6 +40,7 @@ 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 new file mode 100644 index 0000000..ae9a25d --- /dev/null +++ b/src/composables/useAnnotations.ts @@ -0,0 +1,284 @@ +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 a8e0f74..a754f50 100644 --- a/src/utils/listeners/listener.ts +++ b/src/utils/listeners/listener.ts @@ -3,12 +3,10 @@ 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 383a3d3..bd5b22a 100644 --- a/src/utils/listeners/select.ts +++ b/src/utils/listeners/select.ts @@ -1,159 +1,38 @@ -// 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) { - // 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('mousedown', () => { + document.getSelection().removeAllRanges() + fn('cleared') + }) document.addEventListener('mouseup', (e) => { - processSelection(e); - }); + if (e.ignore) return + e.ignore = true - // 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 }); + const selection = document.getSelection() + const text = selection.toString() - 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 }); + if (text === '') return + const range = selection.getRangeAt(0) - 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 [contents] = rendition.getContents() + const cfiRange = contents.cfiFromRange(range) - // 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); - } - }); + 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) + }) } \ No newline at end of file diff --git a/src/utils/listeners/touch.ts b/src/utils/listeners/touch.ts deleted file mode 100644 index 05cc44e..0000000 --- a/src/utils/listeners/touch.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * 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 df3b496..6b3f4a9 100644 --- a/src/views/ReaderView.vue +++ b/src/views/ReaderView.vue @@ -24,7 +24,9 @@
{{ t("reader.loading") }}
@@ -104,9 +106,10 @@ 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 { Annotation, PendingAnnotation, AnnotationFormData } from "../types/annotations"; +import type { AnnotationFormData } from "../types/annotations"; // Import epub.js types import type Rendition from 'epubjs/types/rendition'; @@ -147,22 +150,6 @@ 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, @@ -177,6 +164,28 @@ 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 { @@ -203,7 +212,6 @@ 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); @@ -217,38 +225,6 @@ 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; @@ -276,10 +252,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); } - loadAnnotations(); + // Load annotations + loadAnnotations(bookId); } catch (err: unknown) { const errorMsg = err instanceof Error ? err.message : String(err); console.error("Error loading book:", err); @@ -291,10 +267,8 @@ const loadBook = async (): Promise => { // Handle location changes const locationChange = (epubcifi: string): void => { - // Skip saving the location on the first render to prevent - // overriding our saved location + // Skip saving the location on the first render if (!firstRenderDone.value) { - //console.log("## first render"); firstRenderDone.value = true; return; } @@ -311,19 +285,30 @@ const locationChange = (epubcifi: string): void => { } }; -const getRendition = (renditionObj: Rendition): void => { - setRendition(renditionObj); +const getRendition = (rendition: Rendition): void => { + setRendition(rendition); - renditionObj.on("relocated", (location: RelocatedEvent) => { + rendition.on("relocated", (location: RelocatedEvent) => { currentHref.value = location.start.href; }); - nextTick(() => { - applyAnnotationsToView(); + applyAnnotationsToView(); + + let annotationsApplied = true; + + rendition.on("rendered", async () => { + + if (!annotationsApplied) { + try { + annotationsApplied = true; + } catch (error) { + console.error("An error occurred while applying annotations:", error); + } + } }); // Get book metadata - const book: Book = renditionObj.book; + const book: Book = rendition.book; book.ready.then(() => { const meta = book.packaging?.metadata; if (!bookTitle.value && meta?.title) { @@ -333,194 +318,10 @@ const getRendition = (renditionObj: Rendition): void => { }); }; -// 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(); - } - } +// Wrapper for delete annotation to include bookId +const deleteAnnotationWrapper = (annotationId: string): void => { + const bookId = route.params.bookId as string; + deleteAnnotation(annotationId, bookId); }; // Toggle TOC panel @@ -533,11 +334,6 @@ const toggleToc = (): void => { } }; -// Toggle annotations panel -const toggleAnnotationsPanel = () => { - showAnnotationsPanel.value = !showAnnotationsPanel.value; -}; - // Handle key events const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -583,36 +379,8 @@ 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); }); @@ -653,9 +421,11 @@ defineExpose({ // Annotation related savedAnnotations, goToAnnotation, - editAnnotation, - deleteAnnotation, - toggleAnnotationsPanel + editAnnotation: editAnnotation, + deleteAnnotation: deleteAnnotationWrapper, + toggleAnnotationsPanel, + toggleSelectionBubble, + createAnnotationFromSelection }); @@ -702,6 +472,7 @@ defineExpose({ top: 6px; left: 6px; z-index: 10; + touch-action: manipulation; } .toc-button-bar { @@ -739,6 +510,7 @@ defineExpose({ top: 10px; white-space: nowrap; z-index: 5; + touch-action: manipulation; } .reader-view {