225 lines
7.7 KiB
JavaScript
225 lines
7.7 KiB
JavaScript
// 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]
|
||
|
|
}
|
||
|
|
}
|