diff --git a/comic-book.js b/comic-book.js index 2c3faf4..168e049 100644 --- a/comic-book.js +++ b/comic-book.js @@ -5,7 +5,7 @@ export const makeComicBook = ({ entries, loadBlob, getSize }, file) => { if (cache.has(name)) return cache.get(name) const src = URL.createObjectURL(await loadBlob(name)) const page = URL.createObjectURL( - new Blob([``], { type: 'text/html' })) + new Blob([``], { type: 'text/html' })) urls.set(name, [src, page]) cache.set(name, page) return page diff --git a/fixed-layout.js b/fixed-layout.js index a1a27f7..02e5e67 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -57,6 +57,25 @@ export class FixedLayout extends HTMLElement { startTY: 0, }; dragOffset = { x: 0, y: 0 }; + #touchState = { + mode: null, // null | "pending" | "pan" | "pinch" | "swipe" | "native" + id: null, + realTarget: null, + startX: 0, + startY: 0, + lastX: 0, + lastY: 0, + startTX: 0, + startTY: 0, + startTime: 0, + startDist: 0, + startScale: 1, + lastMidX: 0, + lastMidY: 0, + lastTapTime: 0, + lastTapX: 0, + lastTapY: 0, + }; #magnifier = { enabled: false, size: 150, @@ -75,6 +94,7 @@ export class FixedLayout extends HTMLElement { display: block; overflow: hidden; position: relative; + touch-action: none; }`); this.#wrapper = document.createElement("div"); @@ -88,6 +108,22 @@ export class FixedLayout extends HTMLElement { this.#root.appendChild(this.#wrapper); this.#observer.observe(this); + + // Touch gestures: pinch-zoom, two-finger pan, single-finger pan while + // zoomed, swipe page-turn at fit, double-tap zoom toggle. Page iframes + // forward their touch events here (see #attachEventListenersToIframe). + this.addEventListener("touchstart", this.#onTouchStart.bind(this), { + passive: false, + }); + this.addEventListener("touchmove", this.#onTouchMove.bind(this), { + passive: false, + }); + this.addEventListener("touchend", this.#onTouchEnd.bind(this), { + passive: false, + }); + this.addEventListener("touchcancel", this.#onTouchCancel.bind(this), { + passive: false, + }); } attributeChangedCallback(name, _, value) { @@ -546,6 +582,184 @@ export class FixedLayout extends HTMLElement { } } + // ----- touch gestures ----- + + #atFitScale() { + return this.#zoom == null; + } + + #touchStartsOnSelectable(target) { + if (!this.#isPDF || this.#interactionMode === "pan") return false; + return !!target?.closest?.(".textLayer span, .annotationLayer a"); + } + + #getPinchInfo(touches) { + const [a, b] = touches; + return { + dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), + midX: (a.clientX + b.clientX) / 2, + midY: (a.clientY + b.clientY) / 2, + }; + } + + #onTouchStart(event) { + if (this.hasAttribute("panel-mode")) return; + const st = this.#touchState; + if (event.touches.length === 1) { + const t = event.touches[0]; + st.mode = "pending"; + st.id = t.identifier; + st.realTarget = event.realTarget ?? event.target ?? null; + st.startX = t.clientX; + st.startY = t.clientY; + st.lastX = t.clientX; + st.lastY = t.clientY; + st.startTX = this.#transform.x; + st.startTY = this.#transform.y; + st.startTime = Date.now(); + } else if (event.touches.length >= 2) { + // A second finger always upgrades to pinch (cancels pan/swipe). + const { dist, midX, midY } = this.#getPinchInfo(event.touches); + st.mode = "pinch"; + st.startDist = dist; + st.startScale = this.#transform.scale; + st.lastMidX = midX; + st.lastMidY = midY; + event.preventDefault(); + } + } + + #onTouchMove(event) { + const st = this.#touchState; + if (!st.mode) return; + + if (st.mode === "pinch") { + if (event.touches.length < 2) return; + const { dist, midX, midY } = this.#getPinchInfo(event.touches); + const rect = this.getBoundingClientRect(); + if (dist > 0 && st.startDist > 0) { + const target = dist / st.startDist * st.startScale; + const ratio = target / this.#transform.scale; + this.#zoomByRatio(midX - rect.left, midY - rect.top, ratio); + } + // two-finger pan: follow the midpoint + this.#transform.x += midX - st.lastMidX; + this.#transform.y += midY - st.lastMidY; + this.#applyTransform(); + st.lastMidX = midX; + st.lastMidY = midY; + event.preventDefault(); + return; + } + + const t = [...event.touches].find((x) => x.identifier === st.id); + if (!t) return; + + if (st.mode === "pending") { + if (Math.hypot(t.clientX - st.startX, t.clientY - st.startY) < 10) + return; + // Gesture decided on first significant movement. + if (this.#touchStartsOnSelectable(st.realTarget)) { + st.mode = "native"; // let the text layer handle selection + return; + } + if (this.#atFitScale()) { + st.mode = "swipe"; // page-turn gesture + } else { + st.mode = "pan"; + this.style.cursor = "grabbing"; + } + } + + if (st.mode === "pan") { + this.#transform.x = st.startTX + (t.clientX - st.startX); + this.#transform.y = st.startTY + (t.clientY - st.startY); + this.#applyTransform(); + event.preventDefault(); + } else if (st.mode === "swipe") { + event.preventDefault(); + } + } + + #onTouchEnd(event) { + const st = this.#touchState; + if (!st.mode) return; + + if (st.mode === "pinch") { + if (event.touches.length === 1) { + // continue as a single-finger pan with the remaining finger + const t = event.touches[0]; + st.mode = "pan"; + st.id = t.identifier; + st.startX = t.clientX; + st.startY = t.clientY; + st.startTX = this.#transform.x; + st.startTY = this.#transform.y; + } else if (event.touches.length === 0) { + st.mode = null; + } + return; + } + + if (st.mode === "pan") { + if (event.touches.length === 0) { + st.mode = null; + this.style.cursor = ""; + } + return; + } + + if (st.mode === "swipe") { + const t = [...event.changedTouches].find( + (x) => x.identifier === st.id, + ); + const dx = (t?.clientX ?? st.startX) - st.startX; + const dy = (t?.clientY ?? st.startY) - st.startY; + st.mode = null; + if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) { + // Finger direction maps through next()/prev() so RTL (manga) + // reads correctly: forward is a left swipe in LTR, right in RTL. + if ((dx < 0) !== this.rtl) this.next(); + else this.prev(); + event.preventDefault(); + } + return; + } + + if (st.mode === "pending") { + // no significant movement: a tap. Double-tap toggles zoom. + const t = [...event.changedTouches].find( + (x) => x.identifier === st.id, + ); + const x = t?.clientX ?? st.startX; + const y = t?.clientY ?? st.startY; + const now = Date.now(); + const rect = this.getBoundingClientRect(); + st.mode = null; + if ( + now - st.lastTapTime < 300 && + Math.hypot(x - st.lastTapX, y - st.lastTapY) < 30 + ) { + st.lastTapTime = 0; + if (this.#atFitScale()) { + this.#zoomByRatio(x - rect.left, y - rect.top, 2.5); + } else { + this.resetZoom(); + } + event.preventDefault(); + } else { + st.lastTapTime = now; + st.lastTapX = x; + st.lastTapY = y; + } + } + } + + #onTouchCancel() { + this.#touchState.mode = null; + this.style.cursor = ""; + } + async #createFrame({ index, src: srcOption }, frameId) { const srcOptionIsString = typeof srcOption === "string"; const src = srcOptionIsString ? srcOption : srcOption?.src; @@ -592,17 +806,18 @@ export class FixedLayout extends HTMLElement { } #attachEventListenersToIframe(doc, frameId, frame) { - const convertCoords = (e) => { + const convertPoint = (x, y) => { 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, + clientX: iframeRect.left - flRect.left + x * scaleX, + clientY: iframeRect.top - flRect.top + y * scaleY, }; }; + const convertCoords = (e) => convertPoint(e.clientX, e.clientY); if (!doc) return; const images = doc.querySelectorAll("img"); @@ -613,6 +828,49 @@ export class FixedLayout extends HTMLElement { img.style.WebkitUserDrag = "none"; }); + // Forward touches to the host gesture engine with converted + // coordinates. Handlers decide whether to preventDefault (pan/pinch/ + // swipe) or let the iframe handle it natively (text selection, taps). + const forwardTouches = (list) => + [...list].map((t) => { + const p = convertPoint(t.clientX, t.clientY); + return { identifier: t.identifier, ...p }; + }); + const synthTouch = (event) => ({ + touches: forwardTouches(event.touches), + changedTouches: forwardTouches(event.changedTouches), + realTarget: event.target, + preventDefault: () => event.preventDefault(), + }); + doc.addEventListener( + "touchstart", + (event) => { + this.#onTouchStart(synthTouch(event)); + }, + { passive: false }, + ); + doc.addEventListener( + "touchmove", + (event) => { + this.#onTouchMove(synthTouch(event)); + }, + { passive: false }, + ); + doc.addEventListener( + "touchend", + (event) => { + this.#onTouchEnd(synthTouch(event)); + }, + { passive: false }, + ); + doc.addEventListener( + "touchcancel", + (event) => { + this.#onTouchCancel(synthTouch(event)); + }, + { passive: false }, + ); + doc.addEventListener( "wheel", (event) => { diff --git a/pdf.js b/pdf.js index 6a5cd6f..cef2891 100644 --- a/pdf.js +++ b/pdf.js @@ -100,6 +100,7 @@ const renderPage = async (page, getImageBlob) => { html, body { margin: 0; padding: 0; + touch-action: none; } /* https://github.com/mozilla/pdf.js/commit/bd05b255fabfc313b194bfe9a17ccded4d90fb5a