Merge branch 'zoom-control'

# Conflicts:
#	pdf.js
This commit is contained in:
John O'Keefe
2026-09-15 20:06:16 -04:00
6 changed files with 830 additions and 19 deletions
+2 -1
View File
@@ -5,7 +5,7 @@ export const makeComicBook = ({ entries, loadBlob, getSize }, file) => {
if (cache.has(name)) return cache.get(name) if (cache.has(name)) return cache.get(name)
const src = URL.createObjectURL(await loadBlob(name)) const src = URL.createObjectURL(await loadBlob(name))
const page = URL.createObjectURL( const page = URL.createObjectURL(
new Blob([`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin: 0"><img src="${src}" style="image-rendering: high-quality"></body></html>`], { type: 'text/html' })) new Blob([`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin: 0; touch-action: none"><img src="${src}" style="display: block; image-rendering: high-quality"></body></html>`], { type: 'text/html' }))
urls.set(name, [src, page]) urls.set(name, [src, page])
cache.set(name, page) cache.set(name, page)
return page return page
@@ -34,6 +34,7 @@ export const makeComicBook = ({ entries, loadBlob, getSize }, file) => {
})) }))
book.toc = files.map(name => ({ label: name, href: name })) book.toc = files.map(name => ({ label: name, href: name }))
book.rendition = { layout: 'pre-paginated' } book.rendition = { layout: 'pre-paginated' }
book.format = 'comic'
book.resolveHref = href => ({ index: book.sections.findIndex(s => s.id === href) }) book.resolveHref = href => ({ index: book.sections.findIndex(s => s.id === href) })
book.splitTOCHref = href => [href, null] book.splitTOCHref = href => [href, null]
book.getTOCFragment = doc => doc.documentElement book.getTOCFragment = doc => doc.documentElement
+441 -12
View File
@@ -23,7 +23,7 @@ const getViewport = (doc, viewport) => {
}; };
export class FixedLayout extends HTMLElement { export class FixedLayout extends HTMLElement {
static observedAttributes = ["zoom", "interaction-mode"]; static observedAttributes = ["zoom", "interaction-mode", "spread"];
#root = this.attachShadow({ mode: "closed" }); #root = this.attachShadow({ mode: "closed" });
#observer = new ResizeObserver(() => this.#onResize()); #observer = new ResizeObserver(() => this.#onResize());
#spreads; #spreads;
@@ -57,6 +57,29 @@ export class FixedLayout extends HTMLElement {
startTY: 0, startTY: 0,
}; };
dragOffset = { x: 0, y: 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 = { #magnifier = {
enabled: false, enabled: false,
size: 150, size: 150,
@@ -75,6 +98,7 @@ export class FixedLayout extends HTMLElement {
display: block; display: block;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
touch-action: none;
}`); }`);
this.#wrapper = document.createElement("div"); this.#wrapper = document.createElement("div");
@@ -88,6 +112,22 @@ export class FixedLayout extends HTMLElement {
this.#root.appendChild(this.#wrapper); this.#root.appendChild(this.#wrapper);
this.#observer.observe(this); 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) { attributeChangedCallback(name, _, value) {
@@ -116,11 +156,15 @@ export class FixedLayout extends HTMLElement {
break; break;
} }
case "interaction-mode": { case "interaction-mode": {
if (value === "pan" || value === "select") { if (value === "pan" || value === "select" || value === "text") {
this.#interactionMode = value; this.#interactionMode = value;
} }
break; break;
} }
case "spread": {
this.#setSpread(value == null ? undefined : value);
break;
}
} }
} }
@@ -149,6 +193,25 @@ export class FixedLayout extends HTMLElement {
this.#render(); 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() { toggleMagnifier() {
this.#magnifier.enabled = !this.#magnifier.enabled; this.#magnifier.enabled = !this.#magnifier.enabled;
@@ -492,8 +555,16 @@ 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) if (this.#isPDF && this.#interactionMode === "text" && !event.shiftKey)
return false; 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.startX = event.clientX;
this.#dragState.startY = event.clientY; 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 <html> 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) { async #createFrame({ index, src: srcOption }, frameId) {
const srcOptionIsString = typeof srcOption === "string"; const srcOptionIsString = typeof srcOption === "string";
const src = srcOptionIsString ? srcOption : srcOption?.src; const src = srcOptionIsString ? srcOption : srcOption?.src;
@@ -551,7 +874,8 @@ export class FixedLayout extends HTMLElement {
iframe.setAttribute("scrolling", "no"); iframe.setAttribute("scrolling", "no");
iframe.setAttribute("part", "filter"); iframe.setAttribute("part", "filter");
this.#wrapper.append(element); 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) => { return new Promise((resolve) => {
iframe.addEventListener( iframe.addEventListener(
"load", "load",
@@ -562,7 +886,15 @@ export class FixedLayout extends HTMLElement {
); );
const { width, height } = getViewport(doc, this.defaultViewport); 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({ resolve({
element, element,
@@ -571,6 +903,7 @@ export class FixedLayout extends HTMLElement {
height: parseFloat(height), height: parseFloat(height),
onZoom, onZoom,
frameId, frameId,
index,
}); });
}, },
{ once: true }, { once: true },
@@ -580,17 +913,18 @@ export class FixedLayout extends HTMLElement {
} }
#attachEventListenersToIframe(doc, frameId, frame) { #attachEventListenersToIframe(doc, frameId, frame) {
const convertCoords = (e) => { const convertPoint = (x, y) => {
const iframeRect = frame.iframe.getBoundingClientRect(); const iframeRect = frame.iframe.getBoundingClientRect();
const flRect = this.getBoundingClientRect(); const flRect = this.getBoundingClientRect();
const scaleX = iframeRect.width / (doc.documentElement.clientWidth || 1); const scaleX = iframeRect.width / (doc.documentElement.clientWidth || 1);
const scaleY = const scaleY =
iframeRect.height / (doc.documentElement.clientHeight || 1); iframeRect.height / (doc.documentElement.clientHeight || 1);
return { return {
clientX: iframeRect.left - flRect.left + e.clientX * scaleX, clientX: iframeRect.left - flRect.left + x * scaleX,
clientY: iframeRect.top - flRect.top + e.clientY * scaleY, clientY: iframeRect.top - flRect.top + y * scaleY,
}; };
}; };
const convertCoords = (e) => convertPoint(e.clientX, e.clientY);
if (!doc) return; if (!doc) return;
const images = doc.querySelectorAll("img"); const images = doc.querySelectorAll("img");
@@ -601,6 +935,85 @@ export class FixedLayout extends HTMLElement {
img.style.WebkitUserDrag = "none"; 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 <html> 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( doc.addEventListener(
"wheel", "wheel",
(event) => { (event) => {
@@ -643,6 +1056,7 @@ export class FixedLayout extends HTMLElement {
}); });
mouseEvent.sourceIframe = frameId; mouseEvent.sourceIframe = frameId;
mouseEvent.sourceFrame = frame; mouseEvent.sourceFrame = frame;
mouseEvent.realTarget = event.target;
const dragStarted = this.#handleMouseDown(mouseEvent); const dragStarted = this.#handleMouseDown(mouseEvent);
if (dragStarted) event.preventDefault(); if (dragStarted) event.preventDefault();
}); });
@@ -831,11 +1245,15 @@ export class FixedLayout extends HTMLElement {
this.spread = rendition?.spread; this.spread = rendition?.spread;
this.defaultViewport = rendition?.viewport; this.defaultViewport = rendition?.viewport;
const rtl = book.dir === "rtl"; this.rtl = book.dir === "rtl";
const ltr = !rtl; this.#computeSpreads();
this.rtl = rtl; }
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 })); this.#spreads = book.sections.map((section) => ({ center: section }));
else else
this.#spreads = book.sections.reduce( 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() { get index() {
const spread = this.#spreads[this.#index]; const spread = this.#spreads[this.#index];
const section = const section =
+152 -4
View File
@@ -151,11 +151,28 @@ const getVisibleRange = (doc, start, end, mapRect) => {
} }
const selectionIsBackward = sel => { 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.setStart(sel.anchorNode, sel.anchorOffset)
range.setEnd(sel.focusNode, sel.focusOffset) range.setEnd(sel.focusNode, sel.focusOffset)
return range.collapsed 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) => { const setSelectionTo = (target, collapse) => {
let range let range
@@ -427,7 +444,23 @@ export class Paginator extends HTMLElement {
'max-inline-size', 'max-block-size', 'max-column-count', 'max-inline-size', 'max-block-size', 'max-column-count',
] ]
#root = this.attachShadow({ mode: 'closed' }) #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 #top
#background #background
#container #container
@@ -448,6 +481,7 @@ export class Paginator extends HTMLElement {
#scrollBounds #scrollBounds
#touchState #touchState
#touchScrolled #touchScrolled
#touchSelActive
#lastVisibleRange #lastVisibleRange
constructor() { constructor() {
super() super()
@@ -594,8 +628,11 @@ export class Paginator extends HTMLElement {
}, 700) }, 700)
this.addEventListener('load', ({ detail: { doc } }) => { this.addEventListener('load', ({ detail: { doc } }) => {
let isPointerSelecting = false 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('pointerup', () => isPointerSelecting = false)
doc.addEventListener('pointercancel', () => isPointerSelecting = false)
let isKeyboardSelecting = false let isKeyboardSelecting = false
doc.addEventListener('keydown', () => isKeyboardSelecting = true) doc.addEventListener('keydown', () => isKeyboardSelecting = true)
doc.addEventListener('keyup', () => isKeyboardSelecting = false) doc.addEventListener('keyup', () => isKeyboardSelecting = false)
@@ -617,6 +654,96 @@ export class Paginator extends HTMLElement {
doc.addEventListener('focusin', e => this.scrolled ? null : doc.addEventListener('focusin', e => this.scrolled ? null :
// NOTE: `requestAnimationFrame` is needed in WebKit // NOTE: `requestAnimationFrame` is needed in WebKit
requestAnimationFrame(() => this.#scrollToAnchor(e.target))) 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 = () => { 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) { #onTouchStart(e) {
const touch = e.changedTouches[0] const touch = e.changedTouches[0]
this.#touchState = { this.#touchState = {
@@ -833,6 +975,7 @@ export class Paginator extends HTMLElement {
if (state.pinched) return if (state.pinched) return
state.pinched = globalThis.visualViewport.scale > 1 state.pinched = globalThis.visualViewport.scale > 1
if (this.scrolled || state.pinched) return if (this.scrolled || state.pinched) return
if (this.#touchSelActive) return
if (e.touches.length > 1) { if (e.touches.length > 1) {
if (this.#touchScrolled) e.preventDefault() if (this.#touchScrolled) e.preventDefault()
return return
@@ -851,8 +994,13 @@ export class Paginator extends HTMLElement {
this.scrollBy(dx, dy) this.scrollBy(dx, dy)
} }
#onTouchEnd() { #onTouchEnd() {
const wasScrolled = this.#touchScrolled
this.#touchScrolled = false 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...? // XXX: Firefox seems to report scale as 1... sometimes...?
// at this point I'm basically throwing `requestAnimationFrame` at // at this point I'm basically throwing `requestAnimationFrame` at
+2 -1
View File
@@ -100,6 +100,7 @@ const renderPage = async (page, getImageBlob) => {
html, body { html, body {
margin: 0; margin: 0;
padding: 0; padding: 0;
touch-action: none;
} }
/* /*
https://github.com/mozilla/pdf.js/commit/bd05b255fabfc313b194bfe9a17ccded4d90fb5a https://github.com/mozilla/pdf.js/commit/bd05b255fabfc313b194bfe9a17ccded4d90fb5a
@@ -153,7 +154,7 @@ export const makePDF = async (file, options = {}) => {
isEvalSupported: false, isEvalSupported: false,
}).promise; }).promise;
const book = { rendition: { layout: "pre-paginated" } }; const book = { rendition: { layout: "pre-paginated" }, pdf };
const { metadata, info } = (await pdf.getMetadata()) ?? {}; const { metadata, info } = (await pdf.getMetadata()) ?? {};
// TODO: for better results, parse `metadata.getRaw()` // TODO: for better results, parse `metadata.getRaw()`
+5 -1
View File
@@ -251,7 +251,11 @@ export class View extends HTMLElement {
} }
this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated' 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') await import('./fixed-layout.js')
this.renderer = document.createElement('foliate-fxl') this.renderer = document.createElement('foliate-fxl')
} else { } else {
+228
View File
@@ -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);