diff --git a/src/components/AnnotationModal.vue b/src/components/AnnotationModal.vue new file mode 100644 index 0000000..59c6e1f --- /dev/null +++ b/src/components/AnnotationModal.vue @@ -0,0 +1,225 @@ + + + + + {{ isEditing ? t("settings.update") : t("settings.add") }} {{t("reader.annotation")}} + + {{ t("reader.selectedText") }} + "{{ selectedText }}" + + + + {{ t("reader.nameAnnotation") }} + + {{ t("reader.namelessAnnotation") }} + + + {{ t("reader.noteAnnotation") }} + + + + + {{ name.trim() ? t("settings.cancel") : t("settings.discard") }} + + + {{ isEditing ? t("settings.update") : t("settings.save") }} + + + + + + + + + + \ No newline at end of file diff --git a/src/components/AnnotationsButton.vue b/src/components/AnnotationsButton.vue new file mode 100644 index 0000000..869b713 --- /dev/null +++ b/src/components/AnnotationsButton.vue @@ -0,0 +1,44 @@ + + + + {{ count }} + + + + + + \ No newline at end of file diff --git a/src/components/AnnotationsPanel.vue b/src/components/AnnotationsPanel.vue new file mode 100644 index 0000000..095afcd --- /dev/null +++ b/src/components/AnnotationsPanel.vue @@ -0,0 +1,206 @@ + + + + + {{ t('reader.annotations') }} ({{ annotations.length }}) + × + + + + {{ t('reader.emptyAnnotationList')}} + + + + + {{ annotation.name }} + {{ formatDate(annotation.createdAt) }} + + {{ truncateText(annotation.text, 100) }} + + {{ t('settings.edit') }} + {{ t('settings.delete') }} + + + + + + + + + \ No newline at end of file diff --git a/src/components/BookSettingsModal.vue b/src/components/BookSettingsModal.vue index 8b3df1b..7c54810 100644 --- a/src/components/BookSettingsModal.vue +++ b/src/components/BookSettingsModal.vue @@ -48,14 +48,14 @@ @click="confirmDelete" > - {{ t('settings.deleteBook') }} + {{ t('settings.delete') }} - {{ t('settings.saveChanges') }} + {{ t('settings.save') }} diff --git a/src/composables/useStyles.ts b/src/composables/useStyles.ts index 975278f..7360e06 100644 --- a/src/composables/useStyles.ts +++ b/src/composables/useStyles.ts @@ -1,6 +1,7 @@ // src/composables/useStyles.ts import { ref, watch, nextTick } from 'vue'; -import { type RenditionTheme, type StylesOptions } from '../types/styles'; +import { type StylesOptions } from '../types/styles'; +import type Rendition from 'epubjs/types/rendition'; export function useStyles(options: StylesOptions = {}) { // Initialize style refs with defaults or provided values @@ -10,36 +11,45 @@ export function useStyles(options: StylesOptions = {}) { const fontFamily = ref(options.initialFontFamily || 'Arial, sans-serif'); const fontSize = ref(options.initialFontSize || '100%'); const stylesModalOpen = ref(false); - const rendition = ref(null); + const rendition = ref(null); // Track if hooks are registered to avoid duplicate registration let hooksRegistered = false; + let renderedEventListener: ((section: any, view: any) => void) | null = null; // Local storage management const loadSavedStyles = () => { - const savedStyles = { - text: localStorage.getItem('reader-text-color'), - background: localStorage.getItem('reader-background-color'), - accent: localStorage.getItem('accent-color'), - fontFamily: localStorage.getItem('reader-font-family'), - fontSize: localStorage.getItem('reader-font-size') - }; - - if (savedStyles.text) textColor.value = savedStyles.text; - if (savedStyles.background) backgroundColor.value = savedStyles.background; - if (savedStyles.accent) accentColor.value = savedStyles.accent; - if (savedStyles.fontFamily) fontFamily.value = savedStyles.fontFamily; - if (savedStyles.fontSize) fontSize.value = savedStyles.fontSize; - - applyStylesToDocument(); + try { + const savedStyles = { + text: localStorage.getItem('reader-text-color'), + background: localStorage.getItem('reader-background-color'), + accent: localStorage.getItem('accent-color'), + fontFamily: localStorage.getItem('reader-font-family'), + fontSize: localStorage.getItem('reader-font-size') + }; + + if (savedStyles.text) textColor.value = savedStyles.text; + if (savedStyles.background) backgroundColor.value = savedStyles.background; + if (savedStyles.accent) accentColor.value = savedStyles.accent; + if (savedStyles.fontFamily) fontFamily.value = savedStyles.fontFamily; + if (savedStyles.fontSize) fontSize.value = savedStyles.fontSize; + + applyStylesToDocument(); + } catch (error) { + console.error('Error loading saved styles:', error); + } }; const saveStyles = () => { - localStorage.setItem('reader-text-color', textColor.value); - localStorage.setItem('reader-background-color', backgroundColor.value); - localStorage.setItem('accent-color', accentColor.value); - localStorage.setItem('reader-font-family', fontFamily.value); - localStorage.setItem('reader-font-size', fontSize.value); + try { + localStorage.setItem('reader-text-color', textColor.value); + localStorage.setItem('reader-background-color', backgroundColor.value); + localStorage.setItem('accent-color', accentColor.value); + localStorage.setItem('reader-font-family', fontFamily.value); + localStorage.setItem('reader-font-size', fontSize.value); + } catch (error) { + console.error('Error saving styles:', error); + } }; const applyStylesToDocument = () => { @@ -121,39 +131,56 @@ export function useStyles(options: StylesOptions = {}) { color: ${textColor.value} !important; font-family: ${fontFamily.value} !important; } + ::selection { + background-color: ${accentColor.value}4D; + color: inherit; + } + + ::-moz-selection { + background-color: ${accentColor.value}4D; + color: inherit; + } `; head.appendChild(themeStyle); }; - // Apply styles to all currently loaded content - const applyStylesToAllContent = () => { + // Apply styles to all currently loaded content - IMPROVED VERSION + const applyStylesToAllContent = async () => { if (!rendition.value) return; try { - // Get all iframes (epub.js uses iframes for content) - const iframes = rendition.value.manager?.container?.querySelectorAll('iframe'); - - if (iframes) { - iframes.forEach((iframe: HTMLIFrameElement) => { - try { - const doc = iframe.contentDocument || iframe.contentWindow?.document; - if (doc) { - applyStylesToContent(doc); + // Method 1: Use getContents() API (most reliable) + const contents = rendition.value.getContents(); + if (contents) { + // Handle both single Contents object and array of Contents + const contentsArray = Array.isArray(contents) ? contents : [contents]; + + await Promise.all( + contentsArray.map(async (content: any) => { + if (content && content.document) { + await nextTick(); // Ensure DOM is ready + applyStylesToContent(content.document); } - } catch (error) { - console.warn('Could not access iframe content:', error); - } - }); + }) + ); } - // Also try to get content through epub.js API - if (rendition.value.getContents) { - const contents = rendition.value.getContents(); - contents.forEach((content: any) => { - if (content.document) { - applyStylesToContent(content.document); - } - }); + // Method 2: Use views() as fallback + const views = rendition.value.views(); + if (views && Array.isArray(views)) { + await Promise.all( + views.map(async (view: any) => { + try { + const doc = view.document || view.iframe?.contentDocument; + if (doc) { + await nextTick(); + applyStylesToContent(doc); + } + } catch (error) { + console.warn('Could not access view content:', error); + } + }) + ); } } catch (error) { @@ -161,12 +188,56 @@ export function useStyles(options: StylesOptions = {}) { } }; + // Setup event listeners for automatic style application + const setupEventListeners = () => { + if (!rendition.value || renderedEventListener) return; + + try { + // Create event listener function + renderedEventListener = async (section: any, view: any) => { + try { + if (view && view.document) { + await nextTick(); + applyStylesToContent(view.document); + } + } catch (error) { + console.warn('Could not apply styles to rendered content:', error); + } + }; + + // Register event listener + rendition.value.on('rendered', renderedEventListener); + + // Also listen for display events + rendition.value.on('displayed', async (section: any) => { + await nextTick(); + await applyStylesToAllContent(); + }); + + } catch (error) { + console.error('Error setting up event listeners:', error); + } + }; + + // Remove event listeners when cleaning up + const removeEventListeners = () => { + if (!rendition.value || !renderedEventListener) return; + + try { + rendition.value.off('rendered', renderedEventListener); + renderedEventListener = null; + } catch (error) { + console.error('Error removing event listeners:', error); + } + }; + const registerContentHooks = () => { if (!rendition.value || hooksRegistered) return; try { - rendition.value.hooks.content.register((contents: any) => { + rendition.value.hooks.content.register(async (contents: any) => { if (contents.document) { + await nextTick(); applyStylesToContent(contents.document); } }); @@ -181,30 +252,37 @@ export function useStyles(options: StylesOptions = {}) { if (!rendition.value) return; try { - if (rendition.value.themes) { - const { themes } = rendition.value; - + const { themes } = rendition.value; + + if (themes) { + // Apply theme overrides themes.override('color', textColor.value); themes.override('background', backgroundColor.value); themes.override('font-family', fontFamily.value); - if (themes.fontSize) { + // Use fontSize method if available, otherwise use override + if (typeof themes.fontSize === 'function') { themes.fontSize(fontSize.value); } else { themes.override('font-size', fontSize.value); } } + // Wait for next tick to ensure themes are applied await nextTick(); - applyStylesToAllContent(); + + // Apply styles to all current content + await applyStylesToAllContent(); + + // Setup hooks and event listeners for future content registerContentHooks(); + setupEventListeners(); } catch (error) { console.error('Error applying styles to reader:', error); } }; - // Update status bar meta tags const setMeta = (name: string, content: string) => { let tag = document.querySelector(`meta[name="${name}"]`) as HTMLMetaElement || document.createElement('meta'); @@ -213,18 +291,36 @@ export function useStyles(options: StylesOptions = {}) { if (!tag.parentNode) document.head.appendChild(tag); }; - // Rendition setup (enhanced) - const setRendition = async (renditionObj: RenditionTheme): Promise => { + // Rendition setup - IMPROVED VERSION + const setRendition = async (renditionObj: Rendition): Promise => { + // Clean up previous rendition + if (rendition.value) { + removeEventListeners(); + hooksRegistered = false; + } + rendition.value = renditionObj; hooksRegistered = false; // Reset hook registration flag - // Wait for rendition to be ready - if (renditionObj.display) { - await renditionObj.display(); + try { + // Wait for rendition to be ready if it has a started promise + if (renditionObj.started) { + await renditionObj.started; + } + + // Wait for initial display if display method exists + if (renditionObj.display) { + await renditionObj.display(); + } + + // Apply styles after everything is ready + await applyStylesToReader(); + + } catch (error) { + console.error('Error setting up rendition:', error); + // Still try to apply styles even if setup partially failed + await applyStylesToReader(); } - - // Apply styles after display - await applyStylesToReader(); }; const toggleStylesModal = () => { @@ -233,13 +329,17 @@ export function useStyles(options: StylesOptions = {}) { // Force refresh all styles (useful for debugging or manual refresh) const refreshStyles = async () => { - await nextTick(); - applyStylesToDocument(); - await applyStylesToReader(); + try { + await nextTick(); + applyStylesToDocument(); + await applyStylesToReader(); + } catch (error) { + console.error('Error refreshing styles:', error); + } }; - // Watch for style changes with debouncing - let styleUpdateTimeout: NodeJS.Timeout | null = null; + // Watch for style changes with debouncing - IMPROVED VERSION + let styleUpdateTimeout: ReturnType | null = null; watch( [textColor, backgroundColor, accentColor, fontFamily, fontSize], @@ -251,25 +351,38 @@ export function useStyles(options: StylesOptions = {}) { // Debounce style updates to avoid too frequent changes styleUpdateTimeout = setTimeout(async () => { - applyStylesToDocument(); - await applyStylesToReader(); - saveStyles(); - - if (options.onStyleChange) { - options.onStyleChange( - textColor.value, - backgroundColor.value, - accentColor.value, - fontFamily.value, - fontSize.value - ); + try { + applyStylesToDocument(); + await applyStylesToReader(); + saveStyles(); + + if (options.onStyleChange) { + options.onStyleChange( + textColor.value, + backgroundColor.value, + accentColor.value, + fontFamily.value, + fontSize.value + ); + } + } catch (error) { + console.error('Error updating styles:', error); } - }, 100); + }, 150); // Slightly increased debounce time for better performance } ); + // Load saved styles on initialization loadSavedStyles(); + // Cleanup function + const cleanup = () => { + removeEventListeners(); + if (styleUpdateTimeout) { + clearTimeout(styleUpdateTimeout); + } + }; + return { textColor, backgroundColor, @@ -282,6 +395,7 @@ export function useStyles(options: StylesOptions = {}) { setRendition, applyStylesToReader, applyStylesToDocument, - refreshStyles + refreshStyles, + cleanup }; } \ No newline at end of file diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 82ec0e5..21c6235 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -16,6 +16,15 @@ export const translations = { reader: { loading: 'Loading', back: 'Back', + annotation: 'Annotation', + annotations: 'Annotations', + selectedText: 'Selected text', + namelessAnnotation: 'Note will be discarded if no name is provided', + nameAnnotation: 'Annotation name', + noteAnnotation: 'Note', + namePlaceholderAnnotation: 'Enter a name for this annotation (required)...', + notePlaceholderAnnotation: 'Add a note for this annotation (optional)...', + emptyAnnotationList: 'No annotations yet. Select text and add a note to create your first annotation.' }, settings: { settings: 'Settings', @@ -33,7 +42,12 @@ export const translations = { deleteBook: 'Delete Book', confirmDelete: 'Are you sure you want to delete this book? This action is irreversible!', cancel: 'Cancel', - delete: 'Delete' + delete: 'Delete', + discard: 'Discard', + save: 'Save', + edit: 'Edit', + update: 'Update', + add: 'Add' }, messages: { success: 'Operation completed successfully', @@ -44,48 +58,64 @@ export const translations = { welcome: 'Welcome!' }, }, - sk: { - library: { - library: 'Knižnica', - title: 'Názov', - emptyLibrary: 'V knižnici sa nenašli žiadne knihy.', - loading: 'Načítavam Vašu knižnicu...', - download: 'Stiahnuť', - read: 'Čítať', - size: 'Veľkosť', - local: 'Moje', - added: 'Pridané', - filename: 'Názov súboru', - }, - reader: { - loading: 'Načítavam', - back: 'Späť', - }, - settings: { - settings: 'Nastavenia', - textColor: 'Farba Textu', - backgroundColor: 'Farba Pozadia', - accentColor: 'Terciárna Farba', - fontFamily: 'Písmo', - fontSize: "Veľkosť písma", - presets: 'Predvoľby', - white: 'Svetlá', - black: 'Tmavá', - sepia: 'Sépia', - bookSettings: 'Detail Knihy', - saveChanges: 'Uložiť zmeny', - deleteBook: 'Odstrániť Knihu', - confirmDelete: 'Naozaj chcete odstrániť ', - cancel: 'Zrušiť', - delete: 'Odstrániť' - }, - messages: { - success: 'Operácia bola úspešne dokončená', - error: 'Došlo k chybe', - loading: 'Načítava sa...', - confirmDelete: 'Naozaj chcete zmazať túto položku?', - noResults: 'Nenašli sa žiadne výsledky', - welcome: 'Vitajte!' - }, + + sk: { + library: { + library: 'Knižnica', + title: 'Názov', + emptyLibrary: 'V knižnici sa nenašli žiadne knihy.', + loading: 'Načítava sa vaša knižnica...', + download: 'Stiahnuť', + read: 'Čítať', + size: 'Veľkosť', + local: 'Vlastnené', + added: 'Pridané', + filename: 'Názov súboru', }, + reader: { + loading: 'Načítava sa', + back: 'Späť', + annotation: 'Poznámka', + annotations: 'Poznámky', + selectedText: 'Vybraný text', + namelessAnnotation: 'Poznámka bude zrušená, ak nezadáte názov', + nameAnnotation: 'Názov poznámky', + noteAnnotation: 'Poznámka', + namePlaceholderAnnotation: 'Zadajte názov pre túto poznámku (povinné)...', + notePlaceholderAnnotation: 'Pridajte poznámku k tejto anotácii (voliteľné)...', + emptyAnnotationList: 'Zatiaľ žiadne anotácie. Vyberte text a pridajte poznámku, čím vytvoríte svoju prvú anotáciu.' + }, + settings: { + settings: 'Nastavenia', + textColor: 'Farba textu', + backgroundColor: 'Farba pozadia', + accentColor: 'Akcentová farba', + fontFamily: 'Písmo', + fontSize: 'Veľkosť písma', + presets: 'Prednastavenia', + white: 'Svetlý', + black: 'Tmavý', + sepia: 'Sepia', + bookSettings: 'Nastavenia knihy', + saveChanges: 'Uložiť zmeny', + deleteBook: 'Odstrániť knihu', + confirmDelete: 'Naozaj chcete túto knihu odstrániť? Táto akcia je nevratná!', + cancel: 'Zrušiť', + delete: 'Odstrániť', + discard: 'Zahodiť', + save: 'Uložiť', + edit: 'Upraviť', + update: 'Aktualizovať', + add: 'Pridať' + }, + messages: { + success: 'Operácia bola úspešne dokončená', + error: 'Došlo k chybe', + loading: 'Načítava sa...', + confirmDelete: 'Naozaj chcete odstrániť ', + noResults: 'Nenašli sa žiadne výsledky', + welcome: 'Vitajte!' + }, + } + }; \ No newline at end of file diff --git a/src/types/annotations.ts b/src/types/annotations.ts new file mode 100644 index 0000000..8c7d3c2 --- /dev/null +++ b/src/types/annotations.ts @@ -0,0 +1,25 @@ +// src/types/annotations.ts +import type Contents from 'epubjs/types/contents'; + +export interface Annotation { + id: string; + bookId: string; + cfiRange: string; + text: string; + name: string; + note?: string; + createdAt: number; + updatedAt: number; + chapter?: string; +} + +export interface PendingAnnotation { + cfiRange: string; + text: string; + contents: Contents; +} + +export interface AnnotationFormData { + name: string; + note: string; +} \ No newline at end of file diff --git a/src/types/styles.ts b/src/types/styles.ts index df17055..30c760d 100644 --- a/src/types/styles.ts +++ b/src/types/styles.ts @@ -1,19 +1,4 @@ // src/types/styles.ts -export interface RenditionTheme { - themes: { - register: (name: string, styles: string) => void; - select: (name: string) => void; - unregister?: (name: string) => void; - update?: (name: string) => void; - override: (property: string, value: string | object) => void; - fontSize: (size: string) => void; - }; - views?: () => Array; - next?: () => void; - prev?: () => void; - [key: string]: any; -} - export interface StylesOptions { initialTextColor?: string; initialBackgroundColor?: string; diff --git a/src/views/ReaderView.vue b/src/views/ReaderView.vue index 2dde6b4..08e560d 100644 --- a/src/views/ReaderView.vue +++ b/src/views/ReaderView.vue @@ -1,18 +1,13 @@ - + - - + {{ bookTitle }} - + + - - {{ t("reader.loading") }} - - - {{ error }} - + {{ t("reader.loading") }} + {{ error }} + @@ -58,10 +56,30 @@ :setLocation="setLocation" /> - + + + + + + import { - ref, - reactive, - onMounted, - onUnmounted, - toRefs, - h, - getCurrentInstance, - Transition, + ref, reactive, onMounted, onUnmounted, toRefs, h, + getCurrentInstance, Transition, nextTick } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useI18n } from "../i18n/usei18n"; import StylesModal from "../components/StylesModal.vue"; -import StylesButton from '../components/StylesButton.vue' +import StylesButton from "../components/StylesButton.vue"; +import AnnotationsPanel from "../components/AnnotationsPanel.vue"; +import AnnotationModal from "../components/AnnotationModal.vue"; +import AnnotationsButton from "../components/AnnotationsButton.vue"; import { useStyles } from "../composables/useStyles"; -import { loadBookFromIndexedDB, formatFilename } from "../utils/utils"; -import type { RenditionTheme } from "../types/styles"; +import { loadBookFromIndexedDB } from "../utils/utils"; import EpubView from "../components/EpubView.vue"; import { type EpubFile } from "../types/epubFile"; +import { type Annotation, type PendingAnnotation, type AnnotationFormData } from "../types/annotations"; -// NavItem interface -interface NavItem { +// Import epub.js types +import type Rendition from 'epubjs/types/rendition'; +import type { DisplayedLocation } from 'epubjs/types/rendition'; +import type Contents from 'epubjs/types/contents'; +import type Book from 'epubjs/types/book'; + +// Extended NavItem interface +interface ExtendedNavItem { id: string; href: string; label: string; - subitems: Array; + subitems: Array; parent?: string; expansion: boolean; } -// TocComponent definition - Using setup function within script setup +// Event handler types +interface RelocatedEvent { + start: DisplayedLocation; + end: DisplayedLocation; + atStart: boolean; + atEnd: boolean; +} + +// TocComponent definition const TocComponent = (props: { - toc: Array; + toc: Array; current: string | number; setLocation: (href: string | number, close?: boolean) => void; isSubmenu?: boolean; @@ -139,17 +168,14 @@ const TocComponent = (props: { }, [ props.isSubmenu ? " ".repeat(4) + item.label : item.label, - // Expansion indicator - item.subitems && - item.subitems.length > 0 && + item.subitems.length > 0 && renderH("div", { class: `${item.expansion ? "open" : ""} expansion`, }), ] ), // Nested TOC - item.subitems && - item.subitems.length > 0 && + item.subitems.length > 0 && renderH( Transition, { name: "collapse-transition" }, @@ -178,40 +204,83 @@ const TocComponent = (props: { ); }; -// Setup and state management +// Setup state const { t } = useI18n(); const route = useRoute(); const router = useRouter(); const loading = ref(true); const error = ref(null); const bookData = ref(null); -const bookDataUrl = ref(null); // Add this to store URL for comparison +const bookDataUrl = ref(null); const bookTitle = ref(""); const location = ref(null); const firstRenderDone = ref(false); const showToc = ref(true); -const epubRef = ref | null>(null); // Add null type for initialization +const epubRef = ref | null>(null); const currentHref = ref(null); +// 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, + toc: [] as Array, expandedToc: false, }); const { toc, expandedToc } = toRefs(bookState); const { - textColor, - backgroundColor, - accentColor, - fontFamily, - fontSize, - stylesModalOpen, - toggleStylesModal, - rendition, - setRendition, + textColor, backgroundColor, accentColor, + fontFamily, fontSize, stylesModalOpen, + toggleStylesModal, rendition, setRendition, } = useStyles(); +// Toggle annotations panel +const toggleAnnotationsPanel = () => { + showAnnotationsPanel.value = !showAnnotationsPanel.value; +}; + +// 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); + } +}; + +const generateAnnotationId = (): string => { + return `annotation-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +}; + const loadBook = async (): Promise => { loading.value = true; error.value = null; @@ -228,17 +297,17 @@ const loadBook = async (): Promise => { if (book.data instanceof Blob) { bookData.value = await book.data.arrayBuffer(); - // Create object URL for XHR comparison const blob = new Blob([bookData.value]); bookDataUrl.value = URL.createObjectURL(blob); } else if (book.data instanceof ArrayBuffer) { bookData.value = book.data; - // Create object URL for XHR comparison const blob = new Blob([bookData.value]); bookDataUrl.value = URL.createObjectURL(blob); } else { throw new Error("Book data is in an unsupported format"); } + + loadAnnotations(); } catch (err: unknown) { const errorMsg = err instanceof Error ? err.message : String(err); console.error("Error loading book:", err); @@ -258,19 +327,211 @@ const locationChange = (epubcifi: string): void => { location.value = epubcifi; }; -const getRendition = (renditionObj: any): void => { - setRendition(renditionObj as RenditionTheme); +// 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 text selection +const handleSelection = (cfiRange: string, contents: Contents): void => { + try { + if (!rendition.value) return; + + const range = rendition.value.getRange(cfiRange); + const selectedText = range.toString().trim(); + + if (!selectedText || selectedText.length < 3) { + console.log('Selection too short, ignoring'); + return; + } + + pendingAnnotation.value = { + cfiRange, + text: selectedText, + contents + }; + + showAnnotationModal.value = true; + + if (contents.window && contents.window.getSelection) { + contents.window.getSelection()?.removeAllRanges(); + } + } catch (error) { + console.error('Error handling selection:', 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; +}; + +// 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(); + } + } +}; + +const getRendition = (renditionObj: Rendition): void => { + setRendition(renditionObj); // Track current location for TOC highlighting - renditionObj.on("relocated", (location: { start: { href: string } }) => { + renditionObj.on("relocated", (location: RelocatedEvent) => { currentHref.value = location.start.href; }); + // Handle text selection + renditionObj.on('selected', (cfiRange: string, contents: Contents) => { + handleSelection(cfiRange, contents); + }); + + // Apply saved annotations when view is displayed + renditionObj.on('displayed', () => { + applyAnnotationsToView(); + }); + // Get book metadata - const book = renditionObj.book; + const book: Book = renditionObj.book; book.ready.then(() => { - const meta = book.package.metadata; - if (!bookTitle.value && meta.title) { + const meta = book.packaging?.metadata; + if (!bookTitle.value && meta?.title) { bookTitle.value = meta.title; document.title = meta.title; } @@ -292,18 +553,37 @@ const toggleToc = (): void => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - expandedToc.value = false; + if (showAnnotationModal.value) { + closeAnnotationModal(); + } else if (showAnnotationsPanel.value) { + showAnnotationsPanel.value = false; + } else { + expandedToc.value = false; + } } }; -const onTocChange = (tocData: any[]): void => { - // Convert epubjs NavItem to our NavItem with expansion property - toc.value = tocData.map((i) => ({ - ...i, +// Convert navigation items to our format +const convertNavItems = (items: any[]): ExtendedNavItem[] => { + return items.map((item) => ({ + id: item.id || '', + href: item.href || '', + label: item.label || '', + parent: item.parent, expansion: false, - // Ensure subitems is always an array - subitems: Array.isArray(i.subitems) ? i.subitems.map((s: any) => ({ ...s, expansion: false })) : [] - })); + subitems: Array.isArray(item.subitems) + ? convertNavItems(item.subitems) + : [] + } as ExtendedNavItem)); +}; + +const onTocChange = (tocData: any[]): void => { + try { + toc.value = convertNavItems(tocData); + } catch (error) { + console.error('Error processing TOC data:', error); + toc.value = []; + } }; const setLocation = ( @@ -318,8 +598,7 @@ const setLocation = ( // XHR Progress tracking const originalOpen = XMLHttpRequest.prototype.open; const onProgress = (e: ProgressEvent) => { - // You could emit a progress event here if needed - // emit('progress', Math.floor((e.loaded / e.total) * 100)); + // Progress tracking if needed }; XMLHttpRequest.prototype.open = function ( @@ -334,18 +613,18 @@ XMLHttpRequest.prototype.open = function ( onMounted(() => { loadBook(); + + // Add keyboard shortcuts + window.addEventListener('keydown', handleKeyDown); }); onUnmounted(() => { - // Clean up object URL to prevent memory leaks if (bookDataUrl.value) { URL.revokeObjectURL(bookDataUrl.value); } XMLHttpRequest.prototype.open = originalOpen; - if (expandedToc.value) { - window.removeEventListener('keydown', handleKeyDown); - } + window.removeEventListener('keydown', handleKeyDown); }); defineExpose({ @@ -376,21 +655,30 @@ defineExpose({ setLocation, epubRef, TocComponent, + // Annotation related + savedAnnotations, + goToAnnotation, + editAnnotation, + deleteAnnotation, + toggleAnnotationsPanel }); + + +/* Ensure highlight styles are properly applied */ +:deep(.epub-view) { + height: 100%; +} + +:deep(.saved-annotation) { + background-color: var(--accent-color); + opacity: 0.4; + cursor: pointer; +} + \ No newline at end of file
"{{ selectedText }}"
{{ truncateText(annotation.text, 100) }}