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 isPDF = async (file) => { const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer()); return ( arr[0] === 0x25 && arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46 && arr[4] === 0x2d ); }; 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)) { // Check if CBZ is EPUB-structured (has content.opf) const hasOPF = loader.entries.some((e) => e.filename === "content.opf"); if (hasOPF) { console.log("[view] CBZ has OPF, treating as EPUB"); const { EPUB } = await import("./epub.js"); book = await new EPUB(loader).init(); } else { console.log("[view] CBZ is image-only, using comic-book loader"); 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 if (await isPDF(file)) { const { makePDF } = await import("./pdf.js"); book = await makePDF(file); } 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(); #searchDraw; #searchDrawOptions; #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"); 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, this.#searchDraw, this.#searchDrawOptions); } 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(); } 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(); this.#searchDraw = opts.draw ?? Overlayer.outline; this.#searchDrawOptions = opts.drawOptions; 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", highlight) { 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, highlight || ((range) => this.renderer.scrollToAnchor(range, true)), granularity, ); } startMediaOverlay() { const { index } = this.renderer.getContents()[0]; return this.mediaOverlay.start(index); } } customElements.define("foliate-view", View);