import { createEffect, onCleanup, onMount, Show, untrack } from "solid-js";
import type { GalleryPreviewCache } from "../../App/GalleryPreviewCache";
import type { GalleryCoordinator } from "../../App/GalleryCoordinator";
import { useOverlayHost } from "../../App/OverlayHost";
import texts from "../../i18n";
import type { LoadedReaderPage, ReaderPage } from "../../readerTypes";
import {
currentReaderControlsState,
currentReaderOrientation,
normalizeReaderScrollSizeScale,
state as appState,
} from "../../state";
import {
clamp,
normalizedAspectRatio,
positiveNumber,
registerGlobalStyle,
} from "../../utils";
import type { ScrollMotion } from "../animation";
import type { PointerDragEnd, PointerGestureCallbacks } from "../PointerGesture";
import {
PagesViewport,
pageWindowNumbers,
type PagesViewportActions,
type PagesViewportCallbacks,
} from "./Viewport";
import {
Toolbar,
type ReaderControls,
type ToolbarCallbacks,
} from "./Toolbar";
import { ZoomOverlay, type ZoomOverlayActions, type ZoomOverlayImage } from "./ZoomOverlay";
import {
doublePagePairStart,
ReaderSession,
type ReaderLoadTarget,
type ReaderOptions,
} from "./session";
import { ReaderScrollBar } from "./ScrollBar";
import { ViewportCanvas, type ViewportCanvasCallbacks } from "./ViewportCanvas";
import readerCss from "./index.css";
registerGlobalStyle("ehpeek-reader-style", readerCss);
const VIEWER_ID = "ehpeek-reader";
const DEFAULT_WINDOW_SIZE = 10;
const PAGED_SWIPE_THRESHOLD = 24;
const PAGED_PREVIEW_SWIPE_THRESHOLD = 48;
const PAGED_PREVIEW_SWIPE_AXIS_LIMIT = 32;
const PAGED_WHEEL_THRESHOLD = 8;
const HORIZONTAL_SCROLL_WHEEL_FACTOR = 0.5;
const PROGRESS_IDLE_COMMIT_MS = 180;
const LOADED_IMAGE_INFO_CACHE_LIMIT = 160;
const PROGRESSIVE_IMAGE_SIZE_THRESHOLD = 2 * 1024 * 1024;
const CONCURRENT_IMAGE_BYTE_LIMIT = 6 * 1024 * 1024;
const MIN_CONCURRENT_IMAGE_LOADS = 3;
const SCROLL_GESTURE_IDLE_MS = 160;
const SCROLL_BAR_IDLE_MS = 900;
const SCROLL_BAR_SHOW_DISTANCE = 48;
const SCROLL_BAR_EXPAND_VIEWPORTS = 2;
const MOUSE_HOLD_ZOOM_MS = 350;
const ZOOM_DOUBLE_TAP_MS = 300;
const ZOOM_DOUBLE_TAP_DISTANCE = 36;
const ZOOM_DOUBLE_TAP_SCALE = 1.2;
const TAP_CANCEL_DISTANCE = 8;
const FALLBACK_ASPECT_RATIO = 1.42;
export type { ReaderOptions } from "./session";
type LoadedReaderImage = ZoomOverlayImage & {
originalImageUrl: string | null;
};
export type ReaderActions = {
gotoPage: (pageNum: number) => void;
};
type ReaderProps = {
coordinator: GalleryCoordinator;
fullscreenActive: boolean;
options: ReaderOptions;
previewCache: GalleryPreviewCache;
};
export function Reader(props: ReaderProps) {
const overlayHost = useOverlayHost();
const options = untrack(() => props.options);
const totalPages = options.totalPages ?? 0;
const previewCache = untrack(() => props.previewCache);
const session = new ReaderSession(options);
const readerState = session.state;
const scrollFitPageNum = readerState.navi.currentPageNum();
const readerCallbacks = wireReaderCallbacks(
session,
options,
previewCache,
untrack(() => props.coordinator),
);
const coordinator = untrack(() => props.coordinator);
coordinator.attachReader({
gotoPage: readerCallbacks.gotoPage,
});
let previousFullscreenActive = untrack(() => props.fullscreenActive);
const viewportPageLayout = () =>
readerState.ctrls.value().pageLayout === "double" &&
readerState.ctrls.value().firstPageSeparate &&
readerState.navi.currentPageNum() === 1
? "single"
: readerState.ctrls.value().pageLayout;
createEffect(() => {
const fullscreenActive = props.fullscreenActive;
if (fullscreenActive === previousFullscreenActive) {
return;
}
previousFullscreenActive = fullscreenActive;
session.requestAnimationFrame(() => {
session.requestAnimationFrame(readerCallbacks.realignCurrentPage);
});
});
onMount(() => {
readerCallbacks.init();
onCleanup(() => {
coordinator.attachReader(null);
readerCallbacks.cleanup();
session.dispose();
});
});
return (
1
}>
readerState.overlay.update(null)}
/>
);
}
function wireReaderCallbacks(
session: ReaderSession,
options: ReaderOptions,
previewCache: GalleryPreviewCache,
coordinator: GalleryCoordinator,
) {
const state = session.state;
let viewportActions!: PagesViewportActions;
let zoomOverlay!: ZoomOverlayActions;
const totalPages = options.totalPages && options.totalPages > 0 ? options.totalPages : undefined;
const renderWindowSize = options.renderWindowSize ?? DEFAULT_WINDOW_SIZE;
const preloadWindowSize = options.preloadWindowSize ?? DEFAULT_WINDOW_SIZE;
const pages = new Map();
const loadedImages = new Map();
let pagedTargetPageNumber: number | null = null;
let syncToken = 0;
let closed = false;
let loadDirection: -1 | 1 = 1;
let loadDirectionEdgePageNum = state.navi.currentPageNum();
const scrollFitPageNum = state.navi.currentPageNum();
const pagedMode = () => state.ctrls.value().navigationMode === "paged";
const doublePageActive = () =>
pagedMode() &&
state.ctrls.value().pageLayout === "double" &&
!(state.ctrls.value().firstPageSeparate && state.navi.currentPageNum() === 1);
const stopViewportMotion = () => {
pagedTargetPageNumber = null;
viewportActions.stopMotion();
};
let readerOrientation = currentReaderOrientation();
const updateReaderViewportSize = () => {
state.scrollViewport.setViewportWidth(Math.max(1, window.innerWidth));
state.scrollViewport.setViewportHeight(Math.max(1, window.innerHeight));
const nextOrientation = currentReaderOrientation();
if (nextOrientation !== readerOrientation) {
readerOrientation = nextOrientation;
updateControls(configuredReaderControls());
}
};
function configuredReaderControls(): ReaderControls {
const controls = currentReaderControlsState();
const navigationMode = controls.navigationMode.value;
return {
navigationMode,
direction: navigationMode === "scroll"
? controls.scrollDirection.value
: controls.pagedDirection.value,
firstPageSeparate: state.ctrls.value().firstPageSeparate,
pageLayout: controls.pageLayout.value,
rightTapAction: controls.rightTapAction.value,
};
}
function updateControls(requestedControls: ReaderControls): void {
const previous = state.ctrls.value();
const persistedControls = currentReaderControlsState();
const controls = requestedControls.navigationMode === previous.navigationMode
? requestedControls
: {
...requestedControls,
direction: requestedControls.navigationMode === "scroll"
? persistedControls.scrollDirection.value
: persistedControls.pagedDirection.value,
};
persistedControls.navigationMode.set(controls.navigationMode);
if (controls.navigationMode === "scroll") {
persistedControls.scrollDirection.set(controls.direction);
} else {
persistedControls.pagedDirection.set(controls.direction);
}
persistedControls.pageLayout.set(controls.pageLayout);
persistedControls.rightTapAction.set(controls.rightTapAction);
state.ctrls.update(controls);
if (controls.navigationMode !== "scroll") {
state.scrollViewport.setAdjusting(false);
}
if (
controls.navigationMode !== previous.navigationMode ||
controls.pageLayout !== previous.pageLayout ||
controls.firstPageSeparate !== previous.firstPageSeparate
) {
stopViewportMotion();
viewportActions.resetPosition();
setCurrentPageNumber(state.navi.currentPageNum(), true);
} else if (controls.direction !== previous.direction) {
syncViewportWindow();
scrollToCurrentPage();
}
}
function requestReaderClose(): void {
if (closed) {
return;
}
closed = coordinator.requestCloseReader();
}
function imageAtPoint(point: { clientX: number; clientY: number }): ZoomOverlayImage | null {
const pageNum = viewportActions.pageNumAtPoint(point);
return pageNum === null || !viewportActions.pageImageReady(pageNum)
? null
: loadedImages.get(pageNum) ?? null;
}
function prepareZoomAtPoint(
point: { clientX: number; clientY: number },
scaleMultiplier = 1,
): boolean {
const image = imageAtPoint(point);
if (!image) {
return false;
}
stopViewportMotion();
viewportActions.cancelDrag();
const zoomScale = viewportActions.pageZoomScale(image.pageNum);
state.overlay.update(image);
zoomOverlay.reset({
centerX: point.clientX,
centerY: point.clientY,
scale: zoomScale * scaleMultiplier,
});
return true;
}
function setCurrentPageNumber(pageNumber: number, scrollIntoView: boolean, scrollMotion: ScrollMotion = "instant"): void {
pagedTargetPageNumber = null;
const target = normalizedPageNumber(
clamp(Math.round(pageNumber), 1, maxProgressPageNum()),
);
if (target !== state.navi.currentPageNum()) {
state.navi.setDirection(target > state.navi.currentPageNum() ? 1 : -1);
state.navi.setCurrentPageNum(target);
}
syncAfterPageChange({ scrollIntoView, scrollMotion });
}
function normalizedPageNumber(pageNum: number): number {
if (
!pagedMode() ||
state.ctrls.value().pageLayout !== "double" ||
(totalPages !== undefined && pageNum === totalPages + 1)
) {
return pageNum;
}
return doublePagePairStart(pageNum, state.ctrls.value().firstPageSeparate);
}
function syncAfterPageChange(options: {
scrollIntoView: boolean;
scrollMotion?: ScrollMotion;
}): void {
const token = ++syncToken;
const numbers = pageWindowNumbers(state.navi.currentPageNum(), renderWindowSize);
const missing = numbers.filter((number) => isRealPageNum(number) && !pages.has(number));
syncViewportWindow();
maintainLoadQueue();
notifyActivePageChange();
if (options.scrollIntoView) {
scrollToCurrentPage({ motion: options.scrollMotion });
}
if (missing.length > 0) {
void loadMissingPages(missing, token);
}
}
async function loadMissingPages(pageNums: number[], token: number): Promise {
const pageGroups = new Map();
for (const pageNum of pageNums) {
const previewIndex = previewCache.previewIndexForPage(pageNum);
pageGroups.set(previewIndex, [...(pageGroups.get(previewIndex) ?? []), pageNum]);
}
await Promise.all(Array.from(pageGroups.values(), async (groupPageNums) => {
const loadingTokens = new Map(groupPageNums.flatMap((pageNum) => {
const loadingToken = viewportActions.markPageLoading(pageNum);
return loadingToken === null ? [] : [[pageNum, loadingToken] as const];
}));
let incoming: ReaderPage[];
try {
incoming = await previewCache.getPages(groupPageNums);
}
catch (error) {
console.error("[ehpeek]", error);
const message = error instanceof Error ? error.message : texts.errors.loadFailed;
for (const [pageNum, loadingToken] of loadingTokens) {
viewportActions.setPageError(pageNum, loadingToken, message);
}
return;
}
if (closed) {
return;
}
addPages(incoming);
const loadedPageNums = new Set(incoming.flatMap((page) =>
page.pageNum && page.pageNum > 0 ? [page.pageNum] : []
));
for (const [pageNum, loadingToken] of loadingTokens) {
if (loadedPageNums.has(pageNum)) {
viewportActions.resetPageLoading(pageNum, loadingToken);
} else {
viewportActions.setPageError(
pageNum,
loadingToken,
texts.errors.imageNotFound,
);
}
}
}));
if (closed || token !== syncToken) {
return;
}
syncViewportWindow();
maintainLoadQueue();
notifyActivePageChange();
if (state.ctrls.value().navigationMode === "scroll" && state.navi.currentPageNum() === scrollFitPageNum) {
scrollToCurrentPage();
}
}
function addPages(incomingPages: ReaderPage[]): void {
for (const [index, page] of incomingPages.entries()) {
const pageNum = pageNumForPage(page, index);
if (pageNum > 0) {
pages.set(pageNum, {
...page,
aspectRatio: normalizedAspectRatio(page.aspectRatio, FALLBACK_ASPECT_RATIO),
pageNum,
});
}
}
}
function syncViewportWindow(): void {
state.navi.setViewportWindow({
currentPageNum: state.navi.currentPageNum(),
windowSize: renderWindowSize,
totalPages: totalPages,
pages: pageMetaForViewport(),
});
updatePageNumber();
}
function maintainLoadQueue(): void {
const firstVisiblePageNum =
viewportActions.firstVisiblePageNum() ?? state.navi.currentPageNum();
const movement = (firstVisiblePageNum - loadDirectionEdgePageNum) *
loadDirection;
if (movement >= 0) {
loadDirectionEdgePageNum = firstVisiblePageNum;
} else if (-movement > 2) {
loadDirection = loadDirection === 1 ? -1 : 1;
loadDirectionEdgePageNum = firstVisiblePageNum;
}
const pageNums = [firstVisiblePageNum];
for (let offset = 1; offset <= preloadWindowSize; offset += 1) {
pageNums.push(firstVisiblePageNum + offset * loadDirection);
}
pageNums.push(firstVisiblePageNum - loadDirection);
session.imageQueue.sync(Array.from(new Set(pageNums)).flatMap((pageNum, priority) => {
const target = loadTargetFor(pageNum);
return target ? [{ key: pageNum, priority, target }] : [];
}));
}
function pageMetaForViewport(): Map {
return new Map(Array.from(pages, ([pageNum, page]) => [pageNum, { aspectRatio: page.aspectRatio }]));
}
function loadTargetFor(pageNum: number): ReaderLoadTarget | null {
const page = pages.get(pageNum);
return page ? { pageNum, page } : null;
}
function maxProgressPageNum(): number {
return totalPages ? totalPages + 1 : Number.MAX_SAFE_INTEGER;
}
function isRealPageNum(pageNum: number): boolean {
return pageNum >= 1 && (!totalPages || pageNum <= totalPages);
}
function turnPageBy(delta: number): void {
if (pagedMode()) {
const base = pagedTargetPageNumber ?? state.navi.currentPageNum();
const firstPageSeparate = state.ctrls.value().firstPageSeparate;
let pageDelta = delta;
if (state.ctrls.value().pageLayout === "double") {
if (totalPages !== undefined && base === totalPages + 1 && delta < 0) {
pageDelta = doublePagePairStart(totalPages, firstPageSeparate) - base;
} else if (firstPageSeparate && base === 1 && delta > 0) {
pageDelta = 1;
} else if (firstPageSeparate && base === 2 && delta < 0) {
pageDelta = -1;
} else {
pageDelta = delta * 2;
}
}
animatePagedStep(pageDelta);
return;
}
setCurrentPageNumber(state.navi.currentPageNum() + delta, true);
}
function animatePagedStep(delta: number): void {
const base = pagedTargetPageNumber ?? state.navi.currentPageNum();
const target = clamp(Math.round(base + delta), 1, maxProgressPageNum());
if (target === base) {
scrollToCurrentPage({ motion: "animated", overrideTarget: false });
return;
}
if (viewportActions.pageOffset(target) === null) {
setCurrentPageNumber(target, true, "animated");
return;
}
state.navi.setDirection(target > base ? 1 : -1);
pagedTargetPageNumber = target;
viewportActions.moveToPage(target, "animated", () => {
if (pagedTargetPageNumber !== target) {
return;
}
setCurrentPageNumber(target, true);
});
}
function scrollToCurrentPage({
motion = "instant",
overrideTarget = true,
}: {
motion?: ScrollMotion;
overrideTarget?: boolean;
} = {}): void {
if (pagedTargetPageNumber !== null && !overrideTarget) {
return;
}
pagedTargetPageNumber = null;
viewportActions.moveToPage(state.navi.currentPageNum(), motion);
}
function updatePageNumber(): void {
const pageNum = state.navi.currentPageNum();
const downloadPageNums = doublePageActive()
? [pageNum, pageNum + 1]
: [pageNum];
state.navi.setDownloadInfos(downloadPageNums.flatMap((downloadPageNum) => {
const image = loadedImages.get(downloadPageNum);
if (!image || !isRealPageNum(downloadPageNum)) {
return [];
}
loadedImages.delete(downloadPageNum);
loadedImages.set(downloadPageNum, image);
const currentFileName = imageDownloadFileName(
options.galleryId,
options.galleryToken,
downloadPageNum,
image.imageUrl,
);
return [{
currentFileName,
currentImageUrl: image.imageUrl,
imageHeight: viewportActions.pageImageHeight(downloadPageNum) ?? image.height,
imageWidth: viewportActions.pageImageWidth(downloadPageNum) ?? image.width,
originalFileName: imageDownloadFileName(
options.galleryId,
options.galleryToken,
downloadPageNum,
image.originalImageUrl ?? "",
image.imageUrl,
),
originalImageUrl: image.originalImageUrl,
pageNum: downloadPageNum,
}];
}));
state.navi.setMaxProgressPageNum(Math.max(1, maxProgressPageNum()));
}
function notifyActivePageChange(): void {
const page = pages.get(state.navi.currentPageNum());
if (page) {
coordinator.readerActivePageChanged(page);
}
}
function updateCurrentFromScroll(): void {
const next = viewportActions.centerPageNum();
if (next !== null && next !== state.navi.currentPageNum()) {
state.navi.setDirection(next > state.navi.currentPageNum() ? 1 : -1);
state.navi.setCurrentPageNum(next);
syncAfterPageChange({ scrollIntoView: false });
return;
}
maintainLoadQueue();
}
const onKeydown = (event: KeyboardEvent): void => {
if (shouldIgnoreKeyboardEvent(event)) {
return;
}
if (event.key === "Escape") {
if (state.overlay.image() !== null) {
state.overlay.update(null);
} else {
requestReaderClose();
}
event.preventDefault();
} else if (
event.key === "ArrowLeft" ||
event.key === "ArrowRight" ||
(state.ctrls.value().direction === "ttb" && (event.key === "ArrowUp" || event.key === "ArrowDown"))
) {
event.preventDefault();
if (state.overlay.image() === null) {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
turnPageBy(event.key === "ArrowUp" ? -1 : 1);
} else {
turnPageBy(event.key === "ArrowLeft" ? state.navi.leftTapDelta() : state.navi.rightTapDelta());
}
}
}
};
const gesture = wireGesture();
const viewport = wireViewport();
const scrollViewport = wireScrollViewport();
wireImageQueue();
const toolbar = wireToolbar();
return {
viewportActionsRef: (actions: PagesViewportActions): void => {
viewportActions = actions;
},
zoomOverlayActionsRef: (actions: ZoomOverlayActions): void => {
zoomOverlay = actions;
},
init: () => {
document.addEventListener("keydown", onKeydown, true);
window.addEventListener("resize", updateReaderViewportSize);
viewportActions.focus();
updatePageNumber();
syncAfterPageChange({ scrollIntoView: true });
},
cleanup: () => {
document.removeEventListener("keydown", onKeydown, true);
window.removeEventListener("resize", updateReaderViewportSize);
},
gotoPage: (pageNum: number) => setCurrentPageNumber(pageNum, true),
realignCurrentPage: () => {
scrollToCurrentPage();
},
toolbar,
viewport,
viewportCanvas: scrollViewport.callbacks,
};
function wireScrollViewport(): {
callbacks: ViewportCanvasCallbacks;
endPinch: () => void;
movePinch: (scale: number) => void;
open: () => void;
pinching: () => boolean;
startPinch: () => boolean;
} {
let adjustmentStartSizeScale = state.scrollViewport.sizeScale();
let pinchStartScale: number | null = null;
const updateImageScale = (scale: number | null): void => {
if (scale === null) {
state.scrollViewport.setSizeScale(null);
return;
}
const fitScale = state.scrollViewport.fitScale();
if (fitScale) {
state.scrollViewport.setSizeScale(normalizeReaderScrollSizeScale(
scale / fitScale,
));
}
};
return {
startPinch: () => {
const scalePercent = state.scrollViewport.scalePercent();
if (scalePercent === null) {
return false;
}
pinchStartScale = scalePercent / 100;
return true;
},
movePinch: (scale) => {
if (pinchStartScale === null) {
return;
}
updateImageScale(clamp(pinchStartScale * scale, 0.1, 5));
},
endPinch: () => {
pinchStartScale = null;
},
pinching: () => pinchStartScale !== null,
open: () => {
adjustmentStartSizeScale = state.scrollViewport.sizeScale();
state.scrollViewport.setAdjusting(true);
},
callbacks: {
onApply: () => state.scrollViewport.setAdjusting(false),
onApplyAll: () => {
const persistedScale = state.ctrls.value().direction === "ttb"
? appState.reader.scrollTtbScale
: appState.reader.scrollHorizontalScale;
persistedScale.set(state.scrollViewport.sizeScale());
state.scrollViewport.setAdjusting(false);
},
onClose: () => {
state.scrollViewport.setSizeScale(adjustmentStartSizeScale);
state.scrollViewport.setAdjusting(false);
},
onFit: () => updateImageScale(null),
onOneToOne: () => state.scrollViewport.setSizeScale("one-to-one"),
onScaleChange: updateImageScale,
},
};
}
function wireViewport(): PagesViewportCallbacks {
let scrollFrame: number | null = null;
let scrollBarTimer: number | null = null;
let scrollGestureTimer: number | null = null;
let previousScrollPosition: number | null = null;
let scrollDistance = 0;
const scrollPosition = () => state.ctrls.value().direction === "ttb"
? viewportActions.scrollTop()
: viewportActions.scrollLeft();
const updateScrollBarActivity = (): void => {
const currentScrollPosition = scrollPosition();
if (previousScrollPosition !== null) {
scrollDistance += Math.abs(currentScrollPosition - previousScrollPosition);
}
previousScrollPosition = currentScrollPosition;
if (scrollDistance >= SCROLL_BAR_SHOW_DISTANCE) {
state.scrollBar.updateVisible(true);
}
const viewportSize = state.ctrls.value().direction === "ttb" ? window.innerHeight : window.innerWidth;
if (scrollDistance >= viewportSize * SCROLL_BAR_EXPAND_VIEWPORTS) {
state.scrollBar.updateExpanded(true);
}
session.clearTimeout(scrollGestureTimer);
scrollGestureTimer = session.setTimeout(() => {
scrollGestureTimer = null;
scrollDistance = 0;
previousScrollPosition = scrollPosition();
}, SCROLL_GESTURE_IDLE_MS);
session.clearTimeout(scrollBarTimer);
scrollBarTimer = session.setTimeout(() => {
scrollBarTimer = null;
scrollDistance = 0;
previousScrollPosition = scrollPosition();
state.scrollBar.updateExpanded(false);
state.scrollBar.updateVisible(false);
}, SCROLL_BAR_IDLE_MS);
};
onCleanup(() => {
session.clearTimeout(scrollBarTimer);
session.clearTimeout(scrollGestureTimer);
});
return {
onNativeScroll: (): void => {
if (state.overlay.image() !== null || pagedMode()) {
return;
}
if (state.scrollViewport.adjusting()) {
return;
}
updateScrollBarActivity();
if (viewportActions.isDragging()) {
return;
}
const previousPosition = scrollPosition();
if (state.ctrls.value().direction === "ttb") {
viewportActions.moveToTop(previousPosition);
} else {
viewportActions.moveToLeft(previousPosition);
}
if (scrollPosition() !== previousPosition || scrollFrame !== null) {
return;
}
scrollFrame = session.requestAnimationFrame(() => {
scrollFrame = null;
updateCurrentFromScroll();
});
},
onReloadPage: (pageNum: number): void => {
if (!viewportActions.resetPageError(pageNum)) {
return;
}
if (pages.has(pageNum)) {
maintainLoadQueue();
} else {
void loadMissingPages([pageNum], ++syncToken);
}
},
onWheel: (delta: number, event: WheelEvent): void => {
const deltaPixels = wheelDeltaPixels(delta, event.deltaMode);
if (state.overlay.image() !== null) {
event.preventDefault();
zoomOverlay.moveWheel({
centerX: event.clientX,
centerY: event.clientY,
delta: deltaPixels,
});
return;
}
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
if (!pagedMode()) {
if (scrollViewport.startPinch()) {
scrollViewport.movePinch(
Math.exp(-clamp(deltaPixels, -100, 100) * 0.0025),
);
scrollViewport.endPinch();
}
return;
}
if (!prepareZoomAtPoint(event)) {
return;
}
zoomOverlay.moveWheel({
centerX: event.clientX,
centerY: event.clientY,
delta: deltaPixels,
});
return;
}
if (!pagedMode() && state.ctrls.value().direction === "ttb") {
return;
}
event.preventDefault();
if (!pagedMode()) {
const direction = state.ctrls.value().direction === "rtl" ? -1 : 1;
viewportActions.moveToLeft(
viewportActions.scrollLeft() +
deltaPixels * direction * HORIZONTAL_SCROLL_WHEEL_FACTOR,
);
return;
}
if (!viewportActions.isDragging() && Math.abs(delta) >= PAGED_WHEEL_THRESHOLD) {
turnPageBy(delta > 0 ? 1 : -1);
}
},
pointer: gesture,
};
}
function wireImageQueue(): void {
const acquireImageLoadBudget = createImageLoadBudget(
CONCURRENT_IMAGE_BYTE_LIMIT,
MIN_CONCURRENT_IMAGE_LOADS,
);
const rememberLoadedImage = (pageNum: number, loaded: LoadedReaderPage): LoadedReaderImage => {
const image = {
pageNum,
imageUrl: loaded.imageUrl,
originalImageUrl: loaded.originalImageUrl ?? null,
width: positiveNumber(loaded.width),
height: positiveNumber(loaded.height),
};
if (pageNum === scrollFitPageNum && image.width && image.height && !state.scrollViewport.fitImageSize()) {
state.scrollViewport.setFitImageSize({ height: image.height, width: image.width });
}
loadedImages.delete(pageNum);
loadedImages.set(pageNum, image);
while (loadedImages.size > LOADED_IMAGE_INFO_CACHE_LIMIT) {
const oldestPageNum = loadedImages.keys().next().value as number | undefined;
if (oldestPageNum === undefined) {
break;
}
loadedImages.delete(oldestPageNum);
}
return image;
};
const installImage = async (
target: ReaderLoadTarget,
loaded: LoadedReaderPage,
token: number,
): Promise => {
const imageUrl = loaded.imageUrl;
const width = positiveNumber(loaded.width);
const height = positiveNumber(loaded.height);
let installed = false;
try {
installed = await viewportActions.loadPageImage(target.pageNum, token, {
displayWhileLoading: shouldDisplayImageWhileLoading(
imageUrl,
loaded.originalImageUrl,
),
imageUrl,
highPriority: target.pageNum === state.navi.currentPageNum() || (
doublePageActive() &&
target.pageNum === state.navi.currentPageNum() + 1
),
width,
height,
});
} catch (error) {
const message = error instanceof Error ? error.message : texts.errors.imageLoadFailed;
viewportActions.setPageError(target.pageNum, token, message);
return;
}
if (installed && target.pageNum === scrollFitPageNum && !state.scrollViewport.fitImageSize()) {
const fitWidth = viewportActions.pageImageWidth(target.pageNum);
const fitHeight = viewportActions.pageImageHeight(target.pageNum);
if (fitWidth && fitHeight) {
state.scrollViewport.setFitImageSize({ height: fitHeight, width: fitWidth });
}
}
if (!closed) {
const currentPageNum = state.navi.currentPageNum();
if (target.pageNum === currentPageNum || (
doublePageActive() &&
target.pageNum === currentPageNum + 1
)) {
updatePageNumber();
}
}
};
session.imageQueue.updateCallbacks({
loadTarget: (target) => Promise.resolve(loadedImages.get(target.pageNum) ?? previewCache.loadImage(target.page)),
markLoading: (target) => viewportActions.markPageLoading(target.pageNum),
onLoaded: async (target, loaded, token) => {
const image = rememberLoadedImage(target.pageNum, loaded);
if (!pageWindowNumbers(state.navi.currentPageNum(), renderWindowSize).includes(target.pageNum)) {
return;
}
const releaseBudget = await acquireImageLoadBudget(
imageFileByteSize(image.imageUrl) ?? CONCURRENT_IMAGE_BYTE_LIMIT,
);
try {
if (!pageWindowNumbers(state.navi.currentPageNum(), renderWindowSize).includes(target.pageNum)) {
return;
}
await installImage(target, image, token);
} finally {
releaseBudget();
}
},
onError: (target, error, token) => {
const message = error instanceof Error ? error.message : texts.errors.loadFailed;
viewportActions.setPageError(target.pageNum, token, message);
},
});
}
function wireToolbar(): ToolbarCallbacks {
const toolbar = {} as ToolbarCallbacks;
let progressNavigationTimer: number | null = null;
let pendingProgressPageNum: number | null = null;
const cancelProgressNavigation = (): void => {
if (progressNavigationTimer !== null) {
session.clearTimeout(progressNavigationTimer);
progressNavigationTimer = null;
}
};
const previewProgress = (pageNum: number): void => {
const target = normalizedPageNumber(
clamp(Math.round(pageNum), 1, maxProgressPageNum()),
);
if (target !== state.navi.currentPageNum()) {
state.navi.setDirection(target > state.navi.currentPageNum() ? 1 : -1);
state.navi.setCurrentPageNum(target);
}
++syncToken;
syncViewportWindow();
scrollToCurrentPage();
updatePageNumber();
};
onCleanup(cancelProgressNavigation);
toolbar.onCloseClick = requestReaderClose;
toolbar.onControlsChange = updateControls;
toolbar.onFullscreenClick = coordinator.toggleReaderFullscreen;
toolbar.onOpenOriginalPageClick = (): void => {
const page = pages.get(state.navi.currentPageNum());
if (page && isRealPageNum(state.navi.currentPageNum())) {
coordinator.openOriginalPage(page);
}
};
toolbar.onOpenScrollPreviewClick = (): void => {
coordinator.openPreviewPage(state.navi.currentPageNum());
};
toolbar.onViewportAdjustClick = scrollViewport.open;
toolbar.onProgressPointerDown = (event: PointerEvent): void => {
state.navi.setProgressInputActive(true);
cancelProgressNavigation();
event.stopPropagation();
};
toolbar.onProgressInput = (pageNum: number): void => {
if (!Number.isFinite(pageNum) || pageNum <= 0) {
return;
}
state.navi.setProgressInputActive(true);
pendingProgressPageNum = clamp(Math.round(pageNum), 1, maxProgressPageNum());
previewProgress(pendingProgressPageNum);
cancelProgressNavigation();
progressNavigationTimer = session.setTimeout(
() => toolbar.onProgressCommit(pendingProgressPageNum ?? state.navi.currentPageNum()),
PROGRESS_IDLE_COMMIT_MS,
);
};
toolbar.onProgressCommit = (value: number): void => {
if (!state.navi.progressInputActive() && pendingProgressPageNum === null) {
return;
}
const pageNum = pendingProgressPageNum ?? value;
state.navi.setProgressInputActive(false);
pendingProgressPageNum = null;
cancelProgressNavigation();
if (Number.isFinite(pageNum) && pageNum > 0) {
setCurrentPageNumber(pageNum, true);
}
};
return toolbar;
}
function wireGesture(): PointerGestureCallbacks {
const gesture: PointerGestureCallbacks = { dragAxis: "any" };
let lastZoomTap: { clientX: number; clientY: number; time: number } | null = null;
const isZoomDoubleTap = (
info: PointerDragEnd,
event: PointerEvent | MouseEvent,
): boolean => {
const now = event.timeStamp || performance.now();
const doubleTap = lastZoomTap !== null &&
now - lastZoomTap.time <= ZOOM_DOUBLE_TAP_MS &&
Math.hypot(
info.clientX - lastZoomTap.clientX,
info.clientY - lastZoomTap.clientY,
) <= ZOOM_DOUBLE_TAP_DISTANCE;
lastZoomTap = doubleTap
? null
: { clientX: info.clientX, clientY: info.clientY, time: now };
return doubleTap;
};
const isPageReloadButtonTarget = (event: PointerEvent | MouseEvent): boolean =>
event.target instanceof Element &&
event.target.closest(".ehpeek-reader-page-reload") !== null;
const shouldStartDrag = (event: PointerEvent): boolean =>
state.overlay.image() !== null ||
pagedMode() ||
state.ctrls.value().direction !== "ttb" ||
event.pointerType === "mouse";
const isPreviewSwipe = (info: PointerDragEnd): boolean => {
if (!pagedMode()) {
return false;
}
return state.ctrls.value().direction === "ttb"
? Math.abs(info.dx) >= PAGED_PREVIEW_SWIPE_THRESHOLD &&
Math.abs(info.dy) <= PAGED_PREVIEW_SWIPE_AXIS_LIMIT
: info.dy >= PAGED_PREVIEW_SWIPE_THRESHOLD &&
Math.abs(info.dx) <= PAGED_PREVIEW_SWIPE_AXIS_LIMIT;
};
const runSingleTap = (info: PointerDragEnd, event: PointerEvent | MouseEvent): void => {
if (state.overlay.image() !== null) {
event.preventDefault();
} else if (viewportActions.isHitEndPage(info)) {
requestReaderClose();
} else {
const zone = info.clientX / viewportActions.viewportWidth();
if (zone >= 1 / 3 && zone <= 2 / 3) {
state.toolbar.toggle();
} else {
turnPageBy(zone < 1 / 3 ? state.navi.leftTapDelta() : state.navi.rightTapDelta());
}
}
};
gesture.onTap = (info: PointerDragEnd, event: PointerEvent | MouseEvent): void => {
viewportActions.cancelDrag();
if (state.overlay.image() !== null) {
if (isZoomDoubleTap(info, event)) {
state.overlay.update(null);
}
event.preventDefault();
return;
}
const zone = info.clientX / viewportActions.viewportWidth();
const centerTap = zone >= 1 / 3 && zone <= 2 / 3;
if (centerTap) {
if (
isZoomDoubleTap(info, event) &&
prepareZoomAtPoint(info, ZOOM_DOUBLE_TAP_SCALE)
) {
state.toolbar.close();
event.preventDefault();
return;
}
} else {
lastZoomTap = null;
}
runSingleTap(info, event);
};
gesture.holdDelay = MOUSE_HOLD_ZOOM_MS;
gesture.onHold = (info, event) => {
const mouseInput = event instanceof PointerEvent
? event.pointerType === "mouse"
: event instanceof MouseEvent;
if (!mouseInput) {
return false;
}
lastZoomTap = null;
if (state.overlay.image() !== null) {
state.overlay.update(null);
return "consume";
}
if (!prepareZoomAtPoint(info)) {
return false;
}
zoomOverlay.movePinch({ centerX: info.clientX, centerY: info.clientY, scale: 2 });
zoomOverlay.endPinch();
return "drag";
};
gesture.onStart = (): void => {
if (state.overlay.image() !== null) {
zoomOverlay.startDrag();
return;
}
pagedTargetPageNumber = null;
viewportActions.beginDrag();
};
gesture.onMove = (info: PointerDragEnd): void => {
if (state.overlay.image() !== null) {
zoomOverlay.moveDrag(info);
return;
}
if (!viewportActions.moveDrag({ dx: info.dx, dy: info.dy })) {
return;
}
};
gesture.onEnd = (info: PointerDragEnd): void => {
if (state.overlay.image() !== null) {
return;
}
viewportActions.cancelDrag();
if (isPreviewSwipe(info)) {
scrollToCurrentPage({ motion: "animated" });
coordinator.openPreviewPage(state.navi.currentPageNum());
return;
}
if (!pagedMode()) {
if (state.ctrls.value().direction === "ttb") {
viewportActions.moveToTop(viewportActions.scrollTop());
viewportActions.startVerticalFlingFromDragVelocity(info.velocityY, () => updateCurrentFromScroll());
} else {
viewportActions.moveToLeft(viewportActions.scrollLeft());
viewportActions.startHorizontalFlingFromDragVelocity(info.velocityX, () => updateCurrentFromScroll());
}
updateCurrentFromScroll();
return;
}
if (state.ctrls.value().direction === "ttb") {
if (info.dy >= PAGED_SWIPE_THRESHOLD) {
turnPageBy(-1);
} else if (info.dy <= -PAGED_SWIPE_THRESHOLD) {
turnPageBy(1);
} else {
scrollToCurrentPage({ motion: "animated" });
}
return;
}
if (info.dx >= PAGED_SWIPE_THRESHOLD) {
turnPageBy(state.navi.rightDragDelta());
}
else if (info.dx <= -PAGED_SWIPE_THRESHOLD) {
turnPageBy(state.navi.leftDragDelta());
}
else {
scrollToCurrentPage({ motion: "animated" });
}
};
gesture.onPinchStart = (info: {
clientX: number;
clientY: number;
}): boolean => {
lastZoomTap = null;
stopViewportMotion();
viewportActions.cancelDrag();
if (!pagedMode() && state.overlay.image() === null) {
return scrollViewport.startPinch();
}
if (state.overlay.image() !== null) {
zoomOverlay.startPinch({ centerX: info.clientX, centerY: info.clientY });
return true;
}
const image = imageAtPoint(info);
if (!image) {
return false;
}
const zoomScale = viewportActions.pageZoomScale(image.pageNum);
state.overlay.update(image);
zoomOverlay.reset({ centerX: info.clientX, centerY: info.clientY, scale: zoomScale });
return true;
};
gesture.onPinchMove = (info: { clientX: number; clientY: number; scale: number }) => {
if (scrollViewport.pinching()) {
scrollViewport.movePinch(info.scale);
return;
}
zoomOverlay.movePinch({
centerX: info.clientX,
centerY: info.clientY,
scale: info.scale,
});
};
gesture.onPinchEnd = () => {
if (scrollViewport.pinching()) {
scrollViewport.endPinch();
return;
}
zoomOverlay.endPinch();
};
gesture.shouldCaptureDrag = (event) => {
if (isPageReloadButtonTarget(event)) {
return false;
}
if (!(event instanceof PointerEvent)) {
return false;
}
if (event.pointerType === "mouse" && event.button !== 0) {
return false;
}
return shouldStartDrag(event);
};
gesture.shouldObserveTap = (event) =>
event instanceof PointerEvent &&
!isPageReloadButtonTarget(event) &&
event.pointerType !== "mouse" &&
!shouldStartDrag(event);
gesture.dragStartThreshold = TAP_CANCEL_DISTANCE;
gesture.tapMoveThreshold = TAP_CANCEL_DISTANCE;
return gesture;
}
}
function wheelDeltaPixels(delta: number, mode: number): number {
if (mode === WheelEvent.DOM_DELTA_LINE) {
return delta * 16;
}
if (mode === WheelEvent.DOM_DELTA_PAGE) {
return delta * window.innerHeight;
}
return delta;
}
function imageDownloadFileName(
galleryId: number,
galleryToken: string,
pageNum: number,
imageUrl: string,
fallbackImageUrl = "",
): string {
const extension = imageFileExtension(imageUrl) ||
imageFileExtension(fallbackImageUrl) ||
"webp";
return `${galleryId}-${galleryToken}-p${pageNum}.${extension}`;
}
function imageFileExtension(imageUrl: string): string {
try {
const fileName = decodeURIComponent(new URL(imageUrl).pathname.split("/").pop() ?? "");
const extension = fileName.match(/\.([a-z0-9]{2,5})$/i)?.[1]?.toLowerCase();
if (extension && ["avif", "bmp", "gif", "jpeg", "jpg", "png", "webp"].includes(extension)) {
return extension;
}
} catch {
return "";
}
return "";
}
function shouldDisplayImageWhileLoading(
imageUrl: string,
originalImageUrl?: string | null,
): boolean {
if (
imageFileExtension(imageUrl) === "gif" ||
(originalImageUrl && imageFileExtension(originalImageUrl) === "gif")
) {
return true;
}
const byteSize = imageFileByteSize(imageUrl);
return byteSize !== null && byteSize > PROGRESSIVE_IMAGE_SIZE_THRESHOLD;
}
function imageFileByteSize(imageUrl: string): number | null {
try {
const hathImageKey = new URL(imageUrl).pathname
.split("/")
.find((part) => /^[a-f0-9]{40}-\d+-\d+-\d+-[a-z0-9]+$/i.test(part));
const byteSize = Number(hathImageKey?.split("-")[1]);
return Number.isSafeInteger(byteSize) && byteSize > 0 ? byteSize : null;
} catch {
return null;
}
}
function createImageLoadBudget(maxBytes: number, minConcurrentLoads: number) {
type Waiter = {
bytes: number;
resolve: (release: () => void) => void;
};
const waiters: Waiter[] = [];
let activeBytes = 0;
let activeLoads = 0;
const drain = (): void => {
const next = waiters[0];
if (
!next ||
(activeLoads >= minConcurrentLoads && activeBytes + next.bytes > maxBytes)
) {
return;
}
waiters.shift();
activeBytes += next.bytes;
activeLoads += 1;
let released = false;
next.resolve(() => {
if (released) {
return;
}
released = true;
activeBytes = Math.max(0, activeBytes - next.bytes);
activeLoads = Math.max(0, activeLoads - 1);
drain();
});
drain();
};
return (bytes: number): Promise<() => void> =>
new Promise((resolve) => {
waiters.push({ bytes, resolve });
drain();
});
}
function pageNumForPage(page: ReaderPage | undefined, index: number): number {
const pageNum = page?.pageNum;
return typeof pageNum === "number" && Number.isFinite(pageNum) && pageNum > 0 ? pageNum : index + 1;
}
function shouldIgnoreKeyboardEvent(event: KeyboardEvent): boolean {
if (event.isComposing) {
return true;
}
const eventTarget = event.target;
if (!(eventTarget instanceof Element)) {
return false;
}
return Boolean(eventTarget.closest("input, textarea, select, [contenteditable='true'], [contenteditable='']"));
}