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 7a885c2..a3739a2 100644 --- a/src/components/EpubView.vue +++ b/src/components/EpubView.vue @@ -29,7 +29,7 @@ import ePub from 'epubjs' import type { Book, Rendition, Contents } from 'epubjs' import { clickListener, - swipListener, +// swipListener, wheelListener, keyListener, selectListener @@ -40,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'] @@ -55,7 +54,6 @@ const props = withDefaults(defineProps(), { const { tocChanged, getRendition, - handleTextSelected, handleKeyPress, toggleBubble, epubInitOptions, @@ -220,17 +218,10 @@ const registerEvents = () => { if (!epubOptions?.flow?.includes('scrolled')) { 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) - } - + //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 @@ -370,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/select.ts b/src/utils/listeners/select.ts index e8545a3..bd5b22a 100644 --- a/src/utils/listeners/select.ts +++ b/src/utils/listeners/select.ts @@ -1,7 +1,7 @@ /** * @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', () => { @@ -35,4 +35,4 @@ export default function selectListener(document, rendition, fn) { } fn('selected', react, text, cfiRange) }) -} +} \ 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 {