mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
Add sample reader
This commit is contained in:
+74
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<meta charset="utf-8">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; script-src 'self'; style-src 'self' blob: 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self' blob: data:; frame-src blob: data:; object-src 'none'; form-action 'none';">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
#drop-target {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#nav-bar {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 6px;
|
||||
opacity: .25;
|
||||
transition: opacity 250ms ease;
|
||||
}
|
||||
#nav-bar:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
#nav-bar button {
|
||||
padding: 3px;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
#nav-bar button:hover {
|
||||
background: rgba(0, 0, 0, .1);
|
||||
}
|
||||
#progress-label {
|
||||
font-size: small;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.icon {
|
||||
display: block;
|
||||
fill: none;
|
||||
stroke: currentcolor;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
</style>
|
||||
<input type="file" id="file-input" hidden>
|
||||
<div id="drop-target" class="filter">Drop a file here to open it.</div>
|
||||
<div id="nav-bar">
|
||||
<button id="left-button" aria-label="Turn left page">
|
||||
<svg class="icon" width="24" height="24" aria-hidden="true">
|
||||
<path d="M 15 6 L 9 12 L 15 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="progress-label"></div>
|
||||
<button id="right-button" aria-label="Turn right page">
|
||||
<svg class="icon" width="24" height="24" aria-hidden="true">
|
||||
<path d="M 9 6 L 15 12 L 9 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<script src="vendor/zip-no-worker.min.js"></script>
|
||||
<script src="vendor/fflate.min.js"></script>
|
||||
<script src="reader.js" type="module"></script>
|
||||
@@ -0,0 +1,145 @@
|
||||
/* global zip: false, fflate: false */
|
||||
import { View } from './view.js'
|
||||
|
||||
const { ZipReader, BlobReader, TextWriter, BlobWriter } = zip
|
||||
zip.configure({ useWebWorkers: false })
|
||||
|
||||
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 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 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')
|
||||
|
||||
globalThis.open = async (file, emit) => {
|
||||
if (!file.size) throw new Error('File not found')
|
||||
let book
|
||||
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))
|
||||
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 = new View(book, emit)
|
||||
const element = await view.display()
|
||||
document.body.append(element)
|
||||
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'};
|
||||
-webkit-hyphenate-limit-before: 3;
|
||||
-webkit-hyphenate-limit-after: 2;
|
||||
-webkit-hyphenate-limit-lines: 2;
|
||||
hanging-punctuation: allow-end last;
|
||||
widows: 2;
|
||||
}
|
||||
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 emit = obj => {
|
||||
console.debug(obj)
|
||||
switch (obj.type) {
|
||||
case 'relocated': {
|
||||
const { fraction, location, pageItem } = obj
|
||||
const percent = percentFormat.format(fraction)
|
||||
const loc = pageItem
|
||||
? `Page ${pageItem.label}`
|
||||
: `Loc ${location.current}`
|
||||
$('#progress-label').innerText = `${percent} · ${loc}`
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dragOverHandler = e => e.preventDefault()
|
||||
const dropHandler = e => {
|
||||
e.preventDefault()
|
||||
const file = Array.from(e.dataTransfer.items)
|
||||
.find(item => item.kind === 'file')?.getAsFile()
|
||||
if (file) {
|
||||
document.body.removeChild($('#drop-target'))
|
||||
open(file, emit)
|
||||
.then(view => {
|
||||
const style = {
|
||||
spacing: 1.4,
|
||||
justify: true,
|
||||
hyphenate: true,
|
||||
}
|
||||
view.setAppearance({ css: getCSS(style) })
|
||||
view.renderer.next()
|
||||
$('#nav-bar').style.visibility = 'visible'
|
||||
$('#left-button').addEventListener('click', () => view.goLeft())
|
||||
$('#right-button').addEventListener('click', () => view.goRight())
|
||||
})
|
||||
.catch(e => emit(e))
|
||||
}
|
||||
}
|
||||
const dropTarget = $('#drop-target')
|
||||
dropTarget.addEventListener('drop', dropHandler)
|
||||
dropTarget.addEventListener('dragover', dragOverHandler)
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,267 @@
|
||||
import * as CFI from './epubcfi.js'
|
||||
import { TOCProgress, SectionProgress } from './progress.js'
|
||||
|
||||
const textWalker = function* (doc, func) {
|
||||
const filter = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
|
||||
| NodeFilter.SHOW_CDATA_SECTION
|
||||
const { FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter
|
||||
const acceptNode = node => {
|
||||
const name = node.localName?.toLowerCase()
|
||||
if (name === 'script' || name === 'style') return FILTER_REJECT
|
||||
if (node.nodeType === 1) return FILTER_SKIP
|
||||
return FILTER_ACCEPT
|
||||
}
|
||||
const walker = doc.createTreeWalker(doc.body, filter, { acceptNode })
|
||||
const nodes = []
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode())
|
||||
nodes.push(node)
|
||||
const strs = nodes.map(node => node.nodeValue)
|
||||
const makeRange = (startIndex, startOffset, endIndex, endOffset) => {
|
||||
const range = doc.createRange()
|
||||
range.setStart(nodes[startIndex], startOffset)
|
||||
range.setEnd(nodes[endIndex], endOffset)
|
||||
return range
|
||||
}
|
||||
for (const match of func(strs, makeRange)) yield match
|
||||
}
|
||||
|
||||
const frameRect = (frame, rect) => {
|
||||
const left = rect.left + frame.left
|
||||
const right = rect.right + frame.left
|
||||
const top = rect.top + frame.top
|
||||
const bottom = rect.bottom + frame.top
|
||||
return { left, right, top, bottom }
|
||||
}
|
||||
|
||||
const pointIsInView = ({ x, y }) =>
|
||||
x > 0 && y > 0 && x < window.innerWidth && y < window.innerHeight
|
||||
|
||||
export const getPosition = target => {
|
||||
// TODO: vertical text
|
||||
const frameElement = (target.getRootNode?.() ?? target?.endContainer?.getRootNode?.())
|
||||
?.defaultView?.frameElement
|
||||
const frame = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 }
|
||||
const rects = Array.from(target.getClientRects())
|
||||
const first = frameRect(frame, rects[0])
|
||||
const last = frameRect(frame, rects.at(-1))
|
||||
const start = {
|
||||
point: { x: (first.left + first.right) / 2, y: first.top },
|
||||
dir: 'up',
|
||||
}
|
||||
const end = {
|
||||
point: { x: (last.left + last.right) / 2, y: last.bottom },
|
||||
dir: 'down',
|
||||
}
|
||||
const startInView = pointIsInView(start.point)
|
||||
const endInView = pointIsInView(end.point)
|
||||
if (!startInView && !endInView) return { point: { x: 0, y: 0 } }
|
||||
if (!startInView) return end
|
||||
if (!endInView) return start
|
||||
return start.point.y > window.innerHeight - end.point.y ? start : end
|
||||
}
|
||||
|
||||
// https://www.w3.org/TR/epub-ssv-11/
|
||||
const SSV = Object.fromEntries(Array.from(Object.entries({
|
||||
isRef: ['annoref', 'biblioref', 'glossref', 'noteref'],
|
||||
isLink: ['backlink'],
|
||||
isNote: ['annotation', 'note', 'footnote', 'endnote', 'rearnote'],
|
||||
}), ([k, v]) => [k, el =>
|
||||
el.getAttributeNS('http://www.idpf.org/2007/ops', 'type')
|
||||
?.split(/s/)?.some(t => v.includes(t))]))
|
||||
|
||||
export class View {
|
||||
#sectionProgress
|
||||
#tocProgress
|
||||
#pageProgress
|
||||
language = 'en'
|
||||
textDirection = ''
|
||||
isCJK = false
|
||||
isFixedLayout = false
|
||||
#css
|
||||
constructor(book, emit) {
|
||||
this.book = book
|
||||
this.emit = emit
|
||||
|
||||
if (book.metadata?.language) try {
|
||||
const language = book.metadata.language
|
||||
book.metadata.language = Intl.getCanonicalLocales(language)[0]
|
||||
const tag = typeof language === 'string' ? language : language[0]
|
||||
const locale = new Intl.Locale(tag)
|
||||
this.isCJK = ['zh', 'ja', 'kr'].includes(locale.language)
|
||||
this.textDirection = locale.textInfo.direction
|
||||
} catch(e) {
|
||||
console.warn(e)
|
||||
}
|
||||
|
||||
if (book.splitTOCHref && book.getTOCFragment) {
|
||||
const ids = book.sections.map(s => s.id)
|
||||
this.#sectionProgress = new SectionProgress(book.sections, 150, 1600)
|
||||
const splitHref = book.splitTOCHref.bind(book)
|
||||
const getFragment = book.getTOCFragment.bind(book)
|
||||
this.#tocProgress = new TOCProgress({
|
||||
toc: book.toc ?? [], ids, splitHref, getFragment })
|
||||
this.#pageProgress = new TOCProgress({
|
||||
toc: book.pageList ?? [], ids, splitHref, getFragment })
|
||||
}
|
||||
}
|
||||
async display() {
|
||||
const opts = {
|
||||
book: this.book,
|
||||
onLoad: this.#onLoad.bind(this),
|
||||
onRelocated: this.#onRelocated.bind(this),
|
||||
}
|
||||
this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated'
|
||||
if (this.isFixedLayout) {
|
||||
const { FixedLayout } = await import('./fixed-layout.js')
|
||||
this.renderer = new FixedLayout(opts)
|
||||
} else {
|
||||
const { Paginator } = await import('./paginator.js')
|
||||
this.renderer = new Paginator(opts)
|
||||
}
|
||||
return this.renderer.element
|
||||
}
|
||||
#onRelocated(range, index, fraction) {
|
||||
if (!this.#sectionProgress) return
|
||||
const progress = this.#sectionProgress.getProgress(index, fraction)
|
||||
const tocItem = this.#tocProgress.getProgress(index, range)
|
||||
const pageItem = this.#pageProgress.getProgress(index, range)
|
||||
const cfi = this.getCFI(index, range)
|
||||
this.emit?.({ type: 'relocated', ...progress, tocItem, pageItem, cfi })
|
||||
}
|
||||
#onLoad(doc, index) {
|
||||
const { book } = this
|
||||
|
||||
// set language and dir if not already set
|
||||
doc.documentElement.lang ||= this.language
|
||||
doc.documentElement.dir ||= this.isCJK ? '' : this.textDirection
|
||||
|
||||
this.renderer.setStyle(this.#css)
|
||||
|
||||
// set `document` background to `doc` background
|
||||
// this is needed cause the iframe does not fill the whole viewport
|
||||
const bodyStyle = doc.defaultView.getComputedStyle(doc.body)
|
||||
document.body.style.background =
|
||||
bodyStyle.backgroundColor === 'rgba(0, 0, 0, 0)'
|
||||
&& bodyStyle.backgroundImage === 'none'
|
||||
? doc.defaultView.getComputedStyle(doc.documentElement).background
|
||||
: bodyStyle.background
|
||||
|
||||
// set handlers for links
|
||||
const section = book.sections[index]
|
||||
for (const a of doc.querySelectorAll('a[href]'))
|
||||
a.addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
const href = a.getAttribute('href')
|
||||
const uri = section?.resolveHref?.(href) ?? href
|
||||
if (book?.isExternal?.(uri))
|
||||
this.emit?.({ type: 'external-link', uri })
|
||||
else if (SSV.isRef(a)) {
|
||||
const { index, anchor } = book.resolveHref(uri)
|
||||
const pos = getPosition(a)
|
||||
Promise.resolve(book.sections[index].createDocument())
|
||||
.then(doc => [anchor(doc), doc.contentType])
|
||||
.then(([el, type]) =>
|
||||
[el?.innerHTML, type, SSV.isNote(el)])
|
||||
.then(([content, contentType, isNote]) => content
|
||||
? this.emit?.({
|
||||
type: 'reference',
|
||||
href: isNote ? null : uri,
|
||||
content, contentType, pos,
|
||||
}) : null)
|
||||
.catch(e => console.error(e))
|
||||
return
|
||||
} else this.goTo(uri)
|
||||
})
|
||||
}
|
||||
getCFI(index, range) {
|
||||
if (!range) return ''
|
||||
const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index)
|
||||
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 (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)
|
||||
return resolved
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
console.error(`Could not go to ${target}`)
|
||||
}
|
||||
}
|
||||
async goToFraction(frac) {
|
||||
const [index, anchor] = this.#sectionProgress.getSection(frac)
|
||||
return this.renderer.goTo({ index, anchor })
|
||||
}
|
||||
async select(target) {
|
||||
try {
|
||||
const obj = await this.resolveNavigation(target)
|
||||
await this.renderer.goTo({ ...obj, select: true })
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
console.error(`Could not go to ${target}`)
|
||||
}
|
||||
}
|
||||
goLeft() {
|
||||
return this.book.dir === 'rtl' ? this.renderer.next() : this.renderer.prev()
|
||||
}
|
||||
goRight() {
|
||||
return this.book.dir === 'rtl' ? this.renderer.prev() : this.renderer.next()
|
||||
}
|
||||
setAppearance({ layout, css }) {
|
||||
if (this.isFixedLayout) return
|
||||
Object.assign(this.renderer.layout, layout)
|
||||
this.#css = css
|
||||
this.renderer.setStyle(css)
|
||||
this.renderer.render()
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
for await (const result of iter) yield 'subitems' in result ? {
|
||||
label: this.#tocProgress.getProgress(result.index)?.label ?? '',
|
||||
subitems: result.subitems,
|
||||
} : result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user