mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-30 23:49:10 -04:00
Chrome's native touch selection misbehaves in the paginated column layout: quick taps select words, and dragging from a line-start word re-anchors at a rendered line break, dropping the word and freezing the gesture. preventDefault on touchstart disables it entirely; a long-press timer selects the word under the finger instead and drag moves extend word by word, clamped to the visible page and identical for every word. Clean taps clear the selection or follow links (click events being suppressed by the preventDefault).
1344 lines
54 KiB
JavaScript
1344 lines
54 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' })
|
|
#observer = new ResizeObserver(() => 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 touchSelecting = false
|
|
doc.addEventListener('pointerdown', e => {
|
|
touchSelecting = e.pointerType === 'touch'
|
|
})
|
|
doc.addEventListener('keydown', () => touchSelecting = false)
|
|
// The word selected by the initial long-press. Chrome can
|
|
// re-anchor a touch drag at a rendered line break when the
|
|
// finger crosses just past a line-start word, silently dropping
|
|
// that word from the selection; keep it covered. The repair
|
|
// must never run while the gesture is still changing the
|
|
// selection: a write at the re-anchor moment detaches Chrome's
|
|
// touch selection controller and the selection stops following
|
|
// the finger. Focus-only writes on a settled selection are
|
|
// tolerated, so record here and repair only from the deferred,
|
|
// stability-gated callbacks below.
|
|
let anchorWordStart = null
|
|
doc.addEventListener('pointerdown', e => {
|
|
if (e.pointerType === 'touch') anchorWordStart = null
|
|
})
|
|
const recordAnchorWord = sel => {
|
|
if (anchorWordStart || !sel.rangeCount) return
|
|
const r = sel.getRangeAt(0)
|
|
anchorWordStart =
|
|
{ node: r.startContainer, offset: r.startOffset }
|
|
}
|
|
const repairAnchorWord = sel => {
|
|
if (!anchorWordStart || !sel.rangeCount
|
|
|| sel.type !== 'Range') return false
|
|
const r = sel.getRangeAt(0)
|
|
const probe = doc.createRange()
|
|
probe.setStart(anchorWordStart.node, anchorWordStart.offset)
|
|
probe.collapse(true)
|
|
if (r.compareBoundaryPoints(Range.START_TO_START, probe) <= 0)
|
|
return false
|
|
if (selectionIsBackward(sel))
|
|
sel.extend(probe.startContainer, probe.startOffset)
|
|
else
|
|
sel.setBaseAndExtent(probe.startContainer,
|
|
probe.startOffset, sel.focusNode, sel.focusOffset)
|
|
return true
|
|
}
|
|
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 (touchSelecting && sel.type === 'Range') {
|
|
recordAnchorWord(sel)
|
|
this.#clampTouchSelection(sel, doc)
|
|
// Chrome ignores (or re-maps) selection writes made from
|
|
// JS while the touch selection gesture is active, and no
|
|
// pointer or touch event reaches the document when the
|
|
// finger lifts after the takeover — the only signal is
|
|
// the last selectionchange, mid-gesture. Retry after the
|
|
// gesture has most likely ended, and only while the
|
|
// selection is unchanged since the event that scheduled
|
|
// the retry (an actively changing selection supersedes it
|
|
// and owns the next retry).
|
|
const snap = [sel.anchorNode, sel.anchorOffset,
|
|
sel.focusNode, sel.focusOffset]
|
|
const same = s => s.anchorNode === snap[0]
|
|
&& s.anchorOffset === snap[1]
|
|
&& s.focusNode === snap[2]
|
|
&& s.focusOffset === snap[3]
|
|
const clampLater = () => {
|
|
const s = doc.getSelection()
|
|
if (!s || !s.rangeCount || s.type !== 'Range'
|
|
|| !same(s)) return
|
|
if (repairAnchorWord(s)) return
|
|
this.#clampTouchSelection(s, doc)
|
|
}
|
|
setTimeout(clampLater, 150)
|
|
setTimeout(clampLater, 500)
|
|
}
|
|
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 -----
|
|
// Chrome's native touch selection is disabled for the paginated
|
|
// layout (preventDefault on touchstart): quick taps would select
|
|
// words, and dragging from a line-start word re-anchors at a
|
|
// rendered line break, dropping the word and freezing the
|
|
// gesture. Selection is built here instead: long-press selects
|
|
// the word under the finger, dragging extends it word by word,
|
|
// clamped to the visible page — identical for every word.
|
|
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: this is 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
|
|
const sel = doc.getSelection()
|
|
sel.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]
|
|
const sel = doc.getSelection()
|
|
if (sel && sel.rangeCount) {
|
|
// a plain tap clears the selection instead of paging
|
|
sel.removeAllRanges()
|
|
return
|
|
}
|
|
// touchstart's preventDefault suppresses click events;
|
|
// follow links directly
|
|
const link = doc.elementFromPoint(touch.clientX, touch.clientY)
|
|
?.closest('a')
|
|
link?.click()
|
|
})
|
|
doc.addEventListener('touchcancel', clearGesture)
|
|
}
|
|
})
|
|
|
|
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,
|
|
})
|
|
})
|
|
}
|
|
#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())
|
|
}
|
|
#caretAt(x, y, doc) {
|
|
const size = this.#rtl ? -this.size : this.size
|
|
const left = this.start - size
|
|
const right = this.end - size
|
|
const cx = Math.min(Math.max(x, left + 1), right - 1)
|
|
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 }
|
|
}
|
|
#clampTouchSelection(sel, doc) {
|
|
if (!sel.rangeCount) return
|
|
const backward = selectionIsBackward(sel)
|
|
const probe = sel.getRangeAt(0).cloneRange()
|
|
probe.collapse(backward)
|
|
const rect = probe.getBoundingClientRect()
|
|
if (!rect || (rect.width === 0 && rect.height === 0 && !rect.x && !rect.y))
|
|
return
|
|
const size = this.#rtl ? -this.size : this.size
|
|
const left = this.start - size
|
|
const right = this.end - size
|
|
if (backward ? rect.left >= left : rect.left <= right) return
|
|
const x = backward ? left + 1 : right - 1
|
|
const caret = doc.caretRangeFromPoint?.(x, rect.top)
|
|
?? doc.caretPositionFromPoint?.(x, rect.top)
|
|
if (!caret) return
|
|
const node = caret.startContainer ?? caret.offsetNode
|
|
const offset = caret.startOffset ?? caret.offset
|
|
if (node) sel.extend(node, offset)
|
|
}
|
|
#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)
|