Files
foliate-js/fixed-layout.js
T
john-okeefe 5b822b682b Fix drag offset calculation and scrolling behavior in FixedLayout component
This commit fixes several issues with the drag functionality in the FixedLayout
component to ensure smooth and accurate dragging behavior:

Changes:
- Initialize dragOffset to (0, 0) instead of scroll position when drag starts
- Fix drag offset calculation direction (changed from -= to +=)
- Add proper scroll position updates during drag operations
- Ensure dragOffset is reset to (0, 0) when drag completes

The previous implementation was incorrectly initializing dragOffset with the
current scroll position, which caused the drag offset to compound with scroll
position and led to incorrect rendering. The new approach starts drag offset
at zero and directly updates the scroll position during drag operations.

This ensures that:
1. Drag movements are accurately tracked relative to the drag start point
2. Scroll position is properly synchronized with drag movements
3. Drag state is cleanly reset when drag operations complete
2026-04-17 11:02:26 -04:00

802 lines
24 KiB
JavaScript

const parseViewport = (str) =>
str
?.split(/[,;\s]/) // NOTE: technically, only the comma is valid
?.filter((x) => x)
?.map((x) => x.split("=").map((x) => x.trim()));
const getViewport = (doc, viewport) => {
// use `viewBox` for SVG
if (doc.documentElement.localName === "svg") {
const [, , width, height] =
doc.documentElement.getAttribute("viewBox")?.split(/\s/) ?? [];
return { width, height };
}
// get `viewport` `meta` element
const meta = parseViewport(
doc.querySelector('meta[name="viewport"]')?.getAttribute("content"),
);
if (meta) return Object.fromEntries(meta);
// fallback to book's viewport
if (typeof viewport === "string") return parseViewport(viewport);
if (viewport?.width && viewport.height) return viewport;
// if no viewport (possibly with image directly in spine), get image size
const img = doc.querySelector("img");
if (img) return { width: img.naturalWidth, height: img.naturalHeight };
// just show *something*, i guess...
console.warn(new Error("Missing viewport properties"));
return { width: 1000, height: 2000 };
};
export class FixedLayout extends HTMLElement {
static observedAttributes = ["zoom"];
#root = this.attachShadow({ mode: "closed" });
#observer = new ResizeObserver(() => this.#render());
#spreads;
#index = -1;
defaultViewport;
spread;
#portrait = false;
#left;
#right;
#center;
#side;
#zoom;
#zoomState = {
minScale: 0.1,
maxScale: 10,
zoomStep: 0.1,
};
#dragState = {
isDragging: false,
isPotentialDrag: false,
dragTimeout: null,
pressDelay: 200, // milliseconds
startX: 0,
startY: 0,
scrollLeft: 0,
scrollTop: 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: flex;
justify-content: center;
align-items: center;
overflow: auto;
}`);
this.#observer.observe(this);
// DEBUG: Log initialization
console.log("[FixedLayout Debug] ✓ FixedLayout initialized", {
hasZoomState: !!this.#zoomState,
hasDragState: !!this.#dragState,
hasMagnifier: !!this.#magnifier,
zoomState: this.#zoomState,
dragState: this.#dragState,
});
// NOTE: Event listeners will be attached to wrapper elements in #showSpread
console.log(
"[FixedLayout Debug] Event listeners will be attached to frame wrappers",
);
// NOTE: Event listeners will be attached to iframe documents in #createFrame
console.log(
"[FixedLayout Debug] Event listeners will be attached to iframe documents",
);
}
attributeChangedCallback(name, _, value) {
switch (name) {
case "zoom":
this.#zoom =
value !== "fit-width" && value !== "fit-page"
? parseFloat(value)
: value;
this.#render();
break;
}
}
toggleMagnifier() {
console.log("[FixedLayout Debug] 🔍 Toggle magnifier:", {
current: this.#magnifier.enabled,
willBe: !this.#magnifier.enabled,
});
this.#magnifier.enabled = !this.#magnifier.enabled;
if (this.#magnifier.enabled) {
this.#createMagnifier();
this.addEventListener("mousemove", this.#handleMagnifierMove.bind(this));
this.style.cursor = "crosshair";
console.log("[FixedLayout Debug] ✅ Magnifier enabled");
} else {
this.#destroyMagnifier();
this.removeEventListener(
"mousemove",
this.#handleMagnifierMove.bind(this),
);
this.style.cursor = "";
console.log("[FixedLayout Debug] ❌ Magnifier disabled");
}
}
#createMagnifier() {
if (this.#magnifier.element) {
console.log("[FixedLayout Debug] ⏸️ Magnifier already exists");
return;
}
console.log("[FixedLayout Debug] 🔧 Creating magnifier element");
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;
`;
magnifier.appendChild(lens);
this.#root.appendChild(magnifier);
this.#magnifier.element = magnifier;
console.log("[FixedLayout Debug] ✅ Magnifier element created");
}
#destroyMagnifier() {
if (this.#magnifier.element) {
this.#magnifier.element.remove();
this.#magnifier.element = null;
console.log("[FixedLayout Debug] ✅ Magnifier destroyed");
}
}
#handleMagnifierMove(event) {
if (!this.#magnifier.enabled) return;
const rect = this.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Position magnifier
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`;
// Update lens (clone and zoom iframe content)
const lens = magnifier.querySelector(".magnifier-lens");
const frame = this.#center || this.#left || this.#right;
if (frame?.iframe) {
const iframe = frame.iframe;
lens.innerHTML = "";
lens.appendChild(iframe.cloneNode(true));
lens.style.transform = `scale(${this.#magnifier.magnification})`;
const iframeRect = iframe.getBoundingClientRect();
const relX = (x - iframeRect.left) / (this.#zoom || 1);
const relY = (y - iframeRect.top) / (this.#zoom || 1);
lens.style.transformOrigin = `${-relX + this.#magnifier.size / 2}px ${-relY + this.#magnifier.size / 2}px`;
}
}
#handleWheel(event) {
console.log("[FixedLayout Debug] 🖱️ Wheel event:", {
deltaY: event.deltaY,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
panelMode: this.hasAttribute("panel-mode"),
currentZoom: this.#zoom,
});
// Don't interfere with panel mode
if (this.hasAttribute("panel-mode")) {
console.log("[FixedLayout Debug] ⏸️ Ignoring wheel - panel mode active");
return;
}
// Allow browser zoom with Ctrl/Cmd
if (event.ctrlKey || event.metaKey) {
console.log(
"[FixedLayout Debug] ⏸️ Ignoring wheel - browser zoom shortcut",
);
return;
}
event.preventDefault();
const delta = event.deltaY;
const rect = this.getBoundingClientRect();
// Current zoom level (default to fit-page if not set)
const oldScale =
this.#zoom === "fit-page" || this.#zoom === "fit-width"
? 1
: this.#zoom || 1;
// Calculate new scale
const zoomFactor =
delta > 0 ? 1 - this.#zoomState.zoomStep : 1 + this.#zoomState.zoomStep;
const newScale = Math.min(
this.#zoomState.maxScale,
Math.max(this.#zoomState.minScale, oldScale * zoomFactor),
);
// Zoom toward cursor position
const cursorX = event.clientX - rect.left;
const cursorY = event.clientY - rect.top;
const scaleChange = newScale / oldScale;
const newScrollX =
(this.scrollLeft + this.dragOffset.x + cursorX) * scaleChange -
cursorX -
this.dragOffset.x;
const newScrollY =
(this.scrollTop + this.dragOffset.y + cursorY) * scaleChange -
cursorY -
this.dragOffset.y;
// Apply zoom
console.log("[FixedLayout Debug] 🔍 Applying zoom:", {
oldScale,
newScale,
cursorX,
cursorY,
dragOffsetX: this.dragOffset.x,
dragOffsetY: this.dragOffset.y,
newScrollX,
newScrollY,
});
// Apply zoom
this.setAttribute("zoom", newScale);
this.scrollTo(newScrollX, newScrollY);
}
#handleMouseDown(event) {
console.log("[FixedLayout Debug] 🖱️ Mouse down:", {
button: event.button,
clientX: event.clientX,
clientY: event.clientY,
});
// Only left mouse button
if (event.button !== 0) {
console.log("[FixedLayout Debug] ⏸️ Ignoring - not left button");
return;
}
// Store initial state
this.#dragState.startX = event.clientX;
this.#dragState.startY = event.clientY;
this.#dragState.scrollLeft = this.scrollLeft;
this.#dragState.scrollTop = this.scrollTop;
this.#dragState.isPotentialDrag = true;
console.log(
"[FixedLayout Debug] ⏱️ Potential drag started, timeout set for",
this.#dragState.pressDelay,
"ms",
);
// Start timer to detect long press
this.#dragState.dragTimeout = setTimeout(() => {
// If we haven't moved much and haven't released yet, it's a drag
if (this.#dragState.isPotentialDrag) {
console.log("[FixedLayout Debug] ✋ DRAG MODE ACTIVATED (timeout)");
this.#dragState.isDragging = true;
this.dragOffset.x = 0;
this.dragOffset.y = 0;
this.style.cursor = "grabbing";
// Prevent text selection while dragging
event.preventDefault();
}
}, this.#dragState.pressDelay);
}
#handleMouseMove(event) {
// Check if this is the start of a drag (moved during potential drag)
if (this.#dragState.isPotentialDrag) {
const dx = event.clientX - this.#dragState.startX;
const dy = event.clientY - this.#dragState.startY;
const moved = Math.sqrt(dx * dx + dy * dy);
console.log("[FixedLayout Debug] 📐 Measuring movement:", {
dx,
dy,
moved: moved.toFixed(2),
threshold: 3,
});
// If moved more than 3 pixels, start dragging immediately
if (moved > 3) {
console.log(
"[FixedLayout Debug] ✋ DRAG MODE ACTIVATED (movement > 3px)",
);
clearTimeout(this.#dragState.dragTimeout);
this.#dragState.isPotentialDrag = false;
this.#dragState.isDragging = true;
this.dragOffset.x = 0;
this.dragOffset.y = 0;
this.style.cursor = "grabbing";
}
}
// Handle actual drag
if (this.#dragState.isDragging) {
event.preventDefault();
const dx = event.clientX - this.#dragState.startX;
const dy = event.clientY - this.#dragState.startY;
console.log("[FixedLayout Debug] 🎯 Dragging:", {
dx,
dy,
from: `(${this.dragOffset.x}, ${this.dragOffset.y})`,
to: `(${this.dragOffset.x - dx}, ${this.dragOffset.y - dy})`,
});
// Update drag offset instead of scrolling
this.dragOffset.x += dx;
this.dragOffset.y += dy;
// Re-render with new drag offset
const newScrollX = this.#dragState.scrollLeft - this.dragOffset.x;
const newScrollY = this.#dragState.scrollTop - this.dragOffset.y;
this.scrollLeft = newScrollX;
this.scrollTop = newScrollY;
}
}
#handleMouseUp(event) {
console.log("[FixedLayout Debug] 🖱️ Mouse up:", {
wasDragging: this.#dragState.isDragging,
wasPotentialDrag: this.#dragState.isPotentialDrag,
hasTimeout: !!this.#dragState.dragTimeout,
});
// Clear any pending drag timeout
if (this.#dragState.dragTimeout) {
console.log("[FixedLayout Debug] ⏱️ Clearing drag timeout");
clearTimeout(this.#dragState.dragTimeout);
this.#dragState.dragTimeout = null;
}
// Reset drag state
const wasDragging = this.#dragState.isDragging;
this.#dragState.isDragging = false;
this.#dragState.isPotentialDrag = false;
this.style.cursor = "";
this.dragOffset.x = 0;
this.dragOffset.y = 0;
// NEW: Reset drag offset when drag completes
if (wasDragging) {
console.log("[FixedLayout Debug] ✅ Drag completed");
this.dragOffset.x = 0;
this.dragOffset.y = 0;
this.#render(); // Apply the reset
}
}
async #createFrame({ index, src: srcOption }) {
const srcOptionIsString = typeof srcOption === "string";
const src = srcOptionIsString ? srcOption : srcOption?.src;
const onZoom = srcOptionIsString ? null : srcOption?.onZoom;
const element = document.createElement("div");
element.setAttribute("dir", "ltr");
const iframe = document.createElement("iframe");
element.append(iframe);
Object.assign(iframe.style, {
border: "0",
display: "none",
overflow: "hidden",
});
// `allow-scripts` is needed for events because of WebKit bug
// https://bugs.webkit.org/show_bug.cgi?id=218086
iframe.setAttribute("sandbox", "allow-same-origin allow-scripts");
iframe.setAttribute("scrolling", "no");
iframe.setAttribute("part", "filter");
this.#root.append(element);
if (!src) return { blank: true, element, iframe };
return new Promise((resolve) => {
iframe.addEventListener(
"load",
() => {
const doc = iframe.contentDocument;
this.dispatchEvent(
new CustomEvent("load", { detail: { doc, index } }),
);
const { width, height } = getViewport(doc, this.defaultViewport);
// NEW: Attach event listeners INSIDE the iframe document
this.#attachEventListenersToIframe(doc);
resolve({
element,
iframe,
width: parseFloat(width),
height: parseFloat(height),
onZoom,
});
},
{ once: true },
);
iframe.src = src;
});
}
#attachEventListenersToIframe(doc) {
if (!doc) {
console.log("[FixedLayout Debug] ⏸️ No document to attach listeners");
return;
}
console.log(
"[FixedLayout Debug] 🔧 Attaching event listeners to iframe document",
);
// Attach wheel event listener for zoom to the iframe's document
doc.addEventListener(
"wheel",
(event) => {
// Re-target to FixedLayout element so handler can access scroll position
const fixedLayoutEvent = new WheelEvent("wheel", {
deltaX: event.deltaX,
deltaY: event.deltaY,
deltaZ: event.deltaZ,
deltaMode: event.deltaMode,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
bubbles: true,
cancelable: true,
});
// Call our handler with the event
this.#handleWheel(fixedLayoutEvent);
// Prevent default in iframe
event.preventDefault();
event.stopPropagation();
},
{ passive: false },
);
console.log(
"[FixedLayout Debug] ✓ Wheel listener attached to iframe document",
);
// Attach mouse drag support for pan to the iframe's document
doc.addEventListener("mousedown", (event) => {
console.log("[FixedLayout Debug] 🖱️ Mouse down inside iframe:", {
button: event.button,
clientX: event.clientX,
clientY: event.clientY,
target: event.target.tagName,
});
// Re-target to FixedLayout element
const mouseEvent = new MouseEvent("mousedown", {
button: event.button,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
bubbles: true,
cancelable: true,
});
this.#handleMouseDown(mouseEvent);
});
doc.addEventListener("mousemove", (event) => {
const mouseEvent = new MouseEvent("mousemove", {
button: event.button,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
bubbles: true,
cancelable: true,
});
this.#handleMouseMove(mouseEvent);
});
doc.addEventListener("mouseup", (event) => {
const mouseEvent = new MouseEvent("mouseup", {
button: event.button,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
bubbles: true,
cancelable: true,
});
this.#handleMouseUp(mouseEvent);
});
console.log(
"[FixedLayout Debug] ✓ Mouse drag listeners attached to iframe document",
);
}
#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;
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",
marginBlock: "auto",
});
if (portrait && frame !== target) {
element.style.display = "none";
}
};
if (this.#center) {
transform(this.#center);
} else {
transform(left);
transform(right);
}
}
async #showSpread({ left, right, center, side }) {
this.#root.replaceChildren();
console.log("[FixedLayout Debug] 🧹 Old frame wrappers removed");
this.#left = null;
this.#right = null;
this.#center = null;
if (center) {
this.#center = await this.#createFrame(center);
this.#side = "center";
this.#render();
} else {
this.#left = await this.#createFrame(left);
this.#right = await this.#createFrame(right);
this.#side = this.#left.blank
? "right"
: this.#right.blank
? "left"
: side;
this.#render();
}
}
#goLeft() {
if (this.#center || this.#left?.blank) return;
if (this.#portrait && this.#left?.element?.style?.display === "none") {
this.#side = "left";
this.#render();
this.#reportLocation("page");
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;
}
}
open(book) {
this.book = book;
const { rendition } = book;
this.spread = rendition?.spread;
this.defaultViewport = rendition?.viewport;
const rtl = book.dir === "rtl";
const ltr = !rtl;
this.rtl = rtl;
if (rendition?.spread === "none")
this.#spreads = book.sections.map((section) => ({ center: section }));
else
this.#spreads = book.sections.reduce(
(arr, section, i) => {
const last = arr[arr.length - 1];
const { pageSpread } = section;
const newSpread = () => {
const spread = {};
arr.push(spread);
return spread;
};
if (pageSpread === "center") {
const spread = last.left || last.right ? newSpread() : last;
spread.center = section;
} else if (pageSpread === "left") {
const spread =
last.center || last.left || (ltr && i) ? newSpread() : last;
spread.left = section;
} else if (pageSpread === "right") {
const spread =
last.center || last.right || (rtl && i) ? newSpread() : last;
spread.right = section;
} else if (ltr) {
if (last.center || last.right) newSpread().left = section;
else if (last.left || !i) last.right = section;
else last.left = section;
} else {
if (last.center || last.left) newSpread().right = section;
else if (last.right || !i) last.left = section;
else last.right = section;
}
return arr;
},
[{}],
);
}
get index() {
const spread = this.#spreads[this.#index];
const section =
spread?.center ??
(this.#side === "left"
? (spread.left ?? spread.right)
: (spread.right ?? spread.left));
return this.book.sections.indexOf(section);
}
#reportLocation(reason) {
this.dispatchEvent(
new CustomEvent("relocate", {
detail: {
reason,
range: null,
index: this.index,
fraction: 0,
size: 1,
},
}),
);
}
getSpreadOf(section) {
const spreads = this.#spreads;
for (let index = 0; index < spreads.length; index++) {
const { left, right, center } = spreads[index];
if (left === section) return { index, side: "left" };
if (right === section) return { index, side: "right" };
if (center === section) return { index, side: "center" };
}
}
async goToSpread(index, side, reason) {
if (index < 0 || index > this.#spreads.length - 1) return;
if (index === this.#index) {
this.#render(side);
return;
}
this.#index = index;
const spread = this.#spreads[index];
if (spread.center) {
const index = this.book.sections.indexOf(spread.center);
const src = await spread.center?.load?.();
await this.#showSpread({ center: { index, src } });
} else {
const indexL = this.book.sections.indexOf(spread.left);
const indexR = this.book.sections.indexOf(spread.right);
const srcL = await spread.left?.load?.();
const srcR = await spread.right?.load?.();
const left = { index: indexL, src: srcL };
const right = { index: indexR, src: srcR };
await this.#showSpread({ left, right, side });
}
this.#reportLocation(reason);
}
async select(target) {
await this.goTo(target);
// TODO
}
async goTo(target) {
const { book } = this;
const resolved = await target;
const section = book.sections[resolved.index];
if (!section) return;
const { index, side } = this.getSpreadOf(section);
await this.goToSpread(index, side);
}
async next() {
const s = this.rtl ? this.#goLeft() : this.#goRight();
if (!s)
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,
// TODO: index, overlayer
}));
}
destroy() {
this.#observer.unobserve(this);
}
}
customElements.define("foliate-fxl", FixedLayout);