@uppy/image-editor
Version:
Image editor and cropping UI
341 lines (340 loc) • 12.7 kB
JavaScript
import { jsx as _jsx } from "preact/jsx-runtime";
import { UIPlugin } from '@uppy/core';
import Cropper from 'cropperjs';
import packageJson from '../package.json' with { type: 'json' };
import Editor from './Editor.js';
import locale from './locale.js';
import getCanvasDataThatFitsPerfectlyIntoContainer from './utils/getCanvasDataThatFitsPerfectlyIntoContainer.js';
import getScaleFactorThatRemovesDarkCorners from './utils/getScaleFactorThatRemovesDarkCorners.js';
import limitCropboxMovementOnMove from './utils/limitCropboxMovementOnMove.js';
import limitCropboxMovementOnResize from './utils/limitCropboxMovementOnResize.js';
const defaultEditorState = {
angle: 0,
angleGranular: 0,
isFlippedHorizontally: false,
aspectRatio: 'free',
cropperReady: false,
};
const defaultCropperOptions = {
viewMode: 0,
background: false,
autoCropArea: 1,
responsive: true,
minCropBoxWidth: 70,
minCropBoxHeight: 70,
croppedCanvasOptions: {},
initialAspectRatio: 0,
};
const defaultActions = {
revert: true,
rotate: true,
granularRotate: true,
flip: true,
zoomIn: true,
zoomOut: true,
cropSquare: true,
cropWidescreen: true,
cropWidescreenVertical: true,
};
const defaultOptions = {
// `quality: 1` increases the image size by orders of magnitude - 0.8 seems to be the sweet spot.
// see https://github.com/fengyuanchen/cropperjs/issues/538#issuecomment-1776279427
quality: 0.8,
actions: defaultActions,
cropperOptions: defaultCropperOptions,
};
export default class ImageEditor extends UIPlugin {
static VERSION = packageJson.version;
cropper = null;
objectUrl = null;
prevCropboxData = null;
imgElement = null;
cropstartHandler = null;
cropendHandler = null;
cropperReadyHandler = null;
constructor(uppy, opts) {
super(uppy, {
...defaultOptions,
...opts,
actions: {
...defaultActions,
...opts?.actions,
},
cropperOptions: {
...defaultCropperOptions,
...opts?.cropperOptions,
},
});
this.id = this.opts.id || 'ImageEditor';
this.title = 'Image Editor';
this.type = 'editor';
this.defaultLocale = locale;
this.i18nInit();
}
canEditFile(file) {
if (!file.type || file.isRemote) {
return false;
}
const fileTypeSpecific = file.type.split('/')[1];
if (/^(jpe?g|gif|png|bmp|webp)$/.test(fileTypeSpecific)) {
return true;
}
return false;
}
save = () => {
const { currentImage } = this.getPluginState();
if (!currentImage)
return;
if (!this.cropper)
return;
const saveBlobCallback = (blob) => {
if (!blob)
return;
const fileId = currentImage.id;
if (!this.uppy.getFile(fileId))
return;
this.uppy.setFileState(fileId, {
// Reinserting image's name and type, because .toBlob loses both.
data: new File([blob], currentImage.name ?? this.i18n('unnamed'), {
type: blob.type,
}),
size: blob.size,
preview: undefined,
});
const updatedFile = this.uppy.getFile(fileId);
if (!updatedFile)
return;
this.uppy.emit('thumbnail:request', updatedFile);
this.setPluginState({
currentImage: updatedFile,
});
this.uppy.emit('file-editor:complete', updatedFile);
};
// Fixes black 1px lines on odd-width images.
// This should be removed when cropperjs fixes this issue.
// (See https://github.com/transloadit/uppy/issues/4305 and https://github.com/fengyuanchen/cropperjs/issues/551).
const croppedCanvas = this.cropper.getCroppedCanvas({});
if (croppedCanvas.width % 2 !== 0) {
this.cropper.setData({ width: croppedCanvas.width - 1 });
}
if (croppedCanvas.height % 2 !== 0) {
this.cropper.setData({ height: croppedCanvas.height - 1 });
}
this.cropper
.getCroppedCanvas(this.opts.cropperOptions.croppedCanvasOptions)
.toBlob(saveBlobCallback, currentImage.type, this.opts.quality);
};
storeCropperInstance = (cropper) => {
this.cropper = cropper;
};
selectFile = (file) => {
this.start(file);
};
resetEditorState = (currentImage = this.getPluginState().currentImage) => {
this.setPluginState({
currentImage,
...defaultEditorState,
// Preserve cropperReady if cropper instance exists
cropperReady: !!this.cropper,
});
};
rotateBy = (degrees) => {
if (!this.cropper)
return;
const { angle, angleGranular, isFlippedHorizontally } = this.getPluginState();
const base90 = angle - angleGranular;
const newAngle = base90 + degrees;
this.cropper.scale(isFlippedHorizontally ? -1 : 1);
this.cropper.rotateTo(newAngle);
const canvasData = this.cropper.getCanvasData();
const containerData = this.cropper.getContainerData();
const newCanvasData = getCanvasDataThatFitsPerfectlyIntoContainer(containerData, canvasData);
this.cropper.setCanvasData(newCanvasData);
this.cropper.setCropBoxData(newCanvasData);
this.setPluginState({
angle: newAngle,
angleGranular: 0,
});
};
rotateGranular = (granularAngle) => {
if (!this.cropper)
return;
const { angle, angleGranular, isFlippedHorizontally } = this.getPluginState();
const base90 = angle - angleGranular;
const newAngle = base90 + granularAngle;
this.cropper.rotateTo(newAngle);
const image = this.cropper.getImageData();
const scaleFactor = getScaleFactorThatRemovesDarkCorners(image.naturalWidth, image.naturalHeight, granularAngle);
const scaleFactorX = isFlippedHorizontally ? -scaleFactor : scaleFactor;
this.cropper.scale(scaleFactorX, scaleFactor);
this.setPluginState({
angle: newAngle,
angleGranular: granularAngle,
});
};
flipHorizontal = () => {
if (!this.cropper)
return;
const { isFlippedHorizontally } = this.getPluginState();
this.cropper.scaleX(-this.cropper.getData().scaleX || -1);
this.setPluginState({
isFlippedHorizontally: !isFlippedHorizontally,
});
};
zoom = (ratio) => {
if (!this.cropper)
return;
this.cropper.zoom(ratio);
};
setAspectRatio = (newRatio) => {
if (!this.cropper)
return;
const ratioMap = {
free: 0,
'1:1': 1,
'16:9': 16 / 9,
'9:16': 9 / 16,
};
this.cropper.setAspectRatio(ratioMap[newRatio]);
this.setPluginState({
aspectRatio: newRatio,
});
};
reset = () => {
if (!this.cropper)
return;
this.cropper.reset();
this.cropper.setAspectRatio(this.opts.cropperOptions.initialAspectRatio || 0);
this.resetEditorState();
};
/**
* Start editing a file - creates object URL and prepares state.
* Called by hook's start() or when user opens editor.
*/
start = (file) => {
// Clean up any previous editing session
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl);
this.objectUrl = null;
}
// Get file data - first try the passed file, then try fetching from Uppy state
let fileData = file.data;
if (!(fileData instanceof Blob)) {
const uppyFile = this.uppy.getFile(file.id);
fileData = uppyFile?.data;
}
if (fileData instanceof Blob) {
this.objectUrl = URL.createObjectURL(fileData);
}
else {
// eslint-disable-next-line no-console
console.warn('[Uppy ImageEditor] Cannot edit file: file.data is not a Blob.', 'File:', file, 'file.data:', file.data, 'typeof file.data:', typeof file.data);
}
this.uppy.emit('file-editor:start', file);
this.resetEditorState(file);
};
/**
* Stop editing - destroys cropper, revokes object URL, cleans up listeners.
*/
stop = () => {
this.destroyCropper();
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl);
this.objectUrl = null;
}
this.resetEditorState(null);
};
/**
* Initialize cropper on the image element. Called lazily when first edit action is triggered.
* For headless use, the hook provides the image element.
*/
initCropper = (imgElement) => {
if (this.cropper)
return; // Already initialized
this.imgElement = imgElement;
this.cropper = new Cropper(imgElement, this.opts.cropperOptions);
// Store handlers so we can remove them later
this.cropstartHandler = () => {
if (this.cropper) {
this.prevCropboxData = this.cropper.getCropBoxData();
}
};
this.cropendHandler = ((event) => {
if (!this.cropper || !this.prevCropboxData)
return;
const canvasData = this.cropper.getCanvasData();
const cropboxData = this.cropper.getCropBoxData();
if (event.detail.action === 'all') {
const newCropboxData = limitCropboxMovementOnMove(canvasData, cropboxData, this.prevCropboxData);
if (newCropboxData)
this.cropper.setCropBoxData(newCropboxData);
}
else {
const newCropboxData = limitCropboxMovementOnResize(canvasData, cropboxData, this.prevCropboxData);
if (newCropboxData)
this.cropper.setCropBoxData(newCropboxData);
}
});
this.cropperReadyHandler = () => {
this.setPluginState({ cropperReady: true });
};
imgElement.addEventListener('cropstart', this.cropstartHandler);
imgElement.addEventListener('cropend', this.cropendHandler);
imgElement.addEventListener('ready', this.cropperReadyHandler, {
once: true,
});
};
/**
* Destroy cropper and clean up event listeners.
*/
destroyCropper = () => {
if (!this.cropper)
return;
this.setPluginState({ cropperReady: false });
if (this.cropstartHandler && this.imgElement) {
this.imgElement.removeEventListener('cropstart', this.cropstartHandler);
}
if (this.cropendHandler && this.imgElement) {
this.imgElement.removeEventListener('cropend', this.cropendHandler);
}
if (this.cropperReadyHandler && this.imgElement) {
this.imgElement.removeEventListener('ready', this.cropperReadyHandler);
}
this.cropper.destroy();
this.cropper = null;
this.imgElement = null;
this.cropstartHandler = null;
this.cropendHandler = null;
this.cropperReadyHandler = null;
this.prevCropboxData = null;
};
/**
* Get object URL for the current image (used by headless hook).
*/
getObjectUrl = () => {
return this.objectUrl;
};
install() {
this.resetEditorState(null);
const { target } = this.opts;
if (target) {
this.mount(target, this);
}
}
uninstall() {
const { currentImage } = this.getPluginState();
if (currentImage) {
const file = this.uppy.getFile(currentImage.id);
this.uppy.emit('file-editor:cancel', file);
}
this.stop();
this.unmount();
}
render() {
const { currentImage, angleGranular } = this.getPluginState();
if (currentImage === null || currentImage.isRemote) {
return null;
}
return (_jsx(Editor, { currentImage: currentImage, objectUrl: this.objectUrl ?? '', initCropper: this.initCropper, save: this.save, opts: this.opts, i18n: this.i18n, angleGranular: angleGranular, rotateBy: this.rotateBy, rotateGranular: this.rotateGranular, flipHorizontal: this.flipHorizontal, zoom: this.zoom, setAspectRatio: this.setAspectRatio, reset: this.reset }));
}
}