UNPKG

30.8 kBTypeScriptView Raw
1import React, { PureComponent, createRef } from 'react';
2import clsx from 'clsx';
3
4import './ReactCrop.scss';
5
6const defaultCrop: Crop = {
7 x: 0,
8 y: 0,
9 width: 0,
10 height: 0,
11 unit: 'px',
12};
13
14function clamp(num: number, min: number, max: number) {
15 return Math.min(Math.max(num, min), max);
16}
17
18function isCropValid(crop: Partial<Crop>) {
19 return crop && crop.width && !isNaN(crop.width) && crop.height && !isNaN(crop.height);
20}
21
22function makeAspectCrop(crop: Crop, imageWidth: number, imageHeight: number) {
23 if (!crop.aspect || isNaN(crop.aspect)) {
24 console.warn('`crop.aspect` should be a number in order to make an aspect crop', crop);
25 return { ...defaultCrop, ...crop };
26 }
27
28 const completeCrop: Crop = {
29 unit: 'px',
30 x: crop.x || 0,
31 y: crop.y || 0,
32 width: crop.width || 0,
33 height: crop.height || 0,
34 aspect: crop.aspect,
35 };
36
37 if (crop.width) {
38 completeCrop.height = completeCrop.width / crop.aspect;
39 }
40
41 if (crop.height) {
42 completeCrop.width = completeCrop.height * crop.aspect;
43 }
44
45 if (completeCrop.y + completeCrop.height > imageHeight) {
46 completeCrop.height = imageHeight - completeCrop.y;
47 completeCrop.width = completeCrop.height * crop.aspect;
48 }
49
50 if (completeCrop.x + completeCrop.width > imageWidth) {
51 completeCrop.width = imageWidth - completeCrop.x;
52 completeCrop.height = completeCrop.width / crop.aspect;
53 }
54
55 return completeCrop;
56}
57
58function convertToPercentCrop(crop: Partial<Crop>, imageWidth: number, imageHeight: number): Crop {
59 if (crop.unit === '%') {
60 return { ...defaultCrop, ...crop };
61 }
62
63 return {
64 unit: '%',
65 aspect: crop.aspect,
66 x: crop.x ? (crop.x / imageWidth) * 100 : 0,
67 y: crop.y ? (crop.y / imageHeight) * 100 : 0,
68 width: crop.width ? (crop.width / imageWidth) * 100 : 0,
69 height: crop.height ? (crop.height / imageHeight) * 100 : 0,
70 };
71}
72
73function convertToPixelCrop(crop: Partial<Crop>, imageWidth: number, imageHeight: number): Crop {
74 if (!crop.unit) {
75 return { ...defaultCrop, ...crop, unit: 'px' };
76 }
77
78 if (crop.unit === 'px') {
79 return { ...defaultCrop, ...crop };
80 }
81
82 return {
83 unit: 'px',
84 aspect: crop.aspect,
85 x: crop.x ? (crop.x * imageWidth) / 100 : 0,
86 y: crop.y ? (crop.y * imageHeight) / 100 : 0,
87 width: crop.width ? (crop.width * imageWidth) / 100 : 0,
88 height: crop.height ? (crop.height * imageHeight) / 100 : 0,
89 };
90}
91
92function resolveCrop(pixelCrop: Crop, imageWidth: number, imageHeight: number) {
93 if (pixelCrop.aspect && (!pixelCrop.width || !pixelCrop.height)) {
94 return makeAspectCrop(pixelCrop, imageWidth, imageHeight);
95 }
96
97 return pixelCrop;
98}
99
100function containCrop(prevCrop: Partial<Crop>, crop: Partial<Crop>, imageWidth: number, imageHeight: number) {
101 const pixelCrop = convertToPixelCrop(crop, imageWidth, imageHeight);
102 const prevPixelCrop = convertToPixelCrop(prevCrop, imageWidth, imageHeight);
103
104 // Non-aspects are simple
105 if (!pixelCrop.aspect) {
106 if (pixelCrop.x < 0) {
107 pixelCrop.x = 0;
108 pixelCrop.width += pixelCrop.x;
109 } else if (pixelCrop.x + pixelCrop.width > imageWidth) {
110 pixelCrop.width = imageWidth - pixelCrop.x;
111 }
112
113 if (pixelCrop.y + pixelCrop.height > imageHeight) {
114 pixelCrop.height = imageHeight - pixelCrop.y;
115 }
116
117 return pixelCrop;
118 }
119
120 let adjustedForX = false;
121
122 if (pixelCrop.x < 0) {
123 pixelCrop.x = 0;
124 pixelCrop.width += pixelCrop.x;
125 pixelCrop.height = pixelCrop.width / pixelCrop.aspect;
126 adjustedForX = true;
127 } else if (pixelCrop.x + pixelCrop.width > imageWidth) {
128 pixelCrop.width = imageWidth - pixelCrop.x;
129 pixelCrop.height = pixelCrop.width / pixelCrop.aspect;
130 adjustedForX = true;
131 }
132
133 // If sizing in up direction we need to pin Y at the point it
134 // would be at the boundary.
135 if (adjustedForX && prevPixelCrop.y > pixelCrop.y) {
136 pixelCrop.y = pixelCrop.y + (pixelCrop.height - pixelCrop.height);
137 }
138
139 let adjustedForY = false;
140
141 if (pixelCrop.y + pixelCrop.height > imageHeight) {
142 pixelCrop.height = imageHeight - pixelCrop.y;
143 pixelCrop.width = pixelCrop.height * pixelCrop.aspect;
144 adjustedForY = true;
145 }
146
147 // If sizing in left direction we need to pin X at the point it
148 // would be at the boundary.
149 if (adjustedForY && prevPixelCrop.x > pixelCrop.x) {
150 pixelCrop.x = pixelCrop.x + (pixelCrop.width - pixelCrop.width);
151 }
152
153 return pixelCrop;
154}
155
156const DOC_MOVE_OPTS = { capture: true, passive: false };
157
158type XOrds = 'e' | 'w';
159type YOrds = 'n' | 's';
160type XYOrds = 'nw' | 'ne' | 'se' | 'sw';
161type Ords = XOrds | YOrds | XYOrds;
162
163export interface Crop {
164 aspect?: number;
165 x: number;
166 y: number;
167 width: number;
168 height: number;
169 unit: 'px' | '%';
170}
171
172interface EVData {
173 clientStartX: number;
174 clientStartY: number;
175 cropStartWidth: number;
176 cropStartHeight: number;
177 cropStartX: number;
178 cropStartY: number;
179 xDiff: number;
180 yDiff: number;
181 xInversed: boolean;
182 yInversed: boolean;
183 xCrossOver: boolean;
184 yCrossOver: boolean;
185 lastYCrossover: boolean;
186 startXCrossOver: boolean;
187 startYCrossOver: boolean;
188 isResize: boolean;
189 ord: Ords;
190}
191
192export interface ReactCropProps {
193 /** A string of classes to add to the main `ReactCrop` element. */
194 className?: string;
195 /** A React Node that will be inserted into the `ReactCrop` element */
196 children?: React.ReactNode;
197 /** Show the crop area as a circle. If your aspect is not 1 (a square) then the circle will be warped into an oval shape. Defaults to false. */
198 circularCrop?: boolean;
199 /** All crop params are initially optional. See README.md for more info. */
200 crop: Partial<Crop>;
201 /** Allows setting the crossorigin attribute on the image. */
202 crossorigin?: React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>['crossOrigin'];
203 /** If true then the user cannot resize or draw a new crop. A class of `ReactCrop--disabled` is also added to the container for user styling. */
204 disabled?: boolean;
205 /** If true then the user cannot create or resize a crop, but can still drag the existing crop around. A class of `ReactCrop--locked` is also added to the container for user styling. */
206 locked?: boolean;
207 /** Add an alt attribute to the image element. */
208 imageAlt?: string;
209 /** Inline styles object to be passed to the image element. */
210 imageStyle?: React.CSSProperties;
211 /** If true is passed then selection can't be disabled if the user clicks outside the selection area. */
212 keepSelection?: boolean;
213 /** A minimum crop width, in pixels. */
214 minWidth?: number;
215 /** A minimum crop height, in pixels. */
216 minHeight?: number;
217 /** A maximum crop width, in pixels. */
218 maxWidth?: number;
219 /** A maximum crop height, in pixels. */
220 maxHeight?: number;
221 /** A callback which happens for every change of the crop. You should set the crop to state and pass it back into the library via the `crop` prop. */
222 onChange: (crop: Crop, percentageCrop: Crop) => void;
223 /** A callback which happens after a resize, drag, or nudge. Passes the current crop state object in pixels and percent. */
224 onComplete?: (crop: Crop, percentageCrop: Crop) => void;
225 /** This event is called if the image had an error loading. */
226 onImageError?: React.DOMAttributes<HTMLImageElement>['onError'];
227 /** A callback which happens when the image is loaded. Passes the image DOM element. Return false if you set the crop with setState in there as otherwise the subsequent onChange + onComplete will not have your updated crop. */
228 onImageLoaded?: (image: HTMLImageElement) => void | boolean;
229 /** A callback which happens when a user starts dragging or resizing. It is convenient to manipulate elements outside this component. */
230 onDragStart?: (e: PointerEvent) => void;
231 /** A callback which happens when a user releases the cursor or touch after dragging or resizing. */
232 onDragEnd?: (e: PointerEvent) => void;
233 /** Render a custom HTML element in place of an image. Useful if you want to support videos. */
234 renderComponent?: React.ReactNode;
235 /** Render a custom element in crop selection. */
236 renderSelectionAddon?: (state: ReactCropState) => React.ReactNode;
237 /** Rotates the image, you should pass a value between -180 and 180. Defaults to 0. */
238 rotate?: number;
239 /** Show rule of thirds lines in the cropped area. Defaults to false. */
240 ruleOfThirds?: boolean;
241 /** Scales the image. Defaults to 1 (normal scale). */
242 scale?: number;
243 /** The image source (can be base64 or a blob just like a normal image). */
244 src: string;
245 /** Inline styles object to be passed to the image wrapper element. */
246 style?: React.CSSProperties;
247 /** A non-visual prop to keep pointer coords accurate when a parent element is scaled. Not to be confused with the `scale` prop which scales the image itself. Defaults to 1. */
248 zoom?: number;
249 /** A non-visual prop to keep pointer coords accurate when a parent element is rotated. Not to be confused with the `rotate` prop which rotates the image itself. Defaults to 0, range is from -180 to 180. */
250 spin?: number;
251}
252
253export interface ReactCropState {
254 cropIsActive: boolean;
255 newCropIsBeingDrawn: boolean;
256}
257
258class ReactCrop extends PureComponent<ReactCropProps, ReactCropState> {
259 static xOrds = ['e', 'w'];
260 static yOrds = ['n', 's'];
261 static xyOrds = ['nw', 'ne', 'se', 'sw'];
262
263 static nudgeStep = 1;
264 static nudgeStepMedium = 10;
265 static nudgeStepLarge = 100;
266
267 keysDown = new Set<string>();
268 docMoveBound = false;
269 mouseDownOnCrop = false;
270 dragStarted = false;
271 evData: EVData = {
272 clientStartX: 0,
273 clientStartY: 0,
274 cropStartWidth: 0,
275 cropStartHeight: 0,
276 cropStartX: 0,
277 cropStartY: 0,
278 xDiff: 0,
279 yDiff: 0,
280 xInversed: false,
281 yInversed: false,
282 xCrossOver: false,
283 yCrossOver: false,
284 lastYCrossover: false,
285 startXCrossOver: false,
286 startYCrossOver: false,
287 isResize: true,
288 ord: 'nw',
289 };
290
291 componentRef = createRef<HTMLDivElement>();
292 mediaWrapperRef = createRef<HTMLDivElement>();
293 imageRef = createRef<HTMLImageElement>();
294 cropSelectRef = createRef<HTMLDivElement>();
295
296 state: ReactCropState = {
297 cropIsActive: false,
298 newCropIsBeingDrawn: false,
299 };
300
301 componentDidMount() {
302 if (this.componentRef.current) {
303 this.componentRef.current.addEventListener('medialoaded', this.onMediaLoaded);
304 }
305 }
306
307 componentWillUnmount() {
308 if (this.componentRef.current) {
309 this.componentRef.current.removeEventListener('medialoaded', this.onMediaLoaded);
310 }
311 }
312
313 componentDidUpdate(prevProps: ReactCropProps) {
314 const { crop, onChange, onComplete } = this.props;
315
316 if (
317 this.imageRef.current &&
318 crop &&
319 prevProps.crop !== crop &&
320 crop.aspect &&
321 ((crop.width && !crop.height) || (!crop.width && crop.height))
322 ) {
323 const { width, height } = this.imageRef.current;
324 const newCrop = this.makeNewCrop();
325 const completedCrop = makeAspectCrop(newCrop, width, height);
326
327 const pixelCrop = convertToPixelCrop(completedCrop, width, height);
328 const percentCrop = convertToPercentCrop(completedCrop, width, height);
329 onChange(pixelCrop, percentCrop);
330
331 if (onComplete) {
332 onComplete(pixelCrop, percentCrop);
333 }
334 }
335 }
336
337 bindDocMove() {
338 if (this.docMoveBound) {
339 return;
340 }
341
342 document.addEventListener('pointermove', this.onDocPointerMove, DOC_MOVE_OPTS);
343 document.addEventListener('pointerup', this.onDocPointerDone, DOC_MOVE_OPTS);
344 document.addEventListener('pointercancel', this.onDocPointerDone, DOC_MOVE_OPTS);
345
346 this.docMoveBound = true;
347 }
348
349 unbindDocMove() {
350 if (!this.docMoveBound) {
351 return;
352 }
353
354 document.removeEventListener('pointermove', this.onDocPointerMove, DOC_MOVE_OPTS);
355 document.removeEventListener('pointerup', this.onDocPointerDone, DOC_MOVE_OPTS);
356 document.removeEventListener('pointercancel', this.onDocPointerDone, DOC_MOVE_OPTS);
357
358 this.docMoveBound = false;
359 }
360
361 onCropPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
362 const { crop, disabled } = this.props;
363 const { width, height } = this.mediaDimensions;
364 const pixelCrop = convertToPixelCrop(crop, width, height);
365
366 if (disabled) {
367 return;
368 }
369
370 if (e.cancelable) e.preventDefault(); // Stop drag selection.
371
372 // Bind to doc to follow movements outside of element.
373 this.bindDocMove();
374
375 // Focus for detecting keypress.
376 (this.componentRef.current as HTMLDivElement).focus({ preventScroll: true }); // All other browsers
377
378 const { ord } = (e.target as HTMLElement).dataset;
379 const xInversed = ord === 'nw' || ord === 'w' || ord === 'sw';
380 const yInversed = ord === 'nw' || ord === 'n' || ord === 'ne';
381
382 this.evData = {
383 clientStartX: e.clientX,
384 clientStartY: e.clientY,
385 cropStartWidth: pixelCrop.width,
386 cropStartHeight: pixelCrop.height,
387 cropStartX: xInversed ? pixelCrop.x + pixelCrop.width : pixelCrop.x,
388 cropStartY: yInversed ? pixelCrop.y + pixelCrop.height : pixelCrop.y,
389 xDiff: 0,
390 yDiff: 0,
391 xInversed,
392 yInversed,
393 xCrossOver: xInversed,
394 yCrossOver: yInversed,
395 lastYCrossover: yInversed,
396 startXCrossOver: xInversed,
397 startYCrossOver: yInversed,
398 isResize: Boolean(ord),
399 ord: (ord || 'ne') as Ords,
400 };
401
402 this.mouseDownOnCrop = true;
403 this.setState({ cropIsActive: true });
404 };
405
406 onComponentPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
407 const { crop, disabled, locked, keepSelection, onChange, zoom = 1, spin = 0 } = this.props;
408
409 const componentEl = (this.mediaWrapperRef.current as HTMLDivElement).firstChild;
410
411 if (e.target !== componentEl || !componentEl.contains(e.target as Node)) {
412 return;
413 }
414
415 if (disabled || locked || (keepSelection && isCropValid(crop))) {
416 return;
417 }
418
419 if (e.cancelable) e.preventDefault(); // Stop drag selection.
420
421 // Bind to doc to follow movements outside of element.
422 this.bindDocMove();
423
424 // Focus for detecting keypress.
425 (this.componentRef.current as HTMLDivElement).focus({ preventScroll: true }); // All other browsers
426
427 const rect = (this.mediaWrapperRef.current as HTMLDivElement).getBoundingClientRect();
428 let x = 0;
429 let y = 0;
430 let scaledX = (e.clientX - rect.left) / zoom;
431 let scaledY = (e.clientY - rect.top) / zoom;
432 let degrees = spin;
433 let radians = Math.abs((degrees * Math.PI) / 180.0);
434 if ((degrees > -45 && degrees <= 45) || (Math.abs(degrees) > 135 && Math.abs(degrees) <= 180)) {
435 // Top and Bottom
436 x = scaledX * Math.cos(radians);
437 y = scaledY * Math.cos(radians);
438 } else if (degrees > 45 && degrees <= 135) {
439 // Left
440 x = scaledY * Math.sin(radians);
441 y = scaledX * Math.sin(radians) * -1;
442 } else if (degrees > -135 && degrees <= -45) {
443 // Right
444 x = scaledY * Math.sin(radians) * -1;
445 y = scaledX * Math.sin(radians);
446 }
447
448 const nextCrop: Crop = {
449 unit: 'px',
450 aspect: crop ? crop.aspect : undefined,
451 x,
452 y,
453 width: 0,
454 height: 0,
455 };
456
457 this.evData = {
458 clientStartX: e.clientX,
459 clientStartY: e.clientY,
460 cropStartWidth: nextCrop.width,
461 cropStartHeight: nextCrop.height,
462 cropStartX: nextCrop.x,
463 cropStartY: nextCrop.y,
464 xDiff: 0,
465 yDiff: 0,
466 xInversed: false,
467 yInversed: false,
468 xCrossOver: false,
469 yCrossOver: false,
470 lastYCrossover: false,
471 startXCrossOver: false,
472 startYCrossOver: false,
473 isResize: true,
474 ord: 'nw',
475 };
476
477 this.mouseDownOnCrop = true;
478
479 const { width, height } = this.mediaDimensions;
480
481 onChange(convertToPixelCrop(nextCrop, width, height), convertToPercentCrop(nextCrop, width, height));
482
483 this.setState({ cropIsActive: true, newCropIsBeingDrawn: true });
484 };
485
486 onDocPointerMove = (e: PointerEvent) => {
487 const { crop, disabled, onChange, onDragStart, zoom = 1, spin = 0 } = this.props;
488
489 if (disabled) {
490 return;
491 }
492
493 if (!this.mouseDownOnCrop) {
494 return;
495 }
496
497 if (e.cancelable) e.preventDefault(); // Stop drag selection.
498
499 if (!this.dragStarted) {
500 this.dragStarted = true;
501 if (onDragStart) {
502 onDragStart(e);
503 }
504 }
505
506 const { evData } = this;
507
508 let scaledX = (e.clientX - evData.clientStartX) / zoom;
509 let scaledY = (e.clientY - evData.clientStartY) / zoom;
510 let degrees = spin;
511 let radians = Math.abs((degrees * Math.PI) / 180.0);
512 if ((degrees > -45 && degrees <= 45) || (Math.abs(degrees) > 135 && Math.abs(degrees) <= 180)) {
513 // Top and Bottom
514 evData.xDiff = scaledX * Math.cos(radians);
515 evData.yDiff = scaledY * Math.cos(radians);
516 } else if (degrees > 45 && degrees <= 135) {
517 // Left
518 evData.xDiff = scaledY * Math.sin(radians);
519 evData.yDiff = scaledX * Math.sin(radians) * -1;
520 } else if (degrees > -135 && degrees <= -45) {
521 // Right
522 evData.xDiff = scaledY * Math.sin(radians) * -1;
523 evData.yDiff = scaledX * Math.sin(radians);
524 }
525
526 let nextCrop;
527
528 if (evData.isResize) {
529 nextCrop = this.resizeCrop();
530 } else {
531 nextCrop = this.dragCrop();
532 }
533
534 if (nextCrop !== crop) {
535 const { width, height } = this.mediaDimensions;
536 onChange(convertToPixelCrop(nextCrop, width, height), convertToPercentCrop(nextCrop, width, height));
537 }
538 };
539
540 onComponentKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
541 const { crop, disabled, onChange, onComplete } = this.props;
542
543 if (disabled) {
544 return;
545 }
546
547 this.keysDown.add(e.key);
548 let nudged = false;
549
550 if (!isCropValid(crop)) {
551 return;
552 }
553
554 const nextCrop = this.makeNewCrop();
555 const ctrlCmdPressed = navigator.platform.match('Mac') ? e.metaKey : e.ctrlKey;
556 const nudgeStep = ctrlCmdPressed
557 ? ReactCrop.nudgeStepLarge
558 : e.shiftKey
559 ? ReactCrop.nudgeStepMedium
560 : ReactCrop.nudgeStep;
561
562 if (this.keysDown.has('ArrowLeft')) {
563 nextCrop.x -= nudgeStep;
564 nudged = true;
565 }
566
567 if (this.keysDown.has('ArrowRight')) {
568 nextCrop.x += nudgeStep;
569 nudged = true;
570 }
571
572 if (this.keysDown.has('ArrowUp')) {
573 nextCrop.y -= nudgeStep;
574 nudged = true;
575 }
576
577 if (this.keysDown.has('ArrowDown')) {
578 nextCrop.y += nudgeStep;
579 nudged = true;
580 }
581
582 if (nudged) {
583 if (e.cancelable) e.preventDefault(); // Stop drag selection.
584 const { width, height } = this.mediaDimensions;
585
586 nextCrop.x = clamp(nextCrop.x, 0, width - nextCrop.width);
587 nextCrop.y = clamp(nextCrop.y, 0, height - nextCrop.height);
588
589 const pixelCrop = convertToPixelCrop(nextCrop, width, height);
590 const percentCrop = convertToPercentCrop(nextCrop, width, height);
591
592 onChange(pixelCrop, percentCrop);
593 if (onComplete) {
594 onComplete(pixelCrop, percentCrop);
595 }
596 }
597 };
598
599 onComponentKeyUp = (e: React.KeyboardEvent<HTMLDivElement>) => {
600 this.keysDown.delete(e.key);
601 };
602
603 onDocPointerDone = (e: PointerEvent) => {
604 const { crop, disabled, onComplete, onDragEnd } = this.props;
605
606 this.unbindDocMove();
607
608 if (disabled) {
609 return;
610 }
611
612 if (this.mouseDownOnCrop) {
613 this.mouseDownOnCrop = false;
614 this.dragStarted = false;
615
616 const { width, height } = this.mediaDimensions;
617
618 onDragEnd && onDragEnd(e);
619 onComplete && onComplete(convertToPixelCrop(crop, width, height), convertToPercentCrop(crop, width, height));
620
621 this.setState({ cropIsActive: false, newCropIsBeingDrawn: false });
622 }
623 };
624
625 // When the image is loaded or when a custom component via `renderComponent` prop fires
626 // a custom "medialoaded" event.
627 createNewCrop() {
628 const { width, height } = this.mediaDimensions;
629 const crop = this.makeNewCrop();
630 const resolvedCrop = resolveCrop(crop, width, height);
631 const pixelCrop = convertToPixelCrop(resolvedCrop, width, height);
632 const percentCrop = convertToPercentCrop(resolvedCrop, width, height);
633 return { pixelCrop, percentCrop };
634 }
635
636 // Custom components (using `renderComponent`) should fire a custom event
637 // called "medialoaded" when they are loaded.
638 onMediaLoaded = () => {
639 const { onComplete, onChange } = this.props;
640 const { pixelCrop, percentCrop } = this.createNewCrop();
641 onChange(pixelCrop, percentCrop);
642 onComplete && onComplete(pixelCrop, percentCrop);
643 };
644
645 onImageLoad = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
646 const { onComplete, onChange, onImageLoaded } = this.props;
647
648 // Return false from onImageLoaded if you set the crop with setState in there as otherwise
649 // the subsequent onChange + onComplete will not have your updated crop.
650 const res = onImageLoaded ? onImageLoaded(e.currentTarget) : true;
651
652 if (res !== false) {
653 const { pixelCrop, percentCrop } = this.createNewCrop();
654 onChange(pixelCrop, percentCrop);
655 onComplete && onComplete(pixelCrop, percentCrop);
656 }
657 };
658
659 get mediaDimensions() {
660 let width = 0;
661 let height = 0;
662 if (this.mediaWrapperRef.current) {
663 width = this.mediaWrapperRef.current.clientWidth;
664 height = this.mediaWrapperRef.current.clientHeight;
665 }
666 return { width, height };
667 }
668
669 getCropStyle() {
670 const crop = this.makeNewCrop(this.props.crop ? this.props.crop.unit : 'px');
671
672 return {
673 top: `${crop.y}${crop.unit}`,
674 left: `${crop.x}${crop.unit}`,
675 width: `${crop.width}${crop.unit}`,
676 height: `${crop.height}${crop.unit}`,
677 };
678 }
679
680 getNewSize() {
681 const { crop, minWidth = 0, maxWidth, minHeight = 0, maxHeight } = this.props;
682 const { evData } = this;
683 const { width, height } = this.mediaDimensions;
684
685 // New width.
686 let newWidth = evData.cropStartWidth + evData.xDiff;
687
688 if (evData.xCrossOver) {
689 newWidth = Math.abs(newWidth);
690 }
691
692 newWidth = clamp(newWidth, minWidth, maxWidth || width);
693
694 // New height.
695 let newHeight;
696
697 if (crop.aspect) {
698 newHeight = newWidth / crop.aspect;
699 } else {
700 newHeight = evData.cropStartHeight + evData.yDiff;
701 }
702
703 if (evData.yCrossOver) {
704 // Cap if polarity is inversed and the height fills the y space.
705 newHeight = Math.min(Math.abs(newHeight), evData.cropStartY);
706 }
707
708 newHeight = clamp(newHeight, minHeight, maxHeight || height);
709
710 if (crop.aspect) {
711 newWidth = clamp(newHeight * crop.aspect, 0, width);
712 }
713
714 return {
715 width: newWidth,
716 height: newHeight,
717 };
718 }
719
720 dragCrop() {
721 const nextCrop = this.makeNewCrop();
722 const { evData } = this;
723 const { width, height } = this.mediaDimensions;
724
725 nextCrop.x = clamp(evData.cropStartX + evData.xDiff, 0, width - nextCrop.width);
726 nextCrop.y = clamp(evData.cropStartY + evData.yDiff, 0, height - nextCrop.height);
727
728 return nextCrop;
729 }
730
731 resizeCrop() {
732 const { evData } = this;
733 const { crop, minWidth = 0, minHeight = 0 } = this.props;
734 const nextCrop = this.makeNewCrop();
735 const { ord } = evData;
736
737 // On the inverse change the diff so it's the same and
738 // the same algo applies.
739 if (evData.xInversed) {
740 evData.xDiff -= evData.cropStartWidth * 2;
741 }
742 if (evData.yInversed) {
743 evData.yDiff -= evData.cropStartHeight * 2;
744 }
745
746 // New size.
747 const newSize = this.getNewSize();
748
749 // Adjust x/y to give illusion of 'staticness' as width/height is increased
750 // when polarity is inversed.
751 let newX = evData.cropStartX;
752 let newY = evData.cropStartY;
753
754 if (evData.xCrossOver) {
755 newX = nextCrop.x + (nextCrop.width - newSize.width);
756 }
757
758 if (evData.yCrossOver) {
759 // This has a more favorable behavior for aspect crops crossing over on the
760 // diagonal, else it flips over from the bottom corner and looks jumpy.
761 if (evData.lastYCrossover === false) {
762 newY = nextCrop.y - newSize.height;
763 } else {
764 newY = nextCrop.y + (nextCrop.height - newSize.height);
765 }
766 }
767
768 const { width, height } = this.mediaDimensions;
769
770 const containedCrop = containCrop(
771 this.props.crop,
772 {
773 unit: nextCrop.unit,
774 x: newX,
775 y: newY,
776 width: newSize.width,
777 height: newSize.height,
778 aspect: nextCrop.aspect,
779 },
780 width,
781 height
782 );
783
784 // Apply x/y/width/height changes depending on ordinate (fixed aspect always applies both).
785 if (nextCrop.aspect || ReactCrop.xyOrds.indexOf(ord) > -1) {
786 nextCrop.x = containedCrop.x;
787 nextCrop.y = containedCrop.y;
788 nextCrop.width = containedCrop.width;
789 nextCrop.height = containedCrop.height;
790 } else if (ReactCrop.xOrds.indexOf(ord) > -1) {
791 nextCrop.x = containedCrop.x;
792 nextCrop.width = containedCrop.width;
793 } else if (ReactCrop.yOrds.indexOf(ord) > -1) {
794 nextCrop.y = containedCrop.y;
795 nextCrop.height = containedCrop.height;
796 }
797
798 evData.lastYCrossover = evData.yCrossOver;
799 this.crossOverCheck();
800
801 // Contain crop can result in crops that are too
802 // small to meet minimums. Fixes #425.
803 if (nextCrop.width < minWidth) {
804 return crop;
805 }
806 if (nextCrop.height < minHeight) {
807 return crop;
808 }
809
810 return nextCrop;
811 }
812
813 getRotatedCursor(handle: string, degrees: number) {
814 if ((degrees > -135 && degrees <= -45) || (degrees > 45 && degrees <= 135)) {
815 // Left and Right
816 switch (handle) {
817 case 'nw':
818 return { cursor: 'ne-resize' };
819 case 'n':
820 return { cursor: 'w-resize' };
821 case 'ne':
822 return { cursor: 'nw-resize' };
823 case 'e':
824 return { cursor: 's-resize' };
825 case 'se':
826 return { cursor: 'sw-resize' };
827 case 's':
828 return { cursor: 'e-resize' };
829 case 'sw':
830 return { cursor: 'se-resize' };
831 case 'w':
832 return { cursor: 'n-resize' };
833 }
834 }
835 }
836
837 createCropSelection() {
838 const { disabled, locked, renderSelectionAddon, ruleOfThirds, crop, spin = 0 } = this.props;
839 const style = this.getCropStyle();
840
841 return (
842 <div style={style} className="ReactCrop__crop-selection" onPointerDown={this.onCropPointerDown}>
843 {!disabled && !locked && (
844 <div className="ReactCrop__drag-elements">
845 <div className="ReactCrop__drag-bar ord-n" data-ord="n" />
846 <div className="ReactCrop__drag-bar ord-e" data-ord="e" />
847 <div className="ReactCrop__drag-bar ord-s" data-ord="s" />
848 <div className="ReactCrop__drag-bar ord-w" data-ord="w" />
849
850 <div className="ReactCrop__drag-handle ord-nw" data-ord="nw" style={this.getRotatedCursor('nw', spin)} />
851 <div className="ReactCrop__drag-handle ord-n" data-ord="n" style={this.getRotatedCursor('n', spin)} />
852 <div className="ReactCrop__drag-handle ord-ne" data-ord="ne" style={this.getRotatedCursor('ne', spin)} />
853 <div className="ReactCrop__drag-handle ord-e" data-ord="e" style={this.getRotatedCursor('e', spin)} />
854 <div className="ReactCrop__drag-handle ord-se" data-ord="se" style={this.getRotatedCursor('se', spin)} />
855 <div className="ReactCrop__drag-handle ord-s" data-ord="s" style={this.getRotatedCursor('s', spin)} />
856 <div className="ReactCrop__drag-handle ord-sw" data-ord="sw" style={this.getRotatedCursor('sw', spin)} />
857 <div className="ReactCrop__drag-handle ord-w" data-ord="w" style={this.getRotatedCursor('w', spin)} />
858 </div>
859 )}
860 {renderSelectionAddon && isCropValid(crop) && (
861 <div className="ReactCrop__selection-addon" onMouseDown={e => e.stopPropagation()}>
862 {renderSelectionAddon(this.state)}
863 </div>
864 )}
865 {ruleOfThirds && (
866 <>
867 <div className="ReactCrop__rule-of-thirds-hz" />
868 <div className="ReactCrop__rule-of-thirds-vt" />
869 </>
870 )}
871 </div>
872 );
873 }
874
875 makeNewCrop(unit = 'px') {
876 const crop = { ...defaultCrop, ...(this.props.crop || {}) };
877 const { width, height } = this.mediaDimensions;
878
879 return unit === 'px' ? convertToPixelCrop(crop, width, height) : convertToPercentCrop(crop, width, height);
880 }
881
882 crossOverCheck() {
883 const { evData } = this;
884 const { minWidth, minHeight } = this.props;
885
886 if (
887 !minWidth &&
888 ((!evData.xCrossOver && -Math.abs(evData.cropStartWidth) - evData.xDiff >= 0) ||
889 (evData.xCrossOver && -Math.abs(evData.cropStartWidth) - evData.xDiff <= 0))
890 ) {
891 evData.xCrossOver = !evData.xCrossOver;
892 }
893
894 if (
895 !minHeight &&
896 ((!evData.yCrossOver && -Math.abs(evData.cropStartHeight) - evData.yDiff >= 0) ||
897 (evData.yCrossOver && -Math.abs(evData.cropStartHeight) - evData.yDiff <= 0))
898 ) {
899 evData.yCrossOver = !evData.yCrossOver;
900 }
901 }
902
903 render() {
904 const {
905 children,
906 circularCrop,
907 className,
908 crossorigin,
909 crop,
910 disabled,
911 imageStyle,
912 locked,
913 imageAlt,
914 onImageError,
915 renderComponent,
916 scale = 1,
917 src,
918 style,
919 rotate = 0,
920 ruleOfThirds,
921 } = this.props;
922
923 const { cropIsActive, newCropIsBeingDrawn } = this.state;
924 const cropSelection = isCropValid(crop) && this.componentRef ? this.createCropSelection() : null;
925
926 const componentClasses = clsx('ReactCrop', className, {
927 'ReactCrop--active': cropIsActive,
928 'ReactCrop--disabled': disabled,
929 'ReactCrop--locked': locked,
930 'ReactCrop--new-crop': newCropIsBeingDrawn,
931 'ReactCrop--fixed-aspect': crop && crop.aspect,
932 'ReactCrop--circular-crop': crop && circularCrop,
933 'ReactCrop--rule-of-thirds': crop && ruleOfThirds,
934 'ReactCrop--invisible-crop': !this.dragStarted && crop && !crop.width && !crop.height,
935 });
936
937 return (
938 <div
939 ref={this.componentRef}
940 className={componentClasses}
941 style={style}
942 onPointerDown={this.onComponentPointerDown}
943 tabIndex={0}
944 onKeyDown={this.onComponentKeyDown}
945 onKeyUp={this.onComponentKeyUp}
946 >
947 <div ref={this.mediaWrapperRef} style={{ transform: `scale(${scale}) rotate(${rotate}deg)` }}>
948 {renderComponent || (
949 <img
950 ref={this.imageRef}
951 crossOrigin={crossorigin}
952 className="ReactCrop__image"
953 style={imageStyle}
954 src={src}
955 onLoad={this.onImageLoad}
956 onError={onImageError}
957 alt={imageAlt}
958 />
959 )}
960 </div>
961 {children}
962 {cropSelection}
963 </div>
964 );
965 }
966}
967
968export { ReactCrop as default, ReactCrop as Component, makeAspectCrop, containCrop };