mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
Massively simplify margin/padding and header/footer layout and eliminate unnecessary resizes and resize loops. Also remove layout and style API from View, as these are only applicable to the paginator.
324 lines
12 KiB
JavaScript
324 lines
12 KiB
JavaScript
import './view.js'
|
|
import { createTOCView } from './ui/tree.js'
|
|
import { createMenu } from './ui/menu.js'
|
|
import { Overlayer } from '../foliate-js/overlayer.js'
|
|
|
|
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 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 }
|
|
}
|
|
|
|
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 getView = async 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 Error('File not found')
|
|
else if (await isZip(file)) {
|
|
const loader = await makeZipLoader(file)
|
|
if (isCBZ(file)) {
|
|
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 {
|
|
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 Error('File type not supported')
|
|
const view = document.createElement('foliate-view')
|
|
document.body.append(view)
|
|
await view.open(book)
|
|
return view
|
|
}
|
|
|
|
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' })
|
|
|
|
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 = await getView(file)
|
|
this.view.addEventListener('load', this.#onLoad.bind(this))
|
|
this.view.addEventListener('relocate', this.#onRelocate.bind(this))
|
|
|
|
const { book } = this.view
|
|
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)))
|
|
const sizes = book.sections.filter(s => s.linear !== 'no').map(s => s.size)
|
|
if (sizes.length < 100) {
|
|
const total = sizes.reduce((a, b) => a + b, 0)
|
|
let sum = 0
|
|
for (const size of sizes.slice(0, -1)) {
|
|
sum += size
|
|
const option = document.createElement('option')
|
|
option.value = sum / total
|
|
$('#tick-marks').append(option)
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', this.#handleKeydown.bind(this))
|
|
|
|
const title = book.metadata?.title ?? 'Untitled Book'
|
|
document.title = title
|
|
$('#side-bar-title').innerText = title
|
|
const author = book.metadata?.author
|
|
$('#side-bar-author').innerText = typeof author === 'string' ? author
|
|
: author
|
|
?.map(author => typeof author === 'string' ? author : author.name)
|
|
?.join(', ')
|
|
?? ''
|
|
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
|
|
if (k === 'ArrowLeft' || k === 'h') this.view.goLeft()
|
|
else if(k === 'ArrowRight' || k === 'l') this.view.goRight()
|
|
}
|
|
#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) fetch(url)
|
|
.then(res => res.blob())
|
|
.then(blob => open(new File([blob], new URL(url).pathname)))
|
|
.catch(e => console.error(e))
|
|
else dropTarget.style.visibility = 'visible'
|