zooming
This commit is contained in:
17
site.css
17
site.css
@@ -124,13 +124,14 @@ a:hover {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.BigPicture-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
touch-action: pan-y;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@@ -145,13 +146,24 @@ a:hover {
|
||||
max-width: 100%;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.BigPicture-frame.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.BigPicture-imageWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.BigPicture-zoomTarget {
|
||||
transition: transform 0.2s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.BigPicture img {
|
||||
transition-duration: 0.2s;
|
||||
transition-timing-function: ease-in-out;
|
||||
@@ -163,6 +175,7 @@ a:hover {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
max-width: 200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,16 @@ interface TouchStart {
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface PinchState {
|
||||
distance: number;
|
||||
startZoom: number;
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const BigPicture: React.FC<Props> = ({
|
||||
image,
|
||||
previousImage,
|
||||
@@ -26,11 +36,21 @@ export const BigPicture: React.FC<Props> = ({
|
||||
showPrevious,
|
||||
width,
|
||||
}) => {
|
||||
const viewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [touchStart, setTouchStart] = React.useState<TouchStart | null>(null);
|
||||
const [dragOffset, setDragOffset] = React.useState(0);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [animating, setAnimating] = React.useState(false);
|
||||
const [isSnapping, setIsSnapping] = React.useState(false);
|
||||
const [zoom, setZoom] = React.useState(1);
|
||||
const [pan, setPan] = React.useState<Point>({ x: 0, y: 0 });
|
||||
const [panStart, setPanStart] = React.useState<Point | null>(null);
|
||||
const [pinchState, setPinchState] = React.useState<PinchState | null>(null);
|
||||
const [isPointerPanning, setIsPointerPanning] = React.useState(false);
|
||||
const tapStartRef = React.useRef<Point | null>(null);
|
||||
const lastTapRef = React.useRef<{ time: number; x: number; y: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const onEscape = React.useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
@@ -49,19 +69,151 @@ export const BigPicture: React.FC<Props> = ({
|
||||
showPrevious();
|
||||
}, [showPrevious]);
|
||||
|
||||
const onTouchStart = React.useCallback((e: TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
const getViewportCenter = React.useCallback((): Point | null => {
|
||||
const node = viewportRef.current;
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
const rect = node.getBoundingClientRect();
|
||||
return { x: rect.width / 2, y: rect.height / 2 };
|
||||
}, []);
|
||||
|
||||
const clampPan = React.useCallback(
|
||||
(nextPan: Point, nextZoom?: number) => {
|
||||
const node = viewportRef.current;
|
||||
if (!node) {
|
||||
return nextPan;
|
||||
}
|
||||
const z = nextZoom ?? zoom;
|
||||
if (z <= 1) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
const viewportRect = node.getBoundingClientRect();
|
||||
const viewportWidth = viewportRect.width;
|
||||
const viewportHeight = viewportRect.height;
|
||||
const imgScaleWidth = image.width / width;
|
||||
const imgScaleHeight = image.height / (window.innerHeight - 80);
|
||||
const imgScale = Math.max(imgScaleWidth, imgScaleHeight);
|
||||
const displayWidth = image.width / imgScale;
|
||||
const displayHeight = image.height / imgScale;
|
||||
const maxPanX = Math.max(0, (displayWidth * z - viewportWidth) / 2);
|
||||
const maxPanY = Math.max(0, (displayHeight * z - viewportHeight) / 2);
|
||||
return {
|
||||
x: Math.min(maxPanX, Math.max(-maxPanX, nextPan.x)),
|
||||
y: Math.min(maxPanY, Math.max(-maxPanY, nextPan.y)),
|
||||
};
|
||||
},
|
||||
[image.height, image.width, width, zoom]
|
||||
);
|
||||
|
||||
const applyZoomAt = React.useCallback(
|
||||
(nextZoom: number, focalPoint?: Point) => {
|
||||
const clampedZoom = Math.min(4, Math.max(1, nextZoom));
|
||||
const center = getViewportCenter();
|
||||
if (!center) {
|
||||
setZoom(clampedZoom);
|
||||
setPan({ x: 0, y: 0 });
|
||||
return;
|
||||
}
|
||||
const focus = focalPoint ?? center;
|
||||
const ratio = clampedZoom / zoom;
|
||||
const nextPan = {
|
||||
x: pan.x * ratio + (focus.x - center.x) * (1 - ratio),
|
||||
y: pan.y * ratio + (focus.y - center.y) * (1 - ratio),
|
||||
};
|
||||
setZoom(clampedZoom);
|
||||
setPan(clampPan(nextPan, clampedZoom));
|
||||
},
|
||||
[clampPan, getViewportCenter, pan.x, pan.y, zoom]
|
||||
);
|
||||
|
||||
const startSwipe = React.useCallback((touch: React.Touch) => {
|
||||
setTouchStart({ x: touch.clientX, y: touch.clientY });
|
||||
setIsDragging(true);
|
||||
setDragOffset(0);
|
||||
}, []);
|
||||
|
||||
const onTouchStart = React.useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
tapStartRef.current = null;
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
const [a, b] = [e.touches[0], e.touches[1]];
|
||||
const dx = a.clientX - b.clientX;
|
||||
const dy = a.clientY - b.clientY;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
setPinchState({ distance, startZoom: zoom });
|
||||
setIsDragging(false);
|
||||
setTouchStart(null);
|
||||
setPanStart(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (zoom > 1) {
|
||||
const touch = e.touches[0];
|
||||
setPanStart({ x: touch.clientX, y: touch.clientY });
|
||||
setIsDragging(false);
|
||||
setTouchStart(null);
|
||||
tapStartRef.current = { x: touch.clientX, y: touch.clientY };
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = e.touches[0];
|
||||
startSwipe(touch);
|
||||
tapStartRef.current = { x: touch.clientX, y: touch.clientY };
|
||||
},
|
||||
[startSwipe, zoom]
|
||||
);
|
||||
|
||||
const onTouchEnd = React.useCallback(
|
||||
(e: TouchEvent) => {
|
||||
(e: React.TouchEvent) => {
|
||||
const touch = e.changedTouches[0];
|
||||
const tapStart = tapStartRef.current;
|
||||
const now = Date.now();
|
||||
const isTap =
|
||||
tapStart &&
|
||||
Math.hypot(tapStart.x - touch.clientX, tapStart.y - touch.clientY) < 10;
|
||||
const lastTap = lastTapRef.current;
|
||||
|
||||
if (isTap && lastTap && now - lastTap.time < 350) {
|
||||
const distance = Math.hypot(lastTap.x - touch.clientX, lastTap.y - touch.clientY);
|
||||
if (distance < 40) {
|
||||
const rect = viewportRef.current?.getBoundingClientRect();
|
||||
const focal = rect
|
||||
? { x: touch.clientX - rect.left, y: touch.clientY - rect.top }
|
||||
: undefined;
|
||||
const fitScale = Math.max(
|
||||
image.width / width,
|
||||
image.height / (window.innerHeight - 80)
|
||||
);
|
||||
const targetZoom = zoom > 1.05 ? 1 : Math.min(4, fitScale);
|
||||
applyZoomAt(targetZoom, focal);
|
||||
setIsDragging(false);
|
||||
setTouchStart(null);
|
||||
setPanStart(null);
|
||||
tapStartRef.current = null;
|
||||
lastTapRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isTap) {
|
||||
lastTapRef.current = { time: now, x: touch.clientX, y: touch.clientY };
|
||||
}
|
||||
|
||||
if (pinchState) {
|
||||
setPinchState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (zoom > 1) {
|
||||
setPanStart(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!touchStart) {
|
||||
return;
|
||||
}
|
||||
const touch = e.changedTouches[0];
|
||||
const dx = touch.clientX - touchStart.x;
|
||||
const threshold = Math.max(50, width * 0.08);
|
||||
const movedEnough = Math.abs(dx) > threshold;
|
||||
@@ -90,11 +242,43 @@ export const BigPicture: React.FC<Props> = ({
|
||||
setDragOffset(0);
|
||||
}
|
||||
},
|
||||
[goNext, goPrevious, touchStart, width]
|
||||
[applyZoomAt, goNext, goPrevious, image.height, image.width, pinchState, touchStart, width, zoom]
|
||||
);
|
||||
|
||||
const onTouchMove = React.useCallback(
|
||||
(e: TouchEvent) => {
|
||||
(e: React.TouchEvent) => {
|
||||
if (pinchState && e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
const [a, b] = [e.touches[0], e.touches[1]];
|
||||
const dx = a.clientX - b.clientX;
|
||||
const dy = a.clientY - b.clientY;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const rect = viewportRef.current?.getBoundingClientRect();
|
||||
const midpoint = {
|
||||
x: (a.clientX + b.clientX) / 2 - (rect?.left ?? 0),
|
||||
y: (a.clientY + b.clientY) / 2 - (rect?.top ?? 0),
|
||||
};
|
||||
const ratio = distance / pinchState.distance;
|
||||
applyZoomAt(pinchState.startZoom * ratio, midpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
if (zoom > 1 && panStart) {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
setPan((prev) =>
|
||||
clampPan(
|
||||
{
|
||||
x: prev.x + (touch.clientX - panStart.x),
|
||||
y: prev.y + (touch.clientY - panStart.y),
|
||||
},
|
||||
zoom
|
||||
)
|
||||
);
|
||||
setPanStart({ x: touch.clientX, y: touch.clientY });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!touchStart) {
|
||||
return;
|
||||
}
|
||||
@@ -108,24 +292,93 @@ export const BigPicture: React.FC<Props> = ({
|
||||
}
|
||||
setDragOffset(dx);
|
||||
},
|
||||
[touchStart]
|
||||
[applyZoomAt, clampPan, panStart, pinchState, touchStart, zoom]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("keyup", onEscape);
|
||||
window.addEventListener("touchstart", onTouchStart);
|
||||
window.addEventListener("touchmove", onTouchMove, { passive: true });
|
||||
window.addEventListener("touchend", onTouchEnd);
|
||||
document.body.classList.add("no-scroll");
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keyup", onEscape);
|
||||
window.removeEventListener("touchstart", onTouchStart);
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("touchend", onTouchEnd);
|
||||
document.body.classList.remove("no-scroll");
|
||||
};
|
||||
}, [onEscape, onTouchEnd, onTouchMove, onTouchStart]);
|
||||
}, [onEscape]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, [image.src]);
|
||||
|
||||
const onWheel = React.useCallback(
|
||||
(e: WheelEvent) => {
|
||||
if (!viewportRef.current) {
|
||||
return;
|
||||
}
|
||||
const rect = viewportRef.current.getBoundingClientRect();
|
||||
const focal = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
};
|
||||
const delta = -e.deltaY * (e.ctrlKey ? 0.0025 : 0.0015);
|
||||
const nextZoom = zoom * (1 + delta);
|
||||
if (Math.abs(delta) > 0.0001) {
|
||||
e.preventDefault();
|
||||
}
|
||||
applyZoomAt(nextZoom, focal);
|
||||
},
|
||||
[applyZoomAt, zoom]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const node = viewportRef.current;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => {
|
||||
node.removeEventListener("wheel", onWheel);
|
||||
};
|
||||
}, [onWheel]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isPointerPanning || zoom <= 1) {
|
||||
return;
|
||||
}
|
||||
setPan((prev) =>
|
||||
clampPan(
|
||||
{
|
||||
x: prev.x + e.movementX,
|
||||
y: prev.y + e.movementY,
|
||||
},
|
||||
zoom
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
setIsPointerPanning(false);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [clampPan, isPointerPanning, zoom]);
|
||||
|
||||
const onMouseDown = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (zoom <= 1 || e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setIsPointerPanning(true);
|
||||
},
|
||||
[zoom]
|
||||
);
|
||||
|
||||
const slideWidth = width;
|
||||
const trackBase = -slideWidth + dragOffset;
|
||||
@@ -143,7 +396,14 @@ export const BigPicture: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div className="BigPicture">
|
||||
<div className="BigPicture-viewport">
|
||||
<div
|
||||
className="BigPicture-viewport"
|
||||
ref={viewportRef}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<div
|
||||
className={`BigPicture-track${isDragging ? " is-dragging" : ""}`}
|
||||
style={trackStyle}
|
||||
@@ -152,18 +412,33 @@ export const BigPicture: React.FC<Props> = ({
|
||||
const imgScaleWidth = img.width / width;
|
||||
const imgScaleHeight = img.height / (window.innerHeight - 80);
|
||||
const imgScale = Math.max(imgScaleWidth, imgScaleHeight);
|
||||
const isCurrent = img.src === image.src;
|
||||
return (
|
||||
<div
|
||||
className="BigPicture-frame"
|
||||
key={img.src}
|
||||
style={slideStyle}
|
||||
>
|
||||
<Picture
|
||||
image={img}
|
||||
onClick={() => {}}
|
||||
height={img.height / imgScale}
|
||||
width={img.width / imgScale}
|
||||
/>
|
||||
<div
|
||||
className="BigPicture-imageWrapper"
|
||||
style={
|
||||
isCurrent
|
||||
? { transform: `translate3d(${pan.x}px, ${pan.y}px, 0)` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="BigPicture-zoomTarget"
|
||||
style={isCurrent ? { transform: `scale(${zoom})` } : undefined}
|
||||
>
|
||||
<Picture
|
||||
image={img}
|
||||
onClick={() => {}}
|
||||
height={img.height / imgScale}
|
||||
width={img.width / imgScale}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -175,6 +450,7 @@ export const BigPicture: React.FC<Props> = ({
|
||||
href={`img/${image.src}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
download={image.src}
|
||||
>
|
||||
⬇ Download
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user