const parseViewport = (str) => str ?.split(/[,;\s]/) ?.filter((x) => x) ?.map((x) => x.split("=").map((x) => x.trim())); const getViewport = (doc, viewport) => { if (doc.documentElement.localName === "svg") { const [, , width, height] = doc.documentElement.getAttribute("viewBox")?.split(/\s/) ?? []; return { width, height }; } const meta = parseViewport( doc.querySelector('meta[name="viewport"]')?.getAttribute("content"), ); if (meta) return Object.fromEntries(meta); if (typeof viewport === "string") return parseViewport(viewport); if (viewport?.width && viewport.height) return viewport; const img = doc.querySelector("img"); if (img) return { width: img.naturalWidth, height: img.naturalHeight }; console.warn(new Error("Missing viewport properties")); return { width: 1000, height: 2000 }; }; export class FixedLayout extends HTMLElement { static observedAttributes = ["zoom", "interaction-mode", "spread"]; #root = this.attachShadow({ mode: "closed" }); #observer = new ResizeObserver(() => this.#onResize()); #spreads; #index = -1; defaultViewport; spread; #portrait = false; #left; #right; #center; #side; #zoom; #isPDF; #interactionMode = "select"; #wrapper; #transform = { x: 0, y: 0, scale: 1 }; #baseScale = 1; #zoomState = { minScale: 0.1, maxScale: 10, zoomStep: 0.05, }; #zoomAccum = { ratio: 1, cx: 0, cy: 0, raf: null }; #pdfLastRenderedScale = 1; #pdfSettleTimeout = null; #dragState = { isDragging: false, startX: 0, startY: 0, startTX: 0, startTY: 0, }; dragOffset = { x: 0, y: 0 }; #magnifier = { enabled: false, size: 150, magnification: 2, element: null, }; constructor() { super(); const sheet = new CSSStyleSheet(); this.#root.adoptedStyleSheets = [sheet]; sheet.replaceSync(`:host { width: 100%; height: 100%; display: block; overflow: hidden; position: relative; }`); this.#wrapper = document.createElement("div"); this.#wrapper.style.cssText = ` position: absolute; top: 0; left: 0; display: flex; transform-origin: 0 0; `; this.#root.appendChild(this.#wrapper); this.#observer.observe(this); } attributeChangedCallback(name, _, value) { switch (name) { case "zoom": { if (value == null) { this.#zoom = undefined; this.#render(); return; } const newZoom = value !== "fit-width" && value !== "fit-page" ? parseFloat(value) : value; if (typeof newZoom === "number" && !isNaN(newZoom)) { if (this.#transform.scale === newZoom) return; const rect = this.getBoundingClientRect(); const cx = rect.width / 2; const cy = rect.height / 2; this.#zoomByRatio(cx, cy, newZoom / this.#transform.scale); } else { this.#zoom = newZoom; this.#render(); } break; } case "interaction-mode": { if (value === "pan" || value === "select" || value === "text") { this.#interactionMode = value; } break; } case "spread": { this.#setSpread(value == null ? undefined : value); break; } } } get zoom() { return this.#zoom; } get isPDF() { return this.#isPDF; } get zoomMagnifierEnabled() { return this.#magnifier.enabled; } get currentScale() { return this.#transform.scale; } get zoomPercent() { return Math.round((this.#transform.scale / this.#baseScale) * 100); } resetZoom() { this.#zoom = undefined; this.#render(); } toggleMagnifier() { this.#magnifier.enabled = !this.#magnifier.enabled; if (this.#magnifier.enabled) { this.#createMagnifier(); this.addEventListener("mousemove", this.#handleMagnifierMove.bind(this)); this.style.cursor = "crosshair"; } else { this.#destroyMagnifier(); this.removeEventListener( "mousemove", this.#handleMagnifierMove.bind(this), ); this.style.cursor = ""; } } #onResize() { clearTimeout(this.#pdfSettleTimeout); if (typeof this.#zoom === "number" && !isNaN(this.#zoom)) { // Numeric zoom: rescale frames but preserve user's pan position this.#updateFrameScales(this.#transform.scale); this.#applyTransform(); } else { // Fit-page/fit-width: full re-render (recalculate scale + center) this.#render(); } } #createMagnifier() { if (this.#magnifier.element) return; const magnifier = document.createElement("div"); magnifier.className = "magnifier"; magnifier.style.cssText = ` position: absolute; width: ${this.#magnifier.size}px; height: ${this.#magnifier.size}px; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.8); box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); pointer-events: none; z-index: 1000; display: none; overflow: hidden; background: white; `; const lens = document.createElement("div"); lens.className = "magnifier-lens"; lens.style.cssText = ` width: 100%; height: 100%; border-radius: 50%; overflow: hidden; background-repeat: no-repeat; `; magnifier.appendChild(lens); this.#root.appendChild(magnifier); this.#magnifier.element = magnifier; } #destroyMagnifier() { if (this.#magnifier.element) { this.#magnifier.element.remove(); this.#magnifier.element = null; } } #handleMagnifierMove(event) { if (!this.#magnifier.enabled) return; const rect = this.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const magnifier = this.#magnifier.element; magnifier.style.display = "block"; magnifier.style.left = `${x - this.#magnifier.size / 2}px`; magnifier.style.top = `${y - this.#magnifier.size / 2}px`; const lens = magnifier.querySelector(".magnifier-lens"); const candidates = this.#center ? [this.#center] : [this.#left, this.#right].filter((f) => f && !f.blank); let frame = null; for (const f of candidates) { if (!f?.iframe) continue; const fRect = f.iframe.getBoundingClientRect(); if ( event.clientX >= fRect.left && event.clientX <= fRect.right && event.clientY >= fRect.top && event.clientY <= fRect.bottom ) { frame = f; break; } } if (!frame?.iframe) return; const iframe = frame.iframe; const doc = iframe.contentDocument; const scale = this.#transform.scale || 1; const magScale = this.#magnifier.magnification; const size = this.#magnifier.size; const iframeRect = iframe.getBoundingClientRect(); const relX = x - iframeRect.left; const relY = y - iframeRect.top; // Comic book: img inside iframe const img = doc?.querySelector("img"); if (img) { if (!img.naturalWidth || !img.naturalHeight) { lens.style.display = "none"; return; } // Calculate rendered image dimensions // The img is at natural size, iframe is CSS-scaled by 'scale' const renderedWidth = img.naturalWidth * scale; const renderedHeight = img.naturalHeight * scale; // Position within rendered image (0-1 range) const normX = relX / renderedWidth; const normY = relY / renderedHeight; if (normX < 0 || normX > 1 || normY < 0 || normY > 1) { lens.style.display = "none"; return; } // Background size (zoomed) const bgWidth = renderedWidth * magScale; const bgHeight = renderedHeight * magScale; // Background position (centered on cursor) const bgPosX = -(normX * bgWidth - size / 2); const bgPosY = -(normY * bgHeight - size / 2); lens.style.backgroundImage = `url('${img.src}')`; lens.style.backgroundSize = `${bgWidth}px ${bgHeight}px`; lens.style.backgroundPosition = `${bgPosX}px ${bgPosY}px`; lens.style.display = ""; return; } // PDF: canvas inside iframe const canvas = doc?.querySelector("#canvas canvas") || doc?.querySelector("canvas"); if (canvas) { // Clean up comic book background-image if present lens.style.backgroundImage = ""; lens.style.backgroundSize = ""; lens.style.backgroundPosition = ""; let lensCanvas = lens.querySelector("canvas"); if (!lensCanvas) { lensCanvas = document.createElement("canvas"); lensCanvas.width = size; lensCanvas.height = size; lensCanvas.style.cssText = "border-radius: 50%;"; lens.innerHTML = ""; lens.appendChild(lensCanvas); } const ctx = lensCanvas.getContext("2d"); ctx.clearRect(0, 0, size, size); const dpr = devicePixelRatio; const captureSize = size / magScale; const sx = relX * dpr - (captureSize * dpr) / 2; const sy = relY * dpr - (captureSize * dpr) / 2; ctx.drawImage( canvas, sx, sy, captureSize * dpr, captureSize * dpr, 0, 0, size, size, ); return; } lens.style.display = "none"; } #applyTransform() { this.#wrapper.style.transform = `translate(${this.#transform.x}px, ${this.#transform.y}px)`; } #applyPDFZoomScale(scale) { const cssScale = scale / this.#pdfLastRenderedScale; const candidates = this.#center ? [this.#center] : [this.#left, this.#right]; for (const frame of candidates) { if (!frame?.onZoom || !frame.iframe) continue; Object.assign(frame.iframe.style, { width: `${frame.width * this.#pdfLastRenderedScale}px`, height: `${frame.height * this.#pdfLastRenderedScale}px`, transform: `scale(${cssScale})`, transformOrigin: "top left", }); Object.assign(frame.element.style, { width: `${frame.width * scale}px`, height: `${frame.height * scale}px`, }); } } #schedulePDFRerender(scale) { clearTimeout(this.#pdfSettleTimeout); this.#pdfSettleTimeout = setTimeout(() => { this.#updateFrameScales(scale); this.#pdfSettleTimeout = null; }, 150); } #updateFrameScales(scale) { const left = this.#left ?? {}; const right = this.#center ?? this.#right ?? {}; const { width: hostWidth, height: hostHeight } = this.getBoundingClientRect(); const portrait = this.spread !== "both" && this.spread !== "portrait" && hostHeight > hostWidth; const target = this.#side === "left" ? left : right; const blankWidth = left.width ?? right.width ?? 0; const blankHeight = left.height ?? right.height ?? 0; const transform = (frame) => { let { element, iframe, width, height, blank, onZoom } = frame; if (!iframe) return; 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", flexShrink: "0", }); if (portrait && frame !== target) { element.style.display = "none"; } }; if (this.#center) { transform(this.#center); } else { transform(left); transform(right); } if (this.isPDF) this.#pdfLastRenderedScale = scale; } #getContentSize() { const left = this.#left ?? {}; const right = this.#center ?? this.#right ?? {}; const { width: hostWidth, height: hostHeight } = this.getBoundingClientRect(); const portrait = this.spread !== "both" && this.spread !== "portrait" && hostHeight > hostWidth; const target = this.#side === "left" ? left : right; const blankWidth = left.width ?? right.width ?? 0; const blankHeight = left.height ?? right.height ?? 0; const scale = this.#transform.scale; let contentWidth; let contentHeight; if (this.#center || portrait) { const tw = (target.width ?? blankWidth) * scale; const th = (target.height ?? blankHeight) * scale; contentWidth = tw; contentHeight = th; } else { const lw = (left.width ?? blankWidth) * scale; const rw = (right.width ?? blankWidth) * scale; const lh = (left.height ?? blankHeight) * scale; const rh = (right.height ?? blankHeight) * scale; contentWidth = lw + rw; contentHeight = Math.max(lh, rh); } return { contentWidth, contentHeight }; } #zoomByRatio(cx, cy, ratio) { const oldScale = this.#transform.scale; const newScale = Math.min( this.#zoomState.maxScale, Math.max(this.#zoomState.minScale, oldScale * ratio), ); const actualRatio = newScale / oldScale; this.#transform.x = cx - actualRatio * (cx - this.#transform.x); this.#transform.y = cy - actualRatio * (cy - this.#transform.y); this.#transform.scale = newScale; this.#zoom = newScale; if (this.isPDF) { this.#applyPDFZoomScale(newScale); this.#schedulePDFRerender(newScale); } else { this.#updateFrameScales(newScale); } this.#applyTransform(); this.dispatchEvent( new CustomEvent("zoom", { detail: { scale: newScale } }), ); } #handleWheel(event) { if (this.hasAttribute("panel-mode")) return; if (event.ctrlKey || event.metaKey) return; event.preventDefault(); const rect = this.getBoundingClientRect(); this.#zoomAccum.cx = event.clientX - rect.left; this.#zoomAccum.cy = event.clientY - rect.top; const tick = event.deltaY > 0 ? 1 - this.#zoomState.zoomStep : 1 + this.#zoomState.zoomStep; this.#zoomAccum.ratio = tick; if (!this.#zoomAccum.raf) { this.#zoomAccum.raf = requestAnimationFrame(() => { this.#zoomByRatio( this.#zoomAccum.cx, this.#zoomAccum.cy, this.#zoomAccum.ratio, ); this.#zoomAccum.ratio = 1; this.#zoomAccum.raf = null; }); } } #handleMouseDown(event) { if (event.button !== 0) return; if (this.#isPDF && this.#interactionMode === "text" && !event.shiftKey) return false; if (this.#isPDF && this.#interactionMode === "select" && !event.shiftKey) { const t = event.realTarget; if ( t?.closest?.(".textLayer span") || t?.closest?.(".annotationLayer a") ) return false; } this.#dragState.startX = event.clientX; this.#dragState.startY = event.clientY; this.#dragState.startTX = this.#transform.x; this.#dragState.startTY = this.#transform.y; this.#dragState.isDragging = true; this.style.cursor = "grabbing"; return true; } #handleMouseMove(event) { if (!this.#dragState.isDragging) return; const dx = event.clientX - this.#dragState.startX; const dy = event.clientY - this.#dragState.startY; this.#transform.x = this.#dragState.startTX + dx; this.#transform.y = this.#dragState.startTY + dy; this.#applyTransform(); } #handleMouseUp(event) { if (!this.#dragState.isDragging) return; this.#dragState.isDragging = false; this.style.cursor = ""; // sync #side with the frame the user actually panned to if ( !this.#center && !this.#left?.blank && !this.#right?.blank && !this.#portrait ) { const leftWidth = (this.#left.width ?? 0) * this.#transform.scale; const viewportCenterInWrapper = this.getBoundingClientRect().width / 2 - this.#transform.x; this.#side = viewportCenterInWrapper < leftWidth ? "left" : "right"; } } async #createFrame({ index, src: srcOption }, frameId) { const srcOptionIsString = typeof srcOption === "string"; const src = srcOptionIsString ? srcOption : srcOption?.src; const onZoom = srcOptionIsString ? null : srcOption?.onZoom; const element = document.createElement("div"); element.setAttribute("dir", "ltr"); const iframe = document.createElement("iframe"); element.append(iframe); Object.assign(iframe.style, { border: "0", display: "none", overflow: "hidden", }); iframe.setAttribute("sandbox", "allow-same-origin allow-scripts"); iframe.setAttribute("scrolling", "no"); iframe.setAttribute("part", "filter"); this.#wrapper.append(element); if (!src) return { blank: true, element, iframe, frameId }; 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); this.#attachEventListenersToIframe(doc, frameId, { element, iframe }); resolve({ element, iframe, width: parseFloat(width), height: parseFloat(height), onZoom, frameId, }); }, { once: true }, ); iframe.src = src; }); } #attachEventListenersToIframe(doc, frameId, frame) { const convertCoords = (e) => { const iframeRect = frame.iframe.getBoundingClientRect(); const flRect = this.getBoundingClientRect(); const scaleX = iframeRect.width / (doc.documentElement.clientWidth || 1); const scaleY = iframeRect.height / (doc.documentElement.clientHeight || 1); return { clientX: iframeRect.left - flRect.left + e.clientX * scaleX, clientY: iframeRect.top - flRect.top + e.clientY * scaleY, }; }; if (!doc) return; const images = doc.querySelectorAll("img"); images.forEach((img) => { img.setAttribute("draggable", "false"); img.style.userSelect = "none"; img.style.webkitUserDrag = "none"; img.style.WebkitUserDrag = "none"; }); doc.addEventListener( "wheel", (event) => { const { clientX, clientY } = convertCoords(event); const fixedLayoutEvent = new WheelEvent("wheel", { deltaX: event.deltaX, deltaY: event.deltaY, deltaZ: event.deltaZ, deltaMode: event.deltaMode, clientX, clientY, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, altKey: event.altKey, bubbles: true, cancelable: true, }); fixedLayoutEvent.sourceIframe = frameId; fixedLayoutEvent.sourceFrame = frame; this.#handleWheel(fixedLayoutEvent); event.preventDefault(); event.stopPropagation(); }, { passive: false }, ); doc.addEventListener("mousedown", (event) => { const { clientX, clientY } = convertCoords(event); const mouseEvent = new MouseEvent("mousedown", { button: event.button, clientX, clientY, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, altKey: event.altKey, bubbles: true, cancelable: true, }); mouseEvent.sourceIframe = frameId; mouseEvent.sourceFrame = frame; mouseEvent.realTarget = event.target; const dragStarted = this.#handleMouseDown(mouseEvent); if (dragStarted) event.preventDefault(); }); doc.addEventListener("mousemove", (event) => { const { clientX, clientY } = convertCoords(event); if (this.#magnifier.enabled) { this.#handleMagnifierMove({ clientX, clientY }); } const mouseEvent = new MouseEvent("mousemove", { button: event.button, clientX, clientY, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, altKey: event.altKey, bubbles: true, cancelable: true, }); mouseEvent.sourceIframe = frameId; mouseEvent.sourceFrame = frame; this.#handleMouseMove(mouseEvent); if (this.#dragState.isDragging || this.#magnifier.enabled) event.preventDefault(); }); doc.addEventListener("mouseup", (event) => { const { clientX, clientY } = convertCoords(event); const mouseEvent = new MouseEvent("mouseup", { button: event.button, clientX, clientY, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, altKey: event.altKey, bubbles: true, cancelable: true, }); mouseEvent.sourceIframe = frameId; mouseEvent.sourceFrame = frame; this.#handleMouseUp(mouseEvent); event.preventDefault(); }); } #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 ?? 0; const blankHeight = left.height ?? right.height ?? 0; const scale = typeof this.#zoom === "number" && !isNaN(this.#zoom) ? this.#zoom : (this.#zoom === "fit-width" ? portrait || this.#center ? width / (target.width ?? blankWidth) : width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)) : 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, ), )) || 1; this.#transform.scale = scale; if (typeof this.#zoom !== "number" || isNaN(this.#zoom)) { this.#baseScale = scale; } this.#updateFrameScales(scale); const { contentWidth, contentHeight } = this.#getContentSize(); this.#transform.y = (height - contentHeight) / 2; const isNumericZoom = typeof this.#zoom === "number" && !isNaN(this.#zoom); const hasDualFrames = !this.#center && !this.#left?.blank && !this.#right?.blank; if (isNumericZoom && hasDualFrames && !portrait) { const leftWidth = (this.#left.width ?? 0) * scale; const rightWidth = (this.#right.width ?? 0) * scale; if (side === "right") { this.#transform.x = (width - rightWidth) / 2 - leftWidth; } else { this.#transform.x = (width - leftWidth) / 2; } } else { this.#transform.x = (width - contentWidth) / 2; } this.#applyTransform(); } async #showSpread({ left, right, center, side }) { clearTimeout(this.#pdfSettleTimeout); this.#wrapper.replaceChildren(); this.#left = null; this.#right = null; this.#center = null; if (center) { this.#center = await this.#createFrame(center, "center"); this.#side = "center"; this.#isPDF = !!this.#center?.onZoom; this.#render(); } else { this.#left = await this.#createFrame(left, "left"); this.#right = await this.#createFrame(right, "right"); this.#side = this.#left.blank ? "right" : this.#right.blank ? "left" : side; this.#isPDF = !!(this.#left?.onZoom || this.#right?.onZoom); this.#render(); } } #goLeft() { if (this.#center || this.#left?.blank) return; if (this.#portrait && this.#left?.element?.style?.display === "none") { this.#side = "left"; this.#render(); this.#reportLocation("page"); return true; } // zoomed-in dual-pane - pan to left frame if ( typeof this.#zoom === "number" && !isNaN(this.#zoom) && !this.#right?.blank ) { if (this.#side === "right") { this.#side = "left"; this.#render(); return true; } } } #goRight() { if (this.#center || this.#right?.blank) return; if (this.#portrait && this.#right?.element?.style?.display === "none") { this.#side = "right"; this.#render(); this.#reportLocation("page"); return true; } // zoomed-in dual-pane - pan to right frame if ( typeof this.#zoom === "number" && !isNaN(this.#zoom) && !this.#left?.blank ) { if (this.#side === "left") { this.#side = "right"; this.#render(); return true; } } } open(book) { this.book = book; const { rendition } = book; this.spread = rendition?.spread; this.defaultViewport = rendition?.viewport; this.rtl = book.dir === "rtl"; this.#computeSpreads(); } #computeSpreads() { const { book } = this; const rtl = this.rtl; const ltr = !rtl; if (this.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; }, [{}], ); } #setSpread(value) { this.spread = value; const started = this.#index >= 0; const currentSection = started ? this.book?.sections[this.index] : null; this.#computeSpreads(); if (!started) return; const resolved = currentSection ? this.getSpreadOf(currentSection) : null; if (resolved) this.goToSpread(resolved.index, resolved.side, "page"); else this.#render(); } 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); } 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) return this.goToSpread( this.#index + 1, this.rtl ? "right" : "left", "page", ); } async prev() { const s = this.rtl ? this.#goRight() : this.#goLeft(); if (!s) return this.goToSpread( this.#index - 1, this.rtl ? "left" : "right", "page", ); } getContents() { return Array.from(this.#root.querySelectorAll("iframe"), (frame) => ({ doc: frame.contentDocument, })); } destroy() { this.#observer.unobserve(this); clearTimeout(this.#pdfSettleTimeout); } } customElements.define("foliate-fxl", FixedLayout);