Files
silo-server/web/vendor/foliate-js/view.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

681 lines
26 KiB
JavaScript

import * as CFI from './epubcfi.js'
import { TOCProgress, SectionProgress, PageProgress } from './progress.js'
import { Overlayer } from './overlayer.js'
import { textWalker } from './text-walker.js'
const SEARCH_PREFIX = 'foliate-search:'
const NOTE_PREFIX = 'foliate-note:'
const isZip = async file => {
const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer())
return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04
}
const isPDF = async file => {
const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer())
return arr[0] === 0x25
&& arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46
&& arr[4] === 0x2d
}
const isCBZ = ({ name, type }) =>
type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz')
const isFB2 = ({ name, type }) =>
type === 'application/x-fictionbook+xml' || name.endsWith('.fb2')
const isFBZ = ({ name, type }) =>
type === 'application/x-zip-compressed-fb2'
|| name.endsWith('.fb2.zip') || name.endsWith('.fbz')
const makeZipLoader = async file => {
const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } =
await import('./vendor/zip.js')
configure({ useWebWorkers: false })
const reader = new ZipReader(new BlobReader(file))
const entries = await reader.getEntries()
const map = new Map(entries.map(entry => [entry.filename, entry]))
const load = f => (name, ...args) =>
map.has(name) ? f(map.get(name), ...args) : null
const loadText = load(entry => entry.getData(new TextWriter()))
const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type)))
const getSize = name => map.get(name)?.uncompressedSize ?? 0
return { entries, loadText, loadBlob, getSize }
}
const getFileEntries = async entry => entry.isFile ? entry
: (await Promise.all(Array.from(
await new Promise((resolve, reject) => entry.createReader()
.readEntries(entries => resolve(entries), error => reject(error))),
getFileEntries))).flat()
const makeDirectoryLoader = async entry => {
const entries = await getFileEntries(entry)
const files = await Promise.all(
entries.map(entry => new Promise((resolve, reject) =>
entry.file(file => resolve([file, entry.fullPath]),
error => reject(error)))))
const map = new Map(files.map(([file, path]) =>
[path.replace(entry.fullPath + '/', ''), file]))
const decoder = new TextDecoder()
const decode = x => x ? decoder.decode(x) : null
const getBuffer = name => map.get(name)?.arrayBuffer() ?? null
const loadText = async name => decode(await getBuffer(name))
const loadBlob = name => map.get(name)
const getSize = name => map.get(name)?.size ?? 0
return { loadText, loadBlob, getSize }
}
export class ResponseError extends Error {}
export class NotFoundError extends Error {}
export class UnsupportedTypeError extends Error {}
const fetchFile = async url => {
const res = await fetch(url)
if (!res.ok) throw new ResponseError(
`${res.status} ${res.statusText}`, { cause: res })
return new File([await res.blob()], new URL(res.url).pathname)
}
export const makeBook = async file => {
if (typeof file === 'string') file = await fetchFile(file)
let book
if (file.isDirectory) {
const loader = await makeDirectoryLoader(file)
const { EPUB } = await import('./epub.js')
book = await new EPUB(loader).init()
}
else if (!file.size) throw new NotFoundError('File not found')
else if (await isZip(file)) {
const loader = await makeZipLoader(file)
if (isCBZ(file)) {
const { makeComicBook } = await import('./comic-book.js')
book = makeComicBook(loader, file)
}
else if (isFBZ(file)) {
const { makeFB2 } = await import('./fb2.js')
const { entries } = loader
const entry = entries.find(entry => entry.filename.endsWith('.fb2'))
const blob = await loader.loadBlob((entry ?? entries[0]).filename)
book = await makeFB2(blob)
}
else {
const { EPUB } = await import('./epub.js')
book = await new EPUB(loader).init()
}
}
else if (await isPDF(file)) {
const { makePDF } = await import('./pdf.js')
book = await makePDF(file)
}
else {
const { isMOBI, MOBI } = await import('./mobi.js')
if (await isMOBI(file)) {
const fflate = await import('./vendor/fflate.js')
book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file)
}
else if (isFB2(file)) {
const { makeFB2 } = await import('./fb2.js')
book = await makeFB2(file)
}
}
if (!book) throw new UnsupportedTypeError('File type not supported')
return book
}
class CursorAutohider {
#timeout
#el
#check
#state
constructor(el, check, state = {}) {
this.#el = el
this.#check = check
this.#state = state
if (this.#state.hidden) this.hide()
this.#el.addEventListener('mousemove', ({ screenX, screenY }) => {
// check if it actually moved
if (screenX === this.#state.x && screenY === this.#state.y) return
this.#state.x = screenX, this.#state.y = screenY
this.show()
if (this.#timeout) clearTimeout(this.#timeout)
if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000)
}, false)
}
cloneFor(el) {
return new CursorAutohider(el, this.#check, this.#state)
}
hide() {
this.#el.style.cursor = 'none'
this.#state.hidden = true
}
show() {
this.#el.style.removeProperty('cursor')
this.#state.hidden = false
}
}
class History extends EventTarget {
#arr = []
#index = -1
pushState(x) {
const last = this.#arr[this.#index]
if (last === x || last?.fraction && last.fraction === x.fraction) return
this.#arr[++this.#index] = x
this.#arr.length = this.#index + 1
this.dispatchEvent(new Event('index-change'))
}
replaceState(x) {
const index = this.#index
this.#arr[index] = x
}
back() {
const index = this.#index
if (index <= 0) return
const detail = { state: this.#arr[index - 1] }
this.#index = index - 1
this.dispatchEvent(new CustomEvent('popstate', { detail }))
this.dispatchEvent(new Event('index-change'))
}
forward() {
const index = this.#index
if (index >= this.#arr.length - 1) return
const detail = { state: this.#arr[index + 1] }
this.#index = index + 1
this.dispatchEvent(new CustomEvent('popstate', { detail }))
this.dispatchEvent(new Event('index-change'))
}
get canGoBack() {
return this.#index > 0
}
get canGoForward() {
return this.#index < this.#arr.length - 1
}
clear() {
this.#arr = []
this.#index = -1
}
}
const languageInfo = lang => {
if (!lang) return {}
try {
const canonical = Intl.getCanonicalLocales(lang)[0]
const locale = new Intl.Locale(canonical)
const isCJK = ['zh', 'ja', 'kr'].includes(locale.language)
const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction
return { canonical, locale, isCJK, direction }
} catch (e) {
console.warn(e)
return {}
}
}
export class View extends HTMLElement {
#root = this.attachShadow({ mode: 'open' })
#sectionProgress
#tocProgress
#pageProgress
#cfiProgress
#searchResults = new Map()
#cursorAutohider = new CursorAutohider(this, () =>
this.hasAttribute('autohide-cursor'))
isFixedLayout = false
lastLocation
history = new History()
constructor() {
super()
this.history.addEventListener('popstate', ({ detail }) => {
const resolved = this.resolveNavigation(detail.state)
this.renderer.goTo(resolved)
})
}
async open(book) {
if (typeof book === 'string'
|| typeof book.arrayBuffer === 'function'
|| book.isDirectory) book = await makeBook(book)
this.book = book
this.language = languageInfo(book.metadata?.language)
if (book.splitTOCHref && book.getTOCFragment) {
const ids = book.sections.map(s => s.id)
this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600)
const splitHref = book.splitTOCHref.bind(book)
const getFragment = book.getTOCFragment.bind(book)
this.#tocProgress = new TOCProgress()
await this.#tocProgress.init({
toc: book.toc ?? [], ids, splitHref, getFragment })
this.#pageProgress = new TOCProgress()
await this.#pageProgress.init({
toc: book.pageList ?? [], ids, splitHref, getFragment })
}
this.#cfiProgress = new PageProgress(book, this.resolveNavigation.bind(this))
this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated'
if (this.isFixedLayout) {
await import('./fixed-layout.js')
this.renderer = document.createElement('foliate-fxl')
} else {
await import('./paginator.js')
this.renderer = document.createElement('foliate-paginator')
}
this.renderer.setAttribute('exportparts', 'head,foot,filter,container')
this.renderer.addEventListener('load', e => this.#onLoad(e.detail))
this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail))
this.renderer.addEventListener('create-overlayer', e =>
e.detail.attach(this.#createOverlayer(e.detail)))
this.renderer.open(book)
this.#root.append(this.renderer)
if (book.sections.some(section => section.mediaOverlay)) {
const activeClass = book.media.activeClass
const playbackActiveClass = book.media.playbackActiveClass
this.mediaOverlay = book.getMediaOverlay()
let lastActive
this.mediaOverlay.addEventListener('highlight', e => {
const resolved = this.resolveNavigation(e.detail.text)
this.renderer.goTo(resolved)
.then(() => {
const { doc } = this.renderer.getContents()
.find(x => x.index = resolved.index)
const el = resolved.anchor(doc)
el.classList.add(activeClass)
if (playbackActiveClass) el.ownerDocument
.documentElement.classList.add(playbackActiveClass)
lastActive = new WeakRef(el)
})
})
this.mediaOverlay.addEventListener('unhighlight', () => {
const el = lastActive?.deref()
if (el) {
el.classList.remove(activeClass)
if (playbackActiveClass) el.ownerDocument
.documentElement.classList.remove(playbackActiveClass)
}
})
}
}
close() {
this.renderer?.destroy()
this.renderer?.remove()
this.#sectionProgress = null
this.#tocProgress = null
this.#pageProgress = null
this.#cfiProgress = null
this.#searchResults = new Map()
this.lastLocation = null
this.history.clear()
this.tts = null
this.mediaOverlay = null
}
goToTextStart() {
return this.goTo(this.book.landmarks
?.find(m => m.type.includes('bodymatter') || m.type.includes('text'))
?.href ?? this.book.sections.findIndex(s => s.linear !== 'no'))
}
async init({ lastLocation, showTextStart }) {
const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null
if (resolved) {
await this.renderer.goTo(resolved)
this.history.pushState(lastLocation)
}
else if (showTextStart) await this.goToTextStart()
else {
this.history.pushState(0)
await this.next()
}
}
#emit(name, detail, cancelable) {
return this.dispatchEvent(new CustomEvent(name, { detail, cancelable }))
}
#onRelocate({ reason, range, index, fraction, size }) {
const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {}
const tocItem = this.#tocProgress?.getProgress(index, range)
const pageItem = this.#pageProgress?.getProgress(index, range)
const cfi = this.getCFI(index, range)
this.lastLocation = { ...progress, tocItem, pageItem, cfi, range }
if (reason === 'snap' || reason === 'page' || reason === 'scroll')
this.history.replaceState(cfi)
this.#emit('relocate', this.lastLocation)
}
#onLoad({ doc, index }) {
// set language and dir if not already set
doc.documentElement.lang ||= this.language.canonical ?? ''
if (!this.language.isCJK)
doc.documentElement.dir ||= this.language.direction ?? ''
this.#handleLinks(doc, index)
this.#cursorAutohider.cloneFor(doc.documentElement)
this.#emit('load', { doc, index })
}
#handleLinks(doc, index) {
const { book } = this
const section = book.sections[index]
doc.addEventListener('click', e => {
const a = e.target.closest('a[href]')
if (!a) return
e.preventDefault()
const href_ = a.getAttribute('href')
const href = section?.resolveHref?.(href_) ?? href_
if (book?.isExternal?.(href))
Promise.resolve(this.#emit('external-link', { a, href }, true))
.then(x => x ? globalThis.open(href, '_blank') : null)
.catch(e => console.error(e))
else {
let internalHref = href
if (!book.resolveHref(href)) {
const hashIndex = href_.indexOf('#')
if (hashIndex >= 0) {
const hash = href_.slice(hashIndex)
internalHref = section?.resolveHref?.(hash) ?? href
}
}
Promise.resolve(this.#emit('link', { a, href: internalHref }, true))
.then(x => x ? this.goTo(internalHref) : null)
.catch(e => console.error(e))
}
})
}
async addAnnotation(annotation, remove) {
const { value } = annotation
if (value.startsWith(SEARCH_PREFIX)) {
const cfi = value.replace(SEARCH_PREFIX, '')
const { index, anchor } = await this.resolveNavigation(cfi)
const obj = this.#getOverlayer(index)
if (obj) {
const { overlayer, doc } = obj
if (remove) {
overlayer.remove(value)
return
}
const range = doc ? anchor(doc) : anchor
if (range) overlayer.add(value, range, Overlayer.outline)
}
return
} else if (value.startsWith(NOTE_PREFIX)) {
const cfi = value.replace(NOTE_PREFIX, '')
const { index, anchor } = await this.resolveNavigation(cfi)
const obj = this.#getOverlayer(index)
if (obj) {
const { overlayer, doc } = obj
if (remove) {
overlayer.remove(value)
return
}
const range = doc ? anchor(doc) : anchor
if (range) {
const draw = (func, opts) => overlayer.add(value, range, func, opts)
this.#emit('draw-annotation', { draw, annotation, doc, range })
}
}
return
}
const { index, anchor } = await this.resolveNavigation(value)
const obj = this.#getOverlayer(index)
if (obj) {
const { overlayer, doc } = obj
overlayer.remove(value)
if (!remove) {
const range = doc ? anchor(doc) : anchor
if (range) {
const draw = (func, opts) => overlayer.add(value, range, func, opts)
this.#emit('draw-annotation', { draw, annotation, doc, range })
}
}
}
const label = this.#tocProgress?.getProgress(index)?.label ?? ''
return { index, label }
}
deleteAnnotation(annotation) {
return this.addAnnotation(annotation, true)
}
#getOverlayer(index) {
return this.renderer.getContents()
.find(x => x.index === index && x.overlayer)
}
#createOverlayer({ doc, index }) {
const overlayer = new Overlayer(doc)
doc.addEventListener('click', e => {
const [value, range, rect] = overlayer.hitTest(e)
if (value && !value.startsWith(SEARCH_PREFIX)) {
this.#emit('show-annotation', { value, index, range, rect })
}
}, false)
let lastHitTestTime = 0
const THROTTLE_MS = 200
const isAndroid = /Android/i.test(navigator.userAgent)
doc.addEventListener('mousemove', (e) => {
if (isAndroid) return
const now = performance.now()
if (now - lastHitTestTime < THROTTLE_MS) return
lastHitTestTime = now
const [value] = overlayer.hitTest(e)
if (value && !value.startsWith(SEARCH_PREFIX)) {
doc.body.style.cursor = 'pointer'
} else {
doc.body.style.cursor = ''
}
})
const list = this.#searchResults.get(index)
if (list) for (const item of list) this.addAnnotation(item)
this.#emit('create-overlay', { index })
return overlayer
}
async showAnnotation(annotation) {
const { value } = annotation
const resolved = await this.goTo(value)
if (resolved) {
const { index, anchor } = resolved
const { doc } = this.#getOverlayer(index)
const range = anchor(doc)
this.#emit('show-annotation', { value, index, range })
}
}
getCFI(index, range) {
const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index)
if (!range) return baseCFI
return CFI.joinIndir(baseCFI, CFI.fromRange(range))
}
resolveCFI(cfi) {
if (this.book.resolveCFI)
return this.book.resolveCFI(cfi)
else {
const parts = CFI.parse(cfi)
const index = CFI.fake.toIndex((parts.parent ?? parts).shift())
const anchor = doc => CFI.toRange(doc, parts)
return { index, anchor }
}
}
resolveNavigation(target) {
try {
if (typeof target === 'number') return { index: target }
if (typeof target.fraction === 'number') {
const [index, anchor] = this.#sectionProgress.getSection(target.fraction)
return { index, anchor }
}
if (CFI.isCFI.test(target)) return this.resolveCFI(target)
return this.book.resolveHref(target)
} catch (e) {
console.error(e)
console.error(`Could not resolve target ${target}`)
}
}
async goTo(target) {
const resolved = this.resolveNavigation(target)
try {
await this.renderer.goTo(resolved)
this.history.pushState(target)
return resolved
} catch(e) {
console.error(e)
console.error(`Could not go to ${target}`)
}
}
async goToFraction(frac) {
const [index, anchor] = this.#sectionProgress.getSection(frac)
await this.renderer.goTo({ index, anchor })
this.history.pushState({ fraction: frac })
}
async select(target) {
try {
const obj = await this.resolveNavigation(target)
await this.renderer.goTo({ ...obj, select: true })
this.history.pushState(target)
} catch(e) {
console.error(e)
console.error(`Could not go to ${target}`)
}
}
deselect() {
for (const { doc } of this.renderer.getContents())
doc.defaultView.getSelection().removeAllRanges()
}
getSectionFractions() {
return (this.#sectionProgress?.sectionFractions ?? [])
.map(x => x + Number.EPSILON)
}
getProgressOf(index, range) {
const tocItem = this.#tocProgress?.getProgress(index, range)
const pageItem = this.#pageProgress?.getProgress(index, range)
return { tocItem, pageItem }
}
async getCFIProgress(cfi) {
const progress = await this.#cfiProgress?.getProgress(cfi)
if (!progress || progress.index === -1) return null
return this.#sectionProgress?.getProgress(progress.index, progress.fraction)
}
async getTOCItemOf(target) {
try {
const { index, anchor } = await this.resolveNavigation(target)
const doc = await this.book.sections[index].createDocument()
const frag = anchor(doc)
const isRange = frag instanceof Range
const range = isRange ? frag : doc.createRange()
if (!isRange) range.selectNodeContents(frag)
return this.#tocProgress?.getProgress(index, range)
} catch(e) {
console.error(e)
console.error(`Could not get ${target}`)
}
}
async prev(distance) {
await this.renderer.prev(distance)
}
async next(distance) {
await this.renderer.next(distance)
}
async pan(dx, dy) {
await this.renderer.pan(dx, dy)
}
isOverflowX() {
return this.renderer.isOverflowX
}
isOverflowY() {
return this.renderer.isOverflowY
}
goLeft() {
return this.book.dir === 'rtl' ? this.next() : this.prev()
}
goRight() {
return this.book.dir === 'rtl' ? this.prev() : this.next()
}
async * #searchSection(matcher, query, index) {
const doc = await this.book.sections[index].createDocument()
for (const { range, excerpt } of matcher(doc, query))
yield { cfi: this.getCFI(index, range), excerpt }
}
async * #searchBook(matcher, query) {
const { sections } = this.book
for (const [index, { createDocument }] of sections.entries()) {
if (!createDocument) continue
const doc = await createDocument()
const subitems = Array.from(matcher(doc, query), ({ range, excerpt }) =>
({ cfi: this.getCFI(index, range), excerpt }))
const progress = (index + 1) / sections.length
yield { progress }
if (subitems.length) yield { index, subitems }
}
}
async * search(opts) {
this.clearSearch()
const { searchMatcher } = await import('./search.js')
const { sections } = this.book
const { query, index, results } = opts
const matcher = searchMatcher(textWalker,
{ defaultLocale: this.language, ...opts })
const iter = results?.length
? (async function* () {
for (const result of results) {
if (result.subitems) {
const progress = (result.index + 1) / sections.length
yield { progress }
yield { index: result.index, subitems: result.subitems }
} else {
yield { cfi: result.cfi, excerpt: result.excerpt }
}
}
})()
: index != null
? this.#searchSection(matcher, query, index)
: this.#searchBook(matcher, query)
const list = []
this.#searchResults.set(index, list)
for await (const result of iter) {
if (result.subitems){
const list = result.subitems
.map(({ cfi }) => ({ value: SEARCH_PREFIX + cfi }))
this.#searchResults.set(result.index, list)
for (const item of list) this.addAnnotation(item)
yield {
index: result.index,
label: this.#tocProgress?.getProgress(result.index)?.label ?? '',
subitems: result.subitems,
}
}
else {
if (result.cfi) {
const item = { value: SEARCH_PREFIX + result.cfi }
list.push(item)
this.addAnnotation(item)
}
yield result
}
}
yield 'done'
}
clearSearch() {
for (const list of this.#searchResults.values())
for (const item of list) this.deleteAnnotation(item)
this.#searchResults.clear()
}
async initTTS(granularity = 'word', nodeFilter, highlighter) {
const contents = this.renderer.getContents()
const primaryIndex = this.renderer.primaryIndex
const primary = contents.find(x => x.index === primaryIndex) ?? contents[0]
const doc = primary?.doc
if (!doc) return
if (this.tts && this.tts.doc === doc) return
const { TTS } = await import('./tts.js')
this.tts = new TTS(doc, textWalker, nodeFilter, highlighter || (range =>
this.renderer.scrollToAnchor(range, true)), granularity)
}
startMediaOverlay() {
const contents = this.renderer.getContents()
const primaryIndex = this.renderer.primaryIndex
const primary = contents.find(x => x.index === primaryIndex) ?? contents[0]
const { index } = primary ?? {}
return this.mediaOverlay.start(index)
}
}
customElements.define('foliate-view', View)