PhotoView.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. import React from 'react';
  2. import throttle from 'lodash.throttle';
  3. import Photo from './Photo';
  4. import PhotoWrap from './components/PhotoWrap';
  5. import PhotoMask from './components/PhotoMask';
  6. import isMobile from './utils/isMobile';
  7. import getMultipleTouchPosition from './utils/getMultipleTouchPosition';
  8. import getPositionOnMoveOrScale from './utils/getPositionOnMoveOrScale';
  9. import slideToSuitableOffset from './utils/slideToSuitableOffset';
  10. import { getClosedHorizontal, getClosedVertical } from './utils/getCloseEdge';
  11. import {
  12. minReachOffset,
  13. minScale,
  14. maxScale,
  15. scaleBuffer,
  16. } from './variables';
  17. type ReachFunction = (clientX: number, clientY: number) => void;
  18. interface IPhotoViewProps {
  19. // 图片地址
  20. src: string;
  21. // 容器类名
  22. wrapClassName?: string;
  23. // 图片类名
  24. className?: string;
  25. // style
  26. style?: object;
  27. // 缩放,用于下拉关闭变小的效果
  28. photoScale?: number;
  29. // 自定义 loading
  30. loadingElement?: JSX.Element;
  31. // 加载失败 Element
  32. brokenElement?: JSX.Element;
  33. // 到达顶部滑动事件
  34. onReachTopMove?: ReachFunction;
  35. // 到达右部滑动事件
  36. onReachRightMove?: ReachFunction;
  37. // 到达底部滑动事件
  38. onReachBottomMove?: ReachFunction;
  39. // 到达左部滑动事件
  40. onReachLeftMove?: ReachFunction;
  41. // 触摸解除事件
  42. onReachUp?: ReachFunction;
  43. onPhotoResize?: () => void;
  44. }
  45. const initialState = {
  46. // 图片 X 偏移量
  47. x: 0,
  48. // 图片 y 偏移量
  49. y: 0,
  50. // 图片缩放程度
  51. scale: 1,
  52. // 图片处于触摸的状态
  53. touched: false,
  54. // 背景处于触摸状态
  55. maskTouched: false,
  56. // 触摸开始时 x 原始坐标
  57. clientX: 0,
  58. // 触摸开始时 y 原始坐标
  59. clientY: 0,
  60. // 触摸开始时图片 x 偏移量
  61. lastX: 0,
  62. // 触摸开始时图片 y 偏移量
  63. lastY: 0,
  64. // 触摸开始时时间
  65. touchedTime: 0,
  66. // 多指触控间距
  67. lastTouchLength: 0,
  68. // 当前边缘触发状态,0: 未触发,1: x 轴,2: y 轴
  69. reachState: 0,
  70. };
  71. export default class PhotoView extends React.Component<
  72. IPhotoViewProps,
  73. typeof initialState
  74. > {
  75. static displayName = 'PhotoView';
  76. readonly state = initialState;
  77. private photoRef;
  78. constructor(props) {
  79. super(props);
  80. this.handleMove = throttle(this.handleMove, 8);
  81. }
  82. componentDidMount() {
  83. if (isMobile) {
  84. window.addEventListener('touchmove', this.handleTouchMove, { passive: false });
  85. window.addEventListener('touchend', this.handleTouchEnd);
  86. } else {
  87. window.addEventListener('mousemove', this.handleMouseMove);
  88. window.addEventListener('mouseup', this.handleMouseUp);
  89. }
  90. }
  91. componentWillUnmount() {
  92. if (isMobile) {
  93. window.removeEventListener('touchmove', this.handleTouchMove);
  94. window.removeEventListener('touchend', this.handleTouchEnd);
  95. } else {
  96. window.removeEventListener('mousemove', this.handleMouseMove);
  97. window.removeEventListener('mouseup', this.handleMouseUp);
  98. }
  99. }
  100. handleStart = (clientX: number, clientY: number, touchLength: number = 0) => {
  101. this.setState(prevState => ({
  102. touched: true,
  103. clientX,
  104. clientY,
  105. lastX: prevState.x,
  106. lastY: prevState.y,
  107. lastTouchLength: touchLength,
  108. touchedTime: Date.now(),
  109. }));
  110. }
  111. handleMove = (newClientX: number, newClientY: number, touchLength: number = 0) => {
  112. const { touched, maskTouched } = this.state;
  113. if (touched || maskTouched) {
  114. const { width, naturalWidth } = this.photoRef.state;
  115. const {
  116. x,
  117. y,
  118. clientX,
  119. clientY,
  120. lastX,
  121. lastY,
  122. scale,
  123. lastTouchLength,
  124. reachState,
  125. } = this.state;
  126. let currentX = x;
  127. let currentY = y;
  128. // 边缘状态
  129. let currentReachState = 0;
  130. if (touchLength === 0) {
  131. currentX = newClientX - clientX + lastX;
  132. currentY = newClientY - clientY + lastY;
  133. // 边缘触发检测
  134. currentReachState = this.handleReachCallback(
  135. currentX,
  136. currentY,
  137. scale,
  138. newClientX,
  139. newClientY,
  140. reachState,
  141. );
  142. }
  143. // 横向边缘触发、背景触发禁用当前滑动
  144. if (currentReachState === 1 || maskTouched) {
  145. this.setState({
  146. reachState: 1,
  147. });
  148. } else {
  149. // 目标倍数
  150. const endScale = scale + (touchLength - lastTouchLength) / 100 / 2 * scale;
  151. // 限制最大倍数和最小倍数
  152. const toScale = Math.max(
  153. Math.min(
  154. endScale,
  155. Math.max(maxScale, naturalWidth / width)
  156. ),
  157. minScale - scaleBuffer,
  158. );
  159. this.setState({
  160. lastTouchLength: touchLength,
  161. reachState: currentReachState,
  162. ...getPositionOnMoveOrScale({
  163. x: currentX,
  164. y: currentY,
  165. clientX: newClientX,
  166. clientY: newClientY,
  167. fromScale: scale,
  168. toScale,
  169. }),
  170. });
  171. }
  172. }
  173. }
  174. handleDoubleClick = (e) => {
  175. e.preventDefault();
  176. const { clientX, clientY } = e;
  177. const { width, naturalWidth } = this.photoRef.state;
  178. this.setState(({ x, y, scale }) => {
  179. return {
  180. clientX,
  181. clientY,
  182. ...getPositionOnMoveOrScale({
  183. x,
  184. y,
  185. clientX,
  186. clientY,
  187. fromScale: scale,
  188. // 若图片足够大,则放大适应的倍数
  189. toScale: scale !== 1 ? 1 : Math.max(2, naturalWidth / width),
  190. }),
  191. };
  192. });
  193. }
  194. handleWheel = (e) => {
  195. e.preventDefault();
  196. const { clientX, clientY, deltaY } = e;
  197. const { width, naturalWidth } = this.photoRef.state;
  198. this.setState(({ x, y, scale }) => {
  199. const endScale = scale - deltaY / 100 / 2;
  200. // 限制最大倍数和最小倍数
  201. const toScale = Math.max(
  202. Math.min(
  203. endScale,
  204. Math.max(maxScale, naturalWidth / width)
  205. ),
  206. minScale,
  207. );
  208. return {
  209. clientX,
  210. clientY,
  211. ...getPositionOnMoveOrScale({
  212. x,
  213. y,
  214. clientX,
  215. clientY,
  216. fromScale: scale,
  217. toScale,
  218. }),
  219. };
  220. });
  221. }
  222. handleMaskStart = (clientX: number, clientY: number) => {
  223. this.setState(prevState => ({
  224. maskTouched: true,
  225. clientX,
  226. clientY,
  227. lastX: prevState.x,
  228. lastY: prevState.y,
  229. touchedTime: Date.now(),
  230. }));
  231. }
  232. handleMaskMouseDown = (e) => {
  233. this.handleMaskStart(e.clientX, e.clientY);
  234. }
  235. handleMaskTouchStart = (e) => {
  236. const { clientX, clientY } = e.touches[0];
  237. this.handleMaskStart(clientX, clientY);
  238. }
  239. handleTouchStart = e => {
  240. if (e.touches.length >= 2) {
  241. const { clientX, clientY, touchLength } = getMultipleTouchPosition(e);
  242. this.handleStart(clientX, clientY, touchLength);
  243. } else {
  244. const { clientX, clientY } = e.touches[0];
  245. this.handleStart(clientX, clientY);
  246. }
  247. }
  248. handleMouseDown = e => {
  249. e.preventDefault();
  250. this.handleStart(e.clientX, e.clientY);
  251. }
  252. handleTouchMove = e => {
  253. e.preventDefault();
  254. if (e.touches.length >= 2) {
  255. const { clientX, clientY, touchLength } = getMultipleTouchPosition(e);
  256. this.handleMove(clientX, clientY, touchLength);
  257. } else {
  258. const { clientX, clientY } = e.touches[0];
  259. this.handleMove(clientX, clientY);
  260. }
  261. }
  262. handleMouseMove = e => {
  263. e.preventDefault();
  264. this.handleMove(e.clientX, e.clientY);
  265. }
  266. handleUp = (newClientX: number, newClientY: number) => {
  267. const { touched, maskTouched } = this.state;
  268. if (touched || maskTouched) {
  269. const { onReachUp } = this.props;
  270. const { width, naturalWidth, height } = this.photoRef.state;
  271. this.setState(({
  272. x,
  273. y,
  274. lastX,
  275. lastY,
  276. scale,
  277. touchedTime,
  278. clientX,
  279. clientY,
  280. }) => {
  281. if (onReachUp) {
  282. onReachUp(newClientX, newClientY);
  283. }
  284. const hasMove = clientX !== newClientX || clientY !== newClientY;
  285. // 缩放弹性效果
  286. const toScale = Math.max(
  287. Math.min(
  288. scale,
  289. Math.max(maxScale, naturalWidth / width),
  290. ),
  291. minScale,
  292. );
  293. return {
  294. touched: false,
  295. maskTouched: false,
  296. scale: toScale,
  297. reachState: 0, // 重置触发状态
  298. ...hasMove
  299. ? slideToSuitableOffset({
  300. x,
  301. y,
  302. lastX,
  303. lastY,
  304. width,
  305. height,
  306. scale,
  307. touchedTime,
  308. }) : {
  309. x,
  310. y,
  311. },
  312. };
  313. });
  314. }
  315. }
  316. handleTouchEnd = (e) => {
  317. const { clientX, clientY } = e.changedTouches[0];
  318. this.handleUp(clientX, clientY);
  319. }
  320. handleMouseUp = (e) => {
  321. const { clientX, clientY } = e;
  322. this.handleUp(clientX, clientY);
  323. }
  324. handleResize = () => {
  325. this.setState(initialState);
  326. const { onPhotoResize } = this.props;
  327. if (onPhotoResize) {
  328. onPhotoResize();
  329. }
  330. }
  331. handleReachCallback = (
  332. x: number,
  333. y: number,
  334. scale: number,
  335. newClientX: number,
  336. newClientY: number,
  337. reachState: number,
  338. ): number => {
  339. const { width, height } = this.photoRef.state;
  340. const horizontalType = getClosedHorizontal(x, scale, width);
  341. const verticalType = getClosedVertical(y, scale, height);
  342. const {
  343. onReachTopMove,
  344. onReachRightMove,
  345. onReachBottomMove,
  346. onReachLeftMove,
  347. } = this.props;
  348. // 触碰到边缘
  349. if (
  350. onReachLeftMove
  351. && (horizontalType
  352. && x > minReachOffset
  353. && reachState === 0
  354. || reachState === 1)
  355. ) {
  356. onReachLeftMove(newClientX, newClientY);
  357. return 1;
  358. } else if (
  359. onReachRightMove
  360. && (horizontalType
  361. && x < -minReachOffset
  362. && reachState === 0
  363. || reachState === 1)
  364. ) {
  365. onReachRightMove(newClientX, newClientY);
  366. return 1;
  367. } else if (
  368. onReachTopMove
  369. && (verticalType
  370. && y > minReachOffset
  371. && reachState === 0
  372. || reachState === 2)
  373. ) {
  374. onReachTopMove(newClientX, newClientY);
  375. return 2;
  376. } else if (
  377. onReachBottomMove
  378. && (verticalType
  379. && y < -minReachOffset
  380. && reachState === 0
  381. || reachState === 2)
  382. ) {
  383. onReachBottomMove(newClientX, newClientY);
  384. return 2;
  385. }
  386. return 0;
  387. }
  388. handlePhotoRef = (ref) => {
  389. this.photoRef = ref;
  390. }
  391. render() {
  392. const {
  393. src,
  394. wrapClassName,
  395. className,
  396. style,
  397. photoScale = 1,
  398. loadingElement,
  399. brokenElement,
  400. } = this.props;
  401. const { x, y, scale, touched } = this.state;
  402. const transform = `translate3d(${x}px, ${y}px, 0) scale(${scale * photoScale})`;
  403. return (
  404. <PhotoWrap className={wrapClassName} style={style}>
  405. <PhotoMask
  406. onMouseDown={isMobile ? undefined : this.handleMaskMouseDown}
  407. onTouchStart={isMobile ? this.handleMaskTouchStart : undefined}
  408. />
  409. <Photo
  410. className={className}
  411. src={src}
  412. ref={this.handlePhotoRef}
  413. onDoubleClick={this.handleDoubleClick}
  414. onMouseDown={isMobile ? undefined : this.handleMouseDown}
  415. onTouchStart={isMobile ? this.handleTouchStart : undefined}
  416. onWheel={this.handleWheel}
  417. onPhotoResize={this.handleResize}
  418. style={{
  419. WebkitTransform: transform,
  420. transform,
  421. transition: touched
  422. ? undefined
  423. : 'transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1)',
  424. }}
  425. loadingElement={loadingElement}
  426. brokenElement={brokenElement}
  427. />
  428. </PhotoWrap>
  429. );
  430. }
  431. }