PhotoView.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. import React from 'react';
  2. import classNames from 'classnames';
  3. import Photo from './Photo';
  4. import throttle from './utils/throttle';
  5. import isMobile from './utils/isMobile';
  6. import getMultipleTouchPosition from './utils/getMultipleTouchPosition';
  7. import getPositionOnMoveOrScale from './utils/getPositionOnMoveOrScale';
  8. import slideToPosition from './utils/slideToPosition';
  9. import { getReachType, getCloseEdgeResult } from './utils/getCloseEdge';
  10. import withContinuousTap, { TapFuncType } from './utils/withContinuousTap';
  11. import getAnimateOrigin from './utils/getAnimateOrigin';
  12. import {
  13. maxScale,
  14. minStartTouchOffset,
  15. minScale,
  16. scaleBuffer,
  17. } from './variables';
  18. import {
  19. ReachMoveFunction,
  20. ReachFunction,
  21. PhotoTapFunction,
  22. ReachTypeEnum,
  23. TouchStartEnum,
  24. ShowAnimateEnum,
  25. OriginRectType,
  26. } from './types';
  27. import './PhotoView.less';
  28. export interface IPhotoViewProps {
  29. // 图片地址
  30. src: string;
  31. // 容器类名
  32. viewClassName?: string;
  33. // 图片类名
  34. className?: string;
  35. // style
  36. style?: object;
  37. // 自定义 loading
  38. loadingElement?: JSX.Element;
  39. // 加载失败 Element
  40. brokenElement?: JSX.Element;
  41. // Photo 点击事件
  42. onPhotoTap: PhotoTapFunction;
  43. // Mask 点击事件
  44. onMaskTap: PhotoTapFunction;
  45. // 到达边缘滑动事件
  46. onReachMove: ReachMoveFunction;
  47. // 触摸解除事件
  48. onReachUp: ReachFunction;
  49. // Resize 事件
  50. onPhotoResize?: () => void;
  51. // 动画类型
  52. showAnimateType?: ShowAnimateEnum;
  53. // 动画源位置
  54. originRect?: OriginRectType;
  55. // 进入或结束动画回调
  56. onShowAnimateEnd?: () => void;
  57. }
  58. const initialState = {
  59. // 真实宽度
  60. naturalWidth: 1,
  61. // 真实高度
  62. naturalHeight: 1,
  63. // 宽度
  64. width: 1,
  65. // 高度
  66. height: 1,
  67. // 加载成功状态
  68. loaded: false,
  69. // 图片 X 偏移量
  70. x: 0,
  71. // 图片 y 偏移量
  72. y: 0,
  73. // 图片缩放程度
  74. scale: 1,
  75. // 图片处于触摸的状态
  76. touched: false,
  77. // 背景处于触摸状态
  78. maskTouched: false,
  79. // 触摸开始时 x 原始坐标
  80. clientX: 0,
  81. // 触摸开始时 y 原始坐标
  82. clientY: 0,
  83. // 触摸开始时图片 x 偏移量
  84. lastX: 0,
  85. // 触摸开始时图片 y 偏移量
  86. lastY: 0,
  87. // 触摸开始时时间
  88. touchedTime: 0,
  89. // 多指触控间距
  90. lastTouchLength: 0,
  91. // 当前边缘触发状态
  92. reachState: ReachTypeEnum.Normal,
  93. };
  94. export default class PhotoView extends React.Component<
  95. IPhotoViewProps,
  96. typeof initialState
  97. > {
  98. static displayName = 'PhotoView';
  99. readonly state = initialState;
  100. // 初始响应状态
  101. private initialTouchState = TouchStartEnum.Normal;
  102. private readonly handlePhotoTap: TapFuncType<number>;
  103. constructor(props: IPhotoViewProps) {
  104. super(props);
  105. this.onMove = throttle(this.onMove, 8);
  106. // 单击与双击事件处理
  107. this.handlePhotoTap = withContinuousTap(
  108. this.onPhotoTap,
  109. this.onDoubleTap,
  110. );
  111. }
  112. componentDidMount() {
  113. if (isMobile) {
  114. window.addEventListener('touchmove', this.handleTouchMove, { passive: false });
  115. window.addEventListener('touchend', this.handleTouchEnd, { passive: false });
  116. } else {
  117. window.addEventListener('mousemove', this.handleMouseMove);
  118. window.addEventListener('mouseup', this.handleMouseUp);
  119. }
  120. }
  121. componentWillUnmount() {
  122. if (isMobile) {
  123. window.removeEventListener('touchmove', this.handleTouchMove);
  124. window.removeEventListener('touchend', this.handleTouchEnd);
  125. } else {
  126. window.removeEventListener('mousemove', this.handleMouseMove);
  127. window.removeEventListener('mouseup', this.handleMouseUp);
  128. }
  129. }
  130. handleImageLoad = imageParams => {
  131. this.setState(imageParams);
  132. };
  133. handleStart = (clientX: number, clientY: number, touchLength: number = 0) => {
  134. this.setState(prevState => ({
  135. touched: true,
  136. clientX,
  137. clientY,
  138. lastX: prevState.x,
  139. lastY: prevState.y,
  140. lastTouchLength: touchLength,
  141. touchedTime: Date.now(),
  142. }));
  143. };
  144. onMove = (newClientX: number, newClientY: number, touchLength: number = 0) => {
  145. const {
  146. width,
  147. height,
  148. naturalWidth,
  149. x,
  150. y,
  151. clientX,
  152. clientY,
  153. lastX,
  154. lastY,
  155. scale,
  156. lastTouchLength,
  157. reachState,
  158. touched,
  159. maskTouched,
  160. } = this.state;
  161. if (touched || maskTouched) {
  162. // 单指最小缩放下,以初始移动距离来判断意图
  163. if (touchLength === 0 && scale === minScale && this.initialTouchState === TouchStartEnum.Normal) {
  164. const isBeyondX = Math.abs(newClientX - clientX) > minStartTouchOffset;
  165. const isBeyondY = Math.abs(newClientY - clientY) > minStartTouchOffset;
  166. // 初始移动距离不足则不处理
  167. if (!(isBeyondX || isBeyondY)) {
  168. return;
  169. }
  170. // 设置响应状态
  171. this.initialTouchState = isBeyondX
  172. ? TouchStartEnum.X
  173. : newClientY > clientY
  174. ? TouchStartEnum.YPull
  175. : TouchStartEnum.YPush;
  176. }
  177. let currentX = x;
  178. let currentY = y;
  179. // 边缘触发状态
  180. let currentReachState = ReachTypeEnum.Normal;
  181. if (touchLength === 0) {
  182. const {
  183. onReachMove,
  184. } = this.props;
  185. currentX = newClientX - clientX + lastX;
  186. const planY = newClientY - clientY + lastY;
  187. const touchYOffset = this.initialTouchState === TouchStartEnum.YPush
  188. ? minStartTouchOffset
  189. : -minStartTouchOffset;
  190. // 边缘超出状态
  191. const { horizontalCloseEdge, verticalCloseEdge } = getCloseEdgeResult({
  192. initialTouchState: this.initialTouchState,
  193. planX: currentX,
  194. planY,
  195. scale,
  196. width,
  197. height,
  198. });
  199. // Y 方向在初始响应状态下需要补一个距离
  200. currentY = planY +
  201. (this.initialTouchState === TouchStartEnum.Normal ? 0 : touchYOffset);
  202. // 边缘触发检测
  203. currentReachState = getReachType({ horizontalCloseEdge, verticalCloseEdge, reachState });
  204. // 接触边缘
  205. if (currentReachState != ReachTypeEnum.Normal) {
  206. onReachMove(currentReachState, newClientX, newClientY, scale);
  207. }
  208. }
  209. // 横向边缘触发、背景触发禁用当前滑动
  210. if (currentReachState === ReachTypeEnum.XReach || maskTouched) {
  211. this.setState({
  212. reachState: ReachTypeEnum.XReach,
  213. });
  214. } else {
  215. // 目标倍数
  216. const endScale = scale + (touchLength - lastTouchLength) / 100 / 2 * scale;
  217. // 限制最大倍数和最小倍数
  218. const toScale = Math.max(
  219. Math.min(
  220. endScale,
  221. Math.max(maxScale, naturalWidth / width)
  222. ),
  223. minScale - scaleBuffer,
  224. );
  225. this.setState({
  226. lastTouchLength: touchLength,
  227. reachState: currentReachState,
  228. ...getPositionOnMoveOrScale({
  229. x: currentX,
  230. y: currentY,
  231. clientX: newClientX,
  232. clientY: newClientY,
  233. fromScale: scale,
  234. toScale,
  235. }),
  236. });
  237. }
  238. }
  239. };
  240. onPhotoTap = (clientX: number, clientY: number) => {
  241. const { onPhotoTap } = this.props;
  242. if (onPhotoTap) {
  243. onPhotoTap(clientX, clientY);
  244. }
  245. };
  246. onDoubleTap: TapFuncType<number> = (clientX, clientY) => {
  247. const { width, naturalWidth } = this.state;
  248. const { x, y, scale } = this.state;
  249. this.setState({
  250. clientX,
  251. clientY,
  252. ...getPositionOnMoveOrScale({
  253. x,
  254. y,
  255. clientX,
  256. clientY,
  257. fromScale: scale,
  258. // 若图片足够大,则放大适应的倍数
  259. toScale: scale !== 1 ? 1 : Math.max(2, naturalWidth / width),
  260. }),
  261. });
  262. };
  263. handleWheel = (e) => {
  264. const { clientX, clientY, deltaY } = e;
  265. const { width, naturalWidth } = this.state;
  266. this.setState(({ x, y, scale }) => {
  267. const endScale = scale - deltaY / 100 / 2;
  268. // 限制最大倍数和最小倍数
  269. const toScale = Math.max(
  270. Math.min(
  271. endScale,
  272. Math.max(maxScale, naturalWidth / width)
  273. ),
  274. minScale,
  275. );
  276. return {
  277. clientX,
  278. clientY,
  279. ...getPositionOnMoveOrScale({
  280. x,
  281. y,
  282. clientX,
  283. clientY,
  284. fromScale: scale,
  285. toScale,
  286. }),
  287. };
  288. });
  289. };
  290. handleMaskStart = (clientX: number, clientY: number) => {
  291. this.setState(prevState => ({
  292. maskTouched: true,
  293. clientX,
  294. clientY,
  295. lastX: prevState.x,
  296. lastY: prevState.y,
  297. }));
  298. };
  299. handleMaskMouseDown = (e) => {
  300. this.handleMaskStart(e.clientX, e.clientY);
  301. };
  302. handleMaskTouchStart = (e) => {
  303. const { clientX, clientY } = e.touches[0];
  304. this.handleMaskStart(clientX, clientY);
  305. };
  306. handleTouchStart = (e) => {
  307. const { clientX, clientY, touchLength } = getMultipleTouchPosition(e);
  308. this.handleStart(clientX, clientY, touchLength);
  309. };
  310. handleMouseDown = (e) => {
  311. e.preventDefault();
  312. this.handleStart(e.clientX, e.clientY, 0);
  313. };
  314. handleTouchMove = (e) => {
  315. e.preventDefault();
  316. const { clientX, clientY, touchLength } = getMultipleTouchPosition(e);
  317. this.onMove(clientX, clientY, touchLength);
  318. };
  319. handleMouseMove = (e) => {
  320. e.preventDefault();
  321. this.onMove(e.clientX, e.clientY);
  322. };
  323. handleUp = (newClientX: number, newClientY: number) => {
  324. // 重置响应状态
  325. this.initialTouchState = TouchStartEnum.Normal;
  326. const {
  327. width,
  328. height,
  329. naturalWidth,
  330. x,
  331. y,
  332. lastX,
  333. lastY,
  334. scale,
  335. touchedTime,
  336. clientX,
  337. clientY,
  338. touched,
  339. maskTouched,
  340. } = this.state;
  341. if (touched || maskTouched) {
  342. const { onReachUp, onPhotoTap, onMaskTap } = this.props;
  343. const hasMove = clientX !== newClientX || clientY !== newClientY;
  344. this.setState({
  345. touched: false,
  346. maskTouched: false,
  347. // 限制缩放
  348. scale: Math.max(
  349. Math.min(scale, Math.max(maxScale, naturalWidth / width)),
  350. minScale,
  351. ),
  352. reachState: ReachTypeEnum.Normal, // 重置触发状态
  353. ...hasMove
  354. ? slideToPosition({
  355. x,
  356. y,
  357. lastX,
  358. lastY,
  359. width,
  360. height,
  361. scale,
  362. touchedTime,
  363. }) : {
  364. x,
  365. y,
  366. },
  367. }, () => {
  368. if (onReachUp) {
  369. onReachUp(newClientX, newClientY);
  370. }
  371. // 触发 Tap 事件
  372. if (!hasMove) {
  373. if (touched && onPhotoTap) {
  374. this.handlePhotoTap(newClientX, newClientY);
  375. } else if (maskTouched && onMaskTap) {
  376. onMaskTap(newClientX, newClientY);
  377. }
  378. }
  379. });
  380. }
  381. };
  382. handleTouchEnd = (e) => {
  383. const { clientX, clientY } = e.changedTouches[0];
  384. this.handleUp(clientX, clientY);
  385. };
  386. handleMouseUp = (e) => {
  387. const { clientX, clientY } = e;
  388. this.handleUp(clientX, clientY);
  389. };
  390. render() {
  391. const {
  392. src,
  393. viewClassName,
  394. className,
  395. style,
  396. loadingElement,
  397. brokenElement,
  398. onPhotoResize,
  399. showAnimateType,
  400. originRect,
  401. onShowAnimateEnd,
  402. } = this.props;
  403. const {
  404. width,
  405. height,
  406. naturalWidth,
  407. naturalHeight,
  408. loaded,
  409. x,
  410. y,
  411. scale,
  412. touched,
  413. } = this.state;
  414. const transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
  415. return (
  416. <div className={classNames('PhotoView__PhotoWrap', viewClassName)} style={style}>
  417. <div
  418. className="PhotoView__PhotoMask"
  419. onMouseDown={isMobile ? undefined : this.handleMaskMouseDown}
  420. onTouchStart={isMobile ? this.handleMaskTouchStart : undefined}
  421. />
  422. <div
  423. className={classNames({
  424. PhotoView__animateIn: loaded && showAnimateType === ShowAnimateEnum.In,
  425. PhotoView__animateOut: loaded && showAnimateType === ShowAnimateEnum.Out,
  426. })}
  427. style={{
  428. transformOrigin: loaded ? getAnimateOrigin(originRect, width, height) : undefined,
  429. }}
  430. onAnimationEnd={onShowAnimateEnd}
  431. >
  432. <Photo
  433. className={className}
  434. src={src}
  435. width={width}
  436. height={height}
  437. naturalWidth={naturalWidth}
  438. naturalHeight={naturalHeight}
  439. loaded={loaded}
  440. onMouseDown={isMobile ? undefined : this.handleMouseDown}
  441. onTouchStart={isMobile ? this.handleTouchStart : undefined}
  442. onWheel={this.handleWheel}
  443. onPhotoResize={onPhotoResize}
  444. style={{
  445. WebkitTransform: transform,
  446. transform,
  447. transition: touched
  448. ? undefined
  449. : 'transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1)',
  450. }}
  451. onImageLoad={this.handleImageLoad}
  452. loadingElement={loadingElement}
  453. brokenElement={brokenElement}
  454. />
  455. </div>
  456. </div>
  457. );
  458. }
  459. }