<template> <div id="epub-reader" class="h-full w-full"> <div class="h-full flex items-center justify-center"> <button type="button" aria-label="Previous page" class="w-24 max-w-24 h-full hidden sm:flex items-center overflow-x-hidden justify-center opacity-50 hover:opacity-100"> <span v-if="hasPrev" class="material-icons text-6xl" @mousedown.prevent @click="prev">chevron_left</span> </button> <div id="frame" class="w-full" style="height: 80%"> <div id="viewer"></div> </div> <button type="button" aria-label="Next page" class="w-24 max-w-24 h-full hidden sm:flex items-center justify-center overflow-x-hidden opacity-50 hover:opacity-100"> <span v-if="hasNext" class="material-icons text-6xl" @mousedown.prevent @click="next">chevron_right</span> </button> </div> </div> </template> <script> import ePub from 'epubjs' /** * @typedef {object} EpubReader * @property {ePub.Book} book * @property {ePub.Rendition} rendition */ export default { props: { libraryItem: { type: Object, default: () => {} }, playerOpen: Boolean, keepProgress: Boolean, fileId: String }, data() { return { windowWidth: 0, windowHeight: 0, /** @type {ePub.Book} */ book: null, /** @type {ePub.Rendition} */ rendition: null, ereaderSettings: { theme: 'dark', fontScale: 100, lineSpacing: 115, spread: 'auto' } } }, watch: { playerOpen() { this.resize() } }, computed: { userToken() { return this.$store.getters['user/getToken'] }, /** @returns {string} */ libraryItemId() { return this.libraryItem?.id }, hasPrev() { return !this.rendition?.location?.atStart }, hasNext() { return !this.rendition?.location?.atEnd }, /** @returns {Array<ePub.NavItem>} */ chapters() { return this.book?.navigation?.toc || [] }, userMediaProgress() { if (!this.libraryItemId) return return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) }, savedEbookLocation() { if (!this.keepProgress) return null if (!this.userMediaProgress?.ebookLocation) return null // Validate ebookLocation is an epubcfi if (!String(this.userMediaProgress.ebookLocation).startsWith('epubcfi')) return null return this.userMediaProgress.ebookLocation }, localStorageLocationsKey() { return `ebookLocations-${this.libraryItemId}` }, readerWidth() { if (this.windowWidth < 640) return this.windowWidth return this.windowWidth - 200 }, readerHeight() { if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight return this.windowHeight - 164 }, ebookUrl() { if (this.fileId) { return `/api/items/${this.libraryItemId}/ebook/${this.fileId}` } return `/api/items/${this.libraryItemId}/ebook` }, themeRules() { const isDark = this.ereaderSettings.theme === 'dark' const fontColor = isDark ? '#fff' : '#000' const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)' const lineSpacing = this.ereaderSettings.lineSpacing / 100 const fontScale = this.ereaderSettings.fontScale / 100 return { '*': { color: `${fontColor}!important`, 'background-color': `${backgroundColor}!important`, 'line-height': lineSpacing * fontScale + 'rem!important' }, a: { color: `${fontColor}!important` } } } }, methods: { updateSettings(settings) { this.ereaderSettings = settings if (!this.rendition) return this.applyTheme() const fontScale = settings.fontScale || 100 this.rendition.themes.fontSize(`${fontScale}%`) this.rendition.spread(settings.spread || 'auto') }, prev() { return this.rendition?.prev() }, next() { return this.rendition?.next() }, goToChapter(href) { return this.rendition?.display(href) }, keyUp(e) { const rtl = this.book.package.metadata.direction === 'rtl' if ((e.keyCode || e.which) == 37) { return rtl ? this.next() : this.prev() } else if ((e.keyCode || e.which) == 39) { return rtl ? this.prev() : this.next() } }, /** * @param {object} payload * @param {string} payload.ebookLocation - CFI of the current location * @param {string} payload.ebookProgress - eBook Progress Percentage */ updateProgress(payload) { if (!this.keepProgress) return this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => { console.error('EpubReader.updateProgress failed:', error) }) }, getAllEbookLocationData() { const locations = [] let totalSize = 0 // Total in bytes for (const key in localStorage) { if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) { continue } try { const ebookLocations = JSON.parse(localStorage[key]) if (!ebookLocations.locations) throw new Error('Invalid locations object') ebookLocations.key = key ebookLocations.size = (localStorage[key].length + key.length) * 2 locations.push(ebookLocations) totalSize += ebookLocations.size } catch (error) { console.error('Failed to parse ebook locations', key, error) localStorage.removeItem(key) } } // Sort by oldest lastAccessed first locations.sort((a, b) => a.lastAccessed - b.lastAccessed) return { locations, totalSize } }, /** @param {string} locationString */ checkSaveLocations(locationString) { const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2 // Too large overall if (newLocationsSize > maxSizeInBytes) { console.error('Epub locations are too large to store. Size =', newLocationsSize) return } const ebookLocationsData = this.getAllEbookLocationData() let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize // Remove epub locations until there is room for locations while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) { const oldestLocation = ebookLocationsData.locations.shift() console.log(`Removing cached locations for epub "${oldestLocation.key}" taking up ${oldestLocation.size} bytes`) availableSpace += oldestLocation.size localStorage.removeItem(oldestLocation.key) } console.log(`Cacheing epub locations with key "${this.localStorageLocationsKey}" taking up ${newLocationsSize} bytes`) this.saveLocations(locationString) }, /** @param {string} locationString */ saveLocations(locationString) { localStorage.setItem( this.localStorageLocationsKey, JSON.stringify({ lastAccessed: Date.now(), locations: locationString }) ) }, loadLocations() { const locationsObjString = localStorage.getItem(this.localStorageLocationsKey) if (!locationsObjString) return null const locationsObject = JSON.parse(locationsObjString) // Remove invalid location objects if (!locationsObject.locations) { console.error('Invalid epub locations stored', this.localStorageLocationsKey) localStorage.removeItem(this.localStorageLocationsKey) return null } // Update lastAccessed this.saveLocations(locationsObject.locations) return locationsObject.locations }, /** @param {string} location - CFI of the new location */ relocated(location) { if (this.savedEbookLocation === location.start.cfi) { return } if (location.end.percentage) { this.updateProgress({ ebookLocation: location.start.cfi, ebookProgress: location.end.percentage }) } else { this.updateProgress({ ebookLocation: location.start.cfi }) } }, initEpub() { /** @type {EpubReader} */ const reader = this /** @type {ePub.Book} */ reader.book = new ePub(reader.ebookUrl, { width: this.readerWidth, height: this.readerHeight - 50, openAs: 'epub', requestHeaders: { Authorization: `Bearer ${this.userToken}` } }) /** @type {ePub.Rendition} */ reader.rendition = reader.book.renderTo('viewer', { width: this.readerWidth, height: this.readerHeight * 0.8, spread: 'auto', snap: true, manager: 'continuous', flow: 'paginated' }) // load saved progress reader.rendition.display(this.savedEbookLocation || reader.book.locations.start) reader.rendition.on('rendered', () => { this.applyTheme() }) reader.book.ready.then(() => { // set up event listeners reader.rendition.on('relocated', reader.relocated) reader.rendition.on('keydown', reader.keyUp) reader.rendition.on('touchstart', (event) => { this.$emit('touchstart', event) }) reader.rendition.on('touchend', (event) => { this.$emit('touchend', event) }) // load ebook cfi locations const savedLocations = this.loadLocations() if (savedLocations) { reader.book.locations.load(savedLocations) } else { reader.book.locations.generate().then(() => { this.checkSaveLocations(reader.book.locations.save()) }) } }) }, resize() { this.windowWidth = window.innerWidth this.windowHeight = window.innerHeight this.rendition?.resize(this.readerWidth, this.readerHeight * 0.8) }, applyTheme() { if (!this.rendition) return this.rendition.getContents().forEach((c) => { c.addStylesheetRules(this.themeRules) }) } }, mounted() { this.windowWidth = window.innerWidth this.windowHeight = window.innerHeight window.addEventListener('resize', this.resize) this.initEpub() }, beforeDestroy() { window.removeEventListener('resize', this.resize) this.book?.destroy() } } </script>