PhotoView.tsx 13 KB

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