mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-12 12:21:34 +00:00
Add highlight annotations to ebooks
This commit is contained in:
parent
fe1e0749a2
commit
5682d2c7e8
7 changed files with 298 additions and 92 deletions
|
|
@ -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">
|
<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>
|
<span v-if="hasPrev" class="material-icons text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
||||||
</button>
|
</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 id="viewer"></div>
|
||||||
</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">
|
<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">
|
||||||
|
|
@ -22,6 +33,23 @@ import ePub from 'epubjs'
|
||||||
* @property {ePub.Book} book
|
* @property {ePub.Book} book
|
||||||
* @property {ePub.Rendition} rendition
|
* @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 {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
libraryItem: {
|
libraryItem: {
|
||||||
|
|
@ -34,13 +62,22 @@ export default {
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
annotatorPosition: { top: 0, left: 0 },
|
||||||
|
isTextSelected: false,
|
||||||
|
showAnnotator: false,
|
||||||
|
mousePosition: { x: 0, y: 0 },
|
||||||
windowWidth: 0,
|
windowWidth: 0,
|
||||||
windowHeight: 0,
|
windowHeight: 0,
|
||||||
/** @type {ePub.Book} */
|
/** @type {ePub.Book} */
|
||||||
book: null,
|
book: null,
|
||||||
/** @type {ePub.Rendition} */
|
/** @type {ePub.Rendition} */
|
||||||
rendition: null,
|
rendition: null,
|
||||||
|
/** @type {Chapter[]} */
|
||||||
chapters: [],
|
chapters: [],
|
||||||
|
/** @type {Annotation[]} */
|
||||||
|
bookmarks: [],
|
||||||
|
selectedCFI: '',
|
||||||
|
flattenedChapters: [],
|
||||||
ereaderSettings: {
|
ereaderSettings: {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
font: 'serif',
|
font: 'serif',
|
||||||
|
|
@ -157,26 +194,105 @@ export default {
|
||||||
}
|
}
|
||||||
return foundChapter
|
return foundChapter
|
||||||
},
|
},
|
||||||
/** @returns {Array} Returns an array of chapters that only includes chapters with query results */
|
addResultsToChapters(chapters, results) {
|
||||||
async searchBook(query) {
|
results.forEach((chapter) => {
|
||||||
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) => {
|
|
||||||
chapter.start = this.book.locations.percentageFromCfi(chapter.cfi)
|
chapter.start = this.book.locations.percentageFromCfi(chapter.cfi)
|
||||||
const foundChapter = this.findChapterFromPosition(chapters, chapter.start)
|
const foundChapter = this.findChapterFromPosition(chapters, chapter.start)
|
||||||
if (foundChapter) foundChapter.searchResults.push(chapter)
|
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.searchResults.length) return true
|
||||||
if (o.subitems.length) {
|
if (o.subitems.length) {
|
||||||
return (o.subitems = o.subitems.filter(f)).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
|
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) {
|
keyUp(e) {
|
||||||
const rtl = this.book.package.metadata.direction === 'rtl'
|
const rtl = this.book.package.metadata.direction === 'rtl'
|
||||||
if ((e.keyCode || e.which) == 37) {
|
if ((e.keyCode || e.which) == 37) {
|
||||||
|
|
@ -283,6 +399,7 @@ export default {
|
||||||
},
|
},
|
||||||
/** @param {string} location - CFI of the new location */
|
/** @param {string} location - CFI of the new location */
|
||||||
relocated(location) {
|
relocated(location) {
|
||||||
|
this.showAnnotator = false
|
||||||
if (this.savedEbookLocation === location.start.cfi) {
|
if (this.savedEbookLocation === location.start.cfi) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -342,6 +459,15 @@ export default {
|
||||||
this.$emit('touchend', event)
|
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
|
// load ebook cfi locations
|
||||||
const savedLocations = this.loadLocations()
|
const savedLocations = this.loadLocations()
|
||||||
if (savedLocations) {
|
if (savedLocations) {
|
||||||
|
|
@ -353,6 +479,11 @@ export default {
|
||||||
}
|
}
|
||||||
this.getChapters()
|
this.getChapters()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const bookmarks = this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId, 'epub')
|
||||||
|
bookmarks.forEach((bookmark) => {
|
||||||
|
this.addAnotation(bookmark)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
getChapters() {
|
getChapters() {
|
||||||
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759
|
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759
|
||||||
|
|
@ -400,6 +531,8 @@ export default {
|
||||||
}
|
}
|
||||||
return createTree(toc, tocTree).then(() => {
|
return createTree(toc, tocTree).then(() => {
|
||||||
this.chapters = tocTree
|
this.chapters = tocTree
|
||||||
|
this.$emit('chaptersLoaded', this.chapters)
|
||||||
|
this.flattenChapters()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
flattenChapters(chapters) {
|
flattenChapters(chapters) {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
<template>
|
<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 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">
|
<div class="absolute top-4 left-4 z-20 inline-flex gap-4 items-center">
|
||||||
<button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100">
|
<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>
|
<span class="material-icons text-2xl">menu</span>
|
||||||
</button>
|
</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>
|
<span class="material-icons text-1.5xl">settings</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -23,48 +26,9 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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" />
|
<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" />
|
||||||
|
|
||||||
<!-- TOC side nav -->
|
<readers-sidebar v-if="isEpub" ref="sidebar" :chapters="chapters" :bookmarks="bookmarks" :bookmarksOpen="bookmarksOpen" :sidebarOpen="sidebarOpen" @toggle-sidebar="sidebarOpen = false" @goToChapter="goToChapter" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- ereader settings modal -->
|
<!-- ereader settings modal -->
|
||||||
<modals-modal v-model="showSettings" name="ereader-settings-modal" :width="500" :height="'unset'" :processing="false">
|
<modals-modal v-model="showSettings" name="ereader-settings-modal" :width="500" :height="'unset'" :processing="false">
|
||||||
|
|
@ -119,11 +83,12 @@ export default {
|
||||||
touchendY: 0,
|
touchendY: 0,
|
||||||
touchstartTime: 0,
|
touchstartTime: 0,
|
||||||
touchIdentifier: null,
|
touchIdentifier: null,
|
||||||
|
bookmarks: [],
|
||||||
chapters: [],
|
chapters: [],
|
||||||
isSearching: false,
|
isSearching: false,
|
||||||
searchResults: [],
|
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
tocOpen: false,
|
sidebarOpen: false,
|
||||||
|
bookmarksOpen: false,
|
||||||
showSettings: false,
|
showSettings: false,
|
||||||
ereaderSettings: {
|
ereaderSettings: {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
|
|
@ -273,7 +238,7 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
goToChapter(uri) {
|
goToChapter(uri) {
|
||||||
this.toggleToC()
|
this.sidebarOpen = false
|
||||||
this.$refs.readerComponent.goToChapter(uri)
|
this.$refs.readerComponent.goToChapter(uri)
|
||||||
},
|
},
|
||||||
readerMounted() {
|
readerMounted() {
|
||||||
|
|
@ -285,9 +250,17 @@ export default {
|
||||||
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
|
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
|
||||||
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
|
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
|
||||||
},
|
},
|
||||||
toggleToC() {
|
openToC() {
|
||||||
this.tocOpen = !this.tocOpen
|
this.sidebarOpen = true
|
||||||
this.chapters = this.$refs.readerComponent.chapters
|
this.bookmarksOpen = false
|
||||||
|
},
|
||||||
|
chaptersLoaded(chapters) {
|
||||||
|
this.chapters = chapters
|
||||||
|
},
|
||||||
|
openBookmarks() {
|
||||||
|
this.sidebarOpen = true
|
||||||
|
this.bookmarksOpen = true
|
||||||
|
this.bookmarks = this.$refs.readerComponent.getBookmarks()
|
||||||
},
|
},
|
||||||
openSettings() {
|
openSettings() {
|
||||||
this.showSettings = true
|
this.showSettings = true
|
||||||
|
|
@ -303,15 +276,6 @@ export default {
|
||||||
this.close()
|
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() {
|
next() {
|
||||||
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
|
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
|
||||||
},
|
},
|
||||||
|
|
@ -390,8 +354,8 @@ export default {
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
this.unregisterListeners()
|
this.unregisterListeners()
|
||||||
this.isSearching = false
|
this.$refs.sidebar.isSearching = false
|
||||||
this.searchQuery = ''
|
this.$refs.sidebar.searchQuery = ''
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
96
client/components/readers/Sidebar.vue
Normal file
96
client/components/readers/Sidebar.vue
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="sidebarOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleSidebar"></div>
|
||||||
|
<div class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-50 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="sidebarOpen ? '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="toggleSidebar" 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">{{ sidebarTitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!bookmarksOpen">
|
||||||
|
<form @submit.prevent="searchBook">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
<div class="overflow-y-auto">
|
||||||
|
<div v-if="isSearching && !searchResults.length" class="w-full h-40 justify-center">
|
||||||
|
<p class="text-center text-xl py-4">{{ $strings.MessageNoResults }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="bookmarksOpen && !bookmarks.length" class="w-full h-40 justify-center">
|
||||||
|
<p class="text-center text-xl py-4">{{ $strings.MessageNoBookmarks }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!chapters.length" class="w-full h-40 justify-center">
|
||||||
|
<p class="text-center text-xl py-4">{{ $strings.MessageNoChapters }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li v-for="chapter in contents" :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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
searchQuery: '',
|
||||||
|
isSearching: false,
|
||||||
|
searchResults: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
sidebarOpen: false,
|
||||||
|
bookmarksOpen: false,
|
||||||
|
chapters: [],
|
||||||
|
bookmarks: []
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
sidebarTitle() {
|
||||||
|
return this.bookmarksOpen ? this.$strings.LabelYourBookmarks : this.$strings.HeaderTableOfContents
|
||||||
|
},
|
||||||
|
contents() {
|
||||||
|
return !this.bookmarksOpen ? (this.isSearching ? this.searchResults : this.chapters) : this.bookmarks
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
toggleSidebar() {
|
||||||
|
this.$emit('toggle-sidebar')
|
||||||
|
},
|
||||||
|
goToChapter(target) {
|
||||||
|
this.$emit('goToChapter', target)
|
||||||
|
},
|
||||||
|
async searchBook() {
|
||||||
|
if (this.searchQuery.length > 1) {
|
||||||
|
this.searchResults = await this.$parent.$refs.readerComponent.searchBook(this.searchQuery)
|
||||||
|
this.isSearching = true
|
||||||
|
} else {
|
||||||
|
this.isSearching = false
|
||||||
|
this.searchResults = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -28,9 +28,10 @@ export const getters = {
|
||||||
return li.libraryItemId == libraryItemId
|
return li.libraryItemId == libraryItemId
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
getUserBookmarksForItem: (state) => (libraryItemId, bookmarkType) => {
|
||||||
if (!state.user.bookmarks) return []
|
if (!state.user.bookmarks) return []
|
||||||
return state.user.bookmarks.filter(bm => bm.libraryItemId === libraryItemId)
|
bookmarkType = bookmarkType || 'audio'
|
||||||
|
return state.user.bookmarks.filter(bm => bm.libraryItemId === libraryItemId && bm.type === bookmarkType)
|
||||||
},
|
},
|
||||||
getUserSetting: (state) => (key) => {
|
getUserSetting: (state) => (key) => {
|
||||||
return state.settings?.[key] || null
|
return state.settings?.[key] || null
|
||||||
|
|
|
||||||
|
|
@ -124,9 +124,10 @@ class MeController {
|
||||||
// POST: api/me/item/:id/bookmark
|
// POST: api/me/item/:id/bookmark
|
||||||
async createBookmark(req, res) {
|
async createBookmark(req, res) {
|
||||||
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
||||||
|
req.body.type = req.body.type || 'audio'
|
||||||
|
|
||||||
const { time, title } = req.body
|
const { type, time, title } = req.body
|
||||||
const bookmark = req.user.createBookmark(req.params.id, time, title)
|
const bookmark = req.user.createBookmark(req.params.id, type, time, title)
|
||||||
await Database.updateUser(req.user)
|
await Database.updateUser(req.user)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
res.json(bookmark)
|
res.json(bookmark)
|
||||||
|
|
@ -135,14 +136,15 @@ class MeController {
|
||||||
// PATCH: api/me/item/:id/bookmark
|
// PATCH: api/me/item/:id/bookmark
|
||||||
async updateBookmark(req, res) {
|
async updateBookmark(req, res) {
|
||||||
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
||||||
|
req.body.type = req.body.type || 'audio'
|
||||||
|
|
||||||
const { time, title } = req.body
|
const { type, time, title } = req.body
|
||||||
if (!req.user.findBookmark(req.params.id, time)) {
|
if (!req.user.findBookmark(req.params.id, type, time)) {
|
||||||
Logger.error(`[MeController] updateBookmark not found`)
|
Logger.error(`[MeController] updateBookmark not found`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookmark = req.user.updateBookmark(req.params.id, time, title)
|
const bookmark = req.user.updateBookmark(req.params.id, type, time, title)
|
||||||
if (!bookmark) return res.sendStatus(500)
|
if (!bookmark) return res.sendStatus(500)
|
||||||
|
|
||||||
await Database.updateUser(req.user)
|
await Database.updateUser(req.user)
|
||||||
|
|
@ -150,19 +152,25 @@ class MeController {
|
||||||
res.json(bookmark)
|
res.json(bookmark)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: api/me/item/:id/bookmark/:time
|
// DELETE: api/me/item/:id/bookmark
|
||||||
async removeBookmark(req, res) {
|
async removeBookmark(req, res) {
|
||||||
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
|
||||||
|
req.query.type = req.query.type || 'audio'
|
||||||
|
|
||||||
const time = Number(req.params.time)
|
if (req.query.type == 'audio') {
|
||||||
if (isNaN(time)) return res.sendStatus(500)
|
var time = Number(req.params.time)
|
||||||
|
if (isNaN(time)) return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var time = req.params.time
|
||||||
|
}
|
||||||
|
|
||||||
if (!req.user.findBookmark(req.params.id, time)) {
|
if (!req.user.findBookmark(req.params.id, req.query.type, time)) {
|
||||||
Logger.error(`[MeController] removeBookmark not found`)
|
Logger.error(`[MeController] removeBookmark not found`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.user.removeBookmark(req.params.id, time)
|
req.user.removeBookmark(req.params.id, req.query.type, time)
|
||||||
await Database.updateUser(req.user)
|
await Database.updateUser(req.user)
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
class AudioBookmark {
|
class AudioBookmark {
|
||||||
constructor(bookmark) {
|
constructor(bookmark) {
|
||||||
this.libraryItemId = null
|
this.libraryItemId = null
|
||||||
|
this.type = null
|
||||||
this.title = null
|
this.title = null
|
||||||
this.time = null
|
this.time = null
|
||||||
this.createdAt = null
|
this.createdAt = null
|
||||||
|
|
@ -13,6 +14,7 @@ class AudioBookmark {
|
||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
libraryItemId: this.libraryItemId,
|
libraryItemId: this.libraryItemId,
|
||||||
|
type: this.type || 'audio',
|
||||||
title: this.title || '',
|
title: this.title || '',
|
||||||
time: this.time,
|
time: this.time,
|
||||||
createdAt: this.createdAt
|
createdAt: this.createdAt
|
||||||
|
|
@ -21,13 +23,15 @@ class AudioBookmark {
|
||||||
|
|
||||||
construct(bookmark) {
|
construct(bookmark) {
|
||||||
this.libraryItemId = bookmark.libraryItemId
|
this.libraryItemId = bookmark.libraryItemId
|
||||||
|
this.type = bookmark.type || 'audio'
|
||||||
this.title = bookmark.title || ''
|
this.title = bookmark.title || ''
|
||||||
this.time = bookmark.time || 0
|
this.time = bookmark.time || 0
|
||||||
this.createdAt = bookmark.createdAt
|
this.createdAt = bookmark.createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
setData(libraryItemId, time, title) {
|
setData(libraryItemId, type, time, title) {
|
||||||
this.libraryItemId = libraryItemId
|
this.libraryItemId = libraryItemId
|
||||||
|
this.type = type
|
||||||
this.title = title
|
this.title = title
|
||||||
this.time = time
|
this.time = time
|
||||||
this.createdAt = Date.now()
|
this.createdAt = Date.now()
|
||||||
|
|
|
||||||
|
|
@ -350,25 +350,25 @@ class User {
|
||||||
return this.checkCanAccessLibraryItemWithTags(tags)
|
return this.checkCanAccessLibraryItemWithTags(tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
findBookmark(libraryItemId, time) {
|
findBookmark(libraryItemId, type, time) {
|
||||||
return this.bookmarks.find(bm => bm.libraryItemId === libraryItemId && bm.time == time)
|
return this.bookmarks.find(bm => bm.libraryItemId === libraryItemId && bm.time == time && bm.type === type)
|
||||||
}
|
}
|
||||||
|
|
||||||
createBookmark(libraryItemId, time, title) {
|
createBookmark(libraryItemId, type, time, title) {
|
||||||
var existingBookmark = this.findBookmark(libraryItemId, time)
|
var existingBookmark = this.findBookmark(libraryItemId, type, time)
|
||||||
if (existingBookmark) {
|
if (existingBookmark) {
|
||||||
Logger.warn('[User] Create Bookmark already exists for this time')
|
Logger.warn('[User] Create Bookmark already exists for this time')
|
||||||
existingBookmark.title = title
|
existingBookmark.title = title
|
||||||
return existingBookmark
|
return existingBookmark
|
||||||
}
|
}
|
||||||
var newBookmark = new AudioBookmark()
|
var newBookmark = new AudioBookmark()
|
||||||
newBookmark.setData(libraryItemId, time, title)
|
newBookmark.setData(libraryItemId, type, time, title)
|
||||||
this.bookmarks.push(newBookmark)
|
this.bookmarks.push(newBookmark)
|
||||||
return newBookmark
|
return newBookmark
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBookmark(libraryItemId, time, title) {
|
updateBookmark(libraryItemId, type, time, title) {
|
||||||
var bookmark = this.findBookmark(libraryItemId, time)
|
var bookmark = this.findBookmark(libraryItemId, type, time)
|
||||||
if (!bookmark) {
|
if (!bookmark) {
|
||||||
Logger.error(`[User] updateBookmark not found`)
|
Logger.error(`[User] updateBookmark not found`)
|
||||||
return null
|
return null
|
||||||
|
|
@ -377,8 +377,8 @@ class User {
|
||||||
return bookmark
|
return bookmark
|
||||||
}
|
}
|
||||||
|
|
||||||
removeBookmark(libraryItemId, time) {
|
removeBookmark(libraryItemId, type, time) {
|
||||||
this.bookmarks = this.bookmarks.filter(bm => (bm.libraryItemId !== libraryItemId || bm.time !== time))
|
this.bookmarks = this.bookmarks.filter(bm => (bm.libraryItemId !== libraryItemId || bm.time !== time || bm.type !== type))
|
||||||
}
|
}
|
||||||
|
|
||||||
checkShouldHideSeriesFromContinueListening(seriesId) {
|
checkShouldHideSeriesFromContinueListening(seriesId) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue