mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
430 lines
16 KiB
JavaScript
430 lines
16 KiB
JavaScript
import * as CFI from './epubcfi.js'
|
|
import { TOCProgress, SectionProgress } from './progress.js'
|
|
import { Overlayer } from './overlayer.js'
|
|
import { textWalker } from './text-walker.js'
|
|
|
|
const SEARCH_PREFIX = 'foliate-search:'
|
|
|
|
class History extends EventTarget {
|
|
#arr = []
|
|
#index = -1
|
|
pushState(x) {
|
|
const last = this.#arr[this.#index]
|
|
if (last === x || last?.fraction && last.fraction === x.fraction) return
|
|
this.#arr[++this.#index] = x
|
|
this.#arr.length = this.#index + 1
|
|
this.dispatchEvent(new Event('index-change'))
|
|
}
|
|
replaceState(x) {
|
|
const index = this.#index
|
|
this.#arr[index] = x
|
|
}
|
|
back() {
|
|
const index = this.#index
|
|
if (index <= 0) return
|
|
const detail = { state: this.#arr[index - 1] }
|
|
this.#index = index - 1
|
|
this.dispatchEvent(new CustomEvent('popstate', { detail }))
|
|
this.dispatchEvent(new Event('index-change'))
|
|
}
|
|
forward() {
|
|
const index = this.#index
|
|
if (index >= this.#arr.length - 1) return
|
|
const detail = { state: this.#arr[index + 1] }
|
|
this.#index = index + 1
|
|
this.dispatchEvent(new CustomEvent('popstate', { detail }))
|
|
this.dispatchEvent(new Event('index-change'))
|
|
}
|
|
get canGoBack() {
|
|
return this.#index > 0
|
|
}
|
|
get canGoForward() {
|
|
return this.#index < this.#arr.length - 1
|
|
}
|
|
clear() {
|
|
this.#arr = []
|
|
this.#index = -1
|
|
}
|
|
}
|
|
|
|
const languageInfo = lang => {
|
|
if (!lang) return {}
|
|
try {
|
|
const canonical = Intl.getCanonicalLocales(lang)[0]
|
|
const locale = new Intl.Locale(canonical)
|
|
const isCJK = ['zh', 'ja', 'kr'].includes(locale.language)
|
|
const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction
|
|
return { canonical, locale, isCJK, direction }
|
|
} catch (e) {
|
|
console.warn(e)
|
|
return {}
|
|
}
|
|
}
|
|
|
|
export class View extends HTMLElement {
|
|
#root = this.attachShadow({ mode: 'closed' })
|
|
#sectionProgress
|
|
#tocProgress
|
|
#pageProgress
|
|
#searchResults = new Map()
|
|
#ssml
|
|
#speechDoc
|
|
#speechRanges
|
|
#speechGranularity
|
|
#lastSpeechMark
|
|
isFixedLayout = false
|
|
lastLocation
|
|
history = new History()
|
|
constructor() {
|
|
super()
|
|
this.history.addEventListener('popstate', ({ detail }) => {
|
|
const resolved = this.resolveNavigation(detail.state)
|
|
this.renderer.goTo(resolved)
|
|
})
|
|
}
|
|
async open(book) {
|
|
this.book = book
|
|
this.language = languageInfo(book.metadata?.language)
|
|
|
|
if (book.splitTOCHref && book.getTOCFragment) {
|
|
const ids = book.sections.map(s => s.id)
|
|
this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600)
|
|
const splitHref = book.splitTOCHref.bind(book)
|
|
const getFragment = book.getTOCFragment.bind(book)
|
|
this.#tocProgress = new TOCProgress({
|
|
toc: book.toc ?? [], ids, splitHref, getFragment })
|
|
this.#pageProgress = new TOCProgress({
|
|
toc: book.pageList ?? [], ids, splitHref, getFragment })
|
|
}
|
|
|
|
this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated'
|
|
if (this.isFixedLayout) {
|
|
await import('./fixed-layout.js')
|
|
this.renderer = document.createElement('foliate-fxl')
|
|
} else {
|
|
await import('./paginator.js')
|
|
this.renderer = document.createElement('foliate-paginator')
|
|
}
|
|
this.renderer.setAttribute('exportparts', 'head,foot,filter')
|
|
this.renderer.addEventListener('load', e => this.#onLoad(e.detail))
|
|
this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail))
|
|
this.renderer.addEventListener('create-overlayer', e =>
|
|
e.detail.attach(this.#createOverlayer(e.detail)))
|
|
this.renderer.open(book)
|
|
this.#root.append(this.renderer)
|
|
|
|
if (book.sections.some(section => section.mediaOverlay)) {
|
|
book.media.activeClass ||= '-epub-media-overlay-active'
|
|
const activeClass = book.media.activeClass
|
|
this.mediaOverlay = book.getMediaOverlay()
|
|
let lastActive
|
|
this.mediaOverlay.addEventListener('highlight', e => {
|
|
const resolved = this.resolveNavigation(e.detail.text)
|
|
this.renderer.goTo(resolved)
|
|
.then(() => {
|
|
const { doc } = this.renderer.getContents()
|
|
.find(x => x.index = resolved.index)
|
|
const el = resolved.anchor(doc)
|
|
el.classList.add(activeClass)
|
|
lastActive = new WeakRef(el)
|
|
})
|
|
})
|
|
this.mediaOverlay.addEventListener('unhighlight', () => {
|
|
lastActive?.deref()?.classList?.remove(activeClass)
|
|
})
|
|
}
|
|
}
|
|
close() {
|
|
this.renderer?.destroy()
|
|
this.renderer?.remove()
|
|
this.#sectionProgress = null
|
|
this.#tocProgress = null
|
|
this.#pageProgress = null
|
|
this.#searchResults = new Map()
|
|
this.lastLocation = null
|
|
this.history.clear()
|
|
this.tts = null
|
|
this.mediaOverlay = null
|
|
}
|
|
goToTextStart() {
|
|
return this.goTo(this.book.landmarks
|
|
?.find(m => m.type.includes('bodymatter') || m.type.includes('text'))
|
|
?.href ?? this.book.sections.findIndex(s => s.linear !== 'no'))
|
|
}
|
|
async init({ lastLocation, showTextStart }) {
|
|
const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null
|
|
if (resolved) {
|
|
await this.renderer.goTo(resolved)
|
|
this.history.pushState(lastLocation)
|
|
}
|
|
else if (showTextStart) await this.goToTextStart()
|
|
else {
|
|
this.history.pushState(0)
|
|
await this.next()
|
|
}
|
|
}
|
|
#emit(name, detail, cancelable) {
|
|
return this.dispatchEvent(new CustomEvent(name, { detail, cancelable }))
|
|
}
|
|
#onRelocate({ reason, range, index, fraction, size }) {
|
|
const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {}
|
|
const tocItem = this.#tocProgress?.getProgress(index, range)
|
|
const pageItem = this.#pageProgress?.getProgress(index, range)
|
|
const cfi = this.getCFI(index, range)
|
|
this.lastLocation = { ...progress, tocItem, pageItem, cfi, range }
|
|
if (reason === 'snap' || reason === 'page' || reason === 'scroll')
|
|
this.history.replaceState(cfi)
|
|
this.#emit('relocate', this.lastLocation)
|
|
}
|
|
#onLoad({ doc, index }) {
|
|
// set language and dir if not already set
|
|
doc.documentElement.lang ||= this.language.canonical ?? ''
|
|
if (!this.language.isCJK)
|
|
doc.documentElement.dir ||= this.language.direction ?? ''
|
|
|
|
this.#handleLinks(doc, index)
|
|
|
|
this.#emit('load', { doc, index })
|
|
}
|
|
#handleLinks(doc, index) {
|
|
const { book } = this
|
|
const section = book.sections[index]
|
|
for (const a of doc.querySelectorAll('a[href]'))
|
|
a.addEventListener('click', e => {
|
|
e.preventDefault()
|
|
const href_ = a.getAttribute('href')
|
|
const href = section?.resolveHref?.(href_) ?? href_
|
|
if (book?.isExternal?.(href))
|
|
Promise.resolve(this.#emit('external-link', { a, href }, true))
|
|
.then(x => x ? globalThis.open(href, '_blank') : null)
|
|
.catch(e => console.error(e))
|
|
else Promise.resolve(this.#emit('link', { a, href }, true))
|
|
.then(x => x ? this.goTo(href) : null)
|
|
.catch(e => console.error(e))
|
|
})
|
|
}
|
|
async addAnnotation(annotation, remove) {
|
|
const { value } = annotation
|
|
if (value.startsWith(SEARCH_PREFIX)) {
|
|
const cfi = value.replace(SEARCH_PREFIX, '')
|
|
const { index, anchor } = await this.resolveNavigation(cfi)
|
|
const obj = this.#getOverlayer(index)
|
|
if (obj) {
|
|
const { overlayer, doc } = obj
|
|
if (remove) {
|
|
overlayer.remove(value)
|
|
return
|
|
}
|
|
const range = doc ? anchor(doc) : anchor
|
|
overlayer.add(value, range, Overlayer.outline)
|
|
}
|
|
return
|
|
}
|
|
const { index, anchor } = await this.resolveNavigation(value)
|
|
const obj = this.#getOverlayer(index)
|
|
if (obj) {
|
|
const { overlayer, doc } = obj
|
|
overlayer.remove(value)
|
|
if (!remove) {
|
|
const range = doc ? anchor(doc) : anchor
|
|
const draw = (func, opts) => overlayer.add(value, range, func, opts)
|
|
this.#emit('draw-annotation', { draw, annotation, doc, range })
|
|
}
|
|
}
|
|
const label = this.#tocProgress.getProgress(index)?.label ?? ''
|
|
return { index, label }
|
|
}
|
|
deleteAnnotation(annotation) {
|
|
return this.addAnnotation(annotation, true)
|
|
}
|
|
#getOverlayer(index) {
|
|
return this.renderer.getContents()
|
|
.find(x => x.index === index && x.overlayer)
|
|
}
|
|
#createOverlayer({ doc, index }) {
|
|
const overlayer = new Overlayer()
|
|
doc.addEventListener('click', e => {
|
|
const [value, range] = overlayer.hitTest(e)
|
|
if (value && !value.startsWith(SEARCH_PREFIX)) {
|
|
this.#emit('show-annotation', { value, index, range })
|
|
}
|
|
}, false)
|
|
|
|
const list = this.#searchResults.get(index)
|
|
if (list) for (const item of list) this.addAnnotation(item)
|
|
|
|
this.#emit('create-overlay', { index })
|
|
return overlayer
|
|
}
|
|
async showAnnotation(annotation) {
|
|
const { value } = annotation
|
|
const resolved = await this.goTo(value)
|
|
if (resolved) {
|
|
const { index, anchor } = resolved
|
|
const { doc } = this.#getOverlayer(index)
|
|
const range = anchor(doc)
|
|
this.#emit('show-annotation', { value, index, range })
|
|
}
|
|
}
|
|
getCFI(index, range) {
|
|
const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index)
|
|
if (!range) return baseCFI
|
|
return CFI.joinIndir(baseCFI, CFI.fromRange(range))
|
|
}
|
|
resolveCFI(cfi) {
|
|
if (this.book.resolveCFI)
|
|
return this.book.resolveCFI(cfi)
|
|
else {
|
|
const parts = CFI.parse(cfi)
|
|
const index = CFI.fake.toIndex((parts.parent ?? parts).shift())
|
|
const anchor = doc => CFI.toRange(doc, parts)
|
|
return { index, anchor }
|
|
}
|
|
}
|
|
resolveNavigation(target) {
|
|
try {
|
|
if (typeof target === 'number') return { index: target }
|
|
if (typeof target.fraction === 'number') {
|
|
const [index, anchor] = this.#sectionProgress.getSection(target.fraction)
|
|
return { index, anchor }
|
|
}
|
|
if (CFI.isCFI.test(target)) return this.resolveCFI(target)
|
|
return this.book.resolveHref(target)
|
|
} catch (e) {
|
|
console.error(e)
|
|
console.error(`Could not resolve target ${target}`)
|
|
}
|
|
}
|
|
async goTo(target) {
|
|
const resolved = this.resolveNavigation(target)
|
|
try {
|
|
await this.renderer.goTo(resolved)
|
|
this.history.pushState(target)
|
|
return resolved
|
|
} catch(e) {
|
|
console.error(e)
|
|
console.error(`Could not go to ${target}`)
|
|
}
|
|
}
|
|
async goToFraction(frac) {
|
|
const [index, anchor] = this.#sectionProgress.getSection(frac)
|
|
await this.renderer.goTo({ index, anchor })
|
|
this.history.pushState({ fraction: frac })
|
|
}
|
|
async select(target) {
|
|
try {
|
|
const obj = await this.resolveNavigation(target)
|
|
await this.renderer.goTo({ ...obj, select: true })
|
|
this.history.pushState(target)
|
|
} catch(e) {
|
|
console.error(e)
|
|
console.error(`Could not go to ${target}`)
|
|
}
|
|
}
|
|
deselect() {
|
|
for (const { doc } of this.renderer.getContents())
|
|
doc.defaultView.getSelection().removeAllRanges()
|
|
}
|
|
getProgressOf(index, range) {
|
|
const tocItem = this.#tocProgress?.getProgress(index, range)
|
|
const pageItem = this.#pageProgress?.getProgress(index, range)
|
|
return { tocItem, pageItem }
|
|
}
|
|
async getTOCItemOf(target) {
|
|
try {
|
|
const { index, anchor } = await this.resolveNavigation(target)
|
|
const doc = await this.book.sections[index].createDocument()
|
|
const frag = anchor(doc)
|
|
const isRange = frag instanceof Range
|
|
const range = isRange ? frag : doc.createRange()
|
|
if (!isRange) range.selectNodeContents(frag)
|
|
return this.#tocProgress.getProgress(index, range)
|
|
} catch(e) {
|
|
console.error(e)
|
|
console.error(`Could not get ${target}`)
|
|
}
|
|
}
|
|
async prev(distance) {
|
|
await this.renderer.prev(distance)
|
|
}
|
|
async next(distance) {
|
|
await this.renderer.next(distance)
|
|
}
|
|
goLeft() {
|
|
return this.book.dir === 'rtl' ? this.next() : this.prev()
|
|
}
|
|
goRight() {
|
|
return this.book.dir === 'rtl' ? this.prev() : this.next()
|
|
}
|
|
async * #searchSection(matcher, query, index) {
|
|
const doc = await this.book.sections[index].createDocument()
|
|
for (const { range, excerpt } of matcher(doc, query))
|
|
yield { cfi: this.getCFI(index, range), excerpt }
|
|
}
|
|
async * #searchBook(matcher, query) {
|
|
const { sections } = this.book
|
|
for (const [index, { createDocument }] of sections.entries()) {
|
|
if (!createDocument) continue
|
|
const doc = await createDocument()
|
|
const subitems = Array.from(matcher(doc, query), ({ range, excerpt }) =>
|
|
({ cfi: this.getCFI(index, range), excerpt }))
|
|
const progress = (index + 1) / sections.length
|
|
yield { progress }
|
|
if (subitems.length) yield { index, subitems }
|
|
}
|
|
}
|
|
async * search(opts) {
|
|
this.clearSearch()
|
|
const { searchMatcher } = await import('./search.js')
|
|
const { query, index } = opts
|
|
const matcher = searchMatcher(textWalker,
|
|
{ defaultLocale: this.language, ...opts })
|
|
const iter = index != null
|
|
? this.#searchSection(matcher, query, index)
|
|
: this.#searchBook(matcher, query)
|
|
|
|
const list = []
|
|
this.#searchResults.set(index, list)
|
|
|
|
for await (const result of iter) {
|
|
if (result.subitems){
|
|
const list = result.subitems
|
|
.map(({ cfi }) => ({ value: SEARCH_PREFIX + cfi }))
|
|
this.#searchResults.set(result.index, list)
|
|
for (const item of list) this.addAnnotation(item)
|
|
yield {
|
|
label: this.#tocProgress.getProgress(result.index)?.label ?? '',
|
|
subitems: result.subitems,
|
|
}
|
|
}
|
|
else {
|
|
if (result.cfi) {
|
|
const item = { value: SEARCH_PREFIX + result.cfi }
|
|
list.push(item)
|
|
this.addAnnotation(item)
|
|
}
|
|
yield result
|
|
}
|
|
}
|
|
yield 'done'
|
|
}
|
|
clearSearch() {
|
|
for (const list of this.#searchResults.values())
|
|
for (const item of list) this.deleteAnnotation(item)
|
|
this.#searchResults.clear()
|
|
}
|
|
async initTTS() {
|
|
const doc = this.renderer.getContents()[0].doc
|
|
if (this.tts && this.tts.doc === doc) return
|
|
const { TTS } = await import('./tts.js')
|
|
this.tts = new TTS(doc, textWalker, range =>
|
|
this.renderer.scrollToAnchor(range, true))
|
|
}
|
|
startMediaOverlay() {
|
|
const { index } = this.renderer.getContents()[0]
|
|
return this.mediaOverlay.start(index)
|
|
}
|
|
}
|
|
|
|
customElements.define('foliate-view', View)
|