fastlion-picture-viewer
Version:
图片查看器
561 lines (512 loc) • 16.2 kB
JSX
import React from 'react';
import PropTypes from 'prop-types';
// utils
import { isNaN, isFunction, isEqual } from '../libs/utils';
// style
import './index.css';
class ReactPictureViewer extends React.Component {
static propTypes = {
width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), // viewport 视口的宽度
height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), // viewport 视口的高度
minimum: PropTypes.number, // 缩放的最小尺寸【零点几】
maximum: PropTypes.number, // 缩放的最大尺寸
rate: PropTypes.number, // 缩放的速率
children: PropTypes.object.isRequired, // slot 插槽
className: PropTypes.string, // className
center: PropTypes.bool, // 图片位置是否初始居中
contain: PropTypes.bool, // 图片尺寸是否初始包含在视口范围内
cover: PropTypes.bool,
};
static defaultProps = {
width: '600px',
height: '400px',
minimum: 0.8,
maximum: 8,
rate: 10,
center: true,
contain: true,
};
state = {
focus: false, // 鼠标是否按下,处于可拖动状态
imageWidth: 0, // 图片宽度
imageHeight: 0, // 图片高度
startX: 0, // 鼠标按下时,距离 viewport 的初始 X 位置
startY: 0, // 鼠标按下时,距离 viewport 的初始 Y 位置
startLeft: 0, // 图片距离 viewport 的初始 Left
startTop: 0, // 图片距离 viewport 的初始 Top
currentLeft: 0, // 图片当前距离 viewport 的 left
currentTop: 0, // 图片当前距离 viewport 的 top
scale: 1, // 图片缩放比率 minimum - maximum
hasLoaded: false, // 图片是否已经加载
};
constructor() {
super();
this.viewportDOM = React.createRef();
this.imgDOM = React.createRef();
}
componentDidMount() {
const { width, height } = this.props;
this.imgDOM.current = this.viewportDOM.current.querySelector('img');
this.initViewport(width, height);
// 这边需要将滚轮事件使用原生绑定来处理
// 从而解决新版本 chrome 浏览器带来的 passive event listener
// 在对图片进行滚动缩放时无法使用 e.preventDefault 来禁用浏览器滚动问题
this.imgDOM.current.addEventListener('wheel', this.handleMouseWheel, {
passive: false,
});
this.initPicture();
}
componentWillReceiveProps(nextProps) {
// 如果检测到 props 确实有变化,再去重新 init
const flag =
!isEqual(this.props, nextProps, 'children') ||
!isEqual(this.props.children.props, nextProps.children.props);
flag && this.initPicture(nextProps);
}
shouldComponentUpdate(nextProps, nextState, nextContext) {
return (
!isEqual(this.state, nextState) ||
!isEqual(this.props, nextProps, 'children') ||
!isEqual(this.props.children.props, nextProps.children.props)
);
}
componentWillUpdate(nextProps, nextState) {
const {
scale,
imageWidth: originWidth,
imageHeight: originHeight,
currentLeft,
currentTop,
} = nextState;
const currentImageWidth = scale * originWidth;
const currentImageHeight = scale * originHeight;
// 改变图片位置
this.changePosition(currentLeft, currentTop);
// 改变图片尺寸
this.changeSize(currentImageWidth, currentImageHeight);
}
initViewport = (width, height) => {
// 如果是字符串,就将字符串作为尺寸设置;否则是数字的话,就在后面加 px 设置
this.viewportDOM.current.style.width = isNaN(+width) ? width : `${width}px`;
this.viewportDOM.current.style.height = isNaN(+height)
? height
: `${height}px`;
};
/**
* 图片初始化,包括:
* 1. 初始图片位置居中
* 2. 记录初始图片尺寸
* @param nextProps
*/
initPicture = nextProps => {
nextProps = nextProps || this.props;
const {
children: {
props: { src },
},
center,
contain,
cover,
} = nextProps;
const callback = center ? this.changeToCenter : this.changeToBasePoint;
// 这块有个执行顺序
// 必须是先确定尺寸,再确定位置
if (cover) {
this.changeToCover(src, callback);
} else if (contain) {
this.changeToContain(src, callback);
} else {
this._getImageOriginSize(src)
.then(({ width: imageWidth, height: imageHeight }) => {
this.setState(
{
scale: 1,
imageWidth,
imageHeight,
},
callback,
);
})
.catch(e => {
console.error(e);
});
}
};
/**
* 设置图片尺寸为 contain
* @param src {String} 需要操作的图片的 src
* @param callback {Function} changeToContain 完成后的回调函数,接受更新后的图片尺寸,即 imageWidth 和 imageHeight 两个参数
*/
changeToContain = (src, callback) => {
src = src || this.props.src;
callback = isFunction(callback) ? callback : () => {};
this._getImageOriginSize(src)
.then(({ width: imageOriginWidth, height: imageOriginHeight }) => {
const { imageWidth, imageHeight } = this.recalcImageSizeToContain(
imageOriginWidth,
imageOriginHeight,
);
this.setState(
{
scale: 1,
imageWidth,
imageHeight,
},
() => {
callback(imageWidth, imageHeight);
},
);
})
.catch(e => {
console.error(e);
});
};
/**
* 设置图片位置为 center
*/
changeToCenter = () => {
const { imageWidth, imageHeight } = this.state;
const [viewportDOM] = [this.viewportDOM.current];
const [viewPortWidth, viewPortHeight] = [
viewportDOM.clientWidth,
viewportDOM.clientHeight,
];
// 设置图片默认位置居中
const [top, left] = [
(viewPortHeight - imageHeight) / 2,
(viewPortWidth - imageWidth) / 2,
];
this.setState({
currentLeft: left,
currentTop: top,
startLeft: left,
startTop: top,
});
};
/**
* 设置图片位置为基准点位置
* 基准点位置,基于视口: top: 0 && left: 0
*/
changeToBasePoint = () => {
this.setState({
currentLeft: 0,
currentTop: 0,
startLeft: 0,
startTop: 0,
});
};
/**
* 重新计算图片尺寸,使宽高都不会超过视口尺寸
* @param imageWidth
* @param imageHeight
* @returns {*}
*/
recalcImageSizeToContain = (imageWidth, imageHeight) => {
const rate = imageWidth / imageHeight;
const viewportDOM = this.viewportDOM.current;
const [viewPortWidth, viewPortHeight] = [
viewportDOM.clientWidth,
viewportDOM.clientHeight,
];
if (imageWidth > viewPortWidth) {
imageWidth = viewPortWidth;
imageHeight = imageWidth / rate;
return this.recalcImageSizeToContain(imageWidth, imageHeight);
} else if (imageHeight > viewPortHeight) {
imageHeight = viewPortHeight;
imageWidth = imageHeight * rate;
return this.recalcImageSizeToContain(imageWidth, imageHeight);
} else {
return { imageWidth, imageHeight };
}
};
recalcImageSizeToCover = (imageWidth, imageHeight) => {
const rate = imageWidth / imageHeight;
const viewportDOM = this.viewportDOM.current;
const [viewPortWidth, viewPortHeight] = [
viewportDOM.clientWidth,
viewportDOM.clientHeight,
];
// 先将宽度设置到容器宽度
imageWidth = viewPortWidth;
imageHeight = imageWidth / rate;
if (imageHeight < viewPortHeight) {
imageHeight = viewPortHeight;
imageWidth = imageHeight * rate;
}
return { imageWidth, imageHeight };
};
/**
* 设置图片尺寸为 cover
*/
changeToCover = (src, callback) => {
src = src || this.props.src;
callback = isFunction(callback) ? callback : () => {};
this._getImageOriginSize(src)
.then(({ width: imageOriginWidth, height: imageOriginHeight }) => {
const { imageWidth, imageHeight } = this.recalcImageSizeToCover(
imageOriginWidth,
imageOriginHeight,
);
this.setState(
{
scale: 1,
imageWidth,
imageHeight,
},
() => {
callback(imageWidth, imageHeight);
},
);
})
.catch(e => {
console.error(e);
});
}; // eslint-disable-line
/**
* 改变图片位置
* @param currentLeft {Number} 当前 left
* @param currentTop {Number} 当前 top
*/
changePosition(currentLeft, currentTop) {
const imgDOM = this.imgDOM.current;
imgDOM.style.top = `${currentTop}px`;
imgDOM.style.left = `${currentLeft}px`;
}
/**
* 调整尺寸
* @param width
* @param height
*/
changeSize(width, height) {
const imgDOM = this.imgDOM.current;
imgDOM.style.maxWidth = imgDOM.style.maxHeight = 'none';
imgDOM.style.width = `${width}px`;
imgDOM.style.height = `${height}px`;
}
/**
* 处理鼠标按下
* @param e
*/
handleMouseDown = e => {
const currentDOM = e.target || e.toElement;
if (currentDOM !== this.imgDOM.current) return;
let { top: startY, left: startX } = this._getOffsetInElement(
e,
this.viewportDOM.current,
);
this.setState({
focus: true,
startX,
startY,
});
};
/**
* 处理鼠标移动
* @param e
*/
handleMouseMove = e => {
const { focus, startX, startY, startTop, startLeft } = this.state;
if (!focus) return;
let { left: currentX, top: currentY } = this._getOffsetInElement(
e,
this.viewportDOM.current,
);
let [diffX, diffY] = [currentX - startX, currentY - startY];
this.setState({
currentLeft: startLeft + diffX,
currentTop: startTop + diffY,
});
};
/**
* 处理鼠标放开
*/
handleMouseUp = () => {
const { currentLeft, currentTop } = this.state;
this.setState({
focus: false,
startX: 0,
startY: 0,
startLeft: currentLeft,
startTop: currentTop,
});
};
/**
* 处理鼠标移出
*/
handleMouseLeave = () => {
this.handleMouseUp();
};
changeScale = (scale, igonreLimit = false) => {
let nextScale = scale
const { scale:nowScale } = this.state
const { minimum, maximum } = this.props;
if (!igonreLimit) {
// 如果放大倍率已超过最大倍率且继续放大,则保持原大小
// 如果缩小倍率已超过最小倍率且继续缩小,则保持原大小
if ((nowScale >= maximum && nextScale >= nowScale) || (nowScale <= minimum && nextScale <= nowScale)) {
nextScale = nowScale
} else {
nextScale =
scale <= minimum
? minimum
: scale >= maximum
? maximum
: scale;
}
}
this.setState({
scale: nextScale
})
}
/**
* 处理滚轮缩放
* @param e {Event Object} 事件对象
*/
handleMouseWheel = e => {
const imgDOM = this.imgDOM.current;
const { minimum, maximum, rate } = this.props;
const {
imageWidth: originWidth,
imageHeight: originHeight,
currentLeft,
currentTop,
scale: lastScale,
} = this.state;
const [imageWidth, imageHeight] = [imgDOM.clientWidth, imgDOM.clientHeight];
const event = e.nativeEvent || e;
event.preventDefault();
// 这块的 scale 每次都需要用 1 去加,作为图片的实时缩放比率
let scale = 1 + event.wheelDelta / (12000 / rate);
// 最小缩放至 minimum 就不能再缩小了
// 最大放大至 maximum 倍就不能再放大了
if (
(lastScale <= minimum && scale < 1) ||
(lastScale >= maximum && scale > 1)
)
return;
// 真实的图片缩放比率需要用尺寸相除
let nextScale = (imageWidth * scale) / originWidth;
// 进行缩放比率检测
// 如果小于最小值,使用原始图片尺寸和最小缩放值
// 如果大于最大值,使用最大图片尺寸和最大缩放值
nextScale =
nextScale <= minimum
? minimum
: nextScale >= maximum
? maximum
: nextScale;
let currentImageWidth = nextScale * originWidth;
let currentImageHeight = nextScale * originHeight;
let { left, top } = this._getOffsetInElement(e, this.imgDOM.current);
let rateX = left / imageWidth;
let rateY = top / imageHeight;
let newLeft = rateX * currentImageWidth;
let newTop = rateY * currentImageHeight;
this.setState({
scale: nextScale,
startLeft: currentLeft + (left - newLeft),
startTop: currentTop + (top - newTop),
currentLeft: currentLeft + (left - newLeft),
currentTop: currentTop + (top - newTop),
});
};
/**
* 获取鼠标当前相对于某个元素的位置
* @param e {object} 原生事件对象
* @param target {DOMobject} 目标DOM元素
* @return object 包括offsetLeft和offsetTop
*
* Tips:
* 1.offset 相关属性在 display: none 的元素上失效,为0
* 2.offsetWidth/offsetHeight 包括border-width,clientWidth/clientHeight不包括border-width,只是可见区域而已
* 3.offsetLeft/offsetTop 是从当前元素边框外缘开始算,一直到定位父元素的距离,clientLeft/clientTop其实就是border-width
*/
_getOffsetInElement = (e, target) => {
let currentDOM = e.target || e.toElement;
if (!this._inTargetArea(currentDOM, target)) return null;
let left, top, right, bottom;
const { left: x, top: y } = this._getOffset(target);
left = e.clientX - x;
top = e.clientY - y;
right = target.offsetWidth - left;
bottom = target.offsetHeight - top;
return { top, left, right, bottom };
};
/**
* 判断一个DOM元素是否包裹在另一个DOM元素中【父子关系或者层级嵌套都可以】
* @param {Object} DOM 事件对象中的event.target/或者是需要检测的DOM元素
* @param {Object} targetDOM 作为限制范围的DOM元素
* @return {Boolean} true----是包裹关系,false----不是包裹关系
*/
_inTargetArea = (DOM, targetDOM) => {
if (DOM === targetDOM) return true;
let parent = DOM.parentNode;
while (parent != null) {
if (parent === targetDOM) return true;
DOM = parent;
parent = DOM.parentNode;
}
return false;
};
/**
* 获取某个 DOM 元素相对视口的位置信息
* @param el {object} 目标元素
* @return object {object} 位置信息对象
*/
_getOffset = el => {
const doc = document.documentElement;
const docClientWidth = doc.clientWidth;
const docClientHeight = doc.clientHeight;
let positionInfo = el.getBoundingClientRect();
return {
left: positionInfo.left,
top: positionInfo.top,
right: docClientWidth - positionInfo.right,
bottom: docClientHeight - positionInfo.bottom,
};
};
/**
* 获取图片原始尺寸信息
* @param image
* @returns {Promise<any>}
* @private
*/
_getImageOriginSize = image => {
const src = typeof image === 'object' ? image.src : image;
const self = this;
return new Promise(resolve => {
const image = new Image();
image.src = src;
image.onload = function() {
// 图片首次加载完成修改状态
if (!self.state.hasLoaded) {
self.setState({ hasLoaded: true });
}
const { width, height } = image;
resolve({
width,
height,
});
};
});
};
render() {
const { children, className } = this.props;
return (
<div
ref={this.viewportDOM}
className={`react-picture-viewer ${
this.state.hasLoaded ? '' : 'react-picture-viewer-bofore-loaded'
} ${className}`}
onMouseLeave={this.handleMouseLeave}
onMouseDown={this.handleMouseDown}
onMouseMove={this.handleMouseMove}
onMouseUp={this.handleMouseUp}
>
{children}
</div>
);
}
}
export default ReactPictureViewer;