Skip to main content

Image Editor

Idea#

This tutorial is not designated to describe the creating image editor from the scratch. All components already created and provided here. This tutorial is about the creating basic image editor functionality and integration one with the cropper.

Template#

The used components are available in the documentation repository.

note

To make this code works for Safari the polyfill is needed. Otherwise you can use any library that implements custom filters for canvas.

You can use the context-filter-polyfill for example:

yarn add context-filter-polyfill
import React, { useState } from 'react';import { Navigation } from './components/Navigation';import { Slider } from './components/Slider';import './ImageEditor.scss';
// The polyfill for Safari browser. The dynamic require is needed to work with SSRif (typeof window !== 'undefined') {    require('context-filter-polyfill');}
export const ImageEditor = () => {    const [mode, setMode] = useState('crop');    const [adjustments, setAdjustments] = useState({        brightness: 0,        hue: 0,        saturation: 0,        contrast: 0,    });
    const onChangeValue = (value: number) => {        if (mode in adjustments) {            setAdjustments((previousValue) => ({                ...previousValue,                [mode]: value,            }));        }    };
    return (        <div className={'image-editor'}>            <div className="image-editor__cropper">                {mode !== 'crop' && (                    <Slider className="image-editor__slider" value={adjustments[mode]} onChange={onChangeValue} />                )}            </div>            <Navigation mode={mode} onChange={setMode} />        </div>    );};

Integrate the cropper#

Let's add the cropper to the template above. It's trivial, but we need to block the cropper if an user choice any other mode than crop.

import React, { useState } from 'react';import { Cropper } from 'react-advanced-cropper';import { Navigation } from './components/Navigation';import { Slider } from './components/Slider';import './ImageEditor.scss';
export const ImageEditor = () => {    const [src, setSrc] = useState(        'https://images.pexels.com/photos/12051260/pexels-photo-12051260.jpeg?auto=compress&cs=tinysrgb&h=750&w=1260',    );    const [mode, setMode] = useState('crop');    const [adjustments, setAdjustments] = useState({        brightness: 0,        hue: 0,        saturation: 0,        contrast: 0,    });
    const onChangeValue = (value: number) => {        if (mode in adjustments) {            setAdjustments((previousValue) => ({                ...previousValue,                [mode]: value,            }));        }    };
    const onUpload = (blob: string) => {        setAdjustments({            brightness: 0,            hue: 0,            saturation: 0,            contrast: 0,        });        setMode('crop');        setSrc(blob);    };
    const onDownload = () => {        if (cropperRef.current) {            const newTab = window.open();            if (newTab) {                newTab.document.body.innerHTML = `<img src="${cropperRef.current.getCanvas()?.toDataURL()}"/>`;            }        }    };
    const cropperEnabled = mode === 'crop';
    return (        <div className={'image-editor'}>            <div className="image-editor__cropper">                <Cropper                    src={src}                    stencilProps={{                        movable: cropperEnabled,                        resizable: cropperEnabled,                        lines: cropperEnabled,                        handlers: cropperEnabled,                        overlayClassName: cn(                            'image-editor__cropper-overlay',                            !cropperEnabled && 'image-editor__cropper-overlay--faded',                        ),                    }}                    backgroundWrapperProps={{                        scaleImage: cropperEnabled,                        moveImage: cropperEnabled,                    }}                />                {mode !== 'crop' && (                    <Slider className="image-editor__slider" value={adjustments[mode]} onChange={onChangeValue} />                )}            </div>            <Navigation mode={mode} onChange={setMode} onUpload={onUpload} onDownload={onDownload} />        </div>    );};

But how we will adjust an uploaded image? That's a tough question.

  1. We can change src and pass to it the Blob or DataURL of changed images. The problem here that cropper will reset all of our changes. The performance of this solution will be terrible alike.

  2. We can replace src by using of setImage method. If you use this method to change the actual cropper image it won't reset the changes, so it solves one of the problems. But performance will be still awful.

  3. The right way is the replacing the default background component by a custom one.

Custom background component#

The background component is the special component that uses by the cropper to:

  • display the current image
  • retrieve the ref to content (HTMLImageElement or HTMLCanvasElement) to pass it to its own canvas and crop it

The default background component is CropperBackgroundImage. It's pretty straightforward.

import React, { forwardRef, Event } from 'react';import cn from 'classnames';import {    CropperTransitions,    CropperImage,    CropperState,    getBackgroundStyle} from 'react-advanced-cropper'
interface Props {    className?: string;    image: CropperImage | null;    state: CropperState | null;    transitions?: CropperTransitions;    crossOrigin?: 'anonymous' | 'use-credentials' | boolean;}
export const CropperBackgroundImage = forwardRef<HTMLImageElement, Props>(    ({ className, image, state, crossOrigin, transitions}: Props, ref) => {        const style = image && state ? getBackgroundStyle(image, state, transitions) : {};
        const src = image ? image.src : undefined;
        return src ? (            <img                key={src}                ref={ref}                className={cn('react-cropper-background-image', className)}                src={src}                crossOrigin={crossOrigin === true ? 'anonymous' : crossOrigin || undefined}                style={style}                onMouseDown={(e: Event) => e.preventDefault()}            />        ) : null;    },);
CropperBackgroundImage.displayName = 'CropperBackgroundImage';

Like we said before, we can forward ref to either HTMLImageElement or HTMLCanvasElement elements. The last one can be used to draw image with all adjustments that we made. It's performant and very flexible way.

Let's do it.

First of all we should create adjustable image, that will be an alternative for <img/> tag.advanced

import React, { forwardRef, useRef, CSSProperties, useLayoutEffect } from 'react';import cn from 'classnames';import { mergeRefs } from 'react-advanced-cropper';import './AdjustableImage.scss';
interface Props {    src?: string;    className?: string;    crossOrigin?: 'anonymous' | 'use-credentials' | boolean;    brightness?: number;    saturation?: number;    hue?: number;    contrast?: number;    style?: CSSProperties;}
export const AdjustableImage = forwardRef<HTMLCanvasElement, Props>(    ({ src, className, crossOrigin, brightness = 0, saturation = 0, hue = 0, contrast = 0, style }: Props, ref) => {        const imageRef = useRef<HTMLImageElement>(null);        const canvasRef = useRef<HTMLCanvasElement>(null);
        const drawImage = () => {            const image = imageRef.current;            const canvas = canvasRef.current;            if (canvas && image && image.complete) {                const ctx = canvas.getContext('2d');                canvas.width = image.naturalWidth;                canvas.height = image.naturalHeight;
                if (ctx) {                    ctx.filter = [                        `brightness(${100 + brightness * 100}%)`,                        `contrast(${100 + contrast * 100}%)`,                        `saturate(${100 + saturation * 100}%)`,                        `hue-rotate(${hue * 360}deg)`,                    ].join(' ');
                    ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight);                }            }        };
        useLayoutEffect(() => {            drawImage();        }, [src, brightness, saturation, hue, contrast]);
        return (            <>                <canvas                    key={`${src}-canvas`}                    ref={mergeRefs([ref, canvasRef])}                    className={cn('adjustable-image-element', className)}                    style={style}                />                {src ? (                    <img                        key={`${src}-img`}                        ref={imageRef}                        className={'adjustable-image-source'}                        src={src}                        crossOrigin={crossOrigin === true ? 'anonymous' : crossOrigin || undefined}                        onLoad={drawImage}                    />                ) : null}            </>        );    },);
AdjustableImage.displayName = 'AdjustableImage';

Then, we will use this component to create the custom cropper background image component.

import React, { forwardRef } from 'react';import { CropperTransitions, CropperImage, CropperState } from 'react-advanced-cropper';import { getBackgroundStyle } from 'advanced-cropper';import { AdjustableImage } from './AdjustableImage';
interface DesiredCropperRef {    getState: () => CropperState;    getTransitions: () => CropperTransitions;    getImage: () => CropperImage;}
interface Props {    className?: string;    cropper: DesiredCropperRef;    crossOrigin?: 'anonymous' | 'use-credentials' | boolean;    brightness?: number;    saturation?: number;    hue?: number;    contrast?: number;}
export const AdjustableCropperBackground = forwardRef<HTMLCanvasElement, Props>(    ({ className, cropper, crossOrigin, brightness = 0, saturation = 0, hue = 0, contrast = 0 }: Props, ref) => {        const state = cropper.getState();        const transitions = cropper.getTransitions();        const image = cropper.getImage();
        const style = image && state ? getBackgroundStyle(image, state, transitions) : {};
        return (            <AdjustableImage                src={image?.src}                crossOrigin={crossOrigin}                brightness={brightness}                saturation={saturation}                hue={hue}                contrast={contrast}                ref={ref}                className={className}                style={style}            />        );    },);

Let's use then the created background component in our image editor:

<Cropper    ...    backgroundComponent={AdjustableCropperBackground}    backgroundProps={adjustments}/>

Preview#

What's about the cropper preview? The CropperPreview component is pretty similar to the cropper itself by its structure.

First of all, you should create the custom preview background component.

import React from 'react';import { CropperTransitions, CropperImage, CropperState, Size } from 'react-advanced-cropper';import { getPreviewStyle } from 'advanced-cropper';import { AdjustableImage } from './AdjustableImage';
interface DesiredCropperRef {    getState: () => CropperState;    getTransitions: () => CropperTransitions;    getImage: () => CropperImage;}
interface Props {    className?: string;    cropper: DesiredCropperRef;    crossOrigin?: 'anonymous' | 'use-credentials' | boolean;    brightness?: number;    saturation?: number;    hue?: number;    contrast?: number;    size?: Size | null;}
export const AdjustablePreviewBackground = ({    className,    cropper,    crossOrigin,    brightness = 0,    saturation = 0,    hue = 0,    contrast = 0,    size,}: Props) => {    const state = cropper.getState();    const transitions = cropper.getTransitions();    const image = cropper.getImage();
    const style = image && state && size ? getPreviewStyle(image, state, size, transitions) : {};
    return (        <AdjustableImage            src={image?.src}            crossOrigin={crossOrigin}            brightness={brightness}            saturation={saturation}            hue={hue}            contrast={contrast}            className={className}            style={style}        />    );};

Then just pass it to CropperPreview:

<CropperPreview    ...    backgroundComponent={AdjustablePreviewBackground}    backgroundProps={adjustments}/>

After that you will get the same image editor like you see in the start of tutorial. The full source code of the image editor is available in the following sandbox.