mirror of
https://github.com/infeng/react-viewer.git
synced 2025-12-08 17:36:40 +00:00
722 lines
18 KiB
TypeScript
722 lines
18 KiB
TypeScript
import * as React from 'react';
|
|
import './style/index.less';
|
|
import ViewerCanvas from './ViewerCanvas';
|
|
import ViewerNav from './ViewerNav';
|
|
import ViewerToolbar, { defaultToolbars } from './ViewerToolbar';
|
|
import ViewerProps, { ImageDecorator, ToolbarConfig } from './ViewerProps';
|
|
import Icon, { ActionType } from './Icon';
|
|
import * as constants from './constants';
|
|
import classnames from 'classnames';
|
|
|
|
function noop() { }
|
|
|
|
// const transitionDuration = 300;
|
|
|
|
const ACTION_TYPES = {
|
|
setVisible: 'setVisible',
|
|
setActiveIndex: 'setActiveIndex',
|
|
update: 'update',
|
|
clear: 'clear',
|
|
};
|
|
|
|
function createAction(type, payload) {
|
|
return {
|
|
type,
|
|
payload: payload || {},
|
|
};
|
|
}
|
|
|
|
export interface ViewerCoreState {
|
|
visible?: boolean;
|
|
visibleStart?: boolean;
|
|
transitionEnd?: boolean;
|
|
activeIndex?: number;
|
|
width?: number;
|
|
height?: number;
|
|
top?: number;
|
|
left?: number;
|
|
rotate?: number;
|
|
imageWidth?: number;
|
|
imageHeight?: number;
|
|
scaleX?: number;
|
|
scaleY?: number;
|
|
loading?: boolean;
|
|
loadFailed?: boolean;
|
|
startLoading: boolean;
|
|
}
|
|
|
|
export default (props: ViewerProps) => {
|
|
const {
|
|
visible = false,
|
|
onClose = noop,
|
|
images = [],
|
|
activeIndex = 0,
|
|
zIndex = 1000,
|
|
drag = true,
|
|
attribute = true,
|
|
zoomable = true,
|
|
rotatable = true,
|
|
scalable = true,
|
|
onMaskClick = noop,
|
|
changeable = true,
|
|
customToolbar = (toolbars) => toolbars,
|
|
zoomSpeed = .05,
|
|
disableKeyboardSupport = false,
|
|
noResetZoomAfterChange = false,
|
|
noLimitInitializationSize = false,
|
|
defaultScale = 1,
|
|
loop = true,
|
|
disableMouseZoom = false,
|
|
downloadable = false,
|
|
noImgDetails = false,
|
|
noToolbar = false,
|
|
showTotal = true,
|
|
minScale = 0.1,
|
|
} = props;
|
|
|
|
const initialState: ViewerCoreState = {
|
|
visible: false,
|
|
visibleStart: false,
|
|
transitionEnd: false,
|
|
activeIndex: props.activeIndex,
|
|
width: 0,
|
|
height: 0,
|
|
top: 15,
|
|
left: null,
|
|
rotate: 0,
|
|
imageWidth: 0,
|
|
imageHeight: 0,
|
|
scaleX: defaultScale,
|
|
scaleY: defaultScale,
|
|
loading: false,
|
|
loadFailed: false,
|
|
startLoading: false,
|
|
};
|
|
function setContainerWidthHeight() {
|
|
let width = window.innerWidth;
|
|
let height = window.innerHeight;
|
|
if (props.container) {
|
|
width = props.container.offsetWidth;
|
|
height = props.container.offsetHeight;
|
|
}
|
|
return {
|
|
width,
|
|
height,
|
|
};
|
|
}
|
|
const containerSize = React.useRef(setContainerWidthHeight());
|
|
const footerHeight = constants.FOOTER_HEIGHT;
|
|
function reducer(s: ViewerCoreState, action): typeof initialState {
|
|
switch (action.type) {
|
|
case ACTION_TYPES.setVisible:
|
|
return {
|
|
...s,
|
|
visible: action.payload.visible,
|
|
};
|
|
case ACTION_TYPES.setActiveIndex:
|
|
return {
|
|
...s,
|
|
activeIndex: action.payload.index,
|
|
startLoading: true,
|
|
};
|
|
case ACTION_TYPES.update:
|
|
return {
|
|
...s,
|
|
...action.payload,
|
|
};
|
|
case ACTION_TYPES.clear:
|
|
return {
|
|
...s,
|
|
width: 0,
|
|
height: 0,
|
|
scaleX: defaultScale,
|
|
scaleY: defaultScale,
|
|
rotate: 1,
|
|
imageWidth: 0,
|
|
imageHeight: 0,
|
|
loadFailed: false,
|
|
top: 0,
|
|
left: 0,
|
|
loading: false,
|
|
};
|
|
default:
|
|
break;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
const viewerCore = React.useRef<HTMLDivElement>(null);
|
|
const init = React.useRef(false);
|
|
const currentLoadIndex = React.useRef(0);
|
|
const [ state, dispatch ] = React.useReducer<(s: any, a: any) => ViewerCoreState>(reducer, initialState);
|
|
|
|
React.useEffect(() => {
|
|
init.current = true;
|
|
|
|
return () => {
|
|
init.current = false;
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
containerSize.current = setContainerWidthHeight();
|
|
}, [props.container]);
|
|
|
|
React.useEffect(() => {
|
|
if (visible) {
|
|
if (init.current) {
|
|
dispatch(createAction(ACTION_TYPES.setVisible, {
|
|
visible: true,
|
|
}));
|
|
}
|
|
}
|
|
}, [visible]);
|
|
|
|
React.useEffect(() => {
|
|
bindEvent();
|
|
|
|
return () => {
|
|
bindEvent(true);
|
|
};
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
if (visible) {
|
|
if (!props.container) {
|
|
document.body.style.overflow = 'hidden';
|
|
if (document.body.scrollHeight > document.body.clientHeight) {
|
|
document.body.style.paddingRight = '15px';
|
|
}
|
|
}
|
|
} else {
|
|
dispatch(createAction(ACTION_TYPES.clear, {}));
|
|
}
|
|
|
|
return () => {
|
|
document.body.style.overflow = '';
|
|
document.body.style.paddingRight = '';
|
|
};
|
|
}, [state.visible]);
|
|
|
|
React.useEffect(() => {
|
|
if (visible) {
|
|
dispatch(createAction(ACTION_TYPES.setActiveIndex, {
|
|
index: activeIndex,
|
|
}));
|
|
}
|
|
}, [activeIndex, visible, images]);
|
|
|
|
function loadImg(currentActiveIndex, isReset = false) {
|
|
dispatch(createAction(ACTION_TYPES.update, {
|
|
loading: true,
|
|
loadFailed: false,
|
|
}));
|
|
let activeImage: ImageDecorator = null;
|
|
if (images.length > 0) {
|
|
activeImage = images[currentActiveIndex];
|
|
}
|
|
let loadComplete = false;
|
|
let img = new Image();
|
|
img.onload = () => {
|
|
if (!init.current) {
|
|
return;
|
|
}
|
|
if (!loadComplete) {
|
|
loadImgSuccess(img.width, img.height, true);
|
|
}
|
|
};
|
|
img.onerror = () => {
|
|
if (!init.current) {
|
|
return;
|
|
}
|
|
if (props.defaultImg) {
|
|
dispatch(createAction(ACTION_TYPES.update, {
|
|
loading: false,
|
|
loadFailed: true,
|
|
startLoading: false,
|
|
}));
|
|
const deafultImgWidth = props.defaultImg.width || containerSize.current.width * .5;
|
|
const defaultImgHeight = props.defaultImg.height || containerSize.current.height * .5;
|
|
loadImgSuccess(deafultImgWidth, defaultImgHeight, false);
|
|
} else {
|
|
dispatch(createAction(ACTION_TYPES.update, {
|
|
loading: false,
|
|
loadFailed: false,
|
|
startLoading: false,
|
|
}));
|
|
}
|
|
};
|
|
img.src = activeImage.src;
|
|
if (img.complete) {
|
|
loadComplete = true;
|
|
loadImgSuccess(img.width, img.height, true);
|
|
}
|
|
function loadImgSuccess(imgWidth, imgHeight, success) {
|
|
if (currentActiveIndex !== currentLoadIndex.current) {
|
|
return;
|
|
}
|
|
let realImgWidth = imgWidth;
|
|
let realImgHeight = imgHeight;
|
|
if (props.defaultSize) {
|
|
realImgWidth = props.defaultSize.width;
|
|
realImgHeight = props.defaultSize.height;
|
|
}
|
|
if (activeImage.defaultSize) {
|
|
realImgWidth = activeImage.defaultSize.width;
|
|
realImgHeight = activeImage.defaultSize.height;
|
|
}
|
|
let [width, height] = getImgWidthHeight(realImgWidth, realImgHeight);
|
|
let left = (containerSize.current.width - width) / 2;
|
|
let top = (containerSize.current.height - height - footerHeight) / 2;
|
|
let scaleX = defaultScale;
|
|
let scaleY = defaultScale;
|
|
if (noResetZoomAfterChange && !isReset) {
|
|
scaleX = state.scaleX;
|
|
scaleY = state.scaleY;
|
|
}
|
|
dispatch(createAction(ACTION_TYPES.update, {
|
|
width: width,
|
|
height: height,
|
|
left: left,
|
|
top: top,
|
|
imageWidth: imgWidth,
|
|
imageHeight: imgHeight,
|
|
loading: false,
|
|
rotate: 0,
|
|
scaleX: scaleX,
|
|
scaleY: scaleY,
|
|
loadFailed: !success,
|
|
startLoading: false,
|
|
}));
|
|
}
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
if (state.startLoading) {
|
|
currentLoadIndex.current = state.activeIndex;
|
|
loadImg(state.activeIndex);
|
|
}
|
|
}, [state.startLoading, state.activeIndex]);
|
|
|
|
function getImgWidthHeight(imgWidth, imgHeight) {
|
|
let width = 0;
|
|
let height = 0;
|
|
let maxWidth = containerSize.current.width * 0.8;
|
|
let maxHeight = (containerSize.current.height - footerHeight) * 0.8;
|
|
width = Math.min(maxWidth, imgWidth);
|
|
height = width / imgWidth * imgHeight;
|
|
if (height > maxHeight) {
|
|
height = maxHeight;
|
|
width = height / imgHeight * imgWidth;
|
|
}
|
|
if (noLimitInitializationSize) {
|
|
width = imgWidth;
|
|
height = imgHeight;
|
|
}
|
|
return [width, height];
|
|
}
|
|
|
|
function handleChangeImg(newIndex: number) {
|
|
if (!loop && (newIndex >= images.length || newIndex < 0)) {
|
|
return;
|
|
}
|
|
if (newIndex >= images.length) {
|
|
newIndex = 0;
|
|
}
|
|
if (newIndex < 0) {
|
|
newIndex = images.length - 1;
|
|
}
|
|
if (newIndex === state.activeIndex) {
|
|
return;
|
|
}
|
|
if (props.onChange) {
|
|
const activeImage = getActiveImage(newIndex);
|
|
props.onChange(activeImage, newIndex);
|
|
}
|
|
dispatch(createAction(ACTION_TYPES.setActiveIndex, {
|
|
index: newIndex,
|
|
}));
|
|
}
|
|
|
|
function getActiveImage(activeIndex2 = undefined) {
|
|
let activeImg2: ImageDecorator = {
|
|
src: '',
|
|
alt: '',
|
|
downloadUrl: '',
|
|
};
|
|
|
|
let realActiveIndex = null;
|
|
if (activeIndex2 !== undefined) {
|
|
realActiveIndex = activeIndex2;
|
|
} else {
|
|
realActiveIndex = state.activeIndex;
|
|
}
|
|
if (images.length > 0 && realActiveIndex >= 0) {
|
|
activeImg2 = images[realActiveIndex];
|
|
}
|
|
|
|
return activeImg2;
|
|
}
|
|
|
|
function handleDownload() {
|
|
const activeImage = getActiveImage();
|
|
if (activeImage.downloadUrl) {
|
|
if (props.downloadInNewWindow) {
|
|
window.open(activeImage.downloadUrl, '_blank');
|
|
} else {
|
|
location.href = activeImage.downloadUrl;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleScaleX(newScale: 1 | -1) {
|
|
dispatch(createAction(ACTION_TYPES.update, {
|
|
scaleX: state.scaleX * newScale,
|
|
}));
|
|
}
|
|
|
|
function handleScaleY(newScale: 1 | -1) {
|
|
dispatch(createAction(ACTION_TYPES.update, {
|
|
scaleY: state.scaleY * newScale,
|
|
}));
|
|
}
|
|
|
|
function handleRotate(isRight: boolean = false) {
|
|
dispatch(createAction(ACTION_TYPES.update, {
|
|
rotate: state.rotate + 90 * (isRight ? 1 : -1),
|
|
}));
|
|
}
|
|
|
|
function handleDefaultAction(type: ActionType) {
|
|
switch (type) {
|
|
case ActionType.prev:
|
|
handleChangeImg(state.activeIndex - 1);
|
|
break;
|
|
case ActionType.next:
|
|
handleChangeImg(state.activeIndex + 1);
|
|
break;
|
|
case ActionType.zoomIn:
|
|
let imgCenterXY = getImageCenterXY();
|
|
handleZoom(imgCenterXY.x, imgCenterXY.y, 1, zoomSpeed);
|
|
break;
|
|
case ActionType.zoomOut:
|
|
let imgCenterXY2 = getImageCenterXY();
|
|
handleZoom(imgCenterXY2.x, imgCenterXY2.y, -1, zoomSpeed);
|
|
break;
|
|
case ActionType.rotateLeft:
|
|
handleRotate();
|
|
break;
|
|
case ActionType.rotateRight:
|
|
handleRotate(true);
|
|
break;
|
|
case ActionType.reset:
|
|
loadImg(state.activeIndex, true);
|
|
break;
|
|
case ActionType.scaleX:
|
|
handleScaleX(-1);
|
|
break;
|
|
case ActionType.scaleY:
|
|
handleScaleY(-1);
|
|
break;
|
|
case ActionType.download:
|
|
handleDownload();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
function handleAction(config: ToolbarConfig) {
|
|
handleDefaultAction(config.actionType);
|
|
|
|
if (config.onClick) {
|
|
const activeImage = getActiveImage();
|
|
config.onClick(activeImage);
|
|
}
|
|
}
|
|
|
|
function handleChangeImgState(width, height, top, left) {
|
|
dispatch(createAction(ACTION_TYPES.update, {
|
|
width: width,
|
|
height: height,
|
|
top: top,
|
|
left: left,
|
|
}));
|
|
}
|
|
|
|
function handleResize() {
|
|
containerSize.current = setContainerWidthHeight();
|
|
if (visible) {
|
|
let left = (containerSize.current.width - state.width) / 2;
|
|
let top = (containerSize.current.height - state.height - footerHeight) / 2;
|
|
dispatch(createAction(ACTION_TYPES.update, {
|
|
left: left,
|
|
top: top,
|
|
}));
|
|
}
|
|
}
|
|
|
|
function handleCanvasMouseDown(e) {
|
|
onMaskClick(e);
|
|
}
|
|
|
|
function bindEvent(remove: boolean = false) {
|
|
let funcName = 'addEventListener';
|
|
if (remove) {
|
|
funcName = 'removeEventListener';
|
|
}
|
|
if (!disableKeyboardSupport) {
|
|
document[funcName]('keydown', handleKeydown, true);
|
|
}
|
|
if (viewerCore.current) {
|
|
viewerCore.current[funcName](
|
|
'wheel',
|
|
handleMouseScroll,
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
|
|
function handleKeydown(e) {
|
|
let keyCode = e.keyCode || e.which || e.charCode;
|
|
let isFeatrue = false;
|
|
switch (keyCode) {
|
|
// key: esc
|
|
case 27:
|
|
onClose();
|
|
isFeatrue = true;
|
|
break;
|
|
// key: ←
|
|
case 37:
|
|
if (e.ctrlKey) {
|
|
handleDefaultAction(ActionType.rotateLeft);
|
|
} else {
|
|
handleDefaultAction(ActionType.prev);
|
|
}
|
|
isFeatrue = true;
|
|
break;
|
|
// key: →
|
|
case 39:
|
|
if (e.ctrlKey) {
|
|
handleDefaultAction(ActionType.rotateRight);
|
|
} else {
|
|
handleDefaultAction(ActionType.next);
|
|
}
|
|
isFeatrue = true;
|
|
break;
|
|
// key: ↑
|
|
case 38:
|
|
handleDefaultAction(ActionType.zoomIn);
|
|
isFeatrue = true;
|
|
break;
|
|
// key: ↓
|
|
case 40:
|
|
handleDefaultAction(ActionType.zoomOut);
|
|
isFeatrue = true;
|
|
break;
|
|
// key: Ctrl + 1
|
|
case 49:
|
|
if (e.ctrlKey) {
|
|
loadImg(state.activeIndex);
|
|
isFeatrue = true;
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
if (isFeatrue) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
|
|
function handleMouseScroll(e) {
|
|
if (disableMouseZoom) {
|
|
return;
|
|
}
|
|
if (state.loading) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
let direct: 0 | 1 | -1 = 0;
|
|
const value = e.deltaY;
|
|
if (value === 0) {
|
|
direct = 0;
|
|
} else {
|
|
direct = value > 0 ? -1 : 1;
|
|
}
|
|
if (direct !== 0) {
|
|
let x = e.clientX;
|
|
let y = e.clientY;
|
|
if (props.container) {
|
|
const containerRect = props.container.getBoundingClientRect();
|
|
x -= containerRect.left;
|
|
y -= containerRect.top;
|
|
}
|
|
handleZoom(x, y, direct, zoomSpeed);
|
|
}
|
|
}
|
|
|
|
function getImageCenterXY() {
|
|
return {
|
|
x: state.left + state.width / 2,
|
|
y: state.top + state.height / 2,
|
|
};
|
|
}
|
|
|
|
function handleZoom(targetX, targetY, direct, scale) {
|
|
let imgCenterXY = getImageCenterXY();
|
|
let diffX = targetX - imgCenterXY.x;
|
|
let diffY = targetY - imgCenterXY.y;
|
|
let top = 0;
|
|
let left = 0;
|
|
let width = 0;
|
|
let height = 0;
|
|
let scaleX = 0;
|
|
let scaleY = 0;
|
|
if (state.width === 0) {
|
|
const [imgWidth, imgHeight] = getImgWidthHeight(
|
|
state.imageWidth,
|
|
state.imageHeight,
|
|
);
|
|
left = (containerSize.current.width - imgWidth) / 2;
|
|
top = (containerSize.current.height - footerHeight - imgHeight) / 2;
|
|
width = state.width + imgWidth;
|
|
height = state.height + imgHeight;
|
|
scaleX = scaleY = 1;
|
|
} else {
|
|
let directX = state.scaleX > 0 ? 1 : -1;
|
|
let directY = state.scaleY > 0 ? 1 : -1;
|
|
scaleX = state.scaleX + scale * direct * directX;
|
|
scaleY = state.scaleY + scale * direct * directY;
|
|
if (typeof props.maxScale !== 'undefined') {
|
|
if (Math.abs(scaleX) > props.maxScale) {
|
|
scaleX = props.maxScale * directX;
|
|
}
|
|
if (Math.abs(scaleY) > props.maxScale) {
|
|
scaleY = props.maxScale * directY;
|
|
}
|
|
}
|
|
if (Math.abs(scaleX) < minScale) {
|
|
scaleX = minScale * directX;
|
|
}
|
|
if (Math.abs(scaleY) < minScale) {
|
|
scaleY = minScale * directY;
|
|
}
|
|
top = state.top + -direct * diffY / state.scaleX * scale * directX;
|
|
left = state.left + -direct * diffX / state.scaleY * scale * directY;
|
|
width = state.width;
|
|
height = state.height;
|
|
}
|
|
dispatch(createAction(ACTION_TYPES.update, {
|
|
width: width,
|
|
scaleX: scaleX,
|
|
scaleY: scaleY,
|
|
height: height,
|
|
top: top,
|
|
left: left,
|
|
loading: false,
|
|
}));
|
|
}
|
|
|
|
const prefixCls = 'react-viewer';
|
|
|
|
const className = classnames(`${prefixCls}`, `${prefixCls}-transition`, {
|
|
[`${prefixCls}-inline`]: props.container,
|
|
[props.className]: props.className,
|
|
});
|
|
|
|
let viewerStryle: React.CSSProperties = {
|
|
opacity: (visible && state.visible) ? 1 : 0,
|
|
display: (visible || state.visible) ? 'block' : 'none',
|
|
};
|
|
|
|
let activeImg: ImageDecorator = {
|
|
src: '',
|
|
alt: '',
|
|
};
|
|
|
|
if (visible && state.visible && !state.loading && state.activeIndex !== null && !state.startLoading) {
|
|
activeImg = getActiveImage();
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={className}
|
|
style={viewerStryle}
|
|
onTransitionEnd={() => {
|
|
if (!visible) {
|
|
dispatch(createAction(ACTION_TYPES.setVisible, {
|
|
visible: false,
|
|
}));
|
|
}
|
|
}}
|
|
ref={viewerCore}
|
|
>
|
|
<div className={`${prefixCls}-mask`} style={{ zIndex: zIndex }} />
|
|
{props.noClose || (
|
|
<div
|
|
className={`${prefixCls}-close ${prefixCls}-btn`}
|
|
onClick={() => {
|
|
onClose();
|
|
}}
|
|
style={{ zIndex: zIndex + 10 }}
|
|
>
|
|
<Icon type={ActionType.close} />
|
|
</div>
|
|
)}
|
|
<ViewerCanvas
|
|
prefixCls={prefixCls}
|
|
imgSrc={state.loadFailed ? (props.defaultImg.src || activeImg.src) : activeImg.src}
|
|
visible={visible}
|
|
width={state.width}
|
|
height={state.height}
|
|
top={state.top}
|
|
left={state.left}
|
|
rotate={state.rotate}
|
|
onChangeImgState={handleChangeImgState}
|
|
onResize={handleResize}
|
|
zIndex={zIndex + 5}
|
|
scaleX={state.scaleX}
|
|
scaleY={state.scaleY}
|
|
loading={state.loading}
|
|
drag={drag}
|
|
container={props.container}
|
|
onCanvasMouseDown={handleCanvasMouseDown}
|
|
/>
|
|
{props.noFooter || (
|
|
<div className={`${prefixCls}-footer`} style={{ zIndex: zIndex + 5 }}>
|
|
{noToolbar || (
|
|
<ViewerToolbar
|
|
prefixCls={prefixCls}
|
|
onAction={handleAction}
|
|
alt={activeImg.alt}
|
|
width={state.imageWidth}
|
|
height={state.imageHeight}
|
|
attribute={attribute}
|
|
zoomable={zoomable}
|
|
rotatable={rotatable}
|
|
scalable={scalable}
|
|
changeable={changeable}
|
|
downloadable={downloadable}
|
|
noImgDetails={noImgDetails}
|
|
toolbars={customToolbar(defaultToolbars)}
|
|
activeIndex={state.activeIndex}
|
|
count={images.length}
|
|
showTotal={showTotal}
|
|
/>
|
|
)}
|
|
{props.noNavbar || (
|
|
<ViewerNav
|
|
prefixCls={prefixCls}
|
|
images={props.images}
|
|
activeIndex={state.activeIndex}
|
|
onChangeImg={handleChangeImg}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|