mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
The fixed-layout renderer now accepts an object for the return value of `load()` (on the `book.sections` items). If it's an object, the `src` is the URL of the section, and `onZoom`, if present, will be called when the viewport is zoomed. With this you can scale the page yourself rather than relying on the renderer's own scaling with CSS transform. Also upgraded to PDF.js 4.6.82. And now internal links are broken... Don't know how to fix that yet.
299 lines
11 KiB
JavaScript
299 lines
11 KiB
JavaScript
const parseViewport = str => str
|
|
?.split(/[,;\s]/) // NOTE: technically, only the comma is valid
|
|
?.filter(x => x)
|
|
?.map(x => x.split('=').map(x => x.trim()))
|
|
|
|
const getViewport = (doc, viewport) => {
|
|
// use `viewBox` for SVG
|
|
if (doc.documentElement.localName === 'svg') {
|
|
const [, , width, height] = doc.documentElement
|
|
.getAttribute('viewBox')?.split(/\s/) ?? []
|
|
return { width, height }
|
|
}
|
|
|
|
// get `viewport` `meta` element
|
|
const meta = parseViewport(doc.querySelector('meta[name="viewport"]')
|
|
?.getAttribute('content'))
|
|
if (meta) return Object.fromEntries(meta)
|
|
|
|
// fallback to book's viewport
|
|
if (typeof viewport === 'string') return parseViewport(viewport)
|
|
if (viewport) return viewport
|
|
|
|
// if no viewport (possibly with image directly in spine), get image size
|
|
const img = doc.querySelector('img')
|
|
if (img) return { width: img.naturalWidth, height: img.naturalHeight }
|
|
|
|
// just show *something*, i guess...
|
|
console.warn(new Error('Missing viewport properties'))
|
|
return { width: 1000, height: 2000 }
|
|
}
|
|
|
|
export class FixedLayout extends HTMLElement {
|
|
#root = this.attachShadow({ mode: 'closed' })
|
|
#observer = new ResizeObserver(() => this.#render())
|
|
#spreads
|
|
#index = -1
|
|
defaultViewport
|
|
spread
|
|
#portrait = false
|
|
#left
|
|
#right
|
|
#center
|
|
#side
|
|
constructor() {
|
|
super()
|
|
|
|
const sheet = new CSSStyleSheet()
|
|
this.#root.adoptedStyleSheets = [sheet]
|
|
sheet.replaceSync(`:host {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}`)
|
|
|
|
this.#observer.observe(this)
|
|
}
|
|
async #createFrame({ index, src: srcOption }) {
|
|
const srcOptionIsString = typeof srcOption === 'string'
|
|
const src = srcOptionIsString ? srcOption : srcOption?.src
|
|
const onZoom = srcOptionIsString ? null : srcOption?.onZoom
|
|
const element = document.createElement('div')
|
|
const iframe = document.createElement('iframe')
|
|
element.append(iframe)
|
|
Object.assign(iframe.style, {
|
|
border: '0',
|
|
display: 'none',
|
|
overflow: 'hidden',
|
|
})
|
|
// `allow-scripts` is needed for events because of WebKit bug
|
|
// https://bugs.webkit.org/show_bug.cgi?id=218086
|
|
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts')
|
|
iframe.setAttribute('scrolling', 'no')
|
|
iframe.setAttribute('part', 'filter')
|
|
this.#root.append(element)
|
|
if (!src) return { blank: true, element, iframe }
|
|
return new Promise(resolve => {
|
|
iframe.addEventListener('load', () => {
|
|
const doc = iframe.contentDocument
|
|
this.dispatchEvent(new CustomEvent('load', { detail: { doc, index } }))
|
|
const { width, height } = getViewport(doc, this.defaultViewport)
|
|
resolve({
|
|
element, iframe,
|
|
width: parseFloat(width),
|
|
height: parseFloat(height),
|
|
onZoom,
|
|
})
|
|
}, { once: true })
|
|
iframe.src = src
|
|
})
|
|
}
|
|
#render(side = this.#side) {
|
|
if (!side) return
|
|
const left = this.#left ?? {}
|
|
const right = this.#center ?? this.#right
|
|
const target = side === 'left' ? left : right
|
|
const { width, height } = this.getBoundingClientRect()
|
|
const portrait = this.spread !== 'both' && this.spread !== 'portrait'
|
|
&& height > width
|
|
this.#portrait = portrait
|
|
const blankWidth = left.width ?? right.width
|
|
const blankHeight = left.height ?? right.height
|
|
|
|
const scale = portrait || this.#center
|
|
? Math.min(
|
|
width / (target.width ?? blankWidth),
|
|
height / (target.height ?? blankHeight))
|
|
: Math.min(
|
|
width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)),
|
|
height / Math.max(
|
|
left.height ?? blankHeight,
|
|
right.height ?? blankHeight))
|
|
|
|
const transform = frame => {
|
|
let { element, iframe, width, height, blank, onZoom } = frame
|
|
if (onZoom) onZoom({ doc: frame.iframe.contentDocument, scale })
|
|
const iframeScale = onZoom ? scale : 1
|
|
Object.assign(iframe.style, {
|
|
width: `${width * iframeScale}px`,
|
|
height: `${height * iframeScale}px`,
|
|
transform: onZoom ? 'none' : `scale(${scale})`,
|
|
transformOrigin: 'top left',
|
|
display: blank ? 'none' : 'block',
|
|
})
|
|
Object.assign(element.style, {
|
|
width: `${(width ?? blankWidth) * scale}px`,
|
|
height: `${(height ?? blankHeight) * scale}px`,
|
|
overflow: 'hidden',
|
|
display: 'block',
|
|
})
|
|
if (portrait && frame !== target) {
|
|
element.style.display = 'none'
|
|
}
|
|
}
|
|
if (this.#center) {
|
|
transform(this.#center)
|
|
} else {
|
|
transform(left)
|
|
transform(right)
|
|
}
|
|
}
|
|
async #showSpread({ left, right, center, side }) {
|
|
this.#root.replaceChildren()
|
|
this.#left = null
|
|
this.#right = null
|
|
this.#center = null
|
|
if (center) {
|
|
this.#center = await this.#createFrame(center)
|
|
this.#side = 'center'
|
|
this.#render()
|
|
} else {
|
|
this.#left = await this.#createFrame(left)
|
|
this.#right = await this.#createFrame(right)
|
|
this.#side = this.#left.blank ? 'right'
|
|
: this.#right.blank ? 'left' : side
|
|
this.#render()
|
|
}
|
|
}
|
|
#goLeft() {
|
|
if (this.#center || this.#left?.blank) return
|
|
if (this.#portrait && this.#left?.element?.style?.display === 'none') {
|
|
this.#right.element.style.display = 'none'
|
|
this.#left.element.style.display = 'block'
|
|
this.#side = 'left'
|
|
return true
|
|
}
|
|
}
|
|
#goRight() {
|
|
if (this.#center || this.#right?.blank) return
|
|
if (this.#portrait && this.#right?.element?.style?.display === 'none') {
|
|
this.#left.element.style.display = 'none'
|
|
this.#right.element.style.display = 'block'
|
|
this.#side = 'right'
|
|
return true
|
|
}
|
|
}
|
|
open(book) {
|
|
this.book = book
|
|
const { rendition } = book
|
|
this.spread = rendition?.spread
|
|
this.defaultViewport = rendition?.viewport
|
|
|
|
const rtl = book.dir === 'rtl'
|
|
const ltr = !rtl
|
|
this.rtl = rtl
|
|
|
|
if (rendition?.spread === 'none')
|
|
this.#spreads = book.sections.map(section => ({ center: section }))
|
|
else this.#spreads = book.sections.reduce((arr, section, i) => {
|
|
const last = arr[arr.length - 1]
|
|
const { pageSpread } = section
|
|
const newSpread = () => {
|
|
const spread = {}
|
|
arr.push(spread)
|
|
return spread
|
|
}
|
|
if (pageSpread === 'center') {
|
|
const spread = last.left || last.right ? newSpread() : last
|
|
spread.center = section
|
|
}
|
|
else if (pageSpread === 'left') {
|
|
const spread = last.center || last.left || ltr && i ? newSpread() : last
|
|
spread.left = section
|
|
}
|
|
else if (pageSpread === 'right') {
|
|
const spread = last.center || last.right || rtl && i ? newSpread() : last
|
|
spread.right = section
|
|
}
|
|
else if (ltr) {
|
|
if (last.center || last.right) newSpread().left = section
|
|
else if (last.left || !i) last.right = section
|
|
else last.left = section
|
|
}
|
|
else {
|
|
if (last.center || last.left) newSpread().right = section
|
|
else if (last.right || !i) last.left = section
|
|
else last .right = section
|
|
}
|
|
return arr
|
|
}, [{}])
|
|
}
|
|
get index() {
|
|
const spread = this.#spreads[this.#index]
|
|
const section = spread?.center ?? (this.side === 'left'
|
|
? spread.left ?? spread.right : spread.right ?? spread.left)
|
|
return this.book.sections.indexOf(section)
|
|
}
|
|
#reportLocation(reason) {
|
|
this.dispatchEvent(new CustomEvent('relocate', { detail:
|
|
{ reason, range: null, index: this.index, fraction: 0, size: 1 } }))
|
|
}
|
|
getSpreadOf(section) {
|
|
const spreads = this.#spreads
|
|
for (let index = 0; index < spreads.length; index++) {
|
|
const { left, right, center } = spreads[index]
|
|
if (left === section) return { index, side: 'left' }
|
|
if (right === section) return { index, side: 'right' }
|
|
if (center === section) return { index, side: 'center' }
|
|
}
|
|
}
|
|
async goToSpread(index, side, reason) {
|
|
if (index < 0 || index > this.#spreads.length - 1) return
|
|
if (index === this.#index) {
|
|
this.#render(side)
|
|
return
|
|
}
|
|
this.#index = index
|
|
const spread = this.#spreads[index]
|
|
if (spread.center) {
|
|
const index = this.book.sections.indexOf(spread.center)
|
|
const src = await spread.center?.load?.()
|
|
await this.#showSpread({ center: { index, src } })
|
|
} else {
|
|
const indexL = this.book.sections.indexOf(spread.left)
|
|
const indexR = this.book.sections.indexOf(spread.right)
|
|
const srcL = await spread.left?.load?.()
|
|
const srcR = await spread.right?.load?.()
|
|
const left = { index: indexL, src: srcL }
|
|
const right = { index: indexR, src: srcR }
|
|
await this.#showSpread({ left, right, side })
|
|
}
|
|
this.#reportLocation(reason)
|
|
}
|
|
async select(target) {
|
|
await this.goTo(target)
|
|
// TODO
|
|
}
|
|
async goTo(target) {
|
|
const { book } = this
|
|
const resolved = await target
|
|
const section = book.sections[resolved.index]
|
|
if (!section) return
|
|
const { index, side } = this.getSpreadOf(section)
|
|
await this.goToSpread(index, side)
|
|
}
|
|
async next() {
|
|
const s = this.rtl ? this.#goLeft() : this.#goRight()
|
|
if (s) this.#reportLocation('page')
|
|
else return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left', 'page')
|
|
}
|
|
async prev() {
|
|
const s = this.rtl ? this.#goRight() : this.#goLeft()
|
|
if (s) this.#reportLocation('page')
|
|
else return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right', 'page')
|
|
}
|
|
getContents() {
|
|
return Array.from(this.#root.querySelectorAll('iframe'), frame => ({
|
|
doc: frame.contentDocument,
|
|
// TODO: index, overlayer
|
|
}))
|
|
}
|
|
destroy() {
|
|
this.#observer.unobserve(this)
|
|
}
|
|
}
|
|
|
|
customElements.define('foliate-fxl', FixedLayout)
|