Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add highlight annotations to ebooks #2601

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 142 additions & 9 deletions client/components/readers/EpubReader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@
<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="frame" class="w-full shrink">
<div id="viewer" ref="viewer"></div>

<div v-if="showAnnotator" class="rounded bg-bg absolute" :style="{ top: annotatorPosition.top + 'px', left: annotatorPosition.left + 'px' }">
<button v-if="isTextSelected" class="opacity-80 hover:opacity-100" @mousedown.prevent @mouseup.prevent @click="addAnotationClicked">
<span class="material-icons text-4xl"> bookmark_add </span>
</button>
<button v-else class="opacity-80 hover:opacity-100" @mousedown.prevent @mouseup.prevent @click.stop="removeAnnotation">
<span class="material-icons text-4xl"> bookmark_remove </span>
</button>
</div>

<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">
Expand All @@ -22,6 +33,23 @@ import ePub from 'epubjs'
* @property {ePub.Book} book
* @property {ePub.Rendition} rendition
*/
/**
* @typedef {object} Chapter
* @property {string} title
* @property {string} cfi
* @property {number} start - percentage
* @property {number} end - percentage
* @property {string} href
* @property {number} id
* @property {Annotation[]} searchResults
* @property {Chapter[]} subitems - nested chapters
*/
/**
* @typedef {object} Annotation
* @property {string} cfi
* @property {number} start - percentage
* @property {string} excerpt - highlighted text
*/
export default {
props: {
libraryItem: {
Expand All @@ -34,13 +62,22 @@ export default {
},
data() {
return {
annotatorPosition: { top: 0, left: 0 },
isTextSelected: false,
showAnnotator: false,
mousePosition: { x: 0, y: 0 },
windowWidth: 0,
windowHeight: 0,
/** @type {ePub.Book} */
book: null,
/** @type {ePub.Rendition} */
rendition: null,
/** @type {Chapter[]} */
chapters: [],
/** @type {Annotation[]} */
bookmarks: [],
selectedCFI: '',
flattenedChapters: [],
ereaderSettings: {
theme: 'dark',
font: 'serif',
Expand Down Expand Up @@ -157,26 +194,105 @@ export default {
}
return foundChapter
},
/** @returns {Array} Returns an array of chapters that only includes chapters with query results */
async searchBook(query) {
const chapters = structuredClone(await this.chapters)
const searchResults = await Promise.all(this.book.spine.spineItems.map((item) => item.load(this.book.load.bind(this.book)).then(item.find.bind(item, query)).finally(item.unload.bind(item))))
const mergedResults = [].concat(...searchResults)

mergedResults.forEach((chapter) => {
addResultsToChapters(chapters, results) {
results.forEach((chapter) => {
chapter.start = this.book.locations.percentageFromCfi(chapter.cfi)
const foundChapter = this.findChapterFromPosition(chapters, chapter.start)
if (foundChapter) foundChapter.searchResults.push(chapter)
})

let filteredResults = chapters.filter(function f(o) {
return chapters.filter(function f(o) {
if (o.searchResults.length) return true
if (o.subitems.length) {
return (o.subitems = o.subitems.filter(f)).length
}
})
},
async searchBook(query) {
const chapters = structuredClone(await this.chapters)
const searchResults = await Promise.all(this.book.spine.spineItems.map((item) => item.load(this.book.load.bind(this.book)).then(item.find.bind(item, query)).finally(item.unload.bind(item))))
const mergedResults = [].concat(...searchResults)

const filteredResults = this.addResultsToChapters(chapters, mergedResults)
return filteredResults
},
getBookmarks() {
const chapters = structuredClone(this.chapters)
const mergedResults = structuredClone(this.bookmarks)
let filteredResults = this.addResultsToChapters(chapters, mergedResults)
return filteredResults
},
captureMousePosition(evt, contents) {
const viewElementRect = contents.document.defaultView.frameElement.getBoundingClientRect()
this.annotatorPosition = { top: evt.clientY + viewElementRect.top + 20, left: evt.clientX + viewElementRect.left - 10 }
},
onTextSelected(cfiRange, contents) {
this.isTextSelected = true
this.showAnnotator = true
this.selectedCFI = cfiRange
},
onAnnotationClicked(cfiRange) {
this.isTextSelected = false
this.showAnnotator = true

this.selectedCFI = cfiRange
},
removeAnnotation() {
this.rendition.annotations.remove(this.selectedCFI, 'highlight')
this.bookmarks = this.bookmarks.filter((item) => item.cfi !== this.selectedCFI)
this.showAnnotator = false

var bookmark = {
title: this.rendition.getRange(this.selectedCFI).toString(),
time: this.selectedCFI
}
this.$axios
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${encodeURIComponent(bookmark.time)}?type=epub`)
.then(() => {
this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess)
})
.catch((error) => {
this.$toast.error(this.$strings.ToastBookmarkRemoveFailed)
console.error(error)
})
},
addAnotationClicked() {
var bookmark = {
title: this.rendition.getRange(this.selectedCFI).toString(),
time: this.selectedCFI,
type: 'epub'
}
this.submitCreateBookmark(bookmark)
this.addAnotation(bookmark)

// Deselect text
this.rendition.getContents().forEach((contents) => contents.window.getSelection().removeAllRanges())
this.showAnnotator = false
this.isTextSelected = false
},
addAnotation(bookmark) {
this.bookmarks.push({
excerpt: bookmark.title,
cfi: bookmark.time,
start: this.book.locations.percentageFromCfi(bookmark.time)
})
this.rendition.annotations.add('highlight', bookmark.time, {}, null, 'hl', {
fill: 'red',
'fill-opacity': '0.5',
'mix-blend-mode': 'multiply'
})
},
submitCreateBookmark(bookmark) {
this.$axios
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success(this.$strings.ToastBookmarkCreateSuccess)
})
.catch((error) => {
this.$toast.error(this.$strings.ToastBookmarkCreateFailed)
console.error(error)
})
},
keyUp(e) {
const rtl = this.book.package.metadata.direction === 'rtl'
if ((e.keyCode || e.which) == 37) {
Expand Down Expand Up @@ -283,6 +399,7 @@ export default {
},
/** @param {string} location - CFI of the new location */
relocated(location) {
this.showAnnotator = false
if (this.savedEbookLocation === location.start.cfi) {
return
}
Expand Down Expand Up @@ -342,6 +459,15 @@ export default {
this.$emit('touchend', event)
})

// Show popup to annotate selected text
reader.rendition.on('selected', this.onTextSelected)
reader.rendition.on('mouseup', this.captureMousePosition)
reader.rendition.on('mousedown', () => {
this.showAnnotator = false
})
// Show popup to remove an annotation
reader.rendition.on('markClicked', this.onAnnotationClicked)

// load ebook cfi locations
const savedLocations = this.loadLocations()
if (savedLocations) {
Expand All @@ -353,6 +479,11 @@ export default {
}
this.getChapters()
})

const bookmarks = this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId, 'epub')
bookmarks.forEach((bookmark) => {
this.addAnotation(bookmark)
})
},
getChapters() {
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759
Expand Down Expand Up @@ -400,6 +531,8 @@ export default {
}
return createTree(toc, tocTree).then(() => {
this.chapters = tocTree
this.$emit('chaptersLoaded', this.chapters)
this.flattenChapters()
})
},
flattenChapters(chapters) {
Expand Down
86 changes: 25 additions & 61 deletions client/components/readers/Reader.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
<template>
<div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': !!streamLibraryItem }">
<div class="absolute top-4 left-4 z-20 flex items-center">
<button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100">
<div class="absolute top-4 left-4 z-20 inline-flex gap-4 items-center">
<button v-if="isEpub" @click="openToC" type="button" aria-label="Table of contents menu" class="opacity-80 hover:opacity-100">
<span class="material-icons text-2xl">menu</span>
</button>
<button v-if="hasSettings" @click="openSettings" type="button" aria-label="Ereader settings" class="mx-4 inline-flex opacity-80 hover:opacity-100">
<button v-if="isEpub" @click="openBookmarks" type="button" aria-label="Bookmarks" class="opacity-80 hover:opacity-100">
<span class="material-icons text-2xl">bookmarks</span>
</button>
<button v-if="hasSettings" @click="openSettings" type="button" aria-label="Ereader settings" class="opacity-80 hover:opacity-100">
<span class="material-icons text-1.5xl">settings</span>
</button>
</div>
Expand All @@ -23,48 +26,9 @@
</button>
</div>

<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" @touchstart="touchstart" @touchend="touchend" @hook:mounted="readerMounted" />

<!-- TOC side nav -->
<div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
<div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent>
<div class="flex flex-col p-4 h-full">
<div class="flex items-center mb-2">
<button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-2xl">arrow_back</span>
</button>

<p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p>
</div>
<form @submit.prevent="searchBook" @click.stop.prevent>
<ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" />
</form>

<div class="overflow-y-auto">
<div v-if="isSearching && !this.searchResults.length" class="w-full h-40 justify-center">
<p class="text-center text-xl py-4">{{ $strings.MessageNoResults }}</p>
</div>

<ul>
<li v-for="chapter in isSearching ? this.searchResults : chapters" :key="chapter.id" class="py-1">
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(chapter.href)">{{ chapter.title }}</a>
<div v-for="searchResults in chapter.searchResults" :key="searchResults.cfi" class="text-sm py-1 pl-4">
<a :href="searchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="goToChapter(searchResults.cfi)">{{ searchResults.excerpt }}</a>
</div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" @touchstart="touchstart" @touchend="touchend" @hook:mounted="readerMounted" @chaptersLoaded="chaptersLoaded" />

<ul v-if="chapter.subitems.length">
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(subchapter.href)">{{ subchapter.title }}</a>
<div v-for="subChapterSearchResults in subchapter.searchResults" :key="subChapterSearchResults.cfi" class="text-sm py-1 pl-4">
<a :href="subChapterSearchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="goToChapter(subChapterSearchResults.cfi)">{{ subChapterSearchResults.excerpt }}</a>
</div>
</li>
</ul>
</li>
</ul>
</div>
</div>
</div>
<readers-sidebar v-if="isEpub" ref="sidebar" :chapters="chapters" :bookmarks="bookmarks" :bookmarksOpen="bookmarksOpen" :sidebarOpen="sidebarOpen" @toggle-sidebar="sidebarOpen = false" @goToChapter="goToChapter" />

<!-- ereader settings modal -->
<modals-modal v-model="showSettings" name="ereader-settings-modal" :width="500" :height="'unset'" :processing="false">
Expand Down Expand Up @@ -119,11 +83,12 @@ export default {
touchendY: 0,
touchstartTime: 0,
touchIdentifier: null,
bookmarks: [],
chapters: [],
isSearching: false,
searchResults: [],
searchQuery: '',
tocOpen: false,
sidebarOpen: false,
bookmarksOpen: false,
showSettings: false,
ereaderSettings: {
theme: 'dark',
Expand Down Expand Up @@ -273,7 +238,7 @@ export default {
},
methods: {
goToChapter(uri) {
this.toggleToC()
this.sidebarOpen = false
this.$refs.readerComponent.goToChapter(uri)
},
readerMounted() {
Expand All @@ -285,9 +250,17 @@ export default {
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
},
toggleToC() {
this.tocOpen = !this.tocOpen
this.chapters = this.$refs.readerComponent.chapters
openToC() {
this.sidebarOpen = true
this.bookmarksOpen = false
},
chaptersLoaded(chapters) {
this.chapters = chapters
},
openBookmarks() {
this.sidebarOpen = true
this.bookmarksOpen = true
this.bookmarks = this.$refs.readerComponent.getBookmarks()
},
openSettings() {
this.showSettings = true
Expand All @@ -303,15 +276,6 @@ export default {
this.close()
}
},
async searchBook() {
if (this.searchQuery.length > 1) {
this.searchResults = await this.$refs.readerComponent.searchBook(this.searchQuery)
this.isSearching = true
} else {
this.isSearching = false
this.searchResults = []
}
},
next() {
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
},
Expand Down Expand Up @@ -390,8 +354,8 @@ export default {
},
close() {
this.unregisterListeners()
this.isSearching = false
this.searchQuery = ''
this.$refs.sidebar.isSearching = false
this.$refs.sidebar.searchQuery = ''
this.show = false
}
},
Expand Down
Loading
Loading