UNPKG

foliate-js

Version:
583 lines (557 loc) 22.3 kB
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:' const isZip = async file => { const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer()) return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04 } const isCBZ = ({ name, type }) => type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz') const isFB2 = ({ name, type }) => type === 'application/x-fictionbook+xml' || name.endsWith('.fb2') const isFBZ = ({ name, type }) => type === 'application/x-zip-compressed-fb2' || name.endsWith('.fb2.zip') || name.endsWith('.fbz') const makeZipLoader = async file => { const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = await import('./vendor/zip.js') configure({ useWebWorkers: false }) const reader = new ZipReader(new BlobReader(file)) const entries = await reader.getEntries() const map = new Map(entries.map(entry => [entry.filename, entry])) const load = f => (name, ...args) => map.has(name) ? f(map.get(name), ...args) : null const loadText = load(entry => entry.getData(new TextWriter())) const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type))) const getSize = name => map.get(name)?.uncompressedSize ?? 0 return { entries, loadText, loadBlob, getSize } } const getFileEntries = async entry => entry.isFile ? entry : (await Promise.all(Array.from( await new Promise((resolve, reject) => entry.createReader() .readEntries(entries => resolve(entries), error => reject(error))), getFileEntries))).flat() const makeDirectoryLoader = async entry => { const entries = await getFileEntries(entry) const files = await Promise.all( entries.map(entry => new Promise((resolve, reject) => entry.file(file => resolve([file, entry.fullPath]), error => reject(error))))) const map = new Map(files.map(([file, path]) => [path.replace(entry.fullPath + '/', ''), file])) const decoder = new TextDecoder() const decode = x => x ? decoder.decode(x) : null const getBuffer = name => map.get(name)?.arrayBuffer() ?? null const loadText = async name => decode(await getBuffer(name)) const loadBlob = name => map.get(name) const getSize = name => map.get(name)?.size ?? 0 return { loadText, loadBlob, getSize } } export class ResponseError extends Error {} export class NotFoundError extends Error {} export class UnsupportedTypeError extends Error {} const fetchFile = async url => { const res = await fetch(url) if (!res.ok) throw new ResponseError( `${res.status} ${res.statusText}`, { cause: res }) return new File([await res.blob()], new URL(res.url).pathname) } export const makeBook = async file => { if (typeof file === 'string') file = await fetchFile(file) let book if (file.isDirectory) { const loader = await makeDirectoryLoader(file) const { EPUB } = await import('./epub.js') book = await new EPUB(loader).init() } else if (!file.size) throw new NotFoundError('File not found') else if (await isZip(file)) { const loader = await makeZipLoader(file) if (isCBZ(file)) { const { makeComicBook } = await import('./comic-book.js') book = makeComicBook(loader, file) } else if (isFBZ(file)) { const { makeFB2 } = await import('./fb2.js') const { entries } = loader const entry = entries.find(entry => entry.filename.endsWith('.fb2')) const blob = await loader.loadBlob((entry ?? entries[0]).filename) book = await makeFB2(blob) } else { const { EPUB } = await import('./epub.js') book = await new EPUB(loader).init() } } else { const { isMOBI, MOBI } = await import('./mobi.js') if (await isMOBI(file)) { const fflate = await import('./vendor/fflate.js') book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file) } else if (isFB2(file)) { const { makeFB2 } = await import('./fb2.js') book = await makeFB2(file) } } if (!book) throw new UnsupportedTypeError('File type not supported') return book } class CursorAutohider { #timeout #el #check #state constructor(el, check, state = {}) { this.#el = el this.#check = check this.#state = state if (this.#state.hidden) this.hide() this.#el.addEventListener('mousemove', ({ screenX, screenY }) => { // check if it actually moved if (screenX === this.#state.x && screenY === this.#state.y) return this.#state.x = screenX, this.#state.y = screenY this.show() if (this.#timeout) clearTimeout(this.#timeout) if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000) }, false) } cloneFor(el) { return new CursorAutohider(el, this.#check, this.#state) } hide() { this.#el.style.cursor = 'none' this.#state.hidden = true } show() { this.#el.style.removeProperty('cursor') this.#state.hidden = false } } 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() #cursorAutohider = new CursorAutohider(this, () => this.hasAttribute('autohide-cursor')) 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) { if (typeof book === 'string' || typeof book.arrayBuffer === 'function' || book.isDirectory) book = await makeBook(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() await this.#tocProgress.init({ toc: book.toc ?? [], ids, splitHref, getFragment }) this.#pageProgress = new TOCProgress() await this.#pageProgress.init({ 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,container') 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)) { const activeClass = book.media.activeClass const playbackActiveClass = book.media.playbackActiveClass 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) if (playbackActiveClass) el.ownerDocument .documentElement.classList.add(playbackActiveClass) lastActive = new WeakRef(el) }) }) this.mediaOverlay.addEventListener('unhighlight', () => { const el = lastActive?.deref() if (el) { el.classList.remove(activeClass) if (playbackActiveClass) el.ownerDocument .documentElement.classList.remove(playbackActiveClass) } }) } } 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.#cursorAutohider.cloneFor(doc.documentElement) this.#emit('load', { doc, index }) } #handleLinks(doc, index) { const { book } = this const section = book.sections[index] doc.addEventListener('click', e => { const a = e.target.closest('a[href]') if (!a) return 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) 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() } getSectionFractions() { return (this.#sectionProgress?.sectionFractions ?? []) .map(x => x + Number.EPSILON) } 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(granularity = 'word') { 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), granularity) } startMediaOverlay() { const { index } = this.renderer.getContents()[0] return this.mediaOverlay.start(index) } } customElements.define('foliate-view', View)