mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-10-01 07:49:12 -04:00
touchstart's preventDefault suppresses the click events that foliate's link handling and the overlayer's highlight hit-testing depend on; dispatch one at the tapped element so internal links and the highlight edit popover work on touch as on desktop.
1274 lines
51 KiB
JavaScript
1274 lines
51 KiB
JavaScript
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||
|
||
const debounce = (f, wait, immediate) => {
|
||
let timeout
|
||
return (...args) => {
|
||
const later = () => {
|
||
timeout = null
|
||
if (!immediate) f(...args)
|
||
}
|
||
const callNow = immediate && !timeout
|
||
if (timeout) clearTimeout(timeout)
|
||
timeout = setTimeout(later, wait)
|
||
if (callNow) f(...args)
|
||
}
|
||
}
|
||
|
||
const lerp = (min, max, x) => x * (max - min) + min
|
||
const easeOutQuad = x => 1 - (1 - x) * (1 - x)
|
||
const animate = (a, b, duration, ease, render) => new Promise(resolve => {
|
||
let start
|
||
const step = now => {
|
||
if (document.hidden) {
|
||
render(lerp(a, b, 1))
|
||
return resolve()
|
||
}
|
||
start ??= now
|
||
const fraction = Math.min(1, (now - start) / duration)
|
||
render(lerp(a, b, ease(fraction)))
|
||
if (fraction < 1) requestAnimationFrame(step)
|
||
else resolve()
|
||
}
|
||
if (document.hidden) {
|
||
render(lerp(a, b, 1))
|
||
return resolve()
|
||
}
|
||
requestAnimationFrame(step)
|
||
})
|
||
|
||
// collapsed range doesn't return client rects sometimes (or always?)
|
||
// try make get a non-collapsed range or element
|
||
const uncollapse = range => {
|
||
if (!range?.collapsed) return range
|
||
const { endOffset, endContainer } = range
|
||
if (endContainer.nodeType === 1) {
|
||
const node = endContainer.childNodes[endOffset]
|
||
if (node?.nodeType === 1) return node
|
||
return endContainer
|
||
}
|
||
if (endOffset + 1 < endContainer.length) range.setEnd(endContainer, endOffset + 1)
|
||
else if (endOffset > 1) range.setStart(endContainer, endOffset - 1)
|
||
else return endContainer.parentNode
|
||
return range
|
||
}
|
||
|
||
const makeRange = (doc, node, start, end = start) => {
|
||
const range = doc.createRange()
|
||
range.setStart(node, start)
|
||
range.setEnd(node, end)
|
||
return range
|
||
}
|
||
|
||
// use binary search to find an offset value in a text node
|
||
const bisectNode = (doc, node, cb, start = 0, end = node.nodeValue.length) => {
|
||
if (end - start === 1) {
|
||
const result = cb(makeRange(doc, node, start), makeRange(doc, node, end))
|
||
return result < 0 ? start : end
|
||
}
|
||
const mid = Math.floor(start + (end - start) / 2)
|
||
const result = cb(makeRange(doc, node, start, mid), makeRange(doc, node, mid, end))
|
||
return result < 0 ? bisectNode(doc, node, cb, start, mid)
|
||
: result > 0 ? bisectNode(doc, node, cb, mid, end) : mid
|
||
}
|
||
|
||
const { SHOW_ELEMENT, SHOW_TEXT, SHOW_CDATA_SECTION,
|
||
FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter
|
||
|
||
const filter = SHOW_ELEMENT | SHOW_TEXT | SHOW_CDATA_SECTION
|
||
|
||
// needed cause there seems to be a bug in `getBoundingClientRect()` in Firefox
|
||
// where it fails to include rects that have zero width and non-zero height
|
||
// (CSSOM spec says "rectangles [...] of which the height or width is not zero")
|
||
// which makes the visible range include an extra space at column boundaries
|
||
const getBoundingClientRect = target => {
|
||
let top = Infinity, right = -Infinity, left = Infinity, bottom = -Infinity
|
||
for (const rect of target.getClientRects()) {
|
||
left = Math.min(left, rect.left)
|
||
top = Math.min(top, rect.top)
|
||
right = Math.max(right, rect.right)
|
||
bottom = Math.max(bottom, rect.bottom)
|
||
}
|
||
return new DOMRect(left, top, right - left, bottom - top)
|
||
}
|
||
|
||
const getVisibleRange = (doc, start, end, mapRect) => {
|
||
// first get all visible nodes
|
||
const acceptNode = node => {
|
||
const name = node.localName?.toLowerCase()
|
||
// ignore all scripts, styles, and their children
|
||
if (name === 'script' || name === 'style') return FILTER_REJECT
|
||
if (node.nodeType === 1) {
|
||
const { left, right } = mapRect(node.getBoundingClientRect())
|
||
// no need to check child nodes if it's completely out of view
|
||
if (right < start || left > end) return FILTER_REJECT
|
||
// elements must be completely in view to be considered visible
|
||
// because you can't specify offsets for elements
|
||
if (left >= start && right <= end) return FILTER_ACCEPT
|
||
// TODO: it should probably allow elements that do not contain text
|
||
// because they can exceed the whole viewport in both directions
|
||
// especially in scrolled mode
|
||
} else {
|
||
// ignore empty text nodes
|
||
if (!node.nodeValue?.trim()) return FILTER_SKIP
|
||
// create range to get rect
|
||
const range = doc.createRange()
|
||
range.selectNodeContents(node)
|
||
const { left, right } = mapRect(range.getBoundingClientRect())
|
||
// it's visible if any part of it is in view
|
||
if (right >= start && left <= end) return FILTER_ACCEPT
|
||
}
|
||
return FILTER_SKIP
|
||
}
|
||
const walker = doc.createTreeWalker(doc.body, filter, { acceptNode })
|
||
const nodes = []
|
||
for (let node = walker.nextNode(); node; node = walker.nextNode())
|
||
nodes.push(node)
|
||
|
||
// we're only interested in the first and last visible nodes
|
||
const from = nodes[0] ?? doc.body
|
||
const to = nodes[nodes.length - 1] ?? from
|
||
|
||
// find the offset at which visibility changes
|
||
const startOffset = from.nodeType === 1 ? 0
|
||
: bisectNode(doc, from, (a, b) => {
|
||
const p = mapRect(getBoundingClientRect(a))
|
||
const q = mapRect(getBoundingClientRect(b))
|
||
if (p.right < start && q.left > start) return 0
|
||
return q.left > start ? -1 : 1
|
||
})
|
||
const endOffset = to.nodeType === 1 ? 0
|
||
: bisectNode(doc, to, (a, b) => {
|
||
const p = mapRect(getBoundingClientRect(a))
|
||
const q = mapRect(getBoundingClientRect(b))
|
||
if (p.right < end && q.left > end) return 0
|
||
return q.left > end ? -1 : 1
|
||
})
|
||
|
||
const range = doc.createRange()
|
||
range.setStart(from, startOffset)
|
||
range.setEnd(to, endOffset)
|
||
return range
|
||
}
|
||
|
||
const selectionIsBackward = sel => {
|
||
// the selection may live in an iframe document while this module runs
|
||
// in the host document; create the range in the selection's own
|
||
// document or the boundary points cannot be compared
|
||
const doc = sel.anchorNode?.ownerDocument ?? document
|
||
const range = doc.createRange()
|
||
range.setStart(sel.anchorNode, sel.anchorOffset)
|
||
range.setEnd(sel.focusNode, sel.focusOffset)
|
||
return range.collapsed
|
||
}
|
||
const wordAt = (doc, node, offset) => {
|
||
if (!node) return null
|
||
if (node.nodeType !== 3) return { node, offset }
|
||
const text = node.nodeValue
|
||
if (!text) return null
|
||
const isWord = c => /[\p{L}\p{N}]/u.test(c)
|
||
let start = Math.min(offset, text.length)
|
||
let end = start
|
||
while (start > 0 && isWord(text[start - 1])) start--
|
||
while (end < text.length && isWord(text[end])) end++
|
||
if (start === end) return { node, offset }
|
||
return { node, offset: start, endOffset: end }
|
||
}
|
||
|
||
const setSelectionTo = (target, collapse) => {
|
||
let range
|
||
if (target.startContainer) range = target.cloneRange()
|
||
else if (target.nodeType) {
|
||
range = document.createRange()
|
||
range.selectNode(target)
|
||
}
|
||
if (range) {
|
||
const sel = range.startContainer.ownerDocument.defaultView.getSelection()
|
||
if (sel) {
|
||
sel.removeAllRanges()
|
||
if (collapse === -1) range.collapse(true)
|
||
else if (collapse === 1) range.collapse()
|
||
sel.addRange(range)
|
||
}
|
||
}
|
||
}
|
||
|
||
const getDirection = doc => {
|
||
const { defaultView } = doc
|
||
const { writingMode, direction } = defaultView.getComputedStyle(doc.body)
|
||
const vertical = writingMode === 'vertical-rl'
|
||
|| writingMode === 'vertical-lr'
|
||
const rtl = doc.body.dir === 'rtl'
|
||
|| direction === 'rtl'
|
||
|| doc.documentElement.dir === 'rtl'
|
||
return { vertical, rtl }
|
||
}
|
||
|
||
const getBackground = doc => {
|
||
const bodyStyle = doc.defaultView.getComputedStyle(doc.body)
|
||
return bodyStyle.backgroundColor === 'rgba(0, 0, 0, 0)'
|
||
&& bodyStyle.backgroundImage === 'none'
|
||
? doc.defaultView.getComputedStyle(doc.documentElement).background
|
||
: bodyStyle.background
|
||
}
|
||
|
||
const makeMarginals = (length, part) => Array.from({ length }, () => {
|
||
const div = document.createElement('div')
|
||
const child = document.createElement('div')
|
||
div.append(child)
|
||
child.setAttribute('part', part)
|
||
return div
|
||
})
|
||
|
||
const setStylesImportant = (el, styles) => {
|
||
const { style } = el
|
||
for (const [k, v] of Object.entries(styles)) style.setProperty(k, v, 'important')
|
||
}
|
||
|
||
class View {
|
||
#observer = new ResizeObserver(() => this.expand())
|
||
#element = document.createElement('div')
|
||
#iframe = document.createElement('iframe')
|
||
#contentRange = document.createRange()
|
||
#overlayer
|
||
#vertical = false
|
||
#rtl = false
|
||
#column = true
|
||
#size
|
||
#layout = {}
|
||
constructor({ container, onExpand }) {
|
||
this.container = container
|
||
this.onExpand = onExpand
|
||
this.#iframe.setAttribute('part', 'filter')
|
||
this.#element.append(this.#iframe)
|
||
Object.assign(this.#element.style, {
|
||
boxSizing: 'content-box',
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
flex: '0 0 auto',
|
||
width: '100%', height: '100%',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
})
|
||
Object.assign(this.#iframe.style, {
|
||
overflow: 'hidden',
|
||
border: '0',
|
||
display: 'none',
|
||
width: '100%', height: '100%',
|
||
})
|
||
// `allow-scripts` is needed for events because of WebKit bug
|
||
// https://bugs.webkit.org/show_bug.cgi?id=218086
|
||
this.#iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts')
|
||
this.#iframe.setAttribute('scrolling', 'no')
|
||
}
|
||
get element() {
|
||
return this.#element
|
||
}
|
||
get document() {
|
||
return this.#iframe.contentDocument
|
||
}
|
||
async load(src, afterLoad, beforeRender) {
|
||
if (typeof src !== 'string') throw new Error(`${src} is not string`)
|
||
return new Promise(resolve => {
|
||
this.#iframe.addEventListener('load', () => {
|
||
const doc = this.document
|
||
afterLoad?.(doc)
|
||
|
||
// it needs to be visible for Firefox to get computed style
|
||
this.#iframe.style.display = 'block'
|
||
const { vertical, rtl } = getDirection(doc)
|
||
const background = getBackground(doc)
|
||
this.#iframe.style.display = 'none'
|
||
|
||
this.#vertical = vertical
|
||
this.#rtl = rtl
|
||
|
||
this.#contentRange.selectNodeContents(doc.body)
|
||
const layout = beforeRender?.({ vertical, rtl, background })
|
||
this.#iframe.style.display = 'block'
|
||
this.render(layout)
|
||
this.#observer.observe(doc.body)
|
||
|
||
// the resize observer above doesn't work in Firefox
|
||
// (see https://bugzilla.mozilla.org/show_bug.cgi?id=1832939)
|
||
// until the bug is fixed we can at least account for font load
|
||
doc.fonts.ready.then(() => this.expand())
|
||
|
||
resolve()
|
||
}, { once: true })
|
||
this.#iframe.src = src
|
||
})
|
||
}
|
||
render(layout) {
|
||
if (!layout) return
|
||
this.#column = layout.flow !== 'scrolled'
|
||
this.#layout = layout
|
||
if (this.#column) this.columnize(layout)
|
||
else this.scrolled(layout)
|
||
}
|
||
scrolled({ gap, columnWidth }) {
|
||
const vertical = this.#vertical
|
||
const doc = this.document
|
||
setStylesImportant(doc.documentElement, {
|
||
'box-sizing': 'border-box',
|
||
'padding': vertical ? `${gap}px 0` : `0 ${gap}px`,
|
||
'column-width': 'auto',
|
||
'height': 'auto',
|
||
'width': 'auto',
|
||
})
|
||
setStylesImportant(doc.body, {
|
||
[vertical ? 'max-height' : 'max-width']: `${columnWidth}px`,
|
||
'margin': 'auto',
|
||
})
|
||
this.setImageSize()
|
||
this.expand()
|
||
}
|
||
columnize({ width, height, gap, columnWidth }) {
|
||
const vertical = this.#vertical
|
||
this.#size = vertical ? height : width
|
||
|
||
const doc = this.document
|
||
setStylesImportant(doc.documentElement, {
|
||
'box-sizing': 'border-box',
|
||
'column-width': `${Math.trunc(columnWidth)}px`,
|
||
'column-gap': `${gap}px`,
|
||
'column-fill': 'auto',
|
||
...(vertical
|
||
? { 'width': `${width}px` }
|
||
: { 'height': `${height}px` }),
|
||
'padding': vertical ? `${gap / 2}px 0` : `0 ${gap / 2}px`,
|
||
'overflow': 'hidden',
|
||
// force wrap long words
|
||
'overflow-wrap': 'break-word',
|
||
// reset some potentially problematic props
|
||
'position': 'static', 'border': '0', 'margin': '0',
|
||
'max-height': 'none', 'max-width': 'none',
|
||
'min-height': 'none', 'min-width': 'none',
|
||
// fix glyph clipping in WebKit
|
||
'-webkit-line-box-contain': 'block glyphs replaced',
|
||
})
|
||
setStylesImportant(doc.body, {
|
||
'max-height': 'none',
|
||
'max-width': 'none',
|
||
'margin': '0',
|
||
})
|
||
this.setImageSize()
|
||
this.expand()
|
||
}
|
||
setImageSize() {
|
||
const { width, height, margin } = this.#layout
|
||
const vertical = this.#vertical
|
||
const doc = this.document
|
||
for (const el of doc.body.querySelectorAll('img, svg, video')) {
|
||
// preserve max size if they are already set
|
||
const { maxHeight, maxWidth } = doc.defaultView.getComputedStyle(el)
|
||
setStylesImportant(el, {
|
||
'max-height': vertical
|
||
? (maxHeight !== 'none' && maxHeight !== '0px' ? maxHeight : '100%')
|
||
: `${height - margin * 2}px`,
|
||
'max-width': vertical
|
||
? `${width - margin * 2}px`
|
||
: (maxWidth !== 'none' && maxWidth !== '0px' ? maxWidth : '100%'),
|
||
'object-fit': 'contain',
|
||
'page-break-inside': 'avoid',
|
||
'break-inside': 'avoid',
|
||
'box-sizing': 'border-box',
|
||
})
|
||
}
|
||
}
|
||
expand() {
|
||
const { documentElement } = this.document
|
||
if (this.#column) {
|
||
const side = this.#vertical ? 'height' : 'width'
|
||
const otherSide = this.#vertical ? 'width' : 'height'
|
||
const contentRect = this.#contentRange.getBoundingClientRect()
|
||
const rootRect = documentElement.getBoundingClientRect()
|
||
// offset caused by column break at the start of the page
|
||
// which seem to be supported only by WebKit and only for horizontal writing
|
||
const contentStart = this.#vertical ? 0
|
||
: this.#rtl ? rootRect.right - contentRect.right : contentRect.left - rootRect.left
|
||
const contentSize = contentStart + contentRect[side]
|
||
const pageCount = Math.ceil(contentSize / this.#size)
|
||
const expandedSize = pageCount * this.#size
|
||
this.#element.style.padding = '0'
|
||
this.#iframe.style[side] = `${expandedSize}px`
|
||
this.#element.style[side] = `${expandedSize + this.#size * 2}px`
|
||
this.#iframe.style[otherSide] = '100%'
|
||
this.#element.style[otherSide] = '100%'
|
||
documentElement.style[side] = `${this.#size}px`
|
||
if (this.#overlayer) {
|
||
this.#overlayer.element.style.margin = '0'
|
||
this.#overlayer.element.style.left = this.#vertical ? '0' : `${this.#size}px`
|
||
this.#overlayer.element.style.top = this.#vertical ? `${this.#size}px` : '0'
|
||
this.#overlayer.element.style[side] = `${expandedSize}px`
|
||
this.#overlayer.redraw()
|
||
}
|
||
} else {
|
||
const side = this.#vertical ? 'width' : 'height'
|
||
const otherSide = this.#vertical ? 'height' : 'width'
|
||
const contentSize = documentElement.getBoundingClientRect()[side]
|
||
const expandedSize = contentSize
|
||
const { margin } = this.#layout
|
||
const padding = this.#vertical ? `0 ${margin}px` : `${margin}px 0`
|
||
this.#element.style.padding = padding
|
||
this.#iframe.style[side] = `${expandedSize}px`
|
||
this.#element.style[side] = `${expandedSize}px`
|
||
this.#iframe.style[otherSide] = '100%'
|
||
this.#element.style[otherSide] = '100%'
|
||
if (this.#overlayer) {
|
||
this.#overlayer.element.style.margin = padding
|
||
this.#overlayer.element.style.left = '0'
|
||
this.#overlayer.element.style.top = '0'
|
||
this.#overlayer.element.style[side] = `${expandedSize}px`
|
||
this.#overlayer.redraw()
|
||
}
|
||
}
|
||
this.onExpand()
|
||
}
|
||
set overlayer(overlayer) {
|
||
this.#overlayer = overlayer
|
||
this.#element.append(overlayer.element)
|
||
}
|
||
get overlayer() {
|
||
return this.#overlayer
|
||
}
|
||
destroy() {
|
||
if (this.document) this.#observer.unobserve(this.document.body)
|
||
}
|
||
}
|
||
|
||
// NOTE: everything here assumes the so-called "negative scroll type" for RTL
|
||
export class Paginator extends HTMLElement {
|
||
static observedAttributes = [
|
||
'flow', 'gap', 'margin',
|
||
'max-inline-size', 'max-block-size', 'max-column-count',
|
||
]
|
||
#root = this.attachShadow({ mode: 'closed' })
|
||
#lastContainerSize
|
||
#observer = new ResizeObserver(entries => {
|
||
// Mobile browsers grow/shrink the viewport by ~7–17% when the
|
||
// toolbar hides/shows mid-gesture; re-wrapping the whole book
|
||
// for that reads as a page refresh. Only re-render for real
|
||
// layout changes: any width change, or a height change large
|
||
// enough to be a rotation, keyboard, or split-screen.
|
||
const rect = entries?.[entries.length - 1]?.contentRect
|
||
if (rect && rect.width && rect.height && this.#lastContainerSize) {
|
||
const [w, h] = this.#lastContainerSize
|
||
if (Math.abs(rect.width - w) < 1
|
||
&& Math.abs(rect.height - h) / h < 0.25) return
|
||
}
|
||
if (rect && rect.width && rect.height)
|
||
this.#lastContainerSize = [rect.width, rect.height]
|
||
this.render()
|
||
})
|
||
#top
|
||
#background
|
||
#container
|
||
#header
|
||
#footer
|
||
#view
|
||
#vertical = false
|
||
#rtl = false
|
||
#margin = 0
|
||
#index = -1
|
||
#anchor = 0 // anchor view to a fraction (0-1), Range, or Element
|
||
#justAnchored = false
|
||
#locked = false // while true, prevent any further navigation
|
||
#styles
|
||
#styleMap = new WeakMap()
|
||
#mediaQuery = matchMedia('(prefers-color-scheme: dark)')
|
||
#mediaQueryListener
|
||
#scrollBounds
|
||
#touchState
|
||
#touchScrolled
|
||
#touchSelActive
|
||
#lastVisibleRange
|
||
constructor() {
|
||
super()
|
||
this.#root.innerHTML = `<style>
|
||
:host {
|
||
display: block;
|
||
container-type: size;
|
||
}
|
||
:host, #top {
|
||
box-sizing: border-box;
|
||
position: relative;
|
||
overflow: hidden;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
#top {
|
||
--_gap: 7%;
|
||
--_margin: 48px;
|
||
--_max-inline-size: 720px;
|
||
--_max-block-size: 1440px;
|
||
--_max-column-count: 2;
|
||
--_max-column-count-portrait: 1;
|
||
--_max-column-count-spread: var(--_max-column-count);
|
||
--_half-gap: calc(var(--_gap) / 2);
|
||
--_max-width: calc(var(--_max-inline-size) * var(--_max-column-count-spread));
|
||
--_max-height: var(--_max-block-size);
|
||
display: grid;
|
||
grid-template-columns:
|
||
minmax(var(--_half-gap), 1fr)
|
||
var(--_half-gap)
|
||
minmax(0, calc(var(--_max-width) - var(--_gap)))
|
||
var(--_half-gap)
|
||
minmax(var(--_half-gap), 1fr);
|
||
grid-template-rows:
|
||
minmax(var(--_margin), 1fr)
|
||
minmax(0, var(--_max-height))
|
||
minmax(var(--_margin), 1fr);
|
||
&.vertical {
|
||
--_max-column-count-spread: var(--_max-column-count-portrait);
|
||
--_max-width: var(--_max-block-size);
|
||
--_max-height: calc(var(--_max-inline-size) * var(--_max-column-count-spread));
|
||
}
|
||
@container (orientation: portrait) {
|
||
& {
|
||
--_max-column-count-spread: var(--_max-column-count-portrait);
|
||
}
|
||
&.vertical {
|
||
--_max-column-count-spread: var(--_max-column-count);
|
||
}
|
||
}
|
||
}
|
||
#background {
|
||
grid-column: 1 / -1;
|
||
grid-row: 1 / -1;
|
||
}
|
||
#container {
|
||
grid-column: 2 / 5;
|
||
grid-row: 2;
|
||
overflow: hidden;
|
||
}
|
||
:host([flow="scrolled"]) #container {
|
||
grid-column: 1 / -1;
|
||
grid-row: 1 / -1;
|
||
overflow: auto;
|
||
}
|
||
#header {
|
||
grid-column: 3 / 4;
|
||
grid-row: 1;
|
||
}
|
||
#footer {
|
||
grid-column: 3 / 4;
|
||
grid-row: 3;
|
||
align-self: end;
|
||
}
|
||
#header, #footer {
|
||
display: grid;
|
||
height: var(--_margin);
|
||
}
|
||
:is(#header, #footer) > * {
|
||
display: flex;
|
||
align-items: center;
|
||
min-width: 0;
|
||
}
|
||
:is(#header, #footer) > * > * {
|
||
width: 100%;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
text-align: center;
|
||
font-size: .75em;
|
||
opacity: .6;
|
||
}
|
||
</style>
|
||
<div id="top">
|
||
<div id="background" part="filter"></div>
|
||
<div id="header"></div>
|
||
<div id="container"></div>
|
||
<div id="footer"></div>
|
||
</div>
|
||
`
|
||
|
||
this.#top = this.#root.getElementById('top')
|
||
this.#background = this.#root.getElementById('background')
|
||
this.#container = this.#root.getElementById('container')
|
||
this.#header = this.#root.getElementById('header')
|
||
this.#footer = this.#root.getElementById('footer')
|
||
|
||
this.#observer.observe(this.#container)
|
||
this.#container.addEventListener('scroll', () => this.dispatchEvent(new Event('scroll')))
|
||
this.#container.addEventListener('scroll', debounce(() => {
|
||
if (this.scrolled) {
|
||
if (this.#justAnchored) this.#justAnchored = false
|
||
else this.#afterScroll('scroll')
|
||
}
|
||
}, 250))
|
||
|
||
const opts = { passive: false }
|
||
this.addEventListener('touchstart', this.#onTouchStart.bind(this), opts)
|
||
this.addEventListener('touchmove', this.#onTouchMove.bind(this), opts)
|
||
this.addEventListener('touchend', this.#onTouchEnd.bind(this))
|
||
this.addEventListener('load', ({ detail: { doc } }) => {
|
||
doc.addEventListener('touchstart', this.#onTouchStart.bind(this), opts)
|
||
doc.addEventListener('touchmove', this.#onTouchMove.bind(this), opts)
|
||
doc.addEventListener('touchend', this.#onTouchEnd.bind(this))
|
||
})
|
||
|
||
this.addEventListener('relocate', ({ detail }) => {
|
||
if (detail.reason === 'selection') setSelectionTo(this.#anchor, 0)
|
||
else if (detail.reason === 'navigation') {
|
||
if (this.#anchor === 1) setSelectionTo(detail.range, 1)
|
||
else if (typeof this.#anchor === 'number')
|
||
setSelectionTo(detail.range, -1)
|
||
else setSelectionTo(this.#anchor, -1)
|
||
}
|
||
})
|
||
const checkPointerSelection = debounce((range, sel) => {
|
||
if (!sel.rangeCount) return
|
||
const selRange = sel.getRangeAt(0)
|
||
const backward = selectionIsBackward(sel)
|
||
if (backward && selRange.compareBoundaryPoints(Range.START_TO_START, range) < 0)
|
||
this.prev()
|
||
else if (!backward && selRange.compareBoundaryPoints(Range.END_TO_END, range) > 0)
|
||
this.next()
|
||
}, 700)
|
||
this.addEventListener('load', ({ detail: { doc } }) => {
|
||
let isPointerSelecting = false
|
||
doc.addEventListener('pointerdown', e => {
|
||
if (e.pointerType !== 'touch') isPointerSelecting = true
|
||
})
|
||
doc.addEventListener('pointerup', () => isPointerSelecting = false)
|
||
doc.addEventListener('pointercancel', () => isPointerSelecting = false)
|
||
let isKeyboardSelecting = false
|
||
doc.addEventListener('keydown', () => isKeyboardSelecting = true)
|
||
doc.addEventListener('keyup', () => isKeyboardSelecting = false)
|
||
doc.addEventListener('selectionchange', () => {
|
||
if (this.scrolled) return
|
||
const range = this.#lastVisibleRange
|
||
if (!range) return
|
||
const sel = doc.getSelection()
|
||
if (!sel.rangeCount) return
|
||
if (isPointerSelecting && sel.type === 'Range')
|
||
checkPointerSelection(range, sel)
|
||
else if (isKeyboardSelecting) {
|
||
const selRange = sel.getRangeAt(0).cloneRange()
|
||
const backward = selectionIsBackward(sel)
|
||
if (!backward) selRange.collapse()
|
||
this.#scrollToAnchor(selRange)
|
||
}
|
||
})
|
||
doc.addEventListener('focusin', e => this.scrolled ? null :
|
||
// NOTE: `requestAnimationFrame` is needed in WebKit
|
||
requestAnimationFrame(() => this.#scrollToAnchor(e.target)))
|
||
// ----- custom touch selection -----
|
||
// Chromium's native touch selection misbehaves in the
|
||
// columned layout: quick taps select words, the word
|
||
// iterator re-anchors at page boundaries, and the browser
|
||
// auto-scrolls the container when selections cross pages.
|
||
// preventDefault on touchstart disables it entirely;
|
||
// long-press selects the word under the finger and drag
|
||
// extends it word by word, clamped to the visible page, so
|
||
// the selection can never leave the page and the view can
|
||
// never move mid-gesture.
|
||
if (!this.scrolled) {
|
||
let gesture = null
|
||
const clearGesture = () => {
|
||
if (gesture?.timer) clearTimeout(gesture.timer)
|
||
gesture = null
|
||
this.#touchSelActive = false
|
||
}
|
||
doc.addEventListener('touchstart', e => {
|
||
const touch = e.changedTouches[0]
|
||
if (!touch || e.touches.length !== 1) return clearGesture()
|
||
e.preventDefault()
|
||
const g = gesture = { x: touch.clientX, y: touch.clientY,
|
||
id: touch.identifier, start: null, end: null, timer: null }
|
||
g.timer = setTimeout(() => {
|
||
if (gesture !== g) return
|
||
const caret = this.#caretAt(g.x, g.y, doc)
|
||
const word = caret && wordAt(doc, caret.node, caret.offset)
|
||
if (!word || word.endOffset == null) {
|
||
gesture = null
|
||
return
|
||
}
|
||
const sel = doc.getSelection()
|
||
sel.setBaseAndExtent(word.node, word.offset,
|
||
word.node, word.endOffset)
|
||
g.start = { node: word.node, offset: word.offset }
|
||
g.end = { node: word.node, offset: word.endOffset }
|
||
this.#touchSelActive = true
|
||
}, 450)
|
||
}, { passive: false })
|
||
doc.addEventListener('touchmove', e => {
|
||
const g = gesture
|
||
if (!g) return
|
||
const touch = [...e.changedTouches]
|
||
.find(t => t.identifier === g.id)
|
||
if (!touch) return
|
||
if (!g.start) {
|
||
// moving before the long-press engages: a pan —
|
||
// stand down and let the paginator handle it
|
||
if (Math.hypot(touch.clientX - g.x, touch.clientY - g.y) > 12)
|
||
clearGesture()
|
||
return
|
||
}
|
||
e.preventDefault()
|
||
const caret = this.#caretAt(touch.clientX, touch.clientY, doc)
|
||
if (!caret) return
|
||
const word = wordAt(doc, caret.node, caret.offset)
|
||
const end = word?.endOffset != null
|
||
? { node: word.node, offset: word.endOffset }
|
||
: caret
|
||
const startProbe = doc.createRange()
|
||
startProbe.setStart(g.start.node, g.start.offset)
|
||
startProbe.collapse(true)
|
||
const endProbe = doc.createRange()
|
||
endProbe.setStart(end.node, end.offset)
|
||
endProbe.collapse(true)
|
||
// whichever boundary of the long-pressed word keeps
|
||
// it fully covered serves as the anchor
|
||
const anchor = startProbe.compareBoundaryPoints(
|
||
Range.START_TO_START, endProbe) > 0 ? g.end : g.start
|
||
doc.getSelection().setBaseAndExtent(anchor.node, anchor.offset,
|
||
end.node, end.offset)
|
||
}, { passive: false })
|
||
doc.addEventListener('touchend', e => {
|
||
const g = gesture
|
||
const wasSelecting = !!g?.start
|
||
clearGesture()
|
||
if (wasSelecting || e.changedTouches.length !== 1) return
|
||
const touch = e.changedTouches[0]
|
||
// touchstart's preventDefault suppresses click
|
||
// events; dispatch one so foliate's link handling and
|
||
// the overlayer's highlight hit-testing (the edit
|
||
// popover) behave as on desktop
|
||
const el = doc.elementFromPoint(touch.clientX, touch.clientY)
|
||
el?.dispatchEvent(new (doc.defaultView ?? window).MouseEvent(
|
||
'click', { clientX: touch.clientX, clientY: touch.clientY,
|
||
bubbles: true, cancelable: true }))
|
||
})
|
||
doc.addEventListener('touchcancel', clearGesture)
|
||
doc.addEventListener('contextmenu', e => e.preventDefault())
|
||
}
|
||
})
|
||
|
||
this.#mediaQueryListener = () => {
|
||
if (!this.#view) return
|
||
this.#background.style.background = getBackground(this.#view.document)
|
||
}
|
||
this.#mediaQuery.addEventListener('change', this.#mediaQueryListener)
|
||
}
|
||
attributeChangedCallback(name, _, value) {
|
||
switch (name) {
|
||
case 'flow':
|
||
this.render()
|
||
break
|
||
case 'gap':
|
||
case 'margin':
|
||
case 'max-block-size':
|
||
case 'max-column-count':
|
||
this.#top.style.setProperty('--_' + name, value)
|
||
break
|
||
case 'max-inline-size':
|
||
// needs explicit `render()` as it doesn't necessarily resize
|
||
this.#top.style.setProperty('--_' + name, value)
|
||
this.render()
|
||
break
|
||
}
|
||
}
|
||
open(book) {
|
||
this.bookDir = book.dir
|
||
this.sections = book.sections
|
||
book.transformTarget?.addEventListener('data', ({ detail }) => {
|
||
if (detail.type !== 'text/css') return
|
||
const w = innerWidth
|
||
const h = innerHeight
|
||
detail.data = Promise.resolve(detail.data).then(data => data
|
||
// unprefix as most of the props are (only) supported unprefixed
|
||
.replace(/(?<=[{\s;])-epub-/gi, '')
|
||
// replace vw and vh as they cause problems with layout
|
||
.replace(/(\d*\.?\d+)vw/gi, (_, d) => parseFloat(d) * w / 100 + 'px')
|
||
.replace(/(\d*\.?\d+)vh/gi, (_, d) => parseFloat(d) * h / 100 + 'px')
|
||
// `page-break-*` unsupported in columns; replace with `column-break-*`
|
||
.replace(/page-break-(after|before|inside)\s*:/gi, (_, x) =>
|
||
`-webkit-column-break-${x}:`)
|
||
.replace(/break-(after|before|inside)\s*:\s*(avoid-)?page/gi, (_, x, y) =>
|
||
`break-${x}: ${y ?? ''}column`))
|
||
})
|
||
}
|
||
#createView() {
|
||
if (this.#view) {
|
||
this.#view.destroy()
|
||
this.#container.removeChild(this.#view.element)
|
||
}
|
||
this.#view = new View({
|
||
container: this,
|
||
onExpand: () => this.#scrollToAnchor(this.#anchor),
|
||
})
|
||
this.#container.append(this.#view.element)
|
||
return this.#view
|
||
}
|
||
#beforeRender({ vertical, rtl, background }) {
|
||
this.#vertical = vertical
|
||
this.#rtl = rtl
|
||
this.#top.classList.toggle('vertical', vertical)
|
||
|
||
// set background to `doc` background
|
||
// this is needed because the iframe does not fill the whole element
|
||
this.#background.style.background = background
|
||
|
||
const { width, height } = this.#container.getBoundingClientRect()
|
||
const size = vertical ? height : width
|
||
|
||
const style = getComputedStyle(this.#top)
|
||
const maxInlineSize = parseFloat(style.getPropertyValue('--_max-inline-size'))
|
||
const maxColumnCount = parseInt(style.getPropertyValue('--_max-column-count-spread'))
|
||
const margin = parseFloat(style.getPropertyValue('--_margin'))
|
||
this.#margin = margin
|
||
|
||
const g = parseFloat(style.getPropertyValue('--_gap')) / 100
|
||
// The gap will be a percentage of the #container, not the whole view.
|
||
// This means the outer padding will be bigger than the column gap. Let
|
||
// `a` be the gap percentage. The actual percentage for the column gap
|
||
// will be (1 - a) * a. Let us call this `b`.
|
||
//
|
||
// To make them the same, we start by shrinking the outer padding
|
||
// setting to `b`, but keep the column gap setting the same at `a`. Then
|
||
// the actual size for the column gap will be (1 - b) * a. Repeating the
|
||
// process again and again, we get the sequence
|
||
// x₁ = (1 - b) * a
|
||
// x₂ = (1 - x₁) * a
|
||
// ...
|
||
// which converges to x = (1 - x) * a. Solving for x, x = a / (1 + a).
|
||
// So to make the spacing even, we must shrink the outer padding with
|
||
// f(x) = x / (1 + x).
|
||
// But we want to keep the outer padding, and make the inner gap bigger.
|
||
// So we apply the inverse, f⁻¹ = -x / (x - 1) to the column gap.
|
||
const gap = -g / (g - 1) * size
|
||
|
||
const flow = this.getAttribute('flow')
|
||
if (flow === 'scrolled') {
|
||
// FIXME: vertical-rl only, not -lr
|
||
this.setAttribute('dir', vertical ? 'rtl' : 'ltr')
|
||
this.#top.style.padding = '0'
|
||
const columnWidth = maxInlineSize
|
||
|
||
this.heads = null
|
||
this.feet = null
|
||
this.#header.replaceChildren()
|
||
this.#footer.replaceChildren()
|
||
|
||
return { flow, margin, gap, columnWidth }
|
||
}
|
||
|
||
const divisor = Math.min(maxColumnCount, Math.ceil(size / maxInlineSize))
|
||
const columnWidth = (size / divisor) - gap
|
||
this.setAttribute('dir', rtl ? 'rtl' : 'ltr')
|
||
|
||
const marginalDivisor = vertical
|
||
? Math.min(2, Math.ceil(width / maxInlineSize))
|
||
: divisor
|
||
const marginalStyle = {
|
||
gridTemplateColumns: `repeat(${marginalDivisor}, 1fr)`,
|
||
gap: `${gap}px`,
|
||
direction: this.bookDir === 'rtl' ? 'rtl' : 'ltr',
|
||
}
|
||
Object.assign(this.#header.style, marginalStyle)
|
||
Object.assign(this.#footer.style, marginalStyle)
|
||
const heads = makeMarginals(marginalDivisor, 'head')
|
||
const feet = makeMarginals(marginalDivisor, 'foot')
|
||
this.heads = heads.map(el => el.children[0])
|
||
this.feet = feet.map(el => el.children[0])
|
||
this.#header.replaceChildren(...heads)
|
||
this.#footer.replaceChildren(...feet)
|
||
|
||
return { height, width, margin, gap, columnWidth }
|
||
}
|
||
render() {
|
||
if (!this.#view) return
|
||
this.#view.render(this.#beforeRender({
|
||
vertical: this.#vertical,
|
||
rtl: this.#rtl,
|
||
}))
|
||
this.#scrollToAnchor(this.#anchor)
|
||
}
|
||
get scrolled() {
|
||
return this.getAttribute('flow') === 'scrolled'
|
||
}
|
||
get scrollProp() {
|
||
const { scrolled } = this
|
||
return this.#vertical ? (scrolled ? 'scrollLeft' : 'scrollTop')
|
||
: scrolled ? 'scrollTop' : 'scrollLeft'
|
||
}
|
||
get sideProp() {
|
||
const { scrolled } = this
|
||
return this.#vertical ? (scrolled ? 'width' : 'height')
|
||
: scrolled ? 'height' : 'width'
|
||
}
|
||
get size() {
|
||
return this.#container.getBoundingClientRect()[this.sideProp]
|
||
}
|
||
get viewSize() {
|
||
return this.#view.element.getBoundingClientRect()[this.sideProp]
|
||
}
|
||
get start() {
|
||
return Math.abs(this.#container[this.scrollProp])
|
||
}
|
||
get end() {
|
||
return this.start + this.size
|
||
}
|
||
get page() {
|
||
return Math.floor(((this.start + this.end) / 2) / this.size)
|
||
}
|
||
get pages() {
|
||
return Math.round(this.viewSize / this.size)
|
||
}
|
||
scrollBy(dx, dy) {
|
||
const delta = this.#vertical ? dy : dx
|
||
const element = this.#container
|
||
const { scrollProp } = this
|
||
const [offset, a, b] = this.#scrollBounds
|
||
const rtl = this.#rtl
|
||
const min = rtl ? offset - b : offset - a
|
||
const max = rtl ? offset + a : offset + b
|
||
element[scrollProp] = Math.max(min, Math.min(max,
|
||
element[scrollProp] + delta))
|
||
}
|
||
snap(vx, vy) {
|
||
const velocity = this.#vertical ? vy : vx
|
||
const [offset, a, b] = this.#scrollBounds
|
||
const { start, end, pages, size } = this
|
||
const min = Math.abs(offset) - a
|
||
const max = Math.abs(offset) + b
|
||
const d = velocity * (this.#rtl ? -size : size)
|
||
const page = Math.floor(
|
||
Math.max(min, Math.min(max, (start + end) / 2
|
||
+ (isNaN(d) ? 0 : d))) / size)
|
||
|
||
this.#scrollToPage(page, 'snap').then(() => {
|
||
const dir = page <= 0 ? -1 : page >= pages - 1 ? 1 : null
|
||
if (dir) return this.#goTo({
|
||
index: this.#adjacentIndex(dir),
|
||
anchor: dir < 0 ? () => 1 : () => 0,
|
||
})
|
||
})
|
||
}
|
||
#caretAt(x, y, doc) {
|
||
// Touch points and caret mapping share the iframe's own
|
||
// (content) space, where the visible page is [start - size,
|
||
// start] for LTR; clamp into it so selections cannot leave
|
||
// the visible page.
|
||
const size = this.#rtl ? -this.size : this.size
|
||
const left = this.start - size
|
||
const right = this.start
|
||
const cx = Math.min(Math.max(x, left + 2), right - 2)
|
||
const caret = doc.caretRangeFromPoint?.(cx, y)
|
||
?? doc.caretPositionFromPoint?.(cx, y)
|
||
if (!caret) return null
|
||
return { node: caret.startContainer ?? caret.offsetNode,
|
||
offset: caret.startOffset ?? caret.offset }
|
||
}
|
||
#onTouchStart(e) {
|
||
const touch = e.changedTouches[0]
|
||
this.#touchState = {
|
||
x: touch?.screenX, y: touch?.screenY,
|
||
t: e.timeStamp,
|
||
vx: 0, xy: 0,
|
||
}
|
||
}
|
||
#onTouchMove(e) {
|
||
const state = this.#touchState
|
||
if (state.pinched) return
|
||
state.pinched = globalThis.visualViewport.scale > 1
|
||
if (this.scrolled || state.pinched) return
|
||
if (this.#touchSelActive) return
|
||
if (e.touches.length > 1) {
|
||
if (this.#touchScrolled) e.preventDefault()
|
||
return
|
||
}
|
||
e.preventDefault()
|
||
const touch = e.changedTouches[0]
|
||
const x = touch.screenX, y = touch.screenY
|
||
const dx = state.x - x, dy = state.y - y
|
||
const dt = e.timeStamp - state.t
|
||
state.x = x
|
||
state.y = y
|
||
state.t = e.timeStamp
|
||
state.vx = dx / dt
|
||
state.vy = dy / dt
|
||
this.#touchScrolled = true
|
||
this.scrollBy(dx, dy)
|
||
}
|
||
#onTouchEnd() {
|
||
this.#touchScrolled = false
|
||
if (this.scrolled || this.#touchSelActive) return
|
||
|
||
// XXX: Firefox seems to report scale as 1... sometimes...?
|
||
// at this point I'm basically throwing `requestAnimationFrame` at
|
||
// anything that doesn't work
|
||
requestAnimationFrame(() => {
|
||
if (globalThis.visualViewport.scale === 1)
|
||
this.snap(this.#touchState.vx, this.#touchState.vy)
|
||
})
|
||
}
|
||
// allows one to process rects as if they were LTR and horizontal
|
||
#getRectMapper() {
|
||
if (this.scrolled) {
|
||
const size = this.viewSize
|
||
const margin = this.#margin
|
||
return this.#vertical
|
||
? ({ left, right }) =>
|
||
({ left: size - right - margin, right: size - left - margin })
|
||
: ({ top, bottom }) => ({ left: top + margin, right: bottom + margin })
|
||
}
|
||
const pxSize = this.pages * this.size
|
||
return this.#rtl
|
||
? ({ left, right }) =>
|
||
({ left: pxSize - right, right: pxSize - left })
|
||
: this.#vertical
|
||
? ({ top, bottom }) => ({ left: top, right: bottom })
|
||
: f => f
|
||
}
|
||
async #scrollToRect(rect, reason) {
|
||
if (this.scrolled) {
|
||
const offset = this.#getRectMapper()(rect).left - this.#margin
|
||
return this.#scrollTo(offset, reason)
|
||
}
|
||
const offset = this.#getRectMapper()(rect).left
|
||
return this.#scrollToPage(Math.floor(offset / this.size) + (this.#rtl ? -1 : 1), reason)
|
||
}
|
||
async #scrollTo(offset, reason, smooth) {
|
||
const element = this.#container
|
||
const { scrollProp, size } = this
|
||
if (element[scrollProp] === offset) {
|
||
this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size]
|
||
this.#afterScroll(reason)
|
||
return
|
||
}
|
||
// FIXME: vertical-rl only, not -lr
|
||
if (this.scrolled && this.#vertical) offset = -offset
|
||
if ((reason === 'snap' || smooth) && this.hasAttribute('animated')) return animate(
|
||
element[scrollProp], offset, 300, easeOutQuad,
|
||
x => element[scrollProp] = x,
|
||
).then(() => {
|
||
this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size]
|
||
this.#afterScroll(reason)
|
||
})
|
||
else {
|
||
element[scrollProp] = offset
|
||
this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size]
|
||
this.#afterScroll(reason)
|
||
}
|
||
}
|
||
async #scrollToPage(page, reason, smooth) {
|
||
const offset = this.size * (this.#rtl ? -page : page)
|
||
return this.#scrollTo(offset, reason, smooth)
|
||
}
|
||
async scrollToAnchor(anchor, select) {
|
||
return this.#scrollToAnchor(anchor, select ? 'selection' : 'navigation')
|
||
}
|
||
async #scrollToAnchor(anchor, reason = 'anchor') {
|
||
this.#anchor = anchor
|
||
const rects = uncollapse(anchor)?.getClientRects?.()
|
||
// if anchor is an element or a range
|
||
if (rects) {
|
||
// when the start of the range is immediately after a hyphen in the
|
||
// previous column, there is an extra zero width rect in that column
|
||
const rect = Array.from(rects)
|
||
.find(r => r.width > 0 && r.height > 0) || rects[0]
|
||
if (!rect) return
|
||
await this.#scrollToRect(rect, reason)
|
||
return
|
||
}
|
||
// if anchor is a fraction
|
||
if (this.scrolled) {
|
||
await this.#scrollTo(anchor * this.viewSize, reason)
|
||
return
|
||
}
|
||
const { pages } = this
|
||
if (!pages) return
|
||
const textPages = pages - 2
|
||
const newPage = Math.round(anchor * (textPages - 1))
|
||
await this.#scrollToPage(newPage + 1, reason)
|
||
}
|
||
#getVisibleRange() {
|
||
if (this.scrolled) return getVisibleRange(this.#view.document,
|
||
this.start + this.#margin, this.end - this.#margin, this.#getRectMapper())
|
||
const size = this.#rtl ? -this.size : this.size
|
||
return getVisibleRange(this.#view.document,
|
||
this.start - size, this.end - size, this.#getRectMapper())
|
||
}
|
||
#afterScroll(reason) {
|
||
const range = this.#getVisibleRange()
|
||
this.#lastVisibleRange = range
|
||
// don't set new anchor if relocation was to scroll to anchor
|
||
if (reason !== 'selection' && reason !== 'navigation' && reason !== 'anchor')
|
||
this.#anchor = range
|
||
else this.#justAnchored = true
|
||
|
||
const index = this.#index
|
||
const detail = { reason, range, index }
|
||
if (this.scrolled) detail.fraction = this.start / this.viewSize
|
||
else if (this.pages > 0) {
|
||
const { page, pages } = this
|
||
this.#header.style.visibility = page > 1 ? 'visible' : 'hidden'
|
||
detail.fraction = (page - 1) / (pages - 2)
|
||
detail.size = 1 / (pages - 2)
|
||
}
|
||
this.dispatchEvent(new CustomEvent('relocate', { detail }))
|
||
}
|
||
async #display(promise) {
|
||
const { index, src, anchor, onLoad, select } = await promise
|
||
this.#index = index
|
||
const hasFocus = this.#view?.document?.hasFocus()
|
||
if (src) {
|
||
const view = this.#createView()
|
||
const afterLoad = doc => {
|
||
if (doc.head) {
|
||
const $styleBefore = doc.createElement('style')
|
||
doc.head.prepend($styleBefore)
|
||
const $style = doc.createElement('style')
|
||
doc.head.append($style)
|
||
this.#styleMap.set(doc, [$styleBefore, $style])
|
||
}
|
||
onLoad?.({ doc, index })
|
||
}
|
||
const beforeRender = this.#beforeRender.bind(this)
|
||
await view.load(src, afterLoad, beforeRender)
|
||
this.dispatchEvent(new CustomEvent('create-overlayer', {
|
||
detail: {
|
||
doc: view.document, index,
|
||
attach: overlayer => view.overlayer = overlayer,
|
||
},
|
||
}))
|
||
this.#view = view
|
||
}
|
||
await this.scrollToAnchor((typeof anchor === 'function'
|
||
? anchor(this.#view.document) : anchor) ?? 0, select)
|
||
if (hasFocus) this.focusView()
|
||
}
|
||
#canGoToIndex(index) {
|
||
return index >= 0 && index <= this.sections.length - 1
|
||
}
|
||
async #goTo({ index, anchor, select}) {
|
||
if (index === this.#index) await this.#display({ index, anchor, select })
|
||
else {
|
||
const oldIndex = this.#index
|
||
const onLoad = detail => {
|
||
this.sections[oldIndex]?.unload?.()
|
||
this.setStyles(this.#styles)
|
||
this.dispatchEvent(new CustomEvent('load', { detail }))
|
||
}
|
||
await this.#display(Promise.resolve(this.sections[index].load())
|
||
.then(src => ({ index, src, anchor, onLoad, select }))
|
||
.catch(e => {
|
||
console.warn(e)
|
||
console.warn(new Error(`Failed to load section ${index}`))
|
||
return {}
|
||
}))
|
||
}
|
||
}
|
||
async goTo(target) {
|
||
if (this.#locked) return
|
||
const resolved = await target
|
||
if (this.#canGoToIndex(resolved.index)) return this.#goTo(resolved)
|
||
}
|
||
#scrollPrev(distance) {
|
||
if (!this.#view) return true
|
||
if (this.scrolled) {
|
||
if (this.start > 0) return this.#scrollTo(
|
||
Math.max(0, this.start - (distance ?? this.size)), null, true)
|
||
return true
|
||
}
|
||
if (this.atStart) return
|
||
const page = this.page - 1
|
||
return this.#scrollToPage(page, 'page', true).then(() => page <= 0)
|
||
}
|
||
#scrollNext(distance) {
|
||
if (!this.#view) return true
|
||
if (this.scrolled) {
|
||
if (this.viewSize - this.end > 2) return this.#scrollTo(
|
||
Math.min(this.viewSize, distance ? this.start + distance : this.end), null, true)
|
||
return true
|
||
}
|
||
if (this.atEnd) return
|
||
const page = this.page + 1
|
||
const pages = this.pages
|
||
return this.#scrollToPage(page, 'page', true).then(() => page >= pages - 1)
|
||
}
|
||
get atStart() {
|
||
return this.#adjacentIndex(-1) == null && this.page <= 1
|
||
}
|
||
get atEnd() {
|
||
return this.#adjacentIndex(1) == null && this.page >= this.pages - 2
|
||
}
|
||
#adjacentIndex(dir) {
|
||
for (let index = this.#index + dir; this.#canGoToIndex(index); index += dir)
|
||
if (this.sections[index]?.linear !== 'no') return index
|
||
}
|
||
async #turnPage(dir, distance) {
|
||
if (this.#locked) return
|
||
this.#locked = true
|
||
const prev = dir === -1
|
||
const shouldGo = await (prev ? this.#scrollPrev(distance) : this.#scrollNext(distance))
|
||
if (shouldGo) await this.#goTo({
|
||
index: this.#adjacentIndex(dir),
|
||
anchor: prev ? () => 1 : () => 0,
|
||
})
|
||
if (shouldGo || !this.hasAttribute('animated')) await wait(100)
|
||
this.#locked = false
|
||
}
|
||
prev(distance) {
|
||
return this.#turnPage(-1, distance)
|
||
}
|
||
next(distance) {
|
||
return this.#turnPage(1, distance)
|
||
}
|
||
prevSection() {
|
||
return this.goTo({ index: this.#adjacentIndex(-1) })
|
||
}
|
||
nextSection() {
|
||
return this.goTo({ index: this.#adjacentIndex(1) })
|
||
}
|
||
firstSection() {
|
||
const index = this.sections.findIndex(section => section.linear !== 'no')
|
||
return this.goTo({ index })
|
||
}
|
||
lastSection() {
|
||
const index = this.sections.findLastIndex(section => section.linear !== 'no')
|
||
return this.goTo({ index })
|
||
}
|
||
getContents() {
|
||
if (this.#view) return [{
|
||
index: this.#index,
|
||
overlayer: this.#view.overlayer,
|
||
doc: this.#view.document,
|
||
}]
|
||
return []
|
||
}
|
||
setStyles(styles) {
|
||
this.#styles = styles
|
||
const $$styles = this.#styleMap.get(this.#view?.document)
|
||
if (!$$styles) return
|
||
const [$beforeStyle, $style] = $$styles
|
||
if (Array.isArray(styles)) {
|
||
const [beforeStyle, style] = styles
|
||
$beforeStyle.textContent = beforeStyle
|
||
$style.textContent = style
|
||
} else $style.textContent = styles
|
||
|
||
// NOTE: needs `requestAnimationFrame` in Chromium
|
||
requestAnimationFrame(() =>
|
||
this.#background.style.background = getBackground(this.#view.document))
|
||
|
||
// needed because the resize observer doesn't work in Firefox
|
||
this.#view?.document?.fonts?.ready?.then(() => this.#view.expand())
|
||
}
|
||
focusView() {
|
||
this.#view.document.defaultView.focus()
|
||
}
|
||
destroy() {
|
||
this.#observer.unobserve(this)
|
||
this.#view.destroy()
|
||
this.#view = null
|
||
this.sections[this.#index]?.unload?.()
|
||
this.#mediaQuery.removeEventListener('change', this.#mediaQueryListener)
|
||
}
|
||
}
|
||
|
||
customElements.define('foliate-paginator', Paginator)
|