mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
- Format comic-book.js, epub.js, and view.js with consistent style - No functional changes, formatting only
670 lines
21 KiB
JavaScript
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);
|