Refactor FixedLayout zoom/pan from scroll-based to transform-based model

Replace the overflow:auto + scrollTo zoom approach with a CSS transform
model to fix the scroll-to-zoom cursor targeting bug in dual-pane layouts.

Root cause: flex centering (justify-content:center) and overflow:auto
conflict -- when content fits, flex positions it; when zoomed (overflow),
scrollLeft=0 jumps to the left edge. The zoom-to-cursor formula used
scrollLeft as reference without accounting for the centering offset,
causing systematic drift toward the right pane.

Changes:
- Host CSS: overflow:hidden, display:block, position:relative
- New #wrapper div with transform:translate(x,y) for pan positioning
- New #transform = { x, y, scale } as single source of truth
- #zoomByRatio(cx,cy,ratio) uses panzoom formula: x = cx - ratio*(cx - x)
- #render() centers via transform.x/y = (hostSize - contentSize) / 2
- #updateFrameScales() handles iframe size/scale separately from centering
- Simplified drag: tracks startTX/startTY, updates transform directly
- Simplified attributeChangedCallback: detects external vs internal zoom
  changes and zooms from viewport center for external ones
- Remove all scrollLeft/scrollTop/scrollTo usage
- Remove marginBlock:auto, long-press drag detection, debug console.logs
- Preserve external API: zoom attribute, dragOffset, toggleMagnifier(),
  load/relocate events
This commit is contained in:
2026-04-17 21:33:41 -04:00
parent bd98d7e185
commit a369c54b14
+203 -328
View File
@@ -1,32 +1,23 @@
const parseViewport = (str) => const parseViewport = (str) =>
str str
?.split(/[,;\s]/) // NOTE: technically, only the comma is valid ?.split(/[,;\s]/)
?.filter((x) => x) ?.filter((x) => x)
?.map((x) => x.split("=").map((x) => x.trim())); ?.map((x) => x.split("=").map((x) => x.trim()));
const getViewport = (doc, viewport) => { const getViewport = (doc, viewport) => {
// use `viewBox` for SVG
if (doc.documentElement.localName === "svg") { if (doc.documentElement.localName === "svg") {
const [, , width, height] = const [, , width, height] =
doc.documentElement.getAttribute("viewBox")?.split(/\s/) ?? []; doc.documentElement.getAttribute("viewBox")?.split(/\s/) ?? [];
return { width, height }; return { width, height };
} }
// get `viewport` `meta` element
const meta = parseViewport( const meta = parseViewport(
doc.querySelector('meta[name="viewport"]')?.getAttribute("content"), doc.querySelector('meta[name="viewport"]')?.getAttribute("content"),
); );
if (meta) return Object.fromEntries(meta); if (meta) return Object.fromEntries(meta);
// fallback to book's viewport
if (typeof viewport === "string") return parseViewport(viewport); if (typeof viewport === "string") return parseViewport(viewport);
if (viewport?.width && viewport.height) return 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"); const img = doc.querySelector("img");
if (img) return { width: img.naturalWidth, height: img.naturalHeight }; if (img) return { width: img.naturalWidth, height: img.naturalHeight };
// just show *something*, i guess...
console.warn(new Error("Missing viewport properties")); console.warn(new Error("Missing viewport properties"));
return { width: 1000, height: 2000 }; return { width: 1000, height: 2000 };
}; };
@@ -45,6 +36,8 @@ export class FixedLayout extends HTMLElement {
#center; #center;
#side; #side;
#zoom; #zoom;
#wrapper;
#transform = { x: 0, y: 0, scale: 1 };
#zoomState = { #zoomState = {
minScale: 0.1, minScale: 0.1,
maxScale: 10, maxScale: 10,
@@ -52,14 +45,10 @@ export class FixedLayout extends HTMLElement {
}; };
#dragState = { #dragState = {
isDragging: false, isDragging: false,
isPotentialDrag: false,
dragTimeout: null,
pressDelay: 200, // milliseconds
startX: 0, startX: 0,
startY: 0, startY: 0,
scrollLeft: 0, startTX: 0,
scrollTop: 0, startTY: 0,
draggable: true,
}; };
dragOffset = { x: 0, y: 0 }; dragOffset = { x: 0, y: 0 };
#magnifier = { #magnifier = {
@@ -68,6 +57,7 @@ export class FixedLayout extends HTMLElement {
magnification: 2, magnification: 2,
element: null, element: null,
}; };
constructor() { constructor() {
super(); super();
@@ -76,54 +66,58 @@ export class FixedLayout extends HTMLElement {
sheet.replaceSync(`:host { sheet.replaceSync(`:host {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: block;
justify-content: center; overflow: hidden;
align-items: center; position: relative;
overflow: auto;
}`); }`);
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); 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) { attributeChangedCallback(name, _, value) {
switch (name) { switch (name) {
case "zoom": case "zoom": {
this.#zoom = const newZoom =
value !== "fit-width" && value !== "fit-page" value !== "fit-width" && value !== "fit-page"
? parseFloat(value) ? parseFloat(value)
: value; : value;
this.#render();
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; break;
}
} }
} }
toggleMagnifier() {
console.log("[FixedLayout Debug] 🔍 Toggle magnifier:", {
current: this.#magnifier.enabled,
willBe: !this.#magnifier.enabled,
});
get zoom() {
return this.#zoom;
}
toggleMagnifier() {
this.#magnifier.enabled = !this.#magnifier.enabled; this.#magnifier.enabled = !this.#magnifier.enabled;
if (this.#magnifier.enabled) { if (this.#magnifier.enabled) {
this.#createMagnifier(); this.#createMagnifier();
this.addEventListener("mousemove", this.#handleMagnifierMove.bind(this)); this.addEventListener("mousemove", this.#handleMagnifierMove.bind(this));
this.style.cursor = "crosshair"; this.style.cursor = "crosshair";
console.log("[FixedLayout Debug] ✅ Magnifier enabled");
} else { } else {
this.#destroyMagnifier(); this.#destroyMagnifier();
this.removeEventListener( this.removeEventListener(
@@ -131,16 +125,11 @@ export class FixedLayout extends HTMLElement {
this.#handleMagnifierMove.bind(this), this.#handleMagnifierMove.bind(this),
); );
this.style.cursor = ""; this.style.cursor = "";
console.log("[FixedLayout Debug] ❌ Magnifier disabled");
} }
} }
#createMagnifier() { #createMagnifier() {
if (this.#magnifier.element) { if (this.#magnifier.element) return;
console.log("[FixedLayout Debug] ⏸️ Magnifier already exists");
return;
}
console.log("[FixedLayout Debug] 🔧 Creating magnifier element");
const magnifier = document.createElement("div"); const magnifier = document.createElement("div");
magnifier.className = "magnifier"; magnifier.className = "magnifier";
@@ -170,14 +159,12 @@ export class FixedLayout extends HTMLElement {
magnifier.appendChild(lens); magnifier.appendChild(lens);
this.#root.appendChild(magnifier); this.#root.appendChild(magnifier);
this.#magnifier.element = magnifier; this.#magnifier.element = magnifier;
console.log("[FixedLayout Debug] ✅ Magnifier element created");
} }
#destroyMagnifier() { #destroyMagnifier() {
if (this.#magnifier.element) { if (this.#magnifier.element) {
this.#magnifier.element.remove(); this.#magnifier.element.remove();
this.#magnifier.element = null; this.#magnifier.element = null;
console.log("[FixedLayout Debug] ✅ Magnifier destroyed");
} }
} }
@@ -188,239 +175,165 @@ export class FixedLayout extends HTMLElement {
const x = event.clientX - rect.left; const x = event.clientX - rect.left;
const y = event.clientY - rect.top; const y = event.clientY - rect.top;
// Position magnifier
const magnifier = this.#magnifier.element; const magnifier = this.#magnifier.element;
magnifier.style.display = "block"; magnifier.style.display = "block";
magnifier.style.left = `${x - this.#magnifier.size / 2}px`; magnifier.style.left = `${x - this.#magnifier.size / 2}px`;
magnifier.style.top = `${y - 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 lens = magnifier.querySelector(".magnifier-lens");
const frame = this.#center || this.#left || this.#right; const frame = this.#center || this.#left || this.#right;
if (frame?.iframe) { if (frame?.iframe) {
const iframe = frame.iframe; const iframe = frame.iframe;
lens.innerHTML = ""; lens.innerHTML = "";
lens.appendChild(iframe.cloneNode(true)); lens.appendChild(iframe.cloneNode(true));
const scale = this.#transform.scale || 1;
lens.style.transform = `scale(${this.#magnifier.magnification})`; lens.style.transform = `scale(${this.#magnifier.magnification})`;
const iframeRect = iframe.getBoundingClientRect(); const iframeRect = iframe.getBoundingClientRect();
const relX = (x - iframeRect.left) / (this.#zoom || 1); const relX = (x - iframeRect.left) / scale;
const relY = (y - iframeRect.top) / (this.#zoom || 1); const relY = (y - iframeRect.top) / scale;
lens.style.transformOrigin = `${-relX + this.#magnifier.size / 2}px ${-relY + this.#magnifier.size / 2}px`; lens.style.transformOrigin = `${-relX + this.#magnifier.size / 2}px ${-relY + this.#magnifier.size / 2}px`;
} }
} }
#handleWheel(event) {
console.log("[FixedLayout Debug] 🖱️ Wheel event:", { #applyTransform() {
deltaY: event.deltaY, this.#wrapper.style.transform = `translate(${this.#transform.x}px, ${this.#transform.y}px)`;
ctrlKey: event.ctrlKey, }
metaKey: event.metaKey,
panelMode: this.hasAttribute("panel-mode"), #updateFrameScales(scale) {
currentZoom: this.#zoom, const left = this.#left ?? {};
}); const right = this.#center ?? this.#right ?? {};
// Don't interfere with panel mode const { width: hostWidth, height: hostHeight } = this.getBoundingClientRect();
if (this.hasAttribute("panel-mode")) { const portrait =
console.log("[FixedLayout Debug] ⏸️ Ignoring wheel - panel mode active"); this.spread !== "both" && this.spread !== "portrait" && hostHeight > hostWidth;
return; 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);
} }
}
// Allow browser zoom with Ctrl/Cmd #getContentSize() {
if (event.ctrlKey || event.metaKey) { const left = this.#left ?? {};
console.log( const right = this.#center ?? this.#right ?? {};
"[FixedLayout Debug] ⏸️ Ignoring wheel - browser zoom shortcut", const { width: hostWidth, height: hostHeight } = this.getBoundingClientRect();
); const portrait =
return; 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);
} }
event.preventDefault(); return { contentWidth, contentHeight };
}
const delta = event.deltaY; #zoomByRatio(cx, cy, ratio) {
const rect = this.getBoundingClientRect(); const oldScale = this.#transform.scale;
// DETERMINE WHICH IFRAME SENT THE EVENT
const source = event.sourceIframe || "right"; // Default to right
const leftWidth = this.#left?.element?.offsetWidth || 0;
// ADJUST CURSOR POSITION BASED ON SOURCE IFRAME
const rawCursorX = event.clientX - rect.left;
const rawCursorY = event.clientY - rect.top;
// For both pages, calculate cursor position relative to the spread's viewport
// The cursor position is already relative to the container, not individual pages
const cursorX = rawCursorX;
const cursorY = rawCursorY;
// Current zoom level (default to fit-page if not set)
const oldScale =
this.#zoom === "fit-page" || this.#zoom === "fit-width"
? 1
: this.#zoom || 1;
console.log("[FixedLayout Debug] 📍 Cursor calculation:", {
source,
leftWidth,
rawCursorX,
cursorX,
rectLeft: rect.left,
clientX: event.clientX,
});
// Calculate new scale
const zoomFactor =
delta > 0 ? 1 - this.#zoomState.zoomStep : 1 + this.#zoomState.zoomStep;
const newScale = Math.min( const newScale = Math.min(
this.#zoomState.maxScale, this.#zoomState.maxScale,
Math.max(this.#zoomState.minScale, oldScale * zoomFactor), Math.max(this.#zoomState.minScale, oldScale * ratio),
); );
const actualRatio = newScale / oldScale;
const scaleChange = 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;
this.#updateFrameScales(newScale);
this.#applyTransform();
// Calculate new scroll position (same calculation for both pages)
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,
scrollLeft: this.scrollLeft,
scrollTop: this.scrollTop,
newScrollX,
newScrollY,
source,
leftWidth,
});
// Apply zoom
this.setAttribute("zoom", newScale); this.setAttribute("zoom", newScale);
this.scrollTo(newScrollX, newScrollY); }
#handleWheel(event) {
if (this.hasAttribute("panel-mode")) return;
if (event.ctrlKey || event.metaKey) return;
event.preventDefault();
const rect = this.getBoundingClientRect();
const cx = event.clientX - rect.left;
const cy = event.clientY - rect.top;
const ratio =
event.deltaY > 0
? 1 - this.#zoomState.zoomStep
: 1 + this.#zoomState.zoomStep;
this.#zoomByRatio(cx, cy, ratio);
} }
#handleMouseDown(event) { #handleMouseDown(event) {
console.log("[FixedLayout Debug] 🖱️ Mouse down:", { if (event.button !== 0) return;
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.startX = event.clientX;
this.#dragState.startY = event.clientY; this.#dragState.startY = event.clientY;
this.#dragState.scrollLeft = this.scrollLeft; this.#dragState.startTX = this.#transform.x;
this.#dragState.scrollTop = this.scrollTop; this.#dragState.startTY = this.#transform.y;
this.#dragState.isPotentialDrag = true; this.#dragState.isDragging = true;
console.log( this.style.cursor = "grabbing";
"[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.#dragState.isPotentialDrag = false;
this.dragOffset.x = 0;
this.dragOffset.y = 0;
this.style.cursor = "grabbing";
}
}, this.#dragState.pressDelay);
} }
#handleMouseMove(event) { #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);
// If moved more than 3 pixels, start dragging immediately
if (moved > 3) {
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) return; if (!this.#dragState.isDragging) return;
const dx = event.clientX - this.#dragState.startX; const dx = event.clientX - this.#dragState.startX;
const dy = event.clientY - this.#dragState.startY; const dy = event.clientY - this.#dragState.startY;
if (this.#zoom === "fit-page") {
// Track how far user has dragged
this.dragOffset.x -= dx;
this.dragOffset.y -= dy;
this.#render();
return;
}
if (
this.#zoom !== "fit-page" &&
this.#zoom !== "fit-width" &&
this.#zoom !== 1 &&
this.#dragState.draggable
) {
// Calculate new scroll position directly from mouse movement
const newScrollX = this.#dragState.scrollLeft - dx;
const newScrollY = this.#dragState.scrollTop - dy;
// Apply scroll (browser will automatically clamp to valid range) this.#transform.x = this.#dragState.startTX + dx;
this.scrollLeft = newScrollX; this.#transform.y = this.#dragState.startTY + dy;
this.scrollTop = newScrollY; this.#applyTransform();
} else {
const reason = !this.#dragState.draggable
? "draggable is false"
: this.#zoom === "fit-page"
? "zoom is fit-page"
: this.#zoom === "fit-width"
? "zoom is fit-width"
: this.#zoom === 1
? "zoom is 1"
: "unknown";
console.log(`[FixedLayout Debug] ⛔ Drag ignored - ${reason}`);
}
} }
#handleMouseUp(event) { #handleMouseUp(event) {
console.log("[FixedLayout Debug] 🖱️ Mouse up:", { if (!this.#dragState.isDragging) return;
wasDragging: this.#dragState.isDragging,
wasPotentialDrag: this.#dragState.isPotentialDrag,
hasTimeout: !!this.#dragState.dragTimeout,
isDraggingBeforeReset: this.#dragState.isDragging, // ADD THIS
});
// 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.isDragging = false;
this.#dragState.isPotentialDrag = false;
this.style.cursor = ""; 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 }, frameId) { async #createFrame({ index, src: srcOption }, frameId) {
@@ -436,12 +349,10 @@ export class FixedLayout extends HTMLElement {
display: "none", display: "none",
overflow: "hidden", 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("sandbox", "allow-same-origin allow-scripts");
iframe.setAttribute("scrolling", "no"); iframe.setAttribute("scrolling", "no");
iframe.setAttribute("part", "filter"); iframe.setAttribute("part", "filter");
this.#root.append(element); this.#wrapper.append(element);
if (!src) return { blank: true, element, iframe, frameId }; if (!src) return { blank: true, element, iframe, frameId };
return new Promise((resolve) => { return new Promise((resolve) => {
iframe.addEventListener( iframe.addEventListener(
@@ -453,7 +364,6 @@ export class FixedLayout extends HTMLElement {
); );
const { width, height } = getViewport(doc, this.defaultViewport); const { width, height } = getViewport(doc, this.defaultViewport);
// NEW: Attach event listeners INSIDE the iframe document
this.#attachEventListenersToIframe(doc, frameId, { element, iframe }); this.#attachEventListenersToIframe(doc, frameId, { element, iframe });
resolve({ resolve({
@@ -470,15 +380,19 @@ export class FixedLayout extends HTMLElement {
iframe.src = src; iframe.src = src;
}); });
} }
#attachEventListenersToIframe(doc, frameId, frame) {
if (!doc) {
console.log("[FixedLayout Debug] ⏸️ No document to attach listeners");
return;
}
console.log( #attachEventListenersToIframe(doc, frameId, frame) {
"[FixedLayout Debug] 🔧 Attaching event listeners to iframe document", const convertCoords = (e) => {
); const iframeRect = frame.iframe.getBoundingClientRect();
const flRect = this.getBoundingClientRect();
const scaleX = iframeRect.width / (doc.documentElement.clientWidth || 1);
const scaleY = iframeRect.height / (doc.documentElement.clientHeight || 1);
return {
clientX: iframeRect.left - flRect.left + e.clientX * scaleX,
clientY: iframeRect.top - flRect.top + e.clientY * scaleY,
};
};
if (!doc) return;
const images = doc.querySelectorAll("img"); const images = doc.querySelectorAll("img");
images.forEach((img) => { images.forEach((img) => {
@@ -487,22 +401,18 @@ export class FixedLayout extends HTMLElement {
img.style.webkitUserDrag = "none"; img.style.webkitUserDrag = "none";
img.style.WebkitUserDrag = "none"; img.style.WebkitUserDrag = "none";
}); });
console.log(
`[FixedLayout Debug] 🔒 Disabled native drag on ${images.length} images`,
);
// Attach wheel event listener for zoom to the iframe's document
doc.addEventListener( doc.addEventListener(
"wheel", "wheel",
(event) => { (event) => {
// Re-target to FixedLayout element so handler can access scroll position const { clientX, clientY } = convertCoords(event);
const fixedLayoutEvent = new WheelEvent("wheel", { const fixedLayoutEvent = new WheelEvent("wheel", {
deltaX: event.deltaX, deltaX: event.deltaX,
deltaY: event.deltaY, deltaY: event.deltaY,
deltaZ: event.deltaZ, deltaZ: event.deltaZ,
deltaMode: event.deltaMode, deltaMode: event.deltaMode,
clientX: event.clientX, clientX,
clientY: event.clientY, clientY,
ctrlKey: event.ctrlKey, ctrlKey: event.ctrlKey,
metaKey: event.metaKey, metaKey: event.metaKey,
shiftKey: event.shiftKey, shiftKey: event.shiftKey,
@@ -510,39 +420,21 @@ export class FixedLayout extends HTMLElement {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
}); });
// TAG EVENT WITH SOURCE IFRAME
fixedLayoutEvent.sourceIframe = frameId; fixedLayoutEvent.sourceIframe = frameId;
fixedLayoutEvent.sourceFrame = frame; fixedLayoutEvent.sourceFrame = frame;
// Call our handler with the event
this.#handleWheel(fixedLayoutEvent); this.#handleWheel(fixedLayoutEvent);
// Prevent default in iframe
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}, },
{ passive: false }, { 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) => { doc.addEventListener("mousedown", (event) => {
console.log("[FixedLayout Debug] 🖱️ Mouse down inside iframe:", { const { clientX, clientY } = convertCoords(event);
button: event.button,
clientX: event.clientX,
clientY: event.clientY,
target: event.target.tagName,
});
// Re-target to FixedLayout element
const mouseEvent = new MouseEvent("mousedown", { const mouseEvent = new MouseEvent("mousedown", {
button: event.button, button: event.button,
clientX: event.clientX, clientX,
clientY: event.clientY, clientY,
ctrlKey: event.ctrlKey, ctrlKey: event.ctrlKey,
metaKey: event.metaKey, metaKey: event.metaKey,
shiftKey: event.shiftKey, shiftKey: event.shiftKey,
@@ -550,8 +442,6 @@ export class FixedLayout extends HTMLElement {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
}); });
// TAG EVENT WITH SOURCE IFRAME
mouseEvent.sourceIframe = frameId; mouseEvent.sourceIframe = frameId;
mouseEvent.sourceFrame = frame; mouseEvent.sourceFrame = frame;
this.#handleMouseDown(mouseEvent); this.#handleMouseDown(mouseEvent);
@@ -559,10 +449,11 @@ export class FixedLayout extends HTMLElement {
}); });
doc.addEventListener("mousemove", (event) => { doc.addEventListener("mousemove", (event) => {
const { clientX, clientY } = convertCoords(event);
const mouseEvent = new MouseEvent("mousemove", { const mouseEvent = new MouseEvent("mousemove", {
button: event.button, button: event.button,
clientX: event.clientX, clientX,
clientY: event.clientY, clientY,
ctrlKey: event.ctrlKey, ctrlKey: event.ctrlKey,
metaKey: event.metaKey, metaKey: event.metaKey,
shiftKey: event.shiftKey, shiftKey: event.shiftKey,
@@ -570,8 +461,6 @@ export class FixedLayout extends HTMLElement {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
}); });
// TAG EVENT WITH SOURCE IFRAME
mouseEvent.sourceIframe = frameId; mouseEvent.sourceIframe = frameId;
mouseEvent.sourceFrame = frame; mouseEvent.sourceFrame = frame;
this.#handleMouseMove(mouseEvent); this.#handleMouseMove(mouseEvent);
@@ -579,10 +468,11 @@ export class FixedLayout extends HTMLElement {
}); });
doc.addEventListener("mouseup", (event) => { doc.addEventListener("mouseup", (event) => {
const { clientX, clientY } = convertCoords(event);
const mouseEvent = new MouseEvent("mouseup", { const mouseEvent = new MouseEvent("mouseup", {
button: event.button, button: event.button,
clientX: event.clientX, clientX,
clientY: event.clientY, clientY,
ctrlKey: event.ctrlKey, ctrlKey: event.ctrlKey,
metaKey: event.metaKey, metaKey: event.metaKey,
shiftKey: event.shiftKey, shiftKey: event.shiftKey,
@@ -590,18 +480,13 @@ export class FixedLayout extends HTMLElement {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
}); });
// TAG EVENT WITH SOURCE IFRAME
mouseEvent.sourceIframe = frameId; mouseEvent.sourceIframe = frameId;
mouseEvent.sourceFrame = frame; mouseEvent.sourceFrame = frame;
this.#handleMouseUp(mouseEvent); this.#handleMouseUp(mouseEvent);
event.preventDefault(); event.preventDefault();
}); });
console.log(
"[FixedLayout Debug] ✓ Mouse drag listeners attached to iframe document",
);
} }
#render(side = this.#side) { #render(side = this.#side) {
if (!side) return; if (!side) return;
const left = this.#left ?? {}; const left = this.#left ?? {};
@@ -637,40 +522,19 @@ export class FixedLayout extends HTMLElement {
), ),
)) || 1; )) || 1;
const transform = (frame) => { this.#transform.scale = scale;
let { element, iframe, width, height, blank, onZoom } = frame;
if (!iframe) return; this.#updateFrameScales(scale);
if (onZoom) onZoom({ doc: frame.iframe.contentDocument, scale });
const iframeScale = onZoom ? scale : 1; const { contentWidth, contentHeight } = this.#getContentSize();
Object.assign(iframe.style, { this.#transform.x = (width - contentWidth) / 2;
width: `${width * iframeScale}px`, this.#transform.y = (height - contentHeight) / 2;
height: `${height * iframeScale}px`,
transform: onZoom ? "none" : `scale(${scale})`, this.#applyTransform();
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 }) { async #showSpread({ left, right, center, side }) {
this.#root.replaceChildren(); this.#wrapper.replaceChildren();
console.log("[FixedLayout Debug] 🧹 Old frame wrappers removed");
this.#left = null; this.#left = null;
this.#right = null; this.#right = null;
this.#center = null; this.#center = null;
@@ -689,6 +553,7 @@ export class FixedLayout extends HTMLElement {
this.#render(); this.#render();
} }
} }
#goLeft() { #goLeft() {
if (this.#center || this.#left?.blank) return; if (this.#center || this.#left?.blank) return;
if (this.#portrait && this.#left?.element?.style?.display === "none") { if (this.#portrait && this.#left?.element?.style?.display === "none") {
@@ -698,6 +563,7 @@ export class FixedLayout extends HTMLElement {
return true; return true;
} }
} }
#goRight() { #goRight() {
if (this.#center || this.#right?.blank) return; if (this.#center || this.#right?.blank) return;
if (this.#portrait && this.#right?.element?.style?.display === "none") { if (this.#portrait && this.#right?.element?.style?.display === "none") {
@@ -707,6 +573,7 @@ export class FixedLayout extends HTMLElement {
return true; return true;
} }
} }
open(book) { open(book) {
this.book = book; this.book = book;
const { rendition } = book; const { rendition } = book;
@@ -754,6 +621,7 @@ export class FixedLayout extends HTMLElement {
[{}], [{}],
); );
} }
get index() { get index() {
const spread = this.#spreads[this.#index]; const spread = this.#spreads[this.#index];
const section = const section =
@@ -763,6 +631,7 @@ export class FixedLayout extends HTMLElement {
: (spread.right ?? spread.left)); : (spread.right ?? spread.left));
return this.book.sections.indexOf(section); return this.book.sections.indexOf(section);
} }
#reportLocation(reason) { #reportLocation(reason) {
this.dispatchEvent( this.dispatchEvent(
new CustomEvent("relocate", { new CustomEvent("relocate", {
@@ -776,6 +645,7 @@ export class FixedLayout extends HTMLElement {
}), }),
); );
} }
getSpreadOf(section) { getSpreadOf(section) {
const spreads = this.#spreads; const spreads = this.#spreads;
for (let index = 0; index < spreads.length; index++) { for (let index = 0; index < spreads.length; index++) {
@@ -785,6 +655,7 @@ export class FixedLayout extends HTMLElement {
if (center === section) return { index, side: "center" }; if (center === section) return { index, side: "center" };
} }
} }
async goToSpread(index, side, reason) { async goToSpread(index, side, reason) {
if (index < 0 || index > this.#spreads.length - 1) return; if (index < 0 || index > this.#spreads.length - 1) return;
if (index === this.#index) { if (index === this.#index) {
@@ -808,10 +679,11 @@ export class FixedLayout extends HTMLElement {
} }
this.#reportLocation(reason); this.#reportLocation(reason);
} }
async select(target) { async select(target) {
await this.goTo(target); await this.goTo(target);
// TODO
} }
async goTo(target) { async goTo(target) {
const { book } = this; const { book } = this;
const resolved = await target; const resolved = await target;
@@ -820,6 +692,7 @@ export class FixedLayout extends HTMLElement {
const { index, side } = this.getSpreadOf(section); const { index, side } = this.getSpreadOf(section);
await this.goToSpread(index, side); await this.goToSpread(index, side);
} }
async next() { async next() {
const s = this.rtl ? this.#goLeft() : this.#goRight(); const s = this.rtl ? this.#goLeft() : this.#goRight();
if (!s) if (!s)
@@ -829,6 +702,7 @@ export class FixedLayout extends HTMLElement {
"page", "page",
); );
} }
async prev() { async prev() {
const s = this.rtl ? this.#goRight() : this.#goLeft(); const s = this.rtl ? this.#goRight() : this.#goLeft();
if (!s) if (!s)
@@ -838,12 +712,13 @@ export class FixedLayout extends HTMLElement {
"page", "page",
); );
} }
getContents() { getContents() {
return Array.from(this.#root.querySelectorAll("iframe"), (frame) => ({ return Array.from(this.#root.querySelectorAll("iframe"), (frame) => ({
doc: frame.contentDocument, doc: frame.contentDocument,
// TODO: index, overlayer
})); }));
} }
destroy() { destroy() {
this.#observer.unobserve(this); this.#observer.unobserve(this);
} }