mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-10-01 07:49:12 -04:00
Merge branch 'zoom-control'
# Conflicts: # pdf.js
This commit is contained in:
+2
-1
@@ -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([`<!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])
|
||||
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
|
||||
|
||||
+441
-12
@@ -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 <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) {
|
||||
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 <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(
|
||||
"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 =
|
||||
|
||||
+152
-4
@@ -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
|
||||
|
||||
@@ -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()`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+228
@@ -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);
|
||||
Reference in New Issue
Block a user