import "./view.js"; import { createTOCView } from "./ui/tree.js"; import { createMenu } from "./ui/menu.js"; import { Overlayer } from "./overlayer.js"; import { FixedLayout } from "./fixed-layout.js"; const getCSS = ({ spacing, justify, hyphenate }) => ` @namespace epub "http://www.idpf.org/2007/ops"; html { color-scheme: light dark; } /* https://github.com/whatwg/html/issues/5426 */ @media (prefers-color-scheme: dark) { a:link { color: lightblue; } } p, li, blockquote, dd { line-height: ${spacing}; text-align: ${justify ? "justify" : "start"}; -webkit-hyphens: ${hyphenate ? "auto" : "manual"}; hyphens: ${hyphenate ? "auto" : "manual"}; -webkit-hyphenate-limit-before: 3; -webkit-hyphenate-limit-after: 2; -webkit-hyphenate-limit-lines: 2; hanging-punctuation: allow-end last; widows: 2; } /* prevent the above from overriding the align attribute */ [align="left"] { text-align: left; } [align="right"] { text-align: right; } [align="center"] { text-align: center; } [align="justify"] { text-align: justify; } pre { white-space: pre-wrap !important; } aside[epub|type~="endnote"], aside[epub|type~="footnote"], aside[epub|type~="note"], aside[epub|type~="rearnote"] { display: none; } `; const $ = document.querySelector.bind(document); const locales = "en"; const percentFormat = new Intl.NumberFormat(locales, { style: "percent" }); const listFormat = new Intl.ListFormat(locales, { style: "short", type: "conjunction", }); const formatLanguageMap = (x) => { if (!x) return ""; if (typeof x === "string") return x; const keys = Object.keys(x); return x[keys[0]]; }; const formatOneContributor = (contributor) => typeof contributor === "string" ? contributor : formatLanguageMap(contributor?.name); const formatContributor = (contributor) => Array.isArray(contributor) ? listFormat.format(contributor.map(formatOneContributor)) : formatOneContributor(contributor); class Reader { #tocView; style = { spacing: 1.4, justify: true, hyphenate: true, }; annotations = new Map(); annotationsByValue = new Map(); closeSideBar() { $("#dimming-overlay").classList.remove("show"); $("#side-bar").classList.remove("show"); } constructor() { $("#side-bar-button").addEventListener("click", () => { $("#dimming-overlay").classList.add("show"); $("#side-bar").classList.add("show"); }); $("#dimming-overlay").addEventListener("click", () => this.closeSideBar()); const menu = createMenu([ { name: "layout", label: "Layout", type: "radio", items: [ ["Paginated", "paginated"], ["Scrolled", "scrolled"], ], onclick: (value) => { this.view?.renderer.setAttribute("flow", value); }, }, ]); menu.element.classList.add("menu"); $("#menu-button").append(menu.element); $("#menu-button > button").addEventListener("click", () => menu.element.classList.toggle("show"), ); menu.groups.layout.select("paginated"); } async open(file) { this.view = document.createElement("foliate-view"); document.body.append(this.view); await this.view.open(file); this.view.addEventListener("load", this.#onLoad.bind(this)); this.view.addEventListener("relocate", this.#onRelocate.bind(this)); const { book } = this.view; // DEBUG: Log book and renderer info console.log("[Reader Debug] ๐Ÿ“– Book opened:", { title: book.metadata?.title, type: file.name, rendition: book.rendition, isFixedLayout: this.view.isFixedLayout, renderer: this.view.renderer?.localName, rendererClass: this.view.renderer?.constructor.name, isFixedLayoutInstance: this.view.renderer instanceof FixedLayout, }); book.transformTarget?.addEventListener("data", ({ detail }) => { detail.data = Promise.resolve(detail.data).catch((e) => { console.error(new Error(`Failed to load ${detail.name}`, { cause: e })); return ""; }); }); this.view.renderer.setStyles?.(getCSS(this.style)); this.view.renderer.next(); $("#header-bar").style.visibility = "visible"; $("#nav-bar").style.visibility = "visible"; $("#left-button").addEventListener("click", () => this.view.goLeft()); $("#right-button").addEventListener("click", () => this.view.goRight()); const slider = $("#progress-slider"); slider.dir = book.dir; slider.addEventListener("input", (e) => this.view.goToFraction(parseFloat(e.target.value)), ); for (const fraction of this.view.getSectionFractions()) { const option = document.createElement("option"); option.value = fraction; $("#tick-marks").append(option); } document.addEventListener("keydown", this.#handleKeydown.bind(this)); console.log("[Reader Debug] ๐Ÿ”ง Setting up zoom control listeners..."); // Add zoom control listeners $("#zoom-in").addEventListener("click", () => { console.log("[Reader Debug] ๐Ÿ” Zoom in button clicked"); const renderer = this.view.renderer; console.log( "[Reader Debug] Renderer:", renderer?.localName, renderer?.constructor.name, ); console.log( "[Reader Debug] Is FixedLayout?", renderer instanceof FixedLayout, ); if (renderer instanceof FixedLayout) { const current = renderer.getAttribute("zoom") || 1; const newZoom = Math.min(10, parseFloat(current) * 1.2); renderer.setAttribute("zoom", newZoom); $("#zoom-reset").textContent = `${Math.round(newZoom * 100)}%`; console.log("[Reader Debug] โœ… Zoom applied:", current, "->", newZoom); } else { console.log("[Reader Debug] โŒ Not a FixedLayout renderer, ignoring"); } }); $("#zoom-out").addEventListener("click", () => { console.log("[Reader Debug] ๐Ÿ” Zoom out button clicked"); const renderer = this.view.renderer; console.log( "[Reader Debug] Is FixedLayout?", renderer instanceof FixedLayout, ); if (renderer instanceof FixedLayout) { const current = renderer.getAttribute("zoom") || 1; const newZoom = Math.max(0.1, parseFloat(current) / 1.2); renderer.setAttribute("zoom", newZoom); $("#zoom-reset").textContent = `${Math.round(newZoom * 100)}%`; console.log("[Reader Debug] โœ… Zoom applied:", current, "->", newZoom); } else { console.log("[Reader Debug] โŒ Not a FixedLayout renderer, ignoring"); } }); $("#zoom-reset").addEventListener("click", () => { console.log("[Reader Debug] ๐Ÿ”„ Reset zoom button clicked"); const renderer = this.view.renderer; console.log( "[Reader Debug] Is FixedLayout?", renderer instanceof FixedLayout, ); if (renderer instanceof FixedLayout) { renderer.resetZoom(); renderer.dragOffset = { x: 0, y: 0 }; $("#zoom-reset").textContent = "100%"; console.log("[Reader Debug] โœ… Zoom reset"); } else { console.log("[Reader Debug] โŒ Not a FixedLayout renderer, ignoring"); } }); $("#magnifier-toggle").addEventListener("click", () => { console.log("[Reader Debug] ๐Ÿ” Magnifier toggle button clicked"); const renderer = this.view.renderer; renderer.addEventListener("zoom", (e) => { $("#zoom-reset").textContent = `${Math.round(e.detail.scale * 100)}%`; }); console.log( "[Reader Debug] Is FixedLayout?", renderer instanceof FixedLayout, ); if (renderer instanceof FixedLayout) { renderer.toggleMagnifier(); console.log("[Reader Debug] โœ… Magnifier toggled"); } else { console.log("[Reader Debug] โŒ Not a FixedLayout renderer, ignoring"); } }); const modeToggle = $("#interaction-mode-toggle"); modeToggle.addEventListener("click", () => { const renderer = this.view.renderer; if (!(renderer instanceof FixedLayout)) return; const current = renderer.getAttribute("interaction-mode") || "select"; const next = current === "select" ? "pan" : "select"; renderer.setAttribute("interaction-mode", next); modeToggle.title = next === "select" ? "Select Mode" : "Pan Mode"; const svg = modeToggle.querySelector("svg"); svg.innerHTML = next === "select" ? '' : ''; }); this.view.addEventListener("load", () => { const renderer = this.view.renderer; if (renderer instanceof FixedLayout && renderer.isPDF) { modeToggle.style.display = ""; } }); console.log("[Reader Debug] โœ… Zoom control listeners configured"); const title = formatLanguageMap(book.metadata?.title) || "Untitled Book"; document.title = title; $("#side-bar-title").innerText = title; $("#side-bar-author").innerText = formatContributor(book.metadata?.author); Promise.resolve(book.getCover?.())?.then((blob) => blob ? ($("#side-bar-cover").src = URL.createObjectURL(blob)) : null, ); const toc = book.toc; if (toc) { this.#tocView = createTOCView(toc, (href) => { this.view.goTo(href).catch((e) => console.error(e)); this.closeSideBar(); }); $("#toc-view").append(this.#tocView.element); } // load and show highlights embedded in the file by Calibre const bookmarks = await book.getCalibreBookmarks?.(); if (bookmarks) { const { fromCalibreHighlight } = await import("./epubcfi.js"); for (const obj of bookmarks) { if (obj.type === "highlight") { const value = fromCalibreHighlight(obj); const color = obj.style.which; const note = obj.notes; const annotation = { value, color, note }; const list = this.annotations.get(obj.spine_index); if (list) list.push(annotation); else this.annotations.set(obj.spine_index, [annotation]); this.annotationsByValue.set(value, annotation); } } this.view.addEventListener("create-overlay", (e) => { const { index } = e.detail; const list = this.annotations.get(index); if (list) for (const annotation of list) this.view.addAnnotation(annotation); }); this.view.addEventListener("draw-annotation", (e) => { const { draw, annotation } = e.detail; const { color } = annotation; draw(Overlayer.highlight, { color }); }); this.view.addEventListener("show-annotation", (e) => { const annotation = this.annotationsByValue.get(e.detail.value); if (annotation.note) alert(annotation.note); }); } } #handleKeydown(event) { const k = event.key; console.log("[Reader Debug] โŒจ๏ธ Key pressed:", k); if (k === "ArrowLeft" || k === "h") this.view.goLeft(); else if (k === "ArrowRight" || k === "l") this.view.goRight(); else if (k === "+" || k === "=") { console.log("[Reader Debug] ๐Ÿ” Zoom in via keyboard"); const renderer = this.view.renderer; if (renderer instanceof FixedLayout) { const current = renderer.getAttribute("zoom") || 1; const newZoom = Math.min(10, parseFloat(current) * 1.2); renderer.setAttribute("zoom", newZoom); $("#zoom-reset").textContent = `${Math.round(newZoom * 100)}%`; console.log( "[Reader Debug] โœ… Keyboard zoom applied:", current, "->", newZoom, ); } } else if (k === "-" || k === "_") { console.log("[Reader Debug] ๐Ÿ” Zoom out via keyboard"); const renderer = this.view.renderer; if (renderer instanceof FixedLayout) { const current = renderer.getAttribute("zoom") || 1; const newZoom = Math.max(0.1, parseFloat(current) / 1.2); renderer.setAttribute("zoom", newZoom); $("#zoom-reset").textContent = `${Math.round(newZoom * 100)}%`; console.log( "[Reader Debug] โœ… Keyboard zoom applied:", current, "->", newZoom, ); } } else if (k === "0") { console.log("[Reader Debug] ๐Ÿ”„ Reset zoom via keyboard"); const renderer = this.view.renderer; if (renderer instanceof FixedLayout) { renderer.resetZoom(); renderer.dragOffset = { x: 0, y: 0 }; $("#zoom-reset").textContent = "100%"; console.log("[Reader Debug] โœ… Keyboard zoom reset"); } } else if (k === "Escape") { const renderer = this.view.renderer; if (renderer instanceof FixedLayout && renderer.zoomMagnifierEnabled) { renderer.toggleMagnifier(); } } } #onLoad({ detail: { doc } }) { doc.addEventListener("keydown", this.#handleKeydown.bind(this)); } #onRelocate({ detail }) { const { fraction, location, tocItem, pageItem } = detail; const percent = percentFormat.format(fraction); const loc = pageItem ? `Page ${pageItem.label}` : `Loc ${location.current}`; const slider = $("#progress-slider"); slider.style.visibility = "visible"; slider.value = fraction; slider.title = `${percent} ยท ${loc}`; if (tocItem?.href) this.#tocView?.setCurrentHref?.(tocItem.href); } } const open = async (file) => { document.body.removeChild($("#drop-target")); const reader = new Reader(); globalThis.reader = reader; await reader.open(file); }; const dragOverHandler = (e) => e.preventDefault(); const dropHandler = (e) => { e.preventDefault(); const item = Array.from(e.dataTransfer.items).find( (item) => item.kind === "file", ); if (item) { const entry = item.webkitGetAsEntry(); open(entry.isFile ? item.getAsFile() : entry).catch((e) => console.error(e), ); } }; const dropTarget = $("#drop-target"); dropTarget.addEventListener("drop", dropHandler); dropTarget.addEventListener("dragover", dragOverHandler); $("#file-input").addEventListener("change", (e) => open(e.target.files[0]).catch((e) => console.error(e)), ); $("#file-button").addEventListener("click", () => $("#file-input").click()); const params = new URLSearchParams(location.search); const url = params.get("url"); if (url) open(url).catch((e) => console.error(e)); else dropTarget.style.visibility = "visible";