react-avatar-edit-zports
Version:
ReactJS component to upload, crop, and preview avatars with drag and drop support
534 lines (460 loc) • 14.6 kB
JSX
import React from 'react'
import Konva from 'konva/src/Core'
import 'konva/src/shapes/Image'
import 'konva/src/shapes/Circle'
import 'konva/src/shapes/Rect'
import 'konva/src/shapes/Path'
import 'konva/src/Animation'
import 'konva/src/DragAndDrop'
import Dropzone from 'react-dropzone';
class Avatar extends React.Component {
static defaultProps = {
shadingColor: 'grey',
shadingOpacity: 0.6,
cropRadius: 100,
cropColor: 'white',
closeIconColor: 'white',
lineWidth: 4,
minCropRadius: 30,
backgroundColor: 'grey',
mimeTypes: 'image/jpeg,image/png',
mobileScaleSpeed: 0.5, // experimental
onClose: () => {
},
onCrop: () => {
},
onFileLoad: () => {
},
onImageLoad: () => {
},
label: 'Choose a file',
labelStyle: {
fontSize: '1.25em',
fontWeight: '700',
color: 'black',
display: 'inline-block',
fontFamily: 'sans-serif',
cursor: 'pointer'
},
borderStyle: {
border: '2px solid #979797',
borderStyle: 'dashed',
borderRadius: '8px',
textAlign: 'center'
}
};
constructor(props) {
super(props);
const containerId = this.generateHash('avatar_container');
const loaderId = this.generateHash('avatar_loader');
this.onFileLoad = this.onFileLoad.bind(this);
this.onCloseClick = this.onCloseClick.bind(this);
this.state = {
imgWidth: 0,
imgHeight: 0,
scale: 1,
containerId,
loaderId,
lastMouseY: 0,
showLoader: !(this.props.src || this.props.img)
}
}
get lineWidth() {
return this.props.lineWidth
}
get containerId() {
return this.state.containerId
}
get closeIconColor() {
return this.props.closeIconColor
}
get cropColor() {
return this.props.cropColor
}
get loaderId() {
return this.state.loaderId
}
get mimeTypes() {
return this.props.mimeTypes
}
get backgroundColor() {
return this.props.backgroundColor
}
get shadingColor() {
return this.props.shadingColor
}
get shadingOpacity() {
return this.props.shadingOpacity
}
get mobileScaleSpeed() {
return this.props.mobileScaleSpeed
}
get cropRadius() {
return this.state.cropRadius
}
get minCropRadius() {
return this.props.minCropRadius
}
get scale() {
return this.state.scale
}
get width() {
return this.state.imgWidth
}
get halfWidth() {
return this.state.imgWidth / 2
}
get height() {
return this.state.imgHeight
}
get halfHeight() {
return this.state.imgHeight / 2
}
get image() {
return this.state.image
}
generateHash(prefix) {
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
return prefix + '-' + s4() + '-' + s4() + '-' + s4()
}
onCloseCallback() {
this.props.onClose()
}
onCropCallback(img) {
this.props.onCrop(img)
}
onFileLoadCallback(file) {
this.props.onFileLoad(file)
}
onImageLoadCallback(image) {
this.props.onImageLoad(image)
}
componentDidMount() {
if (this.state.showLoader) return;
const image = this.props.img || new Image();
if (!this.props.img && this.props.src) image.src = this.props.src;
this.setState({ image }, () => {
if (this.image.complete) return this.init();
this.image.onload = () => {
this.onImageLoadCallback(this.image);
this.init()
}
})
}
onFileLoad(e) {
e.preventDefault();
let reader = new FileReader();
let file = e.target.files[0];
this.onFileLoadCallback(file);
const image = new Image();
const ref = this;
reader.onloadend = () => {
image.src = reader.result;
ref.setState({ image, file, showLoader: false }, () => {
if (ref.image.complete) return ref.init();
ref.image.onload = () => ref.init()
})
};
reader.readAsDataURL(file)
}
handleDrop = ([file]) => {
if (!file) return;
let reader = new FileReader();
this.onFileLoadCallback(file);
const image = new Image();
const ref = this;
reader.onloadend = () => {
image.src = reader.result;
ref.setState({ image, file, showLoader: false }, () => {
if (ref.image.complete) return ref.init();
ref.image.onload = () => ref.init()
})
};
reader.readAsDataURL(file)
};
onCloseClick() {
this.setState({ showLoader: true }, () => this.onCloseCallback())
}
init() {
const originalWidth = this.image.width;
const originalHeight = this.image.height;
const ration = originalHeight / originalWidth;
const { imageWidth, imageHeight } = this.props;
let imgHeight;
let imgWidth;
if (imageHeight && imageWidth) {
console.warn('The imageWidth and imageHeight properties can not be set together, using only imageWidth.');
}
if (imageHeight && !imageWidth) {
imgHeight = imageHeight || originalHeight;
imgWidth = imgHeight / ration;
} else if (imageWidth) {
imgWidth = imageWidth;
imgHeight = imgWidth * ration || originalHeight;
} else {
imgHeight = this.props.height || originalHeight;
imgWidth = imgHeight / ration;
}
const scale = imgHeight / originalHeight;
const cropRadius = imgWidth / 4;
this.setState({
imgWidth,
imgHeight,
scale,
cropRadius
}, this.initCanvas)
}
initCanvas() {
const stage = this.initStage();
const background = this.initBackground();
const shading = this.initShading();
const crop = this.initCrop();
const cropStroke = this.initCropStroke();
const resize = this.initResize();
const resizeIcon = this.initResizeIcon();
const layer = new Konva.Layer();
layer.add(background);
layer.add(shading);
layer.add(cropStroke);
layer.add(crop);
layer.add(resize);
layer.add(resizeIcon);
stage.add(layer);
const scaledRadius = (scale = 0) => crop.radius() - scale;
const isLeftCorner = scale => crop.x() - scaledRadius(scale) < 0;
const calcLeft = () => crop.radius() + 1;
const isTopCorner = scale => crop.y() - scaledRadius(scale) < 0;
const calcTop = () => crop.radius() + 1;
const isRightCorner = scale => crop.x() + scaledRadius(scale) > stage.width();
const calcRight = () => stage.width() - crop.radius() - 1;
const isBottomCorner = scale => crop.y() + scaledRadius(scale) > stage.height();
const calcBottom = () => stage.height() - crop.radius() - 1;
const isNotOutOfScale = scale => !isLeftCorner(scale) && !isRightCorner(scale) && !isBottomCorner(scale) && !isTopCorner(scale);
const calcScaleRadius = scale => scaledRadius(scale) >= this.minCropRadius ? scale : crop.radius() - this.minCropRadius;
const calcResizerX = x => x + (crop.radius() * 0.86);
const calcResizerY = y => y - (crop.radius() * 0.5);
const moveResizer = (x, y) => {
resize.x(calcResizerX(x) - 8);
resize.y(calcResizerY(y) - 8);
resizeIcon.x(calcResizerX(x) - 8);
resizeIcon.y(calcResizerY(y) - 10)
};
const getPreview = () => crop.toDataURL({
x: crop.x() - crop.radius(),
y: crop.y() - crop.radius(),
width: crop.radius() * 2,
height: crop.radius() * 2
});
const onScaleCallback = (scaleY) => {
const scale = scaleY > 0 || isNotOutOfScale(scaleY) ? scaleY : 0;
cropStroke.radius(cropStroke.radius() - calcScaleRadius(scale));
crop.radius(crop.radius() - calcScaleRadius(scale));
resize.fire('resize')
};
this.onCropCallback(getPreview());
crop.on("dragmove", () => crop.fire('resize'));
crop.on("dragend", () => this.onCropCallback(getPreview()));
crop.on('resize', () => {
const x = isLeftCorner() ? calcLeft() : (isRightCorner() ? calcRight() : crop.x());
const y = isTopCorner() ? calcTop() : (isBottomCorner() ? calcBottom() : crop.y());
moveResizer(x, y);
crop.setFillPatternOffset({ x: x / this.scale, y: y / this.scale });
crop.x(x);
cropStroke.x(x);
crop.y(y);
cropStroke.y(y)
});
crop.on("mouseenter", () => stage.container().style.cursor = 'move');
crop.on("mouseleave", () => stage.container().style.cursor = 'default');
crop.on('dragstart', () => stage.container().style.cursor = 'move');
crop.on('dragend', () => stage.container().style.cursor = 'default');
resize.on("touchstart", (evt) => {
resize.on("dragmove", (dragEvt) => {
if (dragEvt.evt.type !== 'touchmove') return;
const scaleY = (dragEvt.evt.changedTouches['0'].pageY - evt.evt.changedTouches['0'].pageY) || 0;
onScaleCallback(scaleY * this.mobileScaleSpeed)
})
});
resize.on("dragmove", (evt) => {
if (evt.evt.type === 'touchmove') return;
const newMouseY = evt.evt.y;
const ieScaleFactor = newMouseY ? (newMouseY - this.state.lastMouseY) : undefined;
const scaleY = evt.evt.movementY || ieScaleFactor || 0;
this.setState({
lastMouseY: newMouseY,
});
onScaleCallback(scaleY)
});
resize.on("dragend", () => this.onCropCallback(getPreview()));
resize.on('resize', () => moveResizer(crop.x(), crop.y()));
resize.on("mouseenter", () => stage.container().style.cursor = 'nesw-resize');
resize.on("mouseleave", () => stage.container().style.cursor = 'default');
resize.on('dragstart', (evt) => {
this.setState({
lastMouseY: evt.evt.y,
});
stage.container().style.cursor = 'nesw-resize'
});
resize.on('dragend', () => stage.container().style.cursor = 'default')
}
initStage() {
return new Konva.Stage({
container: this.containerId,
width: this.width,
height: this.height
})
}
initBackground() {
return new Konva.Image({
x: 0,
y: 0,
width: this.width,
height: this.height,
image: this.image
})
}
initShading() {
return new Konva.Rect({
x: 0,
y: 0,
width: this.width,
height: this.height,
fill: this.shadingColor,
strokeWidth: 4,
opacity: this.shadingOpacity
})
}
initCrop() {
return new Konva.Circle({
x: this.halfWidth,
y: this.halfHeight,
radius: this.cropRadius,
fillPatternImage: this.image,
fillPatternOffset: {
x: this.halfWidth / this.scale,
y: this.halfHeight / this.scale
},
fillPatternScale: {
x: this.scale,
y: this.scale
},
opacity: 1,
draggable: true,
dashEnabled: true,
dash: [10, 5]
})
}
initCropStroke() {
return new Konva.Circle({
x: this.halfWidth,
y: this.halfHeight,
radius: this.cropRadius,
stroke: this.cropColor,
strokeWidth: this.lineWidth,
strokeScaleEnabled: true,
dashEnabled: true,
dash: [10, 5]
})
}
initResize() {
return new Konva.Rect({
x: this.halfWidth + this.cropRadius * 0.86 - 8,
y: this.halfHeight + this.cropRadius * -0.5 - 8,
width: 16,
height: 16,
draggable: true,
dragBoundFunc: function (pos) {
return {
x: this.getAbsolutePosition().x,
y: pos.y
}
}
})
}
initResizeIcon() {
return new Konva.Path({
x: this.halfWidth + this.cropRadius * 0.86 - 8,
y: this.halfHeight + this.cropRadius * -0.5 - 10,
data: 'M47.624,0.124l12.021,9.73L44.5,24.5l10,10l14.661-15.161l9.963,12.285v-31.5H47.624z M24.5,44.5 L9.847,59.653L0,47.5V79h31.5l-12.153-9.847L34.5,54.5L24.5,44.5z',
fill: this.cropColor,
scale: {
x: 0.2,
y: 0.2
}
})
}
render() {
const style = {
display: 'flex',
justifyContent: 'center',
backgroundColor: this.backgroundColor,
width: this.props.width || this.width,
position: 'relative'
};
const inputStyle = {
width: 0.1,
height: 0.1,
opacity: 0,
overflow: 'hidden',
position: 'absolute',
zIndex: -1,
};
const label = this.props.label;
const labelStyle = { ...this.props.labelStyle, ...{ lineHeight: (this.props.height || 200) + 'px' } };
const borderStyle = {
...this.props.borderStyle, ...{
width: this.props.width || 200,
height: this.props.height || 200
}
};
const closeBtnStyle = {
position: 'absolute',
zIndex: 999,
cursor: 'pointer',
left: '10px',
top: '10px'
};
return (
<div>
{
this.state.showLoader
? <Dropzone
onDrop={this.handleDrop}
disableClick
style={{ width: this.props.width, height: this.props.height }}
accept={this.mimeTypes}
>
<div style={borderStyle}>
<input
onChange={(e) => this.onFileLoad(e)}
name={this.loaderId} type="file"
id={this.loaderId}
style={inputStyle}
accept={this.mimeTypes}
/>
<label htmlFor={this.loaderId} style={labelStyle}>{label}</label>
</div>
</Dropzone>
: <div style={style}>
<svg
onClick={this.onCloseClick}
style={closeBtnStyle}
viewBox="0 0 475.2 475.2"
width="20px" height="20px">
<g>
<path
d="M405.6,69.6C360.7,24.7,301.1,0,237.6,0s-123.1,24.7-168,69.6S0,174.1,0,237.6s24.7,123.1,69.6,168s104.5,69.6,168,69.6 s123.1-24.7,168-69.6s69.6-104.5,69.6-168S450.5,114.5,405.6,69.6z M386.5,386.5c-39.8,39.8-92.7,61.7-148.9,61.7 s-109.1-21.9-148.9-61.7c-82.1-82.1-82.1-215.7,0-297.8C128.5,48.9,181.4,27,237.6,27s109.1,21.9,148.9,61.7 C468.6,170.8,468.6,304.4,386.5,386.5z"
fill={this.closeIconColor} />
<path
d="M342.3,132.9c-5.3-5.3-13.8-5.3-19.1,0l-85.6,85.6L152,132.9c-5.3-5.3-13.8-5.3-19.1,0c-5.3,5.3-5.3,13.8,0,19.1 l85.6,85.6l-85.6,85.6c-5.3,5.3-5.3,13.8,0,19.1c2.6,2.6,6.1,4,9.5,4s6.9-1.3,9.5-4l85.6-85.6l85.6,85.6c2.6,2.6,6.1,4,9.5,4 c3.5,0,6.9-1.3,9.5-4c5.3-5.3,5.3-13.8,0-19.1l-85.4-85.6l85.6-85.6C347.6,146.7,347.6,138.2,342.3,132.9z"
fill={this.closeIconColor} />
</g>
</svg>
<div id={this.containerId} />
</div>
}
</div>
)
}
}
export default Avatar