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);