UNPKG

ngx-image-cropper

Version:
1,083 lines (1,072 loc) 85.8 kB
import * as i0 from '@angular/core'; import { signal, output, Component, ChangeDetectionStrategy, ViewChild, Input, HostBinding, HostListener } from '@angular/core'; import { takeUntil, first } from 'rxjs/operators'; import { Subject, merge, fromEvent } from 'rxjs'; import { NgIf } from '@angular/common'; import * as i1 from '@angular/platform-browser'; function checkCropperPosition(cropperPosition, cropperState, maintainSize) { cropperPosition = checkCropperSizeRestriction(cropperPosition, cropperState); return checkCropperWithinMaxSizeBounds(cropperPosition, cropperState, maintainSize); } function checkCropperSizeRestriction(cropperPosition, cropperState) { let cropperWidth = cropperPosition.x2 - cropperPosition.x1; let cropperHeight = cropperPosition.y2 - cropperPosition.y1; const centerX = cropperPosition.x1 + cropperWidth / 2; const centerY = cropperPosition.y1 + cropperHeight / 2; if (cropperState.options.cropperStaticHeight && cropperState.options.cropperStaticWidth) { cropperWidth = cropperState.maxSize().width > cropperState.options.cropperStaticWidth ? cropperState.options.cropperStaticWidth : cropperState.maxSize().width; cropperHeight = cropperState.maxSize().height > cropperState.options.cropperStaticHeight ? cropperState.options.cropperStaticHeight : cropperState.maxSize().height; } else { cropperWidth = Math.max(cropperState.cropperScaledMinWidth, Math.min(cropperWidth, cropperState.cropperScaledMaxWidth, cropperState.maxSize().width)); cropperHeight = Math.max(cropperState.cropperScaledMinHeight, Math.min(cropperHeight, cropperState.cropperScaledMaxHeight, cropperState.maxSize().height)); if (cropperState.options.maintainAspectRatio) { if (cropperState.maxSize().width / cropperState.options.aspectRatio < cropperState.maxSize().height) { cropperHeight = cropperWidth / cropperState.options.aspectRatio; } else { cropperWidth = cropperHeight * cropperState.options.aspectRatio; } } } const x1 = centerX - cropperWidth / 2; const x2 = x1 + cropperWidth; const y1 = centerY - cropperHeight / 2; const y2 = y1 + cropperHeight; return { x1, x2, y1, y2 }; } function checkCropperWithinMaxSizeBounds(position, cropperState, maintainSize = false) { if (position.x1 < 0) { position = { ...position, x1: 0, x2: position.x2 - (maintainSize ? position.x1 : 0) }; } if (position.y1 < 0) { position = { ...position, y2: position.y2 - (maintainSize ? position.y1 : 0), y1: 0 }; } if (position.x2 > cropperState.maxSize().width) { position = { ...position, x1: position.x1 - (maintainSize ? (position.x2 - cropperState.maxSize().width) : 0), x2: cropperState.maxSize().width }; } if (position.y2 > cropperState.maxSize().height) { position = { ...position, y1: position.y1 - (maintainSize ? (position.y2 - cropperState.maxSize().height) : 0), y2: cropperState.maxSize().height }; } return position; } function moveCropper(event, moveStart) { const diffX = getClientX(event) - moveStart.clientX; const diffY = getClientY(event) - moveStart.clientY; return { x1: moveStart.cropper.x1 + diffX, y1: moveStart.cropper.y1 + diffY, x2: moveStart.cropper.x2 + diffX, y2: moveStart.cropper.y2 + diffY }; } function resizeCropper(event, moveStart, cropperState) { const cropperPosition = { ...cropperState.cropper() }; const moveX = getClientX(event) - moveStart.clientX; const moveY = getClientY(event) - moveStart.clientY; switch (moveStart.position) { case 'left': cropperPosition.x1 = Math.min(Math.max(moveStart.cropper.x1 + moveX, cropperPosition.x2 - cropperState.cropperScaledMaxWidth), cropperPosition.x2 - cropperState.cropperScaledMinWidth); break; case 'topleft': cropperPosition.x1 = Math.min(Math.max(moveStart.cropper.x1 + moveX, cropperPosition.x2 - cropperState.cropperScaledMaxWidth), cropperPosition.x2 - cropperState.cropperScaledMinWidth); cropperPosition.y1 = Math.min(Math.max(moveStart.cropper.y1 + moveY, cropperPosition.y2 - cropperState.cropperScaledMaxHeight), cropperPosition.y2 - cropperState.cropperScaledMinHeight); break; case 'top': cropperPosition.y1 = Math.min(Math.max(moveStart.cropper.y1 + moveY, cropperPosition.y2 - cropperState.cropperScaledMaxHeight), cropperPosition.y2 - cropperState.cropperScaledMinHeight); break; case 'topright': cropperPosition.x2 = Math.max(Math.min(moveStart.cropper.x2 + moveX, cropperPosition.x1 + cropperState.cropperScaledMaxWidth), cropperPosition.x1 + cropperState.cropperScaledMinWidth); cropperPosition.y1 = Math.min(Math.max(moveStart.cropper.y1 + moveY, cropperPosition.y2 - cropperState.cropperScaledMaxHeight), cropperPosition.y2 - cropperState.cropperScaledMinHeight); break; case 'right': cropperPosition.x2 = Math.max(Math.min(moveStart.cropper.x2 + moveX, cropperPosition.x1 + cropperState.cropperScaledMaxWidth), cropperPosition.x1 + cropperState.cropperScaledMinWidth); break; case 'bottomright': cropperPosition.x2 = Math.max(Math.min(moveStart.cropper.x2 + moveX, cropperPosition.x1 + cropperState.cropperScaledMaxWidth), cropperPosition.x1 + cropperState.cropperScaledMinWidth); cropperPosition.y2 = Math.max(Math.min(moveStart.cropper.y2 + moveY, cropperPosition.y1 + cropperState.cropperScaledMaxHeight), cropperPosition.y1 + cropperState.cropperScaledMinHeight); break; case 'bottom': cropperPosition.y2 = Math.max(Math.min(moveStart.cropper.y2 + moveY, cropperPosition.y1 + cropperState.cropperScaledMaxHeight), cropperPosition.y1 + cropperState.cropperScaledMinHeight); break; case 'bottomleft': cropperPosition.x1 = Math.min(Math.max(moveStart.cropper.x1 + moveX, cropperPosition.x2 - cropperState.cropperScaledMaxWidth), cropperPosition.x2 - cropperState.cropperScaledMinWidth); cropperPosition.y2 = Math.max(Math.min(moveStart.cropper.y2 + moveY, cropperPosition.y1 + cropperState.cropperScaledMaxHeight), cropperPosition.y1 + cropperState.cropperScaledMinHeight); break; case 'center': const scale = 'scale' in event ? event.scale : 1; const newWidth = Math.min(Math.max(cropperState.cropperScaledMinWidth, (Math.abs(moveStart.cropper.x2 - moveStart.cropper.x1)) * scale), cropperState.cropperScaledMaxWidth); const newHeight = Math.min(Math.max(cropperState.cropperScaledMinHeight, (Math.abs(moveStart.cropper.y2 - moveStart.cropper.y1)) * scale), cropperState.cropperScaledMaxHeight); cropperPosition.x1 = moveStart.clientX - newWidth / 2; cropperPosition.x2 = moveStart.clientX + newWidth / 2; cropperPosition.y1 = moveStart.clientY - newHeight / 2; cropperPosition.y2 = moveStart.clientY + newHeight / 2; if (cropperPosition.x1 < 0) { cropperPosition.x2 -= cropperPosition.x1; cropperPosition.x1 = 0; } else if (cropperPosition.x2 > cropperState.maxSize().width) { cropperPosition.x1 -= (cropperPosition.x2 - cropperState.maxSize().width); cropperPosition.x2 = cropperState.maxSize().width; } if (cropperPosition.y1 < 0) { cropperPosition.y2 -= cropperPosition.y1; cropperPosition.y1 = 0; } else if (cropperPosition.y2 > cropperState.maxSize().height) { cropperPosition.y1 -= (cropperPosition.y2 - cropperState.maxSize().height); cropperPosition.y2 = cropperState.maxSize().height; } break; } if (cropperState.options.maintainAspectRatio) { return checkAspectRatio(moveStart.position, cropperPosition, cropperState); } else { return cropperPosition; } } function checkAspectRatio(position, cropperPosition, cropperState) { cropperPosition = { ...cropperPosition }; let overflowX = 0; let overflowY = 0; switch (position) { case 'top': cropperPosition.x2 = cropperPosition.x1 + (cropperPosition.y2 - cropperPosition.y1) * cropperState.options.aspectRatio; overflowX = Math.max(cropperPosition.x2 - cropperState.maxSize().width, 0); overflowY = Math.max(0 - cropperPosition.y1, 0); if (overflowX > 0 || overflowY > 0) { cropperPosition.x2 -= (overflowY * cropperState.options.aspectRatio) > overflowX ? (overflowY * cropperState.options.aspectRatio) : overflowX; cropperPosition.y1 += (overflowY * cropperState.options.aspectRatio) > overflowX ? overflowY : overflowX / cropperState.options.aspectRatio; } break; case 'bottom': cropperPosition.x2 = cropperPosition.x1 + (cropperPosition.y2 - cropperPosition.y1) * cropperState.options.aspectRatio; overflowX = Math.max(cropperPosition.x2 - cropperState.maxSize().width, 0); overflowY = Math.max(cropperPosition.y2 - cropperState.maxSize().height, 0); if (overflowX > 0 || overflowY > 0) { cropperPosition.x2 -= (overflowY * cropperState.options.aspectRatio) > overflowX ? (overflowY * cropperState.options.aspectRatio) : overflowX; cropperPosition.y2 -= (overflowY * cropperState.options.aspectRatio) > overflowX ? overflowY : (overflowX / cropperState.options.aspectRatio); } break; case 'topleft': cropperPosition.y1 = cropperPosition.y2 - (cropperPosition.x2 - cropperPosition.x1) / cropperState.options.aspectRatio; overflowX = Math.max(0 - cropperPosition.x1, 0); overflowY = Math.max(0 - cropperPosition.y1, 0); if (overflowX > 0 || overflowY > 0) { cropperPosition.x1 += (overflowY * cropperState.options.aspectRatio) > overflowX ? (overflowY * cropperState.options.aspectRatio) : overflowX; cropperPosition.y1 += (overflowY * cropperState.options.aspectRatio) > overflowX ? overflowY : overflowX / cropperState.options.aspectRatio; } break; case 'topright': cropperPosition.y1 = cropperPosition.y2 - (cropperPosition.x2 - cropperPosition.x1) / cropperState.options.aspectRatio; overflowX = Math.max(cropperPosition.x2 - cropperState.maxSize().width, 0); overflowY = Math.max(0 - cropperPosition.y1, 0); if (overflowX > 0 || overflowY > 0) { cropperPosition.x2 -= (overflowY * cropperState.options.aspectRatio) > overflowX ? (overflowY * cropperState.options.aspectRatio) : overflowX; cropperPosition.y1 += (overflowY * cropperState.options.aspectRatio) > overflowX ? overflowY : overflowX / cropperState.options.aspectRatio; } break; case 'right': case 'bottomright': cropperPosition.y2 = cropperPosition.y1 + (cropperPosition.x2 - cropperPosition.x1) / cropperState.options.aspectRatio; overflowX = Math.max(cropperPosition.x2 - cropperState.maxSize().width, 0); overflowY = Math.max(cropperPosition.y2 - cropperState.maxSize().height, 0); if (overflowX > 0 || overflowY > 0) { cropperPosition.x2 -= (overflowY * cropperState.options.aspectRatio) > overflowX ? (overflowY * cropperState.options.aspectRatio) : overflowX; cropperPosition.y2 -= (overflowY * cropperState.options.aspectRatio) > overflowX ? overflowY : overflowX / cropperState.options.aspectRatio; } break; case 'left': case 'bottomleft': cropperPosition.y2 = cropperPosition.y1 + (cropperPosition.x2 - cropperPosition.x1) / cropperState.options.aspectRatio; overflowX = Math.max(0 - cropperPosition.x1, 0); overflowY = Math.max(cropperPosition.y2 - cropperState.maxSize().height, 0); if (overflowX > 0 || overflowY > 0) { cropperPosition.x1 += (overflowY * cropperState.options.aspectRatio) > overflowX ? (overflowY * cropperState.options.aspectRatio) : overflowX; cropperPosition.y2 -= (overflowY * cropperState.options.aspectRatio) > overflowX ? overflowY : overflowX / cropperState.options.aspectRatio; } break; case 'center': cropperPosition.x2 = cropperPosition.x1 + (cropperPosition.y2 - cropperPosition.y1) * cropperState.options.aspectRatio; cropperPosition.y2 = cropperPosition.y1 + (cropperPosition.x2 - cropperPosition.x1) / cropperState.options.aspectRatio; const overflowX1 = Math.max(0 - cropperPosition.x1, 0); const overflowX2 = Math.max(cropperPosition.x2 - cropperState.maxSize().width, 0); const overflowY1 = Math.max(cropperPosition.y2 - cropperState.maxSize().height, 0); const overflowY2 = Math.max(0 - cropperPosition.y1, 0); if (overflowX1 > 0 || overflowX2 > 0 || overflowY1 > 0 || overflowY2 > 0) { cropperPosition.x1 += (overflowY1 * cropperState.options.aspectRatio) > overflowX1 ? (overflowY1 * cropperState.options.aspectRatio) : overflowX1; cropperPosition.x2 -= (overflowY2 * cropperState.options.aspectRatio) > overflowX2 ? (overflowY2 * cropperState.options.aspectRatio) : overflowX2; cropperPosition.y1 += (overflowY2 * cropperState.options.aspectRatio) > overflowX2 ? overflowY2 : overflowX2 / cropperState.options.aspectRatio; cropperPosition.y2 -= (overflowY1 * cropperState.options.aspectRatio) > overflowX1 ? overflowY1 : overflowX1 / cropperState.options.aspectRatio; } break; } return cropperPosition; } function getClientX(event) { if ('touches' in event && event.touches[0]) { return event.touches[0].clientX; } else if ('clientX' in event) { return event.clientX; } return 0; } function getClientY(event) { if ('touches' in event && event.touches[0]) { return event.touches[0].clientY; } else if ('clientX' in event) { return event.clientY; } return 0; } class CropperState { constructor() { this.cropper = signal({ x1: 0, x2: 0, y1: 0, y2: 0 }); this.maxSize = signal({ width: 0, height: 0 }); this.transform = {}; this.options = { format: 'png', output: 'blob', autoCrop: true, maintainAspectRatio: true, aspectRatio: 1, resetCropOnAspectRatioChange: true, resizeToWidth: 0, resizeToHeight: 0, cropperMinWidth: 0, cropperMinHeight: 0, cropperMaxHeight: 0, cropperMaxWidth: 0, cropperStaticWidth: 0, cropperStaticHeight: 0, canvasRotation: 0, roundCropper: false, onlyScaleDown: false, imageQuality: 92, backgroundColor: undefined, containWithinAspectRatio: false, hideResizeSquares: false, alignImage: 'center', cropperFrameAriaLabel: undefined, checkImageType: true }; // Internal this.cropperScaledMinWidth = 20; this.cropperScaledMinHeight = 20; this.cropperScaledMaxWidth = 20; this.cropperScaledMaxHeight = 20; this.stepSize = 3; } setOptionsFromChanges(changes) { if (changes['options']?.currentValue) { this.setOptions(changes['options'].currentValue); } const options = Object.entries(changes) .filter(([key]) => key in this.options) .reduce((acc, [key, change]) => ({ ...acc, [key]: change.currentValue }), {}); if (Object.keys(options).length > 0) { this.setOptions(options); } } setOptions(options) { this.options = { ...this.options, ...(options || {}) }; this.validateOptions(); if (!this.loadedImage?.transformed.image.complete || !this.maxSize) { return; } let positionPossiblyChanged = false; if ((this.options.maintainAspectRatio && options['aspectRatio']) || 'maintainAspectRatio' in options) { this.setCropperScaledMinSize(); this.setCropperScaledMaxSize(); if (this.options.maintainAspectRatio && (this.options.resetCropOnAspectRatioChange || !this.aspectRatioIsCorrect())) { this.cropper.set(this.maxSizeCropperPosition()); positionPossiblyChanged = true; } } else { if (options['cropperMinWidth'] || options['cropperMinHeight']) { this.setCropperScaledMinSize(); positionPossiblyChanged = true; } if (options['cropperMaxWidth'] || options['cropperMaxHeight']) { this.setCropperScaledMaxSize(); positionPossiblyChanged = true; } if (options['cropperStaticWidth'] || options['cropperStaticHeight']) { positionPossiblyChanged = true; } } if (positionPossiblyChanged) { this.cropper.update((cropper) => checkCropperPosition(cropper, this, false)); } } validateOptions() { if (this.options.maintainAspectRatio && !this.options.aspectRatio) { throw new Error('`aspectRatio` should > 0 when `maintainAspectRatio` is enabled'); } } setMaxSize(width, height) { this.maxSize.set({ width, height }); this.setCropperScaledMinSize(); this.setCropperScaledMaxSize(); } setCropperScaledMinSize() { if (this.loadedImage?.transformed.size) { this.setCropperScaledMinWidth(); this.setCropperScaledMinHeight(); } else { this.cropperScaledMinWidth = 20; this.cropperScaledMinHeight = 20; } } setCropperScaledMinWidth() { this.cropperScaledMinWidth = this.options.cropperMinWidth > 0 ? Math.max(20, this.options.cropperMinWidth / this.loadedImage.transformed.size.width * this.maxSize().width) : 20; } setCropperScaledMinHeight() { if (this.options.maintainAspectRatio) { this.cropperScaledMinHeight = Math.max(20, this.cropperScaledMinWidth / this.options.aspectRatio); } else if (this.options.cropperMinHeight > 0) { this.cropperScaledMinHeight = Math.max(20, this.options.cropperMinHeight / this.loadedImage.transformed.size.height * this.maxSize().height); } else { this.cropperScaledMinHeight = 20; } } setCropperScaledMaxSize() { if (this.loadedImage?.transformed.size) { const ratio = this.loadedImage.transformed.size.width / this.maxSize().width; this.cropperScaledMaxWidth = this.options.cropperMaxWidth > 20 ? this.options.cropperMaxWidth / ratio : this.maxSize().width; this.cropperScaledMaxHeight = this.options.cropperMaxHeight > 20 ? this.options.cropperMaxHeight / ratio : this.maxSize().height; if (this.options.maintainAspectRatio) { if (this.cropperScaledMaxWidth > this.cropperScaledMaxHeight * this.options.aspectRatio) { this.cropperScaledMaxWidth = this.cropperScaledMaxHeight * this.options.aspectRatio; } else if (this.cropperScaledMaxWidth < this.cropperScaledMaxHeight * this.options.aspectRatio) { this.cropperScaledMaxHeight = this.cropperScaledMaxWidth / this.options.aspectRatio; } } } else { this.cropperScaledMaxWidth = this.maxSize().width; this.cropperScaledMaxHeight = this.maxSize().height; } } equalsCropperPosition(cropper) { const localCropper = this.cropper(); return localCropper == null && cropper == null || localCropper != null && cropper != null && localCropper.x1.toFixed(3) === cropper.x1.toFixed(3) && localCropper.y1.toFixed(3) === cropper.y1.toFixed(3) && localCropper.x2.toFixed(3) === cropper.x2.toFixed(3) && localCropper.y2.toFixed(3) === cropper.y2.toFixed(3); } equalsTransformTranslate(transform) { return (this.transform.translateH ?? 0) === (transform.translateH ?? 0) && (this.transform.translateV ?? 0) === (transform.translateV ?? 0); } equalsTransform(transform) { return this.equalsTransformTranslate(transform) && (this.transform.scale ?? 1) === (transform.scale ?? 1) && (this.transform.rotate ?? 0) === (transform.rotate ?? 0) && (this.transform.flipH ?? false) === (transform.flipH ?? false) && (this.transform.flipV ?? false) === (transform.flipV ?? false); } aspectRatioIsCorrect() { const localCropper = this.cropper(); const currentCropAspectRatio = (localCropper.x2 - localCropper.x1) / (localCropper.y2 - localCropper.y1); return currentCropAspectRatio === this.options.aspectRatio; } resizeCropperPosition(oldMaxSize) { if (oldMaxSize.width !== this.maxSize().width || oldMaxSize.height !== this.maxSize().height) { this.cropper.update(cropper => ({ x1: cropper.x1 * this.maxSize().width / oldMaxSize.width, x2: cropper.x2 * this.maxSize().width / oldMaxSize.width, y1: cropper.y1 * this.maxSize().height / oldMaxSize.height, y2: cropper.y2 * this.maxSize().height / oldMaxSize.height })); } } maxSizeCropperPosition() { return { x1: 0, y1: 0, x2: this.maxSize().width, y2: this.maxSize().height }; } toCropInput() { return { cropper: this.cropper(), maxSize: this.maxSize(), transform: this.transform, loadedImage: this.loadedImage, options: { ...this.options } }; } } var MoveTypes; (function (MoveTypes) { MoveTypes["Drag"] = "drag"; MoveTypes["Move"] = "move"; MoveTypes["Resize"] = "resize"; MoveTypes["Pinch"] = "pinch"; })(MoveTypes || (MoveTypes = {})); /* * Hermite resize - fast image resize/resample using Hermite filter. * https://github.com/viliusle/Hermite-resize */ function resizeCanvas(canvas, width, height) { const width_source = canvas.width; const height_source = canvas.height; width = Math.round(width); height = Math.round(height); const ratio_w = width_source / width; const ratio_h = height_source / height; const ratio_w_half = Math.ceil(ratio_w / 2); const ratio_h_half = Math.ceil(ratio_h / 2); const ctx = canvas.getContext('2d'); if (ctx) { const img = ctx.getImageData(0, 0, width_source, height_source); const img2 = ctx.createImageData(width, height); const data = img.data; const data2 = img2.data; for (let j = 0; j < height; j++) { for (let i = 0; i < width; i++) { const x2 = (i + j * width) * 4; const center_y = j * ratio_h; let weight = 0; let weights = 0; let weights_alpha = 0; let gx_r = 0; let gx_g = 0; let gx_b = 0; let gx_a = 0; const xx_start = Math.floor(i * ratio_w); const yy_start = Math.floor(j * ratio_h); let xx_stop = Math.ceil((i + 1) * ratio_w); let yy_stop = Math.ceil((j + 1) * ratio_h); xx_stop = Math.min(xx_stop, width_source); yy_stop = Math.min(yy_stop, height_source); for (let yy = yy_start; yy < yy_stop; yy++) { const dy = Math.abs(center_y - yy) / ratio_h_half; const center_x = i * ratio_w; const w0 = dy * dy; //pre-calc part of w for (let xx = xx_start; xx < xx_stop; xx++) { const dx = Math.abs(center_x - xx) / ratio_w_half; const w = Math.sqrt(w0 + dx * dx); if (w >= 1) { //pixel too far continue; } //hermite filter weight = 2 * w * w * w - 3 * w * w + 1; const pos_x = 4 * (xx + yy * width_source); //alpha gx_a += weight * data[pos_x + 3]; weights_alpha += weight; //colors if (data[pos_x + 3] < 255) weight = weight * data[pos_x + 3] / 250; gx_r += weight * data[pos_x]; gx_g += weight * data[pos_x + 1]; gx_b += weight * data[pos_x + 2]; weights += weight; } } data2[x2] = gx_r / weights; data2[x2 + 1] = gx_g / weights; data2[x2 + 2] = gx_b / weights; data2[x2 + 3] = gx_a / weights_alpha; } } canvas.width = width; canvas.height = height; //draw ctx.putImageData(img2, 0, 0); } } function percentage(percent, totalValue) { return (percent / 100) * totalValue; } class CropService { crop(input, output) { const imagePosition = this.getImagePosition(input); const width = imagePosition.x2 - imagePosition.x1; const height = imagePosition.y2 - imagePosition.y1; const cropCanvas = document.createElement('canvas'); cropCanvas.width = width; cropCanvas.height = height; const ctx = cropCanvas.getContext('2d'); if (!ctx) { return null; } if (input.options?.backgroundColor != null) { ctx.fillStyle = input.options.backgroundColor; ctx.fillRect(0, 0, width, height); } const scaleX = (input.transform?.scale || 1) * (input.transform?.flipH ? -1 : 1); const scaleY = (input.transform?.scale || 1) * (input.transform?.flipV ? -1 : 1); const { translateH, translateV } = this.getCanvasTranslate(input); const transformedImage = input.loadedImage.transformed; ctx.setTransform(scaleX, 0, 0, scaleY, transformedImage.size.width / 2 + translateH, transformedImage.size.height / 2 + translateV); ctx.translate(-imagePosition.x1 / scaleX, -imagePosition.y1 / scaleY); ctx.rotate((input.transform?.rotate || 0) * Math.PI / 180); ctx.drawImage(transformedImage.image, -transformedImage.size.width / 2, -transformedImage.size.height / 2); const result = { width, height, imagePosition, cropperPosition: { ...input.cropper } }; if (input.options?.containWithinAspectRatio) { result.offsetImagePosition = this.getOffsetImagePosition(input); } const resizeRatio = this.getResizeRatio(width, height, input.options); if (resizeRatio !== 1) { result.width = Math.round(width * resizeRatio); result.height = input.options?.maintainAspectRatio ? Math.round(result.width / (input.options?.aspectRatio ?? 1)) : Math.round(height * resizeRatio); resizeCanvas(cropCanvas, result.width, result.height); } if (output === 'blob') { return this.cropToBlob(result, cropCanvas, input); } else { result.base64 = cropCanvas.toDataURL('image/' + (input.options?.format ?? 'png'), this.getQuality(input.options)); return result; } } async cropToBlob(output, cropCanvas, input) { output.blob = await new Promise(resolve => cropCanvas.toBlob(resolve, 'image/' + (input.options?.format ?? 'png'), this.getQuality(input.options))); if (output.blob) { output.objectUrl = URL.createObjectURL(output.blob); } return output; } getCanvasTranslate(input) { if (input.transform?.translateUnit === 'px') { const ratio = this.getRatio(input); return { translateH: (input.transform?.translateH || 0) * ratio, translateV: (input.transform?.translateV || 0) * ratio }; } else { return { translateH: input.transform?.translateH ? percentage(input.transform.translateH, input.loadedImage.transformed.size.width) : 0, translateV: input.transform?.translateV ? percentage(input.transform.translateV, input.loadedImage.transformed.size.height) : 0 }; } } getRatio(input) { return input.loadedImage.transformed.size.width / input.maxSize.width; } getImagePosition(cropperState) { const ratio = this.getRatio(cropperState); const out = { x1: Math.round(cropperState.cropper.x1 * ratio), y1: Math.round(cropperState.cropper.y1 * ratio), x2: Math.round(cropperState.cropper.x2 * ratio), y2: Math.round(cropperState.cropper.y2 * ratio) }; if (!cropperState.options?.containWithinAspectRatio) { out.x1 = Math.max(out.x1, 0); out.y1 = Math.max(out.y1, 0); out.x2 = Math.min(out.x2, cropperState.loadedImage.transformed.size.width); out.y2 = Math.min(out.y2, cropperState.loadedImage.transformed.size.height); } return out; } getOffsetImagePosition(input) { const canvasRotation = (input.options?.canvasRotation ?? 0) + input.loadedImage.exifTransform.rotate; const ratio = this.getRatio(input); let offsetX; let offsetY; if (canvasRotation % 2) { offsetX = (input.loadedImage.transformed.size.width - input.loadedImage.original.size.height) / 2; offsetY = (input.loadedImage.transformed.size.height - input.loadedImage.original.size.width) / 2; } else { offsetX = (input.loadedImage.transformed.size.width - input.loadedImage.original.size.width) / 2; offsetY = (input.loadedImage.transformed.size.height - input.loadedImage.original.size.height) / 2; } const cropper = input.cropper; const out = { x1: Math.round(cropper.x1 * ratio) - offsetX, y1: Math.round(cropper.y1 * ratio) - offsetY, x2: Math.round(cropper.x2 * ratio) - offsetX, y2: Math.round(cropper.y2 * ratio) - offsetY }; if (!input.options?.containWithinAspectRatio) { out.x1 = Math.max(out.x1, 0); out.y1 = Math.max(out.y1, 0); out.x2 = Math.min(out.x2, input.loadedImage.transformed.size.width); out.y2 = Math.min(out.y2, input.loadedImage.transformed.size.height); } return out; } getResizeRatio(width, height, options) { const ratios = new Array(); if (options?.resizeToWidth && options.resizeToWidth > 0) { ratios.push(options.resizeToWidth / width); } if (options?.resizeToHeight && options.resizeToHeight > 0) { ratios.push(options.resizeToHeight / height); } const result = ratios.length === 0 ? 1 : Math.min(...ratios); if (result > 1 && !options?.onlyScaleDown) { return result; } return Math.min(result, 1); } getQuality(options) { return Math.min(1, Math.max(0, (options?.imageQuality ?? 92) / 100)); } } // Black 2x1 JPEG, with the following meta information set: // - EXIF Orientation: 6 (Rotated 90° CCW) // Source: https://github.com/blueimp/JavaScript-Load-Image const testAutoOrientationImageByteArray = [new Uint8Array([255, 216, 255, 225, 0, 34, 69, 120, 105, 102, 0, 0, 77, 77, 0, 42, 0, 0, 0, 8, 0, 1, 1, 18, 0, 3, 0, 0, 0, 1, 0, 6, 0, 0, 0, 0, 0, 0, 255, 219, 0, 132, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 255, 192, 0, 17, 8, 0, 1, 0, 2, 3, 1, 17, 0, 2, 17, 1, 3, 17, 1, 255, 196, 0, 74, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 11, 16, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 218, 0, 12, 3, 1, 0, 2, 17, 3, 17, 0, 63, 0, 63, 240, 127, 255, 217])]; const testAutoOrientationImageURL = URL.createObjectURL(new Blob(testAutoOrientationImageByteArray, { type: 'image/jpeg' })); function supportsAutomaticRotation() { return new Promise((resolve) => { const img = new Image(); img.onload = () => { // Check if browser supports automatic image orientation: const supported = img.width === 1 && img.height === 2; resolve(supported); }; img.src = testAutoOrientationImageURL; }); } function getTransformationsFromExifData(exifRotationOrArrayBuffer) { if (typeof exifRotationOrArrayBuffer === 'object') { exifRotationOrArrayBuffer = getExifRotation(exifRotationOrArrayBuffer); } switch (exifRotationOrArrayBuffer) { case 2: return { rotate: 0, flip: true }; case 3: return { rotate: 2, flip: false }; case 4: return { rotate: 2, flip: true }; case 5: return { rotate: 1, flip: true }; case 6: return { rotate: 1, flip: false }; case 7: return { rotate: 3, flip: true }; case 8: return { rotate: 3, flip: false }; default: return { rotate: 0, flip: false }; } } function getExifRotation(arrayBuffer) { const view = new DataView(arrayBuffer); if (view.getUint16(0, false) !== 0xFFD8) { return -2; } const length = view.byteLength; let offset = 2; while (offset < length) { if (view.getUint16(offset + 2, false) <= 8) return -1; const marker = view.getUint16(offset, false); offset += 2; if (marker == 0xFFE1) { if (view.getUint32(offset += 2, false) !== 0x45786966) { return -1; } const little = view.getUint16(offset += 6, false) == 0x4949; offset += view.getUint32(offset + 4, little); const tags = view.getUint16(offset, little); offset += 2; for (let i = 0; i < tags; i++) { if (view.getUint16(offset + (i * 12), little) == 0x0112) { return view.getUint16(offset + (i * 12) + 8, little); } } } else if ((marker & 0xFF00) !== 0xFF00) { break; } else { offset += view.getUint16(offset, false); } } return -1; } class LoadImageService { constructor() { this.autoRotateSupported = supportsAutomaticRotation(); } async loadImageFile(file, options) { const arrayBuffer = await file.arrayBuffer(); if (options.checkImageType) { return await this.checkImageTypeAndLoadImageFromArrayBuffer(arrayBuffer, file.type, options); } return await this.loadImageFromArrayBuffer(arrayBuffer, options); } checkImageTypeAndLoadImageFromArrayBuffer(arrayBuffer, imageType, options) { if (!this.isValidImageType(imageType)) { return Promise.reject(new Error('Invalid image type')); } return this.loadImageFromArrayBuffer(arrayBuffer, options, imageType); } isValidImageType(type) { return /image\/(png|jpg|jpeg|heic|bmp|gif|tiff|svg|webp|x-icon|vnd.microsoft.icon)/.test(type); } async loadImageFromURL(url, options) { const res = await fetch(url); const blob = await res.blob(); const buffer = await blob.arrayBuffer(); return await this.loadImageFromArrayBuffer(buffer, options, blob.type); } loadBase64Image(imageBase64, options) { const arrayBuffer = this.base64ToArrayBuffer(imageBase64); return this.loadImageFromArrayBuffer(arrayBuffer, options); } base64ToArrayBuffer(imageBase64) { imageBase64 = imageBase64.replace(/^data:([^;]+);base64,/gmi, ''); const binaryString = atob(imageBase64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes.buffer; } async loadImageFromArrayBuffer(arrayBuffer, options, imageType) { const res = await new Promise(async (resolve, reject) => { try { const blob = new Blob([arrayBuffer], imageType ? { type: imageType } : undefined); const objectUrl = URL.createObjectURL(blob); const originalImage = new Image(); const isSvg = imageType === 'image/svg+xml'; const originalImageSize = isSvg ? await this.getSvgImageSize(blob) : undefined; originalImage.onload = () => resolve({ originalImage, originalImageSize, originalObjectUrl: objectUrl, originalArrayBuffer: arrayBuffer }); originalImage.onerror = reject; originalImage.src = objectUrl; } catch (e) { reject(e); } }); return await this.transformImageFromArrayBuffer(res, options, res.originalImageSize != null); } async getSvgImageSize(blob) { const parser = new DOMParser(); const doc = parser.parseFromString(await blob.text(), 'image/svg+xml'); const svgElement = doc.querySelector('svg'); if (!svgElement) { throw Error('Failed to parse SVG image'); } const widthAttr = svgElement.getAttribute('width'); const heightAttr = svgElement.getAttribute('height'); if (widthAttr && heightAttr) { return null; } const viewBoxAttr = svgElement.getAttribute('viewBox') || svgElement.getAttribute('viewbox'); if (viewBoxAttr) { const viewBox = viewBoxAttr.split(' '); return { width: +viewBox[2], height: +viewBox[3] }; } throw Error('Failed to load SVG image. SVG must have width + height or viewBox definition.'); } async transformImageFromArrayBuffer(res, options, forceTransform = false) { const autoRotate = await this.autoRotateSupported; const exifTransform = getTransformationsFromExifData(autoRotate ? -1 : res.originalArrayBuffer); if (!res.originalImage || !res.originalImage.complete) { return Promise.reject(new Error('No image loaded')); } const loadedImage = { original: { objectUrl: res.originalObjectUrl, image: res.originalImage, size: res.originalImageSize ?? { width: res.originalImage.naturalWidth, height: res.originalImage.naturalHeight } }, exifTransform }; return this.transformLoadedImage(loadedImage, options, forceTransform); } async transformLoadedImage(loadedImage, options, forceTransform = false) { const canvasRotation = (options.canvasRotation ?? 0) + loadedImage.exifTransform.rotate; const originalSize = loadedImage.original.size; if (!forceTransform && canvasRotation === 0 && !loadedImage.exifTransform.flip && !options.containWithinAspectRatio) { return { original: { objectUrl: loadedImage.original.objectUrl, image: loadedImage.original.image, size: { ...originalSize } }, transformed: { objectUrl: loadedImage.original.objectUrl, image: loadedImage.original.image, size: { ...originalSize } }, exifTransform: loadedImage.exifTransform }; } const transformedSize = this.getTransformedSize(originalSize, loadedImage.exifTransform, options); const canvas = document.createElement('canvas'); canvas.width = transformedSize.width; canvas.height = transformedSize.height; const ctx = canvas.getContext('2d'); ctx?.setTransform(loadedImage.exifTransform.flip ? -1 : 1, 0, 0, 1, canvas.width / 2, canvas.height / 2); ctx?.rotate(Math.PI * (canvasRotation / 2)); ctx?.drawImage(loadedImage.original.image, -originalSize.width / 2, -originalSize.height / 2); const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/' + (options.format ?? 'png'))); if (!blob) { throw new Error('Failed to get Blob for transformed image.'); } const objectUrl = URL.createObjectURL(blob); const transformedImage = await this.loadImageFromObjectUrl(objectUrl); return { original: { objectUrl: loadedImage.original.objectUrl, image: loadedImage.original.image, size: { ...originalSize } }, transformed: { objectUrl: objectUrl, image: transformedImage, size: { width: transformedImage.width, height: transformedImage.height } }, exifTransform: loadedImage.exifTransform }; } loadImageFromObjectUrl(objectUrl) { return new Promise(((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = reject; image.src = objectUrl; })); } getTransformedSize(originalSize, exifTransform, options) { const canvasRotation = (options.canvasRotation ?? 0) + exifTransform.rotate; if (options.containWithinAspectRatio) { if (canvasRotation % 2) { const minWidthToContain = originalSize.width * (options.aspectRatio ?? 1); const minHeightToContain = originalSize.height / (options.aspectRatio ?? 1); return { width: Math.max(originalSize.height, minWidthToContain), height: Math.max(originalSize.width, minHeightToContain) }; } else { const minWidthToContain = originalSize.height * (options.aspectRatio ?? 1); const minHeightToContain = originalSize.width / (options.aspectRatio ?? 1); return { width: Math.max(originalSize.width, minWidthToContain), height: Math.max(originalSize.height, minHeightToContain) }; } } if (canvasRotation % 2) { return { height: originalSize.width, width: originalSize.height }; } return { width: originalSize.width, height: originalSize.height }; } } function getPositionForKey(key) { switch (key) { case 'ArrowUp': return 'top'; case 'ArrowRight': return 'right'; case 'ArrowDown': return 'bottom'; case 'ArrowLeft': default: return 'left'; } } function getInvertedPositionForKey(key) { switch (key) { case 'ArrowUp': return 'bottom'; case 'ArrowRight': return 'left'; case 'ArrowDown': return 'top'; case 'ArrowLeft': default: return 'right'; } } function getEventForKey(key, stepSize) { switch (key) { case 'ArrowUp': return { clientX: 0, clientY: stepSize * -1 }; case 'ArrowRight': return { clientX: stepSize, clientY: 0 }; case 'ArrowDown': return { clientX: 0, clientY: stepSize }; case 'ArrowLeft': default: return { clientX: stepSize * -1, clientY: 0 }; } } class ImageCropperComponent { get alignImageStyle() { return this.state.options.alignImage; } constructor(sanitizer) { this.sanitizer = sanitizer; this.pinchStart$ = new Subject(); this.cropService = new CropService(); this.loadImageService = new LoadImageService(); this.setImageMaxSizeRetries = 0; this.resizedWhileHidden = false; this.moveTypes = MoveTypes; this.state = new CropperState(); this.safeImgDataUrl = signal(undefined); this.safeTransformStyle = signal(undefined); this.marginLeft = '0px'; this.imageVisible = false; this.allowMoveImage = false; this.checkImageType = true; this.disabled = false; this.hidden = false; this.imageCropped = output(); this.startCropImage = output(); this.imageLoaded = output(); this.cropperReady = output(); this.loadImageFailed = output(); this.transformChange = output(); this.cropperChange = output(); this.reset(); } ngOnInit() { this.state.stepSize = this.initialStepSize || this.state.stepSize; } ngOnChanges(changes) { const previousCropperPosition = this.state.cropper(); const previousTransform = this.state.transform; const previousBackgroundColor = this.state.options.backgroundColor; this.state.setOptionsFromChanges(changes); this.onChangesInputImage(changes); if (changes['transform'] && this.transform) { this.state.transform = this.transform; this.setCssTransform(); } if (!this.state.loadedImage?.transformed.image.complete || !this.state.maxSize) { return; } if ((this.containWithinAspectRatio && changes['aspectRatio']) || changes['containWithinAspectRatio'] || changes['canvasRotation']) { this.loadImageService .transformLoadedImage(this.state.loadedImage, this.state.options) .then((res) => this.setLoadedImage(res)) .catch((err) => this.loadImageError(err)); return; } if (changes['cropper'] && this.cropper) { this.state.cropper.set(checkCropperPosition(this.cropper, this.state, true)); } const cropperChanged = !this.state.equalsCropperPosition(previousCropperPosition); if (cropperChanged && (!this.cropper || !this.state.equalsCropperPosition(this.cropper))) { this.cropperChange.emit(this.state.cropper()); } if (cropperChanged || !this.state.equalsTransform(previousTransform) || this.state.options.backgroundColor !== previousBackgroundColor) { this.doAutoCrop(); } if (changes['hidden'] && this.resizedWhileHidden && !this.hidden) { setTimeout(() => { this.onResize(); this.resizedWhileHidden = false; }); } } onChangesInputImage(changes) { if (changes['imageChangedEvent'] || changes['imageURL'] || changes['imageBase64'] || changes['imageFile']) { this.reset(); } if (changes['imageChangedEvent'] && this.isValidImageChangedEvent()) { this.loadImageFile(this.imageChangedEvent.target.files[0]); } if (changes['imageURL'] && this.imageURL) { this.loadImageFromURL(this.imageURL); } if (changes['imageBase64'] && this.imageBase64) { this.loadBase64Image(this.imageBase64); } if (changes['imageFile'] && this.imageFile) { this.loadImageFile(this.imageFile); } } isValidImageChangedEvent() { const files = this.imageChangedEvent?.target?.files; return files instanceof FileList && files.length > 0; } reset() { this.state.loadedImage = undefined; this.state.maxSize.set({ width: 0, height: 0 }); this.imageVisible = false; } loadImageFile(file) { this.loadImageService .loadImageFile(file, this.state.options) .then((res) => this.setLoadedImage(res)) .catch((err) => this.loadImageError(err)); } loadBase64Image(imageBase64) { this.loadImageService .loadBase64Image(imageBase64, this.state.options)