234 lines
6.5 KiB
TypeScript
234 lines
6.5 KiB
TypeScript
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
import styled from '@emotion/styled';
|
|
|
|
interface ImageViewerProps {
|
|
/** 图片地址 */
|
|
image: string;
|
|
/** 图片渲染模式 */
|
|
imageRendering?: 'pixelated' | 'auto';
|
|
/** 是否可缩放 */
|
|
zoomable?: boolean;
|
|
/** 是否可移动 */
|
|
movable?: boolean;
|
|
/** 最小缩放比例 */
|
|
minZoomScale?: number;
|
|
/** 最大缩放比例 */
|
|
maxZoomScale?: number;
|
|
/** 缩放步长 */
|
|
zoomStep?: number;
|
|
/** 滚轮缩放事件 */
|
|
onMouseWheelZoom?: (e: WheelEvent, scale: number) => void;
|
|
/** 是否保持变换状态(缩放和位移) */
|
|
keepTransforms?: boolean;
|
|
}
|
|
|
|
export interface ImageViewerRef {
|
|
reset: (type?: 'zoom' | 'position' | 'all') => void;
|
|
setScale: (scale: number) => void;
|
|
scale: number;
|
|
fit: () => void;
|
|
}
|
|
|
|
const ViewerContainer = styled.div`
|
|
height: 100%;
|
|
border: 1px solid #ddd;
|
|
padding: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
`;
|
|
|
|
const ImageContainer = styled.div`
|
|
flex: 1;
|
|
border: 1px solid #eee;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
position: relative;
|
|
`;
|
|
|
|
interface StyledImageProps {
|
|
withAnimation?: boolean;
|
|
imageRendering?: 'pixelated' | 'auto';
|
|
}
|
|
|
|
const StyledImage = styled.img<StyledImageProps>`
|
|
max-width: none;
|
|
max-height: none;
|
|
transform-origin: center center;
|
|
cursor: grab;
|
|
user-select: none;
|
|
position: relative;
|
|
transition: ${(props: StyledImageProps) => props.withAnimation ? 'transform 0.2s ease-out' : 'none'};
|
|
image-rendering: ${props => props.imageRendering};
|
|
|
|
&.dragging {
|
|
cursor: grabbing;
|
|
transition: none;
|
|
}
|
|
`;
|
|
|
|
const ImageViewer = forwardRef<ImageViewerRef, ImageViewerProps>(
|
|
({
|
|
image,
|
|
imageRendering = 'auto',
|
|
zoomable: scalable = true,
|
|
movable = true,
|
|
minZoomScale: minScale = 0.1,
|
|
maxZoomScale: maxScale = 5.0,
|
|
zoomStep = 0.1,
|
|
onMouseWheelZoom,
|
|
keepTransforms = false,
|
|
}, ref) => {
|
|
const [scale, setScale] = useState(1.0);
|
|
const [translateX, setTranslateX] = useState(0);
|
|
const [translateY, setTranslateY] = useState(0);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [useAnimation, setUseAnimation] = useState(false);
|
|
|
|
const startPosRef = useRef({ x: 0, y: 0 });
|
|
const imageRef = useRef<HTMLImageElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 重置视图
|
|
const reset = (type: 'zoom' | 'position' | 'all' = 'all') => {
|
|
setUseAnimation(true);
|
|
if (type === 'zoom' || type === 'all') {
|
|
setScale(1.0);
|
|
}
|
|
if (type === 'position' || type === 'all') {
|
|
setTranslateX(0);
|
|
setTranslateY(0);
|
|
}
|
|
};
|
|
|
|
// 适应容器大小
|
|
const fit = () => {
|
|
if (!imageRef.current || !containerRef.current) return;
|
|
|
|
const container = containerRef.current;
|
|
const img = imageRef.current;
|
|
|
|
const containerRatio = container.clientWidth / container.clientHeight;
|
|
const imageRatio = img.naturalWidth / img.naturalHeight;
|
|
|
|
let fitScale;
|
|
if (imageRatio > containerRatio) {
|
|
fitScale = container.clientWidth / img.naturalWidth * 0.9;
|
|
} else {
|
|
fitScale = container.clientHeight / img.naturalHeight * 0.9;
|
|
}
|
|
|
|
setUseAnimation(false);
|
|
setScale(fitScale);
|
|
setTranslateX(0);
|
|
setTranslateY(0);
|
|
};
|
|
|
|
// 设置缩放
|
|
const setScaleWithLimits = useCallback((newScale: number, withAnimation = false) => {
|
|
if (!scalable) return;
|
|
const limitedScale = Math.min(Math.max(newScale, minScale), maxScale);
|
|
setUseAnimation(withAnimation);
|
|
setScale(limitedScale);
|
|
}, [scalable, minScale, maxScale]);
|
|
|
|
// 暴露方法给父组件
|
|
useImperativeHandle(ref, () => ({
|
|
reset,
|
|
setScale: (scale: number) => setScaleWithLimits(scale, true),
|
|
get scale() {
|
|
return scale;
|
|
},
|
|
fit
|
|
}));
|
|
|
|
// ===== 事件 =====
|
|
// 处理鼠标按下事件
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
if (!movable) return;
|
|
setIsDragging(true);
|
|
startPosRef.current = {
|
|
x: e.clientX - translateX,
|
|
y: e.clientY - translateY
|
|
};
|
|
};
|
|
|
|
// 处理鼠标移动事件
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (!isDragging || !movable) return;
|
|
setUseAnimation(false);
|
|
setTranslateX(e.clientX - startPosRef.current.x);
|
|
setTranslateY(e.clientY - startPosRef.current.y);
|
|
};
|
|
|
|
// 处理鼠标松开事件
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
};
|
|
|
|
// 处理滚轮缩放
|
|
const handleWheel = useCallback((e: WheelEvent) => {
|
|
if (!scalable) return;
|
|
e.preventDefault();
|
|
const delta = e.deltaY;
|
|
setScaleWithLimits(scale + (delta > 0 ? -zoomStep : zoomStep), true);
|
|
onMouseWheelZoom?.(e, scale + (delta > 0 ? -zoomStep : zoomStep));
|
|
}, [scalable, scale, setScaleWithLimits, zoomStep, onMouseWheelZoom]);
|
|
|
|
// 图片加载完成后自动适应容器大小
|
|
const handleImageLoad = () => {
|
|
if (!keepTransforms)
|
|
fit();
|
|
};
|
|
|
|
// 监听
|
|
useEffect(() => {
|
|
const handleGlobalMouseUp = () => {
|
|
if (isDragging) {
|
|
setIsDragging(false);
|
|
}
|
|
};
|
|
document.addEventListener('mouseup', handleGlobalMouseUp);
|
|
return () => {
|
|
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
|
};
|
|
}, [isDragging]);
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
const container = containerRef.current;
|
|
container.addEventListener('wheel', handleWheel, { passive: false });
|
|
return () => {
|
|
container.removeEventListener('wheel', handleWheel);
|
|
};
|
|
}, [handleWheel]);
|
|
|
|
// ===== JSX =====
|
|
return (
|
|
<ViewerContainer>
|
|
<ImageContainer
|
|
ref={containerRef}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
>
|
|
<StyledImage
|
|
ref={imageRef}
|
|
src={image}
|
|
onLoad={handleImageLoad}
|
|
onMouseDown={handleMouseDown}
|
|
onDragStart={(e: React.DragEvent<HTMLImageElement>) => e.preventDefault()}
|
|
className={isDragging ? 'dragging' : ''}
|
|
withAnimation={useAnimation}
|
|
imageRendering={imageRendering}
|
|
style={{
|
|
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`
|
|
}}
|
|
/>
|
|
</ImageContainer>
|
|
</ViewerContainer>
|
|
);
|
|
}
|
|
);
|
|
|
|
export default ImageViewer;
|