diff --git a/comic-book.js b/comic-book.js index 2c3faf4..ce911b2 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 @@ -34,6 +34,7 @@ export const makeComicBook = ({ entries, loadBlob, getSize }, file) => { })) book.toc = files.map(name => ({ label: name, href: name })) book.rendition = { layout: 'pre-paginated' } + book.format = 'comic' book.resolveHref = href => ({ index: book.sections.findIndex(s => s.id === href) }) book.splitTOCHref = href => [href, null] book.getTOCFragment = doc => doc.documentElement diff --git a/fixed-layout.js b/fixed-layout.js index 03963cb..716941d 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", "interaction-mode"]; + static observedAttributes = ["zoom", "interaction-mode", "spread"]; #root = this.attachShadow({ mode: "closed" }); #observer = new ResizeObserver(() => this.#onResize()); #spreads; @@ -57,6 +57,29 @@ export class FixedLayout extends HTMLElement { startTY: 0, }; dragOffset = { x: 0, y: 0 }; + // Rect annotations (PDF text highlights; comic regions later). Keyed by + // host-supplied id, stored as page-fraction rects so they survive iframe + // CSS-scaling and PDF hi-res re-renders without any re-anchoring work. + #rectAnnotations = new Map(); + #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 +98,7 @@ export class FixedLayout extends HTMLElement { display: block; overflow: hidden; position: relative; + touch-action: none; }`); this.#wrapper = document.createElement("div"); @@ -88,6 +112,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) { @@ -116,11 +156,15 @@ export class FixedLayout extends HTMLElement { break; } case "interaction-mode": { - if (value === "pan" || value === "select") { + if (value === "pan" || value === "select" || value === "text") { this.#interactionMode = value; } break; } + case "spread": { + this.#setSpread(value == null ? undefined : value); + break; + } } } @@ -149,6 +193,25 @@ export class FixedLayout extends HTMLElement { this.#render(); } + // Reset pan while keeping the current zoom: the wrapper's base layout + // already centers the spread, so translate(0,0) recenters it. Re-syncs + // #side afterwards (same rule as drag-end) so next()/prev() stay sane. + recenter() { + this.#transform.x = 0; + this.#transform.y = 0; + this.#applyTransform(); + if ( + !this.#center && + !this.#left?.blank && + !this.#right?.blank && + !this.#portrait + ) { + const leftWidth = (this.#left.width ?? 0) * this.#transform.scale; + const viewportCenterInWrapper = this.getBoundingClientRect().width / 2; + this.#side = viewportCenterInWrapper < leftWidth ? "left" : "right"; + } + } + toggleMagnifier() { this.#magnifier.enabled = !this.#magnifier.enabled; @@ -492,8 +555,16 @@ export class FixedLayout extends HTMLElement { #handleMouseDown(event) { if (event.button !== 0) return; - if (this.#isPDF && this.#interactionMode === "select" && !event.shiftKey) + if (this.#isPDF && this.#interactionMode === "text" && !event.shiftKey) return false; + if (this.#isPDF && this.#interactionMode === "select" && !event.shiftKey) { + const t = event.realTarget; + if ( + t?.closest?.(".textLayer span") || + t?.closest?.(".annotationLayer a") + ) + return false; + } this.#dragState.startX = event.clientX; this.#dragState.startY = event.clientY; @@ -534,6 +605,258 @@ 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 = ""; + } + + // ----- rect annotations ----- + + #frameForIndex(index) { + for (const frame of [this.#left, this.#right, this.#center]) { + if (frame && !frame.blank && frame.index === index) return frame; + } + return null; + } + + // Host-side overlay: a div inside the frame's wrapper element, children + // positioned in percentages of the element box (which always equals the + // visible page area). This is immune to iframe-internal transforms + // (pdf.js scales by 1/devicePixelRatio, which would shrink an + // in-document overlay), comic iframe CSS-scaling, PDF hi-res re-renders, + // and the host transform-based pan/zoom — no re-anchoring anywhere. + #ensureOverlay(frame) { + if (!frame?.element) return null; + let overlay = frame.element.querySelector( + ":scope > .foliate-rect-overlay", + ); + if (!overlay) { + overlay = frame.element.ownerDocument.createElement("div"); + overlay.className = "foliate-rect-overlay"; + overlay.style.cssText = + "position:absolute;inset:0;pointer-events:none;"; + frame.element.style.position = "relative"; + frame.element.appendChild(overlay); + } + return overlay; + } + + #renderAnnotationsIntoFrame(frame) { + if (!frame || frame.blank || frame.index == null) return; + const overlay = this.#ensureOverlay(frame); + if (!overlay) return; + const doc = frame.element.ownerDocument; + overlay.replaceChildren(); + for (const a of this.#rectAnnotations.values()) { + if (a.index !== frame.index) continue; + for (const [x, y, w, h] of a.rects) { + const rect = doc.createElement("div"); + Object.assign(rect.style, { + position: "absolute", + left: `${x * 100}%`, + top: `${y * 100}%`, + width: `${w * 100}%`, + height: `${h * 100}%`, + backgroundColor: a.color || "#ffd54f", + opacity: "0.35", + borderRadius: "2px", + }); + overlay.appendChild(rect); + } + } + } + + #renderAnnotationsForIndex(index) { + this.#renderAnnotationsIntoFrame(this.#frameForIndex(index)); + } + + addRectAnnotation({ key, index, rects, color }) { + if (!key || index == null || !Array.isArray(rects) || !rects.length) + return; + this.#rectAnnotations.set(key, { index, rects, color }); + this.#renderAnnotationsForIndex(index); + } + + removeRectAnnotation(key) { + const a = this.#rectAnnotations.get(key); + if (!a) return; + this.#rectAnnotations.delete(key); + this.#renderAnnotationsForIndex(a.index); + } + async #createFrame({ index, src: srcOption }, frameId) { const srcOptionIsString = typeof srcOption === "string"; const src = srcOptionIsString ? srcOption : srcOption?.src; @@ -551,7 +874,8 @@ export class FixedLayout extends HTMLElement { iframe.setAttribute("scrolling", "no"); iframe.setAttribute("part", "filter"); this.#wrapper.append(element); - if (!src) return { blank: true, element, iframe, frameId }; + if (!src) + return { blank: true, element, iframe, frameId, index, onZoom }; return new Promise((resolve) => { iframe.addEventListener( "load", @@ -562,7 +886,15 @@ export class FixedLayout extends HTMLElement { ); const { width, height } = getViewport(doc, this.defaultViewport); - this.#attachEventListenersToIframe(doc, frameId, { element, iframe }); + this.#attachEventListenersToIframe(doc, frameId, { + element, + iframe, + index, + }); + // Re-render persisted annotations into the fresh frame (frames + // are recreated on every spread change; the spread fields aren't + // assigned yet, so pass the frame under construction directly). + this.#renderAnnotationsIntoFrame({ element, iframe, index }); resolve({ element, @@ -571,6 +903,7 @@ export class FixedLayout extends HTMLElement { height: parseFloat(height), onZoom, frameId, + index, }); }, { once: true }, @@ -580,17 +913,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"); @@ -601,6 +935,85 @@ 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 }, + ); + + // Click hit-testing for rect annotations (edit popover). Skipped while a + // text selection is active so drag-selecting doesn't pop the editor. + // Denominator: the rendered canvas for PDFs (its post-transform rect IS + // the visible page; documentElement is dpr× too small because pdf.js + // scales by 1/devicePixelRatio), the img for comics. + doc.addEventListener( + "click", + (event) => { + if (!this.#rectAnnotations.size || frame.index == null) return; + const sel = doc.getSelection(); + if (sel && !sel.isCollapsed) return; + const denom = + doc.querySelector("#canvas canvas") || + doc.querySelector("img"); + const dr = denom?.getBoundingClientRect(); + if (!dr || !dr.width || !dr.height) return; + const fx = (event.clientX - dr.left) / dr.width; + const fy = (event.clientY - dr.top) / dr.height; + for (const [key, a] of this.#rectAnnotations) { + if (a.index !== frame.index) continue; + for (const [x, y, w, h] of a.rects) { + if (fx >= x && fx <= x + w && fy >= y && fy <= y + h) { + const { clientX, clientY } = convertCoords(event); + this.dispatchEvent( + new CustomEvent("show-rect-annotation", { + detail: { key, index: a.index, clientX, clientY }, + }), + ); + return; + } + } + } + }, + false, + ); + doc.addEventListener( "wheel", (event) => { @@ -643,6 +1056,7 @@ export class FixedLayout extends HTMLElement { }); mouseEvent.sourceIframe = frameId; mouseEvent.sourceFrame = frame; + mouseEvent.realTarget = event.target; const dragStarted = this.#handleMouseDown(mouseEvent); if (dragStarted) event.preventDefault(); }); @@ -831,11 +1245,15 @@ export class FixedLayout extends HTMLElement { this.spread = rendition?.spread; this.defaultViewport = rendition?.viewport; - const rtl = book.dir === "rtl"; - const ltr = !rtl; - this.rtl = rtl; + this.rtl = book.dir === "rtl"; + this.#computeSpreads(); + } - if (rendition?.spread === "none") + #computeSpreads() { + const { book } = this; + const rtl = this.rtl; + const ltr = !rtl; + if (this.spread === "none") this.#spreads = book.sections.map((section) => ({ center: section })); else this.#spreads = book.sections.reduce( @@ -873,6 +1291,17 @@ export class FixedLayout extends HTMLElement { ); } + #setSpread(value) { + this.spread = value; + const started = this.#index >= 0; + const currentSection = started ? this.book?.sections[this.index] : null; + this.#computeSpreads(); + if (!started) return; + const resolved = currentSection ? this.getSpreadOf(currentSection) : null; + if (resolved) this.goToSpread(resolved.index, resolved.side, "page"); + else this.#render(); + } + get index() { const spread = this.#spreads[this.#index]; const section = diff --git a/paginator.js b/paginator.js index 7980c20..63364f9 100644 --- a/paginator.js +++ b/paginator.js @@ -151,11 +151,28 @@ const getVisibleRange = (doc, start, end, mapRect) => { } const selectionIsBackward = sel => { - const range = document.createRange() + // the selection may live in an iframe document while this module runs + // in the host document; create the range in the selection's own + // document or the boundary points cannot be compared + const doc = sel.anchorNode?.ownerDocument ?? document + const range = doc.createRange() range.setStart(sel.anchorNode, sel.anchorOffset) range.setEnd(sel.focusNode, sel.focusOffset) return range.collapsed } +const wordAt = (doc, node, offset) => { + if (!node) return null + if (node.nodeType !== 3) return { node, offset } + const text = node.nodeValue + if (!text) return null + const isWord = c => /[\p{L}\p{N}]/u.test(c) + let start = Math.min(offset, text.length) + let end = start + while (start > 0 && isWord(text[start - 1])) start-- + while (end < text.length && isWord(text[end])) end++ + if (start === end) return { node, offset } + return { node, offset: start, endOffset: end } +} const setSelectionTo = (target, collapse) => { let range @@ -427,7 +444,23 @@ export class Paginator extends HTMLElement { 'max-inline-size', 'max-block-size', 'max-column-count', ] #root = this.attachShadow({ mode: 'closed' }) - #observer = new ResizeObserver(() => this.render()) + #lastContainerSize + #observer = new ResizeObserver(entries => { + // Mobile browsers grow/shrink the viewport by ~7–17% when the + // toolbar hides/shows mid-gesture; re-wrapping the whole book + // for that reads as a page refresh. Only re-render for real + // layout changes: any width change, or a height change large + // enough to be a rotation, keyboard, or split-screen. + const rect = entries?.[entries.length - 1]?.contentRect + if (rect && rect.width && rect.height && this.#lastContainerSize) { + const [w, h] = this.#lastContainerSize + if (Math.abs(rect.width - w) < 1 + && Math.abs(rect.height - h) / h < 0.25) return + } + if (rect && rect.width && rect.height) + this.#lastContainerSize = [rect.width, rect.height] + this.render() + }) #top #background #container @@ -448,6 +481,7 @@ export class Paginator extends HTMLElement { #scrollBounds #touchState #touchScrolled + #touchSelActive #lastVisibleRange constructor() { super() @@ -594,8 +628,11 @@ export class Paginator extends HTMLElement { }, 700) this.addEventListener('load', ({ detail: { doc } }) => { let isPointerSelecting = false - doc.addEventListener('pointerdown', () => isPointerSelecting = true) + doc.addEventListener('pointerdown', e => { + if (e.pointerType !== 'touch') isPointerSelecting = true + }) doc.addEventListener('pointerup', () => isPointerSelecting = false) + doc.addEventListener('pointercancel', () => isPointerSelecting = false) let isKeyboardSelecting = false doc.addEventListener('keydown', () => isKeyboardSelecting = true) doc.addEventListener('keyup', () => isKeyboardSelecting = false) @@ -617,6 +654,96 @@ export class Paginator extends HTMLElement { doc.addEventListener('focusin', e => this.scrolled ? null : // NOTE: `requestAnimationFrame` is needed in WebKit requestAnimationFrame(() => this.#scrollToAnchor(e.target))) + // ----- custom touch selection ----- + // Chromium's native touch selection misbehaves in the + // columned layout: quick taps select words, the word + // iterator re-anchors at page boundaries, and the browser + // auto-scrolls the container when selections cross pages. + // preventDefault on touchstart disables it entirely; + // long-press selects the word under the finger and drag + // extends it word by word, clamped to the visible page, so + // the selection can never leave the page and the view can + // never move mid-gesture. + if (!this.scrolled) { + let gesture = null + const clearGesture = () => { + if (gesture?.timer) clearTimeout(gesture.timer) + gesture = null + this.#touchSelActive = false + } + doc.addEventListener('touchstart', e => { + const touch = e.changedTouches[0] + if (!touch || e.touches.length !== 1) return clearGesture() + e.preventDefault() + const g = gesture = { x: touch.clientX, y: touch.clientY, + id: touch.identifier, start: null, end: null, timer: null } + g.timer = setTimeout(() => { + if (gesture !== g) return + const caret = this.#caretAt(g.x, g.y, doc) + const word = caret && wordAt(doc, caret.node, caret.offset) + if (!word || word.endOffset == null) { + gesture = null + return + } + const sel = doc.getSelection() + sel.setBaseAndExtent(word.node, word.offset, + word.node, word.endOffset) + g.start = { node: word.node, offset: word.offset } + g.end = { node: word.node, offset: word.endOffset } + this.#touchSelActive = true + }, 450) + }, { passive: false }) + doc.addEventListener('touchmove', e => { + const g = gesture + if (!g) return + const touch = [...e.changedTouches] + .find(t => t.identifier === g.id) + if (!touch) return + if (!g.start) { + // moving before the long-press engages: a pan — + // stand down and let the paginator handle it + if (Math.hypot(touch.clientX - g.x, touch.clientY - g.y) > 12) + clearGesture() + return + } + e.preventDefault() + const caret = this.#caretAt(touch.clientX, touch.clientY, doc) + if (!caret) return + const word = wordAt(doc, caret.node, caret.offset) + const end = word?.endOffset != null + ? { node: word.node, offset: word.endOffset } + : caret + const startProbe = doc.createRange() + startProbe.setStart(g.start.node, g.start.offset) + startProbe.collapse(true) + const endProbe = doc.createRange() + endProbe.setStart(end.node, end.offset) + endProbe.collapse(true) + // whichever boundary of the long-pressed word keeps + // it fully covered serves as the anchor + const anchor = startProbe.compareBoundaryPoints( + Range.START_TO_START, endProbe) > 0 ? g.end : g.start + doc.getSelection().setBaseAndExtent(anchor.node, anchor.offset, + end.node, end.offset) + }, { passive: false }) + doc.addEventListener('touchend', e => { + const g = gesture + const wasSelecting = !!g?.start + clearGesture() + if (wasSelecting || e.changedTouches.length !== 1) return + const touch = e.changedTouches[0] + // touchstart's preventDefault suppresses click + // events; dispatch one so foliate's link handling and + // the overlayer's highlight hit-testing (the edit + // popover) behave as on desktop + const el = doc.elementFromPoint(touch.clientX, touch.clientY) + el?.dispatchEvent(new (doc.defaultView ?? window).MouseEvent( + 'click', { clientX: touch.clientX, clientY: touch.clientY, + bubbles: true, cancelable: true })) + }) + doc.addEventListener('touchcancel', clearGesture) + doc.addEventListener('contextmenu', e => e.preventDefault()) + } }) this.#mediaQueryListener = () => { @@ -820,6 +947,21 @@ export class Paginator extends HTMLElement { }) }) } + #caretAt(x, y, doc) { + // Touch points and caret mapping share the iframe's own + // (content) space, where the visible page is [start - size, + // start] for LTR; clamp into it so selections cannot leave + // the visible page. + const size = this.#rtl ? -this.size : this.size + const left = this.start - size + const right = this.start + const cx = Math.min(Math.max(x, left + 2), right - 2) + const caret = doc.caretRangeFromPoint?.(cx, y) + ?? doc.caretPositionFromPoint?.(cx, y) + if (!caret) return null + return { node: caret.startContainer ?? caret.offsetNode, + offset: caret.startOffset ?? caret.offset } + } #onTouchStart(e) { const touch = e.changedTouches[0] this.#touchState = { @@ -833,6 +975,7 @@ export class Paginator extends HTMLElement { if (state.pinched) return state.pinched = globalThis.visualViewport.scale > 1 if (this.scrolled || state.pinched) return + if (this.#touchSelActive) return if (e.touches.length > 1) { if (this.#touchScrolled) e.preventDefault() return @@ -851,8 +994,13 @@ export class Paginator extends HTMLElement { this.scrollBy(dx, dy) } #onTouchEnd() { + const wasScrolled = this.#touchScrolled this.#touchScrolled = false - if (this.scrolled) return + if (this.scrolled || this.#touchSelActive) return + // A touch that never panned (a tap) is already page-aligned; + // snapping anyway emits a no-op relocate, which readers treat + // as a page turn (e.g. dismissing a just-opened popover). + if (!wasScrolled) return // XXX: Firefox seems to report scale as 1... sometimes...? // at this point I'm basically throwing `requestAnimationFrame` at diff --git a/pdf.js b/pdf.js index 6a5cd6f..11a1f6c 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 @@ -153,7 +154,7 @@ export const makePDF = async (file, options = {}) => { isEvalSupported: false, }).promise; - const book = { rendition: { layout: "pre-paginated" } }; + const book = { rendition: { layout: "pre-paginated" }, pdf }; const { metadata, info } = (await pdf.getMetadata()) ?? {}; // TODO: for better results, parse `metadata.getRaw()` diff --git a/view.js b/view.js index 5679b93..385b4f8 100644 --- a/view.js +++ b/view.js @@ -251,7 +251,11 @@ export class View extends HTMLElement { } this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated' - if (this.isFixedLayout) { + if (this.book.format === 'comic' && options?.comic?.flow === 'webtoon') { + await import('./webtoon.js') + this.renderer = document.createElement('foliate-webtoon') + } + else if (this.isFixedLayout) { await import('./fixed-layout.js') this.renderer = document.createElement('foliate-fxl') } else { diff --git a/webtoon.js b/webtoon.js new file mode 100644 index 0000000..7227ac7 --- /dev/null +++ b/webtoon.js @@ -0,0 +1,228 @@ +// Vertical-scroll (webtoon) renderer for comics: all pages stacked in one +// scrolling column, lazily loaded with an IntersectionObserver and unloaded +// when far from the reading position (keeping placeholder geometry stable +// via aspect-ratio so the scrollbar never jumps). + +export class Webtoon extends HTMLElement { + #root = this.attachShadow({ mode: "closed" }); + #book; + #pages = []; + #index = -1; + #firstNav = true; + #connected = false; + #scrollRaf = null; + + constructor() { + super(); + + const sheet = new CSSStyleSheet(); + this.#root.adoptedStyleSheets = [sheet]; + sheet.replaceSync(` + :host { + display: block; + width: 100%; + height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + } + .page { + max-width: 900px; + margin: 0 auto; + position: relative; + } + .page img { + display: block; + width: 100%; + /* brightness/contrast/invert driven by the host page + (CSS custom properties inherit into shadow DOM) */ + filter: var(--fx-filter, none); + } + `); + + this.addEventListener("scroll", () => this.#onScroll(), { + passive: true, + }); + } + + open(book) { + this.#book = book; + const sections = book.sections ?? []; + // Placeholder height until the image loads and sets the real + // aspect-ratio; oversized so early scrolling doesn't skip pages. + const minH = Math.round((window.innerHeight || 1000) * 1.4); + const frag = document.createDocumentFragment(); + this.#pages = sections.map((section, i) => { + const el = document.createElement("div"); + el.className = "page"; + el.dataset.index = i; + el.style.minHeight = `${minH}px`; + frag.append(el); + return { el, section, loaded: false, img: null, loading: false }; + }); + this.#root.append(frag); + // Loading is driven from scroll position (#updateIndex), not an + // IntersectionObserver: IO with a shadow-host root proved unreliable + // in Chromium and would leave pages permanently blank. + if (this.#connected) this.#updateIndex(); + } + + connectedCallback() { + this.#connected = true; + if (this.#pages.length) this.#updateIndex(); + } + + async #loadPage(i) { + const p = this.#pages[i]; + if (!p || p.loaded || p.loading) return; + p.loading = true; + try { + const url = await p.section.load?.(); + if (!url) return; + const html = await (await fetch(url)).text(); + const m = html.match(/src="(blob:[^"]+)"/); + if (!m) return; + const img = new Image(); + img.decoding = "async"; + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = m[1]; + }); + // Fix the geometry so later unloading never shifts scroll height. + p.el.style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`; + p.el.style.minHeight = "0"; + p.el.append(img); + p.img = img; + p.loaded = true; + } catch (e) { + console.warn("webtoon: failed to load page", i + 1, e); + } finally { + p.loading = false; + } + } + + // Load a window around the reading position (a few pages of lookahead). + #loadAround(idx) { + const start = Math.max(0, idx - 2); + const end = Math.min(this.#pages.length - 1, idx + 4); + for (let i = start; i <= end; i++) this.#loadPage(i); + } + + // Free far-away pages (images + their blob URLs) while keeping the + // aspect-ratio placeholder, so the column height stays stable. + #unloadFar(current) { + for (let i = 0; i < this.#pages.length; i++) { + const p = this.#pages[i]; + if (!p.loaded || !p.img) continue; + if (Math.abs(i - current) > 12) { + p.img.remove(); + p.img = null; + p.loaded = false; + p.section.unload?.(); + } + } + } + + #onScroll() { + if (this.#scrollRaf) return; + this.#scrollRaf = requestAnimationFrame(() => { + this.#scrollRaf = null; + this.#updateIndex(); + }); + } + + #updateIndex() { + if (!this.#pages.length) return; + const hostRect = this.getBoundingClientRect(); + const center = hostRect.top + hostRect.height / 2; + let idx = this.#index < 0 ? 0 : this.#index; + const n = this.#pages.length; + while (idx < n - 1) { + const r = this.#pages[idx].el.getBoundingClientRect(); + if (center < r.bottom - 1) break; + idx++; + } + while (idx > 0) { + const r = this.#pages[idx].el.getBoundingClientRect(); + if (center > r.top + 1) break; + idx--; + } + const r = this.#pages[idx].el.getBoundingClientRect(); + const fraction = Math.min( + 1, + Math.max(0, (center - r.top) / (r.height || 1)), + ); + if (idx !== this.#index) { + this.#index = idx; + this.#unloadFar(idx); + } + this.#loadAround(idx); + this.#report(fraction); + } + + #report(fraction = 0, reason = "scroll") { + this.dispatchEvent( + new CustomEvent("relocate", { + detail: { + reason, + range: null, + index: Math.max(0, this.#index), + fraction, + size: 1, + }, + }), + ); + } + + get index() { + return Math.max(0, this.#index); + } + + // Renderer interface (mirrors fixed-layout): goTo receives a thenable + // resolving to { index }. + async goTo(target) { + const resolved = await target; + const p = this.#pages[resolved?.index]; + if (!p) return; + this.#firstNav = false; + p.el.scrollIntoView({ block: "start" }); + this.#index = resolved.index; + requestAnimationFrame(() => this.#updateIndex()); + } + + // Screen-height paging, like dedicated webtoon readers. The first call is + // view.init()'s fresh-start probe — a no-op that just reports location. + async next() { + if (this.#firstNav) { + this.#firstNav = false; + this.scrollTo(0, 0); + this.#index = 0; + this.#updateIndex(); + return true; + } + this.scrollBy({ top: this.clientHeight * 0.88, behavior: "smooth" }); + return true; + } + + async prev() { + this.scrollBy({ top: -this.clientHeight * 0.88, behavior: "smooth" }); + return true; + } + + goLeft() { + return this.prev(); + } + + goRight() { + return this.next(); + } + + getContents() { + return []; + } + + destroy() { + } +} + +customElements.define("foliate-webtoon", Webtoon);