This commit is contained in:
freedakgmail
2025-11-22 23:49:24 +08:00
parent b83382b604
commit c033e46782
3580 changed files with 1745279 additions and 2428 deletions
@@ -0,0 +1,7 @@
## ISC License
Copyright (c) 2015, Dominic Tobias (https://github.com/dominictobias)
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,284 @@
# React Image Crop
An image cropping tool for React with no dependencies.
[![React Image Crop on NPM](https://img.shields.io/npm/v/react-image-crop.svg)](https://www.npmjs.com/package/react-image-crop)
[CodeSandbox Demo](https://codesandbox.io/s/react-image-crop-demo-with-react-hooks-y831o)
![ReactCrop GIF](https://raw.githubusercontent.com/dominictobias/react-image-crop/master/crop-demo.gif)
## Table of Contents
1. [Features](#features)
2. [Installation](#installation)
3. [Usage](#usage)
4. [Example](#example)
5. [CDN](#cdn)
6. [Props](#props)
7. [FAQ](#faq)
1. [How can I generate a crop preview in the browser?](#how-can-i-generate-a-crop-preview-in-the-browser)
2. [How to correct image EXIF orientation/rotation?](#how-to-correct-image-exif-orientationrotation)
3. [How to filter, rotate and annotate?](#how-to-filter-rotate-and-annotate)
4. [How can I center the crop?](#how-can-i-center-the-crop)
8. [Contributing / Developing](#contributing--developing)
## Features
- Responsive (you can use pixels or percentages).
- Touch enabled.
- Free-form or fixed aspect crops.
- Fully keyboard accessible (a11y).
- No dependencies/small footprint (<5KB gzip).
- Min/max crop size.
- Crop anything, not just images.
If React Crop doesn't cover your requirements then take a look at [Pintura](https://pqina.nl/pintura/?ref=react-image-crop) (our sponsor). It features cropping, rotating, filtering, annotation, and lots more.
[Learn more about Pintura here](https://pqina.nl/pintura/?ref=react-image-crop)
## Installation
```
npm i react-image-crop --save
yarn add react-image-crop
pnpm add react-image-crop
```
This library works with all modern browsers. It does not work with IE.
## Usage
Include the main js module:
```js
import ReactCrop from 'react-image-crop'
```
Include either `dist/ReactCrop.css` or `ReactCrop.scss`.
```js
import 'react-image-crop/dist/ReactCrop.css'
// or scss:
import 'react-image-crop/src/ReactCrop.scss'
```
## Example
```tsx
import ReactCrop, { type Crop } from 'react-image-crop'
function CropDemo({ src }) {
const [crop, setCrop] = useState<Crop>()
return (
<ReactCrop crop={crop} onChange={c => setCrop(c)}>
<img src={src} />
</ReactCrop>
)
}
```
See the [sandbox demo](https://codesandbox.io/s/react-image-crop-demo-with-react-hooks-y831o) for a more complete example.
## CDN
```html
<link href="https://unpkg.com/react-image-crop/dist/ReactCrop.css" rel="stylesheet" />
<script src="https://unpkg.com/react-image-crop/dist/index.umd.cjs"></script>
```
Note when importing the script globally using a `<script>` tag access the component with `ReactCrop.Component`.
## Props
**`onChange: (crop: PixelCrop, percentCrop: PercentCrop) => void`**
A callback which happens for every change of the crop (i.e. many times as you are dragging/resizing). Passes the current crop state object.
Note you _must_ implement this callback and update your crop state, otherwise nothing will change!
```tsx
<ReactCrop crop={crop} onChange={(crop, percentCrop) => setCrop(crop)} />
```
`crop` and `percentCrop` are interchangeable. `crop` uses pixels and `percentCrop` uses percentages to position and size itself. Percent crops are resistant to image/media resizing.
**`crop?: Crop`**
Starting with no crop:
```tsx
const [crop, setCrop] = useState<Crop>()
<ReactCrop crop={crop} onChange={c => setCrop(c)}>
<img src={src} />
</ReactCrop>
```
Starting with a preselected crop:
```tsx
const [crop, setCrop] = useState<Crop>({
unit: '%', // Can be 'px' or '%'
x: 25,
y: 25,
width: 50,
height: 50
})
<ReactCrop crop={crop} onChange={c => setCrop(c)}>
<img src={src} />
</ReactCrop>
```
⚠️ You must ensure the crop is in bounds and correct to the aspect ratio if manually setting. Aspect ratios can be tricky when using %. You can make use of `centerCrop` and `makeAspectCrop` helpers. See [How can I center the crop?](#how-can-i-center-the-crop) or the [CodeSanbox Demo](https://codesandbox.io/s/react-image-crop-demo-with-react-hooks-y831o) for examples.
**`aspect?: number`**
The aspect ratio of the crop, e.g. `1` for a square or `16 / 9` for landscape. Omit/pass undefined for a free-form crop.
**`minWidth?: number`**
A minimum crop width, in pixels.
**`minHeight?: number`**
A minimum crop height, in pixels.
**`maxWidth?: number`**
A maximum crop width, in pixels.
**`maxHeight?: number`**
A maximum crop height, in pixels.
**`keepSelection?: boolean`**
If true is passed then selection can't be disabled if the user clicks outside the selection area.
**`disabled?: boolean`**
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.
**`locked?: boolean`**
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.
**`className?: string`**
A string of classes to add to the main `ReactCrop` element.
**`style?: React.CSSProperties`**
Inline styles object to be passed to the image wrapper element.
**`onComplete?: (crop: PixelCrop, percentCrop: PercentCrop) => void`**
A callback which happens after a resize, drag, or nudge. Passes the current crop state object.
`percentCrop` is the crop as a percentage. A typical use case for it would be to save it so that the user's crop can be restored regardless of the size of the image (for example saving it on desktop, and then using it on a mobile where the image is smaller).
**`onDragStart?: (e: PointerEvent) => void`**
A callback which happens when a user starts dragging or resizing. It is convenient to manipulate elements outside this component.
**`onDragEnd?: (e: PointerEvent) => void`**
A callback which happens when a user releases the cursor or touch after dragging or resizing.
**`renderSelectionAddon?: (state: ReactCropState) => React.ReactNode`**
Render a custom element inside the crop selection.
**`ruleOfThirds?: boolean`**
Show [rule of thirds](https://en.wikipedia.org/wiki/Rule_of_thirds) lines in the cropped area. Defaults to `false`.
**`circularCrop?: boolean`**
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`.
## FAQ
### How can I generate a crop preview in the browser?
This isn't part of the library but there is an example over here [CodeSandbox Demo](https://codesandbox.io/s/react-image-crop-demo-with-react-hooks-y831o).
### How to correct image EXIF orientation/rotation?
You might find that some images are rotated incorrectly. Unfortunately this is a browser wide issue not related to this library. You need to fix your image before passing it in.
You can use the following library to load images, which will correct the rotation for you: https://github.com/blueimp/JavaScript-Load-Image/
You can read an issue on this subject here: https://github.com/dominictobias/react-image-crop/issues/181
If you're looking for a complete out of the box image editor which already handles EXIF rotation then consider using [Pintura](https://pqina.nl/pintura/?ref=react-image-crop).
<h3>How to filter, rotate and annotate?</h3>
This library is deliberately lightweight and minimal for you to build features on top of. If you wish to perform more advanced image editing out of the box then consider using [Pintura](https://pqina.nl/pintura/?ref=react-image-crop).
![Pintura Demo](https://raw.githubusercontent.com/DominicTobias/react-image-crop/master/doka-demo.gif)
### How can I center the crop?
The easiest way is to use the percentage unit:
```js
crop: {
unit: '%',
width: 50,
height: 50,
x: 25,
y: 25
}
```
Centering an aspect ratio crop is trickier especially when dealing with `%`. However two helper functions are provided:
1. Listen to the load event of your media to get its size:
```jsx
<ReactCrop crop={crop} aspect={16 / 9}>
<img src={src} onLoad={onImageLoad} />
</ReactCrop>
```
2. Use `makeAspectCrop` to create your desired aspect and then `centerCrop` to center it:
```js
function onImageLoad(e) {
const { naturalWidth: width, naturalHeight: height } = e.currentTarget
const crop = centerCrop(
makeAspectCrop(
{
// You don't need to pass a complete crop into
// makeAspectCrop or centerCrop.
unit: '%',
width: 90,
},
16 / 9,
width,
height
),
width,
height
)
setCrop(crop)
}
```
Also remember to set your crop using the percentCrop on changes:
```js
const onCropChange = (crop, percentCrop) => setCrop(percentCrop)
```
And your `aspect` prop should be set to the same value: `<ReactCrop aspect={16 / 9} ... />`.
## Contributing / Developing
To develop run `pnpm install && pnpm dev` and open the localhost server in your browser. Update code and it will reload. When you're ready, open a pull request.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,202 @@
import { default as default_2 } from 'react';
import { PureComponent } from 'react';
export declare const areCropsEqual: (cropA: Partial<Crop>, cropB: Partial<Crop>) => boolean;
export declare function centerCrop(crop: Pick<PercentCrop, 'unit'> & Partial<Omit<PercentCrop, 'unit'>>, containerWidth: number, containerHeight: number): PercentCrop;
export declare function centerCrop(crop: Pick<PixelCrop, 'unit'> & Partial<Omit<PixelCrop, 'unit'>>, containerWidth: number, containerHeight: number): PixelCrop;
export declare const clamp: (num: number, min: number, max: number) => number;
export declare const cls: (...args: unknown[]) => string;
export declare function containCrop(pixelCrop: PixelCrop, aspect: number, ord: Ords, containerWidth: number, containerHeight: number, minWidth?: number, minHeight?: number, maxWidth?: number, maxHeight?: number): {
unit: "px";
x: number;
y: number;
width: number;
height: number;
};
export declare function convertToPercentCrop(crop: Partial<Crop>, containerWidth: number, containerHeight: number): PercentCrop;
export declare function convertToPixelCrop(crop: Partial<Crop>, containerWidth: number, containerHeight: number): PixelCrop;
export declare interface Crop {
x: number;
y: number;
width: number;
height: number;
unit: 'px' | '%';
}
export declare const defaultCrop: PixelCrop;
declare interface EVData {
startClientX: number;
startClientY: number;
startCropX: number;
startCropY: number;
clientX: number;
clientY: number;
isResize: boolean;
ord?: Ords;
}
export declare function makeAspectCrop(crop: Pick<PercentCrop, 'unit'> & Partial<Omit<PercentCrop, 'unit'>>, aspect: number, containerWidth: number, containerHeight: number): PercentCrop;
export declare function makeAspectCrop(crop: Pick<PixelCrop, 'unit'> & Partial<Omit<PixelCrop, 'unit'>>, aspect: number, containerWidth: number, containerHeight: number): PixelCrop;
export declare function nudgeCrop(pixelCrop: PixelCrop, key: string, offset: number, ord: Ords): {
unit: "px";
x: number;
y: number;
width: number;
height: number;
};
export declare type Ords = XOrds | YOrds | XYOrds;
export declare interface PercentCrop extends Crop {
unit: '%';
}
export declare interface PixelCrop extends Crop {
unit: 'px';
}
declare class ReactCrop extends PureComponent<ReactCropProps, ReactCropState> {
static xOrds: string[];
static yOrds: string[];
static xyOrds: string[];
static nudgeStep: number;
static nudgeStepMedium: number;
static nudgeStepLarge: number;
static defaultProps: {
ariaLabels: {
cropArea: string;
nwDragHandle: string;
nDragHandle: string;
neDragHandle: string;
eDragHandle: string;
seDragHandle: string;
sDragHandle: string;
swDragHandle: string;
wDragHandle: string;
};
};
get document(): Document;
docMoveBound: boolean;
mouseDownOnCrop: boolean;
dragStarted: boolean;
evData: EVData;
componentRef: default_2.RefObject<HTMLDivElement | null>;
mediaRef: default_2.RefObject<HTMLDivElement | null>;
resizeObserver?: ResizeObserver;
initChangeCalled: boolean;
instanceId: string;
state: ReactCropState;
getBox(): Rectangle;
componentDidUpdate(prevProps: ReactCropProps): void;
componentWillUnmount(): void;
bindDocMove(): void;
unbindDocMove(): void;
onCropPointerDown: (e: default_2.PointerEvent<HTMLDivElement>) => void;
onComponentPointerDown: (e: default_2.PointerEvent<HTMLDivElement>) => void;
onDocPointerMove: (e: PointerEvent) => void;
onComponentKeyDown: (e: default_2.KeyboardEvent<HTMLDivElement>) => void;
onHandlerKeyDown: (e: default_2.KeyboardEvent<HTMLDivElement>, ord: Ords) => void;
onDocPointerDone: (e: PointerEvent) => void;
onDragFocus: () => void;
getCropStyle(): {
top: string;
left: string;
width: string;
height: string;
} | undefined;
dragCrop(): PixelCrop;
getPointRegion(box: Rectangle, origOrd: Ords | undefined, minWidth: number, minHeight: number): XYOrds;
resolveMinDimensions(box: Rectangle, aspect: number, minWidth?: number, minHeight?: number): number[];
resizeCrop(): PixelCrop;
renderCropSelection(): default_2.JSX.Element | undefined;
makePixelCrop(box: Rectangle): PixelCrop;
render(): default_2.JSX.Element;
}
export { ReactCrop as Component }
export { ReactCrop }
export default ReactCrop;
export declare interface ReactCropProps {
/** An object of labels to override the built-in English ones */
ariaLabels?: {
cropArea: string;
nwDragHandle: string;
nDragHandle: string;
neDragHandle: string;
eDragHandle: string;
seDragHandle: string;
sDragHandle: string;
swDragHandle: string;
wDragHandle: string;
};
/** The aspect ratio of the crop, e.g. `1` for a square or `16 / 9` for landscape. */
aspect?: number;
/** Classes to pass to the `ReactCrop` element. */
className?: string;
/** The elements that you want to perform a crop on. For example
* an image or video. */
children?: default_2.ReactNode;
/** 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. */
circularCrop?: boolean;
/** Since v10 all crop params are required except for aspect. Omit the entire crop object if you don't want a crop. See README on how to create an aspect crop with a % crop. */
crop?: Crop;
/** 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. */
disabled?: boolean;
/** 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. */
locked?: boolean;
/** If true is passed then selection can't be disabled if the user clicks outside the selection area. */
keepSelection?: boolean;
/** A minimum crop width, in pixels. */
minWidth?: number;
/** A minimum crop height, in pixels. */
minHeight?: number;
/** A maximum crop width, in pixels. */
maxWidth?: number;
/** A maximum crop height, in pixels. */
maxHeight?: number;
/** 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. */
onChange: (crop: PixelCrop, percentageCrop: PercentCrop) => void;
/** A callback which happens after a resize, drag, or nudge. Passes the current crop state object in pixels and percent. */
onComplete?: (crop: PixelCrop, percentageCrop: PercentCrop) => void;
/** A callback which happens when a user starts dragging or resizing. It is convenient to manipulate elements outside this component. */
onDragStart?: (e: PointerEvent) => void;
/** A callback which happens when a user releases the cursor or touch after dragging or resizing. */
onDragEnd?: (e: PointerEvent) => void;
/** Render a custom element in crop selection. */
renderSelectionAddon?: (state: ReactCropState) => default_2.ReactNode;
/** Show rule of thirds lines in the cropped area. Defaults to false. */
ruleOfThirds?: boolean;
/** Inline styles object to be passed to the `ReactCrop` element. */
style?: default_2.CSSProperties;
}
export declare interface ReactCropState {
cropIsActive: boolean;
newCropIsBeingDrawn: boolean;
}
declare interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
export declare type XOrds = 'e' | 'w';
export declare type XYOrds = 'nw' | 'ne' | 'se' | 'sw';
export declare type YOrds = 'n' | 's';
export { }
@@ -0,0 +1,459 @@
var _ = Object.defineProperty;
var $ = (a, h, e) => h in a ? _(a, h, { enumerable: !0, configurable: !0, writable: !0, value: e }) : a[h] = e;
var m = (a, h, e) => $(a, typeof h != "symbol" ? h + "" : h, e);
import u, { PureComponent as K, createRef as P } from "react";
const E = {
x: 0,
y: 0,
width: 0,
height: 0,
unit: "px"
}, b = (a, h, e) => Math.min(Math.max(a, h), e), H = (...a) => a.filter((h) => h && typeof h == "string").join(" "), X = (a, h) => a === h || a.width === h.width && a.height === h.height && a.x === h.x && a.y === h.y && a.unit === h.unit;
function B(a, h, e, n) {
const t = D(a, e, n);
return a.width && (t.height = t.width / h), a.height && (t.width = t.height * h), t.y + t.height > n && (t.height = n - t.y, t.width = t.height * h), t.x + t.width > e && (t.width = e - t.x, t.height = t.width / h), a.unit === "%" ? v(t, e, n) : t;
}
function L(a, h, e) {
const n = D(a, h, e);
return n.x = (h - n.width) / 2, n.y = (e - n.height) / 2, a.unit === "%" ? v(n, h, e) : n;
}
function v(a, h, e) {
return a.unit === "%" ? { ...E, ...a, unit: "%" } : {
unit: "%",
x: a.x ? a.x / h * 100 : 0,
y: a.y ? a.y / e * 100 : 0,
width: a.width ? a.width / h * 100 : 0,
height: a.height ? a.height / e * 100 : 0
};
}
function D(a, h, e) {
return a.unit ? a.unit === "px" ? { ...E, ...a, unit: "px" } : {
unit: "px",
x: a.x ? a.x * h / 100 : 0,
y: a.y ? a.y * e / 100 : 0,
width: a.width ? a.width * h / 100 : 0,
height: a.height ? a.height * e / 100 : 0
} : { ...E, ...a, unit: "px" };
}
function k(a, h, e, n, t, d = 0, r = 0, o = n, w = t) {
const i = { ...a };
let s = Math.min(d, n), c = Math.min(r, t), g = Math.min(o, n), p = Math.min(w, t);
h && (h > 1 ? (s = r ? r * h : s, c = s / h, g = o * h) : (c = d ? d / h : c, s = c * h, p = w / h)), i.y < 0 && (i.height = Math.max(i.height + i.y, c), i.y = 0), i.x < 0 && (i.width = Math.max(i.width + i.x, s), i.x = 0);
const l = n - (i.x + i.width);
l < 0 && (i.x = Math.min(i.x, n - s), i.width += l);
const C = t - (i.y + i.height);
if (C < 0 && (i.y = Math.min(i.y, t - c), i.height += C), i.width < s && ((e === "sw" || e == "nw") && (i.x -= s - i.width), i.width = s), i.height < c && ((e === "nw" || e == "ne") && (i.y -= c - i.height), i.height = c), i.width > g && ((e === "sw" || e == "nw") && (i.x -= g - i.width), i.width = g), i.height > p && ((e === "nw" || e == "ne") && (i.y -= p - i.height), i.height = p), h) {
const y = i.width / i.height;
if (y < h) {
const f = Math.max(i.width / h, c);
(e === "nw" || e == "ne") && (i.y -= f - i.height), i.height = f;
} else if (y > h) {
const f = Math.max(i.height * h, s);
(e === "sw" || e == "nw") && (i.x -= f - i.width), i.width = f;
}
}
return i;
}
function I(a, h, e, n) {
const t = { ...a };
return h === "ArrowLeft" ? n === "nw" ? (t.x -= e, t.y -= e, t.width += e, t.height += e) : n === "w" ? (t.x -= e, t.width += e) : n === "sw" ? (t.x -= e, t.width += e, t.height += e) : n === "ne" ? (t.y += e, t.width -= e, t.height -= e) : n === "e" ? t.width -= e : n === "se" && (t.width -= e, t.height -= e) : h === "ArrowRight" && (n === "nw" ? (t.x += e, t.y += e, t.width -= e, t.height -= e) : n === "w" ? (t.x += e, t.width -= e) : n === "sw" ? (t.x += e, t.width -= e, t.height -= e) : n === "ne" ? (t.y -= e, t.width += e, t.height += e) : n === "e" ? t.width += e : n === "se" && (t.width += e, t.height += e)), h === "ArrowUp" ? n === "nw" ? (t.x -= e, t.y -= e, t.width += e, t.height += e) : n === "n" ? (t.y -= e, t.height += e) : n === "ne" ? (t.y -= e, t.width += e, t.height += e) : n === "sw" ? (t.x += e, t.width -= e, t.height -= e) : n === "s" ? t.height -= e : n === "se" && (t.width -= e, t.height -= e) : h === "ArrowDown" && (n === "nw" ? (t.x += e, t.y += e, t.width -= e, t.height -= e) : n === "n" ? (t.y += e, t.height -= e) : n === "ne" ? (t.y += e, t.width -= e, t.height -= e) : n === "sw" ? (t.x -= e, t.width += e, t.height += e) : n === "s" ? t.height += e : n === "se" && (t.width += e, t.height += e)), t;
}
const M = { capture: !0, passive: !1 };
let N = 0;
const x = class x extends K {
constructor() {
super(...arguments);
m(this, "docMoveBound", !1);
m(this, "mouseDownOnCrop", !1);
m(this, "dragStarted", !1);
m(this, "evData", {
startClientX: 0,
startClientY: 0,
startCropX: 0,
startCropY: 0,
clientX: 0,
clientY: 0,
isResize: !0
});
m(this, "componentRef", P());
m(this, "mediaRef", P());
m(this, "resizeObserver");
m(this, "initChangeCalled", !1);
m(this, "instanceId", `rc-${N++}`);
m(this, "state", {
cropIsActive: !1,
newCropIsBeingDrawn: !1
});
m(this, "onCropPointerDown", (e) => {
const { crop: n, disabled: t } = this.props, d = this.getBox();
if (!n)
return;
const r = D(n, d.width, d.height);
if (t)
return;
e.cancelable && e.preventDefault(), this.bindDocMove(), this.componentRef.current.focus({ preventScroll: !0 });
const o = e.target.dataset.ord, w = !!o;
let i = e.clientX, s = e.clientY, c = r.x, g = r.y;
if (o) {
const p = e.clientX - d.x, l = e.clientY - d.y;
let C = 0, y = 0;
o === "ne" || o == "e" ? (C = p - (r.x + r.width), y = l - r.y, c = r.x, g = r.y + r.height) : o === "se" || o === "s" ? (C = p - (r.x + r.width), y = l - (r.y + r.height), c = r.x, g = r.y) : o === "sw" || o == "w" ? (C = p - r.x, y = l - (r.y + r.height), c = r.x + r.width, g = r.y) : (o === "nw" || o == "n") && (C = p - r.x, y = l - r.y, c = r.x + r.width, g = r.y + r.height), i = c + d.x + C, s = g + d.y + y;
}
this.evData = {
startClientX: i,
startClientY: s,
startCropX: c,
startCropY: g,
clientX: e.clientX,
clientY: e.clientY,
isResize: w,
ord: o
}, this.mouseDownOnCrop = !0, this.setState({ cropIsActive: !0 });
});
m(this, "onComponentPointerDown", (e) => {
const { crop: n, disabled: t, locked: d, keepSelection: r, onChange: o } = this.props, w = this.getBox();
if (t || d || r && n)
return;
e.cancelable && e.preventDefault(), this.bindDocMove(), this.componentRef.current.focus({ preventScroll: !0 });
const i = e.clientX - w.x, s = e.clientY - w.y, c = {
unit: "px",
x: i,
y: s,
width: 0,
height: 0
};
this.evData = {
startClientX: e.clientX,
startClientY: e.clientY,
startCropX: i,
startCropY: s,
clientX: e.clientX,
clientY: e.clientY,
isResize: !0
}, this.mouseDownOnCrop = !0, o(D(c, w.width, w.height), v(c, w.width, w.height)), this.setState({ cropIsActive: !0, newCropIsBeingDrawn: !0 });
});
m(this, "onDocPointerMove", (e) => {
const { crop: n, disabled: t, onChange: d, onDragStart: r } = this.props, o = this.getBox();
if (t || !n || !this.mouseDownOnCrop)
return;
e.cancelable && e.preventDefault(), this.dragStarted || (this.dragStarted = !0, r && r(e));
const { evData: w } = this;
w.clientX = e.clientX, w.clientY = e.clientY;
let i;
w.isResize ? i = this.resizeCrop() : i = this.dragCrop(), X(n, i) || d(
D(i, o.width, o.height),
v(i, o.width, o.height)
);
});
m(this, "onComponentKeyDown", (e) => {
const { crop: n, disabled: t, onChange: d, onComplete: r } = this.props;
if (t)
return;
const o = e.key;
let w = !1;
if (!n)
return;
const i = this.getBox(), s = this.makePixelCrop(i), g = (navigator.platform.match("Mac") ? e.metaKey : e.ctrlKey) ? x.nudgeStepLarge : e.shiftKey ? x.nudgeStepMedium : x.nudgeStep;
if (o === "ArrowLeft" ? (s.x -= g, w = !0) : o === "ArrowRight" ? (s.x += g, w = !0) : o === "ArrowUp" ? (s.y -= g, w = !0) : o === "ArrowDown" && (s.y += g, w = !0), w) {
e.cancelable && e.preventDefault(), s.x = b(s.x, 0, i.width - s.width), s.y = b(s.y, 0, i.height - s.height);
const p = D(s, i.width, i.height), l = v(s, i.width, i.height);
d(p, l), r && r(p, l);
}
});
m(this, "onHandlerKeyDown", (e, n) => {
const {
aspect: t = 0,
crop: d,
disabled: r,
minWidth: o = 0,
minHeight: w = 0,
maxWidth: i,
maxHeight: s,
onChange: c,
onComplete: g
} = this.props, p = this.getBox();
if (r || !d)
return;
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "ArrowLeft" || e.key === "ArrowRight")
e.stopPropagation(), e.preventDefault();
else
return;
const C = (navigator.platform.match("Mac") ? e.metaKey : e.ctrlKey) ? x.nudgeStepLarge : e.shiftKey ? x.nudgeStepMedium : x.nudgeStep, y = D(d, p.width, p.height), f = I(y, e.key, C, n), R = k(
f,
t,
n,
p.width,
p.height,
o,
w,
i,
s
);
if (!X(d, R)) {
const Y = v(R, p.width, p.height);
c(R, Y), g && g(R, Y);
}
});
m(this, "onDocPointerDone", (e) => {
const { crop: n, disabled: t, onComplete: d, onDragEnd: r } = this.props, o = this.getBox();
this.unbindDocMove(), !(t || !n) && this.mouseDownOnCrop && (this.mouseDownOnCrop = !1, this.dragStarted = !1, r && r(e), d && d(D(n, o.width, o.height), v(n, o.width, o.height)), this.setState({ cropIsActive: !1, newCropIsBeingDrawn: !1 }));
});
m(this, "onDragFocus", () => {
var e;
(e = this.componentRef.current) == null || e.scrollTo(0, 0);
});
}
get document() {
return document;
}
// We unfortunately get the bounding box every time as x+y changes
// due to scrolling.
getBox() {
const e = this.mediaRef.current;
if (!e)
return { x: 0, y: 0, width: 0, height: 0 };
const { x: n, y: t, width: d, height: r } = e.getBoundingClientRect();
return { x: n, y: t, width: d, height: r };
}
componentDidUpdate(e) {
const { crop: n, onComplete: t } = this.props;
if (t && !e.crop && n) {
const { width: d, height: r } = this.getBox();
d && r && t(D(n, d, r), v(n, d, r));
}
}
componentWillUnmount() {
this.resizeObserver && this.resizeObserver.disconnect(), this.unbindDocMove();
}
bindDocMove() {
this.docMoveBound || (this.document.addEventListener("pointermove", this.onDocPointerMove, M), this.document.addEventListener("pointerup", this.onDocPointerDone, M), this.document.addEventListener("pointercancel", this.onDocPointerDone, M), this.docMoveBound = !0);
}
unbindDocMove() {
this.docMoveBound && (this.document.removeEventListener("pointermove", this.onDocPointerMove, M), this.document.removeEventListener("pointerup", this.onDocPointerDone, M), this.document.removeEventListener("pointercancel", this.onDocPointerDone, M), this.docMoveBound = !1);
}
getCropStyle() {
const { crop: e } = this.props;
if (e)
return {
top: `${e.y}${e.unit}`,
left: `${e.x}${e.unit}`,
width: `${e.width}${e.unit}`,
height: `${e.height}${e.unit}`
};
}
dragCrop() {
const { evData: e } = this, n = this.getBox(), t = this.makePixelCrop(n), d = e.clientX - e.startClientX, r = e.clientY - e.startClientY;
return t.x = b(e.startCropX + d, 0, n.width - t.width), t.y = b(e.startCropY + r, 0, n.height - t.height), t;
}
getPointRegion(e, n, t, d) {
const { evData: r } = this, o = r.clientX - e.x, w = r.clientY - e.y;
let i;
d && n ? i = n === "nw" || n === "n" || n === "ne" : i = w < r.startCropY;
let s;
return t && n ? s = n === "nw" || n === "w" || n === "sw" : s = o < r.startCropX, s ? i ? "nw" : "sw" : i ? "ne" : "se";
}
resolveMinDimensions(e, n, t = 0, d = 0) {
const r = Math.min(t, e.width), o = Math.min(d, e.height);
return !n || !r && !o ? [r, o] : n > 1 ? r ? [r, r / n] : [o * n, o] : o ? [o * n, o] : [r, r / n];
}
resizeCrop() {
const { evData: e } = this, { aspect: n = 0, maxWidth: t, maxHeight: d } = this.props, r = this.getBox(), [o, w] = this.resolveMinDimensions(r, n, this.props.minWidth, this.props.minHeight);
let i = this.makePixelCrop(r);
const s = this.getPointRegion(r, e.ord, o, w), c = e.ord || s;
let g = e.clientX - e.startClientX, p = e.clientY - e.startClientY;
(o && c === "nw" || c === "w" || c === "sw") && (g = Math.min(g, -o)), (w && c === "nw" || c === "n" || c === "ne") && (p = Math.min(p, -w));
const l = {
unit: "px",
x: 0,
y: 0,
width: 0,
height: 0
};
s === "ne" ? (l.x = e.startCropX, l.width = g, n ? (l.height = l.width / n, l.y = e.startCropY - l.height) : (l.height = Math.abs(p), l.y = e.startCropY - l.height)) : s === "se" ? (l.x = e.startCropX, l.y = e.startCropY, l.width = g, n ? l.height = l.width / n : l.height = p) : s === "sw" ? (l.x = e.startCropX + g, l.y = e.startCropY, l.width = Math.abs(g), n ? l.height = l.width / n : l.height = p) : s === "nw" && (l.x = e.startCropX + g, l.width = Math.abs(g), n ? (l.height = l.width / n, l.y = e.startCropY - l.height) : (l.height = Math.abs(p), l.y = e.startCropY + p));
const C = k(
l,
n,
s,
r.width,
r.height,
o,
w,
t,
d
);
return n || x.xyOrds.indexOf(c) > -1 ? i = C : x.xOrds.indexOf(c) > -1 ? (i.x = C.x, i.width = C.width) : x.yOrds.indexOf(c) > -1 && (i.y = C.y, i.height = C.height), i.x = b(i.x, 0, r.width - i.width), i.y = b(i.y, 0, r.height - i.height), i;
}
renderCropSelection() {
const {
ariaLabels: e = x.defaultProps.ariaLabels,
disabled: n,
locked: t,
renderSelectionAddon: d,
ruleOfThirds: r,
crop: o
} = this.props, w = this.getCropStyle();
if (o)
return /* @__PURE__ */ u.createElement(
"div",
{
style: w,
className: "ReactCrop__crop-selection",
onPointerDown: this.onCropPointerDown,
"aria-label": e.cropArea,
tabIndex: 0,
onKeyDown: this.onComponentKeyDown,
role: "group"
},
!n && !t && /* @__PURE__ */ u.createElement("div", { className: "ReactCrop__drag-elements", onFocus: this.onDragFocus }, /* @__PURE__ */ u.createElement("div", { className: "ReactCrop__drag-bar ord-n", "data-ord": "n" }), /* @__PURE__ */ u.createElement("div", { className: "ReactCrop__drag-bar ord-e", "data-ord": "e" }), /* @__PURE__ */ u.createElement("div", { className: "ReactCrop__drag-bar ord-s", "data-ord": "s" }), /* @__PURE__ */ u.createElement("div", { className: "ReactCrop__drag-bar ord-w", "data-ord": "w" }), /* @__PURE__ */ u.createElement(
"div",
{
className: "ReactCrop__drag-handle ord-nw",
"data-ord": "nw",
tabIndex: 0,
"aria-label": e.nwDragHandle,
onKeyDown: (i) => this.onHandlerKeyDown(i, "nw"),
role: "button"
}
), /* @__PURE__ */ u.createElement(
"div",
{
className: "ReactCrop__drag-handle ord-n",
"data-ord": "n",
tabIndex: 0,
"aria-label": e.nDragHandle,
onKeyDown: (i) => this.onHandlerKeyDown(i, "n"),
role: "button"
}
), /* @__PURE__ */ u.createElement(
"div",
{
className: "ReactCrop__drag-handle ord-ne",
"data-ord": "ne",
tabIndex: 0,
"aria-label": e.neDragHandle,
onKeyDown: (i) => this.onHandlerKeyDown(i, "ne"),
role: "button"
}
), /* @__PURE__ */ u.createElement(
"div",
{
className: "ReactCrop__drag-handle ord-e",
"data-ord": "e",
tabIndex: 0,
"aria-label": e.eDragHandle,
onKeyDown: (i) => this.onHandlerKeyDown(i, "e"),
role: "button"
}
), /* @__PURE__ */ u.createElement(
"div",
{
className: "ReactCrop__drag-handle ord-se",
"data-ord": "se",
tabIndex: 0,
"aria-label": e.seDragHandle,
onKeyDown: (i) => this.onHandlerKeyDown(i, "se"),
role: "button"
}
), /* @__PURE__ */ u.createElement(
"div",
{
className: "ReactCrop__drag-handle ord-s",
"data-ord": "s",
tabIndex: 0,
"aria-label": e.sDragHandle,
onKeyDown: (i) => this.onHandlerKeyDown(i, "s"),
role: "button"
}
), /* @__PURE__ */ u.createElement(
"div",
{
className: "ReactCrop__drag-handle ord-sw",
"data-ord": "sw",
tabIndex: 0,
"aria-label": e.swDragHandle,
onKeyDown: (i) => this.onHandlerKeyDown(i, "sw"),
role: "button"
}
), /* @__PURE__ */ u.createElement(
"div",
{
className: "ReactCrop__drag-handle ord-w",
"data-ord": "w",
tabIndex: 0,
"aria-label": e.wDragHandle,
onKeyDown: (i) => this.onHandlerKeyDown(i, "w"),
role: "button"
}
)),
d && /* @__PURE__ */ u.createElement("div", { className: "ReactCrop__selection-addon", onPointerDown: (i) => i.stopPropagation() }, d(this.state)),
r && /* @__PURE__ */ u.createElement(u.Fragment, null, /* @__PURE__ */ u.createElement("div", { className: "ReactCrop__rule-of-thirds-hz" }), /* @__PURE__ */ u.createElement("div", { className: "ReactCrop__rule-of-thirds-vt" }))
);
}
makePixelCrop(e) {
const n = { ...E, ...this.props.crop || {} };
return D(n, e.width, e.height);
}
render() {
const { aspect: e, children: n, circularCrop: t, className: d, crop: r, disabled: o, locked: w, style: i, ruleOfThirds: s } = this.props, { cropIsActive: c, newCropIsBeingDrawn: g } = this.state, p = r ? this.renderCropSelection() : null, l = H(
"ReactCrop",
d,
c && "ReactCrop--active",
o && "ReactCrop--disabled",
w && "ReactCrop--locked",
g && "ReactCrop--new-crop",
r && e && "ReactCrop--fixed-aspect",
r && t && "ReactCrop--circular-crop",
r && s && "ReactCrop--rule-of-thirds",
!this.dragStarted && r && !r.width && !r.height && "ReactCrop--invisible-crop",
t && "ReactCrop--no-animate"
);
return /* @__PURE__ */ u.createElement("div", { ref: this.componentRef, className: l, style: i }, /* @__PURE__ */ u.createElement("div", { ref: this.mediaRef, className: "ReactCrop__child-wrapper", onPointerDown: this.onComponentPointerDown }, n), r ? /* @__PURE__ */ u.createElement("svg", { className: "ReactCrop__crop-mask", width: "100%", height: "100%" }, /* @__PURE__ */ u.createElement("defs", null, /* @__PURE__ */ u.createElement("mask", { id: `hole-${this.instanceId}` }, /* @__PURE__ */ u.createElement("rect", { width: "100%", height: "100%", fill: "white" }), t ? /* @__PURE__ */ u.createElement(
"ellipse",
{
cx: `${r.x + r.width / 2}${r.unit}`,
cy: `${r.y + r.height / 2}${r.unit}`,
rx: `${r.width / 2}${r.unit}`,
ry: `${r.height / 2}${r.unit}`,
fill: "black"
}
) : /* @__PURE__ */ u.createElement(
"rect",
{
x: `${r.x}${r.unit}`,
y: `${r.y}${r.unit}`,
width: `${r.width}${r.unit}`,
height: `${r.height}${r.unit}`,
fill: "black"
}
))), /* @__PURE__ */ u.createElement("rect", { fill: "black", fillOpacity: 0.5, width: "100%", height: "100%", mask: `url(#hole-${this.instanceId})` })) : void 0, p);
}
};
m(x, "xOrds", ["e", "w"]), m(x, "yOrds", ["n", "s"]), m(x, "xyOrds", ["nw", "ne", "se", "sw"]), m(x, "nudgeStep", 1), m(x, "nudgeStepMedium", 10), m(x, "nudgeStepLarge", 100), m(x, "defaultProps", {
ariaLabels: {
cropArea: "Use the arrow keys to move the crop selection area",
nwDragHandle: "Use the arrow keys to move the north west drag handle to change the crop selection area",
nDragHandle: "Use the up and down arrow keys to move the north drag handle to change the crop selection area",
neDragHandle: "Use the arrow keys to move the north east drag handle to change the crop selection area",
eDragHandle: "Use the up and down arrow keys to move the east drag handle to change the crop selection area",
seDragHandle: "Use the arrow keys to move the south east drag handle to change the crop selection area",
sDragHandle: "Use the up and down arrow keys to move the south drag handle to change the crop selection area",
swDragHandle: "Use the arrow keys to move the south west drag handle to change the crop selection area",
wDragHandle: "Use the up and down arrow keys to move the west drag handle to change the crop selection area"
}
});
let S = x;
export {
S as Component,
S as ReactCrop,
X as areCropsEqual,
L as centerCrop,
b as clamp,
H as cls,
k as containCrop,
v as convertToPercentCrop,
D as convertToPixelCrop,
S as default,
E as defaultCrop,
B as makeAspectCrop,
I as nudgeCrop
};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
{
"name": "react-image-crop",
"version": "11.0.10",
"description": "A responsive image cropping tool for React",
"repository": "https://github.com/dominictobias/react-image-crop",
"type": "module",
"types": "./dist/index.d.ts",
"main": "./dist/index.umd.cjs",
"module": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.umd.cjs"
},
"./dist/ReactCrop.css": "./dist/ReactCrop.css",
"./src/ReactCrop.scss": "./src/ReactCrop.scss",
"./package.json": "./package.json"
},
"files": [
"dist",
"src"
],
"browserslist": "last 3 versions, not IE > 0",
"style": "dist/ReactCrop.css",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"prepare": "npm run build"
},
"author": "Dominic Tobias (https://github.com/dominictobias)",
"keywords": [
"react",
"reactjs",
"image",
"crop",
"react-component"
],
"license": "ISC",
"devDependencies": {
"@types/react": "^19.1.1",
"@types/react-dom": "^19.1.2",
"@typescript-eslint/eslint-plugin": "^8.29.1",
"@typescript-eslint/parser": "^8.29.1",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.24.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"sass": "^1.86.3",
"typescript": "^5.8.3",
"vite": "^6.2.6",
"vite-plugin-dts": "^4.5.3"
},
"peerDependencies": {
"react": ">=16.13.1"
}
}
@@ -0,0 +1,310 @@
@use 'sass:math';
// Query to kick us into "mobile" mode with larger drag handles/bars.
// See: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/pointer
$mobile-media-query: '(pointer: coarse)' !default;
// Moved to resolve SASS 1.77.7 deprecation warnings
$antWidth: 10px;
$doubleAntWidth: 10px * 2;
@keyframes marching-ants {
0% {
background-position: 0 0, 0 100%, 0 0, 100% 0;
}
100% {
background-position: $doubleAntWidth 0, (-$doubleAntWidth) 100%, 0 (-$doubleAntWidth), 100% $doubleAntWidth;
}
}
:root {
--rc-drag-handle-size: 12px;
--rc-drag-handle-mobile-size: 24px;
--rc-drag-handle-bg-colour: rgba(0, 0, 0, 0.2);
--rc-drag-bar-size: 6px; // The invisible grip size of the crop selection edges
--rc-border-color: rgba(255, 255, 255, 0.7);
--rc-focus-color: #0088ff;
}
.ReactCrop {
$root: &;
position: relative;
display: inline-block;
cursor: crosshair;
max-width: 100%;
& *,
& *::before,
& *::after {
box-sizing: border-box;
}
&--disabled,
&--locked {
cursor: inherit;
}
&__child-wrapper {
overflow: hidden;
max-height: inherit;
& > img,
& > video {
display: block;
max-width: 100%;
max-height: inherit;
}
}
&:not(#{$root}--disabled) {
#{$root}__child-wrapper {
& > img,
& > video {
touch-action: none;
}
}
#{$root}__crop-selection {
touch-action: none;
}
}
&__crop-mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
// Workaround an SVG precision issue: #611
width: calc(100% + 0.5px);
height: calc(100% + 0.5px);
}
&__crop-selection {
position: absolute;
top: 0;
left: 0;
transform: translate3d(0, 0, 0);
cursor: move;
.ReactCrop--disabled & {
cursor: inherit;
}
.ReactCrop--circular-crop & {
border-radius: 50%;
&::after {
pointer-events: none;
content: '';
position: absolute;
top: -1px;
right: -1px;
bottom: -1px;
left: -1px;
border: 1px solid var(--rc-border-color);
opacity: 0.3;
}
}
.ReactCrop--no-animate & {
// border: 1px dashed white;
outline: 1px dashed white;
}
&:not(.ReactCrop--no-animate &) {
animation: marching-ants 1s;
background-image: linear-gradient(to right, #fff 50%, #444 50%), linear-gradient(to right, #fff 50%, #444 50%),
linear-gradient(to bottom, #fff 50%, #444 50%), linear-gradient(to bottom, #fff 50%, #444 50%);
background-size: $antWidth 1px, $antWidth 1px, 1px $antWidth, 1px $antWidth;
background-position: 0 0, 0 100%, 0 0, 100% 0;
background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
color: #fff;
animation-play-state: running;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
&:focus {
outline: 2px solid var(--rc-focus-color);
outline-offset: -1px;
}
}
&--invisible-crop &__crop-mask,
&--invisible-crop &__crop-selection {
display: none;
}
&__rule-of-thirds-vt::before,
&__rule-of-thirds-vt::after,
&__rule-of-thirds-hz::before,
&__rule-of-thirds-hz::after {
content: '';
display: block;
position: absolute;
background-color: rgba(255, 255, 255, 0.4);
}
&__rule-of-thirds-vt {
&::before,
&::after {
width: 1px;
height: 100%;
}
&::before {
left: 33.3333%;
left: calc(100% / 3);
}
&::after {
left: 66.6666%;
left: calc(100% / 3 * 2);
}
}
&__rule-of-thirds-hz {
&::before,
&::after {
width: 100%;
height: 1px;
}
&::before {
top: 33.3333%;
top: calc(100% / 3);
}
&::after {
top: 66.6666%;
top: calc(100% / 3 * 2);
}
}
&__drag-handle {
position: absolute;
width: var(--rc-drag-handle-size);
height: var(--rc-drag-handle-size);
background-color: var(--rc-drag-handle-bg-colour);
border: 1px solid var(--rc-border-color);
&:focus {
background: var(--rc-focus-color);
}
}
.ord-nw {
top: 0;
left: 0;
transform: translate(-50%, -50%);
cursor: nw-resize;
}
.ord-n {
top: 0;
left: 50%;
transform: translate(-50%, -50%);
cursor: n-resize;
}
.ord-ne {
top: 0;
right: 0;
transform: translate(50%, -50%);
cursor: ne-resize;
}
.ord-e {
top: 50%;
right: 0;
transform: translate(50%, -50%);
cursor: e-resize;
}
.ord-se {
bottom: 0;
right: 0;
transform: translate(50%, 50%);
cursor: se-resize;
}
.ord-s {
bottom: 0;
left: 50%;
transform: translate(-50%, 50%);
cursor: s-resize;
}
.ord-sw {
bottom: 0;
left: 0;
transform: translate(-50%, 50%);
cursor: sw-resize;
}
.ord-w {
top: 50%;
left: 0;
transform: translate(-50%, -50%);
cursor: w-resize;
}
// Use the same specificity as the ords above but just
// come after.
&__disabled &__drag-handle {
cursor: inherit;
}
&__drag-bar {
position: absolute;
&.ord-n {
top: 0;
left: 0;
width: 100%;
height: var(--rc-drag-bar-size);
transform: translateY(-50%);
}
&.ord-e {
right: 0;
top: 0;
width: var(--rc-drag-bar-size);
height: 100%;
transform: translateX(50%);
}
&.ord-s {
bottom: 0;
left: 0;
width: 100%;
height: var(--rc-drag-bar-size);
transform: translateY(50%);
}
&.ord-w {
top: 0;
left: 0;
width: var(--rc-drag-bar-size);
height: 100%;
transform: translateX(-50%);
}
}
&--new-crop &__drag-bar,
&--new-crop &__drag-handle,
&--fixed-aspect &__drag-bar {
display: none;
}
&--fixed-aspect &__drag-handle.ord-n,
&--fixed-aspect &__drag-handle.ord-e,
&--fixed-aspect &__drag-handle.ord-s,
&--fixed-aspect &__drag-handle.ord-w {
display: none;
}
@media #{$mobile-media-query} {
.ord-n,
.ord-e,
.ord-s,
.ord-w {
display: none;
}
&__drag-handle {
width: var(--rc-drag-handle-mobile-size);
height: var(--rc-drag-handle-mobile-size);
}
}
}
@@ -0,0 +1,850 @@
import React, { PureComponent, createRef } from 'react'
import { Ords, XYOrds, Crop, PixelCrop, PercentCrop } from './types'
import {
defaultCrop,
clamp,
cls,
areCropsEqual,
convertToPercentCrop,
convertToPixelCrop,
containCrop,
nudgeCrop,
} from './utils'
import './ReactCrop.scss'
interface EVData {
startClientX: number
startClientY: number
startCropX: number
startCropY: number
clientX: number
clientY: number
isResize: boolean
ord?: Ords
}
interface Rectangle {
x: number
y: number
width: number
height: number
}
const DOC_MOVE_OPTS = { capture: true, passive: false }
let instanceCount = 0
export interface ReactCropProps {
/** An object of labels to override the built-in English ones */
ariaLabels?: {
cropArea: string
nwDragHandle: string
nDragHandle: string
neDragHandle: string
eDragHandle: string
seDragHandle: string
sDragHandle: string
swDragHandle: string
wDragHandle: string
}
/** The aspect ratio of the crop, e.g. `1` for a square or `16 / 9` for landscape. */
aspect?: number
/** Classes to pass to the `ReactCrop` element. */
className?: string
/** The elements that you want to perform a crop on. For example
* an image or video. */
children?: React.ReactNode
/** 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. */
circularCrop?: boolean
/** Since v10 all crop params are required except for aspect. Omit the entire crop object if you don't want a crop. See README on how to create an aspect crop with a % crop. */
crop?: Crop
/** 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. */
disabled?: boolean
/** 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. */
locked?: boolean
/** If true is passed then selection can't be disabled if the user clicks outside the selection area. */
keepSelection?: boolean
/** A minimum crop width, in pixels. */
minWidth?: number
/** A minimum crop height, in pixels. */
minHeight?: number
/** A maximum crop width, in pixels. */
maxWidth?: number
/** A maximum crop height, in pixels. */
maxHeight?: number
/** 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. */
onChange: (crop: PixelCrop, percentageCrop: PercentCrop) => void
/** A callback which happens after a resize, drag, or nudge. Passes the current crop state object in pixels and percent. */
onComplete?: (crop: PixelCrop, percentageCrop: PercentCrop) => void
/** A callback which happens when a user starts dragging or resizing. It is convenient to manipulate elements outside this component. */
onDragStart?: (e: PointerEvent) => void
/** A callback which happens when a user releases the cursor or touch after dragging or resizing. */
onDragEnd?: (e: PointerEvent) => void
/** Render a custom element in crop selection. */
renderSelectionAddon?: (state: ReactCropState) => React.ReactNode
/** Show rule of thirds lines in the cropped area. Defaults to false. */
ruleOfThirds?: boolean
/** Inline styles object to be passed to the `ReactCrop` element. */
style?: React.CSSProperties
}
export interface ReactCropState {
cropIsActive: boolean
newCropIsBeingDrawn: boolean
}
export class ReactCrop extends PureComponent<ReactCropProps, ReactCropState> {
static xOrds = ['e', 'w']
static yOrds = ['n', 's']
static xyOrds = ['nw', 'ne', 'se', 'sw']
static nudgeStep = 1
static nudgeStepMedium = 10
static nudgeStepLarge = 100
static defaultProps = {
ariaLabels: {
cropArea: 'Use the arrow keys to move the crop selection area',
nwDragHandle: 'Use the arrow keys to move the north west drag handle to change the crop selection area',
nDragHandle: 'Use the up and down arrow keys to move the north drag handle to change the crop selection area',
neDragHandle: 'Use the arrow keys to move the north east drag handle to change the crop selection area',
eDragHandle: 'Use the up and down arrow keys to move the east drag handle to change the crop selection area',
seDragHandle: 'Use the arrow keys to move the south east drag handle to change the crop selection area',
sDragHandle: 'Use the up and down arrow keys to move the south drag handle to change the crop selection area',
swDragHandle: 'Use the arrow keys to move the south west drag handle to change the crop selection area',
wDragHandle: 'Use the up and down arrow keys to move the west drag handle to change the crop selection area',
},
}
get document() {
return document
}
docMoveBound = false
mouseDownOnCrop = false
dragStarted = false
evData: EVData = {
startClientX: 0,
startClientY: 0,
startCropX: 0,
startCropY: 0,
clientX: 0,
clientY: 0,
isResize: true,
}
componentRef = createRef<HTMLDivElement>()
mediaRef = createRef<HTMLDivElement>()
resizeObserver?: ResizeObserver
initChangeCalled = false
instanceId = `rc-${instanceCount++}`
state: ReactCropState = {
cropIsActive: false,
newCropIsBeingDrawn: false,
}
// We unfortunately get the bounding box every time as x+y changes
// due to scrolling.
getBox(): Rectangle {
const el = this.mediaRef.current
if (!el) {
return { x: 0, y: 0, width: 0, height: 0 }
}
const { x, y, width, height } = el.getBoundingClientRect()
return { x, y, width, height }
}
componentDidUpdate(prevProps: ReactCropProps) {
const { crop, onComplete } = this.props
// Useful for when programatically setting a new
// crop and wanting to show a preview.
if (onComplete && !prevProps.crop && crop) {
const { width, height } = this.getBox()
if (width && height) {
onComplete(convertToPixelCrop(crop, width, height), convertToPercentCrop(crop, width, height))
}
}
}
componentWillUnmount() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
this.unbindDocMove()
}
bindDocMove() {
if (this.docMoveBound) {
return
}
this.document.addEventListener('pointermove', this.onDocPointerMove, DOC_MOVE_OPTS)
this.document.addEventListener('pointerup', this.onDocPointerDone, DOC_MOVE_OPTS)
this.document.addEventListener('pointercancel', this.onDocPointerDone, DOC_MOVE_OPTS)
this.docMoveBound = true
}
unbindDocMove() {
if (!this.docMoveBound) {
return
}
this.document.removeEventListener('pointermove', this.onDocPointerMove, DOC_MOVE_OPTS)
this.document.removeEventListener('pointerup', this.onDocPointerDone, DOC_MOVE_OPTS)
this.document.removeEventListener('pointercancel', this.onDocPointerDone, DOC_MOVE_OPTS)
this.docMoveBound = false
}
onCropPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const { crop, disabled } = this.props
const box = this.getBox()
if (!crop) {
return
}
const pixelCrop = convertToPixelCrop(crop, box.width, box.height)
if (disabled) {
return
}
if (e.cancelable) e.preventDefault() // Stop drag selection.
// Bind to doc to follow movements outside of element.
this.bindDocMove()
// Focus for detecting keypress.
;(this.componentRef.current as HTMLDivElement).focus({ preventScroll: true })
const ord = (e.target as HTMLElement).dataset.ord as Ords
const isResize = Boolean(ord)
let startClientX = e.clientX
let startClientY = e.clientY
let startCropX = pixelCrop.x
let startCropY = pixelCrop.y
// Set the starting coords to the opposite corner.
if (ord) {
const relativeX = e.clientX - box.x
const relativeY = e.clientY - box.y
let fromCornerX = 0
let fromCornerY = 0
if (ord === 'ne' || ord == 'e') {
fromCornerX = relativeX - (pixelCrop.x + pixelCrop.width)
fromCornerY = relativeY - pixelCrop.y
startCropX = pixelCrop.x
startCropY = pixelCrop.y + pixelCrop.height
} else if (ord === 'se' || ord === 's') {
fromCornerX = relativeX - (pixelCrop.x + pixelCrop.width)
fromCornerY = relativeY - (pixelCrop.y + pixelCrop.height)
startCropX = pixelCrop.x
startCropY = pixelCrop.y
} else if (ord === 'sw' || ord == 'w') {
fromCornerX = relativeX - pixelCrop.x
fromCornerY = relativeY - (pixelCrop.y + pixelCrop.height)
startCropX = pixelCrop.x + pixelCrop.width
startCropY = pixelCrop.y
} else if (ord === 'nw' || ord == 'n') {
fromCornerX = relativeX - pixelCrop.x
fromCornerY = relativeY - pixelCrop.y
startCropX = pixelCrop.x + pixelCrop.width
startCropY = pixelCrop.y + pixelCrop.height
}
startClientX = startCropX + box.x + fromCornerX
startClientY = startCropY + box.y + fromCornerY
}
this.evData = {
startClientX,
startClientY,
startCropX,
startCropY,
clientX: e.clientX,
clientY: e.clientY,
isResize,
ord,
}
this.mouseDownOnCrop = true
this.setState({ cropIsActive: true })
}
onComponentPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const { crop, disabled, locked, keepSelection, onChange } = this.props
const box = this.getBox()
if (disabled || locked || (keepSelection && crop)) {
return
}
if (e.cancelable) e.preventDefault() // Stop drag selection.
// Bind to doc to follow movements outside of element.
this.bindDocMove()
// Focus for detecting keypress.
;(this.componentRef.current as HTMLDivElement).focus({ preventScroll: true })
const cropX = e.clientX - box.x
const cropY = e.clientY - box.y
const nextCrop: PixelCrop = {
unit: 'px',
x: cropX,
y: cropY,
width: 0,
height: 0,
}
this.evData = {
startClientX: e.clientX,
startClientY: e.clientY,
startCropX: cropX,
startCropY: cropY,
clientX: e.clientX,
clientY: e.clientY,
isResize: true,
}
this.mouseDownOnCrop = true
onChange(convertToPixelCrop(nextCrop, box.width, box.height), convertToPercentCrop(nextCrop, box.width, box.height))
this.setState({ cropIsActive: true, newCropIsBeingDrawn: true })
}
onDocPointerMove = (e: PointerEvent) => {
const { crop, disabled, onChange, onDragStart } = this.props
const box = this.getBox()
if (disabled || !crop || !this.mouseDownOnCrop) {
return
}
// Stop drag selection.
if (e.cancelable) e.preventDefault()
if (!this.dragStarted) {
this.dragStarted = true
if (onDragStart) {
onDragStart(e)
}
}
// Update pointer position.
const { evData } = this
evData.clientX = e.clientX
evData.clientY = e.clientY
let nextCrop
if (evData.isResize) {
nextCrop = this.resizeCrop()
} else {
nextCrop = this.dragCrop()
}
if (!areCropsEqual(crop, nextCrop)) {
onChange(
convertToPixelCrop(nextCrop, box.width, box.height),
convertToPercentCrop(nextCrop, box.width, box.height)
)
}
}
onComponentKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const { crop, disabled, onChange, onComplete } = this.props
if (disabled) {
return
}
const keyCode = e.key
let nudged = false
if (!crop) {
return
}
const box = this.getBox()
const nextCrop = this.makePixelCrop(box)
const ctrlCmdPressed = navigator.platform.match('Mac') ? e.metaKey : e.ctrlKey
const nudgeStep = ctrlCmdPressed
? ReactCrop.nudgeStepLarge
: e.shiftKey
? ReactCrop.nudgeStepMedium
: ReactCrop.nudgeStep
if (keyCode === 'ArrowLeft') {
nextCrop.x -= nudgeStep
nudged = true
} else if (keyCode === 'ArrowRight') {
nextCrop.x += nudgeStep
nudged = true
} else if (keyCode === 'ArrowUp') {
nextCrop.y -= nudgeStep
nudged = true
} else if (keyCode === 'ArrowDown') {
nextCrop.y += nudgeStep
nudged = true
}
if (nudged) {
if (e.cancelable) e.preventDefault() // Stop drag selection.
nextCrop.x = clamp(nextCrop.x, 0, box.width - nextCrop.width)
nextCrop.y = clamp(nextCrop.y, 0, box.height - nextCrop.height)
const pixelCrop = convertToPixelCrop(nextCrop, box.width, box.height)
const percentCrop = convertToPercentCrop(nextCrop, box.width, box.height)
onChange(pixelCrop, percentCrop)
if (onComplete) {
onComplete(pixelCrop, percentCrop)
}
}
}
onHandlerKeyDown = (e: React.KeyboardEvent<HTMLDivElement>, ord: Ords) => {
const {
aspect = 0,
crop,
disabled,
minWidth = 0,
minHeight = 0,
maxWidth,
maxHeight,
onChange,
onComplete,
} = this.props
const box = this.getBox()
if (disabled || !crop) {
return
}
// Keep the event from bubbling up to the container
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.stopPropagation()
e.preventDefault()
} else {
return
}
const ctrlCmdPressed = navigator.platform.match('Mac') ? e.metaKey : e.ctrlKey
const offset = ctrlCmdPressed
? ReactCrop.nudgeStepLarge
: e.shiftKey
? ReactCrop.nudgeStepMedium
: ReactCrop.nudgeStep
const pixelCrop = convertToPixelCrop(crop, box.width, box.height)
const nudgedCrop = nudgeCrop(pixelCrop, e.key, offset, ord)
const containedCrop = containCrop(
nudgedCrop,
aspect,
ord,
box.width,
box.height,
minWidth,
minHeight,
maxWidth,
maxHeight
)
if (!areCropsEqual(crop, containedCrop)) {
const percentCrop = convertToPercentCrop(containedCrop, box.width, box.height)
onChange(containedCrop, percentCrop)
if (onComplete) {
onComplete(containedCrop, percentCrop)
}
}
}
onDocPointerDone = (e: PointerEvent) => {
const { crop, disabled, onComplete, onDragEnd } = this.props
const box = this.getBox()
this.unbindDocMove()
if (disabled || !crop) {
return
}
if (this.mouseDownOnCrop) {
this.mouseDownOnCrop = false
this.dragStarted = false
onDragEnd && onDragEnd(e)
onComplete &&
onComplete(convertToPixelCrop(crop, box.width, box.height), convertToPercentCrop(crop, box.width, box.height))
this.setState({ cropIsActive: false, newCropIsBeingDrawn: false })
}
}
onDragFocus = (/*e: React.FocusEvent<HTMLDivElement, Element>*/) => {
// Fixes #491
this.componentRef.current?.scrollTo(0, 0)
}
getCropStyle() {
const { crop } = this.props
if (!crop) {
return undefined
}
return {
top: `${crop.y}${crop.unit}`,
left: `${crop.x}${crop.unit}`,
width: `${crop.width}${crop.unit}`,
height: `${crop.height}${crop.unit}`,
}
}
dragCrop() {
const { evData } = this
const box = this.getBox()
const nextCrop = this.makePixelCrop(box)
const xDiff = evData.clientX - evData.startClientX
const yDiff = evData.clientY - evData.startClientY
nextCrop.x = clamp(evData.startCropX + xDiff, 0, box.width - nextCrop.width)
nextCrop.y = clamp(evData.startCropY + yDiff, 0, box.height - nextCrop.height)
return nextCrop
}
getPointRegion(box: Rectangle, origOrd: Ords | undefined, minWidth: number, minHeight: number): XYOrds {
const { evData } = this
const relativeX = evData.clientX - box.x
const relativeY = evData.clientY - box.y
let topHalf: boolean
if (minHeight && origOrd) {
// Uses orig ord (never flip when minHeight != 0)
topHalf = origOrd === 'nw' || origOrd === 'n' || origOrd === 'ne'
} else {
topHalf = relativeY < evData.startCropY
}
let leftHalf: boolean
if (minWidth && origOrd) {
// Uses orig ord (never flip when minWidth != 0)
leftHalf = origOrd === 'nw' || origOrd === 'w' || origOrd === 'sw'
} else {
leftHalf = relativeX < evData.startCropX
}
if (leftHalf) {
return topHalf ? 'nw' : 'sw'
} else {
return topHalf ? 'ne' : 'se'
}
}
resolveMinDimensions(box: Rectangle, aspect: number, minWidth = 0, minHeight = 0) {
const mw = Math.min(minWidth, box.width)
const mh = Math.min(minHeight, box.height)
if (!aspect || (!mw && !mh)) {
return [mw, mh]
}
if (aspect > 1) {
return mw ? [mw, mw / aspect] : [mh * aspect, mh]
} else {
return mh ? [mh * aspect, mh] : [mw, mw / aspect]
}
}
resizeCrop() {
const { evData } = this
const { aspect = 0, maxWidth, maxHeight } = this.props
const box = this.getBox()
const [minWidth, minHeight] = this.resolveMinDimensions(box, aspect, this.props.minWidth, this.props.minHeight)
let nextCrop = this.makePixelCrop(box)
const area = this.getPointRegion(box, evData.ord, minWidth, minHeight)
const ord = evData.ord || area
let xDiff = evData.clientX - evData.startClientX
let yDiff = evData.clientY - evData.startClientY
// When min dimensions are set, ensure crop isn't dragged when going
// beyond the other side #554
if ((minWidth && ord === 'nw') || ord === 'w' || ord === 'sw') {
xDiff = Math.min(xDiff, -minWidth)
}
if ((minHeight && ord === 'nw') || ord === 'n' || ord === 'ne') {
yDiff = Math.min(yDiff, -minHeight)
}
const tmpCrop: PixelCrop = {
unit: 'px',
x: 0,
y: 0,
width: 0,
height: 0,
}
if (area === 'ne') {
tmpCrop.x = evData.startCropX
tmpCrop.width = xDiff
if (aspect) {
tmpCrop.height = tmpCrop.width / aspect
tmpCrop.y = evData.startCropY - tmpCrop.height
} else {
tmpCrop.height = Math.abs(yDiff)
tmpCrop.y = evData.startCropY - tmpCrop.height
}
} else if (area === 'se') {
tmpCrop.x = evData.startCropX
tmpCrop.y = evData.startCropY
tmpCrop.width = xDiff
if (aspect) {
tmpCrop.height = tmpCrop.width / aspect
} else {
tmpCrop.height = yDiff
}
} else if (area === 'sw') {
tmpCrop.x = evData.startCropX + xDiff
tmpCrop.y = evData.startCropY
tmpCrop.width = Math.abs(xDiff)
if (aspect) {
tmpCrop.height = tmpCrop.width / aspect
} else {
tmpCrop.height = yDiff
}
} else if (area === 'nw') {
tmpCrop.x = evData.startCropX + xDiff
tmpCrop.width = Math.abs(xDiff)
if (aspect) {
tmpCrop.height = tmpCrop.width / aspect
tmpCrop.y = evData.startCropY - tmpCrop.height
} else {
tmpCrop.height = Math.abs(yDiff)
tmpCrop.y = evData.startCropY + yDiff
}
}
const containedCrop = containCrop(
tmpCrop,
aspect,
area,
box.width,
box.height,
minWidth,
minHeight,
maxWidth,
maxHeight
)
// Apply x/y/width/height changes depending on ordinate
// (fixed aspect always applies both).
if (aspect || ReactCrop.xyOrds.indexOf(ord) > -1) {
nextCrop = containedCrop
} else if (ReactCrop.xOrds.indexOf(ord) > -1) {
nextCrop.x = containedCrop.x
nextCrop.width = containedCrop.width
} else if (ReactCrop.yOrds.indexOf(ord) > -1) {
nextCrop.y = containedCrop.y
nextCrop.height = containedCrop.height
}
// When drawing a new crop with min dimensions we allow flipping, but
// ensure we don't flip outside the crop area, just ignore those.
nextCrop.x = clamp(nextCrop.x, 0, box.width - nextCrop.width)
nextCrop.y = clamp(nextCrop.y, 0, box.height - nextCrop.height)
return nextCrop
}
renderCropSelection() {
const {
ariaLabels = ReactCrop.defaultProps.ariaLabels,
disabled,
locked,
renderSelectionAddon,
ruleOfThirds,
crop,
} = this.props
const style = this.getCropStyle()
if (!crop) {
return undefined
}
return (
<div
style={style}
className="ReactCrop__crop-selection"
onPointerDown={this.onCropPointerDown}
aria-label={ariaLabels.cropArea}
tabIndex={0}
onKeyDown={this.onComponentKeyDown}
role="group"
>
{!disabled && !locked && (
<div className="ReactCrop__drag-elements" onFocus={this.onDragFocus}>
<div className="ReactCrop__drag-bar ord-n" data-ord="n" />
<div className="ReactCrop__drag-bar ord-e" data-ord="e" />
<div className="ReactCrop__drag-bar ord-s" data-ord="s" />
<div className="ReactCrop__drag-bar ord-w" data-ord="w" />
<div
className="ReactCrop__drag-handle ord-nw"
data-ord="nw"
tabIndex={0}
aria-label={ariaLabels.nwDragHandle}
onKeyDown={e => this.onHandlerKeyDown(e, 'nw')}
role="button"
/>
<div
className="ReactCrop__drag-handle ord-n"
data-ord="n"
tabIndex={0}
aria-label={ariaLabels.nDragHandle}
onKeyDown={e => this.onHandlerKeyDown(e, 'n')}
role="button"
/>
<div
className="ReactCrop__drag-handle ord-ne"
data-ord="ne"
tabIndex={0}
aria-label={ariaLabels.neDragHandle}
onKeyDown={e => this.onHandlerKeyDown(e, 'ne')}
role="button"
/>
<div
className="ReactCrop__drag-handle ord-e"
data-ord="e"
tabIndex={0}
aria-label={ariaLabels.eDragHandle}
onKeyDown={e => this.onHandlerKeyDown(e, 'e')}
role="button"
/>
<div
className="ReactCrop__drag-handle ord-se"
data-ord="se"
tabIndex={0}
aria-label={ariaLabels.seDragHandle}
onKeyDown={e => this.onHandlerKeyDown(e, 'se')}
role="button"
/>
<div
className="ReactCrop__drag-handle ord-s"
data-ord="s"
tabIndex={0}
aria-label={ariaLabels.sDragHandle}
onKeyDown={e => this.onHandlerKeyDown(e, 's')}
role="button"
/>
<div
className="ReactCrop__drag-handle ord-sw"
data-ord="sw"
tabIndex={0}
aria-label={ariaLabels.swDragHandle}
onKeyDown={e => this.onHandlerKeyDown(e, 'sw')}
role="button"
/>
<div
className="ReactCrop__drag-handle ord-w"
data-ord="w"
tabIndex={0}
aria-label={ariaLabels.wDragHandle}
onKeyDown={e => this.onHandlerKeyDown(e, 'w')}
role="button"
/>
</div>
)}
{renderSelectionAddon && (
<div className="ReactCrop__selection-addon" onPointerDown={e => e.stopPropagation()}>
{renderSelectionAddon(this.state)}
</div>
)}
{ruleOfThirds && (
<>
<div className="ReactCrop__rule-of-thirds-hz" />
<div className="ReactCrop__rule-of-thirds-vt" />
</>
)}
</div>
)
}
makePixelCrop(box: Rectangle) {
const crop = { ...defaultCrop, ...(this.props.crop || {}) }
return convertToPixelCrop(crop, box.width, box.height)
}
render() {
const { aspect, children, circularCrop, className, crop, disabled, locked, style, ruleOfThirds } = this.props
const { cropIsActive, newCropIsBeingDrawn } = this.state
const cropSelection = crop ? this.renderCropSelection() : null
const componentClasses = cls(
'ReactCrop',
className,
cropIsActive && 'ReactCrop--active',
disabled && 'ReactCrop--disabled',
locked && 'ReactCrop--locked',
newCropIsBeingDrawn && 'ReactCrop--new-crop',
crop && aspect && 'ReactCrop--fixed-aspect',
crop && circularCrop && 'ReactCrop--circular-crop',
crop && ruleOfThirds && 'ReactCrop--rule-of-thirds',
!this.dragStarted && crop && !crop.width && !crop.height && 'ReactCrop--invisible-crop',
circularCrop && 'ReactCrop--no-animate'
)
return (
<div ref={this.componentRef} className={componentClasses} style={style}>
<div ref={this.mediaRef} className="ReactCrop__child-wrapper" onPointerDown={this.onComponentPointerDown}>
{children}
</div>
{crop ? (
<svg className="ReactCrop__crop-mask" width="100%" height="100%">
<defs>
<mask id={`hole-${this.instanceId}`}>
<rect width="100%" height="100%" fill="white" />
{circularCrop ? (
<ellipse
cx={`${crop.x + crop.width / 2}${crop.unit}`}
cy={`${crop.y + crop.height / 2}${crop.unit}`}
rx={`${crop.width / 2}${crop.unit}`}
ry={`${crop.height / 2}${crop.unit}`}
fill="black"
/>
) : (
<rect
x={`${crop.x}${crop.unit}`}
y={`${crop.y}${crop.unit}`}
width={`${crop.width}${crop.unit}`}
height={`${crop.height}${crop.unit}`}
fill="black"
/>
)}
</mask>
</defs>
<rect fill="black" fillOpacity={0.5} width="100%" height="100%" mask={`url(#hole-${this.instanceId})`} />
</svg>
) : undefined}
{cropSelection}
</div>
)
}
}
@@ -0,0 +1,145 @@
import React, { useState, useRef } from 'react'
import ReactCrop, { centerCrop, makeAspectCrop, Crop, PixelCrop } from '..'
import { canvasPreview } from './canvasPreview'
import { useDebounceEffect } from './useDebounceEffect'
import '../ReactCrop.scss'
import './index.scss'
// This is to demonstate how to make and center a % aspect crop
// which is a bit trickier so we use some helper functions.
function centerAspectCrop(mediaWidth: number, mediaHeight: number, aspect: number) {
return centerCrop(
makeAspectCrop(
{
unit: '%',
width: 90,
},
aspect,
mediaWidth,
mediaHeight
),
mediaWidth,
mediaHeight
)
}
// const defaultAspect = 9 / 16
const defaultAspect = 16 / 9
export function Demo() {
const [imgSrc, setImgSrc] = useState('')
const previewCanvasRef = useRef<HTMLCanvasElement>(null)
const imgRef = useRef<HTMLImageElement>(null)
const [crop, setCrop] = useState<Crop>()
const [completedCrop, setCompletedCrop] = useState<PixelCrop>()
const [scale, setScale] = useState(1)
const [rotate, setRotate] = useState(0)
const [aspect, setAspect] = useState<number | undefined>(defaultAspect)
function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files && e.target.files.length > 0) {
setCrop(undefined) // Makes crop preview update between images.
const reader = new FileReader()
reader.addEventListener('load', () => setImgSrc(reader.result?.toString() || ''))
reader.readAsDataURL(e.target.files[0])
}
}
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
if (aspect) {
const { width, height } = e.currentTarget
setCrop(centerAspectCrop(width, height, aspect))
}
}
useDebounceEffect(
async () => {
if (completedCrop?.width && completedCrop?.height && imgRef.current && previewCanvasRef.current) {
// We use canvasPreview as it's much faster than imgPreview.
canvasPreview(imgRef.current, previewCanvasRef.current, completedCrop, scale, rotate)
}
},
100,
[completedCrop, scale, rotate]
)
function handleToggleAspectClick() {
if (aspect) {
setAspect(undefined)
} else {
setAspect(defaultAspect)
if (imgRef.current) {
const { width, height } = imgRef.current
setCrop(centerAspectCrop(width, height, defaultAspect))
}
}
}
return (
<div className="App">
<div className="Crop-Controls">
<input type="file" accept="image/*" onChange={onSelectFile} />
<div>
<label htmlFor="scale-input">Scale: </label>
<input
id="scale-input"
type="number"
step="0.1"
value={scale}
disabled={!imgSrc}
onChange={e => setScale(Number(e.target.value))}
/>
</div>
<div>
<label htmlFor="rotate-input">Rotate: </label>
<input
id="rotate-input"
type="number"
value={rotate}
disabled={!imgSrc}
onChange={e => setRotate(Math.min(180, Math.max(-180, Number(e.target.value))))}
/>
</div>
<div>
<button onClick={handleToggleAspectClick}>Toggle aspect {aspect ? 'off' : 'on'}</button>
</div>
</div>
{!!imgSrc && (
<ReactCrop
// className="ReactCrop--no-animate"
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={c => setCompletedCrop(c)}
aspect={aspect}
minWidth={400}
minHeight={200}
circularCrop
ruleOfThirds
>
<img
ref={imgRef}
alt="Crop me"
src={imgSrc}
style={{ transform: `scale(${scale}) rotate(${rotate}deg)` }}
onLoad={onImageLoad}
/>
</ReactCrop>
)}
<div>
{!!completedCrop && (
<canvas
ref={previewCanvasRef}
style={{
border: '1px solid black',
objectFit: 'contain',
width: completedCrop.width,
height: completedCrop.height,
}}
/>
)}
</div>
</div>
)
}
@@ -0,0 +1,55 @@
import { PixelCrop } from '..'
const TO_RADIANS = Math.PI / 180
export async function canvasPreview(
image: HTMLImageElement,
canvas: HTMLCanvasElement,
crop: PixelCrop,
scale = 1,
rotate = 0
) {
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('No 2d context')
}
const scaleX = image.naturalWidth / image.width
const scaleY = image.naturalHeight / image.height
// devicePixelRatio slightly increases sharpness on retina devices
// at the expense of slightly slower render times and needing to
// size the image back down if you want to download/upload and be
// true to the images natural size.
const pixelRatio = window.devicePixelRatio
// const pixelRatio = 1
canvas.width = Math.floor(crop.width * scaleX * pixelRatio)
canvas.height = Math.floor(crop.height * scaleY * pixelRatio)
ctx.scale(pixelRatio, pixelRatio)
ctx.imageSmoothingQuality = 'high'
const cropX = crop.x * scaleX
const cropY = crop.y * scaleY
const rotateRads = rotate * TO_RADIANS
const centerX = image.naturalWidth / 2
const centerY = image.naturalHeight / 2
ctx.save()
// 5) Move the crop origin to the canvas origin (0,0)
ctx.translate(-cropX, -cropY)
// 4) Move the origin to the center of the original position
ctx.translate(centerX, centerY)
// 3) Rotate around the origin
ctx.rotate(rotateRads)
// 2) Scale the image
ctx.scale(scale, scale)
// 1) Move the center of the image to the origin (0,0)
ctx.translate(-centerX, -centerY)
ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight, 0, 0, image.naturalWidth, image.naturalHeight)
ctx.restore()
}
@@ -0,0 +1,31 @@
import { PixelCrop } from '..'
import { canvasPreview } from './canvasPreview'
let previewUrl = ''
function toBlob(canvas: HTMLCanvasElement): Promise<Blob | null> {
return new Promise(resolve => {
canvas.toBlob(resolve)
})
}
// Returns an image source you should set to state and pass
// `{previewSrc && <img alt="Crop preview" src={previewSrc} />}`
export async function imgPreview(image: HTMLImageElement, crop: PixelCrop, scale = 1, rotate = 0) {
const canvas = document.createElement('canvas')
canvasPreview(image, canvas, crop, scale, rotate)
const blob = await toBlob(canvas)
if (!blob) {
console.error('Failed to create blob')
return ''
}
if (previewUrl) {
URL.revokeObjectURL(previewUrl)
}
previewUrl = URL.createObjectURL(blob)
return previewUrl
}
@@ -0,0 +1,18 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
.App {
margin: 20px;
}
img {
max-width: 100%;
}
@@ -0,0 +1,6 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Demo } from './Demo'
const root = createRoot(document.getElementById('root')!)
root.render(<Demo />)
@@ -0,0 +1,13 @@
import { useEffect, DependencyList } from 'react'
export function useDebounceEffect(fn: () => void, waitTime: number, deps?: DependencyList) {
useEffect(() => {
const t = setTimeout(() => {
fn.apply(undefined, [...(deps as [])])
}, waitTime)
return () => {
clearTimeout(t)
}
}, deps)
}
@@ -0,0 +1,6 @@
export * from './types'
export * from './utils'
export * from './ReactCrop'
// For compat with older versions
export { ReactCrop as default, ReactCrop as Component } from './ReactCrop'
@@ -0,0 +1,20 @@
export type XOrds = 'e' | 'w'
export type YOrds = 'n' | 's'
export type XYOrds = 'nw' | 'ne' | 'se' | 'sw'
export type Ords = XOrds | YOrds | XYOrds
export interface Crop {
x: number
y: number
width: number
height: number
unit: 'px' | '%'
}
export interface PixelCrop extends Crop {
unit: 'px'
}
export interface PercentCrop extends Crop {
unit: '%'
}
@@ -0,0 +1,347 @@
import { PixelCrop, PercentCrop, Crop, Ords } from './types'
export const defaultCrop: PixelCrop = {
x: 0,
y: 0,
width: 0,
height: 0,
unit: 'px',
}
export const clamp = (num: number, min: number, max: number) => Math.min(Math.max(num, min), max)
export const cls = (...args: unknown[]) => args.filter(v => v && typeof v === 'string').join(' ')
export const areCropsEqual = (cropA: Partial<Crop>, cropB: Partial<Crop>) =>
cropA === cropB ||
(cropA.width === cropB.width &&
cropA.height === cropB.height &&
cropA.x === cropB.x &&
cropA.y === cropB.y &&
cropA.unit === cropB.unit)
export function makeAspectCrop(
crop: Pick<PercentCrop, 'unit'> & Partial<Omit<PercentCrop, 'unit'>>,
aspect: number,
containerWidth: number,
containerHeight: number
): PercentCrop
export function makeAspectCrop(
crop: Pick<PixelCrop, 'unit'> & Partial<Omit<PixelCrop, 'unit'>>,
aspect: number,
containerWidth: number,
containerHeight: number
): PixelCrop
export function makeAspectCrop(crop: Partial<Crop>, aspect: number, containerWidth: number, containerHeight: number) {
const pixelCrop = convertToPixelCrop(crop, containerWidth, containerHeight)
if (crop.width) {
pixelCrop.height = pixelCrop.width / aspect
}
if (crop.height) {
pixelCrop.width = pixelCrop.height * aspect
}
if (pixelCrop.y + pixelCrop.height > containerHeight) {
pixelCrop.height = containerHeight - pixelCrop.y
pixelCrop.width = pixelCrop.height * aspect
}
if (pixelCrop.x + pixelCrop.width > containerWidth) {
pixelCrop.width = containerWidth - pixelCrop.x
pixelCrop.height = pixelCrop.width / aspect
}
if (crop.unit === '%') {
return convertToPercentCrop(pixelCrop, containerWidth, containerHeight)
}
return pixelCrop
}
export function centerCrop(
crop: Pick<PercentCrop, 'unit'> & Partial<Omit<PercentCrop, 'unit'>>,
containerWidth: number,
containerHeight: number
): PercentCrop
export function centerCrop(
crop: Pick<PixelCrop, 'unit'> & Partial<Omit<PixelCrop, 'unit'>>,
containerWidth: number,
containerHeight: number
): PixelCrop
export function centerCrop(crop: Partial<Crop>, containerWidth: number, containerHeight: number) {
const pixelCrop = convertToPixelCrop(crop, containerWidth, containerHeight)
pixelCrop.x = (containerWidth - pixelCrop.width) / 2
pixelCrop.y = (containerHeight - pixelCrop.height) / 2
if (crop.unit === '%') {
return convertToPercentCrop(pixelCrop, containerWidth, containerHeight)
}
return pixelCrop
}
export function convertToPercentCrop(
crop: Partial<Crop>,
containerWidth: number,
containerHeight: number
): PercentCrop {
if (crop.unit === '%') {
return { ...defaultCrop, ...crop, unit: '%' }
}
return {
unit: '%',
x: crop.x ? (crop.x / containerWidth) * 100 : 0,
y: crop.y ? (crop.y / containerHeight) * 100 : 0,
width: crop.width ? (crop.width / containerWidth) * 100 : 0,
height: crop.height ? (crop.height / containerHeight) * 100 : 0,
}
}
export function convertToPixelCrop(crop: Partial<Crop>, containerWidth: number, containerHeight: number): PixelCrop {
if (!crop.unit) {
return { ...defaultCrop, ...crop, unit: 'px' }
}
if (crop.unit === 'px') {
return { ...defaultCrop, ...crop, unit: 'px' }
}
return {
unit: 'px',
x: crop.x ? (crop.x * containerWidth) / 100 : 0,
y: crop.y ? (crop.y * containerHeight) / 100 : 0,
width: crop.width ? (crop.width * containerWidth) / 100 : 0,
height: crop.height ? (crop.height * containerHeight) / 100 : 0,
}
}
// Sorry.
export function containCrop(
pixelCrop: PixelCrop,
aspect: number,
ord: Ords,
containerWidth: number,
containerHeight: number,
minWidth = 0,
minHeight = 0,
maxWidth = containerWidth,
maxHeight = containerHeight
) {
const containedCrop = { ...pixelCrop }
let _minWidth = Math.min(minWidth, containerWidth)
let _minHeight = Math.min(minHeight, containerHeight)
let _maxWidth = Math.min(maxWidth, containerWidth)
let _maxHeight = Math.min(maxHeight, containerHeight)
if (aspect) {
if (aspect > 1) {
// Landscape - increase width min + max.
_minWidth = minHeight ? minHeight * aspect : _minWidth
_minHeight = _minWidth / aspect
_maxWidth = maxWidth * aspect
} else {
// Portrait - increase height min + max.
_minHeight = minWidth ? minWidth / aspect : _minHeight
_minWidth = _minHeight * aspect
_maxHeight = maxHeight / aspect
}
}
// Stop underflow on top.
if (containedCrop.y < 0) {
containedCrop.height = Math.max(containedCrop.height + containedCrop.y, _minHeight)
containedCrop.y = 0
}
// Stop underflow on left.
if (containedCrop.x < 0) {
containedCrop.width = Math.max(containedCrop.width + containedCrop.x, _minWidth)
containedCrop.x = 0
}
// Stop overflow on right.
const xOverflow = containerWidth - (containedCrop.x + containedCrop.width)
if (xOverflow < 0) {
containedCrop.x = Math.min(containedCrop.x, containerWidth - _minWidth)
containedCrop.width += xOverflow
}
// Stop overflow on bottom.
const yOverflow = containerHeight - (containedCrop.y + containedCrop.height)
if (yOverflow < 0) {
containedCrop.y = Math.min(containedCrop.y, containerHeight - _minHeight)
containedCrop.height += yOverflow
}
// Make crop respect min width generally.
if (containedCrop.width < _minWidth) {
if (ord === 'sw' || ord == 'nw') {
// Stops box moving when min is hit.
containedCrop.x -= _minWidth - containedCrop.width
}
containedCrop.width = _minWidth
}
// Make crop respect min height generally.
if (containedCrop.height < _minHeight) {
if (ord === 'nw' || ord == 'ne') {
// Stops box moving when min is hit.
containedCrop.y -= _minHeight - containedCrop.height
}
containedCrop.height = _minHeight
}
// Make crop respect max width generally.
if (containedCrop.width > _maxWidth) {
if (ord === 'sw' || ord == 'nw') {
// Stops box moving when max is hit.
containedCrop.x -= _maxWidth - containedCrop.width
}
containedCrop.width = _maxWidth
}
// Make crop respect max height generally.
if (containedCrop.height > _maxHeight) {
if (ord === 'nw' || ord == 'ne') {
// Stops box moving when min is hit.
containedCrop.y -= _maxHeight - containedCrop.height
}
containedCrop.height = _maxHeight
}
// Maintain aspect after size fixing.
if (aspect) {
const currAspect = containedCrop.width / containedCrop.height
if (currAspect < aspect) {
// Crop is shrunk on the width so adjust the height.
const newHeight = Math.max(containedCrop.width / aspect, _minHeight)
if (ord === 'nw' || ord == 'ne') {
// Stops box moving when min is hit.
containedCrop.y -= newHeight - containedCrop.height
}
containedCrop.height = newHeight
} else if (currAspect > aspect) {
// Crop is shrunk on the height so adjust the width.
const newWidth = Math.max(containedCrop.height * aspect, _minWidth)
if (ord === 'sw' || ord == 'nw') {
// Stops box moving when max is hit.
containedCrop.x -= newWidth - containedCrop.width
}
containedCrop.width = newWidth
}
}
return containedCrop
}
export function nudgeCrop(pixelCrop: PixelCrop, key: string, offset: number, ord: Ords) {
const nextCrop = { ...pixelCrop }
if (key === 'ArrowLeft') {
if (ord === 'nw') {
nextCrop.x -= offset
nextCrop.y -= offset
nextCrop.width += offset
nextCrop.height += offset
} else if (ord === 'w') {
nextCrop.x -= offset
nextCrop.width += offset
} else if (ord === 'sw') {
nextCrop.x -= offset
nextCrop.width += offset
nextCrop.height += offset
} else if (ord === 'ne') {
nextCrop.y += offset
nextCrop.width -= offset
nextCrop.height -= offset
} else if (ord === 'e') {
nextCrop.width -= offset
} else if (ord === 'se') {
nextCrop.width -= offset
nextCrop.height -= offset
}
} else if (key === 'ArrowRight') {
if (ord === 'nw') {
nextCrop.x += offset
nextCrop.y += offset
nextCrop.width -= offset
nextCrop.height -= offset
} else if (ord === 'w') {
// Niche: Will move right if minWidth hit.
nextCrop.x += offset
nextCrop.width -= offset
} else if (ord === 'sw') {
nextCrop.x += offset
nextCrop.width -= offset
nextCrop.height -= offset
} else if (ord === 'ne') {
nextCrop.y -= offset
nextCrop.width += offset
nextCrop.height += offset
} else if (ord === 'e') {
nextCrop.width += offset
} else if (ord === 'se') {
nextCrop.width += offset
nextCrop.height += offset
}
}
if (key === 'ArrowUp') {
if (ord === 'nw') {
nextCrop.x -= offset
nextCrop.y -= offset
nextCrop.width += offset
nextCrop.height += offset
} else if (ord === 'n') {
nextCrop.y -= offset
nextCrop.height += offset
} else if (ord === 'ne') {
nextCrop.y -= offset
nextCrop.width += offset
nextCrop.height += offset
} else if (ord === 'sw') {
nextCrop.x += offset
nextCrop.width -= offset
nextCrop.height -= offset
} else if (ord === 's') {
nextCrop.height -= offset
} else if (ord === 'se') {
nextCrop.width -= offset
nextCrop.height -= offset
}
} else if (key === 'ArrowDown') {
if (ord === 'nw') {
nextCrop.x += offset
nextCrop.y += offset
nextCrop.width -= offset
nextCrop.height -= offset
} else if (ord === 'n') {
// Niche: Will move down if minHeight hit.
nextCrop.y += offset
nextCrop.height -= offset
} else if (ord === 'ne') {
nextCrop.y += offset
nextCrop.width -= offset
nextCrop.height -= offset
} else if (ord === 'sw') {
nextCrop.x -= offset
nextCrop.width += offset
nextCrop.height += offset
} else if (ord === 's') {
nextCrop.height += offset
} else if (ord === 'se') {
nextCrop.width += offset
nextCrop.height += offset
}
}
return nextCrop
}
@@ -0,0 +1 @@
/// <reference types="vite/client" />