UNPKG

@uploadcare/file-uploader

Version:

Building blocks for Uploadcare products integration

552 lines (486 loc) 16 kB
// @ts-check import { Block } from '../../../abstract/Block.js'; import { debounce } from '../../utils/debounce.js'; import { throttle } from '../../utils/throttle.js'; import { clamp, constraintRect, isRectInsideRect, isRectMatchesAspectRatio, rotateSize, roundRect, } from './crop-utils.js'; import { CROP_PADDING } from './cropper-constants.js'; import { classNames } from './lib/classNames.js'; import { pick } from './lib/pick.js'; import { preloadImage } from '../../utils/preloadImage.js'; import { viewerImageSrc } from './util.js'; /** * @typedef {Object} Operations * @property {boolean} flip * @property {boolean} mirror * @property {Number} rotate */ /** * @param {import('./types.js').Transformations['crop']} crop * @returns {boolean} */ function validateCrop(crop) { if (!crop) { return true; } /** @type {((arg: NonNullable<typeof crop>) => boolean)[]} */ let shouldMatch = [ ({ dimensions, coords }) => [...dimensions, ...coords].every((number) => Number.isInteger(number) && Number.isFinite(number)), ({ dimensions, coords }) => dimensions.every((d) => d > 0) && coords.every((c) => c >= 0), ]; return shouldMatch.every((matcher) => matcher(crop)); } export class EditorImageCropper extends Block { ctxOwner = true; constructor() { super(); this.init$ = { ...this.init$, image: null, '*padding': CROP_PADDING, /** @type {Operations} */ '*operations': { rotate: 0, mirror: false, flip: false, }, /** @type {import('./types.js').Rectangle} */ '*imageBox': { x: 0, y: 0, width: 0, height: 0, }, /** @type {import('./types.js').Rectangle} */ '*cropBox': { x: 0, y: 0, width: 0, height: 0, }, }; /** @private */ this._commitDebounced = debounce(this._commit.bind(this), 300); /** @private */ this._handleResizeThrottled = throttle(this._handleResize.bind(this), 100); this._imageSize = { width: 0, height: 0 }; } /** @private */ _handleResize() { if (!this.isConnected || !this._isActive) { return; } this._initCanvas(); this._syncTransformations(); this._alignImage(); this._alignCrop(); this._draw(); } /** @private */ _syncTransformations() { let transformations = this.$['*editorTransformations']; let pickedTransformations = pick(transformations, Object.keys(this.$['*operations'])); let operations = { ...this.$['*operations'], ...pickedTransformations }; this.$['*operations'] = operations; } /** @private */ _initCanvas() { /** @type {HTMLCanvasElement} */ let canvas = this.ref['canvas-el']; let ctx = canvas.getContext('2d'); let width = this.offsetWidth; let height = this.offsetHeight; let dpr = window.devicePixelRatio; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; canvas.width = width * dpr; canvas.height = height * dpr; ctx?.scale(dpr, dpr); this._canvas = canvas; this._ctx = ctx; } /** @private */ _alignImage() { if (!this._isActive || !this.$.image) { return; } let image = this.$.image; let padding = this.$['*padding']; let operations = this.$['*operations']; let { rotate } = operations; let bounds = { width: this.offsetWidth, height: this.offsetHeight }; let naturalSize = rotateSize({ width: image.naturalWidth, height: image.naturalHeight }, rotate); let imageBox; if (naturalSize.width > bounds.width - padding * 2 || naturalSize.height > bounds.height - padding * 2) { let imageAspectRatio = naturalSize.width / naturalSize.height; let viewportAspectRatio = bounds.width / bounds.height; if (imageAspectRatio > viewportAspectRatio) { let width = bounds.width - padding * 2; let height = width / imageAspectRatio; let x = 0 + padding; let y = padding + (bounds.height - padding * 2) / 2 - height / 2; imageBox = { x, y, width, height }; } else { let height = bounds.height - padding * 2; let width = height * imageAspectRatio; let x = padding + (bounds.width - padding * 2) / 2 - width / 2; let y = 0 + padding; imageBox = { x, y, width, height }; } } else { let { width, height } = naturalSize; let x = padding + (bounds.width - padding * 2) / 2 - width / 2; let y = padding + (bounds.height - padding * 2) / 2 - height / 2; imageBox = { x, y, width, height }; } this.$['*imageBox'] = roundRect(imageBox); } /** @private */ _alignCrop() { let cropBox = this.$['*cropBox']; let imageBox = this.$['*imageBox']; let operations = this.$['*operations']; let { rotate } = operations; let cropTransformation = this.$['*editorTransformations']['crop']; let { width: previewWidth, x: previewX, y: previewY } = this.$['*imageBox']; if (cropTransformation) { let { dimensions: [width, height], coords: [x, y], } = cropTransformation; let { width: sourceWidth } = rotateSize(this._imageSize, rotate); let ratio = previewWidth / sourceWidth; cropBox = constraintRect( roundRect({ x: previewX + x * ratio, y: previewY + y * ratio, width: width * ratio, height: height * ratio, }), this.$['*imageBox'], ); } /** @type {import('./types.js').CropPresetList[0] | undefined} */ const cropPreset = this.$['*cropPresetList']?.[0]; const cropAspectRatio = cropPreset ? cropPreset.width / cropPreset.height : undefined; if ( !isRectInsideRect(cropBox, imageBox) || (cropAspectRatio && !isRectMatchesAspectRatio(cropBox, cropAspectRatio)) ) { const imageAspectRatio = imageBox.width / imageBox.height; let width = imageBox.width; let height = imageBox.height; if (cropAspectRatio) { if (imageAspectRatio > cropAspectRatio) { width = Math.min(imageBox.height * cropAspectRatio, imageBox.width); } else { height = Math.min(imageBox.width / cropAspectRatio, imageBox.height); } } cropBox = { x: imageBox.x + imageBox.width / 2 - width / 2, y: imageBox.y + imageBox.height / 2 - height / 2, width, height, }; } this.$['*cropBox'] = constraintRect(roundRect(cropBox), this.$['*imageBox']); } /** @private */ _drawImage() { let ctx = this._ctx; if (!ctx) return; let image = this.$.image; let imageBox = this.$['*imageBox']; let operations = this.$['*operations']; let { mirror, flip, rotate } = operations; let rotated = rotateSize({ width: imageBox.width, height: imageBox.height }, rotate); ctx.save(); ctx.translate(imageBox.x + imageBox.width / 2, imageBox.y + imageBox.height / 2); ctx.rotate((rotate * Math.PI * -1) / 180); ctx.scale(mirror ? -1 : 1, flip ? -1 : 1); ctx.drawImage(image, -rotated.width / 2, -rotated.height / 2, rotated.width, rotated.height); ctx.restore(); } /** @private */ _draw() { if (!this._isActive || !this.$.image || !this._canvas || !this._ctx) { return; } let canvas = this._canvas; let ctx = this._ctx; ctx.clearRect(0, 0, canvas.width, canvas.height); this._drawImage(); } /** * @private * @param {{ fromViewer?: boolean }} options */ _animateIn({ fromViewer }) { if (this.$.image) { this.ref['frame-el'].toggleThumbs(true); this._transitionToImage(); setTimeout(() => { this.className = classNames({ 'uc-active_from_viewer': fromViewer, 'uc-active_from_editor': !fromViewer, 'uc-inactive_to_editor': false, }); }); } } /** * @private * @returns {NonNullable<import('./types.js').Transformations['crop']>['dimensions']} */ _getCropDimensions() { let cropBox = this.$['*cropBox']; let imageBox = this.$['*imageBox']; let operations = this.$['*operations']; let { rotate } = operations; let { width: previewWidth, height: previewHeight } = imageBox; let { width: sourceWidth, height: sourceHeight } = rotateSize(this._imageSize, rotate); let { width: cropWidth, height: cropHeight } = cropBox; let ratioW = previewWidth / sourceWidth; let ratioH = previewHeight / sourceHeight; /** @type {[Number, Number]} */ let dimensions = [ clamp(Math.round(cropWidth / ratioW), 1, sourceWidth), clamp(Math.round(cropHeight / ratioH), 1, sourceHeight), ]; return dimensions; } /** * @private * @returns {import('./types.js').Transformations['crop']} */ _getCropTransformation() { let cropBox = this.$['*cropBox']; let imageBox = this.$['*imageBox']; let operations = this.$['*operations']; let { rotate } = operations; let { width: previewWidth, height: previewHeight, x: previewX, y: previewY } = imageBox; let { width: sourceWidth, height: sourceHeight } = rotateSize(this._imageSize, rotate); let { x: cropX, y: cropY } = cropBox; let ratioW = previewWidth / sourceWidth; let ratioH = previewHeight / sourceHeight; let dimensions = this._getCropDimensions(); let crop = { dimensions, coords: /** @type {[Number, Number]} */ ([ clamp(Math.round((cropX - previewX) / ratioW), 0, sourceWidth - dimensions[0]), clamp(Math.round((cropY - previewY) / ratioH), 0, sourceHeight - dimensions[1]), ]), }; if (!validateCrop(crop)) { console.error('Cropper is trying to create invalid crop object', { payload: crop, }); return undefined; } if (dimensions[0] === sourceWidth && dimensions[1] === sourceHeight) { return undefined; } return crop; } /** @private */ _commit() { if (!this.isConnected || !this._imageSize) { return; } let operations = this.$['*operations']; let { rotate, mirror, flip } = operations; let crop = this._getCropTransformation(); /** @type {import('./types.js').Transformations} */ let editorTransformations = this.$['*editorTransformations']; let transformations = { ...editorTransformations, crop, rotate, mirror, flip, }; this.$['*editorTransformations'] = transformations; } /** * @param {String} operation * @param {Number} value * @returns {void} */ setValue(operation, value) { this.$['*operations'] = { ...this.$['*operations'], [operation]: value, }; if (!this._isActive) { return; } this._alignImage(); this._alignCrop(); this._draw(); } /** * @param {keyof Operations} operation * @returns {Number | boolean} */ getValue(operation) { return this.$['*operations'][operation]; } /** * @param {import('./types.js').ImageSize} imageSize * @param {{ fromViewer?: boolean }} options */ async activate(imageSize, { fromViewer } = {}) { if (this._isActive) { return; } this._isActive = true; this._imageSize = imageSize; this.removeEventListener('transitionend', this._reset); try { this.$.image = await this._waitForImage(this.$['*originalUrl'], this.$['*editorTransformations']); this._syncTransformations(); this._animateIn({ fromViewer }); } catch (err) { console.error('Failed to activate cropper', { error: err }); } this._observer = new ResizeObserver(([entry]) => { const nonZeroSize = entry.contentRect.width > 0 && entry.contentRect.height > 0; if (nonZeroSize && this._isActive && this.$.image) { this._handleResizeThrottled(); } }); this._observer.observe(this); } deactivate({ reset = false } = {}) { if (!this._isActive) { return; } !reset && this._commit(); this._isActive = false; this._transitionToCrop(); this.className = classNames({ 'uc-active_from_viewer': false, 'uc-active_from_editor': false, 'uc-inactive_to_editor': true, }); this.ref['frame-el'].toggleThumbs(false); this.addEventListener('transitionend', this._reset, { once: true }); this._observer?.disconnect(); } /** @private */ _transitionToCrop() { let dimensions = this._getCropDimensions(); let scaleX = Math.min(this.offsetWidth, dimensions[0]) / this.$['*cropBox'].width; let scaleY = Math.min(this.offsetHeight, dimensions[1]) / this.$['*cropBox'].height; let scale = Math.min(scaleX, scaleY); let cropCenterX = this.$['*cropBox'].x + this.$['*cropBox'].width / 2; let cropCenterY = this.$['*cropBox'].y + this.$['*cropBox'].height / 2; this.style.transform = `scale(${scale}) translate(${(this.offsetWidth / 2 - cropCenterX) / scale}px, ${ (this.offsetHeight / 2 - cropCenterY) / scale }px)`; this.style.transformOrigin = `${cropCenterX}px ${cropCenterY}px`; } /** @private */ _transitionToImage() { let cropCenterX = this.$['*cropBox'].x + this.$['*cropBox'].width / 2; let cropCenterY = this.$['*cropBox'].y + this.$['*cropBox'].height / 2; this.style.transform = `scale(1)`; this.style.transformOrigin = `${cropCenterX}px ${cropCenterY}px`; } /** @private */ _reset() { if (this._isActive) { return; } this.$.image = null; } /** * @private * @param {String} originalUrl * @param {import('./types.js').Transformations} transformations * @returns {Promise<HTMLImageElement>} */ async _waitForImage(originalUrl, transformations) { let width = this.offsetWidth; transformations = { ...transformations, crop: undefined, rotate: undefined, flip: undefined, mirror: undefined, }; let src = await this.proxyUrl(viewerImageSrc(originalUrl, width, transformations)); let { promise, cancel, image } = preloadImage(src); let stop = this._handleImageLoading(src); image.addEventListener('load', stop, { once: true }); image.addEventListener('error', stop, { once: true }); this._cancelPreload?.(); this._cancelPreload = cancel; return promise .then(() => image) .catch((err) => { console.error('Failed to load image', { error: err }); this.$['*networkProblems'] = true; return Promise.resolve(image); }); } /** * @private * @param {String} src * @returns {() => void} Destructor */ _handleImageLoading(src) { let operation = 'crop'; let loadingOperations = this.$['*loadingOperations']; if (!loadingOperations.get(operation)) { loadingOperations.set(operation, new Map()); } if (!loadingOperations.get(operation).get(src)) { loadingOperations.set(operation, loadingOperations.get(operation).set(src, true)); this.$['*loadingOperations'] = loadingOperations; } return () => { if (loadingOperations?.get(operation)?.has(src)) { loadingOperations.get(operation).delete(src); this.$['*loadingOperations'] = loadingOperations; } }; } initCallback() { super.initCallback(); this.sub('*imageBox', () => { this._draw(); }); this.sub('*cropBox', () => { if (this.$.image) { this._commitDebounced(); } }); this.sub('*cropPresetList', () => { this._alignCrop(); }); setTimeout(() => { this.sub('*networkProblems', (networkProblems) => { if (!networkProblems) { this._isActive && this.activate(this._imageSize, { fromViewer: false }); } }); }, 0); } destroyCallback() { super.destroyCallback(); this._observer?.disconnect(); } } EditorImageCropper.template = /* HTML */ ` <canvas class="uc-canvas" ref="canvas-el"></canvas> <uc-crop-frame ref="frame-el"></uc-crop-frame> `;