PhotoView.tsx 13 KB

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