Files
silo-server/web/vendor/foliate-js/paginator.js
8b70357703 feat(ebooks): first-class ebook libraries, scanner, and reader (#124)
* docs: define ebook architecture matching audiobooks

* docs: plan ebook audiobook-parity implementation

* feat: add ebook scanner parser foundation

* fix: harden ebook scanner foundation

* fix: handle ebook isbn labels

* fix: guard ebook subtree scans

* feat: scan ebook libraries in core

* fix: preserve ebook scan people credits

* fix: refresh ebook scan metadata safely

* feat: persist ebook series membership

* test: cover ebook series persistence decisions

* fix: address ebook scanner PR review

* docs: clarify ebook foundation PR scope

* feat: add ebook metadata enricher

* fix: harden ebook poster cache

* feat: wire ebook metadata sync task

* feat: expose ebook library metadata setup

* feat: add ebook catalog scope support

* feat: add ebook detail view

* feat: label ebook file versions by format

* feat: use file-size copy for downloads

* feat: use file language in download dialog

* test: cover ebook detail authors and downloads

* fix: drop narrator credits from ebook scanner merges

* fix: align ebook collection filters with book media

* fix: drop asin provider ids from ebook enrichment

* fix: force ebook people refresh for stale narrators

* chore: omit ebook planning docs from branch

* feat: add ebook detail related content

* feat: add ebook reader file entrypoint

* feat: render ebooks with foliate reader

* feat: persist ebook reader progress

* feat: add ebook reader controls

* feat: extract ebook pdf metadata

* feat: favor scanner isbn during ebook enrichment

* feat: extract fbz ebook metadata

* feat: count cbz ebook pages

* feat: show ebook file page counts

* feat: show ebook download summaries

* feat: switch ebook reader files

* feat: prefer epub for ebook read action

* feat: surface ebook reader progress

* feat: sync ebook reader progress cache

* feat: hide ebook read action for unsupported files

* feat: filter ebook reader file selector

* fix: serve fbz ebook archives with reader mime type

* fix: detect fbz ebooks from compound filename

* fix: authorize fbz ebooks from compound filename

* fix: scope ebook catalog facets

* fix: reject narrator queries for ebooks

* fix: build ebook recommendation text from authors

* fix: include ebooks in embedding eligibility

* fix: include ebooks in recommendation media mix

* fix: include ebooks in recently added recommendations

* feat: include ebook progress in recommendation signals

* feat: include ebooks in continue watching sections

* feat: include ebooks in catalog progress metrics

* fix: read ebook isbn from epub metadata

* fix: filter ebook asin provider aliases

* fix: fall back from unsupported ebook reader files

* fix: sort ebook catalogs by reader progress

* fix: filter ebook catalogs by reader progress

* fix: include ebooks in last watched catalog filters

* feat: reflect ebook reader progress in item user state

* feat: share ebook progress state across item surfaces

* feat: report ebook scan progress

* fix: include ebook activity in recommendations

* fix: expose ebook reader progress on item detail

* fix: support ebook subtree scans

* fix: honor profile header for ebook item progress

* fix: add ebook library default sections

* fix: route ebook continue cards to reader

* fix: hide watched toggle for ebooks

* fix: route ebook watch tonight cards to reader

* fix: route ebook hero actions to reader

* fix: detect archive ebook reader formats by filename

* feat: cache embedded ebook covers during scan

* fix: encode ebook hero reader links

* fix: persist non-epub ebook reader progress

* fix: scope narrator catalog badges to audiobooks

* fix: merge ebook reader progress during item repair

* fix: label ebook progress filters as read

* fix: show ebook related rails as book covers

* fix: remove txt ebook reader support

* fix: reject txt ebook reader files

* fix: label ebook advanced filters as read

* fix: label ebook personalized sorts as read

* fix: remove plain text reader loader path

* test: cover ebook unread catalog rules

* fix: preserve ebook reader library context

* fix: link ebook genres with library scope

* fix: encode related rail item links

* fix: encode catalog card item links

* fix: encode hero and continue item links

* fix: encode watch tonight item links

* fix: encode recommendation and search item links

* test: cover ebook scan format set

* fix: label ebook search results clearly

* fix: make global search prompt media neutral

* fix: encode catalog read API ids

* fix: encode item API ids

* fix: include ebook reader vendor in docker build

* fix: make ebook reader build clean

* fix: clean ebook embedded descriptions

* docs: plan ebook reader shell parity

* feat: add ebook reader shell controls

* fix: widen ebook scrolled reader flow

* fix: remove scrolled reader content width cap

* docs: plan ebook reader full parity

* feat: persist ebook reader config

* feat: add ebook annotations and bookmarks

* feat: add ebook reader tools and aids

* feat: add ebook advanced reader settings

* fix: keep ebook reader panel in viewport

* fix: use foliate sizing units for ebook scroll flow

* fix: keep ebook settings controls readable

* fix: simplify ebook reader settings controls

* feat(ebooks): extract local covers during scan (#98)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* fix(ebooks): harden local cover extraction and format grouping

Address review findings on the local cover scan:

- Restrict generic sidecar covers (cover.jpg, folder.png, ...) to
  single-book directories, always accept images named after the book
  file, and apply exactly one cover per reconcile with sidecar taking
  precedence over the embedded cover.
- Replace the read-then-write poster update with an atomic conditional
  UPDATE (ItemRepository.SetLocalPoster) so provider/admin artwork is
  never clobbered by concurrent writers, and refresh locally owned
  posters when the extracted cover bytes change (thumbhash compare).
- Preserve UTF-8 PDF Info strings (including a UTF-8 BOM) instead of
  forcing everything through Windows-1252; the cp1252 fallback now only
  applies to non-UTF-8 bytes.
- Select EPUB covers by manifest media-type with properties="cover-image"
  outranking the EPUB2 meta name="cover" id, so XHTML cover pages no
  longer shadow the real image.
- Order CBZ pages naturally (2.jpg before 10.jpg, ch2/ before ch10/)
  when picking the cover page, via a single O(n) min-scan.
- Bump the ebook content group key scheme to version 2 and reprocess
  rows written under older versions so pre-existing libraries gain
  sibling-format grouping instead of accumulating duplicates.
- Group different formats only (a same-format sibling with colliding
  sparse metadata stays a separate item) and stop a joining sibling's
  embedded metadata from overwriting a provider-matched item.
- Decode any IANA-labelled OPF/FB2 XML charset (windows-1251, koi8-r,
  shift_jis, ...) via x/net/html/charset, and wire the charset reader
  into FB2 parsing which previously had none.
- Strip the full .fb2.zip double extension from filename-derived titles
  and group keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): add reader profiles and ruler (#99)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* feat(ebooks): add reader profiles and ruler

* fix(ebooks): address reader ruler and profile review findings

- skip renderer setStyles/render when computed styles and attributes are
  unchanged, so ruler position updates no longer re-style the book view
- drag the ruler via a local draft that commits on release, with the
  surface rect cached at pointer-down
- migrate font values persisted before the generic stacks (Inter,
  Georgia, Merriweather, legacy serif) so the font select never renders
  blank, with a Custom fallback option for unknown values
- make the ruler band click-through and move dragging to a dedicated
  keyboard-accessible slider handle so links and text selection keep
  working under the band
- share font stacks between options and profiles via READER_FONT_STACKS
- surface the active reading profile, move presets to the top of the
  settings panel, and drop the redundant profile button aria-labels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): resolve prefer-const lint error in readest document lib

`pnpm run lint` failed on the branch because `direction` is never
reassigned in getDirection; split the destructure so only the
reassigned `writingMode` stays mutable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Merge branch 'main' into work/ebooks-reader-base

Brings the ebook integration branch up to date with main (audiobook
library redesign, continue-watching rework and card affordances,
quic-go bump, jellycompat fixes). Conflict resolutions favor main's
generalized mechanisms and register ebooks with them:

- media scope validation goes through IsValidMediaScope (now including
  "ebook" alongside main's "video" group scope), in Go and in the web
  filter/search types
- continue-watching uses main's typed rails; reading-type sections pull
  resume points from ebook_reader_progress and the ebook library default
  section is wired to ContinueTypeConfig(ContinueTypeReading)
- item_repo keeps main's derived select-list machinery (itemColumnExpr)
  and both poster accessors (GetPoster/SetLocalPoster for ebook covers,
  GetPosterPath for audiobook covers)
- web cards/hero/watch-tonight adopt main's buildMediaPlayHref helpers,
  which now route ebooks to /reader/ebook and encode content ids;
  ebook affordances (BookOpen icon, Read verb, percent-read subtitle)
  carry over onto main's reworked components
- LibraryForm ebook support ported into main's refactored
  useLibraryForm/libraryTypes modules

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy foliate-js vendor into Dockerfile.dev frontend stage

foliate-js is a file:vendor/foliate-js dependency, so pnpm install needs
the vendor directory before the lockfile install layer. The production
Dockerfile already copies it; the dev image was missed, breaking
make dev-deploy with ENOENT on /app/web/vendor/foliate-js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): render Continue Reading sections as upright poster cards

All-ebook continue sections previously fell through to the horizontal
16:9 wide card; include ebooks in the poster-variant check so book
covers render in their natural 2:3 framing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): stop related-rail highlight ring clipping on detail pages

Move the current-item ring onto the cover artwork with a themed
ring-offset color (matching the sidebar profile highlight) and give
the scroll container top headroom so the ring is not cut off by
overflow-x-auto. Applies to both ebook and audiobook detail rails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scanner): harden ebook scanning against data loss and bad metadata

- Reconcile missing ebook files like video/audio, with real per-root walk
  failure tracking (failed/unmounted roots are excluded from deletion),
  symlinked-root support via the shared logical walker, and the empty-root
  cleanup allowance before any destructive reconciliation.
- Create ebook items as 'pending' so enrichment can promote them to
  'matched' (backfill migration included), and protect matched items from
  re-scan clobbering: title/year skipped, people/series fill-empty only.
- PDF metadata: scan head + tail windows (non-linearized PDFs keep the Info
  dict at the end), require proper key delimiters, head values win.
- Cap plain .fb2 reads like .fbz entries; drop .md as an ebook format.
- gofmt internal/scanner/audiobook.go (pre-existing drift).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): make enrichment failures non-terminal with dedicated backoff state

- Provider errors now record a failure (capped retries) instead of stamping
  last_refreshed, which permanently excluded items after transient outages.
- Unconfigured metadata chains and the scan-window membership race skip the
  item without stamping or burning a retry.
- Failure tracking moves to a new ebook_enrichment_state table, decoupling
  it from media_items.refresh_failures (shared with metadata refresh debt).
- Preserve non-author people credits when persisting enrichment results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): gate ebook progress on hidden history and centralize threshold

- Apply user_history_hidden_items gating (video semantics) to the ebook
  watched/in-progress filters, progress sort plan, and Continue Reading.
- Continue Reading pages past dismissed items via the shared collector and
  dedupes items across pages (also fixes the video path's latent exposure).
- Centralize the 0.9 finished threshold as models.EbookFinishedProgressThreshold
  with a single SQL-interpolated mirror in catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(recommendations): correct watcher counting and wire ebook taste signals

- itemWatchersQuery dedupes to distinct (watcher, item) rows so one
  binge-watcher can no longer satisfy minWatchers; the eligibility floor
  now counts distinct accounts rather than profiles.
- Hidden-history gating on GetEbookReaderProgressForUser (signal reader).
- Ebook reading produces canonical implicit taste signals (weighted like
  the equivalent movie progress ratio); ebooks join taste-seed candidates.
- Stale GetRecentlyAddedItems doc comment corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): harden ebook reader endpoints and serve a Content-Security-Policy

- Serve a CSP on all SPA HTML responses: blob/srcdoc book iframes inherit
  it, so script-src 'self' 'wasm-unsafe-eval' blocks script execution from
  malicious book content (sandbox alone is defeated by the WebKit
  allow-scripts requirement). Threat model documented on the constant.
- X-Content-Type-Options: nosniff on frontend, jellycompat, and ebook file
  responses; MIME resolution can no longer fall through to octet-stream
  for an admitted ebook file.
- Annotation PATCH: presence-aware field semantics (absent keeps, present
  sets/clears), invariant re-validation on the merged row, and an atomic
  SELECT ... FOR UPDATE read-merge-write.
- Request size caps (413) on progress/config/annotation writes;
  Content-Disposition via mime.FormatMediaType; hidden-history gating in
  the shared ebook progress lister; FK-cascade indexes for reader tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(api): native read-state endpoints for ebooks

- POST/DELETE /watched/{id} accepts ebook content IDs: mark read upserts
  progress 1.0 preserving the reader's file/location (or picks the
  preferred reader file for never-opened books); mark unread mirrors video
  unwatch semantics and deletes the progress row.
- /history/remove accepts ebooks: hides via user_history_hidden_items
  without touching the reading position (hidden != unread; next reading
  activity resurfaces the book, mirroring video re-watch).
- Access-filter checks match the video branch; shared logic lives in
  ebook_read_state.go. Sort metrics/user-state thresholds use the shared
  constant; profile-header fallback deduplicated.

Clients: response is {type: "ebook", affected_count: 1, played: bool};
the existing watched SSE event fires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): harden the ebook reader UI

- Open-flow race: cancellation checked after every await with full stale-run
  teardown (no wrong-file progress saves, no leaked views/blob URLs);
  book.destroy() on cleanup.
- Progress: monotonic stale-response guard; visibilitychange flush uses the
  refresh-capable client, pagehide uses keepalive; per-book cross-format
  progress documented as deliberate.
- Settings: side effects out of the setState updater; local edits no longer
  clobbered by late server config; pending saves flushed on unmount/pagehide.
- TTS: generation token so Stop actually stops (Chromium/Firefox synthetic
  events); Media Session uninstalled on unmount.
- External book links: http(s) only, opened with noopener,noreferrer.
- apiBlob 512 MiB guard with a user-facing error; fraction bookmarks
  navigable; search-result key collisions fixed; dead e-ink code removed;
  getLibrarySortRelevanceScope deduplicated; md format dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): mark read/unread affordances for ebooks

- Item detail gets a Mark Read/Unread button; card menus drop the ebook
  gate and share type-aware labels/toasts (also dedupes audiobook wording).
- Watched-state invalidation includes the reader progress query key so the
  Continue button and percent refresh after toggling.
- Continue Reading dismiss copy for ebooks; dismissal path now URL-encodes
  item IDs (ebook content IDs can contain reserved characters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record the PR #124 review and hardening pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:18:35 -04:00

2333 lines
99 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
// Transforms ALL children of the container so multi-view layouts
// animate as a unified whole. Extra elements (e.g. background) are
// also transformed so they slide in sync with the content.
const animateScroll = (element, scrollProp, startValue, endValue, duration, extraElements = []) => new Promise(resolve => {
if (document.hidden) {
element[scrollProp] = endValue
return resolve()
}
const children = [...element.children]
if (!children.length) {
element[scrollProp] = endValue
return resolve()
}
const allElements = [...children, ...extraElements]
const isHorizontal = scrollProp === 'scrollLeft'
const delta = endValue - startValue
const transformProp = isHorizontal ? 'translateX' : 'translateY'
// Prepare all elements for animation
for (const el of allElements) {
el.style.willChange = 'transform'
el.style.transform = `${transformProp}(0px)`
el.style.transition = 'none'
}
// Force reflow to apply initial state
element.getBoundingClientRect()
// Start animation on all elements
for (const el of allElements) {
el.style.transition = `transform ${duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)`
el.style.transform = `${transformProp}(${-delta}px)`
}
let resolved = false
const cleanup = () => {
if (resolved) return
resolved = true
for (const el of allElements) {
el.style.willChange = ''
el.style.transform = ''
el.style.transition = ''
}
// Apply final scroll position
element[scrollProp] = endValue
resolve()
}
// Listen for transition end on the first child
const first = children[0]
const onTransitionEnd = (e) => {
if (e.target === first && e.propertyName === 'transform') {
first.removeEventListener('transitionend', onTransitionEnd)
cleanup()
}
}
first.addEventListener('transitionend', onTransitionEnd)
// Fallback timeout in case transitionend doesn't fire
setTimeout(cleanup, duration + 50)
})
// 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())
if (left === 0 && right === 0) return FILTER_REJECT
// 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 (left === 0 && right === 0) return FILTER_REJECT
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 => {
const range = document.createRange()
range.setStart(sel.anchorNode, sel.anchorOffset)
range.setEnd(sel.focusNode, sel.focusOffset)
return range.collapsed
}
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)
}
}
}
export const getDirection = doc => {
const { defaultView } = doc
let { writingMode, direction } = defaultView.getComputedStyle(doc.body)
// Some EPUBs set writing-mode on the first child of body instead of body itself
if (!writingMode || writingMode === 'horizontal-tb') {
const firstChild = doc.body.querySelector(':scope > :not([cfi-inert])')
if (firstChild) {
const childStyle = defaultView.getComputedStyle(firstChild)
if (childStyle.writingMode === 'vertical-rl'
|| childStyle.writingMode === 'vertical-lr') {
writingMode = childStyle.writingMode
}
}
}
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 setStyles = (el, styles) => {
const { style } = el
for (const [k, v] of Object.entries(styles)) style.setProperty(k, v)
}
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
#columnCount = 1
#layout = {}
#contentPages = 0
#bgImageSize = null
fontReady = Promise.resolve()
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: 'flex-start',
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
}
get contentPages() {
return this.#contentPages
}
async load(src, data, 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)
this.#iframe.setAttribute('aria-label', doc.title)
// it needs to be visible for Firefox to get computed style
this.#iframe.style.display = 'block'
const { vertical, rtl } = getDirection(doc)
this.docBackground = getBackground(doc)
doc.body.style.background = 'none'
// Preload background image to get natural dimensions;
// in scrolled mode the view expands to fit the image.
const bgUrl = this.docBackground
?.match(/url\(["']?([^"')]+)["']?\)/)?.[1]
if (bgUrl && !this.container.noBackground) {
const img = new Image()
img.onload = () => {
this.#bgImageSize = {
width: img.naturalWidth,
height: img.naturalHeight,
}
if (!this.#column) this.expand()
}
img.src = bgUrl
}
this.#iframe.style.display = 'none'
this.#vertical = vertical
this.#rtl = rtl
this.#contentRange.selectNodeContents(doc.body)
const layout = beforeRender?.({ vertical, rtl })
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
this.fontReady = doc.fonts.ready.then(() => this.expand())
resolve()
}, { once: true })
if (data) {
this.#iframe.srcdoc = data
} else {
this.#iframe.src = src
}
})
}
render(layout) {
if (!layout || !this.document?.documentElement) return
this.#column = layout.flow !== 'scrolled'
this.#layout = layout
if (this.#column) this.columnize(layout)
else this.scrolled(layout)
}
scrolled({ width, height, marginTop, marginRight, marginBottom, marginLeft, gap, columnWidth }) {
const vertical = this.#vertical
const doc = this.document
setStylesImportant(doc.documentElement, {
'box-sizing': 'border-box',
'column-width': 'auto',
'height': 'auto',
'width': 'auto',
})
const availableWidth = Math.trunc(width - marginLeft - marginRight)
const availableHeight = Math.trunc(height - marginTop - marginBottom)
const sidePaddingLeft = marginLeft / 2 + gap / 2
const sidePaddingRight = marginRight / 2 + gap / 2
setStyles(doc.documentElement, {
'padding': vertical
? `${marginTop * 1.5}px 0px ${marginBottom * 1.5}px 0px`
: `0px ${sidePaddingRight}px 0px ${sidePaddingLeft}px`,
'--page-margin-top': `${vertical ? marginTop * 1.5 : marginTop}px`,
'--page-margin-right': `${vertical ? marginRight : sidePaddingRight}px`,
'--page-margin-bottom': `${vertical ? marginBottom * 1.5 : marginBottom}px`,
'--page-margin-left': `${vertical ? marginLeft : sidePaddingLeft}px`,
'--full-width': `${Math.trunc(width)}`,
'--full-height': `${Math.trunc(height)}`,
'--available-width': `${availableWidth}`,
'--available-height': `${availableHeight}`,
})
setStylesImportant(doc.body, {
[vertical ? 'max-height' : 'max-width']: `${columnWidth}px`,
'margin': 'auto',
// Prevent position:absolute/fixed on body from coupling its
// size to the iframe, which causes diverging expand() loops
'position': 'static',
})
this.setImageSize(availableWidth, availableHeight)
this.expand()
}
columnize({ width, height, marginTop, marginRight, marginBottom, marginLeft, gap, columnWidth, columnCount }) {
const vertical = this.#vertical
this.#size = vertical ? height : width
this.#columnCount = columnCount || 1
const doc = this.document
const horizontalColumnGap = columnCount > 1 ? (marginLeft + marginRight) / 4 + gap / 2 : (marginLeft + marginRight) / 2 + gap
const sidePaddingLeft = columnCount > 1 ? marginLeft / 4 + gap / 4 : marginLeft / 2 + gap / 2
const sidePaddingRight = columnCount > 1 ? marginRight / 4 + gap / 4 : marginRight / 2 + gap / 2
setStylesImportant(doc.documentElement, {
'box-sizing': 'border-box',
'column-width': `${Math.trunc(columnWidth)}px`,
'column-gap': vertical ? `${(marginTop + marginBottom) * 1.5}px` : `${horizontalColumnGap}px`,
'column-fill': 'auto',
...(vertical
? { 'width': `${width}px` }
: { 'height': `${height}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',
})
const availableWidth = vertical
? Math.trunc(width - marginLeft / 2 - marginRight / 2 - gap)
: Math.trunc(width / this.#columnCount)
const availableHeight = vertical
? Math.trunc(height / this.#columnCount)
: Math.trunc(height - marginTop - marginBottom)
setStyles(doc.documentElement, {
'padding': vertical
? `${marginTop * 1.5}px ${marginRight}px ${marginBottom * 1.5}px ${marginLeft}px`
: `${marginTop}px ${sidePaddingRight}px ${marginBottom}px ${sidePaddingLeft}px`,
'--page-margin-top': `${vertical ? marginTop * 1.5 : marginTop}px`,
'--page-margin-right': `${vertical ? marginRight : sidePaddingRight}px`,
'--page-margin-bottom': `${vertical ? marginBottom * 1.5 : marginBottom}px`,
'--page-margin-left': `${vertical ? marginLeft : sidePaddingLeft}px`,
'--full-width': `${Math.trunc(availableWidth)}`,
'--full-height': `${Math.trunc(availableHeight)}`,
'--available-width': `${availableWidth}`,
'--available-height': `${availableHeight}`,
})
setStylesImportant(doc.body, {
'max-height': 'none',
'max-width': 'none',
'margin': '0',
// Prevent position:absolute/fixed on body from coupling its
// size to the iframe, which causes diverging expand() loops
'position': 'static',
})
this.setImageSize(availableWidth, availableHeight)
this.expand()
}
setImageSize(availableWidth, availableHeight) {
const { width, height, marginTop, marginRight, marginBottom, marginLeft } = this.#layout
const vertical = this.#vertical
const doc = this.document
const pageFullscreen = doc.documentElement.hasAttribute('data-duokan-page-fullscreen')
for (const el of doc.body.querySelectorAll('img, svg, video')) {
// clear previous inline constraints so we read CSS-authored values,
// not stale pixel values from a previous resize (#3634)
el.style.removeProperty('max-width')
el.style.removeProperty('max-height')
// preserve max size if they are already set in CSS
let { maxHeight, maxWidth } = doc.defaultView.getComputedStyle(el)
if (parseInt(maxWidth) > availableWidth) {
maxWidth = `${availableWidth}px`
}
if (parseInt(maxHeight) > availableHeight) {
maxHeight = `${availableHeight}px`
}
setStylesImportant(el, {
'max-height': vertical
? (maxHeight !== 'none' && maxHeight !== '0px' ? maxHeight : '100%')
: `${height - (pageFullscreen ? 0 : (marginTop + marginBottom))}px`,
'max-width': vertical
? `${width - (pageFullscreen ? 0 : (marginLeft + marginRight))}px`
: (maxWidth !== 'none' && maxWidth !== '0px' ? maxWidth : '100%'),
'object-fit': pageFullscreen ? 'cover': 'contain',
'page-break-inside': 'avoid',
'break-inside': 'avoid',
'box-sizing': 'border-box',
})
if (pageFullscreen) {
setStylesImportant(doc.documentElement, {
position: 'relative',
})
setStylesImportant(el, {
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
margin: '0',
})
let ancestor = el.parentElement
while (ancestor && ancestor !== doc.body) {
setStylesImportant(ancestor, {
width: '100%',
height: '100%',
margin: '0',
padding: '0',
})
ancestor = ancestor.parentElement
}
if (el.localName === 'svg') {
el.setAttribute('preserveAspectRatio', 'xMidYMid slice')
}
}
}
}
get #zoom() {
// Safari does not zoom the client rects, while Chrome, Edge and Firefox does
if (/^((?!chrome|android).)*AppleWebKit/i.test(navigator.userAgent) && !window.chrome) {
return window.getComputedStyle(this.document.body).zoom || 1.0
}
return 1.0
}
expand() {
if (!this.document?.documentElement) return
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]) * this.#zoom
// Size content by individual columns, not full spreads.
// This allows adjacent sections to share a spread when a
// section doesn't fill all available columns.
const columnSize = this.#size / this.#columnCount
const pageCount = Math.ceil(contentSize / columnSize)
this.#contentPages = pageCount
const expandedSize = pageCount * columnSize
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%'
// One column per "page" — overflow columns extend into adjacent pages
documentElement.style[side] = `${columnSize}px`
if (this.#overlayer) {
this.#overlayer.element.style.margin = '0'
this.#overlayer.element.style.left = '0'
this.#overlayer.element.style.top = '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]
let expandedSize = contentSize
// If the section has a background image, ensure the view is
// at least as large as the image scaled to fit the cross axis
if (this.#bgImageSize) {
const crossSize = this.#element.getBoundingClientRect()[otherSide]
if (crossSize > 0) {
const { width: imgW, height: imgH } = this.#bgImageSize
const scaledSize = this.#vertical
? imgW * crossSize / imgH
: imgH * crossSize / imgW
expandedSize = Math.max(expandedSize, scaledSize)
}
}
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.#overlayer) {
this.#overlayer.element.style.margin = '0'
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
}
#loupeEl = null
#loupeScaler = null
#loupeCursor = null
// Show a magnifier loupe inside the iframe document.
// winX/winY are in main-window (screen) coordinates.
showLoupe(winX, winY, { isVertical, color, gap, margin, radius, magnification }) {
const doc = this.document
if (!doc) return
const frameRect = this.#iframe.getBoundingClientRect()
// Cursor in iframe-viewport coordinates.
const vpX = winX - frameRect.left
const vpY = winY - frameRect.top
// Cursor in document coordinates (accounts for scroll).
const scrollX = doc.scrollingElement?.scrollLeft ?? 0
const scrollY = doc.scrollingElement?.scrollTop ?? 0
const docX = vpX + scrollX
const docY = vpY + scrollY
const MAGNIFICATION = magnification
const MARGIN = margin
// Capsule dimensions: elongated along the reading direction.
// For horizontal text the capsule is wider; for vertical it is taller.
const shortSide = radius * 2
const longSide = Math.round(radius * 3.6)
const loupeW = isVertical ? shortSide : longSide
const loupeH = isVertical ? longSide : shortSide
const halfW = loupeW / 2
const halfH = loupeH / 2
const borderRadius = shortSide / 2 // fully rounded ends
// Position loupe above the cursor (or to the left for vertical text).
const GAP = gap
let loupeLeft = isVertical ? vpX - loupeW - GAP : vpX - halfW
let loupeTop = isVertical ? vpY - halfH : vpY - loupeH - GAP
loupeLeft = Math.max(MARGIN, Math.min(loupeLeft, frameRect.width - loupeW - MARGIN))
loupeTop = Math.max(MARGIN, Math.min(loupeTop, frameRect.height - loupeH - MARGIN))
// CSS-transform math: map document point (docX, docY) to loupe centre.
// visual_pos = offset + coord × MAGNIFICATION = halfW (or halfH)
// ⟹ offset = half − coord × MAGNIFICATION
const offsetX = halfW - docX * MAGNIFICATION
const offsetY = halfH - docY * MAGNIFICATION
// Build loupe DOM structure once; cache it across hide/show cycles so
// the expensive body clone is not repeated on every drag start.
if (!this.#loupeEl || !this.#loupeEl.isConnected) {
this.#loupeEl = doc.createElement('div')
// Clone the live body once — inside the iframe the epub's CSS
// variables, @font-face fonts, and styles apply automatically.
const bodyClone = doc.body.cloneNode(true)
// Wrap the clone in a div that replicates documentElement's inline
// styles (column-width, column-gap, padding, height, etc.) so text
// flows with the same column layout as the original document.
const htmlWrapper = doc.createElement('div')
htmlWrapper.style.cssText = doc.documentElement.style.cssText
// expand() constrains documentElement's page-axis dimension to one
// page size (width for horizontal, height for vertical). Override
// with the full scroll dimension so all columns are rendered.
if (this.#vertical)
htmlWrapper.style.height = `${doc.documentElement.scrollHeight}px`
else
htmlWrapper.style.width = `${doc.documentElement.scrollWidth}px`
htmlWrapper.appendChild(bodyClone)
this.#loupeScaler = doc.createElement('div')
this.#loupeScaler.appendChild(htmlWrapper)
const cursorLen = Math.round(shortSide * 0.44)
this.#loupeCursor = doc.createElement('div')
this.#loupeCursor.style.cssText = isVertical
? `position:absolute;left:calc(50% - ${cursorLen / 2}px);top:50%;`
+ `margin-top:-1px;width:${cursorLen}px;height:2px;background:${color};pointer-events:none;z-index:1;box-sizing:border-box;`
: `position:absolute;left:50%;top:calc(50% - ${cursorLen / 2}px);`
+ `margin-left:-1px;width:2px;height:${cursorLen}px;background:${color};pointer-events:none;z-index:1;box-sizing:border-box;`
this.#loupeEl.appendChild(this.#loupeScaler)
this.#loupeEl.appendChild(this.#loupeCursor)
doc.documentElement.appendChild(this.#loupeEl)
// Static loupe shell styles (set once).
this.#loupeEl.style.cssText = `
position: absolute;
width: ${loupeW}px;
height: ${loupeH}px;
border-radius: ${borderRadius}px;
overflow: hidden;
border: 2.5px solid ${color};
box-shadow: 0 6px 24px rgba(0,0,0,0.28);
background-color: var(--theme-bg-color);
z-index: 9999;
pointer-events: none;
user-select: none;
box-sizing: border-box;
contain: strict;
`
// Static scaler styles (set once; only left/top change per move).
this.#loupeScaler.style.cssText = `
position: absolute;
transform: scale(${MAGNIFICATION});
transform-origin: 0 0;
pointer-events: none;
`
}
// Ensure visible (hideLoupe hides via CSS instead of removing).
this.#loupeEl.style.display = ''
// Update only the dynamic position values (fast path on every move).
this.#loupeScaler.style.left = `${offsetX}px`
this.#loupeScaler.style.top = `${offsetY}px`
this.#loupeScaler.style.width = `${doc.documentElement.scrollWidth}px`
this.#loupeScaler.style.height = `${doc.documentElement.scrollHeight}px`
this.#loupeEl.style.left = `${loupeLeft + scrollX}px`
this.#loupeEl.style.top = `${loupeTop + scrollY}px`
// Cut a capsule-shaped hole in the overlayer so highlights don't paint
// over the loupe.
if (this.#overlayer) {
const overlayerRect = this.#overlayer.element.getBoundingClientRect()
const dx = frameRect.left - overlayerRect.left
const dy = frameRect.top - overlayerRect.top
const pad = 3
const cx = loupeLeft + halfW + dx
const cy = loupeTop + halfH + dy
this.#overlayer.setHole(cx, cy, loupeW + pad * 2, loupeH + pad * 2, borderRadius + pad)
}
}
hideLoupe() {
// Hide via CSS instead of removing — keeps the cached body clone so
// the next showLoupe call skips the expensive cloneNode(true).
if (this.#loupeEl) {
this.#loupeEl.style.display = 'none'
}
if (this.#overlayer)
this.#overlayer.clearHole()
}
destroyLoupe() {
if (this.#loupeEl) {
this.#loupeEl.remove()
this.#loupeEl = null
this.#loupeScaler = null
this.#loupeCursor = null
}
if (this.#overlayer)
this.#overlayer.clearHole()
}
destroy() {
if (this.document?.body) this.#observer.unobserve(this.document.body)
this.destroyLoupe()
}
}
// NOTE: everything here assumes the so-called "negative scroll type" for RTL
export class Paginator extends HTMLElement {
static observedAttributes = [
'flow', 'gap', 'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
'max-inline-size', 'max-block-size', 'max-column-count',
'no-preload', 'no-background', 'no-continuous-scroll',
]
#root = this.attachShadow({ mode: 'open' })
#observer = new ResizeObserver(() => this.render())
#top
#background
#container
#header
#footer
#views = new Map() // Map<sectionIndex, View>
#primaryIndex = -1
#vertical = false
#rtl = false
#marginTop = 0
#marginBottom = 0
#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
#lastVisibleRange
#scrollLocked = false
#isAnimating = false
#filling = false // true while #fillVisibleArea is running
#fillPromise = null // tracks in-progress #fillVisibleArea for awaiting
#stabilizing = false // true while #display is stabilizing layout
#rendered = false // true after first #display completes
#lastLayout = null // cached layout from the last #beforeRender call
// Cache of section index → vertical (boolean). Populated as views
// are loaded so we can check direction *before* loading a section.
#directionCache = new Map()
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-top: 48px;
--_margin-right: 48px;
--_margin-bottom: 48px;
--_margin-left: 48px;
--_max-inline-size: 720px;
--_max-block-size: 1440px;
--_max-column-count: 2;
--_max-column-count-portrait: var(--_max-column-count);
--_max-column-count-spread: var(--_max-column-count);
--_half-gap: calc(var(--_gap) / 2);
--_half-margin-left: calc(var(--_margin-left) / 2);
--_half-margin-right: calc(var(--_margin-right) / 2);
--_max-width: calc(var(--_max-inline-size) * var(--_max-column-count-spread));
--_max-height: var(--_max-block-size);
--_column-count: 1;
--_outer-min-left: calc((var(--_column-count) - 1) * (var(--_margin-left) / 4 + var(--_gap) / 4));
--_outer-min-right: calc((var(--_column-count) - 1) * (var(--_margin-right) / 4 + var(--_gap) / 4));
display: grid;
grid-template-columns:
minmax(var(--_outer-min-left), 1fr)
var(--_margin-left)
minmax(0, calc(var(--_max-width) - var(--_gap)))
var(--_margin-right)
minmax(var(--_outer-min-right), 1fr);
grid-template-rows:
minmax(var(--_margin-top), 1fr)
minmax(0, var(--_max-height))
minmax(var(--_margin-bottom), 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: 1 / -1;
overflow: hidden;
display: flex;
flex-direction: row;
/* GPU acceleration hints for smoother scrolling on high refresh rate displays */
transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
perspective: 1000px;
-webkit-perspective: 1000px;
transition: opacity 50ms ease-in;
}
#container.vertical {
flex-direction: column;
}
#container > * {
/* Ensure child elements are GPU-accelerated for smooth transform animations */
transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
:host([flow="scrolled"]) #container {
grid-column: 2 / 5;
grid-row: 1 / -1;
overflow: auto;
overflow-anchor: auto;
flex-direction: column;
}
:host([flow="scrolled"]) #container.vertical {
flex-direction: row;
}
#header {
grid-column: 3 / 4;
grid-row: 1;
}
#footer {
grid-column: 3 / 4;
grid-row: 3;
align-self: end;
}
#header {
display: grid;
height: var(--_margin-top);
}
#footer {
display: grid;
height: var(--_margin-bottom);
}
: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" part="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)
const debouncedScroll = debounce(() => {
if (this.scrolled && !this.#isAnimating) {
// Skip entirely while stabilizing — preserve #justAnchored
// so the first post-stabilization fire still sees it.
if (this.#stabilizing) return
if (this.#justAnchored) this.#justAnchored = false
else this.#afterScroll('scroll')
// Load previous section when user scrolls near the top.
// Done in debounced handler (not instant) to avoid cascade
// from rapid DOM insertions breaking scroll anchoring.
if (!this.noPreload && !this.noContinuousScroll && !this.#filling && this.#renderedStart < this.size) {
const sorted = this.#sortedViews
const firstIndex = sorted[0]?.[0]
if (firstIndex != null) {
const prevIdx = this.#adjacentIndex(-1, firstIndex)
if (prevIdx != null && !this.#views.has(prevIdx) && this.#isSameDirection(prevIdx)) {
this.#filling = true
this.#loadAdjacentSection(prevIdx)
.finally(() => {
this.#filling = false
this.dispatchEvent(new Event('stabilized'))
})
}
}
}
} else if (!this.scrolled) {
this.#afterScroll('container-scroll')
}
}, 250)
this.#container.addEventListener('scroll', () => {
if (!this.#isAnimating) this.dispatchEvent(new Event('scroll'))
// Preload forward when fewer than minPages ahead
if (!this.noPreload && !this.noContinuousScroll && !this.#filling && !this.#stabilizing) {
const minPages = 5
const pagesAhead = this.size > 0
? Math.floor((this.#renderedViewSize - this.#renderedEnd) / this.size)
: 0
if (pagesAhead < minPages) {
const sorted = this.#sortedViews
const lastIndex = sorted[sorted.length - 1]?.[0]
if (lastIndex != null) {
const nextIdx = this.#adjacentIndex(1, lastIndex)
if (nextIdx != null && !this.#views.has(nextIdx) && this.#isSameDirection(nextIdx)) {
this.#filling = true
this.#loadAdjacentSection(nextIdx)
.finally(() => {
this.#filling = false
this.dispatchEvent(new Event('stabilized'))
})
}
}
}
}
debouncedScroll()
})
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', () => isPointerSelecting = true)
doc.addEventListener('pointerup', () => 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
// FIXME: this won't work on Android WebView, disable for now
if (!isPointerSelecting && 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 => {
if (this.scrolled) return null
if (this.#container && this.#container.contains(e.target)) {
// NOTE: `requestAnimationFrame` is needed in WebKit
requestAnimationFrame(() => this.#scrollToAnchor(e.target))
}
})
})
this.#mediaQueryListener = () => {
const view = this.#primaryView
if (!view) return
this.#replaceBackground()
}
this.#mediaQuery.addEventListener('change', this.#mediaQueryListener)
}
get #primaryView() {
return this.#views.get(this.#primaryIndex)
}
get #sortedViews() {
return [...this.#views.entries()].sort(([a], [b]) => a - b)
}
get primaryIndex() {
return this.#primaryIndex
}
setAttribute(name, value) {
// The scrolled-mode scroll handler is debounced, so #anchor and
// #primaryIndex can lag behind the user's actual viewport by up to
// ~250ms. Toggling out of scrolled mode within that window made
// render() restore the stale anchor — reverting the position to a
// previously visible section. Flush the pending scroll state here,
// before the attribute change so the layout is still in scrolled
// mode and `this.scrolled` (which reads the attribute) is still true.
if (name === 'flow'
&& this.scrolled
&& String(value) !== 'scrolled'
&& this.#views.size > 0) {
this.#flushScrolledState()
}
super.setAttribute(name, value)
}
#flushScrolledState() {
if (this.#views.size > 1) this.#detectPrimaryView()
const result = this.#getVisibleRange()
if (result?.range && !result.range.collapsed) this.#anchor = result.range
}
attributeChangedCallback(name, _, value) {
switch (name) {
case 'flow':
this.render()
break
case 'gap':
case 'margin-top':
case 'margin-bottom':
case 'margin-left':
case 'margin-right':
case 'max-block-size':
case 'max-column-count':
this.#top.style.setProperty('--_' + name, value)
this.render()
break
case 'max-inline-size':
// needs explicit `render()` as it doesn't necessarily resize
this.#top.style.setProperty('--_' + name, value)
this.render()
break
case 'no-continuous-scroll':
if (this.noContinuousScroll) {
for (const [i] of this.#views) {
if (i !== this.#primaryIndex) this.#destroyView(i)
}
}
break
}
}
open(book) {
this.bookDir = book.dir
this.sections = book.sections
book.transformTarget?.addEventListener('data', ({ detail }) => {
if (detail.type !== 'text/css') return
detail.data = Promise.resolve(detail.data).then(data => data
// unprefix as most of the props are (only) supported unprefixed
.replace(/([{\s;])-epub-/gi, '$1')
// `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(index) {
// Destroy existing view for this index if any
const existing = this.#views.get(index)
if (existing) {
existing.destroy()
this.#container.removeChild(existing.element)
this.#views.delete(index)
}
const view = new View({
container: this,
onExpand: () => {
// Only the primary view's resize should adjust scroll;
// non-primary views (preloaded/adjacent) must not scroll
if (this.#filling || this.#stabilizing || this.scrolled) return
if (this.#primaryIndex === index)
this.#scrollToAnchor(this.#anchor)
},
})
this.#views.set(index, view)
const sorted = this.#sortedViews
const myPos = sorted.findIndex(([i]) => i === index)
const nextEntry = sorted[myPos + 1]
if (nextEntry) this.#container.insertBefore(view.element, nextEntry[1].element)
else this.#container.append(view.element)
this.#syncA11y()
return view
}
// Hide non-primary views from accessibility tree so screen-reader
// swipe-next does not wander into pre-loaded adjacent sections
// (which would land several pages into the next section instead
// of its first paragraph). Uses `inert` (blocks focus/keyboard
// traversal) and `aria-hidden` (removes from accessibility tree).
#syncA11y() {
for (const [index, view] of this.#views) {
if (index === this.#primaryIndex) {
view.element.removeAttribute('inert')
view.element.removeAttribute('aria-hidden')
} else {
view.element.setAttribute('inert', '')
view.element.setAttribute('aria-hidden', 'true')
}
}
}
#destroyView(index) {
const view = this.#views.get(index)
if (!view) return
view.destroy()
this.#container.removeChild(view.element)
this.#views.delete(index)
this.sections[index]?.unload?.()
}
#destroyAllViews() {
for (const [index] of this.#views) this.#destroyView(index)
}
#clearViewsExcept(keepIndices) {
for (const [index] of this.#views) {
if (!keepIndices.has(index)) this.#destroyView(index)
}
}
// Check if a section has the same writing direction as current primary.
// Returns true if same or unknown (not yet cached).
#isSameDirection(index) {
if (!this.#directionCache.has(index)) return true
return this.#directionCache.get(index) === this.#vertical
}
// Update the #background grid so each column shows the correct section's
// background. Pass atPosition to pre-compute for a destination scroll
// position (e.g. before an animation starts).
#replaceBackground(atPosition) {
const doc = this.#primaryView?.document
if (!doc?.documentElement) return
if (this.noBackground) return
const htmlStyle = doc.defaultView.getComputedStyle(doc.documentElement)
const themeBgColor = htmlStyle.getPropertyValue('--theme-bg-color')
const overrideColor = htmlStyle.getPropertyValue('--override-color') === 'true'
const bgTextureId = htmlStyle.getPropertyValue('--bg-texture-id')
const isDarkMode = htmlStyle.getPropertyValue('color-scheme') === 'dark'
const fallbackBg = themeBgColor || ''
const resolveBackground = (background) => {
if (!background) return fallbackBg
if (themeBgColor) {
const parsed = background.split(/\s(?=(?:url|rgb|hsl|#[0-9a-fA-F]{3,6}))/)
if ((isDarkMode || overrideColor) && (bgTextureId === 'none' || !bgTextureId)) {
parsed[0] = themeBgColor
}
return parsed.join(' ')
}
return background
}
// Reset any inline backgrounds left over from a previous mode so the
// host's texture isn't occluded after toggling.
this.#background.style.background = ''
for (const [, view] of this.#sortedViews) {
view.element.style.background = ''
}
if (this.scrolled) {
// In scrolled mode, set background directly on each view element
// so it scrolls with the content. The static #background provides
// the fallback color for margins and gaps between views.
const hasTexture = !!bgTextureId && bgTextureId !== 'none'
this.#background.innerHTML = ''
this.#background.style.display = ''
this.#background.style.background = hasTexture ? '' : fallbackBg
for (const [, view] of this.#sortedViews) {
const resolved = resolveBackground(view.docBackground)
const isTransparent = !resolved
|| /^\s*(transparent|rgba\(0,\s*0,\s*0,\s*0\))/.test(resolved)
view.element.style.background = hasTexture && isTransparent ? '' : resolved
}
return
}
const cc = this.columnCount
const columnSize = this.size / cc
const sorted = this.#sortedViews
this.#background.innerHTML = ''
this.#background.style.display = 'grid'
this.#background.style.gridTemplateColumns = `repeat(${cc}, 1fr)`
const scrollPos = atPosition ?? this.#renderedStart
for (let i = 0; i < cc; i++) {
const columnMid = Math.abs(scrollPos) + (i + 0.5) * columnSize
let bg = fallbackBg
// Find which view's content area contains this column
let offset = 0
for (const [, view] of sorted) {
const viewSize = view.element.getBoundingClientRect()[this.sideProp]
if (columnMid < offset + viewSize) {
const contentStart = offset
const contentEnd = contentStart + view.contentPages * columnSize
if (columnMid >= contentStart && columnMid < contentEnd) {
bg = resolveBackground(view.docBackground)
}
break
}
offset += viewSize
}
const col = document.createElement('div')
col.style.background = bg
col.style.backgroundAttachment = 'initial'
col.style.width = '100%'
col.style.height = '100%'
this.#background.appendChild(col)
}
}
#beforeRender({ vertical, rtl }) {
// If writing-mode is about to change, destroy all non-primary
// views BEFORE updating global state. This prevents stale views
// with the wrong direction from remaining in the container while
// flex-direction / scrollProp / sideProp flip.
if (this.#rendered && vertical !== this.#vertical) {
for (const [i] of this.#views) {
if (i !== this.#primaryIndex) this.#destroyView(i)
}
}
this.#vertical = vertical
this.#rtl = rtl
this.#top.classList.toggle('vertical', vertical)
this.#container.classList.toggle('vertical', vertical)
const style = getComputedStyle(this.#top)
const maxInlineSize = parseFloat(style.getPropertyValue('--_max-inline-size'))
const maxColumnCount = parseInt(style.getPropertyValue('--_max-column-count-spread'))
const marginTop = parseFloat(style.getPropertyValue('--_margin-top'))
const marginRight = parseFloat(style.getPropertyValue('--_margin-right'))
const marginBottom = parseFloat(style.getPropertyValue('--_margin-bottom'))
const marginLeft = parseFloat(style.getPropertyValue('--_margin-left'))
this.#marginTop = marginTop
this.#marginBottom = marginBottom
// Compute the column count from the host (Paginator) size rather than
// the #container size. The container width depends on --_column-count
// via the grid template (the outer 1fr tracks have a non-zero min for
// multi-column spreads), so deriving the column count from container
// size at threshold widths creates a feedback loop where the layout
// oscillates between 1 and 2 columns on resize.
const flow = this.getAttribute('flow')
const hostRect = this.getBoundingClientRect()
const hostSize = vertical ? hostRect.height : hostRect.width
const divisor = flow === 'scrolled'
? 1
: Math.min(
maxColumnCount + (vertical ? 1 : 0),
Math.ceil(Math.floor(hostSize) / Math.floor(maxInlineSize)),
)
// Set --_column-count BEFORE measuring the container so the read
// below reflects the grid template that will actually be used.
this.#top.style.setProperty('--_column-count', divisor)
const { width, height } = this.#container.getBoundingClientRect()
const size = vertical ? height : width
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
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()
this.columnCount = 1
this.#replaceBackground()
const layout = { width, height, flow, marginTop, marginRight, marginBottom, marginLeft, gap, columnWidth, columnCount: 1 }
this.#lastLayout = layout
return layout
}
const columnWidth = vertical
? (size / divisor - marginTop * 1.5 - marginBottom * 1.5)
: (size / divisor - gap - marginRight / 2 - marginLeft / 2)
this.setAttribute('dir', rtl ? 'rtl' : 'ltr')
// set background to `doc` background
// this is needed because the iframe does not fill the whole element
this.columnCount = divisor
this.#replaceBackground()
const marginalDivisor = vertical
? Math.min(2, Math.ceil(Math.floor(width) / Math.floor(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)
const layout = { width, height, marginTop, marginRight, marginBottom, marginLeft, gap, columnWidth, columnCount: divisor }
this.#lastLayout = layout
return layout
}
render() {
if (this.#views.size === 0) return
const primaryView = this.#primaryView
if (!primaryView) return
this.#stabilizing = true
const layout = this.#beforeRender({
vertical: this.#vertical,
rtl: this.#rtl,
})
for (const [, view] of this.#views) {
if (view.document) view.render(layout)
}
// Scroll synchronously to prevent visible layout shift during resize.
// RAF deferral is only needed for initial display and mode switches
// (handled by #display), not for resize re-renders.
this.#scrollToAnchor(this.#anchor)
this.#stabilizing = false
this.dispatchEvent(new Event('stabilized'))
}
get scrolled() {
return this.getAttribute('flow') === 'scrolled'
}
get noPreload() {
return this.hasAttribute('no-preload')
}
get noBackground() {
return this.hasAttribute('no-background')
}
get noContinuousScroll() {
return this.scrolled && this.hasAttribute('no-continuous-scroll')
}
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() {
const primaryView = this.#primaryView
if (!primaryView) return 0
return primaryView.element.getBoundingClientRect()[this.sideProp]
}
get start() {
return this.#renderedStart - this.#getViewOffset(this.#primaryIndex)
}
get end() {
return this.#renderedEnd - this.#getViewOffset(this.#primaryIndex)
}
get page() {
return Math.floor(((this.start + this.end) / 2) / this.size)
}
get pages() {
const primaryView = this.#primaryView
if (!primaryView) return 0
const viewSize = primaryView.element.getBoundingClientRect()[this.sideProp]
return Math.round(viewSize / this.size)
}
get containerPosition() {
return this.#container[this.scrollProp]
}
get isOverflowX() {
return false
}
get isOverflowY() {
return false
}
get #renderedViewSize() {
if (this.#views.size === 0) return 0
let total = 0
for (const [, view] of this.#views)
total += view.element.getBoundingClientRect()[this.sideProp]
return total
}
get #renderedStart() {
return Math.abs(this.#container[this.scrollProp])
}
get #renderedEnd() {
return this.#renderedStart + this.size
}
get #renderedPage() {
return Math.floor(((this.#renderedStart + this.#renderedEnd) / 2) / this.size)
}
get #renderedPages() {
return Math.round(this.#renderedViewSize / this.size)
}
set containerPosition(newVal) {
this.#container[this.scrollProp] = newVal
}
set scrollLocked(value) {
this.#scrollLocked = value
}
scrollBy(dx, dy) {
const delta = this.#vertical ? dy : dx
const [offset, a, b] = this.#scrollBounds
const rtl = this.#rtl
const min = rtl ? offset - b : offset - a
const max = rtl ? offset + a : offset + b
this.containerPosition = Math.max(min, Math.min(max,
this.containerPosition + delta))
}
// vx, vy: velocity at the end of the swipe (pixels per ms)
// dx, dy: total distance swiped
// dt: total time of the swipe (ms)
snap(vx, vy, dx, dy, dt) {
const velocity = this.#vertical ? vy : vx
const avgVelocity = this.#vertical ? dy / dt : dx / dt
const horizontal = Math.abs(vx) * 2 > Math.abs(vy)
const orthogonal = this.#vertical ? !horizontal : horizontal
const [offset, a, b] = this.#scrollBounds
const size = this.size
const start = this.#renderedStart
const end = this.#renderedEnd
const pages = this.#renderedPages
const min = Math.abs(offset) - a
const max = Math.abs(offset) + b
const snapping = this.hasAttribute('animated') && !this.hasAttribute('eink')
const v = snapping ? velocity : avgVelocity
const d = v * (this.#rtl ? -size : size) * (orthogonal ? 1 : 0)
const snapOffset = (isNaN(d) ? 0 : snapping ? d * 2 : d * 10)
const page = Math.floor(Math.max(min, Math.min(max, (start + end) / 2 + snapOffset)) / size)
const dir = page < 0 ? -1 : page >= pages ? 1 : null
const doGoTo = () => {
if (!dir) return
const sorted = this.#sortedViews
const edgeIndex = dir < 0
? sorted[0]?.[0] ?? this.#primaryIndex
: sorted[sorted.length - 1]?.[0] ?? this.#primaryIndex
return this.#goTo({
index: this.#adjacentIndex(dir, edgeIndex),
anchor: dir < 0 ? () => 1 : () => 0,
})
}
// Out of range — skip animation, go straight to adjacent section
if (dir) return doGoTo()
this.#scrollToPage(page, 'snap')
}
#onTouchStart(e) {
const touch = e.changedTouches[0]
this.#touchState = {
x: touch?.screenX, y: touch?.screenY,
t: e.timeStamp,
vx: 0, xy: 0,
dx: 0, dy: 0,
dt: 0,
}
// Hint to browser that scrolling will occur for better GPU layer management
const pv = this.#primaryView
if (pv?.element) {
pv.element.style.willChange = 'transform'
}
}
#onTouchMove(e) {
const state = this.#touchState
if (state.pinched) return
state.pinched = globalThis.visualViewport.scale > 1
if (this.scrolled || state.pinched) return
if (e.touches.length > 1) {
if (this.#touchScrolled) e.preventDefault()
return
}
const doc = this.#primaryView?.document
const selection = doc?.getSelection()
if (selection && selection.rangeCount > 0 && !selection.isCollapsed) {
return
}
const touch = e.changedTouches[0]
const isStylus = touch.touchType === 'stylus'
if (!isStylus) e.preventDefault()
if (this.#scrollLocked) return
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
state.dx += dx
state.dy += dy
state.dt += dt
this.#touchScrolled = true
if (!this.hasAttribute('animated') || this.hasAttribute('eink')) return
if (!this.#vertical && Math.abs(state.dx) >= Math.abs(state.dy) && !this.hasAttribute('eink') && (!isStylus || Math.abs(dx) > 1)) {
this.scrollBy(dx, 0)
} else if (this.#vertical && Math.abs(state.dx) < Math.abs(state.dy) && !this.hasAttribute('eink') && (!isStylus || Math.abs(dy) > 1)) {
this.scrollBy(0, dy)
}
}
#onTouchEnd() {
// Remove will-change hint to free GPU resources
// if (this.#view?.element) {
// this.#view.element.style.willChange = 'auto'
// }
if (!this.#touchScrolled) return
this.#touchScrolled = false
if (this.scrolled) 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) {
const { vx, vy, dx, dy, dt } = this.#touchState
this.snap(vx, vy, dx, dy, dt)
}
})
}
// allows one to process rects as if they were LTR and horizontal
#getRectMapper(view) {
if (this.scrolled) {
const size = view ? view.element.getBoundingClientRect()[this.sideProp] : this.#renderedViewSize
const marginTop = this.#marginTop
const marginBottom = this.#marginBottom
return this.#vertical
? ({ left, right }) =>
({ left: size - right - marginTop, right: size - left - marginBottom })
: ({ top, bottom }) => ({ left: top - marginTop, right: bottom - marginBottom })
}
const pxSize = this.#renderedPages * 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) {
// rect is in iframe-local coordinates; add view offset
// to convert to container scroll coordinates
const localOffset = this.#getRectMapper()(rect).left - 3
const viewOffset = this.#getViewOffset(this.#primaryIndex)
return this.#scrollTo(viewOffset + localOffset, reason)
}
// rect is in iframe-local coordinates. Convert to container
// coordinates by adding the primary view's offset.
const localOffset = this.#getRectMapper()(rect).left
const viewOffset = this.#getViewOffset(this.#primaryIndex)
const containerOffset = viewOffset + localOffset
return this.#scrollToPage(Math.floor(containerOffset / this.size + 0.01), reason)
}
async #scrollTo(offset, reason, smooth) {
const { size } = this
if (this.containerPosition === 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') && !this.hasAttribute('eink')) {
const startPosition = this.containerPosition
// Pre-set background for the destination so it's already
// correct when the slide animation reveals the new page
if (!this.scrolled) this.#replaceBackground(offset)
// Use GPU-accelerated scroll animation for smoother experience on high refresh rate screens
this.#isAnimating = true
return animateScroll(
this.#container,
this.scrollProp,
startPosition,
offset,
300,
).then(() => {
this.#isAnimating = false
this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size]
this.#afterScroll(reason)
})
} else {
this.containerPosition = 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, smooth) {
return this.#scrollToAnchor(anchor, select ? 'selection' : 'navigation', smooth)
}
async #scrollToAnchor(anchor, reason = 'anchor', smooth = false) {
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 && r.x >= 0 && r.y >= 0) || rects[0]
if (!rect) return
await this.#scrollToRect(rect, reason)
// focus the element when navigating with keyboard or screen reader
if (reason === 'navigation') {
let node = anchor.focus ? anchor : undefined
if (!node && anchor.startContainer) {
node = anchor.startContainer
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentElement
}
}
if (node && node.focus) {
node.tabIndex = -1
node.style.outline = 'none'
node.focus({ preventScroll: true })
}
}
return
}
// if anchor is a fraction
if (this.scrolled) {
// In scrolled mode with multi-view, offset to the primary view's position
const primaryOffset = this.#getViewOffset(this.#primaryIndex)
const primaryView = this.#primaryView
const primarySize = primaryView
? primaryView.element.getBoundingClientRect()[this.sideProp] : this.#renderedViewSize
await this.#scrollTo(primaryOffset + anchor * primarySize, reason, smooth)
return
}
// In paginated mode, account for pages before the primary section
const primaryView = this.#primaryView
if (!primaryView) return
const pagesBeforePrimary = this.#getPagesBeforeView(this.#primaryIndex)
const textPages = primaryView.contentPages
if (!textPages) return
// textPages is in column units; convert to spread page for scrolling
const newColumn = Math.round(anchor * (textPages - 1))
const newSpreadPage = Math.floor(newColumn / this.columnCount)
await this.#scrollToPage(pagesBeforePrimary + newSpreadPage, reason, smooth)
}
// Get the pixel offset of a view within the container
#getViewOffset(index) {
let offset = 0
for (const [i, view] of this.#sortedViews) {
if (i === index) return offset
offset += view.element.getBoundingClientRect()[this.sideProp]
}
return offset
}
// Get number of full pages (spreads) before a given view.
// Uses floor so the view's first column is always on or after
// the returned page — never rounded past it. The 0.01 tolerance
// absorbs sub-pixel drift on fractional-DPR devices where
// getBoundingClientRect() accumulates ~0.0001px errors.
#getPagesBeforeView(index) {
return Math.floor(this.#getViewOffset(index) / this.size + 0.01)
}
#getVisibleRange() {
const targetView = this.#primaryView
if (!targetView?.document) return
const viewOffset = this.#getViewOffset(this.#primaryIndex)
if (this.scrolled) {
// In scrolled mode, the primary view may be scrolled out of
// the viewport at a section boundary. Try all visible views
// and return the first valid (non-collapsed) range.
for (const [index, v] of this.#sortedViews) {
if (!v.document) continue
const off = this.#getViewOffset(index)
const vSize = v.element.getBoundingClientRect()[this.sideProp]
// Skip views entirely outside the viewport
if (off + vSize <= this.#renderedStart || off >= this.#renderedEnd) continue
const range = getVisibleRange(v.document,
this.#renderedStart - off, this.#renderedEnd - off,
this.#getRectMapper(v))
if (range && !range.collapsed) return { range, index }
}
return
}
const range = getVisibleRange(targetView.document,
this.#renderedStart - viewOffset,
this.#renderedEnd - viewOffset,
this.#getRectMapper(targetView))
return range ? { range, index: this.#primaryIndex } : undefined
}
// Determine which view is primary based on scroll position
#detectPrimaryView() {
if (this.#views.size <= 1) return
const visibleStart = this.#renderedStart
let offset = 0
for (const [index, view] of this.#sortedViews) {
const viewSize = view.element.getBoundingClientRect()[this.sideProp]
if (visibleStart < offset + viewSize - 1) {
if (index !== this.#primaryIndex) {
this.#primaryIndex = index
this.#syncA11y()
this.#trimDistantViews()
this.#replaceBackground()
this.#fillPromise = this.#preloadNext()
}
return
}
offset += viewSize
}
}
// Pre-load adjacent sections from the current primary so the
// next/prev sections are ready when the user paginates.
// Does NOT re-scroll to avoid fighting with the user's current
// scroll position.
async #preloadNext() {
if (this.noPreload || this.noContinuousScroll) return
this.#filling = true
try {
const { size } = this
const minPages = 5
const maxSections = 8
// Load forward sections until we have enough pages ahead
let iterations = 0
while (this.#views.size < maxSections && iterations < maxSections) {
iterations++
const pagesAhead = size > 0
? Math.floor((this.#renderedViewSize - this.#renderedEnd) / size)
: 0
if (pagesAhead >= minPages) break
const sorted = this.#sortedViews
const lastIndex = sorted[sorted.length - 1]?.[0]
if (lastIndex == null) break
const nextIdx = this.#adjacentIndex(1, lastIndex)
if (nextIdx == null) break
// Stop preloading at writing-mode boundaries
if (!this.#isSameDirection(nextIdx)) break
await this.#loadAdjacentSection(nextIdx)
if (!this.#views.has(nextIdx)) break
}
// Wait a frame so ResizeObserver callbacks fire while
// #filling is still true, preventing onExpand from
// re-scrolling to a stale anchor position.
await new Promise(r => requestAnimationFrame(r))
} finally {
this.#filling = false
this.dispatchEvent(new Event('stabilized'))
}
}
#afterScroll(reason) {
// In multi-view, detect which section is primary
if (this.#views.size > 1 && reason !== 'anchor' && reason !== 'navigation') {
this.#detectPrimaryView()
}
const { range, index: visibleIndex } = this.#getVisibleRange() || {}
if (!range) return
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 = visibleIndex ?? this.#primaryIndex
const primaryView = this.#primaryView
const detail = { reason, range, index }
if (this.scrolled) {
const primaryOffset = this.#getViewOffset(index)
const primarySize = primaryView
? primaryView.element.getBoundingClientRect()[this.sideProp] : this.#renderedViewSize
detail.fraction = primarySize > 0
? Math.max(0, Math.min(1, (this.#renderedStart - primaryOffset) / primarySize)) : 0
} else if (this.#renderedPages > 0 && primaryView) {
const page = this.#renderedPage
const pagesBeforePrimary = this.#getPagesBeforeView(index)
const textPages = primaryView.contentPages
this.#header.style.visibility = page > 0 ? 'visible' : 'hidden'
// page is in spread units, textPages is in column units
const localPage = page - pagesBeforePrimary
const localColumn = localPage * this.columnCount
detail.fraction = textPages > 0 ? Math.max(0, Math.min(1, localColumn / textPages)) : 0
detail.size = textPages > 0 ? this.columnCount / textPages : 1
if (reason === 'container-scroll' && localPage === 0) return
}
// Update per-column backgrounds for the current scroll position
if (!this.scrolled) this.#replaceBackground()
this.dispatchEvent(new CustomEvent('relocate', { detail }))
}
async #display(promise) {
this.#stabilizing = true
this.#container.style.opacity = '0'
const { index, src, data, anchor, onLoad, select } = await promise
this.#primaryIndex = index
this.#syncA11y()
const hasFocus = this.#primaryView?.document?.hasFocus()
if (src) {
const view = this.#createView(index)
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.sections[index].spineProperties?.forEach(
prop => doc.documentElement.setAttribute('data-' + prop, ''))
this.#styleMap.set(doc, [$styleBefore, $style])
}
onLoad?.({ doc, index })
}
const beforeRender = this.#beforeRender.bind(this)
await view.load(src, data, afterLoad, beforeRender)
// Cache direction for future preload boundary checks
if (view.document) {
const dir = getDirection(view.document)
this.#directionCache.set(index, dir.vertical)
}
this.dispatchEvent(new CustomEvent('create-overlayer', {
detail: {
doc: view.document, index,
attach: overlayer => view.overlayer = overlayer,
},
}))
}
// Pre-load previous section when needed:
// - Short primary alignment (section shorter than one spread)
// - Scrolled mode with anchor in top half — so the user can
// scroll backward into the previous section immediately
const primaryView = this.#primaryView
if (!this.noPreload && !this.noContinuousScroll && primaryView) {
const needsPrev = (primaryView.contentPages > 0 && primaryView.contentPages < this.columnCount)
if (needsPrev || this.scrolled) {
const sorted = this.#sortedViews
const firstIndex = sorted[0]?.[0]
if (firstIndex != null) {
const prevIdx = this.#adjacentIndex(-1, firstIndex)
if (prevIdx != null && this.#isSameDirection(prevIdx)) {
await this.#loadAdjacentSection(prevIdx)
}
}
}
}
const resolvedAnchor = (typeof anchor === 'function'
? anchor(primaryView.document) : anchor) ?? 0
await this.scrollToAnchor(resolvedAnchor, select)
if (hasFocus) this.focusView()
// Reveal content now that primary section is positioned
this.#container.style.opacity = '1'
this.#rendered = true
// Emit stabilized so listeners can react, but keep #stabilizing
// true until fill completes to prevent the debounced scroll
// handler from loading backward sections during rapid DOM changes.
this.dispatchEvent(new Event('stabilized'))
// Load remaining adjacent sections progressively (non-blocking).
// In scrolled mode, skip reanchor — browser scroll anchoring
// preserves position when content is added above/below.
this.#fillPromise = this.#fillVisibleArea(
{ reanchor: !this.scrolled })
this.#fillPromise.then(() => { this.#stabilizing = false })
}
// Load an adjacent section without changing primary index
async #loadAdjacentSection(index) {
if (this.#views.has(index) || !this.#canGoToIndex(index)) return
const section = this.sections[index]
if (!section || section.linear === 'no') return
try {
const src = await section.load()
const data = await section.loadContent?.()
const view = this.#createView(index)
const afterLoad = doc => {
if (doc.head) {
const $styleBefore = doc.createElement('style')
doc.head.prepend($styleBefore)
const $style = doc.createElement('style')
doc.head.append($style)
section.spineProperties?.forEach(
prop => doc.documentElement.setAttribute('data-' + prop, ''))
this.#styleMap.set(doc, [$styleBefore, $style])
}
this.setStyles(this.#styles)
this.dispatchEvent(new CustomEvent('load', { detail: { doc, index } }))
}
// Adjacent sections reuse the primary view's cached layout
// — they must NOT call #beforeRender, which would modify
// global state (direction, CSS classes, dir attribute, etc.).
const cachedLayout = this.#lastLayout
const beforeRender = () => cachedLayout
await view.load(src, data, afterLoad, beforeRender)
// Cache direction for future preload boundary checks
if (view.document) {
const dir = getDirection(view.document)
this.#directionCache.set(index, dir.vertical)
// Destroy views with a different writing-mode immediately.
// Mixed-direction views corrupt scroll/page calculations.
if (dir.vertical !== this.#vertical) {
this.#destroyView(index)
return
}
}
this.dispatchEvent(new CustomEvent('create-overlayer', {
detail: {
doc: view.document, index,
attach: overlayer => view.overlayer = overlayer,
},
}))
} catch (e) {
console.warn(e)
console.warn(new Error(`Failed to load adjacent section ${index}`))
}
}
// Fill adjacent sections until at least `minPages` pages exist
// beyond the current viewport in each direction (forward always,
// backward only when the primary section is short).
// When reanchor is false (background pre-loading), skip re-scrolling
// to avoid fighting with the user's current scroll position.
async #fillVisibleArea({ reanchor = true } = {}) {
if (this.noPreload || this.noContinuousScroll || this.#filling) return
this.#filling = true
try {
const { size } = this
if (!size) return
const minPages = 5
const maxSections = 8
// If the primary section is shorter than one spread and
// there's no section already loaded before it, load the
// previous section to fill the leading columns
const primaryView = this.#primaryView
if (primaryView && primaryView.contentPages > 0
&& primaryView.contentPages < this.columnCount) {
const sorted = this.#sortedViews
const firstIndex = sorted[0]?.[0]
if (firstIndex != null && firstIndex >= this.#primaryIndex) {
const prevIdx = this.#adjacentIndex(-1, firstIndex)
if (prevIdx != null && this.#isSameDirection(prevIdx)) {
await this.#loadAdjacentSection(prevIdx)
}
}
}
// Load forward sections until we have enough pages ahead
let iterations = 0
while (this.#views.size < maxSections && iterations < maxSections) {
iterations++
const pagesAhead = Math.floor(
(this.#renderedViewSize - this.#renderedEnd) / size)
if (pagesAhead >= minPages) break
const sorted = this.#sortedViews
const lastIndex = sorted[sorted.length - 1]?.[0]
if (lastIndex == null) break
const nextIdx = this.#adjacentIndex(1, lastIndex)
if (nextIdx == null) break
// Stop at writing-mode boundaries
if (!this.#isSameDirection(nextIdx)) break
await this.#loadAdjacentSection(nextIdx)
if (!this.#views.has(nextIdx)) break
}
if (reanchor) this.#scrollToAnchor(this.#anchor)
} finally {
this.#filling = false
// Emit stabilized so post-layout processing (e.g. warichu)
// runs for newly loaded adjacent sections.
this.dispatchEvent(new Event('stabilized'))
}
}
// Trim views whose content is entirely more than 10 pages away
// from the current viewport. Only removes views AFTER the primary
// — removing views before would shift scroll position.
#trimDistantViews() {
const { size } = this
if (!size) return
const maxDistance = size * 10
const viewportEnd = this.#renderedEnd
for (const [index, view] of this.#sortedViews) {
if (index <= this.#primaryIndex) continue
const offset = this.#getViewOffset(index)
if (offset - viewportEnd > maxDistance) {
this.#destroyView(index)
}
}
}
#canGoToIndex(index) {
return index >= 0 && index <= this.sections.length - 1
}
async #goTo({ index, anchor, select }) {
// Check if the target section has a different writing-mode.
// If direction changes, we must destroy all views and do a full
// rebuild via #display — mixed-direction views cannot coexist.
let directionChanged = false
if (this.#views.has(index)) {
const view = this.#views.get(index)
if (view?.document) {
const { vertical } = getDirection(view.document)
directionChanged = vertical !== this.#vertical
}
} else if (this.#directionCache.has(index)) {
directionChanged = this.#directionCache.get(index) !== this.#vertical
}
// When direction is unknown (not cached), #beforeRender will
// detect and clean up stale views if a change actually occurs.
if (this.#views.has(index) && !directionChanged) {
// View already loaded — reuse it without
// clearing/reloading. Just change primary and scroll.
this.#stabilizing = true
this.#container.style.opacity = '0'
const hasFocus = this.#primaryView?.document?.hasFocus()
this.#primaryIndex = index
this.#syncA11y()
this.#trimDistantViews()
// Handle short section alignment
const primaryView = this.#primaryView
if (!this.noPreload && !this.noContinuousScroll && primaryView
&& primaryView.contentPages > 0
&& primaryView.contentPages < this.columnCount) {
const sorted = this.#sortedViews
const firstIndex = sorted[0]?.[0]
if (firstIndex != null) {
const prevIdx = this.#adjacentIndex(-1, firstIndex)
if (prevIdx != null) {
await this.#loadAdjacentSection(prevIdx)
}
}
}
// In noContinuousScroll mode, destroy all non-primary views
if (this.noContinuousScroll) {
for (const [i] of this.#views) {
if (i !== index) this.#destroyView(i)
}
}
await this.scrollToAnchor((typeof anchor === 'function'
? anchor(primaryView.document) : anchor) ?? 0, select)
this.#container.style.opacity = '1'
if (hasFocus) this.focusView()
// Load remaining adjacent sections progressively;
// keep #stabilizing true until fill completes
this.#fillPromise = this.#fillVisibleArea()
this.#fillPromise.then(() => { this.#stabilizing = false })
} else {
// When direction changes, clear ALL views — no reuse possible
// across writing-mode boundaries. When direction is unknown
// (not yet cached), keep nearby views; #beforeRender will
// clean up if the loaded section turns out to differ.
if (directionChanged) {
this.#destroyAllViews()
} else {
const keep = new Set([index])
if (!this.noContinuousScroll) {
for (const [i] of this.#views) {
if (Math.abs(i - index) <= 2) keep.add(i)
}
}
this.#clearViewsExcept(keep)
}
const oldIndex = this.#primaryIndex
const onLoad = detail => {
if (oldIndex >= 0 && !this.#views.has(oldIndex))
this.sections[oldIndex]?.unload?.()
this.setStyles(this.#styles)
this.dispatchEvent(new CustomEvent('load', { detail }))
}
await this.#display(Promise.resolve(this.sections[index].load())
.then(async src => {
const data = await this.sections[index].loadContent?.()
return { index, src, data, 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.#views.size === 0) return true
if (this.scrolled) {
if (this.#renderedStart > 0) return this.#scrollTo(
Math.max(0, this.#renderedStart - (distance ?? this.size)), null, true)
return !this.atStart
}
if (this.atStart) return
const page = this.#renderedPage - 1
// Out of range — skip animation, go straight to previous section
if (page < 0) return true
return this.#scrollToPage(page, 'page', true)
}
#scrollNext(distance) {
if (this.#views.size === 0) return true
if (this.scrolled) {
if (this.#renderedViewSize - this.#renderedEnd > 2) return this.#scrollTo(
Math.min(this.#renderedViewSize, distance ? this.#renderedStart + distance : this.#renderedEnd), null, true)
return !this.atEnd
}
if (this.atEnd) return
const page = this.#renderedPage + 1
const pages = this.#renderedPages
// Out of range — skip animation, go straight to next section
if (page >= pages) return true
return this.#scrollToPage(page, 'page', true)
}
get atStart() {
const sorted = this.#sortedViews
const firstIndex = sorted[0]?.[0] ?? this.#primaryIndex
if (this.scrolled) return this.#adjacentIndex(-1, firstIndex) == null && this.#renderedStart <= 0
return this.#adjacentIndex(-1, firstIndex) == null && this.#renderedPage <= 0
}
get atEnd() {
const sorted = this.#sortedViews
const lastIndex = sorted[sorted.length - 1]?.[0] ?? this.#primaryIndex
if (this.scrolled) return this.#adjacentIndex(1, lastIndex) == null && this.#renderedViewSize - this.#renderedEnd <= 2
return this.#adjacentIndex(1, lastIndex) == null && this.#renderedPage >= this.#renderedPages - 1
}
#adjacentIndex(dir, fromIndex) {
if (fromIndex === undefined) fromIndex = this.#primaryIndex
for (let index = fromIndex + 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) {
// Wait for any in-progress background pre-loading to complete —
// it may already be loading the section we need, so awaiting
// it lets #goTo reuse the view instead of loading from scratch
if (this.#fillPromise) await this.#fillPromise
const sorted = this.#sortedViews
const edgeIndex = prev
? sorted[0]?.[0] ?? this.#primaryIndex
: sorted[sorted.length - 1]?.[0] ?? this.#primaryIndex
await this.#goTo({
index: this.#adjacentIndex(dir, edgeIndex),
anchor: prev ? () => 1 : () => 0,
})
}
if (shouldGo || !this.hasAttribute('animated')) await wait(100)
this.#locked = false
}
async prev(distance) {
return await this.#turnPage(-1, distance)
}
async next(distance) {
return await this.#turnPage(1, distance)
}
async pan(dx, dy) {
if (this.#locked) return
this.#locked = true
this.scrollBy(dx, dy)
this.#locked = false
}
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() {
const contents = []
for (const [index, view] of this.#sortedViews) {
if (view.document) contents.push({
index,
overlayer: view.overlayer,
doc: view.document,
})
}
return contents
}
setStyles(styles) {
this.#styles = styles
for (const [, view] of this.#views) {
const $$styles = this.#styleMap.get(view.document)
if (!$$styles) continue
const [$beforeStyle, $style] = $$styles
if (Array.isArray(styles)) {
const [beforeStyle, style] = styles
$beforeStyle.textContent = beforeStyle
$style.textContent = style
} else $style.textContent = styles
// needed because the resize observer doesn't work in Firefox
view.document?.fonts?.ready?.then(() => view.expand())
}
// NOTE: needs `requestAnimationFrame` in Chromium
const primaryView = this.#primaryView
if (primaryView) {
requestAnimationFrame(() => this.#replaceBackground())
}
}
focusView() {
this.#primaryView?.document?.defaultView?.focus()
}
showLoupe(winX, winY, { isVertical, color, gap, margin, radius, magnification }) {
this.#primaryView?.showLoupe(winX, winY, { isVertical, color, gap, margin, radius, magnification })
}
hideLoupe() {
this.#primaryView?.hideLoupe()
}
destroyLoupe() {
this.#primaryView?.destroyLoupe()
}
destroy() {
this.#observer.unobserve(this)
this.#destroyAllViews()
this.#mediaQuery.removeEventListener('change', this.#mediaQueryListener)
}
}
customElements.define('foliate-paginator', Paginator)