import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, untrack } from "solid-js"; import type { NavigationMode, PageLayout, ReaderScrollSizeScale, ReadDirection, } from "../../state"; import texts from "../../i18n"; import { clamp, normalizedAspectRatio, positiveNumber } from "../../utils"; import { ScrollAnimator, ScrollFlingAnimator, type ScrollMotion } from "../animation"; import { createPointerGestureElement, type PointerGestureCallbacks } from "../PointerGesture"; import { Icon } from "../Widgets/Icon"; const FALLBACK_ASPECT_RATIO = 1.42; const PAGE_SLOT_SPACING = 8; const DEFAULT_DECODED_IMAGE_CACHE_LIMIT = 24; const DECODED_IMAGE_CACHE_BYTES = 96 * 1024 * 1024; const HORIZONTAL_FLING_VELOCITY_MULTIPLIER = 1.4; const HORIZONTAL_FLING_MAX_VELOCITY = 1.8; type PageMeta = { aspectRatio: number; }; type PageState = "idle" | "loading" | "ready" | "error"; type VerticalScrollBounds = { min?: number; max?: number }; type HorizontalScrollBounds = { min?: number; max?: number }; type ViewportCenterAnchor = { pageNum: number; xRatio: number; yRatio: number; }; export type PagesViewportWindowOptions = { currentPageNum: number; windowSize: number; totalPages?: number; pages: Map; }; export function pageWindowNumbers(currentPageNum: number, windowSize: number): number[] { const numbers: number[] = []; for (let offset = -windowSize; offset <= windowSize; offset += 1) { numbers.push(currentPageNum + offset); } return numbers; } type ViewportImage = { displayWhileLoading: boolean; imageUrl: string; highPriority: boolean; width: number | null; height: number | null; }; export type ScrollFitImageSize = { height: number; width: number; }; type PageSlotKind = "page" | "blank" | "end"; type DoublePageSide = "left" | "right" | null; type SlotContent = { pageNum: number; kind: PageSlotKind; state: PageState; errorMessage?: string; }; type SlotElements = { node: HTMLElement; frame: HTMLElement; }; type PageSlot = { pageNum: number; index: number; kind: PageSlotKind; state: PageState; aspectRatio: number; width: number | null; height: number | null; image: HTMLImageElement | null; errorMessage: string | null; frameWidth: number; frameHeight: number; elements: SlotElements | null; token: number; }; type CachedPageImage = { bytes: number; height: number | null; image: HTMLImageElement; width: number | null; }; export type PagesViewportCallbacks = { onNativeScroll: () => void; onReloadPage: (pageNum: number) => void; onWheel: (delta: number, event: WheelEvent) => void; pointer: PointerGestureCallbacks; }; export type PagesViewportActions = { beginDrag: () => void; cancelDrag: () => void; centerPageNum: () => number | null; firstVisiblePageNum: () => number | null; focus: () => void; isDragging: () => boolean; isHitEndPage: (point: { clientX: number; clientY: number }) => boolean; loadPageImage: (pageNum: number, token: number, slotImage: ViewportImage) => Promise; markPageLoading: (pageNum: number) => number | null; moveToPage: (pageNum: number, motion?: ScrollMotion, onComplete?: () => void) => void; moveToTop: (scrollTop: number) => void; moveDrag: (delta: { dx: number; dy: number }) => boolean; moveToLeft: (scrollLeft: number) => void; pageImageHeight: (pageNum: number) => number | null; pageImageReady: (pageNum: number) => boolean; pageImageWidth: (pageNum: number) => number | null; pageZoomScale: (pageNum: number) => number; pageNumAtPoint: (point: { clientX: number; clientY: number }) => number | null; pageOffset: (pageNum: number) => number | null; resetPageError: (pageNum: number) => boolean; resetPageLoading: (pageNum: number, token: number) => boolean; resetPosition: () => void; scrollLeft: () => number; scrollTop: () => number; setPageError: (pageNum: number, token: number, errorMessage: string) => boolean; startHorizontalFlingFromDragVelocity: (dragVelocityX: number, onStop: () => void) => void; startVerticalFlingFromDragVelocity: (dragVelocityY: number, onStop: () => void) => void; stopMotion: () => void; viewportWidth: () => number; }; export function PagesViewport(props: { actionsRef: (actions: PagesViewportActions) => void; callbacks: PagesViewportCallbacks; decodedImageCacheLimit?: number; direction: ReadDirection; navigationMode: NavigationMode; pageLayout: PageLayout; scrollFitImageSize: ScrollFitImageSize | null; scrollFitPageNum: number; scrollSizeScale: ReaderScrollSizeScale; window: PagesViewportWindowOptions; zoomActive: boolean; }) { const [slots, setSlots] = createSignal([]); const [revision, setRevision] = createSignal(0); const [renderedScrollSizeScale, setRenderedScrollSizeScale] = createSignal( untrack(() => props.scrollSizeScale), ); const horizontalAnimator = new ScrollAnimator("x"); const verticalAnimator = new ScrollAnimator("y"); const flingAnimator = new ScrollFlingAnimator(); let pageSlots: PageSlot[] = []; let scroller!: HTMLDivElement; let scrollerApi!: ReturnType; let dragStartPosition: { left: number; top: number } | null = null; let resizeFrame: number | null = null; let scrollScaleRevision = 0; let moveRequestToken = 0; let disposed = false; let syncedDirection = untrack(() => props.direction); let syncedNavigationMode = untrack(() => props.navigationMode); const decodedImageCacheLimit = Math.max( 0, Math.floor(untrack(() => props.decodedImageCacheLimit) ?? DEFAULT_DECODED_IMAGE_CACHE_LIMIT), ); const cachedImages = new Map(); const pageErrors = new Map(); let cachedImageBytes = 0; const refresh = () => setRevision((value) => value + 1); const pagedMode = () => props.navigationMode === "paged"; const horizontalAxis = () => props.direction !== "ttb"; const slotFor = (pageNum: number) => pageSlots.find((slot) => slot.pageNum === pageNum); const viewportWidth = () => scrollerApi.viewportWidth(); const viewportHeight = () => scrollerApi.viewportHeight(); const scrollTop = () => scrollerApi.scrollTop(); const visualSlotIndex = (index: number, slotCount: number) => props.direction === "rtl" ? slotCount - 1 - index : index; const horizontalAnchorOffset = (pageSlots: PageSlot[], anchor: ViewportCenterAnchor): number | null => { const orderedSlots = props.direction === "rtl" ? pageSlots.slice().reverse() : pageSlots; let offset = 0; for (const slot of orderedSlots) { const extent = slot.frameWidth + PAGE_SLOT_SPACING; if (slot.pageNum === anchor.pageNum) { return offset + extent * anchor.xRatio; } offset += extent; } return null; }; const applySlotSize = (slot: PageSlot) => { const aspectRatio = pageSlotAspectRatio(slot); if (pagedMode()) { const availableWidth = props.pageLayout === "double" ? Math.max(1, (viewportWidth() - 3) / 2) : viewportWidth(); if (slot.kind !== "page") { slot.frameWidth = availableWidth; slot.frameHeight = viewportHeight(); return; } const frameSize = containFitFrame(aspectRatio, availableWidth, viewportHeight()); slot.frameWidth = frameSize.width; slot.frameHeight = frameSize.height; return; } const sizeScale = renderedScrollSizeScale(); const reference = props.scrollFitImageSize; const referenceAspectRatio = reference ? reference.height / reference.width : props.window.pages.get(props.scrollFitPageNum)?.aspectRatio ?? FALLBACK_ASPECT_RATIO; const scaleMultiplier = sizeScale === "one-to-one" && reference ? 1 / containFitScale( reference.width, reference.height, viewportWidth(), viewportHeight(), ) : typeof sizeScale === "number" ? sizeScale : 1; const referenceFrame = containFitFrame( referenceAspectRatio, viewportWidth(), viewportHeight(), scaleMultiplier, ); if (horizontalAxis()) { slot.frameHeight = referenceFrame.height; slot.frameWidth = referenceFrame.height / aspectRatio; } else { slot.frameWidth = referenceFrame.width; slot.frameHeight = referenceFrame.width * aspectRatio; } }; const renderSlots = () => { for (const slot of pageSlots) { applySlotSize(slot); } setSlots(pageSlots.slice()); refresh(); }; const refreshSlot = (slot: PageSlot) => { applySlotSize(slot); refresh(); }; const pageOffset = (pageNum: number) => { const elements = slotFor(pageNum)?.elements; return elements ? scrollerApi.slotOffset(elements, props.navigationMode, props.direction, props.pageLayout) : null; }; const verticalScrollBoundsForElements = ( firstElements: SlotElements | null | undefined, lastElements: SlotElements | null | undefined, ): VerticalScrollBounds | null => { const bounds: VerticalScrollBounds = {}; if (firstElements) { bounds.min = scrollerApi.slotTop(firstElements); } if (lastElements) { const lastElementsRect = lastElements.node.getBoundingClientRect(); const lastElementsTop = scrollerApi.slotTop(lastElements); bounds.max = lastElementsTop + lastElementsRect.height - viewportHeight(); } if (bounds.min === undefined && bounds.max === undefined) { return null; } if (bounds.min !== undefined && bounds.max !== undefined) { bounds.max = Math.max(bounds.min, bounds.max); } return bounds; }; const verticalScrollBounds = (): VerticalScrollBounds | null => { if (props.navigationMode !== "scroll" || horizontalAxis()) { return null; } return verticalScrollBoundsForElements( slotFor(1)?.elements, props.window.totalPages ? slotFor(props.window.totalPages + 1)?.elements : null, ); }; const moveToTop = (nextScrollTop: number) => { scrollerApi.moveToTop(nextScrollTop, verticalScrollBounds()); }; const horizontalScrollBounds = (): HorizontalScrollBounds | null => { if (props.navigationMode !== "scroll" || !horizontalAxis()) { return null; } const firstElements = slotFor(1)?.elements; const endElements = props.window.totalPages ? slotFor(props.window.totalPages + 1)?.elements : null; const bounds: HorizontalScrollBounds = {}; if (props.direction === "rtl") { if (firstElements) { bounds.max = scrollerApi.slotLeft(firstElements) + firstElements.node.getBoundingClientRect().width - viewportWidth(); } if (endElements) { bounds.min = scrollerApi.slotLeft(endElements); } } else { if (firstElements) { bounds.min = scrollerApi.slotLeft(firstElements); } if (endElements) { bounds.max = scrollerApi.slotLeft(endElements) + endElements.node.getBoundingClientRect().width - viewportWidth(); } } if (bounds.min !== undefined && bounds.max !== undefined) { bounds.max = Math.max(bounds.min, bounds.max); } return bounds.min === undefined && bounds.max === undefined ? null : bounds; }; const moveToLeft = (nextScrollLeft: number) => { const bounds = horizontalScrollBounds(); scrollerApi.moveToLeft(bounds ? clamp(nextScrollLeft, bounds.min ?? Number.NEGATIVE_INFINITY, bounds.max ?? Number.POSITIVE_INFINITY) : nextScrollLeft); }; const pageNumAtPoint = (point: { clientX: number; clientY: number }): number | null => { const element = document.elementFromPoint(point.clientX, point.clientY); const pageNode = element instanceof Element ? element.closest(".ehpeek-page") : null; if (!pageNode || !scroller.contains(pageNode)) { return null; } const pageNum = Number(pageNode.dataset.ehpeekPageNum || ""); return Number.isFinite(pageNum) ? pageNum : null; }; const stopMotion = () => { moveRequestToken += 1; dragStartPosition = null; flingAnimator.cancel(); horizontalAnimator.cancel(); verticalAnimator.cancel(); }; const performPageMove = (pageNum: number, motion: ScrollMotion, onComplete?: () => void): boolean => { const delta = pageOffset(pageNum); if (delta === null) { return false; } if (horizontalAxis()) { horizontalAnimator.scrollTo(scroller, scrollerApi.scrollLeft() + delta, motion, onComplete); } else if (pagedMode()) { verticalAnimator.scrollTo(scroller, scrollTop() + delta, motion, onComplete); } else { moveToTop(scrollTop() + delta); onComplete?.(); } return true; }; const moveToPage = (pageNum: number, motion: ScrollMotion = "instant", onComplete?: () => void): void => { const requestToken = ++moveRequestToken; if (performPageMove(pageNum, motion, onComplete)) { return; } queueMicrotask(() => { untrack(() => { if (!disposed && requestToken === moveRequestToken) { performPageMove(pageNum, motion, onComplete); } }); }); }; const resizePages = () => { for (const slot of pageSlots) { applySlotSize(slot); } refresh(); }; const gestureDragging = createPointerGestureElement( () => scroller ?? null, () => props.callbacks.pointer, ); const syncWindow = (options: PagesViewportWindowOptions) => { const preserveHorizontalPosition = props.navigationMode === "scroll" && horizontalAxis() && syncedNavigationMode === props.navigationMode && syncedDirection === props.direction; const anchor = preserveHorizontalPosition ? scrollerApi.centerAnchor() : null; const oldAnchorOffset = anchor ? horizontalAnchorOffset(pageSlots, anchor) : null; const oldScrollLeft = scrollerApi.scrollLeft(); const oldSlots = new Map(pageSlots.map((slot) => [slot.pageNum, slot])); const nextSlots: PageSlot[] = []; for (const pageNum of pageWindowNumbers(options.currentPageNum, options.windowSize)) { const kind = pageSlotKind(pageNum, options.totalPages); const oldSlot = oldSlots.get(pageNum); const slot = oldSlot && oldSlot.kind === kind ? oldSlot : pageSlot(pageNum, kind); if (!oldSlot && kind === "page") { const errorMessage = pageErrors.get(pageNum); const cached = cachedImages.get(pageNum); if (errorMessage) { slot.state = "error"; slot.errorMessage = errorMessage; } else if (cached) { cachedImages.delete(pageNum); cachedImageBytes -= cached.bytes; slot.state = "ready"; slot.image = cached.image; slot.width = cached.width; slot.height = cached.height; } } if (kind === "page") { const page = options.pages.get(pageNum); if (page) { applyPageMetaToSlot(slot, page); } } else { clearNonPageSlotMeta(slot); } nextSlots.push(slot); } const nextSet = new Set(nextSlots); for (const slot of pageSlots) { if (!nextSet.has(slot)) { if (slot.kind === "page" && slot.state === "ready" && slot.image) { const width = positiveNumber(slot.image.naturalWidth) ?? slot.width; const height = positiveNumber(slot.image.naturalHeight) ?? slot.height; const cached = { bytes: width && height ? width * height * 4 : 0, height, image: slot.image, width, }; const previous = cachedImages.get(slot.pageNum); if (previous) { cachedImageBytes -= previous.bytes; } cachedImages.delete(slot.pageNum); cachedImages.set(slot.pageNum, cached); cachedImageBytes += cached.bytes; } slot.token += 1; } } while (cachedImages.size > decodedImageCacheLimit || cachedImageBytes > DECODED_IMAGE_CACHE_BYTES) { const oldest = cachedImages.entries().next().value as [number, CachedPageImage] | undefined; if (!oldest) { break; } cachedImages.delete(oldest[0]); cachedImageBytes -= oldest[1].bytes; oldest[1].image.removeAttribute("src"); } pageSlots = nextSlots; pageSlots.forEach((slot, index) => { slot.index = index; }); renderSlots(); const newAnchorOffset = anchor ? horizontalAnchorOffset(pageSlots, anchor) : null; if (oldAnchorOffset !== null && newAnchorOffset !== null) { moveToLeft(oldScrollLeft + newAnchorOffset - oldAnchorOffset); } syncedNavigationMode = props.navigationMode; syncedDirection = props.direction; }; const actions: PagesViewportActions = { focus: () => scroller.focus({ preventScroll: true }), isDragging: gestureDragging, beginDrag(): void { stopMotion(); dragStartPosition = { left: scrollerApi.scrollLeft(), top: scrollTop(), }; }, cancelDrag: () => { dragStartPosition = null; }, moveDrag(delta): boolean { if (dragStartPosition === null) { return false; } if (!pagedMode()) { moveToLeft(dragStartPosition.left - delta.dx); moveToTop(dragStartPosition.top - delta.dy); } else if (horizontalAxis()) { scrollerApi.moveToLeft(dragStartPosition.left - delta.dx); } else { moveToTop(dragStartPosition.top - delta.dy); } return true; }, moveToLeft, resetPosition: () => scrollerApi.resetPosition(), stopMotion, markPageLoading(pageNum): number | null { const slot = slotFor(pageNum); if (!slot || slot.kind !== "page" || slot.state !== "idle") { return null; } slot.state = "loading"; slot.errorMessage = null; slot.token += 1; refreshSlot(slot); return slot.token; }, async loadPageImage(pageNum, token, slotImage): Promise { const image = pageImageDom(pageNum, slotImage); const pendingSlot = slotFor(pageNum); if (pendingSlot && pendingSlot.token === token) { if (slotImage.displayWhileLoading) { pendingSlot.image = image; } pendingSlot.width = slotImage.width; pendingSlot.height = slotImage.height; refreshSlot(pendingSlot); } await loadImage(image); const slot = slotFor(pageNum); if (!slot || slot.token !== token || !slot.elements) { return false; } slot.state = "ready"; slot.image = image; slot.errorMessage = null; pageErrors.delete(pageNum); slot.width = positiveNumber(image.naturalWidth) ?? slotImage.width; slot.height = positiveNumber(image.naturalHeight) ?? slotImage.height; refreshSlot(slot); return true; }, setPageError(pageNum, token, errorMessage): boolean { const slot = slotFor(pageNum); if (!slot || slot.token !== token) { return false; } slot.state = "error"; slot.image = null; slot.errorMessage = errorMessage; pageErrors.set(pageNum, errorMessage); refresh(); return true; }, resetPageError(pageNum): boolean { const slot = slotFor(pageNum); if (!slot || slot.kind !== "page" || slot.state !== "error") { return false; } slot.state = "idle"; slot.errorMessage = null; pageErrors.delete(pageNum); refreshSlot(slot); return true; }, resetPageLoading(pageNum, token): boolean { const slot = slotFor(pageNum); if (!slot || slot.kind !== "page" || slot.state !== "loading" || slot.token !== token) { return false; } slot.state = "idle"; refreshSlot(slot); return true; }, moveToPage, moveToTop, scrollLeft: () => scrollerApi.scrollLeft(), scrollTop, viewportWidth, pageOffset, centerPageNum(): number | null { for (const slot of pageSlots) { if (slot.elements && slot.kind !== "blank" && scrollerApi.slotContainsViewportTarget(slot.elements, props.direction)) { return slot.pageNum; } } return null; }, firstVisiblePageNum(): number | null { let first: { distance: number; pageNum: number } | null = null; for (const slot of pageSlots) { if (!slot.elements || slot.kind !== "page") { continue; } const distance = scrollerApi.slotViewportStartDistance( slot.elements, props.direction, ); if (distance !== null && (!first || distance < first.distance)) { first = { distance, pageNum: slot.pageNum }; } } return first?.pageNum ?? null; }, isHitEndPage(point): boolean { const pageNum = pageNumAtPoint(point); return pageNum !== null && slotFor(pageNum)?.kind === "end"; }, pageImageHeight(pageNum): number | null { const slot = slotFor(pageNum); return slot?.image?.naturalHeight || slot?.height || null; }, pageImageReady(pageNum): boolean { const slot = slotFor(pageNum); return slot?.state === "ready" && slot.image !== null; }, pageImageWidth(pageNum): number | null { const slot = slotFor(pageNum); return slot?.image?.naturalWidth || slot?.width || null; }, pageZoomScale(pageNum): number { const slot = slotFor(pageNum); const frameRect = slot?.elements?.frame.getBoundingClientRect(); const imageWidth = slot?.image?.naturalWidth || slot?.width; const imageHeight = slot?.image?.naturalHeight || slot?.height; if (!frameRect || !imageWidth || !imageHeight) { return 1; } const readerScale = Math.min(frameRect.width / imageWidth, frameRect.height / imageHeight); const overlayScale = Math.min( 1, viewportWidth() / imageWidth, viewportHeight() / imageHeight, ); return readerScale > 0 && overlayScale > 0 ? readerScale / overlayScale : 1; }, pageNumAtPoint, startVerticalFlingFromDragVelocity(dragVelocityY, onStop): void { flingAnimator.start({ axis: "y", scroller, initialVelocity: -dragVelocityY, setScrollPosition: moveToTop, canRun: () => !disposed && props.navigationMode === "scroll" && !horizontalAxis(), onStop, }); }, startHorizontalFlingFromDragVelocity(dragVelocityX, onStop): void { flingAnimator.start({ axis: "x", scroller, initialVelocity: -dragVelocityX * HORIZONTAL_FLING_VELOCITY_MULTIPLIER, maxVelocity: HORIZONTAL_FLING_MAX_VELOCITY, setScrollPosition: moveToLeft, canRun: () => !disposed && props.navigationMode === "scroll" && horizontalAxis(), onStop, }); }, }; untrack(() => props.actionsRef(actions)); createEffect(() => syncWindow(props.window)); createEffect(() => { const navigationMode = props.navigationMode; const direction = props.direction; const scrollFitImageSize = props.scrollFitImageSize; const scrollSizeScale = props.scrollSizeScale; void scrollFitImageSize; if (navigationMode !== "scroll") { setRenderedScrollSizeScale(1); return; } const anchor = scrollerApi.centerAnchor(); const scaleRevision = ++scrollScaleRevision; setRenderedScrollSizeScale(scrollSizeScale); untrack(resizePages); queueMicrotask(() => { if (disposed || scaleRevision !== scrollScaleRevision) { return; } if (anchor) { scrollerApi.restoreCenterAnchor(anchor); } else if (direction === "ttb") { scrollerApi.centerHorizontal(); } else { scrollerApi.centerVertical(); } }); }); const scrollStripStyle = () => { void revision(); if (props.navigationMode !== "scroll") { return {}; } if (props.direction === "ttb") { return { width: `${Math.max(viewportWidth(), ...pageSlots.map((slot) => slot.frameWidth))}px`, }; } return { height: `${Math.max(viewportHeight(), ...pageSlots.map((slot) => slot.frameHeight))}px`, width: "max-content", }; }; const stripClass = () => { if (props.navigationMode === "paged") { if (props.direction === "ttb" && props.pageLayout === "double") { return "grid grid-cols-2 auto-rows-[100%] w-full h-full gap-x-3px"; } return props.direction === "ttb" ? "flex flex-col w-full h-full" : `flex flex-row w-auto h-full${props.pageLayout === "double" ? " gap-3px" : ""}`; } if (props.direction === "ttb") { return "flex flex-col min-h-full mx-auto py-56px px-0 pb-72px"; } return `flex flex-row min-w-full my-auto py-0 ${props.direction === "rtl" ? "pl-72px pr-56px" : "pl-56px pr-72px"}`; }; onMount(() => { const observer = new ResizeObserver(() => { if (resizeFrame !== null) { return; } resizeFrame = window.requestAnimationFrame(() => { resizeFrame = null; untrack(resizePages); }); }); observer.observe(scroller); onCleanup(() => observer.disconnect()); }); onCleanup(() => { disposed = true; stopMotion(); for (const cached of cachedImages.values()) { cached.image.removeAttribute("src"); } cachedImages.clear(); cachedImageBytes = 0; if (resizeFrame !== null) { window.cancelAnimationFrame(resizeFrame); resizeFrame = null; } }); return (
{ scroller = element; scrollerApi = createPagesScroller(element); }} class={ "w-full h-full overflow-auto overscroll-contain scroll-auto cursor-grab scrollbar-hidden " + (!props.zoomActive && props.navigationMode === "scroll" && props.direction === "ttb" ? "[touch-action:pan-x_pan-y] " : "touch-none ") + "[&[data-dragging=true]]:(cursor-grabbing select-none) " + "[#ehpeek-reader[data-navigation-mode=paged]_&]:(overflow-hidden touch-none select-none)" } tabIndex={-1} onScroll={() => props.callbacks.onNativeScroll()} onWheel={(event: WheelEvent) => { const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; props.callbacks.onWheel(delta, event); }} >
{(slot) => ( props.callbacks.onReloadPage(pageNum)} /> )}
); } function PageSlotView(props: { doublePageSide: DoublePageSide; direction: ReadDirection; navigationMode: NavigationMode; pageLayout: PageLayout; slot: PageSlot; revision: number; visualIndex: number; onReloadPage: (pageNum: number) => void; }) { let node!: HTMLElement; let frame!: HTMLElement; const content = createMemo(() => { void props.revision; return { pageNum: props.slot.pageNum, kind: props.slot.kind, state: props.slot.state, errorMessage: props.slot.errorMessage ?? undefined, }; }); const image = createMemo(() => { void props.revision; return props.slot.state === "ready" || props.slot.state === "loading" ? props.slot.image : null; }); const imageLoading = createMemo(() => { void props.revision; return props.slot.state === "loading" && props.slot.image !== null; }); const slotStyle = createMemo(() => { void props.revision; const frameShortSide = Math.min(props.slot.frameWidth, props.slot.frameHeight); return { "--reader-page-height": `${props.slot.frameHeight + PAGE_SLOT_SPACING}px`, "--reader-page-width": `${props.slot.frameWidth + PAGE_SLOT_SPACING}px`, "--reader-frame-width": `${props.slot.frameWidth}px`, "--reader-frame-height": `${props.slot.frameHeight}px`, "--reader-end-font-size": `${Math.max(10, frameShortSide * 0.11)}px`, "--reader-end-padding": `${Math.min(24, Math.max(4, frameShortSide * 0.06))}px`, order: String(props.visualIndex), }; }); onCleanup(() => { if (props.slot.elements?.node === node) { props.slot.elements = null; } }); return (
{ frame = element; props.slot.elements = { node, frame }; }} class="relative flex w-[var(--reader-frame-width)] h-[var(--reader-frame-height)] items-center justify-center overflow-hidden [container-type:size]" > } > {(currentImage) => currentImage}
); } function doublePageSide( pageNum: number, currentPageNum: number, navigationMode: NavigationMode, pageLayout: PageLayout, direction: ReadDirection, ): DoublePageSide { if (navigationMode !== "paged" || pageLayout !== "double") { return null; } const firstInPair = Math.abs(pageNum - currentPageNum) % 2 === 0; if (direction === "rtl") { return firstInPair ? "right" : "left"; } return firstInPair ? "left" : "right"; } function pageSlotClass( navigationMode: NavigationMode, direction: ReadDirection, pageLayout: PageLayout, ): string { if (navigationMode === "scroll") { return direction === "ttb" ? "w-full h-[var(--reader-page-height)] items-start pb-sm" : "flex-[0_0_var(--reader-page-width)] w-[var(--reader-page-width)] h-full pr-sm"; } if (direction === "ttb") { return pageLayout === "double" ? "w-full h-full" : "flex-[0_0_100%] w-full h-full"; } return pageLayout === "double" ? "h-full" : "flex-[0_0_100%] w-full h-full"; } function PageSlotPlaceholder(props: { content: SlotContent; text: string; onReloadPage: (pageNum: number) => void; }) { const stop = (event: Event) => { event.preventDefault(); event.stopPropagation(); }; return (
}>
{texts.common.status.failed}
{props.content.errorMessage}
); } function pageImageDom(pageNum: number, slotImage: ViewportImage): HTMLImageElement { const image = document.createElement("img"); image.className = "block w-full h-full object-contain select-none [-webkit-user-drag:none]"; image.alt = `Page ${pageNum}`; image.decoding = "async"; image.loading = "eager"; image.draggable = false; image.setAttribute("fetchpriority", slotImage.highPriority ? "high" : "low"); image.src = slotImage.imageUrl; if (slotImage.width && slotImage.height) { image.width = slotImage.width; image.height = slotImage.height; } return image; } async function loadImage(image: HTMLImageElement): Promise { if (image.complete && image.naturalWidth > 0) { return; } await new Promise((resolve, reject) => { image.addEventListener("load", () => resolve(), { once: true }); image.addEventListener("error", () => reject(new Error(texts.errors.imageLoadFailed)), { once: true }); }); try { await image.decode(); } catch { // Loaded is enough. } } function createPagesScroller(element: HTMLElement) { const clampedTop = (scrollTop: number, bounds?: VerticalScrollBounds | null): number => { if (!bounds) { return scrollTop; } return clamp(scrollTop, bounds.min ?? Number.NEGATIVE_INFINITY, bounds.max ?? Number.POSITIVE_INFINITY); }; return { element, resetPosition(): void { element.scrollLeft = 0; element.scrollTop = 0; }, scrollLeft(): number { return element.scrollLeft; }, scrollTop(): number { return element.scrollTop; }, viewportWidth(): number { return element.clientWidth || window.innerWidth || 1; }, viewportHeight(): number { return element.clientHeight; }, moveToLeft(scrollLeft: number): void { element.scrollLeft = scrollLeft; }, centerHorizontal(): void { element.scrollLeft = Math.max(0, (element.scrollWidth - element.clientWidth) / 2); }, centerVertical(): void { element.scrollTop = Math.max(0, (element.scrollHeight - element.clientHeight) / 2); }, centerAnchor(): ViewportCenterAnchor | null { const viewportRect = element.getBoundingClientRect(); const centerX = viewportRect.left + viewportRect.width / 2; const centerY = viewportRect.top + viewportRect.height / 2; const pages = Array.from(element.querySelectorAll(".ehpeek-page")); let closest: { distance: number; node: HTMLElement } | null = null; for (const node of pages) { const rect = node.getBoundingClientRect(); const dx = centerX < rect.left ? rect.left - centerX : centerX > rect.right ? centerX - rect.right : 0; const dy = centerY < rect.top ? rect.top - centerY : centerY > rect.bottom ? centerY - rect.bottom : 0; const distance = Math.hypot(dx, dy); if (!closest || distance < closest.distance) { closest = { distance, node }; } } if (!closest) { return null; } const rect = closest.node.getBoundingClientRect(); const pageNum = Number(closest.node.dataset.ehpeekPageNum || ""); return Number.isFinite(pageNum) && rect.width > 0 && rect.height > 0 ? { pageNum, xRatio: (centerX - rect.left) / rect.width, yRatio: (centerY - rect.top) / rect.height, } : null; }, restoreCenterAnchor(anchor: ViewportCenterAnchor): void { const node = element.querySelector(`.ehpeek-page[data-ehpeek-page-num="${anchor.pageNum}"]`); if (!node) { return; } const viewportRect = element.getBoundingClientRect(); const pageRect = node.getBoundingClientRect(); const centerX = viewportRect.left + viewportRect.width / 2; const centerY = viewportRect.top + viewportRect.height / 2; element.scrollLeft += pageRect.left + pageRect.width * anchor.xRatio - centerX; element.scrollTop += pageRect.top + pageRect.height * anchor.yRatio - centerY; }, moveToTop(scrollTop: number, bounds?: VerticalScrollBounds | null): void { element.scrollTop = clampedTop(scrollTop, bounds); }, slotTop(elements: SlotElements): number { const elementsRect = elements.node.getBoundingClientRect(); const scrollerRect = element.getBoundingClientRect(); return element.scrollTop + elementsRect.top - scrollerRect.top; }, slotLeft(elements: SlotElements): number { const elementsRect = elements.node.getBoundingClientRect(); const scrollerRect = element.getBoundingClientRect(); return element.scrollLeft + elementsRect.left - scrollerRect.left; }, slotOffset( elements: SlotElements, navigationMode: NavigationMode, direction: ReadDirection, pageLayout: PageLayout, ): number { const pageRect = elements.node.getBoundingClientRect(); const scrollerRect = element.getBoundingClientRect(); if (direction === "ttb") { return pageRect.top - scrollerRect.top; } if (direction === "rtl" && (navigationMode === "scroll" || pageLayout === "double")) { return pageRect.right - scrollerRect.right; } return pageRect.left - scrollerRect.left; }, slotContainsViewportTarget(elements: SlotElements, direction: ReadDirection): boolean { const scrollerRect = element.getBoundingClientRect(); const rect = elements.node.getBoundingClientRect(); if (direction === "ttb") { const target = scrollerRect.top + Math.min(80, scrollerRect.height * 0.14); return rect.top <= target && rect.bottom > target; } const offset = Math.min(80, scrollerRect.width * 0.14); const target = direction === "rtl" ? scrollerRect.right - offset : scrollerRect.left + offset; return rect.left <= target && rect.right > target; }, slotViewportStartDistance( elements: SlotElements, direction: ReadDirection, ): number | null { const scrollerRect = element.getBoundingClientRect(); const rect = elements.node.getBoundingClientRect(); if ( rect.bottom <= scrollerRect.top || rect.top >= scrollerRect.bottom || rect.right <= scrollerRect.left || rect.left >= scrollerRect.right ) { return null; } if (direction === "ttb") { return Math.max(0, rect.top - scrollerRect.top); } return direction === "rtl" ? Math.max(0, scrollerRect.right - rect.right) : Math.max(0, rect.left - scrollerRect.left); }, }; } function slotPlaceholderText(content: SlotContent): string { if (content.state === "error") { return texts.common.status.failed; } if (content.kind === "end") { return texts.reader.end; } if (content.kind === "blank") { return ""; } return String(content.pageNum); } function pageSlotKind(pageNum: number, totalPages: number | undefined): PageSlotKind { if (pageNum < 1) { return "blank"; } if (totalPages && pageNum === totalPages + 1) { return "end"; } if (totalPages && pageNum > totalPages + 1) { return "blank"; } return "page"; } function pageSlot(pageNum: number, kind: PageSlotKind): PageSlot { return { pageNum, index: 0, kind, state: kind === "page" ? "idle" : "ready", aspectRatio: FALLBACK_ASPECT_RATIO, width: null, height: null, image: null, errorMessage: null, frameWidth: 1, frameHeight: Math.ceil(FALLBACK_ASPECT_RATIO), elements: null, token: 0, }; } function applyPageMetaToSlot(slot: PageSlot, page: PageMeta): void { const aspectRatio = normalizedAspectRatio(page.aspectRatio, FALLBACK_ASPECT_RATIO); if (slot.aspectRatio === aspectRatio) { return; } slot.aspectRatio = aspectRatio; } function clearNonPageSlotMeta(slot: PageSlot): void { if (slot.kind !== "blank" && slot.kind !== "end") { return; } slot.state = "ready"; slot.image = null; slot.errorMessage = null; slot.width = null; slot.height = null; slot.token += 1; } function pageSlotAspectRatio(slot: PageSlot): number { return slot.width && slot.height && slot.width > 0 && slot.height > 0 ? slot.height / slot.width : normalizedAspectRatio(slot.aspectRatio, FALLBACK_ASPECT_RATIO); } export function containFitScale( imageWidth: number, imageHeight: number, viewportWidth: number, viewportHeight: number, ): number { return Math.min( Math.max(1, viewportWidth) / Math.max(1, imageWidth), Math.max(1, viewportHeight) / Math.max(1, imageHeight), ); } function containFitFrame( aspectRatio: number, viewportWidth: number, viewportHeight: number, scale = 1, ): { height: number; width: number } { const width = Math.max(1, Math.min( Math.max(1, viewportWidth), Math.max(1, viewportHeight) / aspectRatio, ) * scale); return { height: width * aspectRatio, width }; }