Files
foliate-js/fixed-layout.js

1409 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const 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);