mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
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:
+133
-19
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 } }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user