zooming
This commit is contained in:
17
site.css
17
site.css
@@ -124,13 +124,14 @@ a:hover {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.BigPicture-track {
|
.BigPicture-track {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
touch-action: pan-y;
|
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,13 +146,24 @@ a:hover {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
touch-action: pan-y;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.BigPicture-frame.is-dragging {
|
.BigPicture-frame.is-dragging {
|
||||||
cursor: grabbing;
|
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 {
|
.BigPicture img {
|
||||||
transition-duration: 0.2s;
|
transition-duration: 0.2s;
|
||||||
transition-timing-function: ease-in-out;
|
transition-timing-function: ease-in-out;
|
||||||
@@ -163,6 +175,7 @@ a:hover {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ interface TouchStart {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PinchState {
|
||||||
|
distance: number;
|
||||||
|
startZoom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const BigPicture: React.FC<Props> = ({
|
export const BigPicture: React.FC<Props> = ({
|
||||||
image,
|
image,
|
||||||
previousImage,
|
previousImage,
|
||||||
@@ -26,11 +36,21 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
showPrevious,
|
showPrevious,
|
||||||
width,
|
width,
|
||||||
}) => {
|
}) => {
|
||||||
|
const viewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const [touchStart, setTouchStart] = React.useState<TouchStart | null>(null);
|
const [touchStart, setTouchStart] = React.useState<TouchStart | null>(null);
|
||||||
const [dragOffset, setDragOffset] = React.useState(0);
|
const [dragOffset, setDragOffset] = React.useState(0);
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
const [animating, setAnimating] = React.useState(false);
|
const [animating, setAnimating] = React.useState(false);
|
||||||
const [isSnapping, setIsSnapping] = 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(
|
const onEscape = React.useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
@@ -49,19 +69,151 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
showPrevious();
|
showPrevious();
|
||||||
}, [showPrevious]);
|
}, [showPrevious]);
|
||||||
|
|
||||||
const onTouchStart = React.useCallback((e: TouchEvent) => {
|
const getViewportCenter = React.useCallback((): Point | null => {
|
||||||
const touch = e.touches[0];
|
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 });
|
setTouchStart({ x: touch.clientX, y: touch.clientY });
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
setDragOffset(0);
|
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(
|
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) {
|
if (!touchStart) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const touch = e.changedTouches[0];
|
|
||||||
const dx = touch.clientX - touchStart.x;
|
const dx = touch.clientX - touchStart.x;
|
||||||
const threshold = Math.max(50, width * 0.08);
|
const threshold = Math.max(50, width * 0.08);
|
||||||
const movedEnough = Math.abs(dx) > threshold;
|
const movedEnough = Math.abs(dx) > threshold;
|
||||||
@@ -90,11 +242,43 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
setDragOffset(0);
|
setDragOffset(0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[goNext, goPrevious, touchStart, width]
|
[applyZoomAt, goNext, goPrevious, image.height, image.width, pinchState, touchStart, width, zoom]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onTouchMove = React.useCallback(
|
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) {
|
if (!touchStart) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -108,24 +292,93 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
setDragOffset(dx);
|
setDragOffset(dx);
|
||||||
},
|
},
|
||||||
[touchStart]
|
[applyZoomAt, clampPan, panStart, pinchState, touchStart, zoom]
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
window.addEventListener("keyup", onEscape);
|
window.addEventListener("keyup", onEscape);
|
||||||
window.addEventListener("touchstart", onTouchStart);
|
|
||||||
window.addEventListener("touchmove", onTouchMove, { passive: true });
|
|
||||||
window.addEventListener("touchend", onTouchEnd);
|
|
||||||
document.body.classList.add("no-scroll");
|
document.body.classList.add("no-scroll");
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keyup", onEscape);
|
window.removeEventListener("keyup", onEscape);
|
||||||
window.removeEventListener("touchstart", onTouchStart);
|
|
||||||
window.removeEventListener("touchmove", onTouchMove);
|
|
||||||
window.removeEventListener("touchend", onTouchEnd);
|
|
||||||
document.body.classList.remove("no-scroll");
|
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 slideWidth = width;
|
||||||
const trackBase = -slideWidth + dragOffset;
|
const trackBase = -slideWidth + dragOffset;
|
||||||
@@ -143,7 +396,14 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="BigPicture">
|
<div className="BigPicture">
|
||||||
<div className="BigPicture-viewport">
|
<div
|
||||||
|
className="BigPicture-viewport"
|
||||||
|
ref={viewportRef}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`BigPicture-track${isDragging ? " is-dragging" : ""}`}
|
className={`BigPicture-track${isDragging ? " is-dragging" : ""}`}
|
||||||
style={trackStyle}
|
style={trackStyle}
|
||||||
@@ -152,18 +412,33 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
const imgScaleWidth = img.width / width;
|
const imgScaleWidth = img.width / width;
|
||||||
const imgScaleHeight = img.height / (window.innerHeight - 80);
|
const imgScaleHeight = img.height / (window.innerHeight - 80);
|
||||||
const imgScale = Math.max(imgScaleWidth, imgScaleHeight);
|
const imgScale = Math.max(imgScaleWidth, imgScaleHeight);
|
||||||
|
const isCurrent = img.src === image.src;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="BigPicture-frame"
|
className="BigPicture-frame"
|
||||||
key={img.src}
|
key={img.src}
|
||||||
style={slideStyle}
|
style={slideStyle}
|
||||||
>
|
>
|
||||||
<Picture
|
<div
|
||||||
image={img}
|
className="BigPicture-imageWrapper"
|
||||||
onClick={() => {}}
|
style={
|
||||||
height={img.height / imgScale}
|
isCurrent
|
||||||
width={img.width / imgScale}
|
? { 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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -175,6 +450,7 @@ export const BigPicture: React.FC<Props> = ({
|
|||||||
href={`img/${image.src}`}
|
href={`img/${image.src}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
download={image.src}
|
||||||
>
|
>
|
||||||
⬇ Download
|
⬇ Download
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user