Crop Area:
@@ -158,13 +299,16 @@ class App extends React.Component<{}, State> {
onCropChange={this.onCropChange}
onRotationChange={this.onRotationChange}
onCropComplete={this.onCropComplete}
- onCropAreaChange={this.onCropComplete}
+ onCropAreaChange={this.onCropAreaChange}
onZoomChange={this.onZoomChange}
onInteractionStart={this.onInteractionStart}
onInteractionEnd={this.onInteractionEnd}
initialCroppedAreaPixels={
- !!urlArgs.setInitialCrop ? { width: 699, height: 524, x: 875, y: 157 } : undefined // used to set the initial crop in e2e test
+ Boolean(urlArgs.setInitialCrop) // used to set the initial crop in e2e test
+ ? { width: 699, height: 524, x: 875, y: 157 }
+ : this.state.initialCroppedAreaPixels
}
+ initialCroppedAreaPercentages={this.state.initialCroppedAreaPercentages}
transform={[
`translate(${this.state.crop.x}px, ${this.state.crop.y}px)`,
`rotateZ(${this.state.rotation}deg)`,
diff --git a/examples/src/styles.css b/examples/src/styles.css
index 031e3ba..b64670b 100644
--- a/examples/src/styles.css
+++ b/examples/src/styles.css
@@ -3,6 +3,11 @@ body {
padding: 0;
}
+input[type='range'] {
+ width: 370px;
+ vertical-align: middle;
+}
+
.App {
position: absolute;
top: 0;
@@ -14,6 +19,13 @@ body {
.controls {
z-index: 1;
position: fixed;
+ pointer-events: none;
+}
+
+.controls input,
+.controls button,
+.controls label {
+ pointer-events: all;
}
.crop-container {
diff --git a/package.json b/package.json
index 0dd507b..c4de884 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "react-easy-crop",
- "version": "3.5.3",
+ "version": "4.0.0",
"description": "A React component to crop images/videos with easy interactions",
"homepage": "https://ricardo-ch.github.io/react-easy-crop/",
"keywords": [
@@ -58,6 +58,7 @@
"@babel/preset-env": "^7.3.4",
"@babel/preset-react": "^7.0.0",
"@types/jest": "^26.0.14",
+ "@types/lodash.debounce": "^4.0.6",
"@types/react": "^16.9.17",
"@types/react-dom": "^16.9.4",
"@typescript-eslint/eslint-plugin": "4.2.0",
@@ -72,6 +73,7 @@
"eslint": "7.9.0",
"eslint-config-prettier": "^6.9.0",
"eslint-config-react-app": "^5.1.0",
+ "eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-flowtype": "5.2.0",
"eslint-plugin-import": "2.x",
"eslint-plugin-jsx-a11y": "6.x",
@@ -81,6 +83,7 @@
"husky": "^4.3.0",
"jest": "26.4.2",
"lint-staged": "^10.4.0",
+ "lodash.debounce": "^4.0.8",
"np": "^6.5.0",
"prettier": "^2.1.2",
"query-string": "^6.1.0",
diff --git a/src/helpers.test.ts b/src/helpers.test.ts
index 71b5ef8..826a5d1 100644
--- a/src/helpers.test.ts
+++ b/src/helpers.test.ts
@@ -25,7 +25,8 @@ describe('Helpers', () => {
})
test('when rotated 90 degrees', () => {
const cropSize = helpers.getCropSize(1800, 600, 1000, 600, 16 / 9, 90)
- expect(cropSize).toEqual({ height: 337.5, width: 600 })
+ expect(cropSize.width).toBeCloseTo(600, 0)
+ expect(cropSize.height).toBeCloseTo(337.5, 1)
})
test('when rotated 90 degrees and container is vertical', () => {
const cropSize = helpers.getCropSize(600, 314, 600, 800, 1000 / 1910, 90)
@@ -180,8 +181,13 @@ describe('Helpers', () => {
const zoom = 1
const rotation = 45
const areas = helpers.computeCroppedArea(crop, imgSize, cropSize, aspect, zoom, rotation)
- expect(areas.croppedAreaPercentages).toEqual({ height: 100, width: 100, x: 0, y: 0 })
- expect(areas.croppedAreaPixels).toEqual({ height: 1200, width: 2000, x: 0, y: 0 })
+ expect(areas.croppedAreaPercentages).toEqual({
+ height: 53.03300858899106,
+ width: 88.38834764831843,
+ x: 5.805826175840782,
+ y: 23.48349570550447,
+ })
+ expect(areas.croppedAreaPixels).toEqual({ height: 1200, width: 2000, x: 131, y: 531 })
})
test('should compute the correct areas when there is a rotation and the media was moved', () => {
@@ -192,8 +198,14 @@ describe('Helpers', () => {
const zoom = 1
const rotation = 45
const areas = helpers.computeCroppedArea(crop, imgSize, cropSize, aspect, zoom, rotation)
- expect(areas.croppedAreaPercentages).toEqual({ height: 100, width: 100, x: -5, y: 0 })
- expect(areas.croppedAreaPixels).toEqual({ height: 1200, width: 2000, x: -100, y: 0 })
+
+ expect(areas.croppedAreaPercentages).toEqual({
+ height: 53.03300858899106,
+ width: 88.38834764831843,
+ x: 1.3864087934248597,
+ y: 23.48349570550447,
+ })
+ expect(areas.croppedAreaPixels).toEqual({ height: 1200, width: 2000, x: 31, y: 531 })
})
})
@@ -201,8 +213,16 @@ describe('Helpers', () => {
test('should compute the correct crop and zoom when the media was not moved and not zoomed', () => {
const croppedAreaPixels = { height: 1200, width: 2000, x: 0, y: 0 }
const imgSize = { width: 1000, height: 600, naturalWidth: 2000, naturalHeight: 1200 }
+ const cropSize = { height: 600, width: 1000 }
- const { crop, zoom } = helpers.getInitialCropFromCroppedAreaPixels(croppedAreaPixels, imgSize)
+ const { crop, zoom } = helpers.getInitialCropFromCroppedAreaPixels(
+ croppedAreaPixels,
+ imgSize,
+ 0,
+ cropSize,
+ 1,
+ 3
+ )
expect(crop).toEqual({ x: 0, y: 0 })
expect(zoom).toEqual(1)
@@ -211,11 +231,19 @@ describe('Helpers', () => {
test('should compute the correct crop and zoom when the media was moved but not zoomed', () => {
const croppedAreaPixels = { height: 1200, width: 1600, x: 100, y: 0 }
const imgSize = { width: 1000, height: 600, naturalWidth: 2000, naturalHeight: 1200 }
+ const cropSize = { width: 800, height: 600 }
- const { crop, zoom } = helpers.getInitialCropFromCroppedAreaPixels(croppedAreaPixels, imgSize)
+ const { crop, zoom } = helpers.getInitialCropFromCroppedAreaPixels(
+ croppedAreaPixels,
+ imgSize,
+ 0,
+ cropSize,
+ 1,
+ 3
+ )
- expect(crop).toEqual({ x: 50, y: 0 })
expect(zoom).toEqual(1)
+ expect(crop).toEqual({ x: 50, y: 0 })
})
test('should compute the correct crop and zoom even when cropSize is used', () => {
@@ -226,7 +254,10 @@ describe('Helpers', () => {
const { crop, zoom } = helpers.getInitialCropFromCroppedAreaPixels(
croppedAreaPixels,
imgSize,
- cropSize
+ 0,
+ cropSize,
+ 1,
+ 3
)
expect(crop.x).toBeCloseTo(237.6, 1)
@@ -237,8 +268,16 @@ describe('Helpers', () => {
test('should compute the correct crop and zoom when there is a zoom', () => {
const croppedAreaPixels = { height: 600, width: 1000, x: 500, y: 300 }
const imgSize = { width: 1000, height: 600, naturalWidth: 2000, naturalHeight: 1200 }
+ const cropSize = { height: 600, width: 1000 }
- const { crop, zoom } = helpers.getInitialCropFromCroppedAreaPixels(croppedAreaPixels, imgSize)
+ const { crop, zoom } = helpers.getInitialCropFromCroppedAreaPixels(
+ croppedAreaPixels,
+ imgSize,
+ 0,
+ cropSize,
+ 1,
+ 3
+ )
expect(crop).toEqual({ x: 0, y: 0 })
expect(zoom).toEqual(2)
@@ -247,8 +286,16 @@ describe('Helpers', () => {
test('should compute the correct crop and zoom even when restrictPosition was false', () => {
const croppedAreaPixels = { height: 1200, width: 2000, x: -2000, y: -1200 }
const imgSize = { width: 1000, height: 600, naturalWidth: 2000, naturalHeight: 1200 }
+ const cropSize = { width: 1000, height: 600 }
- const { crop, zoom } = helpers.getInitialCropFromCroppedAreaPixels(croppedAreaPixels, imgSize)
+ const { crop, zoom } = helpers.getInitialCropFromCroppedAreaPixels(
+ croppedAreaPixels,
+ imgSize,
+ 0,
+ cropSize,
+ 1,
+ 3
+ )
expect(crop).toEqual({ x: 1000, y: 600 })
expect(zoom).toEqual(1)
@@ -292,29 +339,10 @@ describe('Helpers', () => {
expect(center).toEqual(expected)
})
})
- describe('rotateAroundMidPoint', () => {
- test('sould rotate correctly around supplied values', () => {
- expect(helpers.rotateAroundMidPoint(0, 0, 66, 77, 90)).toEqual([143, 11])
- })
- test.each([
- [{ x: 0, y: 0, xMid: 66, yMid: 77, degrees: 9 }, [12.858023328818689, -9.37667691848084]],
- [{ x: 40, y: 0, xMid: 66, yMid: 77, degrees: 99 }, [146.1192983168716, 63.36555695262421]],
- [{ x: 0, y: 40, xMid: 660, yMid: 77, degrees: 88 }, [673.9437927760558, -583.8892272105957]],
- [
- { x: 70, y: 40, xMid: 9, yMid: 737, degrees: 240 },
- [-625.1197064377536, 1032.6724503691496],
- ],
- [
- { x: 40, y: 40, xMid: 636, yMid: 77, degrees: 45 },
- [240.72730931671987, -370.5985924910845],
- ],
- ])('.rotateAroundMidPoint(%s)', ({ x, y, xMid, yMid, degrees }, expected) => {
- expect(helpers.rotateAroundMidPoint(x, y, xMid, yMid, degrees)).toEqual(expected)
- })
- })
- describe('translateSize', () => {
+
+ describe('rotateSize', () => {
test('should return correct bounding area once rotated', () => {
- expect(helpers.translateSize(50, 50, 66)).toEqual({
+ expect(helpers.rotateSize(50, 50, 66)).toEqual({
height: 66.01410503592005,
width: 66.01410503592005,
})
@@ -331,11 +359,11 @@ describe('Helpers', () => {
{ width: 1780, height: 60, rotation: 95 },
{
height: 1778.4559071681665,
- width: 214.90890397633643,
+ width: 214.9089039763364,
},
],
- ])('.translateSize(%s)', ({ width, height, rotation }, expected) => {
- expect(helpers.translateSize(width, height, rotation)).toEqual(expected)
+ ])('.rotateSize(%s)', ({ width, height, rotation }, expected) => {
+ expect(helpers.rotateSize(width, height, rotation)).toEqual(expected)
})
})
})
diff --git a/src/helpers.ts b/src/helpers.ts
index 4fe0226..7c0c3e6 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -12,7 +12,7 @@ export function getCropSize(
aspect: number,
rotation = 0
): Size {
- const { width, height } = translateSize(mediaWidth, mediaHeight, rotation)
+ const { width, height } = rotateSize(mediaWidth, mediaHeight, rotation)
const fittingWidth = Math.min(width, containerWidth)
const fittingHeight = Math.min(height, containerHeight)
@@ -29,6 +29,17 @@ export function getCropSize(
}
}
+/**
+ * Compute media zoom.
+ * We fit the media into the container with "max-width: 100%; max-height: 100%;"
+ */
+export function getMediaZoom(mediaSize: MediaSize) {
+ // Take the axis with more pixels to improve accuracy
+ return mediaSize.width > mediaSize.height
+ ? mediaSize.width / mediaSize.naturalWidth
+ : mediaSize.height / mediaSize.naturalHeight
+}
+
/**
* Ensure a new media position stays in the crop area.
*/
@@ -39,7 +50,7 @@ export function restrictPosition(
zoom: number,
rotation = 0
): Point {
- const { width, height } = translateSize(mediaSize.width, mediaSize.height, rotation)
+ const { width, height } = rotateSize(mediaSize.width, mediaSize.height, rotation)
return {
x: restrictPositionCoord(position.x, width, cropSize.width, zoom),
@@ -54,7 +65,8 @@ function restrictPositionCoord(
zoom: number
): number {
const maxPosition = (mediaSize * zoom) / 2 - cropSize / 2
- return Math.min(maxPosition, Math.max(position, -maxPosition))
+
+ return clamp(position, -maxPosition, maxPosition)
}
export function getDistanceBetweenPoints(pointA: Point, pointB: Point) {
@@ -80,37 +92,46 @@ export function computeCroppedArea(
): { croppedAreaPercentages: Area; croppedAreaPixels: Area } {
// if the media is rotated by the user, we cannot limit the position anymore
// as it might need to be negative.
- const limitAreaFn = restrictPosition && rotation === 0 ? limitArea : noOp
+ const limitAreaFn = restrictPosition ? limitArea : noOp
+
+ const mediaBBoxSize = rotateSize(mediaSize.width, mediaSize.height, rotation)
+ const mediaNaturalBBoxSize = rotateSize(mediaSize.naturalWidth, mediaSize.naturalHeight, rotation)
+
+ // calculate the crop area in percentages
+ // in the rotated space
const croppedAreaPercentages = {
x: limitAreaFn(
100,
- (((mediaSize.width - cropSize.width / zoom) / 2 - crop.x / zoom) / mediaSize.width) * 100
+ (((mediaBBoxSize.width - cropSize.width / zoom) / 2 - crop.x / zoom) / mediaBBoxSize.width) *
+ 100
),
y: limitAreaFn(
100,
- (((mediaSize.height - cropSize.height / zoom) / 2 - crop.y / zoom) / mediaSize.height) * 100
+ (((mediaBBoxSize.height - cropSize.height / zoom) / 2 - crop.y / zoom) /
+ mediaBBoxSize.height) *
+ 100
),
- width: limitAreaFn(100, ((cropSize.width / mediaSize.width) * 100) / zoom),
- height: limitAreaFn(100, ((cropSize.height / mediaSize.height) * 100) / zoom),
+ width: limitAreaFn(100, ((cropSize.width / mediaBBoxSize.width) * 100) / zoom),
+ height: limitAreaFn(100, ((cropSize.height / mediaBBoxSize.height) * 100) / zoom),
}
// we compute the pixels size naively
const widthInPixels = Math.round(
limitAreaFn(
- mediaSize.naturalWidth,
- (croppedAreaPercentages.width * mediaSize.naturalWidth) / 100
+ mediaNaturalBBoxSize.width,
+ (croppedAreaPercentages.width * mediaNaturalBBoxSize.width) / 100
)
)
const heightInPixels = Math.round(
limitAreaFn(
- mediaSize.naturalHeight,
- (croppedAreaPercentages.height * mediaSize.naturalHeight) / 100
+ mediaNaturalBBoxSize.height,
+ (croppedAreaPercentages.height * mediaNaturalBBoxSize.height) / 100
)
)
- const isImgWiderThanHigh = mediaSize.naturalWidth >= mediaSize.naturalHeight * aspect
+ const isImgWiderThanHigh = mediaNaturalBBoxSize.width >= mediaNaturalBBoxSize.height * aspect
// then we ensure the width and height exactly match the aspect (to avoid rounding approximations)
- // if the media is wider than high, when zoom is 0, the crop height will be equals to iamge height
+ // if the media is wider than high, when zoom is 0, the crop height will be equals to image height
// thus we want to compute the width from the height and aspect for accuracy.
// Otherwise, we compute the height from width and aspect.
const sizePixels = isImgWiderThanHigh
@@ -122,21 +143,23 @@ export function computeCroppedArea(
width: widthInPixels,
height: Math.round(widthInPixels / aspect),
}
+
const croppedAreaPixels = {
...sizePixels,
x: Math.round(
limitAreaFn(
- mediaSize.naturalWidth - sizePixels.width,
- (croppedAreaPercentages.x * mediaSize.naturalWidth) / 100
+ mediaNaturalBBoxSize.width - sizePixels.width,
+ (croppedAreaPercentages.x * mediaNaturalBBoxSize.width) / 100
)
),
y: Math.round(
limitAreaFn(
- mediaSize.naturalHeight - sizePixels.height,
- (croppedAreaPercentages.y * mediaSize.naturalHeight) / 100
+ mediaNaturalBBoxSize.height - sizePixels.height,
+ (croppedAreaPercentages.y * mediaNaturalBBoxSize.height) / 100
)
),
}
+
return { croppedAreaPercentages, croppedAreaPixels }
}
@@ -152,46 +175,84 @@ function noOp(_max: number, value: number) {
}
/**
- * Compute the crop and zoom from the croppedAreaPixels
+ * Compute crop and zoom from the croppedAreaPercentages.
+ */
+export function getInitialCropFromCroppedAreaPercentages(
+ croppedAreaPercentages: Area,
+ mediaSize: MediaSize,
+ rotation: number,
+ cropSize: Size,
+ minZoom: number,
+ maxZoom: number
+) {
+ const mediaBBoxSize = rotateSize(mediaSize.width, mediaSize.height, rotation)
+
+ // This is the inverse process of computeCroppedArea
+ const zoom = clamp(
+ (cropSize.width / mediaBBoxSize.width) * (100 / croppedAreaPercentages.width),
+ minZoom,
+ maxZoom
+ )
+
+ const crop = {
+ x:
+ (zoom * mediaBBoxSize.width) / 2 -
+ cropSize.width / 2 -
+ mediaBBoxSize.width * zoom * (croppedAreaPercentages.x / 100),
+ y:
+ (zoom * mediaBBoxSize.height) / 2 -
+ cropSize.height / 2 -
+ mediaBBoxSize.height * zoom * (croppedAreaPercentages.y / 100),
+ }
+
+ return { crop, zoom }
+}
+
+/**
+ * Compute zoom from the croppedAreaPixels
*/
function getZoomFromCroppedAreaPixels(
croppedAreaPixels: Area,
mediaSize: MediaSize,
- cropSize?: Size
+ cropSize: Size
): number {
- const mediaZoom = mediaSize.width / mediaSize.naturalWidth
+ const mediaZoom = getMediaZoom(mediaSize)
- if (cropSize) {
- const isHeightMaxSize = cropSize.height > cropSize.width
- return isHeightMaxSize
- ? cropSize.height / mediaZoom / croppedAreaPixels.height
- : cropSize.width / mediaZoom / croppedAreaPixels.width
- }
-
- const aspect = croppedAreaPixels.width / croppedAreaPixels.height
- const isHeightMaxSize = mediaSize.naturalWidth >= mediaSize.naturalHeight * aspect
- return isHeightMaxSize
- ? mediaSize.naturalHeight / croppedAreaPixels.height
- : mediaSize.naturalWidth / croppedAreaPixels.width
+ return cropSize.height > cropSize.width
+ ? cropSize.height / (croppedAreaPixels.height * mediaZoom)
+ : cropSize.width / (croppedAreaPixels.width * mediaZoom)
}
/**
- * Compute the crop and zoom from the croppedAreaPixels
+ * Compute crop and zoom from the croppedAreaPixels
*/
export function getInitialCropFromCroppedAreaPixels(
croppedAreaPixels: Area,
mediaSize: MediaSize,
- cropSize?: Size
+ rotation = 0,
+ cropSize: Size,
+ minZoom: number,
+ maxZoom: number
): { crop: Point; zoom: number } {
- const mediaZoom = mediaSize.width / mediaSize.naturalWidth
+ const mediaNaturalBBoxSize = rotateSize(mediaSize.naturalWidth, mediaSize.naturalHeight, rotation)
- const zoom = getZoomFromCroppedAreaPixels(croppedAreaPixels, mediaSize, cropSize)
+ const zoom = clamp(
+ getZoomFromCroppedAreaPixels(croppedAreaPixels, mediaSize, cropSize),
+ minZoom,
+ maxZoom
+ )
- const cropZoom = mediaZoom * zoom
+ const cropZoom =
+ cropSize.height > cropSize.width
+ ? cropSize.height / croppedAreaPixels.height
+ : cropSize.width / croppedAreaPixels.width
const crop = {
- x: ((mediaSize.naturalWidth - croppedAreaPixels.width) / 2 - croppedAreaPixels.x) * cropZoom,
- y: ((mediaSize.naturalHeight - croppedAreaPixels.height) / 2 - croppedAreaPixels.y) * cropZoom,
+ x:
+ ((mediaNaturalBBoxSize.width - croppedAreaPixels.width) / 2 - croppedAreaPixels.x) * cropZoom,
+ y:
+ ((mediaNaturalBBoxSize.height - croppedAreaPixels.height) / 2 - croppedAreaPixels.y) *
+ cropZoom,
}
return { crop, zoom }
}
@@ -206,48 +267,27 @@ export function getCenter(a: Point, b: Point): Point {
}
}
+export function getRadianAngle(degreeValue: number) {
+ return (degreeValue * Math.PI) / 180
+}
+
/**
- *
- * Returns an x,y point once rotated around xMid,yMid
+ * Returns the new bounding area of a rotated rectangle.
*/
-export function rotateAroundMidPoint(
- x: number,
- y: number,
- xMid: number,
- yMid: number,
- degrees: number
-): [number, number] {
- const cos = Math.cos
- const sin = Math.sin
- const radian = (degrees * Math.PI) / 180 // Convert to radians
- // Subtract midpoints, so that midpoint is translated to origin
- // and add it in the end again
- const xr = (x - xMid) * cos(radian) - (y - yMid) * sin(radian) + xMid
- const yr = (x - xMid) * sin(radian) + (y - yMid) * cos(radian) + yMid
-
- return [xr, yr]
+export function rotateSize(width: number, height: number, rotation: number): Size {
+ const rotRad = getRadianAngle(rotation)
+
+ return {
+ width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
+ height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height),
+ }
}
/**
- * Returns the new bounding area of a rotated rectangle.
+ * Clamp value between min and max
*/
-export function translateSize(width: number, height: number, rotation: number): Size {
- const centerX = width / 2
- const centerY = height / 2
-
- const outerBounds = [
- rotateAroundMidPoint(0, 0, centerX, centerY, rotation),
- rotateAroundMidPoint(width, 0, centerX, centerY, rotation),
- rotateAroundMidPoint(width, height, centerX, centerY, rotation),
- rotateAroundMidPoint(0, height, centerX, centerY, rotation),
- ]
-
- const minX = Math.min(...outerBounds.map(p => p[0]))
- const maxX = Math.max(...outerBounds.map(p => p[0]))
- const minY = Math.min(...outerBounds.map(p => p[1]))
- const maxY = Math.max(...outerBounds.map(p => p[1]))
-
- return { width: maxX - minX, height: maxY - minY }
+export function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), max)
}
/**
@@ -255,7 +295,7 @@ export function translateSize(width: number, height: number, rotation: number):
*/
export function classNames(...args: (boolean | string | number | undefined | void | null)[]) {
return args
- .filter(value => {
+ .filter((value) => {
if (typeof value === 'string' && value.length > 0) {
return true
}
diff --git a/src/index.tsx b/src/index.tsx
index 7471683..2a25174 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -9,7 +9,9 @@ import {
computeCroppedArea,
getCenter,
getInitialCropFromCroppedAreaPixels,
+ getInitialCropFromCroppedAreaPercentages,
classNames,
+ clamp,
} from './helpers'
import cssStyles from './styles.css'
@@ -49,9 +51,10 @@ export type CropperProps = {
cropAreaClassName?: string
}
restrictPosition: boolean
- initialCroppedAreaPixels?: Area
mediaProps: React.ImgHTMLAttributes | React.VideoHTMLAttributes
disableAutomaticStylesInjection?: boolean
+ initialCroppedAreaPixels?: Area
+ initialCroppedAreaPercentages?: Area
}
type State = {
@@ -183,29 +186,44 @@ class Cropper extends React.Component {
}
onMediaLoad = () => {
- this.computeSizes()
- this.emitCropData()
- this.setInitialCrop()
+ const cropSize = this.computeSizes()
+
+ if (cropSize) {
+ this.emitCropData()
+ this.setInitialCrop(cropSize)
+ }
if (this.props.onMediaLoaded) {
this.props.onMediaLoaded(this.mediaSize)
}
}
- setInitialCrop = () => {
- const { initialCroppedAreaPixels, cropSize } = this.props
-
- if (!initialCroppedAreaPixels) {
- return
+ setInitialCrop = (cropSize: Size) => {
+ if (this.props.initialCroppedAreaPercentages) {
+ const { crop, zoom } = getInitialCropFromCroppedAreaPercentages(
+ this.props.initialCroppedAreaPercentages,
+ this.mediaSize,
+ this.props.rotation,
+ cropSize,
+ this.props.minZoom,
+ this.props.maxZoom
+ )
+
+ this.props.onCropChange(crop)
+ this.props.onZoomChange && this.props.onZoomChange(zoom)
+ } else if (this.props.initialCroppedAreaPixels) {
+ const { crop, zoom } = getInitialCropFromCroppedAreaPixels(
+ this.props.initialCroppedAreaPixels,
+ this.mediaSize,
+ this.props.rotation,
+ cropSize,
+ this.props.minZoom,
+ this.props.maxZoom
+ )
+
+ this.props.onCropChange(crop)
+ this.props.onZoomChange && this.props.onZoomChange(zoom)
}
-
- const { crop, zoom } = getInitialCropFromCroppedAreaPixels(
- initialCroppedAreaPixels,
- this.mediaSize,
- cropSize
- )
- this.props.onCropChange(crop)
- this.props.onZoomChange && this.props.onZoomChange(zoom)
}
getAspect() {
@@ -218,20 +236,70 @@ class Cropper extends React.Component {
computeSizes = () => {
const mediaRef = this.imageRef || this.videoRef
+
if (mediaRef && this.containerRef) {
this.containerRect = this.containerRef.getBoundingClientRect()
+ const containerAspect = this.containerRect.width / this.containerRect.height
+ const naturalWidth = this.imageRef?.naturalWidth || this.videoRef?.videoWidth || 0
+ const naturalHeight = this.imageRef?.naturalHeight || this.videoRef?.videoHeight || 0
+ const isMediaScaledDown =
+ mediaRef.offsetWidth < naturalWidth || mediaRef.offsetHeight < naturalHeight
+ const mediaAspect = naturalWidth / naturalHeight
+
+ // We do not rely on the offsetWidth/offsetHeight if the media is scaled down
+ // as the values they report are rounded. That will result in precision losses
+ // when calculating zoom. We use the fact that the media is positionned relative
+ // to the container. That allows us to use the container's dimensions
+ // and natural aspect ratio of the media to calculate accurate media size.
+ // However, for this to work, the container should not be rotated
+ let renderedMediaSize: Size
+
+ if (isMediaScaledDown) {
+ switch (this.props.objectFit) {
+ default:
+ case 'contain':
+ renderedMediaSize =
+ containerAspect > mediaAspect
+ ? {
+ width: this.containerRect.height * mediaAspect,
+ height: this.containerRect.height,
+ }
+ : {
+ width: this.containerRect.width,
+ height: this.containerRect.width / mediaAspect,
+ }
+ break
+ case 'horizontal-cover':
+ renderedMediaSize = {
+ width: this.containerRect.width,
+ height: this.containerRect.width / mediaAspect,
+ }
+ break
+ case 'vertical-cover':
+ renderedMediaSize = {
+ width: this.containerRect.height * mediaAspect,
+ height: this.containerRect.height,
+ }
+ break
+ }
+ } else {
+ renderedMediaSize = {
+ width: mediaRef.offsetWidth,
+ height: mediaRef.offsetHeight,
+ }
+ }
this.mediaSize = {
- width: mediaRef.offsetWidth,
- height: mediaRef.offsetHeight,
- naturalWidth: this.imageRef?.naturalWidth || this.videoRef?.videoWidth || 0,
- naturalHeight: this.imageRef?.naturalHeight || this.videoRef?.videoHeight || 0,
+ ...renderedMediaSize,
+ naturalWidth,
+ naturalHeight,
}
+
const cropSize = this.props.cropSize
? this.props.cropSize
: getCropSize(
- mediaRef.offsetWidth,
- mediaRef.offsetHeight,
+ this.mediaSize.width,
+ this.mediaSize.height,
this.containerRect.width,
this.containerRect.height,
this.props.aspect,
@@ -245,6 +313,8 @@ class Cropper extends React.Component {
this.props.onCropSizeChange && this.props.onCropSizeChange(cropSize)
}
this.setState({ cropSize }, this.recomputeCropPosition)
+
+ return cropSize
}
}
@@ -396,7 +466,7 @@ class Cropper extends React.Component {
const zoomPoint = this.getPointOnContainer(point)
const zoomTarget = this.getPointOnMedia(zoomPoint)
- const newZoom = Math.min(this.props.maxZoom, Math.max(zoom, this.props.minZoom))
+ const newZoom = clamp(zoom, this.props.minZoom, this.props.maxZoom)
const requestedPosition = {
x: zoomTarget.x * newZoom - zoomPoint.x,
y: zoomTarget.y * newZoom - zoomPoint.y,
@@ -477,6 +547,7 @@ class Cropper extends React.Component {
this.props.rotation
)
: this.props.crop
+
this.props.onCropChange(newPosition)
this.emitCropData()
}
diff --git a/yarn.lock b/yarn.lock
index 58b51f5..20e4f55 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1572,6 +1572,18 @@
dependencies:
"@types/node" "*"
+"@types/lodash.debounce@^4.0.6":
+ version "4.0.6"
+ resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz#c5a2326cd3efc46566c47e4c0aa248dc0ee57d60"
+ integrity sha512-4WTmnnhCfDvvuLMaF3KV4Qfki93KebocUF45msxhYyjMttZDQYzHkO639ohhk8+oco2cluAFL3t5+Jn4mleylQ==
+ dependencies:
+ "@types/lodash" "*"
+
+"@types/lodash@*":
+ version "4.14.177"
+ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.177.tgz#f70c0d19c30fab101cad46b52be60363c43c4578"
+ integrity sha512-0fDwydE2clKe9MNfvXHBHF9WEahRuj+msTuQqOmAApNORFvhMYZKNGGJdCzuhheVjMps/ti0Ak/iJPACMaevvw==
+
"@types/minimatch@*":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
@@ -4482,6 +4494,13 @@ eslint-module-utils@^2.6.0:
debug "^2.6.9"
pkg-dir "^2.0.0"
+eslint-plugin-cypress@^2.12.1:
+ version "2.12.1"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-cypress/-/eslint-plugin-cypress-2.12.1.tgz#9aeee700708ca8c058e00cdafe215199918c2632"
+ integrity sha512-c2W/uPADl5kospNDihgiLc7n87t5XhUbFDoTl6CfVkmG+kDAb5Ux10V9PoLPu9N+r7znpc+iQlcmAqT1A/89HA==
+ dependencies:
+ globals "^11.12.0"
+
eslint-plugin-flowtype@5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-5.2.0.tgz#a4bef5dc18f9b2bdb41569a4ab05d73805a3d261"
@@ -5402,7 +5421,7 @@ global-prefix@^3.0.0:
kind-of "^6.0.2"
which "^1.3.1"
-globals@^11.1.0:
+globals@^11.1.0, globals@^11.12.0:
version "11.12.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
@@ -7332,6 +7351,11 @@ lodash.camelcase@^4.3.0:
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
+lodash.debounce@^4.0.8:
+ version "4.0.8"
+ resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
+ integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
+
lodash.memoize@4.x, lodash.memoize@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
@@ -10974,9 +10998,9 @@ tmp@~0.2.1:
rimraf "^3.0.0"
tmpl@1.0.x:
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
- integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"
+ integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==
to-arraybuffer@^1.0.0:
version "1.0.1"