UNPKG

@uppy/image-editor

Version:

Image editor and cropping UI

341 lines (340 loc) 12.7 kB
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 })); } }