Files
foliate-js/view.js
T
john-okeefe 82142f8960 style: apply code formatting to core files
- Format comic-book.js, epub.js, and view.js with consistent style
- No functional changes, formatting only
2026-04-16 20:04:05 -04:00

670 lines
21 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:";
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);