From d5b2e3dedafb3502d673db55e4cdc608ac1170a8 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 18 Apr 2026 00:39:15 -0400 Subject: [PATCH] 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. --- fixed-layout.js | 152 ++++++++++++++++++++++++++++++++++++++++++------ reader.html | 10 ++++ reader.js | 28 +++++++++ 3 files changed, 171 insertions(+), 19 deletions(-) diff --git a/fixed-layout.js b/fixed-layout.js index 4f0023d..bdc4482 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -23,7 +23,7 @@ const getViewport = (doc, viewport) => { }; export class FixedLayout extends HTMLElement { - static observedAttributes = ["zoom"]; + static observedAttributes = ["zoom", "interaction-mode"]; #root = this.attachShadow({ mode: "closed" }); #observer = new ResizeObserver(() => this.#render()); #spreads; @@ -36,6 +36,8 @@ export class FixedLayout extends HTMLElement { #center; #side; #zoom; + #isPDF; + #interactionMode = "select"; #wrapper; #transform = { x: 0, y: 0, scale: 1 }; #zoomState = { @@ -109,6 +111,12 @@ export class FixedLayout extends HTMLElement { } 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; } + get isPDF() { + return this.#isPDF; + } + + get zoomMagnifierEnabled() { + return this.#magnifier.enabled; + } + toggleMagnifier() { this.#magnifier.enabled = !this.#magnifier.enabled; @@ -159,6 +175,7 @@ export class FixedLayout extends HTMLElement { height: 100%; border-radius: 50%; overflow: hidden; + background-repeat: no-repeat; `; magnifier.appendChild(lens); @@ -175,31 +192,109 @@ export class FixedLayout extends HTMLElement { #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 frame = this.#center || this.#left || this.#right; - if (frame?.iframe) { - const iframe = frame.iframe; - lens.innerHTML = ""; - lens.appendChild(iframe.cloneNode(true)); - const scale = this.#transform.scale || 1; - lens.style.transform = `scale(${this.#magnifier.magnification})`; - - const iframeRect = iframe.getBoundingClientRect(); - const relX = (x - iframeRect.left) / scale; - const relY = (y - iframeRect.top) / scale; - - lens.style.transformOrigin = `${-relX + this.#magnifier.size / 2}px ${-relY + this.#magnifier.size / 2}px`; + 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() { @@ -300,6 +395,10 @@ export class FixedLayout extends HTMLElement { this.#applyTransform(); this.setAttribute("zoom", newScale); + + this.dispatchEvent( + new CustomEvent("zoom", { detail: { scale: newScale } }), + ); } #handleWheel(event) { @@ -321,6 +420,8 @@ export class FixedLayout extends HTMLElement { #handleMouseDown(event) { if (event.button !== 0) return; + if (this.#isPDF && this.#interactionMode === "select" && !event.shiftKey) + return; this.#dragState.startX = event.clientX; this.#dragState.startY = event.clientY; @@ -470,6 +571,9 @@ export class FixedLayout extends HTMLElement { 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, @@ -583,6 +687,7 @@ export class FixedLayout extends HTMLElement { 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"); @@ -592,6 +697,7 @@ export class FixedLayout extends HTMLElement { : this.#right.blank ? "left" : side; + this.#isPDF = !!(this.#left?.onZoom || this.#right?.onZoom); this.#render(); } } @@ -606,7 +712,11 @@ export class FixedLayout extends HTMLElement { } // 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") { this.#side = "left"; this.#render(); @@ -625,7 +735,11 @@ export class FixedLayout extends HTMLElement { } // 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") { this.#side = "right"; this.#render(); diff --git a/reader.html b/reader.html index 57969df..41e9747 100644 --- a/reader.html +++ b/reader.html @@ -365,6 +365,16 @@ + diff --git a/reader.js b/reader.js index 19c8c71..a9f9a2a 100644 --- a/reader.js +++ b/reader.js @@ -221,6 +221,9 @@ class Reader { $("#magnifier-toggle").addEventListener("click", () => { console.log("[Reader Debug] 🔍 Magnifier toggle button clicked"); const renderer = this.view.renderer; + renderer.addEventListener("zoom", (e) => { + $("#zoom-reset").textContent = `${Math.round(e.detail.scale * 100)}%`; + }); console.log( "[Reader Debug] Is FixedLayout?", renderer instanceof FixedLayout, @@ -232,6 +235,26 @@ class Reader { 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" + ? '' + : ''; + }); + 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"); const title = formatLanguageMap(book.metadata?.title) || "Untitled Book"; @@ -328,6 +351,11 @@ class Reader { $("#zoom-reset").textContent = "100%"; 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 } }) {