mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
1409 lines
42 KiB
JavaScript
1409 lines
42 KiB
JavaScript
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 };
|
||
// Rect annotations (PDF text highlights; comic regions later). Keyed by
|
||
// host-supplied id, stored as page-fraction rects so they survive iframe
|
||
// CSS-scaling and PDF hi-res re-renders without any re-anchoring work.
|
||
#rectAnnotations = new Map();
|
||
#touchState = {
|
||
mode: null, // null | "pending" | "pan" | "pinch" | "swipe" | "native"
|
||
id: null,
|
||
realTarget: null,
|
||
startX: 0,
|
||
startY: 0,
|
||
lastX: 0,
|
||
lastY: 0,
|
||
startTX: 0,
|
||
startTY: 0,
|
||
startTime: 0,
|
||
startDist: 0,
|
||
startScale: 1,
|
||
lastMidX: 0,
|
||
lastMidY: 0,
|
||
lastTapTime: 0,
|
||
lastTapX: 0,
|
||
lastTapY: 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;
|
||
touch-action: none;
|
||
}`);
|
||
|
||
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);
|
||
|
||
// Touch gestures: pinch-zoom, two-finger pan, single-finger pan while
|
||
// zoomed, swipe page-turn at fit, double-tap zoom toggle. Page iframes
|
||
// forward their touch events here (see #attachEventListenersToIframe).
|
||
this.addEventListener("touchstart", this.#onTouchStart.bind(this), {
|
||
passive: false,
|
||
});
|
||
this.addEventListener("touchmove", this.#onTouchMove.bind(this), {
|
||
passive: false,
|
||
});
|
||
this.addEventListener("touchend", this.#onTouchEnd.bind(this), {
|
||
passive: false,
|
||
});
|
||
this.addEventListener("touchcancel", this.#onTouchCancel.bind(this), {
|
||
passive: false,
|
||
});
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// Reset pan while keeping the current zoom: the wrapper's base layout
|
||
// already centers the spread, so translate(0,0) recenters it. Re-syncs
|
||
// #side afterwards (same rule as drag-end) so next()/prev() stay sane.
|
||
recenter() {
|
||
this.#transform.x = 0;
|
||
this.#transform.y = 0;
|
||
this.#applyTransform();
|
||
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.#side = viewportCenterInWrapper < leftWidth ? "left" : "right";
|
||
}
|
||
}
|
||
|
||
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";
|
||
}
|
||
}
|
||
|
||
// ----- touch gestures -----
|
||
|
||
#atFitScale() {
|
||
return this.#zoom == null;
|
||
}
|
||
|
||
#touchStartsOnSelectable(target) {
|
||
if (!this.#isPDF || this.#interactionMode === "pan") return false;
|
||
return !!target?.closest?.(".textLayer span, .annotationLayer a");
|
||
}
|
||
|
||
#getPinchInfo(touches) {
|
||
const [a, b] = touches;
|
||
return {
|
||
dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
|
||
midX: (a.clientX + b.clientX) / 2,
|
||
midY: (a.clientY + b.clientY) / 2,
|
||
};
|
||
}
|
||
|
||
#onTouchStart(event) {
|
||
if (this.hasAttribute("panel-mode")) return;
|
||
const st = this.#touchState;
|
||
if (event.touches.length === 1) {
|
||
const t = event.touches[0];
|
||
st.mode = "pending";
|
||
st.id = t.identifier;
|
||
st.realTarget = event.realTarget ?? event.target ?? null;
|
||
st.startX = t.clientX;
|
||
st.startY = t.clientY;
|
||
st.lastX = t.clientX;
|
||
st.lastY = t.clientY;
|
||
st.startTX = this.#transform.x;
|
||
st.startTY = this.#transform.y;
|
||
st.startTime = Date.now();
|
||
} else if (event.touches.length >= 2) {
|
||
// A second finger always upgrades to pinch (cancels pan/swipe).
|
||
const { dist, midX, midY } = this.#getPinchInfo(event.touches);
|
||
st.mode = "pinch";
|
||
st.startDist = dist;
|
||
st.startScale = this.#transform.scale;
|
||
st.lastMidX = midX;
|
||
st.lastMidY = midY;
|
||
event.preventDefault();
|
||
}
|
||
}
|
||
|
||
#onTouchMove(event) {
|
||
const st = this.#touchState;
|
||
if (!st.mode) return;
|
||
|
||
if (st.mode === "pinch") {
|
||
if (event.touches.length < 2) return;
|
||
const { dist, midX, midY } = this.#getPinchInfo(event.touches);
|
||
const rect = this.getBoundingClientRect();
|
||
if (dist > 0 && st.startDist > 0) {
|
||
const target = dist / st.startDist * st.startScale;
|
||
const ratio = target / this.#transform.scale;
|
||
this.#zoomByRatio(midX - rect.left, midY - rect.top, ratio);
|
||
}
|
||
// two-finger pan: follow the midpoint
|
||
this.#transform.x += midX - st.lastMidX;
|
||
this.#transform.y += midY - st.lastMidY;
|
||
this.#applyTransform();
|
||
st.lastMidX = midX;
|
||
st.lastMidY = midY;
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
|
||
const t = [...event.touches].find((x) => x.identifier === st.id);
|
||
if (!t) return;
|
||
|
||
if (st.mode === "pending") {
|
||
if (Math.hypot(t.clientX - st.startX, t.clientY - st.startY) < 10)
|
||
return;
|
||
// Gesture decided on first significant movement.
|
||
if (this.#touchStartsOnSelectable(st.realTarget)) {
|
||
st.mode = "native"; // let the text layer handle selection
|
||
return;
|
||
}
|
||
if (this.#atFitScale()) {
|
||
st.mode = "swipe"; // page-turn gesture
|
||
} else {
|
||
st.mode = "pan";
|
||
this.style.cursor = "grabbing";
|
||
}
|
||
}
|
||
|
||
if (st.mode === "pan") {
|
||
this.#transform.x = st.startTX + (t.clientX - st.startX);
|
||
this.#transform.y = st.startTY + (t.clientY - st.startY);
|
||
this.#applyTransform();
|
||
event.preventDefault();
|
||
} else if (st.mode === "swipe") {
|
||
event.preventDefault();
|
||
}
|
||
}
|
||
|
||
#onTouchEnd(event) {
|
||
const st = this.#touchState;
|
||
if (!st.mode) return;
|
||
|
||
if (st.mode === "pinch") {
|
||
if (event.touches.length === 1) {
|
||
// continue as a single-finger pan with the remaining finger
|
||
const t = event.touches[0];
|
||
st.mode = "pan";
|
||
st.id = t.identifier;
|
||
st.startX = t.clientX;
|
||
st.startY = t.clientY;
|
||
st.startTX = this.#transform.x;
|
||
st.startTY = this.#transform.y;
|
||
} else if (event.touches.length === 0) {
|
||
st.mode = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (st.mode === "pan") {
|
||
if (event.touches.length === 0) {
|
||
st.mode = null;
|
||
this.style.cursor = "";
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (st.mode === "swipe") {
|
||
const t = [...event.changedTouches].find(
|
||
(x) => x.identifier === st.id,
|
||
);
|
||
const dx = (t?.clientX ?? st.startX) - st.startX;
|
||
const dy = (t?.clientY ?? st.startY) - st.startY;
|
||
st.mode = null;
|
||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) {
|
||
// Finger direction maps through next()/prev() so RTL (manga)
|
||
// reads correctly: forward is a left swipe in LTR, right in RTL.
|
||
if ((dx < 0) !== this.rtl) this.next();
|
||
else this.prev();
|
||
event.preventDefault();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (st.mode === "pending") {
|
||
// no significant movement: a tap. Double-tap toggles zoom.
|
||
const t = [...event.changedTouches].find(
|
||
(x) => x.identifier === st.id,
|
||
);
|
||
const x = t?.clientX ?? st.startX;
|
||
const y = t?.clientY ?? st.startY;
|
||
const now = Date.now();
|
||
const rect = this.getBoundingClientRect();
|
||
st.mode = null;
|
||
if (
|
||
now - st.lastTapTime < 300 &&
|
||
Math.hypot(x - st.lastTapX, y - st.lastTapY) < 30
|
||
) {
|
||
st.lastTapTime = 0;
|
||
if (this.#atFitScale()) {
|
||
this.#zoomByRatio(x - rect.left, y - rect.top, 2.5);
|
||
} else {
|
||
this.resetZoom();
|
||
}
|
||
event.preventDefault();
|
||
} else {
|
||
st.lastTapTime = now;
|
||
st.lastTapX = x;
|
||
st.lastTapY = y;
|
||
}
|
||
}
|
||
}
|
||
|
||
#onTouchCancel() {
|
||
this.#touchState.mode = null;
|
||
this.style.cursor = "";
|
||
}
|
||
|
||
// ----- rect annotations -----
|
||
|
||
#frameForIndex(index) {
|
||
for (const frame of [this.#left, this.#right, this.#center]) {
|
||
if (frame && !frame.blank && frame.index === index) return frame;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Host-side overlay: a div inside the frame's wrapper element, children
|
||
// positioned in percentages of the element box (which always equals the
|
||
// visible page area). This is immune to iframe-internal transforms
|
||
// (pdf.js scales <html> by 1/devicePixelRatio, which would shrink an
|
||
// in-document overlay), comic iframe CSS-scaling, PDF hi-res re-renders,
|
||
// and the host transform-based pan/zoom — no re-anchoring anywhere.
|
||
#ensureOverlay(frame) {
|
||
if (!frame?.element) return null;
|
||
let overlay = frame.element.querySelector(
|
||
":scope > .foliate-rect-overlay",
|
||
);
|
||
if (!overlay) {
|
||
overlay = frame.element.ownerDocument.createElement("div");
|
||
overlay.className = "foliate-rect-overlay";
|
||
overlay.style.cssText =
|
||
"position:absolute;inset:0;pointer-events:none;";
|
||
frame.element.style.position = "relative";
|
||
frame.element.appendChild(overlay);
|
||
}
|
||
return overlay;
|
||
}
|
||
|
||
#renderAnnotationsIntoFrame(frame) {
|
||
if (!frame || frame.blank || frame.index == null) return;
|
||
const overlay = this.#ensureOverlay(frame);
|
||
if (!overlay) return;
|
||
const doc = frame.element.ownerDocument;
|
||
overlay.replaceChildren();
|
||
for (const a of this.#rectAnnotations.values()) {
|
||
if (a.index !== frame.index) continue;
|
||
for (const [x, y, w, h] of a.rects) {
|
||
const rect = doc.createElement("div");
|
||
Object.assign(rect.style, {
|
||
position: "absolute",
|
||
left: `${x * 100}%`,
|
||
top: `${y * 100}%`,
|
||
width: `${w * 100}%`,
|
||
height: `${h * 100}%`,
|
||
backgroundColor: a.color || "#ffd54f",
|
||
opacity: "0.35",
|
||
borderRadius: "2px",
|
||
});
|
||
overlay.appendChild(rect);
|
||
}
|
||
}
|
||
}
|
||
|
||
#renderAnnotationsForIndex(index) {
|
||
this.#renderAnnotationsIntoFrame(this.#frameForIndex(index));
|
||
}
|
||
|
||
addRectAnnotation({ key, index, rects, color }) {
|
||
if (!key || index == null || !Array.isArray(rects) || !rects.length)
|
||
return;
|
||
this.#rectAnnotations.set(key, { index, rects, color });
|
||
this.#renderAnnotationsForIndex(index);
|
||
}
|
||
|
||
removeRectAnnotation(key) {
|
||
const a = this.#rectAnnotations.get(key);
|
||
if (!a) return;
|
||
this.#rectAnnotations.delete(key);
|
||
this.#renderAnnotationsForIndex(a.index);
|
||
}
|
||
|
||
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, index, onZoom };
|
||
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,
|
||
index,
|
||
});
|
||
// Re-render persisted annotations into the fresh frame (frames
|
||
// are recreated on every spread change; the spread fields aren't
|
||
// assigned yet, so pass the frame under construction directly).
|
||
this.#renderAnnotationsIntoFrame({ element, iframe, index });
|
||
|
||
resolve({
|
||
element,
|
||
iframe,
|
||
width: parseFloat(width),
|
||
height: parseFloat(height),
|
||
onZoom,
|
||
frameId,
|
||
index,
|
||
});
|
||
},
|
||
{ once: true },
|
||
);
|
||
iframe.src = src;
|
||
});
|
||
}
|
||
|
||
#attachEventListenersToIframe(doc, frameId, frame) {
|
||
const convertPoint = (x, y) => {
|
||
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 + x * scaleX,
|
||
clientY: iframeRect.top - flRect.top + y * scaleY,
|
||
};
|
||
};
|
||
const convertCoords = (e) => convertPoint(e.clientX, e.clientY);
|
||
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";
|
||
});
|
||
|
||
// Forward touches to the host gesture engine with converted
|
||
// coordinates. Handlers decide whether to preventDefault (pan/pinch/
|
||
// swipe) or let the iframe handle it natively (text selection, taps).
|
||
const forwardTouches = (list) =>
|
||
[...list].map((t) => {
|
||
const p = convertPoint(t.clientX, t.clientY);
|
||
return { identifier: t.identifier, ...p };
|
||
});
|
||
const synthTouch = (event) => ({
|
||
touches: forwardTouches(event.touches),
|
||
changedTouches: forwardTouches(event.changedTouches),
|
||
realTarget: event.target,
|
||
preventDefault: () => event.preventDefault(),
|
||
});
|
||
doc.addEventListener(
|
||
"touchstart",
|
||
(event) => {
|
||
this.#onTouchStart(synthTouch(event));
|
||
},
|
||
{ passive: false },
|
||
);
|
||
doc.addEventListener(
|
||
"touchmove",
|
||
(event) => {
|
||
this.#onTouchMove(synthTouch(event));
|
||
},
|
||
{ passive: false },
|
||
);
|
||
doc.addEventListener(
|
||
"touchend",
|
||
(event) => {
|
||
this.#onTouchEnd(synthTouch(event));
|
||
},
|
||
{ passive: false },
|
||
);
|
||
doc.addEventListener(
|
||
"touchcancel",
|
||
(event) => {
|
||
this.#onTouchCancel(synthTouch(event));
|
||
},
|
||
{ passive: false },
|
||
);
|
||
|
||
// Click hit-testing for rect annotations (edit popover). Skipped while a
|
||
// text selection is active so drag-selecting doesn't pop the editor.
|
||
// Denominator: the rendered canvas for PDFs (its post-transform rect IS
|
||
// the visible page; documentElement is dpr× too small because pdf.js
|
||
// scales <html> by 1/devicePixelRatio), the img for comics.
|
||
doc.addEventListener(
|
||
"click",
|
||
(event) => {
|
||
if (!this.#rectAnnotations.size || frame.index == null) return;
|
||
const sel = doc.getSelection();
|
||
if (sel && !sel.isCollapsed) return;
|
||
const denom =
|
||
doc.querySelector("#canvas canvas") ||
|
||
doc.querySelector("img");
|
||
const dr = denom?.getBoundingClientRect();
|
||
if (!dr || !dr.width || !dr.height) return;
|
||
const fx = (event.clientX - dr.left) / dr.width;
|
||
const fy = (event.clientY - dr.top) / dr.height;
|
||
for (const [key, a] of this.#rectAnnotations) {
|
||
if (a.index !== frame.index) continue;
|
||
for (const [x, y, w, h] of a.rects) {
|
||
if (fx >= x && fx <= x + w && fy >= y && fy <= y + h) {
|
||
const { clientX, clientY } = convertCoords(event);
|
||
this.dispatchEvent(
|
||
new CustomEvent("show-rect-annotation", {
|
||
detail: { key, index: a.index, clientX, clientY },
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
false,
|
||
);
|
||
|
||
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);
|