import { CLASS_CROP, CLASS_DISABLED, CLASS_HIDDEN, CLASS_MODAL, CLASS_MOVE, DATA_ACTION, DRAG_MODE_CROP, DRAG_MODE_MOVE, DRAG_MODE_NONE, EVENT_LOAD, EVENT_ZOOM, NAMESPACE, } from './constants'; import { addClass, dispatchEvent, each, extend, getAdjustedSizes, getOffset, getPointersCenter, getSourceCanvas, isFunction, isNumber, isPlainObject, isUndefined, normalizeDecimalNumber, removeClass, removeData, removeListener, setData, toggleClass, } from './utilities'; export default { // Show the crop box manually crop() { if (this.ready && !this.disabled) { if (!this.cropped) { this.cropped = true; this.limitCropBox(true, true); if (this.options.modal) { addClass(this.dragBox, CLASS_MODAL); } removeClass(this.cropBox, CLASS_HIDDEN); } this.setCropBoxData(this.initialCropBoxData); } return this; }, // Reset the image and crop box to their initial states reset() { if (this.ready && !this.disabled) { this.imageData = extend({}, this.initialImageData); this.canvasData = extend({}, this.initialCanvasData); this.cropBoxData = extend({}, this.initialCropBoxData); this.renderCanvas(); if (this.cropped) { this.renderCropBox(); } } return this; }, // Clear the crop box clear() { if (this.cropped && !this.disabled) { extend(this.cropBoxData, { left: 0, top: 0, width: 0, height: 0, }); this.cropped = false; this.renderCropBox(); this.limitCanvas(true, true); // Render canvas after crop box rendered this.renderCanvas(); removeClass(this.dragBox, CLASS_MODAL); addClass(this.cropBox, CLASS_HIDDEN); } return this; }, /** * Replace the image's src and rebuild the cropper * @param {string} url - The new URL. * @param {boolean} [onlyColorChanged] - Indicate if the new image only changed color. * @returns {Object} this */ replace(url, onlyColorChanged = false) { if (!this.disabled && url) { if (this.isImg) { this.element.src = url; } if (onlyColorChanged) { this.url = url; this.image.src = url; if (this.ready) { this.image2.src = url; each(this.previews, (element) => { element.getElementsByTagName('img')[0].src = url; }); } } else { if (this.isImg) { this.replaced = true; } // Clear previous data this.options.data = null; this.load(url); } } return this; }, // Enable (unfreeze) the cropper enable() { if (this.ready) { this.disabled = false; removeClass(this.cropper, CLASS_DISABLED); } return this; }, // Disable (freeze) the cropper disable() { if (this.ready) { this.disabled = true; addClass(this.cropper, CLASS_DISABLED); } return this; }, // Destroy the cropper and remove the instance from the image destroy() { const { element, image } = this; if (this.loaded) { if (this.isImg && this.replaced) { element.src = this.originalUrl; } this.unbuild(); removeClass(element, CLASS_HIDDEN); } else if (this.isImg) { removeListener(element, EVENT_LOAD, this.onStart); } else if (image) { image.parentNode.removeChild(image); } removeData(element, NAMESPACE); return this; }, /** * Move the canvas with relative offsets * @param {number} offsetX - The relative offset distance on the x-axis. * @param {number} offsetY - The relative offset distance on the y-axis. * @returns {Object} this */ move(offsetX, offsetY) { const { left, top } = this.canvasData; return this.moveTo( isUndefined(offsetX) ? offsetX : (left + Number(offsetX)), isUndefined(offsetY) ? offsetY : (top + Number(offsetY)), ); }, /** * Move the canvas to an absolute point * @param {number} x - The x-axis coordinate. * @param {number} [y=x] - The y-axis coordinate. * @returns {Object} this */ moveTo(x, y = x) { const { canvasData } = this; let changed = false; x = Number(x); y = Number(y); if (this.ready && !this.disabled && this.options.movable) { if (isNumber(x)) { canvasData.left = x; changed = true; } if (isNumber(y)) { canvasData.top = y; changed = true; } if (changed) { this.renderCanvas(true); } } return this; }, /** * Zoom the canvas with a relative ratio * @param {number} ratio - The target ratio. * @param {Event} _originalEvent - The original event if any. * @returns {Object} this */ zoom(ratio, _originalEvent) { const { canvasData } = this; ratio = Number(ratio); if (ratio < 0) { ratio = 1 / (1 - ratio); } else { ratio = 1 + ratio; } return this.zoomTo((canvasData.width * ratio) / canvasData.naturalWidth, null, _originalEvent); }, /** * Zoom the canvas to an absolute ratio * @param {number} ratio - The target ratio. * @param {Object} pivot - The zoom pivot point coordinate. * @param {Event} _originalEvent - The original event if any. * @returns {Object} this */ zoomTo(ratio, pivot, _originalEvent) { const { options, canvasData } = this; const { width, height, naturalWidth, naturalHeight, } = canvasData; ratio = Number(ratio); if (ratio >= 0 && this.ready && !this.disabled && options.zoomable) { const newWidth = naturalWidth * ratio; const newHeight = naturalHeight * ratio; if (dispatchEvent(this.element, EVENT_ZOOM, { originalEvent: _originalEvent, oldRatio: width / naturalWidth, ratio: newWidth / naturalWidth, }) === false) { return this; } if (_originalEvent) { const { pointers } = this; const offset = getOffset(this.cropper); const center = pointers && Object.keys(pointers).length ? getPointersCenter(pointers) : { pageX: _originalEvent.pageX, pageY: _originalEvent.pageY, }; // Zoom from the triggering point of the event canvasData.left -= (newWidth - width) * ( ((center.pageX - offset.left) - canvasData.left) / width ); canvasData.top -= (newHeight - height) * ( ((center.pageY - offset.top) - canvasData.top) / height ); } else if (isPlainObject(pivot) && isNumber(pivot.x) && isNumber(pivot.y)) { canvasData.left -= (newWidth - width) * ( (pivot.x - canvasData.left) / width ); canvasData.top -= (newHeight - height) * ( (pivot.y - canvasData.top) / height ); } else { // Zoom from the center of the canvas canvasData.left -= (newWidth - width) / 2; canvasData.top -= (newHeight - height) / 2; } canvasData.width = newWidth; canvasData.height = newHeight; this.renderCanvas(true); } return this; }, /** * Rotate the canvas with a relative degree * @param {number} degree - The rotate degree. * @returns {Object} this */ rotate(degree) { return this.rotateTo((this.imageData.rotate || 0) + Number(degree)); }, /** * Rotate the canvas to an absolute degree * @param {number} degree - The rotate degree. * @returns {Object} this */ rotateTo(degree) { degree = Number(degree); if (isNumber(degree) && this.ready && !this.disabled && this.options.rotatable) { this.imageData.rotate = degree % 360; this.renderCanvas(true, true); } return this; }, /** * Scale the image on the x-axis. * @param {number} scaleX - The scale ratio on the x-axis. * @returns {Object} this */ scaleX(scaleX) { const { scaleY } = this.imageData; return this.scale(scaleX, isNumber(scaleY) ? scaleY : 1); }, /** * Scale the image on the y-axis. * @param {number} scaleY - The scale ratio on the y-axis. * @returns {Object} this */ scaleY(scaleY) { const { scaleX } = this.imageData; return this.scale(isNumber(scaleX) ? scaleX : 1, scaleY); }, /** * Scale the image * @param {number} scaleX - The scale ratio on the x-axis. * @param {number} [scaleY=scaleX] - The scale ratio on the y-axis. * @returns {Object} this */ scale(scaleX, scaleY = scaleX) { const { imageData } = this; let transformed = false; scaleX = Number(scaleX); scaleY = Number(scaleY); if (this.ready && !this.disabled && this.options.scalable) { if (isNumber(scaleX)) { imageData.scaleX = scaleX; transformed = true; } if (isNumber(scaleY)) { imageData.scaleY = scaleY; transformed = true; } if (transformed) { this.renderCanvas(true, true); } } return this; }, /** * Get the cropped area position and size data (base on the original image) * @param {boolean} [rounded=false] - Indicate if round the data values or not. * @returns {Object} The result cropped data. */ getData(rounded = false) { const { options, imageData, canvasData, cropBoxData, } = this; let data; if (this.ready && this.cropped) { data = { x: cropBoxData.left - canvasData.left, y: cropBoxData.top - canvasData.top, width: cropBoxData.width, height: cropBoxData.height, }; const ratio = imageData.width / imageData.naturalWidth; each(data, (n, i) => { n /= ratio; data[i] = rounded ? Math.round(n) : n; }); } else { data = { x: 0, y: 0, width: 0, height: 0, }; } if (options.rotatable) { data.rotate = imageData.rotate || 0; } if (options.scalable) { data.scaleX = imageData.scaleX || 1; data.scaleY = imageData.scaleY || 1; } return data; }, /** * Set the cropped area position and size with new data * @param {Object} data - The new data. * @returns {Object} this */ setData(data) { const { options, imageData, canvasData } = this; const cropBoxData = {}; if (isFunction(data)) { data = data.call(this.element); } if (this.ready && !this.disabled && isPlainObject(data)) { let transformed = false; if (options.rotatable) { if (isNumber(data.rotate) && data.rotate !== imageData.rotate) { imageData.rotate = data.rotate; transformed = true; } } if (options.scalable) { if (isNumber(data.scaleX) && data.scaleX !== imageData.scaleX) { imageData.scaleX = data.scaleX; transformed = true; } if (isNumber(data.scaleY) && data.scaleY !== imageData.scaleY) { imageData.scaleY = data.scaleY; transformed = true; } } if (transformed) { this.renderCanvas(true, true); } const ratio = imageData.width / imageData.naturalWidth; if (isNumber(data.x)) { cropBoxData.left = (data.x * ratio) + canvasData.left; } if (isNumber(data.y)) { cropBoxData.top = (data.y * ratio) + canvasData.top; } if (isNumber(data.width)) { cropBoxData.width = data.width * ratio; } if (isNumber(data.height)) { cropBoxData.height = data.height * ratio; } this.setCropBoxData(cropBoxData); } return this; }, /** * Get the container size data. * @returns {Object} The result container data. */ getContainerData() { return this.ready ? extend({}, this.containerData) : {}; }, /** * Get the image position and size data. * @returns {Object} The result image data. */ getImageData() { return this.loaded ? extend({}, this.imageData) : {}; }, /** * Get the canvas position and size data. * @returns {Object} The result canvas data. */ getCanvasData() { const { canvasData } = this; const data = {}; if (this.ready) { each([ 'left', 'top', 'width', 'height', 'naturalWidth', 'naturalHeight', ], (n) => { data[n] = canvasData[n]; }); } return data; }, /** * Set the canvas position and size with new data. * @param {Object} data - The new canvas data. * @returns {Object} this */ setCanvasData(data) { const { canvasData } = this; const { aspectRatio } = canvasData; if (isFunction(data)) { data = data.call(this.element); } if (this.ready && !this.disabled && isPlainObject(data)) { if (isNumber(data.left)) { canvasData.left = data.left; } if (isNumber(data.top)) { canvasData.top = data.top; } if (isNumber(data.width)) { canvasData.width = data.width; canvasData.height = data.width / aspectRatio; } else if (isNumber(data.height)) { canvasData.height = data.height; canvasData.width = data.height * aspectRatio; } this.renderCanvas(true); } return this; }, /** * Get the crop box position and size data. * @returns {Object} The result crop box data. */ getCropBoxData() { const { cropBoxData } = this; let data; if (this.ready && this.cropped) { data = { left: cropBoxData.left, top: cropBoxData.top, width: cropBoxData.width, height: cropBoxData.height, }; } return data || {}; }, /** * Set the crop box position and size with new data. * @param {Object} data - The new crop box data. * @returns {Object} this */ setCropBoxData(data) { const { cropBoxData } = this; const { aspectRatio } = this.options; let widthChanged; let heightChanged; if (isFunction(data)) { data = data.call(this.element); } if (this.ready && this.cropped && !this.disabled && isPlainObject(data)) { if (isNumber(data.left)) { cropBoxData.left = data.left; } if (isNumber(data.top)) { cropBoxData.top = data.top; } if (isNumber(data.width) && data.width !== cropBoxData.width) { widthChanged = true; cropBoxData.width = data.width; } if (isNumber(data.height) && data.height !== cropBoxData.height) { heightChanged = true; cropBoxData.height = data.height; } if (aspectRatio) { if (widthChanged) { cropBoxData.height = cropBoxData.width / aspectRatio; } else if (heightChanged) { cropBoxData.width = cropBoxData.height * aspectRatio; } } this.renderCropBox(); } return this; }, /** * Get a canvas drawn the cropped image. * @param {Object} [options={}] - The config options. * @returns {HTMLCanvasElement} - The result canvas. */ getCroppedCanvas(options = {}) { if (!this.ready || !window.HTMLCanvasElement) { return null; } const { canvasData } = this; const source = getSourceCanvas(this.image, this.imageData, canvasData, options); // Returns the source canvas if it is not cropped. if (!this.cropped) { return source; } let { x: initialX, y: initialY, width: initialWidth, height: initialHeight, } = this.getData(); const ratio = source.width / Math.floor(canvasData.naturalWidth); if (ratio !== 1) { initialX *= ratio; initialY *= ratio; initialWidth *= ratio; initialHeight *= ratio; } const aspectRatio = initialWidth / initialHeight; const maxSizes = getAdjustedSizes({ aspectRatio, width: options.maxWidth || Infinity, height: options.maxHeight || Infinity, }); const minSizes = getAdjustedSizes({ aspectRatio, width: options.minWidth || 0, height: options.minHeight || 0, }, 'cover'); let { width, height, } = getAdjustedSizes({ aspectRatio, width: options.width || (ratio !== 1 ? source.width : initialWidth), height: options.height || (ratio !== 1 ? source.height : initialHeight), }); width = Math.min(maxSizes.width, Math.max(minSizes.width, width)); height = Math.min(maxSizes.height, Math.max(minSizes.height, height)); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); canvas.width = normalizeDecimalNumber(width); canvas.height = normalizeDecimalNumber(height); context.fillStyle = options.fillColor || 'transparent'; context.fillRect(0, 0, width, height); const { imageSmoothingEnabled = true, imageSmoothingQuality } = options; context.imageSmoothingEnabled = imageSmoothingEnabled; if (imageSmoothingQuality) { context.imageSmoothingQuality = imageSmoothingQuality; } // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D.drawImage const sourceWidth = source.width; const sourceHeight = source.height; // Source canvas parameters let srcX = initialX; let srcY = initialY; let srcWidth; let srcHeight; // Destination canvas parameters let dstX; let dstY; let dstWidth; let dstHeight; if (srcX <= -initialWidth || srcX > sourceWidth) { srcX = 0; srcWidth = 0; dstX = 0; dstWidth = 0; } else if (srcX <= 0) { dstX = -srcX; srcX = 0; srcWidth = Math.min(sourceWidth, initialWidth + srcX); dstWidth = srcWidth; } else if (srcX <= sourceWidth) { dstX = 0; srcWidth = Math.min(initialWidth, sourceWidth - srcX); dstWidth = srcWidth; } if (srcWidth <= 0 || srcY <= -initialHeight || srcY > sourceHeight) { srcY = 0; srcHeight = 0; dstY = 0; dstHeight = 0; } else if (srcY <= 0) { dstY = -srcY; srcY = 0; srcHeight = Math.min(sourceHeight, initialHeight + srcY); dstHeight = srcHeight; } else if (srcY <= sourceHeight) { dstY = 0; srcHeight = Math.min(initialHeight, sourceHeight - srcY); dstHeight = srcHeight; } // All the numerical parameters should be integer for `drawImage` // https://github.com/fengyuanchen/cropper/issues/476 const params = [ srcX, srcY, srcWidth, srcHeight, ]; // Avoid "IndexSizeError" if (dstWidth > 0 && dstHeight > 0) { const scale = width / initialWidth; params.push( dstX * scale, dstY * scale, dstWidth * scale, dstHeight * scale, ); } context.drawImage(source, ...params.map(param => Math.floor(normalizeDecimalNumber(param)))); return canvas; }, /** * Change the aspect ratio of the crop box. * @param {number} aspectRatio - The new aspect ratio. * @returns {Object} this */ setAspectRatio(aspectRatio) { const { options } = this; if (!this.disabled && !isUndefined(aspectRatio)) { // 0 -> NaN options.aspectRatio = Math.max(0, aspectRatio) || NaN; if (this.ready) { this.initCropBox(); if (this.cropped) { this.renderCropBox(); } } } return this; }, /** * Change the drag mode. * @param {string} mode - The new drag mode. * @returns {Object} this */ setDragMode(mode) { const { options, dragBox, face } = this; if (this.loaded && !this.disabled) { const croppable = mode === DRAG_MODE_CROP; const movable = options.movable && mode === DRAG_MODE_MOVE; mode = (croppable || movable) ? mode : DRAG_MODE_NONE; setData(dragBox, DATA_ACTION, mode); toggleClass(dragBox, CLASS_CROP, croppable); toggleClass(dragBox, CLASS_MOVE, movable); if (!options.cropBoxMovable) { // Sync drag mode to crop box when it is not movable setData(face, DATA_ACTION, mode); toggleClass(face, CLASS_CROP, croppable); toggleClass(face, CLASS_MOVE, movable); } } return this; }, };