Files

225 lines
7.7 KiB
JavaScript
Raw Permalink Normal View History

// assign a unique ID for each TOC item
const assignIDs = toc => {
let id = 0
const assignID = item => {
item.id = id++
if (item.subitems) for (const subitem of item.subitems) assignID(subitem)
}
for (const item of toc) assignID(item)
return toc
}
const flatten = items => items
.map(item => item.subitems?.length
? [item, flatten(item.subitems)].flat()
: item)
.flat()
export class TOCProgress {
async init({ toc, ids, splitHref, getFragment }) {
assignIDs(toc)
const items = flatten(toc)
const grouped = new Map()
for (const [i, item] of items.entries()) {
const [id, fragment] = await splitHref(item?.href) ?? []
const value = { fragment, item }
if (grouped.has(id)) grouped.get(id).items.push(value)
else grouped.set(id, { prev: items[i - 1], items: [value] })
}
const map = new Map()
for (const [i, id] of ids.entries()) {
if (grouped.has(id)) map.set(id, grouped.get(id))
else map.set(id, map.get(ids[i - 1]))
}
this.ids = ids
this.map = map
this.getFragment = getFragment
}
getProgress(index, range) {
if (!this.ids) return
const id = this.ids[index]
const obj = this.map.get(id)
if (!obj) return null
const { prev, items } = obj
if (!items) return prev
if (!range || items.length === 1 && !items[0].fragment) return items[0].item
const doc = range.startContainer.getRootNode()
for (const [i, { fragment }] of items.entries()) {
const el = this.getFragment(doc, fragment)
if (!el) continue
if (range.comparePoint(el, 0) > 0)
return (items[i - 1]?.item ?? prev)
}
return items[items.length - 1].item
}
}
export class PageProgress {
#book
#cache = new Map()
#resolveNavigation
constructor(book, resolveNavigation) {
this.#book = book
this.#resolveNavigation = resolveNavigation
}
async #getCache(index) {
let cached = this.#cache.get(index)
if (cached) return cached
const section = this.#book.sections[index]
if (!section?.createDocument) return null
const doc = await section.createDocument()
const root = doc.body ?? doc.documentElement
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const nodes = []
const offsets = []
let total = 0
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const len = node.nodeValue?.length ?? 0
nodes.push(node)
offsets.push(total)
total += len
}
cached = { doc, nodes, offsets, total }
this.#cache.set(index, cached)
return cached
}
async getProgress(cfi) {
try {
const nav = this.#resolveNavigation(cfi)
if (!nav) return null
const { index, anchor } = nav
if (index == null || !anchor) return null
const cached = await this.#getCache(index)
if (!cached) return null
const { doc, nodes, offsets, total } = cached
const frag = anchor(doc)
if (!frag) return null
const isRange = frag instanceof Range
const range = isRange ? frag : doc.createRange()
if (!isRange) range.selectNodeContents(frag)
const offset = this.#findOffset(range, nodes, offsets, total)
return {
fraction: total > 0 ? offset / total : 0,
index,
}
} catch (e) {
console.error(e)
return null
}
}
#findOffset(range, nodes, offsets, total) {
if (!nodes.length) return 0
const container = range.startContainer
// fast path: startContainer is a text node in the index
if (container.nodeType === Node.TEXT_NODE) {
const i = this.#bsearchNode(container, nodes)
if (i >= 0) return offsets[i] + range.startOffset
}
// element container: collapse to start and binary search
const collapsed = range.cloneRange()
collapsed.collapse(true)
const i = this.#bsearchCollapsed(collapsed, nodes)
return i >= 0 ? offsets[i] : total
}
// binary search for an exact text node by document position
#bsearchNode(target, nodes) {
let low = 0, high = nodes.length - 1
while (low <= high) {
const mid = (low + high) >> 1
const node = nodes[mid]
if (node === target) return mid
const pos = node.compareDocumentPosition(target)
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) low = mid + 1
else high = mid - 1
}
return -1
}
// binary search for the first text node at or after a collapsed range point
// collapsed.comparePoint returns: -1 = node is before, 1 = node is at/after
#bsearchCollapsed(collapsed, nodes) {
let low = 0, high = nodes.length - 1, result = -1
while (low <= high) {
const mid = (low + high) >> 1
if (collapsed.comparePoint(nodes[mid], 0) > 0) {
result = mid
high = mid - 1
} else {
low = mid + 1
}
}
return result
}
}
export class SectionProgress {
constructor(sections, sizePerLoc, sizePerTimeUnit) {
this.sizes = sections.map(s => s.linear != 'no' && s.size > 0 ? s.size : 0)
this.sizePerLoc = sizePerLoc
this.sizePerTimeUnit = sizePerTimeUnit
this.sizeTotal = this.sizes.reduce((a, b) => a + b, 0)
this.sectionFractions = this.#getSectionFractions()
}
#getSectionFractions() {
const { sizeTotal } = this
const results = [0]
let sum = 0
for (const size of this.sizes) results.push((sum += size) / sizeTotal)
return results
}
// get progress given index of and fractions within a section
getProgress(index, fractionInSection, pageFraction = 0) {
const { sizes, sizePerLoc, sizePerTimeUnit, sizeTotal } = this
const sizeInSection = sizes[index] ?? 0
const sizeBefore = sizes.slice(0, index).reduce((a, b) => a + b, 0)
const size = sizeBefore + fractionInSection * sizeInSection
const nextSize = size + pageFraction * sizeInSection
const remainingTotal = sizeTotal - size
const remainingSection = (1 - fractionInSection) * sizeInSection
return {
fraction: nextSize / sizeTotal,
section: {
current: index,
total: sizes.length,
},
location: {
current: Math.floor(size / sizePerLoc),
next: Math.floor(nextSize / sizePerLoc),
total: Math.ceil(sizeTotal / sizePerLoc),
},
time: {
section: remainingSection / sizePerTimeUnit,
total: remainingTotal / sizePerTimeUnit,
},
}
}
// the inverse of `getProgress`
// get index of and fraction in section based on total fraction
getSection(fraction) {
if (fraction <= 0) return [0, 0]
if (fraction >= 1) return [this.sizes.length - 1, 1]
fraction = fraction + Number.EPSILON
const { sizeTotal } = this
let index = this.sectionFractions.findIndex(x => x > fraction) - 1
if (index < 0) return [0, 0]
while (!this.sizes[index]) index++
const fractionInSection = (fraction - this.sectionFractions[index])
/ (this.sizes[index] / sizeTotal)
return [index, fractionInSection]
}
}