Add magnifier, interaction mode toggle, and zoom percentage display

Magnifier (fixed-layout.js):
- Rewrite magnifier to use CSS background-image for comics (blob URLs
  from iframe img elements) and canvas drawImage for PDFs. Replaces
  broken iframe.cloneNode approach that created empty sandboxed clones.
- Detect which iframe is under the cursor by checking bounding rects,
  so magnifier works on both left and right panes.
- Forward mousemove events from iframe documents to handleMagnifierMove
  so the magnifier follows the cursor inside iframes.
- Add background-repeat: no-repeat to lens styles.
- Add zoomMagnifierEnabled getter for external state checks.

Interaction mode toggle (PDF only):
- Add 'interaction-mode' observed attribute with 'select'/'pan' values.
- In select mode (default for PDFs): mouse events pass through for text
  selection; Shift+drag enables panning as a fallback.
- In pan mode: drag-to-pan works as normal.
- Comic books always use pan mode (no text to select).
- Auto-detect PDF via #isPDF flag set in #showSpread from onZoom presence.
- Add isPDF getter so reader.js can show/hide the toggle button.
- Add toggle button in reader.html, hidden by default, shown for PDFs
  via the view 'load' event.
- Escape key dismisses the magnifier.

Zoom percentage display (reader.js):
- Dispatch 'zoom' CustomEvent from #zoomByRatio with the new scale.
- Listener in reader.js updates the percentage display on scroll zoom.

Also includes minor formatting (line wrapping) in #goLeft/#goRight.
This commit is contained in:
2026-04-18 00:39:15 -04:00
parent 3173cf038d
commit d5b2e3deda
3 changed files with 171 additions and 19 deletions
+133 -19
View File
@@ -23,7 +23,7 @@ const getViewport = (doc, viewport) => {
}; };
export class FixedLayout extends HTMLElement { export class FixedLayout extends HTMLElement {
static observedAttributes = ["zoom"]; static observedAttributes = ["zoom", "interaction-mode"];
#root = this.attachShadow({ mode: "closed" }); #root = this.attachShadow({ mode: "closed" });
#observer = new ResizeObserver(() => this.#render()); #observer = new ResizeObserver(() => this.#render());
#spreads; #spreads;
@@ -36,6 +36,8 @@ export class FixedLayout extends HTMLElement {
#center; #center;
#side; #side;
#zoom; #zoom;
#isPDF;
#interactionMode = "select";
#wrapper; #wrapper;
#transform = { x: 0, y: 0, scale: 1 }; #transform = { x: 0, y: 0, scale: 1 };
#zoomState = { #zoomState = {
@@ -109,6 +111,12 @@ export class FixedLayout extends HTMLElement {
} }
break; break;
} }
case "interaction-mode": {
if (value === "pan" || value === "select") {
this.#interactionMode = value;
}
break;
}
} }
} }
@@ -116,6 +124,14 @@ export class FixedLayout extends HTMLElement {
return this.#zoom; return this.#zoom;
} }
get isPDF() {
return this.#isPDF;
}
get zoomMagnifierEnabled() {
return this.#magnifier.enabled;
}
toggleMagnifier() { toggleMagnifier() {
this.#magnifier.enabled = !this.#magnifier.enabled; this.#magnifier.enabled = !this.#magnifier.enabled;
@@ -159,6 +175,7 @@ export class FixedLayout extends HTMLElement {
height: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
background-repeat: no-repeat;
`; `;
magnifier.appendChild(lens); magnifier.appendChild(lens);
@@ -175,32 +192,110 @@ export class FixedLayout extends HTMLElement {
#handleMagnifierMove(event) { #handleMagnifierMove(event) {
if (!this.#magnifier.enabled) return; if (!this.#magnifier.enabled) return;
const rect = this.getBoundingClientRect(); const rect = this.getBoundingClientRect();
const x = event.clientX - rect.left; const x = event.clientX - rect.left;
const y = event.clientY - rect.top; const y = event.clientY - rect.top;
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`;
const lens = magnifier.querySelector(".magnifier-lens"); const lens = magnifier.querySelector(".magnifier-lens");
const frame = this.#center || this.#left || this.#right; const candidates = this.#center
if (frame?.iframe) { ? [this.#center]
const iframe = frame.iframe; : [this.#left, this.#right].filter((f) => f && !f.blank);
lens.innerHTML = ""; let frame = null;
lens.appendChild(iframe.cloneNode(true)); for (const f of candidates) {
const scale = this.#transform.scale || 1; if (!f?.iframe) continue;
lens.style.transform = `scale(${this.#magnifier.magnification})`; const fRect = f.iframe.getBoundingClientRect();
if (
const iframeRect = iframe.getBoundingClientRect(); event.clientX >= fRect.left &&
const relX = (x - iframeRect.left) / scale; event.clientX <= fRect.right &&
const relY = (y - iframeRect.top) / scale; event.clientY >= fRect.top &&
event.clientY <= fRect.bottom
lens.style.transformOrigin = `${-relX + this.#magnifier.size / 2}px ${-relY + this.#magnifier.size / 2}px`; ) {
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() { #applyTransform() {
this.#wrapper.style.transform = `translate(${this.#transform.x}px, ${this.#transform.y}px)`; this.#wrapper.style.transform = `translate(${this.#transform.x}px, ${this.#transform.y}px)`;
@@ -300,6 +395,10 @@ export class FixedLayout extends HTMLElement {
this.#applyTransform(); this.#applyTransform();
this.setAttribute("zoom", newScale); this.setAttribute("zoom", newScale);
this.dispatchEvent(
new CustomEvent("zoom", { detail: { scale: newScale } }),
);
} }
#handleWheel(event) { #handleWheel(event) {
@@ -321,6 +420,8 @@ export class FixedLayout extends HTMLElement {
#handleMouseDown(event) { #handleMouseDown(event) {
if (event.button !== 0) return; if (event.button !== 0) return;
if (this.#isPDF && this.#interactionMode === "select" && !event.shiftKey)
return;
this.#dragState.startX = event.clientX; this.#dragState.startX = event.clientX;
this.#dragState.startY = event.clientY; this.#dragState.startY = event.clientY;
@@ -470,6 +571,9 @@ export class FixedLayout extends HTMLElement {
doc.addEventListener("mousemove", (event) => { doc.addEventListener("mousemove", (event) => {
const { clientX, clientY } = convertCoords(event); const { clientX, clientY } = convertCoords(event);
if (this.#magnifier.enabled) {
this.#handleMagnifierMove({ clientX, clientY });
}
const mouseEvent = new MouseEvent("mousemove", { const mouseEvent = new MouseEvent("mousemove", {
button: event.button, button: event.button,
clientX, clientX,
@@ -583,6 +687,7 @@ export class FixedLayout extends HTMLElement {
if (center) { if (center) {
this.#center = await this.#createFrame(center, "center"); this.#center = await this.#createFrame(center, "center");
this.#side = "center"; this.#side = "center";
this.#isPDF = !!this.#center?.onZoom;
this.#render(); this.#render();
} else { } else {
this.#left = await this.#createFrame(left, "left"); this.#left = await this.#createFrame(left, "left");
@@ -592,6 +697,7 @@ export class FixedLayout extends HTMLElement {
: this.#right.blank : this.#right.blank
? "left" ? "left"
: side; : side;
this.#isPDF = !!(this.#left?.onZoom || this.#right?.onZoom);
this.#render(); this.#render();
} }
} }
@@ -606,7 +712,11 @@ export class FixedLayout extends HTMLElement {
} }
// zoomed-in dual-pane - pan to left frame // zoomed-in dual-pane - pan to left frame
if (typeof this.#zoom === "number" && !isNaN(this.#zoom) && !this.#right?.blank) { if (
typeof this.#zoom === "number" &&
!isNaN(this.#zoom) &&
!this.#right?.blank
) {
if (this.#side === "right") { if (this.#side === "right") {
this.#side = "left"; this.#side = "left";
this.#render(); this.#render();
@@ -625,7 +735,11 @@ export class FixedLayout extends HTMLElement {
} }
// zoomed-in dual-pane - pan to right frame // zoomed-in dual-pane - pan to right frame
if (typeof this.#zoom === "number" && !isNaN(this.#zoom) && !this.#left?.blank) { if (
typeof this.#zoom === "number" &&
!isNaN(this.#zoom) &&
!this.#left?.blank
) {
if (this.#side === "left") { if (this.#side === "left") {
this.#side = "right"; this.#side = "right";
this.#render(); this.#render();
+10
View File
@@ -365,6 +365,16 @@
<path d="M 14 14 L 18 18" /> <path d="M 14 14 L 18 18" />
</svg> </svg>
</button> </button>
<button
id="interaction-mode-toggle"
aria-label="Toggle pan/select mode"
title="Pan Mode"
style="display: none"
>
<svg class="icon" width="20" height="20" aria-hidden="true">
<path d="M 5 9 h 10 M 10 5 v 10" />
</svg>
</button>
</div> </div>
</div> </div>
<script src="reader.js" type="module"></script> <script src="reader.js" type="module"></script>
+28
View File
@@ -221,6 +221,9 @@ class Reader {
$("#magnifier-toggle").addEventListener("click", () => { $("#magnifier-toggle").addEventListener("click", () => {
console.log("[Reader Debug] 🔍 Magnifier toggle button clicked"); console.log("[Reader Debug] 🔍 Magnifier toggle button clicked");
const renderer = this.view.renderer; const renderer = this.view.renderer;
renderer.addEventListener("zoom", (e) => {
$("#zoom-reset").textContent = `${Math.round(e.detail.scale * 100)}%`;
});
console.log( console.log(
"[Reader Debug] Is FixedLayout?", "[Reader Debug] Is FixedLayout?",
renderer instanceof FixedLayout, renderer instanceof FixedLayout,
@@ -232,6 +235,26 @@ class Reader {
console.log("[Reader Debug] ❌ Not a FixedLayout renderer, ignoring"); console.log("[Reader Debug] ❌ Not a FixedLayout renderer, ignoring");
} }
}); });
const modeToggle = $("#interaction-mode-toggle");
modeToggle.addEventListener("click", () => {
const renderer = this.view.renderer;
if (!(renderer instanceof FixedLayout)) return;
const current = renderer.getAttribute("interaction-mode") || "select";
const next = current === "select" ? "pan" : "select";
renderer.setAttribute("interaction-mode", next);
modeToggle.title = next === "select" ? "Select Mode" : "Pan Mode";
const svg = modeToggle.querySelector("svg");
svg.innerHTML =
next === "select"
? '<path d="M 5 3 v 12 M 5 15 l -2 2 M 5 15 l 2 2 M 5 3 l 3 3" />'
: '<path d="M 5 9 h 10 M 10 5 v 10" />';
});
this.view.addEventListener("load", () => {
const renderer = this.view.renderer;
if (renderer instanceof FixedLayout && renderer.isPDF) {
modeToggle.style.display = "";
}
});
console.log("[Reader Debug] ✅ Zoom control listeners configured"); console.log("[Reader Debug] ✅ Zoom control listeners configured");
const title = formatLanguageMap(book.metadata?.title) || "Untitled Book"; const title = formatLanguageMap(book.metadata?.title) || "Untitled Book";
@@ -328,6 +351,11 @@ class Reader {
$("#zoom-reset").textContent = "100%"; $("#zoom-reset").textContent = "100%";
console.log("[Reader Debug] ✅ Keyboard zoom reset"); console.log("[Reader Debug] ✅ Keyboard zoom reset");
} }
} else if (k === "Escape") {
const renderer = this.view.renderer;
if (renderer instanceof FixedLayout && renderer.zoomMagnifierEnabled) {
renderer.toggleMagnifier();
}
} }
} }
#onLoad({ detail: { doc } }) { #onLoad({ detail: { doc } }) {