UNPKG

@mbarde/volto-image-crop-widget

Version:

Volto add-on: Extends Voltos FileWidget to be able to crop images in-place.

381 lines (352 loc) 12.7 kB
import { useEffect, useRef, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import { Button, Label, Modal } from 'semantic-ui-react'; // eslint-disable-next-line import/no-unresolved import FileWidgetOrig from '@plone/volto-original/components/manage/Widgets/FileWidget'; import { Icon } from '@plone/volto/components'; import { flattenToAppURL } from '@plone/volto/helpers'; import config from '@plone/volto/registry'; import checkSVG from '@plone/volto/icons/check.svg'; import cropSVG from '@plone/volto/icons/cut.svg'; import horizontalSVG from '@plone/volto/icons/horizontal.svg'; import undoSVG from '@plone/volto/icons/undo.svg'; import verticalSVG from '@plone/volto/icons/vertical.svg'; import warnSVG from '@plone/volto/icons/warning.svg'; import ReactCrop, { centerCrop, makeAspectCrop } from 'react-image-crop'; import 'react-image-crop/dist/ReactCrop.css'; import messages from '@mbarde/volto-image-crop-widget/messages'; import './style.less'; /* from original FileWidget */ const imageMimetypes = [ 'image/png', 'image/jpeg', 'image/webp', 'image/jpg', 'image/gif', 'image/svg+xml', ]; function adjustImageBrightness(image, brightnessValue) { const canvas = document.createElement('canvas'); canvas.height = image.naturalHeight; canvas.width = image.naturalWidth; const ctx = canvas.getContext('2d'); ctx.filter = `brightness(${brightnessValue}%)`; ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight); const base64Image = canvas.toDataURL('image/jpeg'); return base64Image; } function flipImage(image, horizontally) { const width = image.naturalWidth; const height = image.naturalHeight; const scaleH = horizontally ? -1 : 1, // Set horizontal scale to -1 if flip horizontal scaleV = horizontally ? 1 : -1, // Set verical scale to -1 if flip vertical posX = horizontally ? width * -1 : 0, // Set x position to -100% if flip horizontal posY = horizontally ? 0 : height * -1; // Set y position to -100% if flip vertical const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.save(); // Save the current state ctx.scale(scaleH, scaleV); // Set scale to flip the image ctx.drawImage(image, posX, posY, width, height); // draw the image ctx.restore(); // Restore the last saved state const base64Image = canvas.toDataURL('image/jpeg'); return base64Image; } function getCropAsBase64(image, pixelCrop) { /* crop selection is in image element dimensions, but the original image could be bigger (here: natural* vs. client*), we need to scale crop selection accordingly (based on: https://stackoverflow.com/a/52274005) */ const fx = image.naturalWidth / image.clientWidth; const fy = image.naturalHeight / image.clientHeight; const cropX = pixelCrop.x * fx; const cropY = pixelCrop.y * fy; const cropWidth = pixelCrop.width * fx; const cropHeight = pixelCrop.height * fy; const canvas = document.createElement('canvas'); canvas.width = cropWidth; canvas.height = cropHeight; const ctx = canvas.getContext('2d'); ctx.drawImage( image, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight, ); const base64Image = canvas.toDataURL('image/jpeg'); return base64Image; } const FileWidget = (props) => { const { id, value, onChange } = props; const intl = useIntl(); const imgRef = useRef(null); const [curAspectRatio, setCurAspectRatio] = useState(false); const [crop, setCrop] = useState(); const [modalOpen, setModalOpen] = useState(false); const [warnModalOpen, setWarnModalOpen] = useState(false); const [isImage, setIsImage] = useState(false); const [history, setHistory] = useState([]); const [brightness, setBrightness] = useState(100); // in % useEffect(() => { if (value && imageMimetypes.includes(value['content-type'])) { setIsImage(true); } /* when a new image has been uploaded value.download does not exist yet */ const imgsrc = value?.download ? flattenToAppURL(value?.download) + '?id=' + Date.now() : value?.data ? `data:${value['content-type']};${value.encoding},${value.data}` : null; setImgSrc(imgsrc); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [value, value?.download, value?.data]); const [imgSrc, setImgSrc] = useState(null); /* no need for our fancy new cropping options if file is not an image */ if (!isImage) return <FileWidgetOrig {...props} />; const onUndo = (evt) => { if (history.length > 0) { setImgSrc(history[0]); setHistory(history.slice(1)); } evt.preventDefault(); }; const applyChanges = (evt) => { const data = brightness === 100 ? imgSrc : adjustImageBrightness(imgRef.current, brightness); onChange(id, { data: data.replace('data:image/jpeg;base64,', ''), encoding: 'base64', 'content-type': 'image/jpeg', filename: value?.filename || 'changed.jpeg', history: value, }); evt.preventDefault(); setModalOpen(false); setWarnModalOpen(false); }; const onFlip = (evt, horizontal) => { setHistory((history) => [imgSrc, ...history]); const data = flipImage(imgRef.current, horizontal); setImgSrc(data); setCrop(); evt.preventDefault(); }; const onCrop = (evt) => { setHistory((history) => [imgSrc, ...history]); const data = getCropAsBase64(imgRef.current, crop); setImgSrc(data); setCrop(); evt.preventDefault(); }; const initCrop = (newAspect) => { if (newAspect === false) { setCurAspectRatio(false); return; } if (imgRef?.current) { const { naturalWidth: width, naturalHeight: height } = imgRef.current; const crop = centerCrop( makeAspectCrop( { // You don't need to pass a complete crop into // makeAspectCrop or centerCrop. unit: '%', width: 50, }, newAspect, width, height, ), width, height, ); setCurAspectRatio(newAspect); setCrop(crop); } }; const aspectRatios = config.settings.image_crop_aspect_ratios || config.settings.image_crop_apect_ratios || // keep typo for compatibility []; return ( <div className="field-wrapper-image-container"> <div className="btn-wrapper"> {imgSrc && ( <Modal className="modal-image-crop" closeIcon size="fullscreen" trigger={ <Button className="btn-crop" title={intl.formatMessage(messages.cropImage)} onClick={(evt) => evt.preventDefault()} > <Icon name={cropSVG} size="20px"></Icon> <span>{intl.formatMessage(messages.cropImage)}</span> </Button> } onClose={() => setModalOpen(false)} onOpen={() => setModalOpen(true)} open={modalOpen} > <Modal.Header> {intl.formatMessage(messages.cropImage)} </Modal.Header> <Modal.Content image> <ReactCrop crop={crop} onChange={(c) => setCrop(c)} aspect={curAspectRatio} > <img /* Modifying `imgSrc` directly when brightness changes, could easily result in data loss on extreme bright or dark values. Instead we visualize these changes only via CSS and compute actual data only on apply. */ style={{ filter: `brightness(${brightness}%)` }} src={imgSrc} ref={imgRef} alt="to crop" /> </ReactCrop> </Modal.Content> <Modal.Actions> {history && history.length > 0 && ( <Button icon className="btn-undo" onClick={onUndo} title={intl.formatMessage(messages.undo)} secondary > <Icon name={undoSVG} size="14px" /> <span>{intl.formatMessage(messages.undo)}</span> </Button> )} <Button.Group> <Button icon="plus" onClick={() => { setBrightness(brightness + 10); }} title={intl.formatMessage(messages.lighten)} aria-label={intl.formatMessage(messages.lighten)} /> <Button as="div" labelPosition="left"> <Label className="brightness" basic title={intl.formatMessage(messages.brightness)} > {brightness + '%'} </Label> <Button icon="minus" onClick={() => { setBrightness(brightness - 10); }} title={intl.formatMessage(messages.darken)} aria-label={intl.formatMessage(messages.darken)} /> </Button> </Button.Group> <Button.Group> <Button icon onClick={(evt) => { onFlip(evt, true); }} title={intl.formatMessage(messages.flipHorizontally)} aria-label={intl.formatMessage(messages.flipHorizontally)} > <Icon name={horizontalSVG} size="14px" /> </Button> <Button icon onClick={(evt) => { onFlip(evt, false); }} title={intl.formatMessage(messages.flipVertically)} aria-label={intl.formatMessage(messages.flipVertically)} > <Icon name={verticalSVG} size="14px" /> </Button> </Button.Group> {aspectRatios.length > 0 && ( <Button.Group> {aspectRatios.map((aspect, index) => { const isActive = aspect.ratio === curAspectRatio; return ( <Button key={`ratio-${index}`} active={isActive} onClick={() => { if (isActive) initCrop(false); else initCrop(aspect.ratio); }} > {aspect.label} </Button> ); })} </Button.Group> )} <Button icon onClick={onCrop} disabled={!crop} positive> <Icon name={cropSVG} size="14px"></Icon>{' '} {intl.formatMessage(messages.crop)} </Button> <Button icon onClick={() => setWarnModalOpen(true)} disabled={history.length === 0 && brightness === 100} positive > <Icon name={checkSVG} size="14px" />{' '} {intl.formatMessage(messages.apply)} </Button> </Modal.Actions> <Modal onClose={() => setWarnModalOpen(false)} open={warnModalOpen} size="small" centered={true} > <Modal.Header> <Icon name={warnSVG} size="24px" style={{ marginBottom: '-4px' }} />{' '} {intl.formatMessage(messages.warning)} </Modal.Header> <Modal.Content> <p>{intl.formatMessage(messages.hint_save_after_crop)}</p> </Modal.Content> <Modal.Actions> <Button primary content={intl.formatMessage(messages.understood)} onClick={applyChanges} /> </Modal.Actions> </Modal> </Modal> )} </div> <FileWidgetOrig {...props} /> </div> ); }; export default FileWidget;