mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
Supposedly Kindle uses 150 bytes. Source: https://www.mobileread.com/forums/showpost.php?s=6fbd6367bde3031320a271b6e3208445&p=1858934&postcount=3 However, since the way locations are calculated here is not exact anyway, it seems more useful to use a smaller number that's more in line with (1) the size of an actual page and (2) Foliate's old location size.
349 lines
14 KiB
JavaScript
349 lines
14 KiB
JavaScript
import * as CFI from './epubcfi.js'
|
|
import { TOCProgress, SectionProgress } from './progress.js'
|
|
import { Overlayer } from './overlayer.js'
|
|
import { Annotations } from './annotations.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, sx = 1, sy = 1) => {
|
|
const left = sx * rect.left + frame.left
|
|
const right = sx * rect.right + frame.left
|
|
const top = sy * rect.top + frame.top
|
|
const bottom = sy * 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 transform = frameElement ? getComputedStyle(frameElement).transform : ''
|
|
const match = transform.match(/matrix\((.+)\)/)
|
|
const [sx, , , sy] = match?.[1]?.split(/\s*,\s*/)?.map(x => parseFloat(x)) ?? []
|
|
|
|
const frame = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 }
|
|
const rects = Array.from(target.getClientRects())
|
|
const first = frameRect(frame, rects[0], sx, sy)
|
|
const last = frameRect(frame, rects.at(-1), sx, sy)
|
|
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
|
|
#css
|
|
language = 'en'
|
|
textDirection = ''
|
|
isCJK = false
|
|
isFixedLayout = false
|
|
annotations = new Annotations({
|
|
resolve: value => this.resolveCFI(value),
|
|
compare: CFI.compare,
|
|
onAdd: (annotation, index, position) => {
|
|
const o = this.#getOverlayer(index)
|
|
if (o) this.#drawAnnotation(o.doc, o.overlayer, annotation)
|
|
const label = this.#tocProgress.getProgress(index)?.label ?? ''
|
|
this?.emit({ type: 'add-annotation', annotation, label, index, position })
|
|
},
|
|
onDelete: (key, index, position) => {
|
|
this.#getOverlayer(index)?.overlayer?.remove(key)
|
|
this?.emit({ type: 'delete-annotation', index, position })
|
|
},
|
|
onUpdate: (annotation, index) => {
|
|
const o = this.#getOverlayer(index)
|
|
if (o) {
|
|
o.overlayer.remove(annotation.value)
|
|
this.#drawAnnotation(o.doc, o.overlayer, annotation)
|
|
}
|
|
},
|
|
})
|
|
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, 1500, 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),
|
|
createOverlayer: this.#createOverlayer.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
|
|
}
|
|
async init({ lastLocation, annotations }) {
|
|
if (lastLocation) {
|
|
const resolved = this.resolveNavigation(lastLocation)
|
|
if (resolved) await this.renderer.goTo(resolved)
|
|
else await this.renderer.next()
|
|
} else await this.renderer.next()
|
|
|
|
if (annotations) {
|
|
annotations.sort((a, b) => CFI.compare(a.value, b.value))
|
|
for (const annotation of annotations)
|
|
await this.annotations.add(annotation, true)
|
|
}
|
|
}
|
|
#onRelocated(range, index, fraction, size) {
|
|
if (!this.#sectionProgress) return
|
|
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.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 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)
|
|
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, element: a,
|
|
}) : null)
|
|
.catch(e => console.error(e))
|
|
return
|
|
} else this.goTo(uri)
|
|
})
|
|
|
|
this.emit?.({ type: 'loaded', doc, index })
|
|
}
|
|
#drawAnnotation(doc, overlayer, annotation) {
|
|
const { value } = annotation
|
|
const anchor = this.annotations.getAnchor(value)
|
|
const range = doc ? anchor(doc) : anchor
|
|
const [func, opts] = this.emit({ type: 'draw-annotation', annotation })
|
|
overlayer.add(value, range, func, opts)
|
|
}
|
|
#getOverlayer(index) {
|
|
const obj = this.renderer.getOverlayer()
|
|
if (obj.index === index) return obj
|
|
}
|
|
#createOverlayer(doc, index) {
|
|
const overlayer = new Overlayer()
|
|
for (const annotation of this.annotations.getByIndex(index))
|
|
this.#drawAnnotation(doc, overlayer, annotation)
|
|
doc.addEventListener('click', e => {
|
|
const [value, range] = overlayer.hitTest(e)
|
|
if (value) {
|
|
this.emit?.({ type: 'show-annotation', value, range })
|
|
}
|
|
}, false)
|
|
return overlayer
|
|
}
|
|
async showAnnotation(annotation) {
|
|
const { value } = annotation
|
|
const { index, anchor } = await this.goTo(value)
|
|
const { doc } = this.#getOverlayer(index)
|
|
const range = anchor(doc)
|
|
this.emit?.({ type: 'show-annotation', value, range })
|
|
}
|
|
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}`)
|
|
}
|
|
}
|
|
deselect() {
|
|
return this.renderer.deselect?.()
|
|
}
|
|
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}`)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|