index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import React from 'react';
  2. import BaseComponent from '../_base/baseComponent';
  3. import cls from "classnames";
  4. import PropTypes from 'prop-types';
  5. import "@douyinfe/semi-foundation/cropper/cropper.scss";
  6. import CropperFoundation, { CropperAdapter, ImageDataState, CropperBox } from '@douyinfe/semi-foundation/cropper/foundation';
  7. import { cssClasses, strings } from '@douyinfe/semi-foundation/cropper/constants';
  8. import ResizeObserver, { ObserverProperty } from '../resizeObserver';
  9. import { isUndefined } from 'lodash';
  10. interface CropperProps {
  11. className?: string;
  12. style?: React.CSSProperties;
  13. /* The address of the image that needs to be cropped */
  14. src?: string;
  15. /* Parameters that need to be transparently transmitted to the img node */
  16. imgProps?: React.ImgHTMLAttributes<HTMLImageElement>;
  17. /* The shape to crop, defaults to rectangle */
  18. shape?: 'rect' | 'round' | 'roundRect';
  19. /* Controlled crop ratio */
  20. aspectRatio?: number;
  21. /* The initial width-to-height ratio of the cropping box, default is 1 */
  22. defaultAspectRatio?: number;
  23. /* controlled scaling */
  24. /* when img loaded,After the image is loaded, an initial layer of scaling
  25. will be performed on the image to fit the zoom area.
  26. The zoom parameter is to zoom based on the initial zoom.
  27. */
  28. zoom?: number;
  29. onZoomChange?: (zoom: number) => void;
  30. /* Image rotation angle */
  31. rotate?: number;
  32. /* Show crop box resizing box ?*/
  33. showResizeBox?: boolean;
  34. cropperBoxStyle?: React.CSSProperties;
  35. cropperBoxCls?: string;
  36. /* The fill color of the non-picture parts in the cut result */
  37. fill?: string;
  38. maxZoom?: number;
  39. minZoom?: number;
  40. zoomStep?: number;
  41. preview?: () => HTMLElement
  42. }
  43. interface CropperState {
  44. imgData: ImageDataState;
  45. cropperBox: CropperBox;
  46. zoom: number;
  47. rotate: number;
  48. loaded: boolean
  49. }
  50. const prefixCls = cssClasses.PREFIX;
  51. class Cropper extends BaseComponent<CropperProps, CropperState> {
  52. static __SemiComponentName__ = "Cropper";
  53. static propTypes = {
  54. className: PropTypes.string,
  55. style: PropTypes.object,
  56. };
  57. static defaultProps = {
  58. shape: 'rect',
  59. defaultAspectRatio: 1,
  60. showResizeBox: true,
  61. fill: 'rgba(0, 0, 0, 0)',
  62. maxZoom: 3,
  63. minZoom: 0.1,
  64. zoomStep: 0.1,
  65. }
  66. containerRef: HTMLDivElement;
  67. imgRef: React.RefObject<HTMLImageElement>;
  68. foundation: CropperFoundation;
  69. constructor(props: CropperProps) {
  70. super(props);
  71. this.state = {
  72. imgData: {
  73. width: 0,
  74. height: 0,
  75. centerPoint: {
  76. x: 0,
  77. y: 0
  78. }
  79. },
  80. cropperBox: {
  81. width: 0,
  82. height: 0,
  83. centerPoint: {
  84. x: 0,
  85. y: 0,
  86. }
  87. },
  88. zoom: 1,
  89. rotate: 0,
  90. loaded: false,
  91. };
  92. this.foundation = new CropperFoundation(this.adapter);
  93. this.imgRef = React.createRef();
  94. }
  95. get adapter(): CropperAdapter<CropperProps, CropperState> {
  96. return {
  97. ...super.adapter,
  98. getContainer: () => this.containerRef as unknown as HTMLElement,
  99. notifyZoomChange: (zoom: number) => {
  100. const { onZoomChange } = this.props;
  101. onZoomChange?.(zoom);
  102. },
  103. getImg: () => this.imgRef.current,
  104. };
  105. }
  106. static getDerivedStateFromProps(nextProps: CropperProps, prevState: CropperState) {
  107. const { rotate: newRotate, zoom: newZoom } = nextProps;
  108. const { rotate, zoom, imgData, cropperBox, loaded } = prevState;
  109. let nextWidth = imgData.width, nextHeight = imgData.height;
  110. let nextImgCenter = { ...imgData.centerPoint };
  111. const nextState = {} as any;
  112. if (!loaded) {
  113. return null;
  114. }
  115. if (!isUndefined(newRotate) && newRotate !== rotate) {
  116. nextState.rotate = newRotate;
  117. if (loaded) {
  118. // 因为以裁切框的左上方顶点作为原点,所以centerPoint 的 y 坐标与实际的坐标系方向相反,
  119. // 因此 y 方向需要先做变换,再使用旋转变换公式计算中心点坐标
  120. const rotateCenter = {
  121. x: cropperBox.centerPoint.x,
  122. y: - cropperBox.centerPoint.y
  123. };
  124. const imgCenter = {
  125. x: imgData.centerPoint.x,
  126. y: - imgData.centerPoint.y
  127. };
  128. const angle = (newRotate - rotate) * Math.PI / 180;
  129. nextImgCenter = {
  130. x: (imgCenter.x - rotateCenter.x) * Math.cos(angle) + (imgCenter.y - rotateCenter.y) * Math.sin(angle) + rotateCenter.x,
  131. y: - (-(imgCenter.x - rotateCenter.x) * Math.sin(angle) + (imgCenter.y - rotateCenter.y) * Math.cos(angle) + rotateCenter.y),
  132. };
  133. }
  134. }
  135. if (!isUndefined(newRotate) && newZoom !== zoom) {
  136. nextState.zoom = newZoom;
  137. if (loaded) {
  138. // 同上
  139. const scaleCenter = {
  140. x: cropperBox.centerPoint.x,
  141. y: - cropperBox.centerPoint.y
  142. };
  143. const currentImgCenter = {
  144. x: nextImgCenter.x,
  145. y: - nextImgCenter.y
  146. };
  147. nextWidth = imgData.width / zoom * newZoom;
  148. nextHeight = imgData.height / zoom * newZoom;
  149. nextImgCenter = {
  150. x: (currentImgCenter.x - scaleCenter.x) / zoom * newZoom + scaleCenter.x,
  151. y: - [(currentImgCenter.y - scaleCenter.y) / zoom * newZoom + scaleCenter.y],
  152. };
  153. }
  154. }
  155. if ((newRotate !== rotate || newZoom !== zoom)) {
  156. nextState.imgData = {
  157. width: nextWidth,
  158. height: nextHeight,
  159. centerPoint: nextImgCenter,
  160. };
  161. }
  162. if (Object.keys(nextState).length) {
  163. return nextState;
  164. }
  165. return null;
  166. }
  167. componentDidMount(): void {
  168. this.foundation.init();
  169. }
  170. componentWillUnmount(): void {
  171. this.foundation.destroy();
  172. this.unRegisterImageWrapRef();
  173. }
  174. unRegisterImageWrapRef = (): void => {
  175. if (this.containerRef) {
  176. (this.containerRef as any).removeEventListener("wheel", this.foundation.handleWheel);
  177. }
  178. this.containerRef = null;
  179. };
  180. registryImageWrapRef = (ref: any): void => {
  181. this.unRegisterImageWrapRef();
  182. if (ref) {
  183. // We need to use preventDefault to prevent the page from being enlarged when zooming in with two fingers.
  184. ref.addEventListener("wheel", this.foundation.handleWheel, { passive: false });
  185. }
  186. this.containerRef = ref;
  187. };
  188. // ref method: Get the cropped canvas
  189. getCropperCanvas = () => {
  190. return this.foundation.getCropperCanvas();
  191. }
  192. render() {
  193. const { className, style, src, shape, showResizeBox, cropperBoxStyle, cropperBoxCls } = this.props;
  194. const { imgData, cropperBox, rotate, loaded } = this.state;
  195. const imgX = imgData.centerPoint.x - imgData.width / 2;
  196. const imgY = imgData.centerPoint.y - imgData.height / 2;
  197. const cropperBoxX = cropperBox.centerPoint.x - cropperBox.width / 2;
  198. const cropperBoxY = cropperBox.centerPoint.y - cropperBox.height / 2;
  199. const cropperImgX = imgX - cropperBoxX;
  200. const cropperImgY = imgY - cropperBoxY;
  201. this.foundation.updatePreview({
  202. width: imgData.width,
  203. height: imgData.height,
  204. translateX: cropperImgX,
  205. translateY: cropperImgY,
  206. rotate: rotate,
  207. });
  208. return (<ResizeObserver
  209. onResize={this.foundation.handleResize}
  210. observerProperty={ObserverProperty.Width}
  211. >
  212. <div
  213. className={cls(prefixCls, className)}
  214. style={style}
  215. ref={this.registryImageWrapRef}
  216. >
  217. {/* Img layer */}
  218. <div className={cssClasses.IMG_WRAPPER}>
  219. <img
  220. ref={this.imgRef}
  221. src={src}
  222. onLoad={this.foundation.handleImageLoad}
  223. className={cssClasses.IMG}
  224. crossOrigin='anonymous'
  225. style={{
  226. width: imgData.width,
  227. height: imgData.height,
  228. transformOrigin: 'center',
  229. transform: `translate(${imgX}px, ${imgY}px) rotate(${rotate}deg)`,
  230. }}
  231. />
  232. </div>
  233. {/* Mask layer */}
  234. <div
  235. className={cssClasses.MASK}
  236. onMouseDown={this.foundation.handleMaskMouseDown}
  237. />
  238. {/* Cropper box */}
  239. <div
  240. className={cls(cssClasses.CROPPER_BOX, {
  241. [cropperBoxCls]: cropperBoxCls,
  242. [cssClasses.CROPPER_VIEW_BOX_ROUND]: shape === 'round',
  243. })}
  244. style={{
  245. ...cropperBoxStyle,
  246. width: cropperBox.width,
  247. height: cropperBox.height,
  248. transform: `translate(${cropperBoxX}px, ${cropperBoxY}px)`,
  249. }}
  250. onMouseDown={this.foundation.handleCropperBoxMouseDown}
  251. >
  252. <div
  253. className={cls(cssClasses.CROPPER_VIEW_BOX, {
  254. [cssClasses.CROPPER_VIEW_BOX_ROUND]: shape.includes('round'),
  255. })}
  256. >
  257. <img
  258. onDragStart={this.foundation.viewIMGDragStart}
  259. className={cssClasses.CROPPER_IMG}
  260. src={src}
  261. style={{
  262. width: imgData.width,
  263. height: imgData.height,
  264. transformOrigin: 'center',
  265. transform: `translate(${cropperImgX}px, ${cropperImgY}px) rotate(${rotate}deg)`,
  266. }}
  267. />
  268. </div>
  269. {/* 裁剪框的拖拽操作按钮 */}
  270. {loaded && showResizeBox && (shape === 'round' ? strings.roundCorner : strings.corner).map(corner => (
  271. <div
  272. className={cls(cssClasses.CORNER, `${cssClasses.CORNER}-${corner}`)}
  273. data-dir={corner}
  274. key={corner}
  275. onMouseDown={this.foundation.handleCornerMouseDown}
  276. />
  277. ))}
  278. </div>
  279. </div>
  280. </ResizeObserver>);
  281. }
  282. }
  283. export default Cropper;