2297 lines
75 KiB
TypeScript
2297 lines
75 KiB
TypeScript
import type { CSSProperties, JSX } from "preact";
|
||
import { createPortal } from "preact/compat";
|
||
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
||
import {
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronUp,
|
||
ChevronsDown,
|
||
Download,
|
||
LoaderCircle,
|
||
Maximize2,
|
||
Minimize2,
|
||
RefreshCw,
|
||
ScanSearch,
|
||
X,
|
||
} from "./icons";
|
||
import type { GalleryImage } from "./types";
|
||
import {
|
||
canPreloadViewerNeighbors,
|
||
getViewerOriginalAsset,
|
||
getViewerOriginalStatus,
|
||
getReadyViewerViewportRenderAsset,
|
||
getViewerThumbnailStatus,
|
||
getViewerViewportRenderAsset,
|
||
prepareViewerImages,
|
||
supportsViewerViewportBitmaps,
|
||
viewerOriginalUrl,
|
||
viewerThumbnailUrl,
|
||
} from "./viewerAssets";
|
||
|
||
const MIN_SCALE = 1;
|
||
const MAX_SCALE = 5;
|
||
const DOUBLE_TAP_SCALE = 2.5;
|
||
const DOUBLE_TAP_DELAY_MS = 280;
|
||
const VIEWER_SCROLL_EPSILON = 2;
|
||
const WHEEL_HANDOFF_DELAY_MS = 220;
|
||
const MAX_RENDER_EDGE = 4096;
|
||
const MAX_NATIVE_IMAGE_EDGE = 12_288;
|
||
const MOBILE_VIEWPORT_RENDER_EDGE = 2048;
|
||
const MOBILE_SWIPE_MOTION_MS = 170;
|
||
const MOBILE_SWIPE_CLEANUP_MS = MOBILE_SWIPE_MOTION_MS + 50;
|
||
const MOBILE_RENDER_SETTLE_MS = MOBILE_SWIPE_CLEANUP_MS + 16;
|
||
const MOBILE_NEIGHBOR_PRELOAD_DELAY_MS = 120;
|
||
const TOUCH_SWIPE_MIN_DISTANCE_PX = 36;
|
||
const TOUCH_SWIPE_MAX_DISTANCE_PX = 56;
|
||
const TOUCH_SWIPE_DISTANCE_RATIO = 0.08;
|
||
const TOUCH_SWIPE_FLICK_MIN_DISTANCE_PX = 20;
|
||
const TOUCH_SWIPE_FLICK_VELOCITY = 0.32;
|
||
|
||
type ViewerTransform = {
|
||
scale: number;
|
||
x: number;
|
||
y: number;
|
||
};
|
||
|
||
type PointerPoint = {
|
||
x: number;
|
||
y: number;
|
||
};
|
||
|
||
type GestureStart = {
|
||
point: PointerPoint;
|
||
transform: ViewerTransform;
|
||
scrollTop: number;
|
||
startedAt: number;
|
||
pointerType: string;
|
||
};
|
||
|
||
type GestureMode = "pan" | "navigate" | "page" | "dismiss" | "pinch";
|
||
|
||
type ViewerPage = "image" | "transition" | "details";
|
||
|
||
type PinchStart = {
|
||
distance: number;
|
||
midpoint: PointerPoint;
|
||
transform: ViewerTransform;
|
||
};
|
||
|
||
type ViewerSourcePresentation = "direct" | "upgrade";
|
||
|
||
type ViewerSourceStrategy = "viewport-upgrade" | "bounded-canvas" | "direct-original";
|
||
|
||
type ViewerSourceState = {
|
||
id: string;
|
||
attempt: number;
|
||
loaded: boolean;
|
||
failed: boolean;
|
||
presentation: ViewerSourcePresentation;
|
||
};
|
||
|
||
type ViewerDisplayState = {
|
||
sourceLoaded: boolean;
|
||
thumbnailLoaded: boolean;
|
||
viewportBitmapRenderer: boolean;
|
||
nativeOriginalActive: boolean;
|
||
};
|
||
|
||
type ThumbnailLoadState = {
|
||
id: string;
|
||
loaded: boolean;
|
||
failed: boolean;
|
||
};
|
||
|
||
type FullResolutionState = {
|
||
id: string;
|
||
requested: boolean;
|
||
loaded: boolean;
|
||
failed: boolean;
|
||
};
|
||
|
||
type PreparedSwipeSnapshot = {
|
||
imageId: string;
|
||
element: HTMLDivElement;
|
||
};
|
||
|
||
type ViewportSize = {
|
||
width: number;
|
||
height: number;
|
||
};
|
||
|
||
type GestureGeometry = {
|
||
surfaceWidth: number;
|
||
surfaceHeight: number;
|
||
mediaWidth: number;
|
||
mediaHeight: number;
|
||
surfaceBounds: DOMRect;
|
||
};
|
||
|
||
type GestureHandlers = {
|
||
begin: (id: number, point: PointerPoint, pointerType: string) => void;
|
||
move: (id: number, point: PointerPoint) => void;
|
||
finish: (id: number, point: PointerPoint, pointerType: string) => void;
|
||
cancel: () => void;
|
||
};
|
||
|
||
type IdleScheduler = Window & {
|
||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||
cancelIdleCallback?: (handle: number) => void;
|
||
};
|
||
|
||
const DEFAULT_TRANSFORM: ViewerTransform = { scale: 1, x: 0, y: 0 };
|
||
|
||
function readViewportSize(): ViewportSize {
|
||
return {
|
||
width: window.visualViewport?.width ?? window.innerWidth,
|
||
height: window.visualViewport?.height ?? window.innerHeight,
|
||
};
|
||
}
|
||
|
||
function useViewportSize(): ViewportSize {
|
||
const [size, setSize] = useState(readViewportSize);
|
||
useEffect(() => {
|
||
const viewport = window.visualViewport;
|
||
let frame = 0;
|
||
const schedule = () => {
|
||
if (frame) return;
|
||
frame = window.requestAnimationFrame(() => {
|
||
frame = 0;
|
||
setSize(readViewportSize());
|
||
});
|
||
};
|
||
window.addEventListener("resize", schedule);
|
||
window.addEventListener("orientationchange", schedule);
|
||
viewport?.addEventListener("resize", schedule);
|
||
return () => {
|
||
if (frame) window.cancelAnimationFrame(frame);
|
||
window.removeEventListener("resize", schedule);
|
||
window.removeEventListener("orientationchange", schedule);
|
||
viewport?.removeEventListener("resize", schedule);
|
||
};
|
||
}, []);
|
||
return size;
|
||
}
|
||
|
||
function clamp(value: number, minimum: number, maximum: number): number {
|
||
return Math.min(maximum, Math.max(minimum, value));
|
||
}
|
||
|
||
function pointDistance(first: PointerPoint, second: PointerPoint): number {
|
||
return Math.hypot(second.x - first.x, second.y - first.y);
|
||
}
|
||
|
||
function pointMidpoint(first: PointerPoint, second: PointerPoint): PointerPoint {
|
||
return {
|
||
x: (first.x + second.x) / 2,
|
||
y: (first.y + second.y) / 2,
|
||
};
|
||
}
|
||
|
||
function rubberBand(distance: number, limit: number): number {
|
||
if (!distance) return 0;
|
||
return Math.sign(distance) * limit * (1 - Math.exp(-Math.abs(distance) / limit));
|
||
}
|
||
|
||
function mouseSideDirection(button: number): -1 | 1 | null {
|
||
if (button === 3) return -1;
|
||
if (button === 4) return 1;
|
||
return null;
|
||
}
|
||
|
||
function viewerMediaDimensions(
|
||
image: GalleryImage,
|
||
viewport: ViewportSize,
|
||
): { width: number; height: number } {
|
||
const availableWidth = Math.max(1, viewport.width);
|
||
const availableHeight = Math.max(1, viewport.height);
|
||
const imageRatio = image.width / Math.max(1, image.height);
|
||
const width = Math.min(availableWidth, availableHeight * imageRatio);
|
||
return { width, height: width / imageRatio };
|
||
}
|
||
|
||
function viewportRenderDimensions(
|
||
image: GalleryImage,
|
||
viewport: ViewportSize,
|
||
): { width: number; height: number } {
|
||
const media = viewerMediaDimensions(image, viewport);
|
||
const density = Math.min(3, Math.max(1, window.devicePixelRatio || 1));
|
||
const requestedWidth = media.width * density;
|
||
const requestedHeight = media.height * density;
|
||
const scale = Math.min(
|
||
1,
|
||
MOBILE_VIEWPORT_RENDER_EDGE / Math.max(1, requestedWidth, requestedHeight),
|
||
);
|
||
return {
|
||
width: Math.max(1, Math.round(requestedWidth * scale)),
|
||
height: Math.max(1, Math.round(requestedHeight * scale)),
|
||
};
|
||
}
|
||
|
||
function selectViewerSourceStrategy(
|
||
image: GalleryImage,
|
||
renderSize: ViewportSize,
|
||
viewportBitmapsSupported: boolean,
|
||
): ViewerSourceStrategy {
|
||
const sourceFitsViewport = image.width <= renderSize.width
|
||
&& image.height <= renderSize.height;
|
||
if (viewportBitmapsSupported && !sourceFitsViewport) return "viewport-upgrade";
|
||
if (Math.max(image.width, image.height) > MAX_NATIVE_IMAGE_EDGE) return "bounded-canvas";
|
||
return "direct-original";
|
||
}
|
||
|
||
function isDirectOriginalReady(
|
||
image: GalleryImage,
|
||
strategy: ViewerSourceStrategy,
|
||
displayedSources: ReadonlySet<string>,
|
||
): boolean {
|
||
return strategy === "direct-original" && (
|
||
displayedSources.has(viewerOriginalUrl(image))
|
||
|| getViewerOriginalStatus(image) === "ready"
|
||
);
|
||
}
|
||
|
||
function initialViewerSourceState(id: string, ready: boolean): ViewerSourceState {
|
||
return {
|
||
id,
|
||
attempt: 0,
|
||
loaded: ready,
|
||
failed: false,
|
||
presentation: ready ? "direct" : "upgrade",
|
||
};
|
||
}
|
||
|
||
function pendingViewerSourceState(
|
||
id: string,
|
||
attempt: number,
|
||
failed = false,
|
||
): ViewerSourceState {
|
||
return { id, attempt, loaded: false, failed, presentation: "upgrade" };
|
||
}
|
||
|
||
function resolveViewerDisplaySource({
|
||
sourceLoaded,
|
||
thumbnailLoaded,
|
||
viewportBitmapRenderer,
|
||
nativeOriginalActive,
|
||
}: ViewerDisplayState): "original" | "viewport-bitmap" | "thumbnail" | "placeholder" {
|
||
if (nativeOriginalActive) return "original";
|
||
if (sourceLoaded) return viewportBitmapRenderer ? "viewport-bitmap" : "original";
|
||
return thumbnailLoaded ? "thumbnail" : "placeholder";
|
||
}
|
||
|
||
function drawViewerCanvas(
|
||
canvas: HTMLCanvasElement,
|
||
source: CanvasImageSource,
|
||
width: number,
|
||
height: number,
|
||
sourceKey: string,
|
||
): void {
|
||
const context = canvas.getContext("2d", { alpha: true });
|
||
if (!context) throw new Error("canvas renderer is unavailable");
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
context.imageSmoothingEnabled = true;
|
||
context.imageSmoothingQuality = "high";
|
||
context.clearRect(0, 0, width, height);
|
||
context.drawImage(source, 0, 0, width, height);
|
||
canvas.dataset.renderedSource = sourceKey;
|
||
}
|
||
|
||
function SimilarImageCard({
|
||
image,
|
||
onOpen,
|
||
}: {
|
||
image: GalleryImage;
|
||
onOpen: (image: GalleryImage) => void;
|
||
}) {
|
||
return (
|
||
<figure
|
||
className="viewer-similar-card"
|
||
title={image.name}
|
||
data-image-id={image.id}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label={`查看相似图片 ${image.name}`}
|
||
onClick={() => onOpen(image)}
|
||
onKeyDown={(event) => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
onOpen(image);
|
||
}}
|
||
>
|
||
<div className="viewer-similar-card-media">
|
||
<img
|
||
src={viewerThumbnailUrl(image)}
|
||
alt={image.name}
|
||
loading="lazy"
|
||
decoding="async"
|
||
width={image.width}
|
||
height={image.height}
|
||
draggable={false}
|
||
onError={(event) => {
|
||
event.currentTarget.dataset.failed = "true";
|
||
}}
|
||
/>
|
||
<span className="viewer-similar-card-fallback" aria-hidden="true">
|
||
<RefreshCw size={18} />
|
||
</span>
|
||
</div>
|
||
<figcaption>{image.name}</figcaption>
|
||
</figure>
|
||
);
|
||
}
|
||
|
||
function SimilarImageMasonry({
|
||
images,
|
||
total,
|
||
columnCount,
|
||
hasMore,
|
||
loadingMore,
|
||
error,
|
||
onLoadMore,
|
||
onOpen,
|
||
}: {
|
||
images: GalleryImage[];
|
||
total: number;
|
||
columnCount: number;
|
||
hasMore: boolean;
|
||
loadingMore: boolean;
|
||
error: string | null;
|
||
onLoadMore: () => void;
|
||
onOpen: (image: GalleryImage) => void;
|
||
}) {
|
||
const columns = Array.from({ length: columnCount }, () => [] as GalleryImage[]);
|
||
const heights = Array.from({ length: columnCount }, () => 0);
|
||
for (const image of images) {
|
||
const target = heights.indexOf(Math.min(...heights));
|
||
columns[target]!.push(image);
|
||
heights[target] += image.height / Math.max(1, image.width) + 0.12;
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
className="viewer-similar-masonry"
|
||
data-columns={columnCount}
|
||
style={{ "--similar-columns": columnCount } as CSSProperties}
|
||
>
|
||
{columns.map((column, index) => (
|
||
<div key={index} className="viewer-similar-column">
|
||
{column.map((candidate) => (
|
||
<SimilarImageCard key={candidate.id} image={candidate} onOpen={onOpen} />
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{error && (
|
||
<div className="viewer-similar-inline-error" role="alert">
|
||
<span>{error}</span>
|
||
<button type="button" onClick={onLoadMore}>重试</button>
|
||
</div>
|
||
)}
|
||
{!error && (
|
||
<div className="viewer-similar-pagination">
|
||
{hasMore ? (
|
||
<button
|
||
type="button"
|
||
className="viewer-similar-load-more"
|
||
onClick={onLoadMore}
|
||
disabled={loadingMore}
|
||
aria-busy={loadingMore}
|
||
>
|
||
{loadingMore && <LoaderCircle className="spin" size={15} />}
|
||
<span>{loadingMore ? "正在加载" : "加载更多相似图片"}</span>
|
||
{!loadingMore && <small>{images.length} / {total}</small>}
|
||
</button>
|
||
) : (
|
||
<span className="viewer-similar-count">
|
||
已显示全部 {total.toLocaleString()} 张相似图片
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SimilarImageSkeleton() {
|
||
return (
|
||
<div className="viewer-similar-skeleton" aria-label="正在查找相似图片">
|
||
{[1, 2, 3, 4, 5, 6].map((item) => <span key={item} />)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ImageViewer({
|
||
images,
|
||
activeIndex,
|
||
total,
|
||
hasMore,
|
||
loadingMore,
|
||
onNavigate,
|
||
onClose,
|
||
similarActive,
|
||
similarImages,
|
||
similarTotal,
|
||
similarHasMore,
|
||
similarLoading,
|
||
similarLoadingMore,
|
||
similarError,
|
||
onSearchSimilar,
|
||
onLoadMoreSimilar,
|
||
onOpenSimilar,
|
||
}: {
|
||
images: GalleryImage[];
|
||
activeIndex: number;
|
||
total: number;
|
||
hasMore: boolean;
|
||
loadingMore: boolean;
|
||
onNavigate: (direction: -1 | 1) => void;
|
||
onClose: () => void;
|
||
similarActive: boolean;
|
||
similarImages: GalleryImage[];
|
||
similarTotal: number;
|
||
similarHasMore: boolean;
|
||
similarLoading: boolean;
|
||
similarLoadingMore: boolean;
|
||
similarError: string | null;
|
||
onSearchSimilar: (image: GalleryImage) => void;
|
||
onLoadMoreSimilar: () => void;
|
||
onOpenSimilar: (image: GalleryImage) => void;
|
||
}) {
|
||
const image = images[activeIndex]!;
|
||
const viewport = useViewportSize();
|
||
const compactViewport = viewport.width <= 720;
|
||
const mediaDimensions = viewerMediaDimensions(image, viewport);
|
||
const viewportRender = viewportRenderDimensions(image, viewport);
|
||
const viewportBitmapCapable = supportsViewerViewportBitmaps();
|
||
const sourceStrategy = selectViewerSourceStrategy(
|
||
image,
|
||
viewportRender,
|
||
viewportBitmapCapable,
|
||
);
|
||
const useViewportBitmapRenderer = sourceStrategy === "viewport-upgrade";
|
||
const useSafeCanvasRenderer = sourceStrategy === "bounded-canvas";
|
||
const useCanvasRenderer = sourceStrategy !== "direct-original";
|
||
const canPrevious = activeIndex > 0;
|
||
const canNext = activeIndex < images.length - 1 || hasMore;
|
||
const waitingForNext = loadingMore && activeIndex === images.length - 1;
|
||
const dialogRef = useRef<HTMLDivElement>(null);
|
||
const detailsSectionRef = useRef<HTMLElement>(null);
|
||
const similarSectionRef = useRef<HTMLElement>(null);
|
||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||
const mediaRef = useRef<HTMLDivElement>(null);
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const pointersRef = useRef(new Map<number, PointerPoint>());
|
||
const gestureStartRef = useRef<GestureStart | null>(null);
|
||
const pinchStartRef = useRef<PinchStart | null>(null);
|
||
const gestureGeometryRef = useRef<GestureGeometry | null>(null);
|
||
const gestureAxisRef = useRef<"x" | "y" | null>(null);
|
||
const gestureModeRef = useRef<GestureMode | null>(null);
|
||
const pinchedRef = useRef(false);
|
||
const lastTapRef = useRef<{ at: number; point: PointerPoint } | null>(null);
|
||
const lastTouchAtRef = useRef(0);
|
||
const gestureHandlersRef = useRef<GestureHandlers | null>(null);
|
||
const retryTimerRef = useRef(0);
|
||
const sourceUpgradeTimerRef = useRef(0);
|
||
const scrollTopRef = useRef(0);
|
||
const scrollPageRef = useRef<ViewerPage>("image");
|
||
const wheelZoomBlockedUntilRef = useRef(0);
|
||
const displayedOriginalsRef = useRef(new Set<string>());
|
||
const transformFrameRef = useRef(0);
|
||
const pendingTransformRef = useRef<ViewerTransform | null>(null);
|
||
const swipeCleanupTimerRef = useRef(0);
|
||
const swipeOutgoingRef = useRef<HTMLElement | null>(null);
|
||
const preparedSwipeSnapshotRef = useRef<PreparedSwipeSnapshot | null>(null);
|
||
const [dragging, setDragging] = useState(false);
|
||
const [scrollPage, setScrollPage] = useState<ViewerPage>("image");
|
||
const [fullscreen, setFullscreen] = useState(false);
|
||
const [fullscreenAvailable, setFullscreenAvailable] = useState(
|
||
() => typeof document !== "undefined"
|
||
&& document.fullscreenEnabled
|
||
&& "requestFullscreen" in HTMLElement.prototype,
|
||
);
|
||
const [transform, setTransform] = useState<ViewerTransform>(DEFAULT_TRANSFORM);
|
||
const transformRef = useRef<ViewerTransform>(DEFAULT_TRANSFORM);
|
||
const [thumbnailState, setThumbnailState] = useState<ThumbnailLoadState>({
|
||
id: image.id,
|
||
loaded: getViewerThumbnailStatus(image) === "ready",
|
||
failed: false,
|
||
});
|
||
const directOriginalReady = isDirectOriginalReady(
|
||
image,
|
||
sourceStrategy,
|
||
displayedOriginalsRef.current,
|
||
);
|
||
const [loadState, setLoadState] = useState<ViewerSourceState>(
|
||
initialViewerSourceState(image.id, directOriginalReady),
|
||
);
|
||
const [fullResolutionState, setFullResolutionState] = useState<FullResolutionState>({
|
||
id: image.id,
|
||
requested: false,
|
||
loaded: false,
|
||
failed: false,
|
||
});
|
||
const currentLoadState = loadState.id === image.id
|
||
? loadState
|
||
: initialViewerSourceState(image.id, directOriginalReady);
|
||
const currentFullResolution = fullResolutionState.id === image.id
|
||
? fullResolutionState
|
||
: { id: image.id, requested: false, loaded: false, failed: false };
|
||
const thumbnailLoaded = thumbnailState.id === image.id
|
||
? thumbnailState.loaded
|
||
: getViewerThumbnailStatus(image) === "ready";
|
||
const thumbnailFailed = thumbnailState.id === image.id && thumbnailState.failed;
|
||
const fullSource = viewerOriginalUrl(image, currentLoadState.attempt);
|
||
|
||
const viewerIsBusy = (includeSwipe = true) => pointersRef.current.size > 0
|
||
|| (includeSwipe && Boolean(dialogRef.current?.hasAttribute("data-swipe-direction")));
|
||
|
||
const markOriginalReady = (
|
||
imageId: string,
|
||
attempt: number,
|
||
presentation: ViewerSourcePresentation = "upgrade",
|
||
) => {
|
||
window.clearTimeout(sourceUpgradeTimerRef.current);
|
||
const commit = () => {
|
||
sourceUpgradeTimerRef.current = 0;
|
||
if (dialogRef.current?.dataset.imageId !== imageId) return;
|
||
if (presentation === "upgrade" && viewerIsBusy()) {
|
||
sourceUpgradeTimerRef.current = window.setTimeout(commit, 48);
|
||
return;
|
||
}
|
||
setLoadState((current) => {
|
||
if (current.id === imageId && current.attempt !== attempt) return current;
|
||
if (
|
||
current.id === imageId
|
||
&& current.loaded
|
||
&& (current.presentation === "direct" || presentation === "upgrade")
|
||
) return current;
|
||
return {
|
||
id: imageId,
|
||
attempt,
|
||
loaded: true,
|
||
failed: false,
|
||
presentation,
|
||
};
|
||
});
|
||
};
|
||
commit();
|
||
};
|
||
|
||
const applyTransformToDom = (next: ViewerTransform) => {
|
||
const media = mediaRef.current;
|
||
if (media) {
|
||
media.style.transform = `translate3d(${next.x}px, ${next.y}px, 0) scale(${next.scale})`;
|
||
}
|
||
const viewer = dialogRef.current;
|
||
if (!viewer) return;
|
||
const surfaceHeight = gestureGeometryRef.current?.surfaceHeight
|
||
?? surfaceRef.current?.clientHeight
|
||
?? 640;
|
||
const dismissProgress = next.scale === MIN_SCALE
|
||
? clamp(Math.max(0, next.y) / Math.max(1, surfaceHeight), 0, 0.45)
|
||
: 0;
|
||
viewer.style.setProperty("--viewer-dismiss-progress", String(dismissProgress));
|
||
const zoomed = next.scale > MIN_SCALE;
|
||
viewer.dataset.zoomed = String(zoomed);
|
||
if (useViewportBitmapRenderer) {
|
||
const nativeActive = zoomed && viewer.dataset.nativeOriginalLoaded === "true";
|
||
viewer.dataset.nativeOriginalActive = String(nativeActive);
|
||
viewer.dataset.displaySource = resolveViewerDisplaySource({
|
||
sourceLoaded: viewer.dataset.fullLoaded === "true",
|
||
thumbnailLoaded: viewer.dataset.thumbnailLoaded === "true",
|
||
viewportBitmapRenderer: true,
|
||
nativeOriginalActive: nativeActive,
|
||
});
|
||
}
|
||
};
|
||
|
||
const cancelTransformFrame = () => {
|
||
if (transformFrameRef.current) {
|
||
window.cancelAnimationFrame(transformFrameRef.current);
|
||
transformFrameRef.current = 0;
|
||
}
|
||
pendingTransformRef.current = null;
|
||
};
|
||
|
||
const flushStagedTransform = () => {
|
||
if (transformFrameRef.current) {
|
||
window.cancelAnimationFrame(transformFrameRef.current);
|
||
transformFrameRef.current = 0;
|
||
}
|
||
const pending = pendingTransformRef.current;
|
||
pendingTransformRef.current = null;
|
||
if (pending) applyTransformToDom(pending);
|
||
};
|
||
|
||
const stageTransform = (next: ViewerTransform) => {
|
||
transformRef.current = next;
|
||
pendingTransformRef.current = next;
|
||
if (transformFrameRef.current) return;
|
||
transformFrameRef.current = window.requestAnimationFrame(() => {
|
||
transformFrameRef.current = 0;
|
||
const pending = pendingTransformRef.current;
|
||
pendingTransformRef.current = null;
|
||
if (pending) applyTransformToDom(pending);
|
||
});
|
||
};
|
||
|
||
const commitTransform = (next: ViewerTransform) => {
|
||
cancelTransformFrame();
|
||
transformRef.current = next;
|
||
applyTransformToDom(next);
|
||
setTransform(next);
|
||
};
|
||
|
||
const syncScrollPage = (top: number) => {
|
||
const viewer = dialogRef.current;
|
||
const detailsTop = detailsSectionRef.current?.offsetTop ?? viewer?.clientHeight ?? 0;
|
||
const nextPage: ViewerPage = top <= VIEWER_SCROLL_EPSILON
|
||
? "image"
|
||
: detailsTop > 0 && top >= detailsTop - VIEWER_SCROLL_EPSILON
|
||
? "details"
|
||
: "transition";
|
||
if (scrollPageRef.current === nextPage) return;
|
||
scrollPageRef.current = nextPage;
|
||
setScrollPage(nextPage);
|
||
};
|
||
|
||
const forceViewerScroll = (top: number) => {
|
||
const viewer = dialogRef.current;
|
||
if (!viewer) return;
|
||
const previousBehavior = viewer.style.scrollBehavior;
|
||
viewer.style.scrollBehavior = "auto";
|
||
viewer.scrollTop = top;
|
||
viewer.style.scrollBehavior = previousBehavior;
|
||
scrollTopRef.current = viewer.scrollTop;
|
||
syncScrollPage(viewer.scrollTop);
|
||
};
|
||
|
||
const constrainTransform = (
|
||
next: ViewerTransform,
|
||
geometry = gestureGeometryRef.current,
|
||
): ViewerTransform => {
|
||
const scale = clamp(next.scale, MIN_SCALE, MAX_SCALE);
|
||
if (scale <= MIN_SCALE) return DEFAULT_TRANSFORM;
|
||
|
||
const surface = surfaceRef.current;
|
||
const media = mediaRef.current;
|
||
const surfaceWidth = geometry?.surfaceWidth ?? surface?.clientWidth;
|
||
const surfaceHeight = geometry?.surfaceHeight ?? surface?.clientHeight;
|
||
const mediaWidth = geometry?.mediaWidth ?? media?.offsetWidth;
|
||
const mediaHeight = geometry?.mediaHeight ?? media?.offsetHeight;
|
||
if (
|
||
surfaceWidth === undefined
|
||
|| surfaceHeight === undefined
|
||
|| mediaWidth === undefined
|
||
|| mediaHeight === undefined
|
||
) return { scale, x: next.x, y: next.y };
|
||
const maximumX = Math.max(0, (mediaWidth * scale - surfaceWidth) / 2);
|
||
const maximumY = Math.max(0, (mediaHeight * scale - surfaceHeight) / 2);
|
||
return {
|
||
scale,
|
||
x: clamp(next.x, -maximumX, maximumX),
|
||
y: clamp(next.y, -maximumY, maximumY),
|
||
};
|
||
};
|
||
|
||
const zoomAt = (nextScale: number, point?: PointerPoint) => {
|
||
const surface = surfaceRef.current;
|
||
const current = transformRef.current;
|
||
const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
|
||
if (scale <= MIN_SCALE || !surface) {
|
||
commitTransform(DEFAULT_TRANSFORM);
|
||
return;
|
||
}
|
||
if ((dialogRef.current?.scrollTop ?? scrollTopRef.current) > VIEWER_SCROLL_EPSILON) {
|
||
return;
|
||
}
|
||
forceViewerScroll(0);
|
||
|
||
const bounds = surface.getBoundingClientRect();
|
||
const center = {
|
||
x: bounds.left + bounds.width / 2,
|
||
y: bounds.top + bounds.height / 2,
|
||
};
|
||
const anchor = point ?? center;
|
||
const imagePoint = {
|
||
x: (anchor.x - center.x - current.x) / current.scale,
|
||
y: (anchor.y - center.y - current.y) / current.scale,
|
||
};
|
||
commitTransform(constrainTransform({
|
||
scale,
|
||
x: anchor.x - center.x - imagePoint.x * scale,
|
||
y: anchor.y - center.y - imagePoint.y * scale,
|
||
}));
|
||
};
|
||
|
||
const zoomBy = (amount: number) => {
|
||
zoomAt(transformRef.current.scale + amount);
|
||
};
|
||
|
||
const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
|
||
const scrollToDetails = (focus = false) => {
|
||
commitTransform(DEFAULT_TRANSFORM);
|
||
wheelZoomBlockedUntilRef.current = performance.now() + WHEEL_HANDOFF_DELAY_MS;
|
||
const viewer = dialogRef.current;
|
||
const details = detailsSectionRef.current;
|
||
if (!viewer || !details) return;
|
||
viewer.scrollTo({
|
||
top: details.offsetTop,
|
||
behavior: reducedMotion() ? "auto" : "smooth",
|
||
});
|
||
if (focus) {
|
||
window.requestAnimationFrame(() => details.focus({ preventScroll: true }));
|
||
}
|
||
};
|
||
|
||
const scrollToImage = (focus = false) => {
|
||
wheelZoomBlockedUntilRef.current = performance.now() + WHEEL_HANDOFF_DELAY_MS;
|
||
dialogRef.current?.scrollTo({
|
||
top: 0,
|
||
behavior: reducedMotion() ? "auto" : "smooth",
|
||
});
|
||
if (focus) {
|
||
window.requestAnimationFrame(() => closeButtonRef.current?.focus({ preventScroll: true }));
|
||
}
|
||
};
|
||
|
||
const scrollToSimilar = () => {
|
||
commitTransform(DEFAULT_TRANSFORM);
|
||
wheelZoomBlockedUntilRef.current = performance.now() + WHEEL_HANDOFF_DELAY_MS;
|
||
window.requestAnimationFrame(() => {
|
||
similarSectionRef.current?.scrollIntoView({
|
||
behavior: reducedMotion() ? "auto" : "smooth",
|
||
block: "start",
|
||
});
|
||
});
|
||
};
|
||
|
||
const parkViewerControlFocus = () => {
|
||
const viewer = dialogRef.current;
|
||
const active = document.activeElement;
|
||
if (
|
||
viewer
|
||
&& active instanceof HTMLElement
|
||
&& active !== viewer
|
||
&& viewer.contains(active)
|
||
) {
|
||
viewer.focus({ preventScroll: true });
|
||
}
|
||
};
|
||
|
||
const clearSwipeMotion = () => {
|
||
window.clearTimeout(swipeCleanupTimerRef.current);
|
||
swipeCleanupTimerRef.current = 0;
|
||
dialogRef.current?.removeAttribute("data-swipe-direction");
|
||
const outgoing = swipeOutgoingRef.current;
|
||
swipeOutgoingRef.current = null;
|
||
outgoing?.getAnimations().forEach((animation) => animation.cancel());
|
||
outgoing?.remove();
|
||
};
|
||
|
||
const prepareSwipeSnapshot = (): PreparedSwipeSnapshot | null => {
|
||
const media = mediaRef.current;
|
||
if (!media) return null;
|
||
const snapshot = media.cloneNode(true) as HTMLDivElement;
|
||
snapshot.classList.add("viewer-swipe-outgoing");
|
||
snapshot.setAttribute("aria-hidden", "true");
|
||
snapshot.querySelectorAll<HTMLElement>("[id]").forEach((element) => {
|
||
element.removeAttribute("id");
|
||
});
|
||
const thumbnail = snapshot.querySelector<HTMLElement>(".viewer-thumbnail");
|
||
snapshot.querySelectorAll(".viewer-original").forEach((original) => original.remove());
|
||
if (thumbnail) {
|
||
thumbnail.style.opacity = "1";
|
||
thumbnail.style.filter = "none";
|
||
}
|
||
return {
|
||
imageId: image.id,
|
||
element: snapshot,
|
||
};
|
||
};
|
||
|
||
const takePreparedSwipeSnapshot = () => {
|
||
const media = mediaRef.current;
|
||
const prepared = preparedSwipeSnapshotRef.current;
|
||
if (
|
||
!media
|
||
|| !prepared
|
||
|| prepared.imageId !== image.id
|
||
) return null;
|
||
preparedSwipeSnapshotRef.current = null;
|
||
const bounds = media.getBoundingClientRect();
|
||
const snapshot = prepared.element;
|
||
Object.assign(snapshot.style, {
|
||
position: "fixed",
|
||
left: `${bounds.left}px`,
|
||
top: `${bounds.top}px`,
|
||
width: `${bounds.width}px`,
|
||
height: `${bounds.height}px`,
|
||
transform: "translate3d(0, 0, 0)",
|
||
});
|
||
document.body.append(snapshot);
|
||
return snapshot;
|
||
};
|
||
|
||
const navigateWithSwipeMotion = (direction: -1 | 1) => {
|
||
if (!compactViewport || reducedMotion()) {
|
||
commitTransform(DEFAULT_TRANSFORM);
|
||
onNavigate(direction);
|
||
return;
|
||
}
|
||
|
||
clearSwipeMotion();
|
||
const releaseStartedAt = performance.now();
|
||
const outgoing = takePreparedSwipeSnapshot();
|
||
const viewer = dialogRef.current;
|
||
if (viewer) viewer.dataset.swipeDirection = direction > 0 ? "next" : "previous";
|
||
commitTransform(DEFAULT_TRANSFORM);
|
||
onNavigate(direction);
|
||
if (viewer) {
|
||
viewer.dataset.swipeSnapshot = outgoing ? "prepared" : "skipped";
|
||
viewer.dataset.swipeReleaseCostMs = (performance.now() - releaseStartedAt).toFixed(2);
|
||
}
|
||
|
||
if (outgoing) {
|
||
swipeOutgoingRef.current = outgoing;
|
||
const travel = Math.max(128, viewport.width * 0.38) * -direction;
|
||
const animation = outgoing.animate(
|
||
[
|
||
{ opacity: 1, transform: "translate3d(0, 0, 0) scale(1)" },
|
||
{ opacity: 0, transform: `translate3d(${travel}px, 0, 0) scale(.985)` },
|
||
],
|
||
{
|
||
duration: MOBILE_SWIPE_MOTION_MS,
|
||
easing: "cubic-bezier(.18, .82, .24, 1)",
|
||
fill: "forwards",
|
||
},
|
||
);
|
||
void animation.finished.catch(() => undefined).then(() => {
|
||
if (swipeOutgoingRef.current !== outgoing) return;
|
||
swipeOutgoingRef.current = null;
|
||
outgoing.remove();
|
||
});
|
||
}
|
||
|
||
swipeCleanupTimerRef.current = window.setTimeout(() => {
|
||
dialogRef.current?.removeAttribute("data-swipe-direction");
|
||
const staleOutgoing = swipeOutgoingRef.current;
|
||
swipeOutgoingRef.current = null;
|
||
staleOutgoing?.remove();
|
||
swipeCleanupTimerRef.current = 0;
|
||
}, MOBILE_SWIPE_CLEANUP_MS);
|
||
};
|
||
|
||
useLayoutEffect(() => {
|
||
window.clearTimeout(retryTimerRef.current);
|
||
window.clearTimeout(sourceUpgradeTimerRef.current);
|
||
sourceUpgradeTimerRef.current = 0;
|
||
pointersRef.current.clear();
|
||
gestureStartRef.current = null;
|
||
pinchStartRef.current = null;
|
||
gestureGeometryRef.current = null;
|
||
gestureAxisRef.current = null;
|
||
gestureModeRef.current = null;
|
||
pinchedRef.current = false;
|
||
lastTapRef.current = null;
|
||
setDragging(false);
|
||
commitTransform(DEFAULT_TRANSFORM);
|
||
wheelZoomBlockedUntilRef.current = 0;
|
||
forceViewerScroll(0);
|
||
setThumbnailState({
|
||
id: image.id,
|
||
loaded: getViewerThumbnailStatus(image) === "ready",
|
||
failed: false,
|
||
});
|
||
const originalReady = isDirectOriginalReady(
|
||
image,
|
||
sourceStrategy,
|
||
displayedOriginalsRef.current,
|
||
);
|
||
setLoadState(initialViewerSourceState(image.id, originalReady));
|
||
setFullResolutionState({
|
||
id: image.id,
|
||
requested: false,
|
||
loaded: false,
|
||
failed: false,
|
||
});
|
||
}, [
|
||
image.id,
|
||
sourceStrategy,
|
||
viewportRender.height,
|
||
viewportRender.width,
|
||
]);
|
||
|
||
useLayoutEffect(() => {
|
||
if (transformRef.current.scale > MIN_SCALE) {
|
||
forceViewerScroll(0);
|
||
commitTransform(constrainTransform(transformRef.current));
|
||
return;
|
||
}
|
||
if (scrollPageRef.current === "details") {
|
||
forceViewerScroll(detailsSectionRef.current?.offsetTop ?? viewport.height);
|
||
}
|
||
}, [viewport.width, viewport.height]);
|
||
|
||
useEffect(() => {
|
||
const previouslyFocused = document.activeElement instanceof HTMLElement
|
||
? document.activeElement
|
||
: null;
|
||
const root = document.getElementById("root");
|
||
const rootWasInert = root?.inert ?? false;
|
||
const htmlOverflow = document.documentElement.style.overflow;
|
||
const bodyOverflow = document.body.style.overflow;
|
||
root && (root.inert = true);
|
||
document.documentElement.style.overflow = "hidden";
|
||
document.body.style.overflow = "hidden";
|
||
const frame = window.requestAnimationFrame(() => {
|
||
closeButtonRef.current?.focus({ preventScroll: true });
|
||
});
|
||
|
||
return () => {
|
||
window.cancelAnimationFrame(frame);
|
||
if (root) root.inert = rootWasInert;
|
||
document.documentElement.style.overflow = htmlOverflow;
|
||
document.body.style.overflow = bodyOverflow;
|
||
if (previouslyFocused?.isConnected) previouslyFocused.focus({ preventScroll: true });
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const updateFullscreenState = () => {
|
||
setFullscreen(document.fullscreenElement === dialogRef.current);
|
||
};
|
||
setFullscreenAvailable(Boolean(
|
||
document.fullscreenEnabled && dialogRef.current?.requestFullscreen,
|
||
));
|
||
updateFullscreenState();
|
||
document.addEventListener("fullscreenchange", updateFullscreenState);
|
||
return () => {
|
||
document.removeEventListener("fullscreenchange", updateFullscreenState);
|
||
if (document.fullscreenElement === dialogRef.current) {
|
||
void document.exitFullscreen().catch(() => undefined);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const toggleFullscreen = async () => {
|
||
try {
|
||
if (document.fullscreenElement === dialogRef.current) {
|
||
await document.exitFullscreen();
|
||
} else {
|
||
await dialogRef.current?.requestFullscreen();
|
||
}
|
||
} catch (error) {
|
||
console.warn("Pixhelf could not change fullscreen mode", error);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Tab") {
|
||
const focusable = Array.from(
|
||
dialogRef.current?.querySelectorAll<HTMLElement>(
|
||
'button:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])',
|
||
) ?? [],
|
||
).filter((element) => !element.hasAttribute("inert"));
|
||
if (!focusable.length) return;
|
||
const first = focusable[0];
|
||
const last = focusable[focusable.length - 1];
|
||
if (event.shiftKey && document.activeElement === first) {
|
||
event.preventDefault();
|
||
last.focus();
|
||
} else if (!event.shiftKey && document.activeElement === last) {
|
||
event.preventDefault();
|
||
first.focus();
|
||
}
|
||
return;
|
||
}
|
||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||
switch (event.key) {
|
||
case "Escape":
|
||
if (document.fullscreenElement === dialogRef.current) return;
|
||
event.preventDefault();
|
||
onClose();
|
||
break;
|
||
case "ArrowLeft":
|
||
if (canPrevious) {
|
||
event.preventDefault();
|
||
parkViewerControlFocus();
|
||
onNavigate(-1);
|
||
}
|
||
break;
|
||
case "ArrowRight":
|
||
if (canNext) {
|
||
event.preventDefault();
|
||
parkViewerControlFocus();
|
||
onNavigate(1);
|
||
}
|
||
break;
|
||
case "+":
|
||
case "=":
|
||
event.preventDefault();
|
||
zoomBy(0.5);
|
||
break;
|
||
case "-":
|
||
event.preventDefault();
|
||
zoomBy(-0.5);
|
||
break;
|
||
case "0":
|
||
event.preventDefault();
|
||
commitTransform(DEFAULT_TRANSFORM);
|
||
break;
|
||
case "ArrowDown":
|
||
case "PageDown":
|
||
if (
|
||
transformRef.current.scale <= MIN_SCALE &&
|
||
(dialogRef.current?.scrollTop ?? 0) < (detailsSectionRef.current?.offsetTop ?? Infinity)
|
||
) {
|
||
event.preventDefault();
|
||
scrollToDetails(true);
|
||
}
|
||
break;
|
||
case "ArrowUp":
|
||
case "PageUp":
|
||
if (
|
||
transformRef.current.scale <= MIN_SCALE &&
|
||
(dialogRef.current?.scrollTop ?? 0) > 1
|
||
) {
|
||
event.preventDefault();
|
||
scrollToImage(true);
|
||
}
|
||
break;
|
||
}
|
||
};
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [canNext, canPrevious, onClose, onNavigate]);
|
||
|
||
useEffect(() => {
|
||
const pressedButtons = new Set<number>();
|
||
const suppressSideButton = (event: MouseEvent | PointerEvent) => {
|
||
if (mouseSideDirection(event.button) === null) return false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
return true;
|
||
};
|
||
const navigateFromSideButton = (event: MouseEvent | PointerEvent) => {
|
||
const direction = mouseSideDirection(event.button);
|
||
if (direction === null || !suppressSideButton(event)) return;
|
||
if (pressedButtons.has(event.button)) return;
|
||
pressedButtons.add(event.button);
|
||
if (direction < 0 && canPrevious) onNavigate(-1);
|
||
if (direction > 0 && canNext) onNavigate(1);
|
||
};
|
||
const releaseSideButton = (event: MouseEvent | PointerEvent) => {
|
||
if (!suppressSideButton(event)) return;
|
||
pressedButtons.delete(event.button);
|
||
};
|
||
const resetSideButtons = () => pressedButtons.clear();
|
||
const handlePointerDown = (event: PointerEvent) => {
|
||
if (event.pointerType === "mouse") navigateFromSideButton(event);
|
||
};
|
||
const handlePointerCancel = (event: PointerEvent) => {
|
||
if (mouseSideDirection(event.button) !== null) suppressSideButton(event);
|
||
resetSideButtons();
|
||
};
|
||
|
||
window.addEventListener("pointerdown", handlePointerDown, true);
|
||
window.addEventListener("pointerup", releaseSideButton, true);
|
||
window.addEventListener("pointercancel", handlePointerCancel, true);
|
||
window.addEventListener("mousedown", navigateFromSideButton, true);
|
||
window.addEventListener("mouseup", releaseSideButton, true);
|
||
window.addEventListener("auxclick", suppressSideButton, true);
|
||
window.addEventListener("blur", resetSideButtons);
|
||
return () => {
|
||
window.removeEventListener("pointerdown", handlePointerDown, true);
|
||
window.removeEventListener("pointerup", releaseSideButton, true);
|
||
window.removeEventListener("pointercancel", handlePointerCancel, true);
|
||
window.removeEventListener("mousedown", navigateFromSideButton, true);
|
||
window.removeEventListener("mouseup", releaseSideButton, true);
|
||
window.removeEventListener("auxclick", suppressSideButton, true);
|
||
window.removeEventListener("blur", resetSideButtons);
|
||
};
|
||
}, [canNext, canPrevious, onNavigate]);
|
||
|
||
useEffect(() => {
|
||
prepareViewerImages(images, activeIndex, {
|
||
preloadOriginals: !viewportBitmapCapable,
|
||
});
|
||
if (!viewportBitmapCapable) return;
|
||
let disposed = false;
|
||
let delayTimer = 0;
|
||
let idleHandle = 0;
|
||
const activeAsset = useViewportBitmapRenderer
|
||
? getViewerViewportRenderAsset(
|
||
image,
|
||
viewportRender.width,
|
||
viewportRender.height,
|
||
0,
|
||
"high",
|
||
)
|
||
: getViewerOriginalAsset(image, 0, "high");
|
||
const idleScheduler = window as IdleScheduler;
|
||
const preloadNeighbors = async () => {
|
||
if (disposed || !canPreloadViewerNeighbors()) return;
|
||
// Forward navigation is the common path. Prepare one neighbor at a time, using
|
||
// its original only when it is already no larger than the target render size.
|
||
for (const offset of [1, -1]) {
|
||
if (disposed) return;
|
||
const candidate = images[activeIndex + offset];
|
||
if (!candidate) continue;
|
||
const render = viewportRenderDimensions(candidate, viewport);
|
||
const strategy = selectViewerSourceStrategy(
|
||
candidate,
|
||
render,
|
||
viewportBitmapCapable,
|
||
);
|
||
const asset = strategy === "viewport-upgrade"
|
||
? getViewerViewportRenderAsset(
|
||
candidate,
|
||
render.width,
|
||
render.height,
|
||
0,
|
||
"low",
|
||
)
|
||
: getViewerOriginalAsset(candidate, 0, "low");
|
||
if (asset.status === "loading") await asset.promise;
|
||
}
|
||
};
|
||
const scheduleNeighbors = () => {
|
||
if (disposed) return;
|
||
delayTimer = window.setTimeout(() => {
|
||
if (idleScheduler.requestIdleCallback) {
|
||
idleHandle = idleScheduler.requestIdleCallback(
|
||
() => void preloadNeighbors(),
|
||
{ timeout: 600 },
|
||
);
|
||
} else {
|
||
void preloadNeighbors();
|
||
}
|
||
}, MOBILE_NEIGHBOR_PRELOAD_DELAY_MS);
|
||
};
|
||
if (activeAsset.status === "loading") void activeAsset.promise.then(scheduleNeighbors);
|
||
else scheduleNeighbors();
|
||
return () => {
|
||
disposed = true;
|
||
window.clearTimeout(delayTimer);
|
||
if (idleHandle) idleScheduler.cancelIdleCallback?.(idleHandle);
|
||
};
|
||
}, [
|
||
activeIndex,
|
||
image,
|
||
images,
|
||
useViewportBitmapRenderer,
|
||
viewportBitmapCapable,
|
||
viewport.height,
|
||
viewport.width,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!useViewportBitmapRenderer
|
||
|| currentFullResolution.requested
|
||
|| transform.scale <= MIN_SCALE
|
||
) return;
|
||
setFullResolutionState((current) => current.id === image.id
|
||
? { ...current, requested: true, failed: false }
|
||
: { id: image.id, requested: true, loaded: false, failed: false });
|
||
}, [
|
||
currentFullResolution.requested,
|
||
image.id,
|
||
transform.scale,
|
||
useViewportBitmapRenderer,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!compactViewport || reducedMotion()) {
|
||
preparedSwipeSnapshotRef.current = null;
|
||
return;
|
||
}
|
||
let disposed = false;
|
||
let prepared: PreparedSwipeSnapshot | null = null;
|
||
let idleHandle = 0;
|
||
let fallbackTimer = 0;
|
||
const prepare = () => {
|
||
if (disposed) return;
|
||
prepared = prepareSwipeSnapshot();
|
||
if (prepared) preparedSwipeSnapshotRef.current = prepared;
|
||
};
|
||
const idleScheduler = window as IdleScheduler;
|
||
if (idleScheduler.requestIdleCallback) {
|
||
idleHandle = idleScheduler.requestIdleCallback(prepare, { timeout: 180 });
|
||
} else {
|
||
fallbackTimer = globalThis.setTimeout(prepare, 32);
|
||
}
|
||
return () => {
|
||
disposed = true;
|
||
if (idleHandle) idleScheduler.cancelIdleCallback?.(idleHandle);
|
||
globalThis.clearTimeout(fallbackTimer);
|
||
if (preparedSwipeSnapshotRef.current === prepared) {
|
||
preparedSwipeSnapshotRef.current = null;
|
||
}
|
||
};
|
||
}, [
|
||
compactViewport,
|
||
image.id,
|
||
thumbnailLoaded,
|
||
viewportRender.height,
|
||
viewportRender.width,
|
||
]);
|
||
|
||
useEffect(() => () => {
|
||
window.clearTimeout(retryTimerRef.current);
|
||
window.clearTimeout(sourceUpgradeTimerRef.current);
|
||
window.clearTimeout(swipeCleanupTimerRef.current);
|
||
cancelTransformFrame();
|
||
swipeOutgoingRef.current?.remove();
|
||
preparedSwipeSnapshotRef.current = null;
|
||
}, []);
|
||
|
||
const handleOriginalError = () => {
|
||
const { attempt } = currentLoadState;
|
||
if (dialogRef.current?.dataset.imageId !== image.id) return;
|
||
if (attempt === 0) {
|
||
window.clearTimeout(retryTimerRef.current);
|
||
retryTimerRef.current = window.setTimeout(() => {
|
||
if (dialogRef.current?.dataset.imageId !== image.id) return;
|
||
setLoadState((current) => current.id === image.id && current.attempt !== attempt
|
||
? current
|
||
: pendingViewerSourceState(image.id, attempt + 1));
|
||
}, 900);
|
||
return;
|
||
}
|
||
setLoadState((current) => current.id === image.id && current.attempt !== attempt
|
||
? current
|
||
: pendingViewerSourceState(image.id, attempt, true));
|
||
};
|
||
|
||
const retryOriginal = () => {
|
||
window.clearTimeout(retryTimerRef.current);
|
||
setLoadState(pendingViewerSourceState(
|
||
image.id,
|
||
currentLoadState.attempt + 1,
|
||
));
|
||
};
|
||
|
||
useLayoutEffect(() => {
|
||
if (
|
||
!useViewportBitmapRenderer
|
||
|| viewerIsBusy()
|
||
) return;
|
||
const asset = getReadyViewerViewportRenderAsset(
|
||
image,
|
||
viewportRender.width,
|
||
viewportRender.height,
|
||
currentLoadState.attempt,
|
||
);
|
||
const canvas = canvasRef.current;
|
||
if (!asset?.bitmap || !canvas) return;
|
||
const sourceKey = `${fullSource}@${asset.width}x${asset.height}`;
|
||
try {
|
||
if (canvas.dataset.renderedSource !== sourceKey) {
|
||
drawViewerCanvas(canvas, asset.bitmap, asset.width, asset.height, sourceKey);
|
||
}
|
||
markOriginalReady(image.id, currentLoadState.attempt, "direct");
|
||
} catch (error) {
|
||
console.warn("Pixhelf could not reuse the prepared viewer image", error);
|
||
}
|
||
}, [
|
||
fullSource,
|
||
image.id,
|
||
useViewportBitmapRenderer,
|
||
viewportRender.height,
|
||
viewportRender.width,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!useCanvasRenderer) return;
|
||
let disposed = false;
|
||
let frame = 0;
|
||
let settleTimer = 0;
|
||
const attempt = currentLoadState.attempt;
|
||
const viewportAsset = useViewportBitmapRenderer
|
||
? getViewerViewportRenderAsset(
|
||
image,
|
||
viewportRender.width,
|
||
viewportRender.height,
|
||
attempt,
|
||
"high",
|
||
)
|
||
: null;
|
||
const originalAsset = useSafeCanvasRenderer
|
||
? getViewerOriginalAsset(image, attempt, "high")
|
||
: null;
|
||
|
||
const renderOriginal = () => {
|
||
const status = viewportAsset?.status ?? originalAsset?.status;
|
||
if (disposed || status === "loading") return;
|
||
if (status === "failed") {
|
||
handleOriginalError();
|
||
return;
|
||
}
|
||
try {
|
||
const renderScale = useViewportBitmapRenderer
|
||
? 1
|
||
: Math.min(1, MAX_RENDER_EDGE / Math.max(1, image.width, image.height));
|
||
const renderWidth = viewportAsset?.width
|
||
?? Math.max(1, Math.round(image.width * renderScale));
|
||
const renderHeight = viewportAsset?.height
|
||
?? Math.max(1, Math.round(image.height * renderScale));
|
||
const source = viewportAsset?.bitmap ?? originalAsset?.element;
|
||
const canvas = canvasRef.current;
|
||
if (!canvas || !source) throw new Error("canvas renderer is unavailable");
|
||
const sourceKey = `${fullSource}@${renderWidth}x${renderHeight}`;
|
||
if (canvas.dataset.renderedSource !== sourceKey) {
|
||
drawViewerCanvas(canvas, source, renderWidth, renderHeight, sourceKey);
|
||
}
|
||
|
||
window.clearTimeout(retryTimerRef.current);
|
||
markOriginalReady(image.id, attempt);
|
||
} catch (error) {
|
||
if (disposed) return;
|
||
console.warn("Pixhelf could not render the original image", error);
|
||
handleOriginalError();
|
||
}
|
||
};
|
||
|
||
const scheduleRender = () => {
|
||
if (disposed || frame || settleTimer) return;
|
||
const swipeInProgress = useViewportBitmapRenderer
|
||
&& dialogRef.current?.hasAttribute("data-swipe-direction");
|
||
if (viewerIsBusy(useViewportBitmapRenderer)) {
|
||
settleTimer = window.setTimeout(() => {
|
||
settleTimer = 0;
|
||
scheduleRender();
|
||
}, swipeInProgress ? MOBILE_RENDER_SETTLE_MS : 48);
|
||
return;
|
||
}
|
||
frame = window.requestAnimationFrame(() => {
|
||
frame = 0;
|
||
if (viewerIsBusy(useViewportBitmapRenderer)) {
|
||
scheduleRender();
|
||
return;
|
||
}
|
||
renderOriginal();
|
||
});
|
||
};
|
||
|
||
scheduleRender();
|
||
const pending = viewportAsset ?? originalAsset;
|
||
if (pending?.status === "loading") void pending.promise.then(scheduleRender);
|
||
return () => {
|
||
disposed = true;
|
||
if (frame) window.cancelAnimationFrame(frame);
|
||
window.clearTimeout(settleTimer);
|
||
};
|
||
}, [
|
||
fullSource,
|
||
image.id,
|
||
image.width,
|
||
image.height,
|
||
useCanvasRenderer,
|
||
useSafeCanvasRenderer,
|
||
useViewportBitmapRenderer,
|
||
viewportRender.height,
|
||
viewportRender.width,
|
||
]);
|
||
|
||
const beginContact = (id: number, point: PointerPoint, pointerType: string) => {
|
||
if (pointerType !== "mouse") lastTouchAtRef.current = performance.now();
|
||
if (pointerType !== "mouse" && pointersRef.current.size === 0) clearSwipeMotion();
|
||
if (pointersRef.current.size === 0) {
|
||
const surface = surfaceRef.current;
|
||
const media = mediaRef.current;
|
||
if (surface && media) {
|
||
gestureGeometryRef.current = {
|
||
surfaceWidth: surface.clientWidth,
|
||
surfaceHeight: surface.clientHeight,
|
||
mediaWidth: media.offsetWidth,
|
||
mediaHeight: media.offsetHeight,
|
||
surfaceBounds: surface.getBoundingClientRect(),
|
||
};
|
||
}
|
||
}
|
||
pointersRef.current.set(id, point);
|
||
setDragging(true);
|
||
|
||
if (pointersRef.current.size === 1) {
|
||
gestureStartRef.current = {
|
||
point,
|
||
transform: transformRef.current,
|
||
scrollTop: dialogRef.current?.scrollTop ?? 0,
|
||
startedAt: performance.now(),
|
||
pointerType,
|
||
};
|
||
gestureAxisRef.current = null;
|
||
gestureModeRef.current = null;
|
||
} else if (pointersRef.current.size === 2) {
|
||
const [first, second] = Array.from(pointersRef.current.values());
|
||
pinchedRef.current = true;
|
||
gestureModeRef.current = "pinch";
|
||
lastTapRef.current = null;
|
||
if ((dialogRef.current?.scrollTop ?? scrollTopRef.current) <= VIEWER_SCROLL_EPSILON) {
|
||
pinchStartRef.current = {
|
||
distance: Math.max(1, pointDistance(first, second)),
|
||
midpoint: pointMidpoint(first, second),
|
||
transform: transformRef.current,
|
||
};
|
||
} else {
|
||
pinchStartRef.current = null;
|
||
}
|
||
}
|
||
};
|
||
|
||
const moveContact = (id: number, point: PointerPoint) => {
|
||
if (!pointersRef.current.has(id)) return;
|
||
pointersRef.current.set(id, point);
|
||
|
||
if (pointersRef.current.size >= 2 && pinchStartRef.current) {
|
||
const [first, second] = Array.from(pointersRef.current.values());
|
||
const start = pinchStartRef.current;
|
||
const midpoint = pointMidpoint(first, second);
|
||
const scale = clamp(
|
||
start.transform.scale * pointDistance(first, second) / start.distance,
|
||
MIN_SCALE,
|
||
MAX_SCALE,
|
||
);
|
||
const bounds = gestureGeometryRef.current?.surfaceBounds
|
||
?? surfaceRef.current?.getBoundingClientRect();
|
||
if (!bounds) return;
|
||
const center = {
|
||
x: bounds.left + bounds.width / 2,
|
||
y: bounds.top + bounds.height / 2,
|
||
};
|
||
const imagePoint = {
|
||
x: (start.midpoint.x - center.x - start.transform.x) / start.transform.scale,
|
||
y: (start.midpoint.y - center.y - start.transform.y) / start.transform.scale,
|
||
};
|
||
stageTransform(constrainTransform({
|
||
scale,
|
||
x: midpoint.x - center.x - imagePoint.x * scale,
|
||
y: midpoint.y - center.y - imagePoint.y * scale,
|
||
}));
|
||
return;
|
||
}
|
||
if (pointersRef.current.size >= 2 || pinchedRef.current) return;
|
||
|
||
const start = gestureStartRef.current;
|
||
if (!start || pointersRef.current.size !== 1) return;
|
||
const deltaX = point.x - start.point.x;
|
||
const deltaY = point.y - start.point.y;
|
||
if (!gestureAxisRef.current && Math.hypot(deltaX, deltaY) > 7) {
|
||
gestureAxisRef.current = Math.abs(deltaX) >= Math.abs(deltaY) ? "x" : "y";
|
||
gestureModeRef.current = start.transform.scale > MIN_SCALE
|
||
? "pan"
|
||
: start.scrollTop > VIEWER_SCROLL_EPSILON
|
||
? "page"
|
||
: gestureAxisRef.current === "x"
|
||
? "navigate"
|
||
: deltaY < 0
|
||
? "page"
|
||
: "dismiss";
|
||
}
|
||
|
||
if (gestureModeRef.current === "pan") {
|
||
forceViewerScroll(0);
|
||
stageTransform(constrainTransform({
|
||
...start.transform,
|
||
x: start.transform.x + deltaX,
|
||
y: start.transform.y + deltaY,
|
||
}));
|
||
return;
|
||
}
|
||
|
||
const surface = surfaceRef.current;
|
||
if (gestureModeRef.current === "navigate") {
|
||
const navigationAvailable = deltaX < 0 ? canNext : deltaX > 0 && canPrevious;
|
||
const maximumTravel = Math.max(
|
||
1,
|
||
(gestureGeometryRef.current?.surfaceWidth ?? surface?.clientWidth ?? 360) * 0.94,
|
||
);
|
||
stageTransform({
|
||
scale: 1,
|
||
x: navigationAvailable
|
||
? clamp(deltaX * 0.92, -maximumTravel, maximumTravel)
|
||
: rubberBand(deltaX, Math.max(72, maximumTravel * 0.22)) * 0.38,
|
||
y: 0,
|
||
});
|
||
} else if (gestureModeRef.current === "page") {
|
||
const viewer = dialogRef.current;
|
||
if (viewer) {
|
||
const detailsTop = detailsSectionRef.current?.offsetTop ?? viewer.clientHeight;
|
||
viewer.scrollTop = clamp(start.scrollTop - deltaY, 0, detailsTop);
|
||
scrollTopRef.current = viewer.scrollTop;
|
||
syncScrollPage(viewer.scrollTop);
|
||
if (transformRef.current.x || transformRef.current.y) stageTransform(DEFAULT_TRANSFORM);
|
||
return;
|
||
}
|
||
} else if (gestureModeRef.current === "dismiss") {
|
||
stageTransform({
|
||
scale: 1,
|
||
x: 0,
|
||
y: rubberBand(
|
||
Math.max(0, deltaY),
|
||
Math.max(
|
||
80,
|
||
(gestureGeometryRef.current?.surfaceHeight ?? surface?.clientHeight ?? 640) * 0.2,
|
||
),
|
||
),
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleTouchTap = (point: PointerPoint) => {
|
||
const now = performance.now();
|
||
const lastTap = lastTapRef.current;
|
||
if (
|
||
lastTap &&
|
||
now - lastTap.at <= DOUBLE_TAP_DELAY_MS &&
|
||
pointDistance(lastTap.point, point) < 32
|
||
) {
|
||
lastTapRef.current = null;
|
||
zoomAt(
|
||
transformRef.current.scale > MIN_SCALE ? MIN_SCALE : DOUBLE_TAP_SCALE,
|
||
point,
|
||
);
|
||
return;
|
||
}
|
||
lastTapRef.current = { at: now, point };
|
||
};
|
||
|
||
const finishContact = (
|
||
id: number,
|
||
point: PointerPoint,
|
||
pointerType: string,
|
||
) => {
|
||
flushStagedTransform();
|
||
const pointerCount = pointersRef.current.size;
|
||
pointersRef.current.delete(id);
|
||
|
||
if (pointerCount > 1) {
|
||
const remaining = pointersRef.current.values().next().value as PointerPoint | undefined;
|
||
pinchStartRef.current = null;
|
||
if (remaining) {
|
||
gestureStartRef.current = {
|
||
point: remaining,
|
||
transform: transformRef.current,
|
||
scrollTop: dialogRef.current?.scrollTop ?? 0,
|
||
startedAt: performance.now(),
|
||
pointerType,
|
||
};
|
||
}
|
||
return;
|
||
}
|
||
|
||
setDragging(false);
|
||
const start = gestureStartRef.current;
|
||
const gestureMode = gestureModeRef.current;
|
||
gestureStartRef.current = null;
|
||
pinchStartRef.current = null;
|
||
gestureAxisRef.current = null;
|
||
gestureModeRef.current = null;
|
||
const wasPinched = pinchedRef.current;
|
||
pinchedRef.current = false;
|
||
if (wasPinched) {
|
||
const viewer = dialogRef.current;
|
||
const detailsTop = detailsSectionRef.current?.offsetTop ?? viewer?.clientHeight ?? 0;
|
||
const currentScroll = viewer?.scrollTop ?? 0;
|
||
if (currentScroll > VIEWER_SCROLL_EPSILON && detailsTop > 0) {
|
||
if (currentScroll >= detailsTop * 0.18) scrollToDetails();
|
||
else scrollToImage();
|
||
gestureGeometryRef.current = null;
|
||
return;
|
||
}
|
||
commitTransform(constrainTransform(transformRef.current));
|
||
gestureGeometryRef.current = null;
|
||
return;
|
||
}
|
||
if (!start) {
|
||
gestureGeometryRef.current = null;
|
||
return;
|
||
}
|
||
|
||
const deltaX = point.x - start.point.x;
|
||
const deltaY = point.y - start.point.y;
|
||
const elapsed = Math.max(1, performance.now() - start.startedAt);
|
||
const isTap = Math.hypot(deltaX, deltaY) < 10 && elapsed < 320;
|
||
|
||
if (start.transform.scale > MIN_SCALE) {
|
||
if (isTap && start.pointerType !== "mouse") {
|
||
handleTouchTap(point);
|
||
gestureGeometryRef.current = null;
|
||
return;
|
||
}
|
||
commitTransform(constrainTransform(transformRef.current));
|
||
gestureGeometryRef.current = null;
|
||
return;
|
||
}
|
||
|
||
const surface = surfaceRef.current;
|
||
const surfaceWidth = gestureGeometryRef.current?.surfaceWidth
|
||
?? surface?.clientWidth
|
||
?? 360;
|
||
const isTouchSwipe = start.pointerType === "touch";
|
||
const horizontalDistance = Math.abs(deltaX);
|
||
const horizontalThreshold = isTouchSwipe
|
||
? clamp(
|
||
surfaceWidth * TOUCH_SWIPE_DISTANCE_RATIO,
|
||
TOUCH_SWIPE_MIN_DISTANCE_PX,
|
||
TOUCH_SWIPE_MAX_DISTANCE_PX,
|
||
)
|
||
: Math.max(54, surfaceWidth * 0.11);
|
||
const horizontalFlick = horizontalDistance > (
|
||
isTouchSwipe ? TOUCH_SWIPE_FLICK_MIN_DISTANCE_PX : 30
|
||
) && horizontalDistance / elapsed > (
|
||
isTouchSwipe ? TOUCH_SWIPE_FLICK_VELOCITY : 0.48
|
||
);
|
||
const verticalThreshold = Math.max(
|
||
84,
|
||
(gestureGeometryRef.current?.surfaceHeight ?? surface?.clientHeight ?? 640) * 0.12,
|
||
);
|
||
const verticalFlick = deltaY > 38 && deltaY / elapsed > 0.52;
|
||
const detailsFlick = deltaY < -38 && -deltaY / elapsed > 0.52;
|
||
|
||
if (
|
||
gestureMode === "navigate" &&
|
||
horizontalDistance > Math.abs(deltaY) &&
|
||
(horizontalDistance >= horizontalThreshold || horizontalFlick)
|
||
) {
|
||
if (deltaX < 0 && canNext) {
|
||
navigateWithSwipeMotion(1);
|
||
gestureGeometryRef.current = null;
|
||
return;
|
||
}
|
||
if (deltaX > 0 && canPrevious) {
|
||
navigateWithSwipeMotion(-1);
|
||
gestureGeometryRef.current = null;
|
||
return;
|
||
}
|
||
}
|
||
if (gestureMode === "page") {
|
||
const viewer = dialogRef.current;
|
||
const detailsTop = detailsSectionRef.current?.offsetTop ?? viewer?.clientHeight ?? 640;
|
||
const currentScroll = viewer?.scrollTop ?? 0;
|
||
if (detailsFlick || currentScroll >= detailsTop * 0.18) {
|
||
scrollToDetails();
|
||
} else {
|
||
scrollToImage();
|
||
}
|
||
gestureGeometryRef.current = null;
|
||
return;
|
||
}
|
||
if (
|
||
gestureMode === "dismiss" &&
|
||
deltaY > Math.abs(deltaX) &&
|
||
(deltaY >= verticalThreshold || verticalFlick)
|
||
) {
|
||
onClose();
|
||
gestureGeometryRef.current = null;
|
||
return;
|
||
}
|
||
|
||
commitTransform(DEFAULT_TRANSFORM);
|
||
if (isTap && start.pointerType !== "mouse") {
|
||
handleTouchTap(point);
|
||
}
|
||
gestureGeometryRef.current = null;
|
||
};
|
||
|
||
const cancelContacts = () => {
|
||
flushStagedTransform();
|
||
pointersRef.current.clear();
|
||
gestureStartRef.current = null;
|
||
pinchStartRef.current = null;
|
||
gestureAxisRef.current = null;
|
||
gestureModeRef.current = null;
|
||
pinchedRef.current = false;
|
||
setDragging(false);
|
||
const viewer = dialogRef.current;
|
||
const detailsTop = detailsSectionRef.current?.offsetTop ?? viewer?.clientHeight ?? 0;
|
||
if ((viewer?.scrollTop ?? 0) > 1 && detailsTop > 0) {
|
||
if ((viewer?.scrollTop ?? 0) >= detailsTop * 0.18) {
|
||
scrollToDetails();
|
||
} else {
|
||
scrollToImage();
|
||
}
|
||
gestureGeometryRef.current = null;
|
||
return;
|
||
}
|
||
commitTransform(constrainTransform(transformRef.current));
|
||
gestureGeometryRef.current = null;
|
||
};
|
||
|
||
const beginPointer = (event: JSX.TargetedPointerEvent<HTMLDivElement>) => {
|
||
// Touch input is handled by non-passive native TouchEvent listeners below. Keeping
|
||
// it out of this path prevents browsers that emit both event families from applying
|
||
// every movement twice.
|
||
if (event.pointerType === "touch") return;
|
||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||
event.preventDefault();
|
||
try {
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
} catch {
|
||
// Some pen/browser combinations do not allow capture here.
|
||
}
|
||
beginContact(
|
||
event.pointerId,
|
||
{ x: event.clientX, y: event.clientY },
|
||
event.pointerType,
|
||
);
|
||
};
|
||
|
||
const movePointer = (event: JSX.TargetedPointerEvent<HTMLDivElement>) => {
|
||
if (event.pointerType === "touch") return;
|
||
if (!pointersRef.current.has(event.pointerId)) return;
|
||
event.preventDefault();
|
||
moveContact(event.pointerId, { x: event.clientX, y: event.clientY });
|
||
};
|
||
|
||
const finishPointer = (event: JSX.TargetedPointerEvent<HTMLDivElement>) => {
|
||
if (event.pointerType === "touch") return;
|
||
finishContact(
|
||
event.pointerId,
|
||
{ x: event.clientX, y: event.clientY },
|
||
event.pointerType,
|
||
);
|
||
try {
|
||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||
} catch {
|
||
// The browser may have released capture already.
|
||
}
|
||
};
|
||
|
||
const cancelPointer = (event: JSX.TargetedPointerEvent<HTMLDivElement>) => {
|
||
if (event.pointerType !== "touch") cancelContacts();
|
||
};
|
||
|
||
gestureHandlersRef.current = {
|
||
begin: beginContact,
|
||
move: moveContact,
|
||
finish: finishContact,
|
||
cancel: cancelContacts,
|
||
};
|
||
|
||
useEffect(() => {
|
||
const surface = surfaceRef.current;
|
||
if (!surface) return;
|
||
|
||
const touchPoint = (touch: Touch): PointerPoint => ({
|
||
x: touch.clientX,
|
||
y: touch.clientY,
|
||
});
|
||
const handleTouchStart = (event: TouchEvent) => {
|
||
event.preventDefault();
|
||
for (const touch of Array.from(event.changedTouches)) {
|
||
gestureHandlersRef.current?.begin(touch.identifier, touchPoint(touch), "touch");
|
||
}
|
||
};
|
||
const handleTouchMove = (event: TouchEvent) => {
|
||
event.preventDefault();
|
||
for (const touch of Array.from(event.changedTouches)) {
|
||
gestureHandlersRef.current?.move(touch.identifier, touchPoint(touch));
|
||
}
|
||
};
|
||
const handleTouchEnd = (event: TouchEvent) => {
|
||
event.preventDefault();
|
||
for (const touch of Array.from(event.changedTouches)) {
|
||
gestureHandlersRef.current?.finish(touch.identifier, touchPoint(touch), "touch");
|
||
}
|
||
};
|
||
const handleTouchCancel = (event: TouchEvent) => {
|
||
event.preventDefault();
|
||
gestureHandlersRef.current?.cancel();
|
||
};
|
||
|
||
surface.addEventListener("touchstart", handleTouchStart, { passive: false });
|
||
surface.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||
surface.addEventListener("touchend", handleTouchEnd, { passive: false });
|
||
surface.addEventListener("touchcancel", handleTouchCancel, { passive: false });
|
||
return () => {
|
||
surface.removeEventListener("touchstart", handleTouchStart);
|
||
surface.removeEventListener("touchmove", handleTouchMove);
|
||
surface.removeEventListener("touchend", handleTouchEnd);
|
||
surface.removeEventListener("touchcancel", handleTouchCancel);
|
||
};
|
||
}, []);
|
||
|
||
const handleViewerScroll = (event: JSX.TargetedEvent<HTMLDivElement, Event>) => {
|
||
const viewer = event.currentTarget;
|
||
const previousTop = scrollTopRef.current;
|
||
const nextTop = viewer.scrollTop;
|
||
if (transformRef.current.scale > MIN_SCALE && nextTop > VIEWER_SCROLL_EPSILON) {
|
||
forceViewerScroll(0);
|
||
return;
|
||
}
|
||
scrollTopRef.current = nextTop;
|
||
syncScrollPage(nextTop);
|
||
if (nextTop > VIEWER_SCROLL_EPSILON || previousTop > VIEWER_SCROLL_EPSILON) {
|
||
wheelZoomBlockedUntilRef.current = performance.now() + WHEEL_HANDOFF_DELAY_MS;
|
||
}
|
||
};
|
||
|
||
const handleWheel = (event: JSX.TargetedWheelEvent<HTMLDivElement>) => {
|
||
const now = performance.now();
|
||
const scrollTop = dialogRef.current?.scrollTop ?? scrollTopRef.current;
|
||
if (scrollTop > VIEWER_SCROLL_EPSILON) {
|
||
if (event.ctrlKey) event.preventDefault();
|
||
return;
|
||
}
|
||
const currentScale = transformRef.current.scale;
|
||
if (currentScale <= MIN_SCALE && !event.ctrlKey && event.deltaY > 0) {
|
||
if (now < wheelZoomBlockedUntilRef.current) event.preventDefault();
|
||
return;
|
||
}
|
||
if (now < wheelZoomBlockedUntilRef.current) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
const deltaMultiplier = event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||
? 16
|
||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
||
? surfaceRef.current?.clientHeight ?? viewport.height
|
||
: 1;
|
||
const normalizedDelta = clamp(event.deltaY * deltaMultiplier, -160, 160);
|
||
const nextScale = currentScale * Math.exp(-normalizedDelta * 0.002);
|
||
if (currentScale > MIN_SCALE && nextScale <= MIN_SCALE) {
|
||
wheelZoomBlockedUntilRef.current = now + WHEEL_HANDOFF_DELAY_MS;
|
||
}
|
||
zoomAt(nextScale, { x: event.clientX, y: event.clientY });
|
||
};
|
||
|
||
const displayPosition = Math.min(total, activeIndex + 1);
|
||
const liveTransform = transformRef.current;
|
||
const viewerStyle = {
|
||
"--viewer-dismiss-progress": String(
|
||
liveTransform.scale === 1
|
||
? clamp(
|
||
Math.max(0, liveTransform.y)
|
||
/ Math.max(1, gestureGeometryRef.current?.surfaceHeight ?? surfaceRef.current?.clientHeight ?? 640),
|
||
0,
|
||
0.45,
|
||
)
|
||
: 0,
|
||
),
|
||
} as CSSProperties;
|
||
const mediaWidth = mediaDimensions.width;
|
||
const mediaHeight = mediaDimensions.height;
|
||
const fileExtension = image.name.includes(".")
|
||
? image.name.split(".").pop()?.toLocaleUpperCase() ?? "图片"
|
||
: "图片";
|
||
const orientation = image.width === image.height
|
||
? "方形"
|
||
: image.width > image.height
|
||
? "横向"
|
||
: "竖向";
|
||
const megapixels = image.width * image.height / 1_000_000;
|
||
const nativeOriginalActive = currentFullResolution.loaded
|
||
&& liveTransform.scale > MIN_SCALE;
|
||
const displaySource = resolveViewerDisplaySource({
|
||
sourceLoaded: currentLoadState.loaded,
|
||
thumbnailLoaded,
|
||
viewportBitmapRenderer: useViewportBitmapRenderer,
|
||
nativeOriginalActive,
|
||
});
|
||
const renderer = sourceStrategy === "viewport-upgrade"
|
||
? "viewport-bitmap"
|
||
: sourceStrategy === "bounded-canvas"
|
||
? "safe-canvas"
|
||
: "native";
|
||
const mediaStyle = {
|
||
width: `${mediaWidth}px`,
|
||
height: `${mediaHeight}px`,
|
||
aspectRatio: `${image.width} / ${image.height}`,
|
||
transform: `translate3d(${liveTransform.x}px, ${liveTransform.y}px, 0) scale(${liveTransform.scale})`,
|
||
} as CSSProperties;
|
||
const viewer = (
|
||
<div
|
||
ref={dialogRef}
|
||
className="image-viewer"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={`图片查看器:${image.name}`}
|
||
data-image-id={image.id}
|
||
data-image-name={image.name}
|
||
data-full-loaded={currentLoadState.loaded}
|
||
data-full-failed={currentLoadState.failed}
|
||
data-source-presentation={currentLoadState.presentation}
|
||
data-source-strategy={sourceStrategy}
|
||
data-thumbnail-loaded={thumbnailLoaded}
|
||
data-thumbnail-failed={thumbnailFailed}
|
||
data-display-source={displaySource}
|
||
data-native-original-requested={currentFullResolution.requested}
|
||
data-native-original-loaded={currentFullResolution.loaded}
|
||
data-native-original-active={nativeOriginalActive}
|
||
data-zoomed={liveTransform.scale > MIN_SCALE}
|
||
data-dragging={dragging}
|
||
data-page={scrollPage}
|
||
data-renderer={renderer}
|
||
data-control-system="unified"
|
||
data-ui-layout="floating"
|
||
data-scroll-mode="continuous"
|
||
data-mouse-side-navigation="true"
|
||
data-mobile-swipe-motion="interruptible"
|
||
data-mobile-gesture-renderer="raf-dom"
|
||
data-mobile-original-policy={useViewportBitmapRenderer ? "zoom-only" : "direct"}
|
||
tabIndex={-1}
|
||
onScroll={handleViewerScroll}
|
||
style={viewerStyle}
|
||
>
|
||
<section className="viewer-stage" aria-label="图片浏览区域">
|
||
<div className="viewer-backdrop" aria-hidden="true" />
|
||
|
||
<header className="viewer-header">
|
||
<div className="viewer-header-actions">
|
||
<button
|
||
type="button"
|
||
className={`viewer-control viewer-similar-trigger${similarActive ? " is-active" : ""}`}
|
||
onClick={() => {
|
||
onSearchSimilar(image);
|
||
scrollToSimilar();
|
||
}}
|
||
aria-label="以图搜图,查找相似图片"
|
||
aria-pressed={similarActive}
|
||
title="以图搜图"
|
||
>
|
||
<ScanSearch size={19} />
|
||
</button>
|
||
<a
|
||
className="viewer-control"
|
||
href={viewerOriginalUrl(image)}
|
||
download={image.name}
|
||
aria-label="下载原图"
|
||
title="下载原图"
|
||
>
|
||
<Download size={19} />
|
||
</a>
|
||
{fullscreenAvailable && (
|
||
<button
|
||
type="button"
|
||
className="viewer-control viewer-fullscreen"
|
||
onClick={() => void toggleFullscreen()}
|
||
aria-label={fullscreen ? "退出全屏" : "进入全屏"}
|
||
title={fullscreen ? "退出全屏" : "全屏查看"}
|
||
>
|
||
{fullscreen ? <Minimize2 size={19} /> : <Maximize2 size={19} />}
|
||
</button>
|
||
)}
|
||
<button
|
||
ref={closeButtonRef}
|
||
type="button"
|
||
className="viewer-control viewer-close"
|
||
onClick={onClose}
|
||
aria-label="关闭查看器"
|
||
title="关闭 (Esc)"
|
||
>
|
||
<X size={21} />
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div
|
||
ref={surfaceRef}
|
||
className="viewer-gesture-surface"
|
||
onPointerDown={beginPointer}
|
||
onPointerMove={movePointer}
|
||
onPointerUp={finishPointer}
|
||
onPointerCancel={cancelPointer}
|
||
onWheel={handleWheel}
|
||
onDblClick={(event: JSX.TargetedMouseEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
if (performance.now() - lastTouchAtRef.current < 500) return;
|
||
zoomAt(
|
||
transformRef.current.scale > MIN_SCALE ? MIN_SCALE : DOUBLE_TAP_SCALE,
|
||
{ x: event.clientX, y: event.clientY },
|
||
);
|
||
}}
|
||
>
|
||
<div className="viewer-media-center">
|
||
<div key={image.id} ref={mediaRef} className="viewer-media" style={mediaStyle}>
|
||
<img
|
||
key={`thumbnail-${image.id}`}
|
||
className="viewer-thumbnail"
|
||
src={viewerThumbnailUrl(image)}
|
||
width={image.width}
|
||
height={image.height}
|
||
alt=""
|
||
aria-hidden="true"
|
||
loading="eager"
|
||
decoding="async"
|
||
fetchPriority="high"
|
||
draggable={false}
|
||
onLoad={() => setThumbnailState({
|
||
id: image.id,
|
||
loaded: true,
|
||
failed: false,
|
||
})}
|
||
onError={() => setThumbnailState({
|
||
id: image.id,
|
||
loaded: false,
|
||
failed: true,
|
||
})}
|
||
/>
|
||
{useCanvasRenderer ? (
|
||
<canvas
|
||
ref={canvasRef}
|
||
key={`${fullSource}-${viewportRender.width}x${viewportRender.height}`}
|
||
className="viewer-original viewer-original-canvas"
|
||
role="img"
|
||
aria-label={image.name}
|
||
data-original-url={fullSource}
|
||
/>
|
||
) : (
|
||
<img
|
||
key={fullSource}
|
||
className="viewer-original"
|
||
src={fullSource}
|
||
width={image.width}
|
||
height={image.height}
|
||
alt={image.name}
|
||
loading="eager"
|
||
decoding="async"
|
||
fetchPriority="high"
|
||
draggable={false}
|
||
onLoad={(event) => {
|
||
const original = event.currentTarget;
|
||
window.clearTimeout(retryTimerRef.current);
|
||
const decoded = typeof original.decode === "function"
|
||
? original.decode().catch(() => undefined)
|
||
: Promise.resolve();
|
||
void decoded.then(() => {
|
||
displayedOriginalsRef.current.add(fullSource);
|
||
markOriginalReady(image.id, currentLoadState.attempt);
|
||
});
|
||
}}
|
||
onError={handleOriginalError}
|
||
/>
|
||
)}
|
||
{useViewportBitmapRenderer && currentFullResolution.requested && (
|
||
<img
|
||
key={`native-original-${fullSource}`}
|
||
className="viewer-original viewer-native-original"
|
||
src={fullSource}
|
||
width={image.width}
|
||
height={image.height}
|
||
alt=""
|
||
aria-hidden="true"
|
||
loading="eager"
|
||
decoding="async"
|
||
fetchPriority="high"
|
||
draggable={false}
|
||
onLoad={() => {
|
||
displayedOriginalsRef.current.add(fullSource);
|
||
setFullResolutionState({
|
||
id: image.id,
|
||
requested: true,
|
||
loaded: true,
|
||
failed: false,
|
||
});
|
||
}}
|
||
onError={() => setFullResolutionState({
|
||
id: image.id,
|
||
requested: true,
|
||
loaded: false,
|
||
failed: true,
|
||
})}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="viewer-nav viewer-previous"
|
||
onClick={() => onNavigate(-1)}
|
||
disabled={!canPrevious}
|
||
aria-label="上一张图片"
|
||
title="上一张 (←)"
|
||
>
|
||
<ChevronLeft size={23} strokeWidth={1.8} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="viewer-nav viewer-next"
|
||
onClick={() => onNavigate(1)}
|
||
disabled={!canNext || waitingForNext}
|
||
aria-label={waitingForNext ? "正在加载下一张" : "下一张图片"}
|
||
title="下一张 (→)"
|
||
>
|
||
{waitingForNext
|
||
? <LoaderCircle className="spin" size={20} />
|
||
: <ChevronRight size={23} strokeWidth={1.8} />}
|
||
</button>
|
||
|
||
<div className="viewer-bottom-bar">
|
||
<button
|
||
type="button"
|
||
className="viewer-scroll-cue"
|
||
onClick={() => scrollToDetails(true)}
|
||
aria-label="向下滚动到图片详情"
|
||
title="查看图片详情"
|
||
>
|
||
<ChevronsDown size={38} strokeWidth={1.55} />
|
||
</button>
|
||
</div>
|
||
|
||
{currentLoadState.failed && (
|
||
<button type="button" className="viewer-load-error" onClick={retryOriginal}>
|
||
<RefreshCw size={15} />
|
||
<span>原图加载失败,点击重试</span>
|
||
</button>
|
||
)}
|
||
</section>
|
||
|
||
<section
|
||
ref={detailsSectionRef}
|
||
className="viewer-details-page"
|
||
tabIndex={-1}
|
||
aria-labelledby="image-viewer-details-title"
|
||
data-details-image-name={image.name}
|
||
>
|
||
<div className="viewer-details-inner">
|
||
<header className="viewer-details-header">
|
||
<div className="viewer-details-heading">
|
||
<h2 id="image-viewer-details-title">图片详情</h2>
|
||
<span>{displayPosition} / {total}</span>
|
||
</div>
|
||
<div className="viewer-details-actions">
|
||
<button
|
||
type="button"
|
||
className="viewer-control viewer-details-return"
|
||
onClick={() => scrollToImage(true)}
|
||
aria-label="返回图片"
|
||
title="返回图片"
|
||
>
|
||
<ChevronUp size={20} strokeWidth={1.8} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="viewer-control viewer-details-close"
|
||
onClick={onClose}
|
||
aria-label="关闭查看器"
|
||
title="关闭 (Esc)"
|
||
>
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="viewer-details-content">
|
||
<article className="viewer-details-summary">
|
||
<img
|
||
key={`details-thumbnail-${image.id}`}
|
||
src={viewerThumbnailUrl(image)}
|
||
alt=""
|
||
aria-hidden="true"
|
||
loading="eager"
|
||
decoding="async"
|
||
/>
|
||
<div>
|
||
<span className="viewer-details-kind">{orientation} · {fileExtension}</span>
|
||
<h3 title={image.name}>{image.name}</h3>
|
||
<p>{image.width.toLocaleString()} × {image.height.toLocaleString()} 像素</p>
|
||
</div>
|
||
</article>
|
||
|
||
<section className="viewer-details-section" aria-labelledby="viewer-file-info-title">
|
||
<h3 id="viewer-file-info-title">文件信息</h3>
|
||
<dl className="viewer-details-grid">
|
||
<div>
|
||
<dt>分辨率</dt>
|
||
<dd>{image.width.toLocaleString()} × {image.height.toLocaleString()}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>像素</dt>
|
||
<dd>{megapixels >= 1 ? `${megapixels.toFixed(1)} MP` : `${Math.round(megapixels * 1000)} KP`}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>格式</dt>
|
||
<dd>{fileExtension}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>图库位置</dt>
|
||
<dd>{displayPosition} / {total}</dd>
|
||
</div>
|
||
</dl>
|
||
</section>
|
||
|
||
<a
|
||
className="viewer-details-download"
|
||
href={viewerOriginalUrl(image)}
|
||
download={image.name}
|
||
>
|
||
<Download size={19} />
|
||
<span>
|
||
<strong>下载原图</strong>
|
||
<small>保留原始尺寸与格式</small>
|
||
</span>
|
||
<ChevronRight size={18} />
|
||
</a>
|
||
|
||
<section
|
||
ref={similarSectionRef}
|
||
className="viewer-similar-section"
|
||
aria-labelledby="viewer-similar-title"
|
||
data-similar-active={similarActive}
|
||
>
|
||
<div className="viewer-similar-heading">
|
||
<div>
|
||
<h3 id="viewer-similar-title">相似图片</h3>
|
||
<p>在图库中查找构图、色彩和纹理相近的图片</p>
|
||
</div>
|
||
<ScanSearch size={20} aria-hidden="true" />
|
||
</div>
|
||
|
||
{!similarActive ? (
|
||
<button
|
||
type="button"
|
||
className="viewer-similar-search-button"
|
||
onClick={() => onSearchSimilar(image)}
|
||
>
|
||
<ScanSearch size={18} />
|
||
<span>以图搜图</span>
|
||
<ChevronRight size={17} />
|
||
</button>
|
||
) : similarLoading && !similarImages.length ? (
|
||
<SimilarImageSkeleton />
|
||
) : similarError && !similarImages.length ? (
|
||
<div className="viewer-similar-error" role="alert">
|
||
<span>{similarError}</span>
|
||
<button type="button" onClick={() => onSearchSimilar(image)}>
|
||
<RefreshCw size={15} />
|
||
重试
|
||
</button>
|
||
</div>
|
||
) : similarImages.length ? (
|
||
<SimilarImageMasonry
|
||
images={similarImages}
|
||
total={similarTotal}
|
||
columnCount={compactViewport ? 2 : 3}
|
||
hasMore={similarHasMore}
|
||
loadingMore={similarLoadingMore}
|
||
error={similarError}
|
||
onLoadMore={onLoadMoreSimilar}
|
||
onOpen={onOpenSimilar}
|
||
/>
|
||
) : (
|
||
<div className="viewer-similar-empty">
|
||
<ScanSearch size={22} />
|
||
<span>暂时没有找到相似图片</span>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
|
||
return createPortal(viewer, document.body);
|
||
}
|