previewInner.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. import React, { CSSProperties } from "react";
  2. import BaseComponent from "../_base/baseComponent";
  3. import { PreviewInnerProps, PreviewInnerStates } from "./interface";
  4. import PropTypes from "prop-types";
  5. import { cssClasses, numbers } from "@douyinfe/semi-foundation/image/constants";
  6. import cls from "classnames";
  7. import { isEqual, isFunction } from "lodash";
  8. import Portal from "../_portal";
  9. import { IconArrowLeft, IconArrowRight } from "@douyinfe/semi-icons";
  10. import Header from "./previewHeader";
  11. import Footer from "./previewFooter";
  12. import PreviewImage from "./previewImage";
  13. import PreviewInnerFoundation, { PreviewInnerAdapter, RatioType } from "@douyinfe/semi-foundation/image/previewInnerFoundation";
  14. import { PreviewContext, PreviewContextProps } from "./previewContext";
  15. import { getScrollbarWidth } from "../_utils";
  16. import ReactDOM from "react-dom";
  17. const prefixCls = cssClasses.PREFIX;
  18. export default class PreviewInner extends BaseComponent<PreviewInnerProps, PreviewInnerStates> {
  19. static contextType = PreviewContext;
  20. static propTypes = {
  21. style: PropTypes.object,
  22. className: PropTypes.string,
  23. visible: PropTypes.bool,
  24. src: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
  25. currentIndex: PropTypes.number,
  26. defaultCurrentIndex: PropTypes.number,
  27. defaultVisible: PropTypes.bool,
  28. maskClosable: PropTypes.bool,
  29. closable: PropTypes.bool,
  30. zoomStep: PropTypes.number,
  31. infinite: PropTypes.bool,
  32. showTooltip: PropTypes.bool,
  33. closeOnEsc: PropTypes.bool,
  34. prevTip: PropTypes.string,
  35. nextTip: PropTypes.string,
  36. zoomInTip: PropTypes.string,
  37. zoomOutTip: PropTypes.string,
  38. downloadTip: PropTypes.string,
  39. adaptiveTip: PropTypes.string,
  40. originTip: PropTypes.string,
  41. lazyLoad: PropTypes.bool,
  42. preLoad: PropTypes.bool,
  43. preLoadGap: PropTypes.number,
  44. disableDownload: PropTypes.bool,
  45. viewerVisibleDelay: PropTypes.number,
  46. zIndex: PropTypes.number,
  47. maxZoom: PropTypes.number,
  48. minZoom: PropTypes.number,
  49. renderHeader: PropTypes.func,
  50. renderPreviewMenu: PropTypes.func,
  51. getPopupContainer: PropTypes.func,
  52. onVisibleChange: PropTypes.func,
  53. onChange: PropTypes.func,
  54. onClose: PropTypes.func,
  55. onZoomIn: PropTypes.func,
  56. onZoomOut: PropTypes.func,
  57. onPrev: PropTypes.func,
  58. onNext: PropTypes.func,
  59. onDownload: PropTypes.func,
  60. onRatioChange: PropTypes.func,
  61. onRotateLeft: PropTypes.func,
  62. }
  63. static defaultProps = {
  64. showTooltip: false,
  65. zoomStep: 0.1,
  66. infinite: false,
  67. closable: true,
  68. closeOnEsc: true,
  69. lazyLoad: false,
  70. preLoad: true,
  71. preLoadGap: 2,
  72. zIndex: numbers.DEFAULT_Z_INDEX,
  73. maskClosable: true,
  74. viewerVisibleDelay: 10000,
  75. maxZoom: 5,
  76. minZoom: 0.1
  77. };
  78. private bodyOverflow: string;
  79. private scrollBarWidth: number;
  80. private originBodyWidth: string;
  81. get adapter(): PreviewInnerAdapter<PreviewInnerProps, PreviewInnerStates> {
  82. return {
  83. ...super.adapter,
  84. getIsInGroup: () => this.isInGroup(),
  85. disabledBodyScroll: () => {
  86. const { getPopupContainer } = this.props;
  87. this.bodyOverflow = document.body.style.overflow || '';
  88. if (!getPopupContainer && this.bodyOverflow !== 'hidden') {
  89. document.body.style.overflow = 'hidden';
  90. document.body.style.width = `calc(${this.originBodyWidth || '100%'} - ${this.scrollBarWidth}px)`;
  91. }
  92. },
  93. enabledBodyScroll: () => {
  94. const { getPopupContainer } = this.props;
  95. if (!getPopupContainer && this.bodyOverflow !== 'hidden') {
  96. document.body.style.overflow = this.bodyOverflow;
  97. document.body.style.width = this.originBodyWidth;
  98. }
  99. },
  100. notifyChange: (index: number, direction: string) => {
  101. const { onChange, onPrev, onNext } = this.props;
  102. isFunction(onChange) && onChange(index);
  103. if (direction === "prev") {
  104. onPrev && onPrev(index);
  105. } else {
  106. onNext && onNext(index);
  107. }
  108. },
  109. notifyZoom: (zoom: number, increase: boolean) => {
  110. const { onZoomIn, onZoomOut } = this.props;
  111. if (increase) {
  112. isFunction(onZoomIn) && onZoomIn(zoom);
  113. } else {
  114. isFunction(onZoomOut) && onZoomOut(zoom);
  115. }
  116. },
  117. notifyClose: () => {
  118. const { onClose } = this.props;
  119. isFunction(onClose) && onClose();
  120. },
  121. notifyVisibleChange: (visible: boolean) => {
  122. const { onVisibleChange } = this.props;
  123. isFunction(onVisibleChange) && onVisibleChange(visible);
  124. },
  125. notifyRatioChange: (type: RatioType) => {
  126. const { onRatioChange } = this.props;
  127. isFunction(onRatioChange) && onRatioChange(type);
  128. },
  129. notifyRotateChange: (angle: number) => {
  130. const { onRotateLeft } = this.props;
  131. isFunction(onRotateLeft) && onRotateLeft(angle);
  132. },
  133. notifyDownload: (src: string, index: number) => {
  134. const { onDownload } = this.props;
  135. isFunction(onDownload) && onDownload(src, index);
  136. },
  137. notifyDownloadError: (src: string) => {
  138. const { onDownloadError } = this.props;
  139. isFunction(onDownloadError) && onDownloadError(src);
  140. },
  141. registerKeyDownListener: () => {
  142. window && window.addEventListener("keydown", this.handleKeyDown);
  143. },
  144. unregisterKeyDownListener: () => {
  145. window && window.removeEventListener("keydown", this.handleKeyDown);
  146. },
  147. getSetDownloadFunc: () => {
  148. return this.context?.setDownloadName ?? this.props.setDownloadName;
  149. },
  150. isValidTarget: (e) => {
  151. const headerDom = this.headerRef && this.headerRef.current;
  152. const footerDom = this.footerRef && this.footerRef.current;
  153. const leftIconDom = this.leftIconRef && this.leftIconRef.current;
  154. const rightIconDom = this.rightIconRef && this.rightIconRef.current;
  155. const target = e.target as any;
  156. if (
  157. headerDom && headerDom.contains(target) ||
  158. footerDom && footerDom.contains(target) ||
  159. leftIconDom && leftIconDom.contains(target) ||
  160. rightIconDom && rightIconDom.contains(target)
  161. ) {
  162. // Move in the operation area, return false
  163. return false;
  164. }
  165. // Move in the preview area except the operation area, return true
  166. return true;
  167. },
  168. changeImageZoom: (...args) => {
  169. this.imageRef?.current && this.imageRef.current.foundation.changeZoom(...args);
  170. }
  171. };
  172. }
  173. context: PreviewContextProps;
  174. foundation: PreviewInnerFoundation;
  175. imageWrapRef: React.RefObject<HTMLDivElement>;
  176. headerRef: React.RefObject<HTMLElement>;
  177. imageRef: React.RefObject<PreviewImage>;
  178. footerRef: React.RefObject<HTMLElement>;
  179. leftIconRef: React.RefObject<HTMLDivElement>;
  180. rightIconRef: React.RefObject<HTMLDivElement>;
  181. constructor(props: PreviewInnerProps) {
  182. super(props);
  183. this.state = {
  184. imgSrc: [],
  185. imgLoadStatus: new Map(),
  186. zoom: 0.1,
  187. currentIndex: 0,
  188. ratio: "adaptation",
  189. rotation: 0,
  190. viewerVisible: true,
  191. visible: false,
  192. preloadAfterVisibleChange: true,
  193. direction: "",
  194. };
  195. this.foundation = new PreviewInnerFoundation(this.adapter);
  196. this.bodyOverflow = '';
  197. this.originBodyWidth = '100%';
  198. this.scrollBarWidth = 0;
  199. this.imageWrapRef = null;
  200. this.imageRef = React.createRef<PreviewImage>();
  201. this.headerRef = React.createRef<HTMLElement>();
  202. this.footerRef = React.createRef<HTMLElement>();
  203. this.leftIconRef = React.createRef<HTMLDivElement>();
  204. this.rightIconRef = React.createRef<HTMLDivElement>();
  205. }
  206. static getDerivedStateFromProps(props: PreviewInnerProps, state: PreviewInnerStates) {
  207. const willUpdateStates: Partial<PreviewInnerStates> = {};
  208. let src = [];
  209. if (props.visible) {
  210. // if src in props
  211. src = Array.isArray(props.src) ? props.src : [props.src];
  212. }
  213. if (!isEqual(src, state.imgSrc)) {
  214. willUpdateStates.imgSrc = src;
  215. }
  216. if (props.visible !== state.visible) {
  217. willUpdateStates.visible = props.visible;
  218. if (props.visible) {
  219. willUpdateStates.preloadAfterVisibleChange = true;
  220. willUpdateStates.viewerVisible = true;
  221. willUpdateStates.rotation = 0;
  222. willUpdateStates.ratio = 'adaptation';
  223. }
  224. }
  225. if ("currentIndex" in props && props.currentIndex !== state.currentIndex) {
  226. willUpdateStates.currentIndex = props.currentIndex;
  227. // ratio will set to adaptation when change picture,
  228. // attention: If the ratio is controlled, the ratio should not change as the index changes
  229. willUpdateStates.ratio = 'adaptation';
  230. }
  231. return willUpdateStates;
  232. }
  233. componentDidMount() {
  234. this.scrollBarWidth = getScrollbarWidth();
  235. this.originBodyWidth = document.body.style.width;
  236. if (this.props.visible) {
  237. this.foundation.beforeShow();
  238. }
  239. }
  240. componentDidUpdate(prevProps: PreviewInnerProps, prevState: PreviewInnerStates) {
  241. if (prevProps.src !== this.props.src) {
  242. this.foundation.updateTimer();
  243. }
  244. // hide => show
  245. if (!prevProps.visible && this.props.visible) {
  246. this.foundation.beforeShow();
  247. }
  248. // show => hide
  249. if (prevProps.visible && !this.props.visible) {
  250. this.foundation.afterHide();
  251. }
  252. }
  253. componentWillUnmount() {
  254. this.foundation.clearTimer();
  255. }
  256. isInGroup() {
  257. return Boolean(this.context && this.context.isGroup);
  258. }
  259. viewVisibleChange = () => {
  260. this.foundation.handleViewVisibleChange();
  261. }
  262. handleSwitchImage = (direction: string) => {
  263. this.foundation.handleSwitchImage(direction);
  264. }
  265. handleDownload = () => {
  266. this.foundation.handleDownload();
  267. }
  268. handlePreviewClose = (e: React.MouseEvent<HTMLElement>) => {
  269. this.foundation.handlePreviewClose(e);
  270. }
  271. handleAdjustRatio = (type: RatioType) => {
  272. this.foundation.handleAdjustRatio(type);
  273. }
  274. handleRotateImage = (direction) => {
  275. this.foundation.handleRotateImage(direction);
  276. }
  277. handleZoomImage = (newZoom: number, notify: boolean = true) => {
  278. this.foundation.handleZoomImage(newZoom, notify);
  279. }
  280. handleMouseUp = (e): void => {
  281. this.foundation.handleMouseUp(e.nativeEvent);
  282. }
  283. handleMouseMove = (e): void => {
  284. this.foundation.handleMouseMove(e);
  285. }
  286. handleKeyDown = (e: KeyboardEvent) => {
  287. this.foundation.handleKeyDown(e);
  288. };
  289. onImageError = () => {
  290. this.foundation.preloadSingleImage();
  291. }
  292. onImageLoad = (src) => {
  293. this.foundation.onImageLoad(src);
  294. }
  295. handleMouseDown = (e): void => {
  296. this.foundation.handleMouseDown(e);
  297. }
  298. handleWheel = (e) => {
  299. this.foundation.handleWheel(e);
  300. }
  301. // 为什么通过 addEventListener 注册 wheel 事件而不是使用 onWheel 事件?
  302. // 因为 Passive Event Listeners(https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners)
  303. // Passive Event Listeners 是一种优化技术,用于提高滚动性能。在默认情况下,浏览器会假设事件的监听器不会调用
  304. // preventDefault() 方法来阻止事件的默认行为,从而允许进行一些优化操作,例如滚动平滑。
  305. // 对于 Image 而言,如果使用触控板,双指朝不同方向分开放大图片,则需要 preventDefault 防止页面整体放大。
  306. // Why register wheel event through addEventListener instead of using onWheel event?
  307. // Because of Passive Event Listeners(an optimization technique used to improve scrolling performance. By default,
  308. // the browser will assume that event listeners will not call preventDefault() method to prevent the default behavior of the event,
  309. // allowing some optimization operations such as scroll smoothing.)
  310. // For Image, if we use the trackpad and spread your fingers in different directions to enlarge the image, we need to preventDefault
  311. // to prevent the page from being enlarged as a whole.
  312. registryImageWrapRef = (ref): void => {
  313. if (this.imageWrapRef) {
  314. (this.imageWrapRef as any).removeEventListener("wheel", this.handleWheel);
  315. }
  316. if (ref) {
  317. ref.addEventListener("wheel", this.handleWheel, { passive: false });
  318. }
  319. this.imageWrapRef = ref;
  320. };
  321. render() {
  322. const {
  323. getPopupContainer,
  324. closable,
  325. zIndex,
  326. visible,
  327. className,
  328. style,
  329. infinite,
  330. zoomStep,
  331. crossOrigin,
  332. prevTip,
  333. nextTip,
  334. zoomInTip,
  335. zoomOutTip,
  336. rotateTip,
  337. downloadTip,
  338. adaptiveTip,
  339. originTip,
  340. showTooltip,
  341. disableDownload,
  342. renderLeftIcon,
  343. renderRightIcon,
  344. renderCloseIcon,
  345. renderPreviewMenu,
  346. renderHeader,
  347. } = this.props;
  348. const { currentIndex, imgSrc, zoom, ratio, rotation, viewerVisible } = this.state;
  349. let wrapperStyle: {
  350. zIndex?: CSSProperties["zIndex"];
  351. position?: CSSProperties["position"]
  352. } = {
  353. zIndex,
  354. };
  355. if (getPopupContainer) {
  356. wrapperStyle = {
  357. zIndex,
  358. position: "static",
  359. };
  360. }
  361. const previewPrefixCls = `${prefixCls}-preview`;
  362. const previewWrapperCls = cls(previewPrefixCls,
  363. {
  364. [`${prefixCls}-hide`]: !visible,
  365. [`${previewPrefixCls}-popup`]: getPopupContainer,
  366. },
  367. className,
  368. );
  369. const hideViewerCls = !viewerVisible ? `${previewPrefixCls}-hide` : "";
  370. const total = imgSrc.length;
  371. const showPrev = total !== 1 && (infinite || currentIndex !== 0);
  372. const showNext = total !== 1 && (infinite || currentIndex !== total - 1);
  373. const leftIcon = typeof renderLeftIcon === 'function' ? renderLeftIcon(currentIndex) : renderLeftIcon;
  374. const rightIcon = typeof renderRightIcon === 'function' ? renderRightIcon(currentIndex) : renderRightIcon;
  375. return (
  376. visible && <Portal
  377. getPopupContainer={getPopupContainer}
  378. style={wrapperStyle}
  379. >
  380. {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
  381. <div
  382. className={previewWrapperCls}
  383. style={style}
  384. onMouseDown={this.handleMouseDown}
  385. onMouseUp={this.handleMouseUp}
  386. ref={this.registryImageWrapRef}
  387. onMouseMove={this.handleMouseMove}
  388. >
  389. <Header ref={this.headerRef} className={cls(hideViewerCls)} onClose={this.handlePreviewClose} renderHeader={renderHeader} closable={closable} renderCloseIcon={renderCloseIcon} />
  390. <PreviewImage
  391. ref={this.imageRef}
  392. src={imgSrc[currentIndex]}
  393. onZoom={this.handleZoomImage}
  394. disableDownload={disableDownload}
  395. setRatio={this.handleAdjustRatio}
  396. zoom={zoom}
  397. ratio={ratio}
  398. rotation={rotation}
  399. crossOrigin={crossOrigin}
  400. onError={this.onImageError}
  401. onLoad={this.onImageLoad}
  402. />
  403. {showPrev && (
  404. // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
  405. <div
  406. ref={this.leftIconRef}
  407. className={cls(`${previewPrefixCls}-icon`, `${previewPrefixCls}-prev`, hideViewerCls)}
  408. onClick={(): void => this.handleSwitchImage("prev")}
  409. >
  410. {React.isValidElement(leftIcon) ? leftIcon : <IconArrowLeft size="large" />}
  411. </div>
  412. )}
  413. {showNext && (
  414. // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
  415. <div
  416. ref={this.rightIconRef}
  417. className={cls(`${previewPrefixCls}-icon`, `${previewPrefixCls}-next`, hideViewerCls)}
  418. onClick={(): void => this.handleSwitchImage("next")}
  419. >
  420. {React.isValidElement(rightIcon) ? rightIcon : <IconArrowRight size="large" />}
  421. </div>
  422. )}
  423. <Footer
  424. forwardRef={this.footerRef}
  425. className={hideViewerCls}
  426. totalNum={total}
  427. curPage={currentIndex + 1}
  428. disabledPrev={!showPrev}
  429. disabledNext={!showNext}
  430. zoom={zoom * 100}
  431. step={zoomStep * 100}
  432. showTooltip={showTooltip}
  433. ratio={ratio}
  434. prevTip={prevTip}
  435. nextTip={nextTip}
  436. zIndex={zIndex}
  437. zoomInTip={zoomInTip}
  438. zoomOutTip={zoomOutTip}
  439. rotateTip={rotateTip}
  440. downloadTip={downloadTip}
  441. disableDownload={disableDownload}
  442. adaptiveTip={adaptiveTip}
  443. originTip={originTip}
  444. onPrev={(): void => this.handleSwitchImage("prev")}
  445. onNext={(): void => this.handleSwitchImage("next")}
  446. onZoomIn={this.handleZoomImage}
  447. onZoomOut={this.handleZoomImage}
  448. onDownload={this.handleDownload}
  449. onRotate={this.handleRotateImage}
  450. onAdjustRatio={this.handleAdjustRatio}
  451. renderPreviewMenu={renderPreviewMenu}
  452. />
  453. </div>
  454. </Portal>
  455. );
  456. }
  457. }