diff --git a/reader.js b/reader.js index f62102c..ff7be63 100644 --- a/reader.js +++ b/reader.js @@ -1,7 +1,7 @@ -import './view.js' -import { createTOCView } from './ui/tree.js' -import { createMenu } from './ui/menu.js' -import { Overlayer } from './overlayer.js' +import "./view.js"; +import { createTOCView } from "./ui/tree.js"; +import { createMenu } from "./ui/menu.js"; +import { Overlayer } from "./overlayer.js"; const getCSS = ({ spacing, justify, hyphenate }) => ` @namespace epub "http://www.idpf.org/2007/ops"; @@ -16,9 +16,9 @@ const getCSS = ({ spacing, justify, hyphenate }) => ` } p, li, blockquote, dd { line-height: ${spacing}; - text-align: ${justify ? 'justify' : 'start'}; - -webkit-hyphens: ${hyphenate ? 'auto' : 'manual'}; - hyphens: ${hyphenate ? 'auto' : 'manual'}; + 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; @@ -40,200 +40,231 @@ const getCSS = ({ spacing, justify, hyphenate }) => ` aside[epub|type~="rearnote"] { display: none; } -` +`; -const $ = document.querySelector.bind(document) +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 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 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 formatOneContributor = (contributor) => + typeof contributor === "string" + ? contributor + : formatLanguageMap(contributor?.name); -const formatContributor = contributor => Array.isArray(contributor) +const formatContributor = (contributor) => + Array.isArray(contributor) ? listFormat.format(contributor.map(formatOneContributor)) - : formatOneContributor(contributor) + : formatOneContributor(contributor); class Reader { - #tocView - style = { - spacing: 1.4, - justify: true, - hyphenate: true, + #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; + 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); } - annotations = new Map() - annotationsByValue = new Map() - closeSideBar() { - $('#dimming-overlay').classList.remove('show') - $('#side-bar').classList.remove('show') + + document.addEventListener("keydown", this.#handleKeydown.bind(this)); + + 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); } - 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 - 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)) - - 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) - }) + // 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 - if (k === 'ArrowLeft' || k === 'h') this.view.goLeft() - else if(k === 'ArrowRight' || k === 'l') this.view.goRight() + } + #handleKeydown(event) { + const k = event.key; + const renderer = this.view?.renderer; + + // Check if in panel mode + if (renderer?.hasAttribute?.("panel-mode")) { + if (k === "ArrowRight" || k === "l") { + event.preventDefault(); + renderer.nextPanel(); + } else if (k === "ArrowLeft" || k === "h") { + event.preventDefault(); + renderer.prevPanel(); + } else if (k === "Escape" || k === "p") { + event.preventDefault(); + renderer.togglePanelMode(); + } + return; } - #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) + if (k === "ArrowLeft" || k === "h") this.view.goLeft(); + else if (k === "ArrowRight" || k === "l") this.view.goRight(); + else if (k === "p") { + event.preventDefault(); + renderer?.togglePanelMode?.(); } + } + #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 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) +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()) +$("#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' +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";