Files
foliate-js/paginator.js
T

822 lines
30 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)
}
}
// 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) 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
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(a.getBoundingClientRect())
const q = mapRect(b.getBoundingClientRect())
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(a.getBoundingClientRect())
const q = mapRect(b.getBoundingClientRect())
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 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
})
class View {
#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%',
})
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)
new ResizeObserver(() => this.expand()).observe(doc.body)
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
Object.assign(doc.documentElement.style, {
boxSizing: 'border-box',
padding: vertical ? `${gap}px 0` : `0 ${gap}px`,
columnWidth: 'auto',
height: 'auto',
width: 'auto',
})
Object.assign(doc.body.style, {
[vertical ? 'maxHeight' : 'maxWidth']: `${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
Object.assign(doc.documentElement.style, {
boxSizing: 'border-box',
columnWidth: `${Math.trunc(columnWidth)}px`,
columnGap: `${gap}px`,
columnFill: 'auto',
...(vertical
? { width: `${width}px` }
: { height: `${height}px` }),
padding: vertical ? `${gap / 2}px 0` : `0 ${gap / 2}px`,
overflow: 'hidden',
// force wrap long words
overflowWrap: 'anywhere',
// reset some potentially problematic props
position: 'static', border: '0', margin: '0',
maxHeight: 'none', maxWidth: 'none',
minHeight: 'none', minWidth: 'none',
// fix glyph clipping in WebKit
webkitLineBoxContain: 'block glyphs replaced',
})
Object.assign(doc.body.style, {
maxHeight: 'none',
maxWidth: '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)
Object.assign(el.style, {
maxHeight: vertical
? (maxHeight !== 'none' && maxHeight !== '0px' ? maxHeight : '100%')
: `${height - margin * 2}px`,
maxWidth: vertical
? `${width - margin * 2}px`
: (maxWidth !== 'none' && maxWidth !== '0px' ? maxWidth : '100%'),
objectFit: 'contain',
pageBreakInside: 'avoid',
breakInside: 'avoid',
boxSizing: 'border-box',
})
}
}
expand() {
if (this.#column) {
const side = this.#vertical ? 'height' : 'width'
const otherSide = this.#vertical ? 'width' : 'height'
const contentSize = this.#contentRange.getBoundingClientRect()[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}px`
this.#iframe.style[otherSide] = '100%'
this.#element.style[otherSide] = '100%'
if (this.document)
this.document.documentElement.style[side] = `${this.#size}px`
if (this.#overlayer) {
this.#overlayer.element.style.margin = '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 doc = this.document
const contentSize = doc?.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[side] = `${expandedSize}px`
this.#overlayer.redraw()
}
}
this.onExpand()
}
set overlayer(overlayer) {
this.#overlayer = overlayer
this.#element.append(overlayer.element)
}
get overlayer() {
return this.#overlayer
}
}
// NOTE: everything here assumes the so-called "negative scroll type" for RTL
export class Paginator extends HTMLElement {
#root = this.attachShadow({ mode: 'closed' })
#gap = 0
#shouldUpdateGap = true
#observer = new ResizeObserver(() => this.render())
#background
#maxWidthContainer
#maxHeightContainer
#container
#header
#footer
#view
#vertical = false
#rtl = false
#index = -1
#anchor = 0 // anchor view to a fraction (0-1), Range, or Element
#locked = false // while true, prevent any further navigation
#styleMap = new WeakMap()
layout = {
margin: 48,
gap: 0.05,
maxColumnWidth: 700,
}
constructor() {
super()
this.#root.innerHTML = `<style>
:host {
box-sizing: border-box;
position: relative;
overflow: hidden;
display: flex;
}
#background {
position: absolute;
top: 0;
left: 0;
}
#max-width {
position: relative;
display: flex;
}
:host, #background, #max-width, #max-height, #container {
width: 100%;
height: 100%;
margin: auto;
}
#header, #footer {
position: absolute;
left: 0;
right: 0;
display: grid;
margin: auto;
}
:is(#header, #footer) > * {
display: flex;
align-items: center;
minWidth: 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="background" part="filter"></div>
<div id="max-width">
<div id="max-height">
<div id="container"></div>
</div>
<div id="header"></div>
<div id="footer"></div>
</div>
`
this.#background = this.#root.getElementById('background')
this.#maxWidthContainer = this.#root.getElementById('max-width')
this.#maxHeightContainer = this.#root.getElementById('max-height')
this.#container = this.#root.getElementById('container')
this.#header = this.#root.getElementById('header')
this.#footer = this.#root.getElementById('footer')
this.#observer.observe(this)
this.#container.addEventListener('scroll', debounce(() => {
if (this.scrolled) this.#afterScroll('scroll')
}, 250))
}
open(book) {
this.bookDir = book.dir
this.sections = book.sections
}
#createView() {
if (this.#view) this.#container.removeChild(this.#view.element)
this.#view = new View({
container: this,
onExpand: this.#scrollToAnchor.bind(this),
})
this.#container.append(this.#view.element)
return this.#view
}
#beforeRender({ vertical, rtl, background }) {
this.#vertical = vertical
this.#rtl = rtl
// set background to `doc` background
// this is needed because the iframe does not fill the whole element
this.#background.style.background = background
const { flow, margin, gap: gape, maxColumnWidth, maxColumns } = this.layout
if (flow === 'scrolled') {
// FIXME: vertical-rl only, not -lr
this.setAttribute('dir', vertical ? 'rtl' : 'ltr')
this.style.padding = '0'
this.#container.style.overflow ='auto'
this.#maxWidthContainer.style.maxWidth = 'none'
this.#maxHeightContainer.style.maxHeight = 'none'
const columnWidth = this.layout.maxColumnWidth
const { width, height } = this.#container.getBoundingClientRect()
const size = vertical ? height : width
const gap = Math.trunc(gape * size)
this.heads = null
this.feet = null
this.#header.replaceChildren()
this.#footer.replaceChildren()
return { flow, margin, gap, columnWidth }
}
const maxSize = `${maxColumns * maxColumnWidth}px`
this.#maxWidthContainer.style.maxWidth = vertical ? 'none' : maxSize
this.#maxHeightContainer.style.maxHeight = vertical ? maxSize : 'none'
if (this.#shouldUpdateGap) {
this.#shouldUpdateGap = false
const { width, height } = this.#container.getBoundingClientRect()
const size = vertical ? height : width
const gap = Math.trunc(gape * size)
const paddingH = `${vertical
? Math.min(margin, Math.trunc(gape * width))
: gap / 2}px`
const paddingV = `${vertical
? Math.max(margin - gap / 2, gap / 2)
: margin}px`
this.#gap = gap
this.style.padding = `${paddingV} ${paddingH}`
this.#header.style.top = `-${paddingV}`
this.#footer.style.bottom = `-${paddingV}`
const newRect = this.#container.getBoundingClientRect()
const newSize = vertical ? newRect.height : newRect.width
// if the size is different, don't do anything
// as the resize observer would fire in that case
if (newSize !== size) return
}
this.#shouldUpdateGap = true
const gap = this.#gap
const { width, height } = this.#container.getBoundingClientRect()
const size = vertical ? height : width
const divisor = Math.ceil(size / maxColumnWidth)
const columnWidth = (size / divisor) - gap
this.setAttribute('dir', rtl ? 'rtl' : 'ltr')
this.#container.style.overflow ='hidden'
const marginalDivisor = vertical
? Math.min(2, Math.ceil(width / maxColumnWidth))
: divisor
const marginalStyle = {
maxWidth: vertical ? 'none' : maxSize,
height: `${margin}px`,
gridTemplateColumns: `repeat(${marginalDivisor}, 1fr)`,
gap: `${gap}px`,
padding: vertical ? '0' : `0 ${gap / 2}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()
}
get scrolled() {
return this.layout.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)
}
// allows one to process rects as if they were LTR and horizontal
#getRectMapper() {
if (this.scrolled) {
const size = this.viewSize
const margin = this.layout.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.layout.margin
return this.#scrollTo(offset, reason)
}
const offset = this.#getRectMapper()(rect).left
+ this.layout.margin / 2
return this.#scrollToPage(Math.floor(offset / this.size), reason)
}
async #scrollTo(offset, reason) {
const element = this.#container
const { scrollProp } = this
if (element[scrollProp] === offset) {
this.#afterScroll(reason)
return
}
// FIXME: vertical-rl only, not -lr
if (this.scrolled && this.#vertical) offset = -offset
element[scrollProp] = offset
this.#afterScroll(reason)
/*return new Promise((resolve, reject) => {
try {
const onScroll = () => {
if (element[scrollProp] - offset > 2) return
element.removeEventListener('scroll', onScroll)
resolve()
this.#afterScroll(reason)
}
element.addEventListener('scroll', onScroll)
if (this.scrolled) {
const coord = scrollProp === 'scrollLeft' ? 'left' : 'top'
element.scrollTo({ [coord]: offset, behavior: 'smooth' })
}
element[scrollProp] = offset
} catch (e) {
reject(e)
}
})*/
}
async #scrollToPage(page, reason) {
const offset = this.size * (this.#rtl ? -page : page)
return this.#scrollTo(offset, reason)
}
async #scrollToAnchor(select) {
const rects = uncollapse(this.#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, 'anchor')
if (select) this.#selectAnchor()
return
}
// if anchor is a fraction
if (this.scrolled) {
await this.#scrollTo(this.#anchor * this.viewSize, 'anchor')
return
}
const { pages } = this
if (!pages) return
const newPage = Math.round(this.#anchor * (pages - 1))
await this.#scrollToPage(newPage, 'anchor')
}
#selectAnchor() {
const { defaultView } = this.#view.document
if (this.#anchor instanceof defaultView.Range) {
const sel = defaultView.getSelection()
sel.removeAllRanges()
sel.addRange(this.#anchor)
}
}
#getVisibleRange() {
return getVisibleRange(this.#view.document,
this.start, this.end, this.#getRectMapper(), this.scrolled)
}
#afterScroll(reason) {
const range = this.#getVisibleRange()
// don't set new anchor if relocation was to scroll to anchor
if (reason !== 'anchor') this.#anchor = range
const index = this.#index
const detail = { 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 > 0 ? 'visible' : 'hidden'
detail.fraction = page / pages
detail.size = 1 / pages
}
this.dispatchEvent(new CustomEvent('relocate', { detail }))
}
async #display(promise) {
const { index, src, anchor, onLoad, select } = await promise
this.#index = index
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
}
this.#anchor = (typeof anchor === 'function'
? anchor(this.#view.document) : anchor) ?? 0
await this.#scrollToAnchor(select)
}
#canScrollToPage(page) {
return page > -1 && page < this.pages
}
scrollPrev() {
if (!this.#view) return null
if (this.scrolled) {
if (this.start > 0)
return this.#scrollTo(Math.max(0, this.start - this.size))
else return null
}
const page = this.page - 1
if (this.#canScrollToPage(page)) return this.#scrollToPage(page)
return null
}
scrollNext() {
if (!this.#view) return null
if (this.scrolled) {
if (this.viewSize - this.end > 2)
return this.#scrollTo(Math.min(this.viewSize, this.end))
else return null
}
const page = this.page + 1
if (this.#canScrollToPage(page)) return this.#scrollToPage(page)
return null
}
#canGoToIndex(index) {
return index >= 0 && index <= this.sections.length - 1
}
async #goTo(tryScroll, target, lock) {
if (this.#locked) return
if (lock) this.#locked = true
const scroll = tryScroll?.()
if (scroll) await scroll
else {
const { index, anchor, select } = await target
if (!this.#canGoToIndex(index)) {
this.#locked = false
return null
}
if (index === this.#index) await this.#display({ index, anchor, select })
else {
const oldIndex = this.#index
const onLoad = detail => {
this.sections[oldIndex]?.unload?.()
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 {}
}))
}
}
if (lock) {
await wait(100) // throttle by 100ms
this.#locked = false
}
}
async goTo(target) {
return this.#goTo(null, target)
}
#adjacentIndex(dir) {
for (let index = this.#index + dir; this.#canGoToIndex(index); index += dir)
if (this.sections[index]?.linear !== 'no') return index
}
prev() {
const index = this.#adjacentIndex(-1)
return this.#goTo(() => this.scrollPrev(), { index, anchor: () => 1 }, true)
}
next() {
const index = this.#adjacentIndex(1)
return this.#goTo(() => this.scrollNext(), { index }, true)
}
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 })
}
getOverlayer() {
if (this.#view) return {
index: this.#index,
overlayer: this.#view.overlayer,
doc: this.#view.document,
}
}
setStyle(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
}
deselect() {
const sel = this.#view.document.defaultView.getSelection()
sel.removeAllRanges()
}
destroy() {
this.#observer.unobserve(this)
}
}
customElements.define('foliate-paginator', Paginator)