Lukáš Hudec24.3.2017Zpět

Upload a ořez obrázků: Část 1. - Klient v Reactu

Úvodem

Během vývoje na jednom konkrétním projektu se mi nepodařilo najít dobrý tutoriál nebo návod, jak jednoduše a efektivně řešit upload a ořez obrázku na platformě ReactJS & Redux, s využitím AWS / NodeJS na straně serveru. 

Proto vznikl tento článek: abyste měli k dispozici jednoduchou kuchařku s ušetřili čas při hledání na Google / Stack Overflow.

Veškerý kód v tomto článku je napsán v ECMAScript 2015, lépe známý jako ES6, který přináší základní vlastnosti jako arrow funkce, object/array destructuring atd. Také využívám JSX syntaxi, která umožní psát lépe čitelný kód pro React komponenty.

Tak pojďme na to.

Popis problému

Základní idea je: potřebujeme jednoduchý mechanismus, který umožní uživateli nahrát její/jeho obrázek a aplikovat na něj oříznutí (crop) před finálním odesláním. Tedy proces, který sestává ze dvou kroků - nahrání samotného zdrojového obrázku a následně uživatelský výběr oblasti v obrázku, která nás zajímá. Jakmile jsme spokojeni, server dostane crop parametry - parametry ořezu, aplikuje je na obrázek a vrátí odkaz na finální výsledek.

Zní to jednoduše ne? Nicméně, je s tím spojeno pár věcí, které je nutné ošetřit. Začněme s tím, že někdo nahraje obrázek příliš velký. Musí existovat nějaká kontrola, aby nám začátečníci (nebo zkušení útočníci) neposlali obrázek třeba přímo z fotoaparátu (> 2MB), protože zpracování takového obrázku vyžaduje hodně práce jak na straně serveru při zpracování, tak na straně prohlížeče při zobrazení. V našem řešení jsme se rozhodli obrázek upravit při prvním nahrání na server - zmenšit jej, pokud velikost přesahuje definovaná maxima.

Pokud je obrázek příliš malý, existují dvě řešení - můžeme nahrání prostě nepovolit, nebo se pokusit obrázek zvětšit pomocí metody upscaling, což může výrazně zhoršit jeho kvalitu. Abychom udrželi tento tutorial jednoduchý, nebudeme se ošetřením minimální velikosti zabývat.

Další typický parametr je třeba rozhodnout při konfiguraci - jak se bude ořezová oblast chovat. Typicky se rozhodujeme mezi fixním poměrem stran (ideální pro fotky, avatary) a volitelným ořezem stran (pozadí, obrázky do článků). Toto řeší jednoduše použitá knihovna pro ořez, kde se poměr stran nastavuje.

Client-side knihovny a frameworky

Náš projekt používá moderní přístup na bázi ReactJS s knihovnou pro správu stavu aplikace Redux, kterou používáme v kombinaci s ImmutableJS.

Přímo na ořezy obrázků jsem našel knihovnu React-cropper. To nám umožní použít již předvytvořený komponent v Reactu, který je založen na známém jQuery pluginu cropper. Pokud jej chcete vidět v akci, zde je demo. Pomocí ref je pak možné s pluginem cropper komunikovat.

Co vlastně cropper umí? Zde jsou základní funkce:

  • Crop, neboli ořez
  • Rotace
  • Zoom
  • Poměr stran

Pojďme si nyní ukázat, jak Cropper použít. Všimněte si atributu ref, který nám umožní s cropperem komunikovat.

<Cropper 
    ref='cropper'
    src={PATH_TO_IMAGE_SOURCE}
    aspectRatio={16 / 9} 
/> 

A nyní lze přistupovat k metodám pluginu cropper v nadřazené komponentě takto:

this.refs.cropper.getData();

To je z pohledu front-endu snad vše, co budeme potřebovat. Prosté, že?

Pár slov k serverovému řešení

Používáme AWS s S3 pro ukládání souborů a node.js lambda funkcemi pro komunikaci formou REST.

Zájemce o konkrétní detaily implementace odkazujeme na druhou část seriálu.

Komunikace mezi klientem (ReactJS & Redux) a serverem (node.js, AWS)

Vše vysvětluje jednoduchý obrázek (v angličtině):

Crop Guide

Pojďme se do něj ponořit hlouběji. Zde je příklad použití pro nahrání, dejme tomu, loga společnosti na web.

Z obrázku je patrné, že upload obrázku má 2 fáze. Za prvé si uživatel vybere, který soubor chce uploadovat. Ještě na straně klienta pomocí File object můžeme ověřit, že se jedná o obrázek (atribut type). Následně jej odešleme do útrob AWS. To uloží soubor dočasně na serveru, který nám vrátí odkaz na tento soubor.

Nyní nastavíme na straně klienta crop parametry - tedy ořezové souřadnice. Jak jsem už říkal, můžeme si vybrat mezi pevným a volným poměrem stran výsledného obrázku.

Fáze 2 začíná poté, co se rozhodnu, jak obrázek oříznout. Pošleme na server požadavek s crop parametry - což jsou startovní x, y souřadnice a výška / šířka. Poté proběhne samotné oříznutí obrázku, což je zodpovědnost serveru. Výsledek se uloží a my dostaneme odkaz na oříznutý obrázek.

Zní to dobře? Tak to pojďme implementovat.

Redux Asynchronous Actions

Předpokládám že znáte knihovnu Redux a koncept actions, abyste pochopili, co teď budeme dělat.

Jak jsem psal, potřebujeme dvě asynchronní akce pro uload a oříznutí obrázku. Pomůžeme si řešením od autora Reduxu Dan Abramova a použijeme Redux-thunk middleware. To se projeví tak, že v Reduxu nevyvoláváme akci formou klasického objektu, ale funkce, která jako parametr očekává dispatch. Tento se pak použije pro vyvolání (dispatchování) libovolného počtu dalších akcí v Reduxu, tentokrát už formou akce = jednoduchý objekt.

Takže budeme mít 2 akce. První pro počáteční upload:

export const sendFileToS3 = (credentials, file) => {
   // ...
}

Parametry:

  • credentials - objekt s AWS credentias (access key id, secret access key, session token)
  • file - je HTML5’s File API objekt.

A druhá akce pro ořez:

export const cropImage = (action, key, data) => {
   // ...
}

Parametry:

  • action - akce, která se vyvolá když dostaneme kladnou odezvu ze serveru
  • key - odkaz na řešený soubor v našem stavu (záleží na naší implementaci)
  • datacrop parametry, pole se čtyřmi hodnotami x, y, šířka, výška

Nyní detailněji. Nejprve si definujme funkci na upload na server. Zde pracujeme s knihovnou AWS a pošleme soubor přes S3 - což udělá funkce sendFileToS3:

// sendFileToS3
    const bucket = /* Název S3 bucketu */
    const maxSize = 1024 * 1024 * 1024; // 10MB
    AWS.config.credentials = {/* AWS credentials */};
    AWS.config.region = /* AWS region */    
    const bucket = new AWS.S3({ params: { Bucket: bucket } });

Následně thunk vrátí funkci místo action objektu. Jakmile je soubor úspěšně uploadnutý, dostaneme odkaz na dočasný soubor, který následně půjdeme oříznout.

// sendFileToS3
    return (dispatch) => {
        if (file) {
            if (file.size < maxSize) {
                const params = {
                    /* jakékoliv parametry co potřebuji v rámci API... */
                };
		  bucket.upload(params).on('httpUploadProgress', (evt) => {
                    // ... Upload status
            }).send((err, data) => {
                    if (err){
                        // ošetření chyb
                    } else {
                    	   dispatch(actions.saveTempFile(data.link,     
                               data.key));
                    }
                });
            } else {
                // ošetření chyby - soubor je příliš velký
            }
        }
    }
};

A nyní akce cropImage. Tato akce je vyvolána poté, co je definována ořezová oblast, a stiskneme tlačítko “Crop”. Potom se odešlou crop parametry na server, který provede fyzický ořez a pošle finální odkaz. Všimněte si apiGlobalClient, jde o instanci SDK generovanou z AWS pro použití s endpointy API.

export const cropImage = (action, key, data) => {
    return (dispatch) => {
        apiGlobalClient.filePhase2Post({
            /* jakékoliv parametry co potřebuji v rámci API... */
        }, {
            "data": {
                "key":  key,
                "x0":   data.x,
                "y0":   data.y,
                "w":    data.width,
                "h":    data.height,
            }
        }).then((response) => {
	     dispatch(action(response.data)));
        }).catch((error) => {
            // ošetření chyby
        })
    }
};

Nakonec, když dostaneme úspěšnou odpověď, odešleme akci pro uložení informace do dat našeho Redux stavu. Tato akce je v reálném projektu zpracována kombinací několika reducerů. Pro jednoduchost přikládám, jak by to vypadalo na příkladě jednoduchého reduceru. Všimněte si, že pro uložení samotného stavu používáme knihovnu immutable.js, jak jsme zmínili na začátku. Upravujeme propery profileImageSource, a immutable.js nám sama zajistí vytvoření nového objektu:

export const exampleReducer(state, action) => {
    switch(action.type) {
        //… ostatní případy
        case “SET_MEMBER_PROFILE_IMAGE”:
            return state.update(‘profileImageSource’, () =>    
    action.payload);
    }
}

A prostý action creator, tedy generátor akce Reduxu - action objectu:

export const setMemberProfileImage = data => ({
    type: “SET_MEMBER_PROFILE_IMAGE”,
    payload: data,
});

Navázání akce na tlačítko

Nyní k logice v React komponentě. Máme tlačítko “Crop”. Po kliknutí na něj se z cropperu vytáhnou crop parametry a pošlou se dál. Vyžujeme k tomu metodu cropperu getData():

<button 
    onClick={() =>     
        this.props.handleSubmit(this.refs.cropper.getData(true))}>
    Crop
</button>

A metoda handleSubmit je do komponenty formou props vložena přes mapDispatchToProps, což je standardní cesta v komunikaci React - Redux:

const mapDispatchToProps = dispatch => ({
    handleSubmit: data => dispatch(actions.cropImage(              
        actions.setMemberProfileImage,            
        this.props.temp.get('key'),
        data,
    )),
});

K samotnému propojení slouží funkce connect, která dekoruje náš komponent a přidává - přes jasně definované body - přístup k store a dispatch. K tomu je potřeba ještě jedna funkce, mapStateToProps. Ta předává potřebný rozsah state z Reduxu do komponenty.

Vybrat tyto data můžete přes tzv. selector funkce, které přijmou state jako parametr a vrátí data, která potřebujeme. V tomto případě jsem si uložil dočasný odkaz na obrázek ve stavovém stromě pod atributem temp:

const mapStateToProps = state => ({
    temp: state => state.get('temp'),
});

Všechno se pak setká v metodě connect:

export default connect(
    mapStateToProps, 
    mapDispatchToProps,
)(ImageInputDialog);

Takto potom může vypadat kód celé komponenty:

import React from 'react';
import { connect } from 'react-redux'; 
import Cropper from 'react-cropper';
import '../../../../../node_modules/cropperjs/dist/cropper.css';
import styles from './styles.css';
import * as actions from '../actions';

class ImageInputDialog extends React.Component {
    constructor(props) {
        super(props);
    };

    render = () => {
        const { 
            imageType, 
            temp, 
            handleCancel, 
            handleSubmit, 
            memberId 
        } = this.props;
        const uploadStatus = temp.get('uploadStatus');
        let aspectRatio = null;

        switch (imageType) {
            case 'logo':
            case 'profile':
                aspectRatio = 1;
                break;
            case 'cover':
                aspectRatio = 21 / 9;
                break;
            case 'other':
                aspectRatio = null;
                break;
        }

        return (
            <div> 
                {uploadStatus !== 'finished' ?
                    <div className='loading'>
			            Working... Please wait
                    </div>
                :
                    <Cropper
                        ref='cropper'
                        src={temp.get('link')}
                        style={{height: 500, width: '100%', overflow: 'hidden'}}
                        // Cropper.js options
                        aspectRatio={aspectRatio}
                        guides={false} />
                    <button
                        disabled={uploadStatus !== 'finished'}
                        onClick={handleCancel}
                    >
                        Cancel
                    </button>
                    <button
                        onClick={() => handleSubmit(
                            this.refs.cropper.getData(true),    
                            memberId
                        )} 
                    >
                        Crop
                    </button>
                }
            </div>
        );
    }
}

ImageInputDialog.propTypes = {
    ImageType: React.PropTypes.string,
    temp: React.PropTypes.object,
    handleSubmit: React.PropTypes.func,
    handleCancel: React.PropTypes.func,
};

const mapStateToProps = state => ({
    temp: state => state.get('temp'),
});

const mapDispatchToProps = dispatch => ({
    handleSubmit: data => dispatch(actions.cropImage(              
        actions.setMemberProfileImage,            
        this.props.temp.get('key'),
        data,
    )),
});

export default connect(
    mapStateToProps, 
    mapDispatchToProps,
)(ImageInputDialog);

Závěrem

Doufám, že tento článek byl přínosný a usnadnil vám řešení tohoto klasického problému. V druhé části seriálu se podíváme na serverové zpracování.

Lukáš Hudec

Front-end developer

Seriál: Upload a ořez obrázků - React/AWS

Upload a ořez obrázků: Část 2. - AWS backend

Pokračujeme v tématu uploadu a ořezu obrázků, nyní popisem backend části, kterou jsme stavěli na Amazon Web Services (AWS).

Marek Gach21.3.2018