diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml index 8048cff6a22..e1756904900 100644 --- a/.github/workflows/javascript.yml +++ b/.github/workflows/javascript.yml @@ -74,3 +74,9 @@ jobs: - name: "Check 'sortablejs'" run: | diff -wu wcfsetup/install/files/js/3rdParty/Sortable.min.js node_modules/sortablejs/Sortable.min.js + - name: "Check 'cropperjs'" + run: | + diff -wu wcfsetup/install/files/js/3rdParty/cropper.min.js node_modules/cropperjs/dist/cropper.min.js + - name: "Check 'exifreader'" + run: | + diff -wu wcfsetup/install/files/js/3rdParty/exif-reader.js node_modules/exifreader/dist/exif-reader.js diff --git a/package-lock.json b/package-lock.json index fbf55411ba6..cf6d29e866d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,9 @@ "@woltlab/editor": "git+https://github.com/WoltLab/editor.git#54d7052a834e3b41e5fdacd8a17a4a4ea1b6e76c", "@woltlab/visual-dom-diff": "git+https://github.com/WoltLab/visual-dom-diff.git#e5b51fce3157d1eda310566fc1f86101341d1fea", "@woltlab/zxcvbn": "git+https://github.com/WoltLab/zxcvbn.git#5b582b24e437f1883ccad3c37dae7c3c5f1e7da3", + "cropperjs": "2.0.0-rc.2", "emoji-picker-element": "^1.23.0", + "exifreader": "^4.25.0", "focus-trap": "^7.6.1", "html-parsed-element": "^0.4.1", "perfect-scrollbar": "^1.5.6", @@ -801,6 +803,126 @@ "lodash-es": "4.17.21" } }, + "node_modules/@cropper/element": { + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@cropper/element/-/element-2.0.0-rc.2.tgz", + "integrity": "sha512-4G6lTJblndwzpsb43YKeHiKcocOkDIWystGzbHNbqRysE0U0lYHuRyvV7FW6a9S63wtMFSYuwFxcdUdUcmkF8w==", + "license": "MIT", + "dependencies": { + "@cropper/utils": "^2.0.0-rc.2" + } + }, + "node_modules/@cropper/element-canvas": { + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@cropper/element-canvas/-/element-canvas-2.0.0-rc.2.tgz", + "integrity": "sha512-0aqbJ3ycQM6/yn4T03vw8K/OeTB8C6+Z/jimuavy4UM2CENH9ucSLM4hAG0yYCgghIyv9Zd0unaBmtgW+I5+SQ==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.0.0-rc.2", + "@cropper/utils": "^2.0.0-rc.2" + } + }, + "node_modules/@cropper/element-crosshair": { + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@cropper/element-crosshair/-/element-crosshair-2.0.0-rc.2.tgz", + "integrity": "sha512-yopINLvaZhL3E2GNienju1zeQ1Cifkn5f/0R7ZabXcAgUI0s2sLzNqL8+2XV2J3DzEzYEIYc+49KmMle04nVWQ==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.0.0-rc.2", + "@cropper/utils": "^2.0.0-rc.2" + } + }, + "node_modules/@cropper/element-grid": { + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@cropper/element-grid/-/element-grid-2.0.0-rc.2.tgz", + "integrity": "sha512-PzAfEya6CmIc/o/lcA/NZ1rohszz42wjq2z3E2zq2jMfNDxY/EIoFnGI6+hJrxCAaoKD8UlKOEHQdRQbtnjcMg==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.0.0-rc.2", + "@cropper/utils": "^2.0.0-rc.2" + } + }, + "node_modules/@cropper/element-handle": { + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@cropper/element-handle/-/element-handle-2.0.0-rc.2.tgz", + "integrity": "sha512-wOWX4xpryxKcrhnJC2mHebqQQ622UN2oyQoDZcaMzvlwt7nnX3bInF+SFrIj9/aCxtCUYY0oD2gaJkfd6aNJ0g==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.0.0-rc.2", + "@cropper/utils": "^2.0.0-rc.2" + } + }, + "node_modules/@cropper/element-image": { + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@cropper/element-image/-/element-image-2.0.0-rc.2.tgz", + "integrity": "sha512-RTKnuJrqn1K8FscS11auit2W57AG04mxRNOxBldYs3lKTkwZjzJdQFkZ/Nxu+cwVXT+c6IeEiayNKvu4B7CAQg==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.0.0-rc.2", + "@cropper/element-canvas": "^2.0.0-rc.2", + "@cropper/utils": "^2.0.0-rc.2" + } + }, + "node_modules/@cropper/element-selection": { + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@cropper/element-selection/-/element-selection-2.0.0-rc.2.tgz", + "integrity": "sha512-UIgIHKHz4qNKlm5YRnC/Pu9+VrInm5TSOzkmU8kPt2swUk0WHNRv3ZcOjCQZ2ccTQnAH3FVM3FYDZ8HjRwLcBg==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.0.0-rc.2", + "@cropper/element-canvas": "^2.0.0-rc.2", + "@cropper/element-image": "^2.0.0-rc.2", + "@cropper/utils": "^2.0.0-rc.2" + } + }, + "node_modules/@cropper/element-shade": { + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@cropper/element-shade/-/element-shade-2.0.0-rc.2.tgz", + "integrity": "sha512-vHAGFxlqgflGZWkRYNWNHUY0zsV72YZGmCgtUu4sMrnWLZL/jMGhxmm8zZCe/aB94F829XcQ6uf3BoiApB+7Ng==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.0.0-rc.2", + "@cropper/element-canvas": "^2.0.0-rc.2", + "@cropper/element-selection": "^2.0.0-rc.2", + "@cropper/utils": "^2.0.0-rc.2" + } + }, + "node_modules/@cropper/element-viewer": { + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@cropper/element-viewer/-/element-viewer-2.0.0-rc.2.tgz", + "integrity": "sha512-2z9mIA7ic3enNS4xvq9Gq6hnRZ1tPr0h+lCrOHP55NL4he63lE9oTVJfDx19rL95wUS4VxL2ANvr2BVLNiBM7A==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.0.0-rc.2", + "@cropper/element-canvas": "^2.0.0-rc.2", + "@cropper/element-image": "^2.0.0-rc.2", + "@cropper/element-selection": "^2.0.0-rc.2", + "@cropper/utils": "^2.0.0-rc.2" + } + }, + "node_modules/@cropper/elements": { + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@cropper/elements/-/elements-2.0.0-rc.2.tgz", + "integrity": "sha512-NG5kdqpv7/tGvUfNjJiIHr2Ip431v5t/P5cIXTcYAgt8PRyFJmjx3fatC7NLnP/FUlv+bbzd8PMRI4LY4Gaw3Q==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.0.0-rc.2", + "@cropper/element-canvas": "^2.0.0-rc.2", + "@cropper/element-crosshair": "^2.0.0-rc.2", + "@cropper/element-grid": "^2.0.0-rc.2", + "@cropper/element-handle": "^2.0.0-rc.2", + "@cropper/element-image": "^2.0.0-rc.2", + "@cropper/element-selection": "^2.0.0-rc.2", + "@cropper/element-shade": "^2.0.0-rc.2", + "@cropper/element-viewer": "^2.0.0-rc.2" + } + }, + "node_modules/@cropper/utils": { + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@cropper/utils/-/utils-2.0.0-rc.2.tgz", + "integrity": "sha512-EEivNsyV6BtL496m4Q/IeAC6FGlyKjKIT1qMtwaxtkR+2ZlKnf9O7AdcGpClemIBA+TbwWAzp0UyIvYFtKUZ1Q==", + "license": "MIT" + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -2085,6 +2207,16 @@ "@types/zxcvbn": "^4.4.1" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.5.tgz", + "integrity": "sha512-6g1EwSs8cr8JhP1iBxzyVAWM6BIDvx9Y3FZRIQiMDzgG43Pxi8YkWOZ0nQj2NHgNzgXDZbJewFx/n+YAvMZrfg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.6" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -2437,6 +2569,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cropperjs": { + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-2.0.0-rc.2.tgz", + "integrity": "sha512-BTuz+UeZphGOEnBCuQiNT4rk1uFfKJaKmTgoH9XU7Q8IMkLdodW7YPWINmXJXwWMt1nXiKze5qKADVbz9xtVFg==", + "license": "MIT", + "dependencies": { + "@cropper/elements": "^2.0.0-rc.2", + "@cropper/utils": "^2.0.0-rc.2" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2785,6 +2927,16 @@ "node": ">=0.8.x" } }, + "node_modules/exifreader": { + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/exifreader/-/exifreader-4.25.0.tgz", + "integrity": "sha512-lPyPXWTUuYgoKdKf3rw2EDoE9Zl7xHoy/ehPNeQ4gFVNLzfLyNMP4oEI+sP0/Czp5r/2i7cFhqg5MHsl4FYtyw==", + "hasInstallScript": true, + "license": "MPL-2.0", + "optionalDependencies": { + "@xmldom/xmldom": "^0.9.4" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index 0a2abb0a206..9f6cdd58e61 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "@woltlab/editor": "git+https://github.com/WoltLab/editor.git#54d7052a834e3b41e5fdacd8a17a4a4ea1b6e76c", "@woltlab/visual-dom-diff": "git+https://github.com/WoltLab/visual-dom-diff.git#e5b51fce3157d1eda310566fc1f86101341d1fea", "@woltlab/zxcvbn": "git+https://github.com/WoltLab/zxcvbn.git#5b582b24e437f1883ccad3c37dae7c3c5f1e7da3", + "cropperjs": "2.0.0-rc.2", "emoji-picker-element": "^1.23.0", + "exifreader": "^4.25.0", "focus-trap": "^7.6.1", "html-parsed-element": "^0.4.1", "perfect-scrollbar": "^1.5.6", @@ -38,5 +40,15 @@ "tabbable": "^6.2.0", "tslib": "^2.8.1", "webpack-cli": "^5.1.4" + }, + "exifreader": { + "include": { + "jpeg": true, + "png": true, + "webp": true, + "exif": [ + "Orientation" + ] + } } } diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index e43ccb5c4d8..857bec5eeb7 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -11,6 +11,7 @@ import ImageResizer from "WoltLabSuite/Core/Image/Resizer"; import { AttachmentData } from "../Ckeditor/Attachment"; import { innerError } from "WoltLabSuite/Core/Dom/Util"; import { getPhrase } from "WoltLabSuite/Core/Language"; +import { cropImage, CropperConfiguration } from "WoltLabSuite/Core/Component/Image/Cropper"; export type CkeditorDropEvent = { file: File; @@ -268,9 +269,28 @@ export function setup(): void { return; } - void resizeImage(element, file).then((resizedFile) => { - void upload(element, resizedFile); - }); + if (element.dataset.cropperConfiguration) { + const cropperConfiguration = JSON.parse(element.dataset.cropperConfiguration) as CropperConfiguration; + + void cropImage(element, file, cropperConfiguration) + .then((resizedFile) => { + void upload(element, resizedFile); + }) + .catch((e) => { + if (e === undefined) { + // User closed the dialog. + return; + } + + if (e instanceof Error) { + innerError(element, e.message); + } + }); + } else { + void resizeImage(element, file).then((resizedFile) => { + void upload(element, resizedFile); + }); + } }); element.addEventListener("ckeditorDrop", (event: CustomEvent) => { diff --git a/ts/WoltLabSuite/Core/Component/Image/Cropper.ts b/ts/WoltLabSuite/Core/Component/Image/Cropper.ts new file mode 100644 index 00000000000..5ff0cb49bd2 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Image/Cropper.ts @@ -0,0 +1,448 @@ +/** + * An image cropper that allows the user to crop an image before uploading it. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ + +import ImageResizer from "WoltLabSuite/Core/Image/Resizer"; +import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog"; +import Cropper, { CropperCanvas, CropperImage, CropperSelection } from "cropperjs"; +import type { Selection } from "@cropper/element-selection"; +import { getPhrase } from "WoltLabSuite/Core/Language"; +import WoltlabCoreDialogElement from "WoltLabSuite/Core/Element/woltlab-core-dialog"; +import * as ExifUtil from "WoltLabSuite/Core/Image/ExifUtil"; +import ExifReader from "exifreader"; +import DomUtil from "WoltLabSuite/Core/Dom/Util"; + +export interface CropperConfiguration { + aspectRatio: number; + type: "minMax" | "exact"; + sizes: { + width: number; + height: number; + }[]; +} + +function inSelection(selection: Selection, maxSelection: Selection): boolean { + return ( + Math.round(selection.x) >= maxSelection.x && + Math.round(selection.y) >= maxSelection.y && + Math.round(selection.x + selection.width) <= Math.round(maxSelection.x + maxSelection.width) && + Math.round(selection.y + selection.height) <= Math.round(maxSelection.y + maxSelection.height) + ); +} + +abstract class ImageCropper { + readonly configuration: CropperConfiguration; + readonly file: File; + readonly element: WoltlabCoreFileUploadElement; + readonly resizer: ImageResizer; + protected image?: HTMLImageElement | HTMLCanvasElement; + protected cropperCanvas?: CropperCanvas | null; + protected cropperImage?: CropperImage | null; + protected cropperSelection?: CropperSelection | null; + protected dialog?: WoltlabCoreDialogElement; + protected exif?: ExifUtil.Exif; + protected orientation?: number; + protected cropperCanvasRect?: DOMRect; + #cropper?: Cropper; + + constructor(element: WoltlabCoreFileUploadElement, file: File, configuration: CropperConfiguration) { + this.configuration = configuration; + this.element = element; + this.file = file; + this.resizer = new ImageResizer(); + } + + protected get width() { + switch (this.orientation) { + case 90: + case 270: + return this.image!.height; + default: + return this.image!.width; + } + } + + protected get height() { + switch (this.orientation) { + case 90: + case 270: + return this.image!.width; + default: + return this.image!.height; + } + } + + abstract get minSize(): { width: number; height: number }; + + abstract get maxSize(): { width: number; height: number }; + + public async loadImage() { + const { image, exif } = await this.resizer.loadFile(this.file); + this.image = image; + this.exif = exif; + const tags = await ExifReader.load(this.file); + if (tags.Orientation) { + switch (tags.Orientation.value) { + case 3: + this.orientation = 180; + break; + case 6: + this.orientation = 90; + break; + case 8: + this.orientation = 270; + break; + // Any other rotation is unsupported. + } + } + } + + public async showDialog(): Promise { + this.dialog = dialogFactory().fromElement(this.image!).asPrompt({ + extra: this.getDialogExtra(), + }); + this.dialog.show(getPhrase("wcf.upload.crop.image")); + + this.createCropper(); + + const resize = () => { + this.centerSelection(); + }; + + window.addEventListener("resize", resize, { passive: true }); + + return new Promise((resolve, reject) => { + let callReject = true; + this.dialog!.addEventListener("afterClose", () => { + window.removeEventListener("resize", resize); + + // If the dialog is closed without confirming, reject the promise to trigger a cancel event. + if (callReject) { + reject(); + } + }); + + this.dialog!.addEventListener("primary", () => { + callReject = false; + + void this.getCanvas() + .then((canvas) => { + this.resizer + .saveFile( + { exif: this.orientation ? undefined : this.exif, image: canvas }, + this.file.name, + this.file.type, + ) + .then((resizedFile) => { + resolve(resizedFile); + }) + .catch(() => { + reject(); + }); + }) + .catch(() => { + reject(); + }); + }); + }); + } + + protected getDialogExtra(): string | undefined { + return undefined; + } + + protected getCanvas(): Promise { + // Calculate the size of the image in relation to the window size + const selectionRatio = Math.min( + this.cropperCanvasRect!.width / this.width, + this.cropperCanvasRect!.height / this.height, + ); + const width = this.cropperSelection!.width / selectionRatio; + const height = width / this.configuration.aspectRatio; + + return this.cropperSelection!.$toCanvas({ + width: Math.max(Math.min(Math.floor(width), this.maxSize.width), this.minSize.width), + height: Math.max(Math.min(Math.ceil(height), this.maxSize.height), this.minSize.height), + }); + } + + protected createCropper() { + this.#cropper = new Cropper(this.image!, { + template: this.getCropperTemplate(), + }); + + this.cropperCanvas = this.#cropper.getCropperCanvas(); + this.cropperImage = this.#cropper.getCropperImage(); + this.cropperSelection = this.#cropper.getCropperSelection(); + + this.setCropperStyle(); + + if (this.orientation) { + this.cropperImage!.$rotate(`${this.orientation}deg`); + } + + this.centerSelection(); + + // Limit the selection to the canvas boundaries + this.cropperSelection!.addEventListener("change", (event: CustomEvent) => { + // see https://fengyuanchen.github.io/cropperjs/v2/api/cropper-selection.html#limit-boundaries + const cropperCanvasRect = this.cropperCanvas!.getBoundingClientRect(); + const selection = event.detail as Selection; + + const maxSelection: Selection = { + x: 0, + y: 0, + width: cropperCanvasRect.width, + height: cropperCanvasRect.height, + }; + + if (!inSelection(selection, maxSelection)) { + event.preventDefault(); + } + }); + + // Limit the selection to the min/max size + this.cropperSelection!.addEventListener("change", (event: CustomEvent) => { + const selection = event.detail as Selection; + this.cropperCanvasRect = this.cropperCanvas!.getBoundingClientRect(); + + const selectionRatio = Math.min( + this.cropperCanvasRect.width / this.width, + this.cropperCanvasRect.height / this.height, + ); + + const minWidth = this.minSize.width * selectionRatio; + const maxWidth = this.cropperCanvasRect.width; + const minHeight = minWidth / this.configuration.aspectRatio; + const maxHeight = maxWidth / this.configuration.aspectRatio; + + if ( + selection.width < minWidth || + selection.height < minHeight || + selection.width > maxWidth || + selection.height > maxHeight + ) { + event.preventDefault(); + } + }); + } + + protected setCropperStyle() { + this.cropperCanvas!.style.aspectRatio = `${this.width}/${this.height}`; + + this.cropperSelection!.aspectRatio = this.configuration.aspectRatio; + } + + protected centerSelection(): void { + // Set to the maximum size + this.cropperCanvas!.style.width = `${this.width}px`; + this.cropperCanvas!.style.height = `${this.height}px`; + + const dimension = DomUtil.innerDimensions(this.cropperCanvas!.parentElement!); + const ratio = Math.min(dimension.width / this.width, dimension.height / this.height); + + this.cropperCanvas!.style.height = `${this.height * ratio}px`; + this.cropperCanvas!.style.width = `${this.width * ratio}px`; + + this.cropperImage!.$center("contain"); + this.cropperCanvasRect = this.cropperImage!.getBoundingClientRect(); + + const selectionRatio = Math.min( + this.cropperCanvasRect.width / this.maxSize.width, + this.cropperCanvasRect.height / this.maxSize.height, + ); + + this.cropperSelection!.$change( + 0, + 0, + Math.min(this.cropperCanvasRect.width, this.maxSize.width * selectionRatio), + Math.min(this.cropperCanvasRect.height, this.maxSize.height * selectionRatio), + this.configuration.aspectRatio, + true, + ); + + this.cropperSelection!.$center(); + this.cropperSelection!.scrollIntoView({ block: "center", inline: "center" }); + } + + protected getCropperTemplate(): string { + return ` + + + + + + + + + + + + + + + + +`; + } +} + +class ExactImageCropper extends ImageCropper { + get minSize() { + return this.configuration.sizes[0]; + } + + get maxSize() { + return this.configuration.sizes[this.configuration.sizes.length - 1]; + } + + public async showDialog(): Promise { + // The image already has the correct size, cropping is not necessary + if ( + this.configuration.sizes.filter((size) => { + return size.width == this.width && size.height == this.height; + }).length > 0 && + this.image instanceof HTMLCanvasElement + ) { + return this.resizer.saveFile( + { exif: this.orientation ? undefined : this.exif, image: this.image }, + this.file.name, + this.file.type, + ); + } + + return super.showDialog(); + } + + protected getCanvas(): Promise { + // Calculate the size of the image in relation to the window size + const selectionRatio = Math.min( + this.cropperCanvasRect!.width / this.width, + this.cropperCanvasRect!.height / this.height, + ); + const width = this.cropperSelection!.width / selectionRatio; + const height = width / this.configuration.aspectRatio; + + const sizes = this.configuration.sizes + .filter((size) => { + return width >= size.width && height >= size.height; + }) + .reverse(); + + const size = sizes.length > 0 ? sizes[0] : this.minSize; + + return this.cropperSelection!.$toCanvas({ + width: size.width, + height: size.height, + }); + } + + public async loadImage(): Promise { + await super.loadImage(); + + const sizes = this.configuration.sizes + .filter((size) => { + return size.width <= this.width && size.height <= this.height; + }) + .sort((a, b) => { + if (this.configuration.aspectRatio >= 1) { + return a.width - b.width; + } else { + return a.height - b.height; + } + }); + + if (sizes.length === 0) { + const smallestSize = + this.configuration.sizes.length > 1 ? this.configuration.sizes[this.configuration.sizes.length - 1] : undefined; + throw new Error( + getPhrase("wcf.upload.error.image.tooSmall", { + width: smallestSize?.width, + height: smallestSize?.height, + }), + ); + } + + this.configuration.sizes = sizes; + } +} + +class MinMaxImageCropper extends ImageCropper { + constructor(element: WoltlabCoreFileUploadElement, file: File, configuration: CropperConfiguration) { + super(element, file, configuration); + if (configuration.sizes.length !== 2) { + throw new Error("MinMaxImageCropper requires exactly two sizes"); + } + } + + get minSize() { + return this.configuration.sizes[0]; + } + + get maxSize() { + return this.configuration.sizes[1]; + } + + protected getDialogExtra(): string { + return getPhrase("wcf.global.button.reset"); + } + + public async loadImage(): Promise { + await super.loadImage(); + + if (this.width < this.minSize.width || this.height < this.minSize.height) { + throw new Error( + getPhrase("wcf.upload.error.image.tooSmall", { + width: this.minSize.width, + height: this.minSize.height, + }), + ); + } + } + + protected createCropper() { + super.createCropper(); + + this.dialog!.addEventListener("extra", () => { + this.centerSelection(); + }); + } +} + +export async function cropImage( + element: WoltlabCoreFileUploadElement, + file: File, + configuration: CropperConfiguration, +): Promise { + switch (file.type) { + case "image/jpeg": + case "image/png": + case "image/webp": + // Potential candidate for a resize operation. + break; + + default: + // Not an image or an unsupported file type. + return file; + } + + let imageCropper: ImageCropper; + switch (configuration.type) { + case "exact": + imageCropper = new ExactImageCropper(element, file, configuration); + break; + case "minMax": + imageCropper = new MinMaxImageCropper(element, file, configuration); + break; + default: + throw new Error("Invalid configuration type"); + } + + await imageCropper.loadImage(); + return imageCropper.showDialog(); +} diff --git a/ts/WoltLabSuite/Core/Dom/Util.ts b/ts/WoltLabSuite/Core/Dom/Util.ts index a3469471005..6f1052d80aa 100644 --- a/ts/WoltLabSuite/Core/Dom/Util.ts +++ b/ts/WoltLabSuite/Core/Dom/Util.ts @@ -96,6 +96,42 @@ const DomUtil = { return id; }, + /** + * Returns the inner height of an element including paddings. + */ + innerHeight(element: HTMLElement, styles?: CSSStyleDeclaration): number { + styles = styles || window.getComputedStyle(element); + + let height = element.clientHeight; + height -= ~~styles.paddingTop + ~~styles.paddingBottom; + + return height; + }, + + /** + * Returns the inner width of an element including paddings. + */ + innerWidth(element: HTMLElement, styles?: CSSStyleDeclaration): number { + styles = styles || window.getComputedStyle(element); + + let width = element.clientWidth; + width -= ~~parseInt(styles.paddingLeft) + ~~parseInt(styles.paddingRight); + + return width; + }, + + /** + * Returns the inner dimensions of an element including paddings. + */ + innerDimensions(element: HTMLElement): Dimensions { + const styles = window.getComputedStyle(element); + + return { + height: DomUtil.innerHeight(element, styles), + width: DomUtil.innerWidth(element, styles), + }; + }, + /** * Returns the outer height of an element including margins. */ diff --git a/wcfsetup/install/files/js/3rdParty/cropper.min.js b/wcfsetup/install/files/js/3rdParty/cropper.min.js new file mode 100644 index 00000000000..1ea266e1d49 --- /dev/null +++ b/wcfsetup/install/files/js/3rdParty/cropper.min.js @@ -0,0 +1,2 @@ +/*! Cropper.js v2.0.0-rc.2 | (c) 2015-present Chen Fengyuan | MIT */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).Cropper={})}(this,(function(t){"use strict";const e="undefined"!=typeof window&&void 0!==window.document,i=e?window:{},s=!!e&&"ontouchstart"in i.document.documentElement,n=!!e&&"PointerEvent"in i,a="cropper",o=`${a}-canvas`,r=`${a}-crosshair`,h=`${a}-grid`,c=`${a}-handle`,l=`${a}-image`,d=`${a}-selection`,u=`${a}-shade`,$=`${a}-viewer`,p="select",g="move",m="scale",b="rotate",f="transform",v="none",C="n-resize",w="e-resize",y="s-resize",E="w-resize",S="ne-resize",A="nw-resize",T="se-resize",k="sw-resize",x="action",O=s?"touchend touchcancel":"mouseup",N=s?"touchmove":"mousemove",I=s?"touchstart":"mousedown",R=n?"pointerdown":I,z=n?"pointermove":N,M=n?"pointerup pointercancel":O,P="error",D="keydown",_="load",W="wheel",Y="action",L="actionend",X="actionmove",H="actionstart",j="change",V="transform";function U(t){return"string"==typeof t}const q=Number.isNaN||i.isNaN;function B(t){return"number"==typeof t&&!q(t)}function K(t){return B(t)&&t>0&&t<1/0}function Z(t){return void 0===t}function F(t){return"object"==typeof t&&null!==t}const{hasOwnProperty:G}=Object.prototype;function J(t){if(!F(t))return!1;try{const{constructor:e}=t,{prototype:i}=e;return e&&i&&G.call(i,"isPrototypeOf")}catch(t){return!1}}function Q(t){return"function"==typeof t}function tt(t){return"object"==typeof t&&null!==t&&1===t.nodeType}const et=/([a-z\d])([A-Z])/g;function it(t){return String(t).replace(et,"$1-$2").toLowerCase()}const st=/-[A-z\d]/g;function nt(t){return t.replace(st,(t=>t.slice(1).toUpperCase()))}const at=/\s\s*/;function ot(t,e,i,s){e.trim().split(at).forEach((e=>{t.removeEventListener(e,i,s)}))}function rt(t,e,i,s){e.trim().split(at).forEach((e=>{t.addEventListener(e,i,s)}))}function ht(t,e,i,s){rt(t,e,i,Object.assign(Object.assign({},s),{once:!0}))}const ct={bubbles:!0,cancelable:!0,composed:!0};function lt(t,e,i,s){return t.dispatchEvent(new CustomEvent(e,Object.assign(Object.assign(Object.assign({},ct),{detail:i}),s)))}const dt=Promise.resolve();function ut(t,e){return e?dt.then(t?e.bind(t):e):dt}function $t(t){const{documentElement:e}=t.ownerDocument,s=t.getBoundingClientRect();return{left:s.left+(i.pageXOffset-e.clientLeft),top:s.top+(i.pageYOffset-e.clientTop)}}const pt=/deg|g?rad|turn$/i;function gt(t){const e=parseFloat(t)||0;if(0!==e){const[i="rad"]=String(t).match(pt)||[];switch(i.toLowerCase()){case"deg":return e/360*(2*Math.PI);case"grad":return e/400*(2*Math.PI);case"turn":return e*(2*Math.PI)}}return e}const mt="contain";function bt(t,e=mt){const{aspectRatio:i}=t;let{width:s,height:n}=t;const a=K(s),o=K(n);if(a&&o){const t=n*i;e===mt&&t>s||"cover"===e&&t{const e=nt(t);let i=this[e];Z(i)||this.$propertyChangedCallback(e,void 0,i),Object.defineProperty(this,e,{enumerable:!0,configurable:!0,get:()=>i,set(t){const s=i;i=t,this.$propertyChangedCallback(e,s,t)}})}));const t=this.attachShadow({mode:this.shadowRootMode||Ct});if(this.shadowRoot||wt.set(this,t),yt.set(this,this.$addStyles(this.$sharedStyle)),this.$style&&this.$addStyles(this.$style),this.$template){const e=document.createElement("template");e.innerHTML=this.$template,t.appendChild(e.content)}if(this.slottable){const e=document.createElement("slot");t.appendChild(e)}}disconnectedCallback(){yt.has(this)&&yt.delete(this),wt.has(this)&&wt.delete(this)}$getTagNameOf(t){var e;return null!==(e=Et.get(t))&&void 0!==e?e:t}$setStyles(t){return Object.keys(t).forEach((e=>{let i=t[e];B(i)&&(i=0!==i&&vt.test(e)?`${i}px`:String(i)),this.style[e]=i})),this}$getShadowRoot(){return this.shadowRoot||wt.get(this)}$addStyles(t){let e;const i=this.$getShadowRoot();return St?(e=new CSSStyleSheet,e.replaceSync(t),i.adoptedStyleSheets=i.adoptedStyleSheets.concat(e)):(e=document.createElement("style"),e.textContent=t,i.appendChild(e)),e}$emit(t,e,i){return lt(this,t,e,i)}$nextTick(t){return ut(this,t)}static $define(t,s){F(t)&&(s=t,t=""),t||(t=this.$name||this.name),t=it(t),e&&i.customElements&&!i.customElements.get(t)&&customElements.define(t,this,s)}}At.$version="2.0.0-rc.2";class Tt extends At{constructor(){super(...arguments),this.$onPointerDown=null,this.$onPointerMove=null,this.$onPointerUp=null,this.$onWheel=null,this.$wheeling=!1,this.$pointers=new Map,this.$style=':host{display:block;min-height:100px;min-width:200px;overflow:hidden;position:relative;touch-action:none;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}:host([background]){background-color:#fff;background-image:repeating-linear-gradient(45deg,#ccc 25%,transparent 0,transparent 75%,#ccc 0,#ccc),repeating-linear-gradient(45deg,#ccc 25%,transparent 0,transparent 75%,#ccc 0,#ccc);background-image:repeating-conic-gradient(#ccc 0 25%,#fff 0 50%);background-position:0 0,.5rem .5rem;background-size:1rem 1rem}:host([disabled]){pointer-events:none}:host([disabled]):after{bottom:0;content:"";cursor:not-allowed;display:block;left:0;pointer-events:none;position:absolute;right:0;top:0}',this.$action=v,this.background=!1,this.disabled=!1,this.scaleStep=.1,this.themeColor="#39f"}static get observedAttributes(){return super.observedAttributes.concat(["background","disabled","scale-step"])}connectedCallback(){super.connectedCallback(),this.disabled||this.$bind()}disconnectedCallback(){this.disabled||this.$unbind(),super.disconnectedCallback()}$propertyChangedCallback(t,e,i){if(!Object.is(i,e)&&(super.$propertyChangedCallback(t,e,i),"disabled"===t))i?this.$unbind():this.$bind()}$bind(){this.$onPointerDown||(this.$onPointerDown=this.$handlePointerDown.bind(this),rt(this,R,this.$onPointerDown)),this.$onPointerMove||(this.$onPointerMove=this.$handlePointerMove.bind(this),rt(this.ownerDocument,z,this.$onPointerMove)),this.$onPointerUp||(this.$onPointerUp=this.$handlePointerUp.bind(this),rt(this.ownerDocument,M,this.$onPointerUp)),this.$onWheel||(this.$onWheel=this.$handleWheel.bind(this),rt(this,W,this.$onWheel,{passive:!1,capture:!0}))}$unbind(){this.$onPointerDown&&(ot(this,R,this.$onPointerDown),this.$onPointerDown=null),this.$onPointerMove&&(ot(this.ownerDocument,z,this.$onPointerMove),this.$onPointerMove=null),this.$onPointerUp&&(ot(this.ownerDocument,M,this.$onPointerUp),this.$onPointerUp=null),this.$onWheel&&(ot(this,W,this.$onWheel,{capture:!0}),this.$onWheel=null)}$handlePointerDown(t){const{buttons:e,button:i,type:s}=t;if(this.disabled||("pointerdown"===s&&"mouse"===t.pointerType||"mousedown"===s)&&(B(e)&&1!==e||B(i)&&0!==i||t.ctrlKey))return;const{$pointers:n}=this;let a="";if(t.changedTouches)Array.from(t.changedTouches).forEach((({identifier:t,pageX:e,pageY:i})=>{n.set(t,{startX:e,startY:i,endX:e,endY:i})}));else{const{pointerId:e=0,pageX:i,pageY:s}=t;n.set(e,{startX:i,startY:s,endX:i,endY:s})}n.size>1?a=f:tt(t.target)&&(a=t.target.action||t.target.getAttribute(x)||""),!1!==this.$emit(H,{action:a,relatedEvent:t})&&(t.preventDefault(),this.$action=a,this.style.willChange="transform")}$handlePointerMove(t){const{$action:e,$pointers:i}=this;if(this.disabled||e===v||0===i.size)return;if(!1===this.$emit(X,{action:e,relatedEvent:t}))return;if(t.preventDefault(),t.changedTouches)Array.from(t.changedTouches).forEach((({identifier:t,pageX:e,pageY:s})=>{const n=i.get(t);n&&Object.assign(n,{endX:e,endY:s})}));else{const{pointerId:e=0,pageX:s,pageY:n}=t,a=i.get(e);a&&Object.assign(a,{endX:s,endY:n})}const s={action:e,relatedEvent:t};if(e===f){const e=new Map(i);let n=0,a=0,o=0,r=0,h=t.pageX,c=t.pageY;i.forEach(((t,i)=>{e.delete(i),e.forEach((e=>{let i=e.startX-t.startX,s=e.startY-t.startY,l=e.endX-t.endX,d=e.endY-t.endY,u=0,$=0,p=0,g=0;if(0===i?s<0?p=2*Math.PI:s>0&&(p=Math.PI):i>0?p=Math.PI/2+Math.atan(s/i):i<0&&(p=1.5*Math.PI+Math.atan(s/i)),0===l?d<0?g=2*Math.PI:d>0&&(g=Math.PI):l>0?g=Math.PI/2+Math.atan(d/l):l<0&&(g=1.5*Math.PI+Math.atan(d/l)),g>0||p>0){const i=g-p,s=Math.abs(i);s>n&&(n=s,o=i,h=(t.startX+e.startX)/2,c=(t.startY+e.startY)/2)}if(i=Math.abs(i),s=Math.abs(s),l=Math.abs(l),d=Math.abs(d),i>0&&s>0?u=Math.sqrt(i*i+s*s):i>0?u=i:s>0&&(u=s),l>0&&d>0?$=Math.sqrt(l*l+d*d):l>0?$=l:d>0&&($=d),u>0&&$>0){const i=($-u)/u,s=Math.abs(i);s>a&&(a=s,r=i,h=(t.startX+e.startX)/2,c=(t.startY+e.startY)/2)}}))}));const l=n>0,d=a>0;l&&d?(s.rotate=o,s.scale=r,s.centerX=h,s.centerY=c):l?(s.action=b,s.rotate=o,s.centerX=h,s.centerY=c):d?(s.action=m,s.scale=r,s.centerX=h,s.centerY=c):s.action=v}else{const[t]=Array.from(i.values());Object.assign(s,t)}i.forEach((t=>{t.startX=t.endX,t.startY=t.endY})),s.action!==v&&this.$emit(Y,s,{cancelable:!1})}$handlePointerUp(t){const{$action:e,$pointers:i}=this;if(!this.disabled&&e!==v&&!1!==this.$emit(L,{action:e,relatedEvent:t})){if(t.preventDefault(),t.changedTouches)Array.from(t.changedTouches).forEach((({identifier:t})=>{i.delete(t)}));else{const{pointerId:e=0}=t;i.delete(e)}0===i.size&&(this.style.willChange="",this.$action=v)}}$handleWheel(t){if(this.disabled)return;if(t.preventDefault(),this.$wheeling)return;this.$wheeling=!0,setTimeout((()=>{this.$wheeling=!1}),50);const e=(t.deltaY>0?-1:1)*this.scaleStep;this.$emit(Y,{action:m,scale:e,relatedEvent:t},{cancelable:!1})}$setAction(t){return U(t)&&(this.$action=t),this}$toCanvas(t){return new Promise(((e,i)=>{if(!this.isConnected)return void i(new Error("The current element is not connected to the DOM."));const s=document.createElement("canvas");let n=this.offsetWidth,a=this.offsetHeight,o=1;J(t)&&(K(t.width)||K(t.height))&&(({width:n,height:a}=bt({aspectRatio:n/a,width:t.width,height:t.height})),o=n/this.offsetWidth),s.width=n,s.height=a;const r=this.querySelector(this.$getTagNameOf(l));r?r.$ready().then((i=>{const h=s.getContext("2d");if(h){const[e,c,l,d,u,$]=r.$getTransform();let p=u,g=$,m=i.naturalWidth,b=i.naturalHeight;1!==o&&(p*=o,g*=o,m*=o,b*=o);const f=m/2,v=b/2;h.fillStyle="transparent",h.fillRect(0,0,n,a),J(t)&&Q(t.beforeDraw)&&t.beforeDraw.call(this,h,s),h.save(),h.translate(f,v),h.transform(e,c,l,d,p,g),h.translate(-f,-v),h.drawImage(i,0,0,m,b),h.restore()}e(s)})).catch(i):e(s)}))}}Tt.$name=o,Tt.$version="2.0.0-rc.2";const kt=new WeakMap,xt=["alt","crossorigin","decoding","importance","loading","referrerpolicy","sizes","src","srcset"];class Ot extends At{constructor(){super(...arguments),this.$matrix=[1,0,0,1,0,0],this.$onLoad=null,this.$onCanvasAction=null,this.$onCanvasActionEnd=null,this.$onCanvasActionStart=null,this.$actionStartTarget=null,this.$style=":host{display:inline-block}img{display:block;height:100%;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}",this.$image=new Image,this.initialCenterSize="contain",this.rotatable=!1,this.scalable=!1,this.skewable=!1,this.slottable=!1,this.translatable=!1}set $canvas(t){kt.set(this,t)}get $canvas(){return kt.get(this)}static get observedAttributes(){return super.observedAttributes.concat(xt,["initial-center-size","rotatable","scalable","skewable","translatable"])}attributeChangedCallback(t,e,i){Object.is(i,e)||(super.attributeChangedCallback(t,e,i),xt.includes(t)&&this.$image.setAttribute(t,i))}$propertyChangedCallback(t,e,i){if(!Object.is(i,e)&&(super.$propertyChangedCallback(t,e,i),"initialCenterSize"===t))this.$nextTick((()=>{this.$center(i)}))}connectedCallback(){super.connectedCallback();const{$image:t}=this,e=this.closest(this.$getTagNameOf(o));e&&(this.$canvas=e,this.$setStyles({display:"block",position:"absolute"}),this.$onCanvasActionStart=t=>{var e,i;this.$actionStartTarget=null===(i=null===(e=t.detail)||void 0===e?void 0:e.relatedEvent)||void 0===i?void 0:i.target},this.$onCanvasActionEnd=()=>{this.$actionStartTarget=null},this.$onCanvasAction=this.$handleAction.bind(this),rt(e,H,this.$onCanvasActionStart),rt(e,L,this.$onCanvasActionEnd),rt(e,Y,this.$onCanvasAction)),this.$onLoad=this.$handleLoad.bind(this),rt(t,_,this.$onLoad),this.$getShadowRoot().appendChild(t)}disconnectedCallback(){const{$image:t,$canvas:e}=this;e&&(this.$onCanvasActionStart&&(ot(e,H,this.$onCanvasActionStart),this.$onCanvasActionStart=null),this.$onCanvasActionEnd&&(ot(e,L,this.$onCanvasActionEnd),this.$onCanvasActionEnd=null),this.$onCanvasAction&&(ot(e,Y,this.$onCanvasAction),this.$onCanvasAction=null)),t&&this.$onLoad&&(ot(t,_,this.$onLoad),this.$onLoad=null),this.$getShadowRoot().removeChild(t),super.disconnectedCallback()}$handleLoad(){const{$image:t}=this;this.$setStyles({width:t.naturalWidth,height:t.naturalHeight}),this.$canvas&&this.$center(this.initialCenterSize)}$handleAction(t){if(this.hidden||!(this.rotatable||this.scalable||this.translatable))return;const{$canvas:e}=this,{detail:i}=t;if(i){const{relatedEvent:t}=i;let{action:s}=i;switch(s!==f||this.rotatable&&this.scalable||(s=this.rotatable?b:this.scalable?m:v),s){case g:if(this.translatable){let s=null;t&&(s=t.target.closest(this.$getTagNameOf(d))),s||(s=e.querySelector(this.$getTagNameOf(d))),s&&s.multiple&&!s.active&&(s=e.querySelector(`${this.$getTagNameOf(d)}[active]`)),s&&!s.hidden&&s.movable&&!s.dynamic&&this.$actionStartTarget&&s.contains(this.$actionStartTarget)||this.$move(i.endX-i.startX,i.endY-i.startY)}break;case b:if(this.rotatable)if(t){const{x:e,y:s}=this.getBoundingClientRect();this.$rotate(i.rotate,t.clientX-e,t.clientY-s)}else this.$rotate(i.rotate);break;case m:if(this.scalable)if(t){const e=t.target.closest(this.$getTagNameOf(d));if(!e||!e.zoomable||e.zoomable&&e.dynamic){const{x:e,y:s}=this.getBoundingClientRect();this.$zoom(i.scale,t.clientX-e,t.clientY-s)}}else this.$zoom(i.scale);break;case f:if(this.rotatable&&this.scalable){const{rotate:e}=i;let{scale:s}=i;s<0?s=1/(1-s):s+=1;const n=Math.cos(e),a=Math.sin(e),[o,r,h,c]=[n*s,a*s,-a*s,n*s];if(t){const e=this.getBoundingClientRect(),i=t.clientX-e.x,s=t.clientY-e.y,[n,a,l,d]=this.$matrix,u=i-e.width/2,$=s-e.height/2,p=(u*d-l*$)/(n*d-l*a),g=($*n-a*u)/(n*d-l*a);this.$transform(o,r,h,c,p*(1-o)+g*h,g*(1-c)+p*r)}else this.$transform(o,r,h,c,0,0)}}}}$ready(t){const{$image:e}=this,i=new Promise(((t,i)=>{const s=new Error("Failed to load the image source");if(e.complete)e.naturalWidth>0&&e.naturalHeight>0?t(e):i(s);else{const n=()=>{ot(e,P,a),t(e)},a=()=>{ot(e,_,n),i(s)};ht(e,_,n),ht(e,P,a)}}));return Q(t)&&i.then((e=>(t(e),e))),i}$center(t){const{parentElement:e}=this;if(!e)return this;const i=e.getBoundingClientRect(),s=i.width,n=i.height,{x:a,y:o,width:r,height:h}=this.getBoundingClientRect(),c=a+r/2,l=o+h/2,d=i.x+s/2,u=i.y+n/2;if(this.$move(d-c,u-l),t&&(r!==s||h!==n)){const e=s/r,i=n/h;switch(t){case"cover":this.$scale(Math.max(e,i));break;case"contain":this.$scale(Math.min(e,i))}}return this}$move(t,e=t){if(this.translatable&&B(t)&&B(e)){const[i,s,n,a]=this.$matrix,o=(t*a-n*e)/(i*a-n*s),r=(e*i-s*t)/(i*a-n*s);this.$translate(o,r)}return this}$moveTo(t,e=t){if(this.translatable&&B(t)&&B(e)){const[i,s,n,a]=this.$matrix,o=(t*a-n*e)/(i*a-n*s),r=(e*i-s*t)/(i*a-n*s);this.$setTransform(i,s,n,a,o,r)}return this}$rotate(t,e,i){if(this.rotatable){const s=gt(t),n=Math.cos(s),a=Math.sin(s),[o,r,h,c]=[n,a,-a,n];if(B(e)&&B(i)){const[t,s,n,a]=this.$matrix,{width:l,height:d}=this.getBoundingClientRect(),u=e-l/2,$=i-d/2,p=(u*a-n*$)/(t*a-n*s),g=($*t-s*u)/(t*a-n*s);this.$transform(o,r,h,c,p*(1-o)-g*h,g*(1-c)-p*r)}else this.$transform(o,r,h,c,0,0)}return this}$zoom(t,e,i){if(!this.scalable||0===t)return this;if(t<0?t=1/(1-t):t+=1,B(e)&&B(i)){const[s,n,a,o]=this.$matrix,{width:r,height:h}=this.getBoundingClientRect(),c=e-r/2,l=i-h/2,d=(c*o-a*l)/(s*o-a*n),u=(l*s-n*c)/(s*o-a*n);this.$transform(t,0,0,t,d*(1-t),u*(1-t))}else this.$scale(t);return this}$scale(t,e=t){return this.scalable&&this.$transform(t,0,0,e,0,0),this}$skew(t,e=0){if(this.skewable){const i=gt(t),s=gt(e);this.$transform(1,Math.tan(s),Math.tan(i),1,0,0)}return this}$translate(t,e=t){return this.translatable&&B(t)&&B(e)&&this.$transform(1,0,0,1,t,e),this}$transform(t,e,i,s,n,a){return B(t)&&B(e)&&B(i)&&B(s)&&B(n)&&B(a)?this.$setTransform(ft(this.$matrix,[t,e,i,s,n,a])):this}$setTransform(t,e,i,s,n,a){if((this.rotatable||this.scalable||this.skewable||this.translatable)&&(Array.isArray(t)&&([t,e,i,s,n,a]=t),B(t)&&B(e)&&B(i)&&B(s)&&B(n)&&B(a))){const o=[...this.$matrix],r=[t,e,i,s,n,a];if(!1===this.$emit(V,{matrix:r,oldMatrix:o}))return this;this.$matrix=r,this.style.transform=`matrix(${r.join(", ")})`}return this}$getTransform(){return this.$matrix.slice()}$resetTransform(){return this.$setTransform([1,0,0,1,0,0])}}Ot.$name=l,Ot.$version="2.0.0-rc.2";const Nt=new WeakMap;class It extends At{constructor(){super(...arguments),this.$onCanvasChange=null,this.$onCanvasActionEnd=null,this.$onCanvasActionStart=null,this.$style=":host{display:block;height:0;left:0;outline:var(--theme-color) solid 1px;position:relative;top:0;width:0}:host([transparent]){outline-color:transparent}",this.x=0,this.y=0,this.width=0,this.height=0,this.slottable=!1,this.themeColor="rgba(0, 0, 0, 0.65)"}set $canvas(t){Nt.set(this,t)}get $canvas(){return Nt.get(this)}static get observedAttributes(){return super.observedAttributes.concat(["height","width","x","y"])}connectedCallback(){super.connectedCallback();const t=this.closest(this.$getTagNameOf(o));if(t){this.$canvas=t,this.style.position="absolute";const e=t.querySelector(this.$getTagNameOf(d));e&&(this.$onCanvasActionStart=t=>{e.hidden&&t.detail.action===p&&(this.hidden=!1)},this.$onCanvasActionEnd=t=>{e.hidden&&t.detail.action===p&&(this.hidden=!0)},this.$onCanvasChange=t=>{const{x:i,y:s,width:n,height:a}=t.detail;this.$change(i,s,n,a),(e.hidden||0===i&&0===s&&0===n&&0===a)&&(this.hidden=!0)},rt(t,H,this.$onCanvasActionStart),rt(t,L,this.$onCanvasActionEnd),rt(t,j,this.$onCanvasChange))}this.$render()}disconnectedCallback(){const{$canvas:t}=this;t&&(this.$onCanvasActionStart&&(ot(t,H,this.$onCanvasActionStart),this.$onCanvasActionStart=null),this.$onCanvasActionEnd&&(ot(t,L,this.$onCanvasActionEnd),this.$onCanvasActionEnd=null),this.$onCanvasChange&&(ot(t,j,this.$onCanvasChange),this.$onCanvasChange=null)),super.disconnectedCallback()}$change(t,e,i=this.width,s=this.height){return B(t)&&B(e)&&B(i)&&B(s)&&(t!==this.x||e!==this.y||i!==this.width||s!==this.height)?(this.hidden&&(this.hidden=!1),this.x=t,this.y=e,this.width=i,this.height=s,this.$render()):this}$reset(){return this.$change(0,0,0,0)}$render(){return this.$setStyles({transform:`translate(${this.x}px, ${this.y}px)`,width:this.width,height:this.height,outlineWidth:i.innerWidth})}}It.$name=u,It.$version="2.0.0-rc.2";class Rt extends At{constructor(){super(...arguments),this.$onCanvasCropEnd=null,this.$onCanvasCropStart=null,this.$style=':host{background-color:var(--theme-color);display:block}:host([action=move]),:host([action=select]){height:100%;left:0;position:absolute;top:0;width:100%}:host([action=move]){cursor:move}:host([action=select]){cursor:crosshair}:host([action$=-resize]){background-color:transparent;height:15px;position:absolute;width:15px}:host([action$=-resize]):after{background-color:var(--theme-color);content:"";display:block;height:5px;left:50%;position:absolute;top:50%;transform:translate(-50%,-50%);width:5px}:host([action=n-resize]),:host([action=s-resize]){cursor:ns-resize;left:50%;transform:translateX(-50%);width:100%}:host([action=n-resize]){top:-8px}:host([action=s-resize]){bottom:-8px}:host([action=e-resize]),:host([action=w-resize]){cursor:ew-resize;height:100%;top:50%;transform:translateY(-50%)}:host([action=e-resize]){right:-8px}:host([action=w-resize]){left:-8px}:host([action=ne-resize]){cursor:nesw-resize;right:-8px;top:-8px}:host([action=nw-resize]){cursor:nwse-resize;left:-8px;top:-8px}:host([action=se-resize]){bottom:-8px;cursor:nwse-resize;right:-8px}:host([action=se-resize]):after{height:15px;width:15px}@media (pointer:coarse){:host([action=se-resize]):after{height:10px;width:10px}}@media (pointer:fine){:host([action=se-resize]):after{height:5px;width:5px}}:host([action=sw-resize]){bottom:-8px;cursor:nesw-resize;left:-8px}:host([plain]){background-color:transparent}',this.action=v,this.plain=!1,this.slottable=!1,this.themeColor="rgba(51, 153, 255, 0.5)"}static get observedAttributes(){return super.observedAttributes.concat(["action","plain"])}}Rt.$name=c,Rt.$version="2.0.0-rc.2";const zt=new WeakMap;class Mt extends At{constructor(){super(...arguments),this.$onCanvasAction=null,this.$onCanvasActionStart=null,this.$onCanvasActionEnd=null,this.$onDocumentKeyDown=null,this.$action="",this.$actionStartTarget=null,this.$changing=!1,this.$style=':host{display:block;left:0;position:relative;right:0}:host([outlined]){outline:1px solid var(--theme-color)}:host([multiple]){outline:1px dashed hsla(0,0%,100%,.5)}:host([multiple]):after{bottom:0;content:"";cursor:pointer;display:block;left:0;position:absolute;right:0;top:0}:host([multiple][active]){outline-color:var(--theme-color);z-index:1}:host([multiple])>*{visibility:hidden}:host([multiple][active])>*{visibility:visible}:host([multiple][active]):after{display:none}',this.$initialSelection={x:0,y:0,width:0,height:0},this.x=0,this.y=0,this.width=0,this.height=0,this.aspectRatio=NaN,this.initialAspectRatio=NaN,this.initialCoverage=NaN,this.active=!1,this.linked=!1,this.dynamic=!1,this.movable=!1,this.resizable=!1,this.zoomable=!1,this.multiple=!1,this.keyboard=!1,this.outlined=!1,this.precise=!1}set $canvas(t){zt.set(this,t)}get $canvas(){return zt.get(this)}static get observedAttributes(){return super.observedAttributes.concat(["active","aspect-ratio","dynamic","height","initial-aspect-ratio","initial-coverage","keyboard","linked","movable","multiple","outlined","precise","resizable","width","x","y","zoomable"])}$propertyChangedCallback(t,e,i){if(!Object.is(i,e))switch(super.$propertyChangedCallback(t,e,i),t){case"x":case"y":case"width":case"height":this.$changing||this.$nextTick((()=>{this.$change(this.x,this.y,this.width,this.height,this.aspectRatio,!0)}));break;case"aspectRatio":case"initialAspectRatio":this.$nextTick((()=>{this.$initSelection()}));break;case"initialCoverage":this.$nextTick((()=>{K(i)&&i<=1&&this.$initSelection(!0,!0)}));break;case"keyboard":this.$nextTick((()=>{this.$canvas&&(i?this.$onDocumentKeyDown||(this.$onDocumentKeyDown=this.$handleKeyDown.bind(this),rt(this.ownerDocument,D,this.$onDocumentKeyDown)):this.$onDocumentKeyDown&&(ot(this.ownerDocument,D,this.$onDocumentKeyDown),this.$onDocumentKeyDown=null))}));break;case"multiple":this.$nextTick((()=>{if(this.$canvas){const t=this.$getSelections();i?(t.forEach((t=>{t.active=!1})),this.active=!0,this.$emit(j,{x:this.x,y:this.y,width:this.width,height:this.height})):(this.active=!1,t.slice(1).forEach((t=>{this.$removeSelection(t)})))}}));break;case"precise":this.$nextTick((()=>{this.$change(this.x,this.y)}));break;case"linked":i&&(this.dynamic=!0)}}connectedCallback(){super.connectedCallback();const t=this.closest(this.$getTagNameOf(o));t?(this.$canvas=t,this.$setStyles({position:"absolute",transform:`translate(${this.x}px, ${this.y}px)`}),this.hidden||this.$render(),this.$initSelection(!0),this.$onCanvasActionStart=this.$handleActionStart.bind(this),this.$onCanvasActionEnd=this.$handleActionEnd.bind(this),this.$onCanvasAction=this.$handleAction.bind(this),rt(t,H,this.$onCanvasActionStart),rt(t,L,this.$onCanvasActionEnd),rt(t,Y,this.$onCanvasAction)):this.$render()}disconnectedCallback(){const{$canvas:t}=this;t&&(this.$onCanvasActionStart&&(ot(t,H,this.$onCanvasActionStart),this.$onCanvasActionStart=null),this.$onCanvasActionEnd&&(ot(t,L,this.$onCanvasActionEnd),this.$onCanvasActionEnd=null),this.$onCanvasAction&&(ot(t,Y,this.$onCanvasAction),this.$onCanvasAction=null)),super.disconnectedCallback()}$getSelections(){let t=[];return this.parentElement&&(t=Array.from(this.parentElement.querySelectorAll(this.$getTagNameOf(d)))),t}$initSelection(t=!1,e=!1){const{initialCoverage:i,parentElement:s}=this;if(K(i)&&s){const n=this.aspectRatio||this.initialAspectRatio;let a=(e?0:this.width)||s.offsetWidth*i,o=(e?0:this.height)||s.offsetHeight*i;K(n)&&({width:a,height:o}=bt({aspectRatio:n,width:a,height:o})),this.$change(this.x,this.y,a,o),t&&this.$center(),this.$initialSelection={x:this.x,y:this.y,width:this.width,height:this.height}}}$createSelection(){const t=this.cloneNode(!0);return this.hasAttribute("id")&&t.removeAttribute("id"),t.initialCoverage=NaN,this.active=!1,this.parentElement&&this.parentElement.insertBefore(t,this.nextSibling),t}$removeSelection(t=this){if(this.parentElement){const e=this.$getSelections();if(e.length>1){const i=e.indexOf(t),s=e[i+1]||e[i-1];s&&(t.active=!1,this.parentElement.removeChild(t),s.active=!0,s.$emit(j,{x:s.x,y:s.y,width:s.width,height:s.height}))}else this.$clear()}}$handleActionStart(t){var e,i;const s=null===(i=null===(e=t.detail)||void 0===e?void 0:e.relatedEvent)||void 0===i?void 0:i.target;this.$action="",this.$actionStartTarget=s,!this.hidden&&this.multiple&&!this.active&&s===this&&this.parentElement&&(this.$getSelections().forEach((t=>{t.active=!1})),this.active=!0,this.$emit(j,{x:this.x,y:this.y,width:this.width,height:this.height}))}$handleAction(t){const{currentTarget:e,detail:i}=t;if(!e||!i)return;const{relatedEvent:s}=i;let{action:n}=i;if(!n&&this.multiple&&(n=this.$action||(null==s?void 0:s.target.action),this.$action=n),!n||this.hidden&&n!==p||this.multiple&&!this.active&&n!==m)return;const a=i.endX-i.startX,o=i.endY-i.startY,{width:r,height:h}=this;let{aspectRatio:c}=this;switch(!K(c)&&s.shiftKey&&(c=K(r)&&K(h)?r/h:1),n){case p:if(0!==a&&0!==o){const{$canvas:t}=this,s=$t(e);(this.multiple&&!this.hidden?this.$createSelection():this).$change(i.startX-s.left,i.startY-s.top,Math.abs(a),Math.abs(o),c),a<0?o<0?n=A:o>0&&(n=k):a>0&&(o<0?n=S:o>0&&(n=T)),t&&(t.$action=n)}break;case g:this.movable&&(this.dynamic||this.$actionStartTarget&&this.contains(this.$actionStartTarget))&&this.$move(a,o);break;case m:if(s&&this.zoomable&&(this.dynamic||this.contains(s.target))){const t=$t(e);this.$zoom(i.scale,s.pageX-t.left,s.pageY-t.top)}break;default:this.$resize(n,a,o,c)}}$handleActionEnd(){this.$action="",this.$actionStartTarget=null}$handleKeyDown(t){if(this.hidden||!this.keyboard||this.multiple&&!this.active||t.defaultPrevented)return;const{activeElement:e}=document;if(!e||!["INPUT","TEXTAREA"].includes(e.tagName)&&!["true","plaintext-only"].includes(e.contentEditable))switch(t.key){case"Backspace":t.metaKey&&(t.preventDefault(),this.$removeSelection());break;case"Delete":t.preventDefault(),this.$removeSelection();break;case"ArrowLeft":t.preventDefault(),this.$move(-1,0);break;case"ArrowRight":t.preventDefault(),this.$move(1,0);break;case"ArrowUp":t.preventDefault(),this.$move(0,-1);break;case"ArrowDown":t.preventDefault(),this.$move(0,1);break;case"+":t.preventDefault(),this.$zoom(.1);break;case"-":t.preventDefault(),this.$zoom(-.1)}}$center(){const{parentElement:t}=this;if(!t)return this;const e=(t.offsetWidth-this.width)/2,i=(t.offsetHeight-this.height)/2;return this.$change(e,i)}$move(t,e=t){return this.$moveTo(this.x+t,this.y+e)}$moveTo(t,e=t){return this.movable?this.$change(t,e):this}$resize(t,e=0,i=0,s=this.aspectRatio){if(!this.resizable)return this;const n=K(s),{$canvas:a}=this;let{x:o,y:r,width:h,height:c}=this;switch(t){case C:r+=i,c-=i,c<0&&(t=y,c=-c,r-=c),n&&(o+=(e=i*s)/2,h-=e,h<0&&(h=-h,o-=h));break;case w:h+=e,h<0&&(t=E,h=-h,o-=h),n&&(r-=(i=e/s)/2,c+=i,c<0&&(c=-c,r-=c));break;case y:c+=i,c<0&&(t=C,c=-c,r-=c),n&&(o-=(e=i*s)/2,h+=e,h<0&&(h=-h,o-=h));break;case E:o+=e,h-=e,h<0&&(t=w,h=-h,o-=h),n&&(r+=(i=e/s)/2,c-=i,c<0&&(c=-c,r-=c));break;case S:n&&(i=-e/s),r+=i,c-=i,h+=e,h<0&&c<0?(t=k,h=-h,c=-c,o-=h,r-=c):h<0?(t=A,h=-h,o-=h):c<0&&(t=T,c=-c,r-=c);break;case A:n&&(i=e/s),o+=e,r+=i,h-=e,c-=i,h<0&&c<0?(t=T,h=-h,c=-c,o-=h,r-=c):h<0?(t=S,h=-h,o-=h):c<0&&(t=k,c=-c,r-=c);break;case T:n&&(i=e/s),h+=e,c+=i,h<0&&c<0?(t=A,h=-h,c=-c,o-=h,r-=c):h<0?(t=k,h=-h,o-=h):c<0&&(t=S,c=-c,r-=c);break;case k:n&&(i=-e/s),o+=e,h-=e,c+=i,h<0&&c<0?(t=S,h=-h,c=-c,o-=h,r-=c):h<0?(t=T,h=-h,o-=h):c<0&&(t=A,c=-c,r-=c)}return a&&a.$setAction(t),this.$change(o,r,h,c)}$zoom(t,e,i){if(!this.zoomable||0===t)return this;t<0?t=1/(1-t):t+=1;const{width:s,height:n}=this,a=s*t,o=n*t;let r=this.x,h=this.y;return B(e)&&B(i)?(r-=(a-s)*((e-this.x)/s),h-=(o-n)*((i-this.y)/n)):(r-=(a-s)/2,h-=(o-n)/2),this.$change(r,h,a,o)}$change(t,e,i=this.width,s=this.height,n=this.aspectRatio,a=!1){return this.$changing||!B(t)||!B(e)||!B(i)||!B(s)||i<0||s<0?this:(K(n)&&({width:i,height:s}=bt({aspectRatio:n,width:i,height:s},"cover")),this.precise||(t=Math.round(t),e=Math.round(e),i=Math.round(i),s=Math.round(s)),t===this.x&&e===this.y&&i===this.width&&s===this.height&&Object.is(n,this.aspectRatio)&&!a?this:(this.hidden&&(this.hidden=!1),!1===this.$emit(j,{x:t,y:e,width:i,height:s})?this:(this.$changing=!0,this.x=t,this.y=e,this.width=i,this.height=s,this.$changing=!1,this.$render())))}$reset(){const{x:t,y:e,width:i,height:s}=this.$initialSelection;return this.$change(t,e,i,s)}$clear(){return this.$change(0,0,0,0,NaN,!0),this.hidden=!0,this}$render(){return this.$setStyles({transform:`translate(${this.x}px, ${this.y}px)`,width:this.width,height:this.height})}$toCanvas(t){return new Promise(((e,i)=>{if(!this.isConnected)return void i(new Error("The current element is not connected to the DOM."));const s=document.createElement("canvas");let{width:n,height:a}=this,o=1;if(J(t)&&(K(t.width)||K(t.height))&&(({width:n,height:a}=bt({aspectRatio:n/a,width:t.width,height:t.height})),o=n/this.width),s.width=n,s.height=a,!this.$canvas)return void e(s);const r=this.$canvas.querySelector(this.$getTagNameOf(l));r?r.$ready().then((i=>{const h=s.getContext("2d");if(h){const[e,c,l,d,u,$]=r.$getTransform(),p=-this.x,g=-this.y,m=(p*d-l*g)/(e*d-l*c),b=(g*e-c*p)/(e*d-l*c);let f=e*m+l*b+u,v=c*m+d*b+$,C=i.naturalWidth,w=i.naturalHeight;1!==o&&(f*=o,v*=o,C*=o,w*=o);const y=C/2,E=w/2;h.fillStyle="transparent",h.fillRect(0,0,n,a),J(t)&&Q(t.beforeDraw)&&t.beforeDraw.call(this,h,s),h.save(),h.translate(y,E),h.transform(e,c,l,d,f,v),h.translate(-y,-E),h.drawImage(i,0,0,C,w),h.restore()}e(s)})).catch(i):e(s)}))}}Mt.$name=d,Mt.$version="2.0.0-rc.2";class Pt extends At{constructor(){super(...arguments),this.$style=":host{display:flex;flex-direction:column;position:relative;touch-action:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}:host([bordered]){border:1px dashed var(--theme-color)}:host([covered]){bottom:0;left:0;position:absolute;right:0;top:0}:host>span{display:flex;flex:1}:host>span+span{border-top:1px dashed var(--theme-color)}:host>span>span{flex:1}:host>span>span+span{border-left:1px dashed var(--theme-color)}",this.bordered=!1,this.columns=3,this.covered=!1,this.rows=3,this.slottable=!1,this.themeColor="rgba(238, 238, 238, 0.5)"}static get observedAttributes(){return super.observedAttributes.concat(["bordered","columns","covered","rows"])}$propertyChangedCallback(t,e,i){Object.is(i,e)||(super.$propertyChangedCallback(t,e,i),"rows"!==t&&"columns"!==t||this.$nextTick((()=>{this.$render()})))}connectedCallback(){super.connectedCallback(),this.$render()}$render(){const t=this.$getShadowRoot(),e=document.createDocumentFragment();for(let t=0;t{setTimeout((()=>{this.$render()}),50)})))}$handleSourceImageTransform(t){this.$render(void 0,t.detail.matrix)}$render(t,e){const{$canvas:i,$selection:s}=this;t||s.hidden||(t=s),(!t||0===t.x&&0===t.y&&0===t.width&&0===t.height)&&(t={x:0,y:0,width:i.offsetWidth,height:i.offsetHeight});const{x:n,y:a,width:o,height:r}=t,h={},{clientWidth:c,clientHeight:l}=this;let d=c,u=l,$=NaN;switch(this.resize){case"both":$=1,d=o,u=r,h.width=o,h.height=r;break;case"horizontal":$=r>0?l/r:0,d=o*$,h.width=d;break;case Xt:$=o>0?c/o:0,u=r*$,h.height=u;break;default:c>0?$=o>0?c/o:0:l>0&&($=r>0?l/r:0)}this.$scale=$,this.$setStyles(h),this.$sourceImage&&this.$transformImageByOffset(null!=e?e:this.$sourceImage.$getTransform(),-n,-a)}$transformImageByOffset(t,e,i){const{$image:s,$scale:n,$sourceImage:a}=this;if(a&&s&&n>=0){const[a,o,r,h,c,l]=t,d=(e*h-r*i)/(a*h-r*o),u=(i*a-o*e)/(a*h-r*o),$=a*d+r*u+c,p=o*d+h*u+l;s.$ready((t=>{this.$setStyles.call(s,{width:t.naturalWidth*n,height:t.naturalHeight*n})})),s.$setTransform(a,o,r,h,$*n,p*n)}}}Ht.$name=$,Ht.$version="2.0.0-rc.2";var jt='';const Vt=/^img|canvas$/,Ut=/<(\/?(?:script|style)[^>]*)>/gi,qt={template:jt};Tt.$define(),Dt.$define(),Pt.$define(),Rt.$define(),Ot.$define(),Mt.$define(),It.$define(),Ht.$define();class Bt{constructor(t,e){if(this.options=qt,U(t)&&(t=document.querySelector(t)),!tt(t)||!Vt.test(t.localName))throw new Error("The first argument is required and must be an or element.");this.element=t,e=Object.assign(Object.assign({},qt),e),this.options=e;const{ownerDocument:i}=t;let{container:s}=e;if(s&&(U(s)&&(s=i.querySelector(s)),!tt(s)))throw new Error("The `container` option must be an element or a valid selector.");tt(s)||(s=t.parentElement?t.parentElement:i.body),this.container=s;const n=t.localName;let a="";"img"===n?({src:a}=t):"canvas"===n&&window.HTMLCanvasElement&&(a=t.toDataURL());const{template:o}=e;if(o&&U(o)){const e=document.createElement("template"),i=document.createDocumentFragment();e.innerHTML=o.replace(Ut,"<$1>"),i.appendChild(e.content),Array.from(i.querySelectorAll(l)).forEach((e=>{e.setAttribute("src",a),e.setAttribute("alt",t.alt||"The image to crop")})),t.parentElement?(t.style.display="none",s.insertBefore(i,t.nextSibling)):s.appendChild(i)}}getCropperCanvas(){return this.container.querySelector(o)}getCropperImage(){return this.container.querySelector(l)}getCropperSelection(){return this.container.querySelector(d)}getCropperSelections(){return this.container.querySelectorAll(d)}}Bt.version="2.0.0-rc.2",t.ACTION_MOVE=g,t.ACTION_NONE=v,t.ACTION_RESIZE_EAST=w,t.ACTION_RESIZE_NORTH=C,t.ACTION_RESIZE_NORTHEAST=S,t.ACTION_RESIZE_NORTHWEST=A,t.ACTION_RESIZE_SOUTH=y,t.ACTION_RESIZE_SOUTHEAST=T,t.ACTION_RESIZE_SOUTHWEST=k,t.ACTION_RESIZE_WEST=E,t.ACTION_ROTATE=b,t.ACTION_SCALE=m,t.ACTION_SELECT=p,t.ACTION_TRANSFORM=f,t.ATTRIBUTE_ACTION=x,t.CROPPER_CANVAS=o,t.CROPPER_CROSSHAIR=r,t.CROPPER_GIRD=h,t.CROPPER_HANDLE=c,t.CROPPER_IMAGE=l,t.CROPPER_SELECTION=d,t.CROPPER_SHADE=u,t.CROPPER_VIEWER=$,t.CropperCanvas=Tt,t.CropperCrosshair=Dt,t.CropperElement=At,t.CropperGrid=Pt,t.CropperHandle=Rt,t.CropperImage=Ot,t.CropperSelection=Mt,t.CropperShade=It,t.CropperViewer=Ht,t.DEFAULT_TEMPLATE=jt,t.EVENT_ACTION=Y,t.EVENT_ACTION_END=L,t.EVENT_ACTION_MOVE=X,t.EVENT_ACTION_START=H,t.EVENT_CHANGE=j,t.EVENT_ERROR=P,t.EVENT_KEYDOWN=D,t.EVENT_LOAD=_,t.EVENT_POINTER_DOWN=R,t.EVENT_POINTER_MOVE=z,t.EVENT_POINTER_UP=M,t.EVENT_RESIZE="resize",t.EVENT_TOUCH_END=O,t.EVENT_TOUCH_MOVE=N,t.EVENT_TOUCH_START=I,t.EVENT_TRANSFORM=V,t.EVENT_WHEEL=W,t.HAS_POINTER_EVENT=n,t.IS_BROWSER=e,t.IS_TOUCH_DEVICE=s,t.NAMESPACE=a,t.WINDOW=i,t.default=Bt,t.emit=lt,t.getAdjustedSizes=bt,t.getOffset=$t,t.isElement=tt,t.isFunction=Q,t.isNaN=q,t.isNumber=B,t.isObject=F,t.isPlainObject=J,t.isPositiveNumber=K,t.isString=U,t.isUndefined=Z,t.multiplyMatrices=ft,t.nextTick=ut,t.off=ot,t.on=rt,t.once=ht,t.toAngleInRadian=gt,t.toCamelCase=nt,t.toKebabCase=it,Object.defineProperty(t,"__esModule",{value:!0})})); diff --git a/wcfsetup/install/files/js/3rdParty/exif-reader.js b/wcfsetup/install/files/js/3rdParty/exif-reader.js new file mode 100644 index 00000000000..01f41d181a5 --- /dev/null +++ b/wcfsetup/install/files/js/3rdParty/exif-reader.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.ExifReader=t():e.ExifReader=t()}("undefined"!=typeof self?self:this,(function(){return function(){"use strict";var e={d:function(t,n){for(var r in n)e.o(n,r)&&!e.o(t,r)&&Object.defineProperty(t,r,{enumerable:1,get:n[r]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r:function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:1})}},t={};function n(e,t,n){for(var i=[],o=0;o=T&&n<=x||n===P||n===y||n===h||n===m||n===b||n===A||n===S}function O(e,t){return e.getUint16(t)===U}var k="‰PNG\r\n\n",B=4,C=4,_=0,D=B,R=B+C,j="tEXt",M="iTXt",N="zTXt",G="pHYs",z="tIME",F="eXIf";function V(e,t,r){var i=n(e,t+D,C);return i===j||i===M||i===N&&r}function H(e,t){return n(e,t+D,C)===F}function X(e,t){var r=[G,z],i=n(e,t+D,C);return r.includes(i)}var Y={parseAppMarkers:function(e,t){if(function(e){return!!e&&e.byteLength>=s&&e.getUint16(0)===d}(e))return W(function(e){for(var t,n,r=p;r+g+5<=e.byteLength;){if(L(e,r))t=e.getUint16(r+l),n=r+v;else{if(!I(e,r)){if(O(e,r)){r++;continue}break}t=e.getUint16(r+l)}r+=l+t}return{hasAppMarkers:r>p,fileDataOffset:void 0,jfifDataOffset:void 0,tiffHeaderOffset:n,iptcDataOffset:void 0,xmpChunks:void 0,iccChunks:void 0,mpfDataOffset:void 0}}(e),"jpeg","JPEG");if(function(e){return!!e&&n(e,0,k.length)===k}(e))return W(function(e,t){for(var r={hasAppMarkers:0},i=k.length;i+B+C<=e.byteLength;){if(V(e,i,t)){r.hasAppMarkers=1;var o=n(e,i+D,C);r.pngTextChunks||(r.pngTextChunks=[]),r.pngTextChunks.push({length:e.getUint32(i+_),type:o,offset:i+R})}else H(e,i)?(r.hasAppMarkers=1,r.tiffHeaderOffset=i+R):X(e,i)&&(r.hasAppMarkers=1,r.pngChunkOffsets||(r.pngChunkOffsets=[]),r.pngChunkOffsets.push(i+_));i+=e.getUint32(i+_)+B+C+4}return r}(e,t),"png","PNG");if(function(e){return!!e&&"RIFF"===n(e,0,4)&&"WEBP"===n(e,8,4)}(e))return W(function(e){for(var t,r,i=12,o=0;i+8e.byteLength);c++){var s=de(e,t,n,r,i,o);void 0!==s&&(u[s.name]={id:s.id,value:s.value,description:s.description},"MakerNote"===s.name&&(u[s.name].__offset=s.__offset)),r+=12}return u}function de(e,t,n,r,i,o){var f,u,a=oe.getTypeSize("SHORT"),c=a+oe.getTypeSize("SHORT"),s=c+oe.getTypeSize("LONG"),d=oe.getShortAt(e,r,i),p=oe.getShortAt(e,r+a,i),g=oe.getLongAt(e,r+c,i);if(void 0!==oe.typeSizes[p]&&(o||void 0!==ne[t][d])){f=function(e,t){return oe.typeSizes[e]*t<=oe.getTypeSize("LONG")}(p,g)?pe(e,u=r+s,p,g,i):function(e,t,n,r,i){return t+n+oe.typeSizes[r]*i<=e.byteLength}(e,n,u=oe.getLongAt(e,r+s,i),p,g)?pe(e,n+u,p,g,i,33723===d):"",p===oe.tagTypes.ASCII&&(f=function(e){try{return e.map((function(e){return decodeURIComponent(escape(e))}))}catch(t){return e}}(f=function(e){for(var t=[],n=0,r=0;r5&&void 0!==arguments[5]&&arguments[5]&&(r*=oe.typeSizes[n],n=oe.tagTypes.BYTE);for(var f=0;f0?Promise.all(o):void 0}}},xe="STATE_KEYWORD",Pe="STATE_COMPRESSION",Ue="STATE_LANG",Ee="STATE_TRANSLATED_KEYWORD",Le="STATE_TEXT",Ie=1,Oe=1,ke=6;function Be(e,t,n,r,i){for(var f,u=[],a=[],c=[],s=xe,d=o,p=0;p3&&void 0!==arguments[3]?arguments[3]:"string";if(0===t&&"function"==typeof DecompressionStream){var i=new DecompressionStream("deflate"),o=new Blob([e]).stream().pipeThrough(i);return"dataview"===r?new Response(o).arrayBuffer().then((function(e){return new DataView(e)})):new Response(o).arrayBuffer().then((function(e){return new TextDecoder(n).decode(e)}))}return void 0!==t?Promise.reject("Unknown compression method ".concat(t,".")):e}(f,d,function(e){return e===j||e===N?"latin1":"utf-8"}(r));return l instanceof Promise?l.then((function(e){return De(e,r,a,u)})).catch((function(){return De("".split(""),r,a,u)})):De(l,r,a,u)}function Ce(e){var t=e.type,n=e.dataView,r=e.offset;if(t===M){if(n.getUint8(r)===Oe)return n.getUint8(r+1)}else if(t===N)return n.getUint8(r);return o}function _e(e,t){return t===xe&&[M,N].includes(e)?Pe:t===Pe?e===M?Ue:Le:t===Ue?Ee:Le}function De(e,t,r,i){var o=function(e){return e instanceof DataView?n(e,0,e.byteLength):e}(e);return{name:Re(t,r,i),value:o,description:t===M?je(e):o}}function Re(e,t,n){var i=r(n);if(e===j||0===t.length)return i;var o=r(t);return"".concat(i," (").concat(o,")")}function je(e){return Se("UTF-8",e)}function Me(e,t){return"raw profile type exif"===e.toLowerCase()&&"exif"===t.substring(1,5)}function Ne(e,t){return"raw profile type iptc"===e.toLowerCase()&&"iptc"===t.substring(1,5)}function Ge(e){return function(e){for(var t=new DataView(new ArrayBuffer(e.length/2)),n=0;n1&&void 0!==arguments[1]?arguments[1]:{};return function(e){return"string"==typeof e}(e)?(n.async=1,function(e,t){return/^\w+:\/\//.test(e)?"undefined"!=typeof fetch?function(e){var t=(arguments.length>1&&void 0!==arguments[1]?arguments[1]:{}).length,n={method:"GET"};return Number.isInteger(t)&&t>=0&&(n.headers={range:"bytes=0-".concat(t-1)}),fetch(e,n).then((function(e){return e.arrayBuffer()}))}(e,t):function(e){var t=(arguments.length>1&&void 0!==arguments[1]?arguments[1]:{}).length;return new Promise((function(n,r){var i={};Number.isInteger(t)&&t>=0&&(i.headers={range:"bytes=0-".concat(t-1)});var o=function(e){return/^https:\/\//.test(e)?require("https").get:require("http").get}(e);o(e,i,(function(e){if(e.statusCode>=200&&e.statusCode<=299){var t=[];e.on("data",(function(e){return t.push(Buffer.from(e))})),e.on("error",(function(e){return r(e)})),e.on("end",(function(){return n(Buffer.concat(t))}))}else r("Could not fetch file: ".concat(e.statusCode," ").concat(e.statusMessage)),e.resume()})).on("error",(function(e){return r(e)}))}))}(e,t):function(e){return/^data:[^;,]*(;base64)?,/.test(e)}(e)?Promise.resolve(function(e){var t=e.substring(e.indexOf(",")+1);if(-1!==e.indexOf(";base64")){if("undefined"!=typeof atob)return Uint8Array.from(atob(t),(function(e){return e.charCodeAt(0)})).buffer;if("undefined"==typeof Buffer)return;return"undefined"!=typeof Buffer.from?Buffer.from(t,"base64"):new Buffer(t,"base64")}var n=decodeURIComponent(t);return"undefined"!=typeof Buffer?"undefined"!=typeof Buffer.from?Buffer.from(n):new Buffer(n):Uint8Array.from(n,(function(e){return e.charCodeAt(0)})).buffer}(e)):function(e){var t=(arguments.length>1&&void 0!==arguments[1]?arguments[1]:{}).length;return new Promise((function(n,r){var i=function(){try{return require("fs")}catch(e){return}}();i.open(e,(function(o,f){o?r(o):i.stat(e,(function(o,u){if(o)r(o);else{var a=Math.min(u.size,void 0!==t?t:u.size),c=Buffer.alloc(a),s={buffer:c,length:a};i.read(f,s,(function(t){t?r(t):i.close(f,(function(t){t&&console.warn("Could not close file ".concat(e,":"),t),n(c)}))}))}}))}))}))}(e,t)}(e,n).then((function(e){return rt(e,n)}))):function(e){return"undefined"!=typeof window&&"undefined"!=typeof File&&e instanceof File}(e)?(n.async=1,(t=e,new Promise((function(e,n){var r=new FileReader;r.onload=function(t){return e(t.target.result)},r.onerror=function(){return n(r.error)},r.readAsArrayBuffer(t)}))).then((function(e){return rt(e,n)}))):rt(e,n)}function rt(e,t){return function(e){try{return Buffer.isBuffer(e)}catch(e){return 0}}(e)&&(e=new Uint8Array(e).buffer),it(function(e){try{return new DataView(e)}catch(t){return new a(e)}}(e),t)}function it(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{expanded:0,async:0,includeUnknown:0},n=t.expanded,r=void 0===n?0:n,o=t.async,f=void 0===o?0:o,u=t.includeUnknown,a=void 0===u?0:u,s=0,d={},p=[],g=Y.parseAppMarkers(e,f),l=g.fileType,v=(g.fileDataOffset,g.jfifDataOffset,g.tiffHeaderOffset),y=(g.iptcDataOffset,g.xmpChunks,g.iccChunks,g.mpfDataOffset,g.pngHeaderOffset,g.pngTextChunks),h=g.pngChunkOffsets,m=g.vp8xChunkOffset;if(g.gifHeaderOffset,function(e){return void 0!==e}(v)){s=1;var b=he.read(e,v,a),A=b.tags;b.byteOrder,A.Thumbnail&&(d.Thumbnail=A.Thumbnail,delete A.Thumbnail),r?(d.exif=A,function(e){if(e.exif){if(e.exif.GPSLatitude&&e.exif.GPSLatitudeRef)try{e.gps=e.gps||{},e.gps.Latitude=c(e.exif.GPSLatitude.value),"S"===e.exif.GPSLatitudeRef.value.join("")&&(e.gps.Latitude=-e.gps.Latitude)}catch(e){}if(e.exif.GPSLongitude&&e.exif.GPSLongitudeRef)try{e.gps=e.gps||{},e.gps.Longitude=c(e.exif.GPSLongitude.value),"W"===e.exif.GPSLongitudeRef.value.join("")&&(e.gps.Longitude=-e.gps.Longitude)}catch(e){}if(e.exif.GPSAltitude&&e.exif.GPSAltitudeRef)try{e.gps=e.gps||{},e.gps.Altitude=e.exif.GPSAltitude.value[0]/e.exif.GPSAltitude.value[1],1===e.exif.GPSAltitudeRef.value&&(e.gps.Altitude=-e.gps.Altitude)}catch(e){}}}(d)):d=i({},d,A),A.MakerNote&&delete A.MakerNote.__offset}if(function(e){return void 0!==e}(y)){s=1;var S=we.read(e,y,f,a),T=S.readTags,w=S.readTagsPromise;U(T),w&&p.push(w.then((function(e){return e.forEach(U)})))}if(function(e){return void 0!==e}(h)){s=1;var x=ze.read(e,h);r?d.png=d.png?i({},d.png,x):x:d=i({},d,x)}if(function(e){return void 0!==e}(m)){s=1;var P=qe.read(e,m);r?d.riff=d.riff?i({},d.riff,P):P:d=i({},d,P)}if(delete d.Thumbnail,l&&(r?(d.file||(d.file={}),d.file.FileType=l):d.FileType=l,s=1),!s)throw new Ze.MetadataMissingError;return f?Promise.all(p).then((function(){return d})):d;function U(e){if(r){for(var t=0,n=["exif","iptc"];t { - void upload(element, resizedFile); - }); + if (element.dataset.cropperConfiguration) { + const cropperConfiguration = JSON.parse(element.dataset.cropperConfiguration); + void (0, Cropper_1.cropImage)(element, file, cropperConfiguration) + .then((resizedFile) => { + void upload(element, resizedFile); + }) + .catch((e) => { + if (e === undefined) { + // User closed the dialog. + return; + } + if (e instanceof Error) { + (0, Util_1.innerError)(element, e.message); + } + }); + } + else { + void resizeImage(element, file).then((resizedFile) => { + void upload(element, resizedFile); + }); + } }); element.addEventListener("ckeditorDrop", (event) => { const { file } = event.detail; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Image/Cropper.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Image/Cropper.js new file mode 100644 index 00000000000..4fd299bde87 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Image/Cropper.js @@ -0,0 +1,330 @@ +/** + * An image cropper that allows the user to crop an image before uploading it. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ +define(["require", "exports", "tslib", "WoltLabSuite/Core/Image/Resizer", "WoltLabSuite/Core/Component/Dialog", "cropperjs", "WoltLabSuite/Core/Language", "exifreader", "WoltLabSuite/Core/Dom/Util"], function (require, exports, tslib_1, Resizer_1, Dialog_1, cropperjs_1, Language_1, exifreader_1, Util_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.cropImage = cropImage; + Resizer_1 = tslib_1.__importDefault(Resizer_1); + cropperjs_1 = tslib_1.__importDefault(cropperjs_1); + exifreader_1 = tslib_1.__importDefault(exifreader_1); + Util_1 = tslib_1.__importDefault(Util_1); + function inSelection(selection, maxSelection) { + return (Math.round(selection.x) >= maxSelection.x && + Math.round(selection.y) >= maxSelection.y && + Math.round(selection.x + selection.width) <= Math.round(maxSelection.x + maxSelection.width) && + Math.round(selection.y + selection.height) <= Math.round(maxSelection.y + maxSelection.height)); + } + class ImageCropper { + configuration; + file; + element; + resizer; + image; + cropperCanvas; + cropperImage; + cropperSelection; + dialog; + exif; + orientation; + cropperCanvasRect; + #cropper; + constructor(element, file, configuration) { + this.configuration = configuration; + this.element = element; + this.file = file; + this.resizer = new Resizer_1.default(); + } + get width() { + switch (this.orientation) { + case 90: + case 270: + return this.image.height; + default: + return this.image.width; + } + } + get height() { + switch (this.orientation) { + case 90: + case 270: + return this.image.width; + default: + return this.image.height; + } + } + async loadImage() { + const { image, exif } = await this.resizer.loadFile(this.file); + this.image = image; + this.exif = exif; + const tags = await exifreader_1.default.load(this.file); + if (tags.Orientation) { + switch (tags.Orientation.value) { + case 3: + this.orientation = 180; + break; + case 6: + this.orientation = 90; + break; + case 8: + this.orientation = 270; + break; + // Any other rotation is unsupported. + } + } + } + async showDialog() { + this.dialog = (0, Dialog_1.dialogFactory)().fromElement(this.image).asPrompt({ + extra: this.getDialogExtra(), + }); + this.dialog.show((0, Language_1.getPhrase)("wcf.upload.crop.image")); + this.createCropper(); + const resize = () => { + this.centerSelection(); + }; + window.addEventListener("resize", resize, { passive: true }); + return new Promise((resolve, reject) => { + let callReject = true; + this.dialog.addEventListener("afterClose", () => { + window.removeEventListener("resize", resize); + // If the dialog is closed without confirming, reject the promise to trigger a cancel event. + if (callReject) { + reject(); + } + }); + this.dialog.addEventListener("primary", () => { + callReject = false; + void this.getCanvas() + .then((canvas) => { + this.resizer + .saveFile({ exif: this.orientation ? undefined : this.exif, image: canvas }, this.file.name, this.file.type) + .then((resizedFile) => { + resolve(resizedFile); + }) + .catch(() => { + reject(); + }); + }) + .catch(() => { + reject(); + }); + }); + }); + } + getDialogExtra() { + return undefined; + } + getCanvas() { + // Calculate the size of the image in relation to the window size + const selectionRatio = Math.min(this.cropperCanvasRect.width / this.width, this.cropperCanvasRect.height / this.height); + const width = this.cropperSelection.width / selectionRatio; + const height = width / this.configuration.aspectRatio; + return this.cropperSelection.$toCanvas({ + width: Math.max(Math.min(Math.floor(width), this.maxSize.width), this.minSize.width), + height: Math.max(Math.min(Math.ceil(height), this.maxSize.height), this.minSize.height), + }); + } + createCropper() { + this.#cropper = new cropperjs_1.default(this.image, { + template: this.getCropperTemplate(), + }); + this.cropperCanvas = this.#cropper.getCropperCanvas(); + this.cropperImage = this.#cropper.getCropperImage(); + this.cropperSelection = this.#cropper.getCropperSelection(); + this.setCropperStyle(); + if (this.orientation) { + this.cropperImage.$rotate(`${this.orientation}deg`); + } + this.centerSelection(); + // Limit the selection to the canvas boundaries + this.cropperSelection.addEventListener("change", (event) => { + // see https://fengyuanchen.github.io/cropperjs/v2/api/cropper-selection.html#limit-boundaries + const cropperCanvasRect = this.cropperCanvas.getBoundingClientRect(); + const selection = event.detail; + const maxSelection = { + x: 0, + y: 0, + width: cropperCanvasRect.width, + height: cropperCanvasRect.height, + }; + if (!inSelection(selection, maxSelection)) { + event.preventDefault(); + } + }); + // Limit the selection to the min/max size + this.cropperSelection.addEventListener("change", (event) => { + const selection = event.detail; + this.cropperCanvasRect = this.cropperCanvas.getBoundingClientRect(); + const selectionRatio = Math.min(this.cropperCanvasRect.width / this.width, this.cropperCanvasRect.height / this.height); + const minWidth = this.minSize.width * selectionRatio; + const maxWidth = this.cropperCanvasRect.width; + const minHeight = minWidth / this.configuration.aspectRatio; + const maxHeight = maxWidth / this.configuration.aspectRatio; + if (selection.width < minWidth || + selection.height < minHeight || + selection.width > maxWidth || + selection.height > maxHeight) { + event.preventDefault(); + } + }); + } + setCropperStyle() { + this.cropperCanvas.style.aspectRatio = `${this.width}/${this.height}`; + this.cropperSelection.aspectRatio = this.configuration.aspectRatio; + } + centerSelection() { + // Set to the maximum size + this.cropperCanvas.style.width = `${this.width}px`; + this.cropperCanvas.style.height = `${this.height}px`; + const dimension = Util_1.default.innerDimensions(this.cropperCanvas.parentElement); + const ratio = Math.min(dimension.width / this.width, dimension.height / this.height); + this.cropperCanvas.style.height = `${this.height * ratio}px`; + this.cropperCanvas.style.width = `${this.width * ratio}px`; + this.cropperImage.$center("contain"); + this.cropperCanvasRect = this.cropperImage.getBoundingClientRect(); + const selectionRatio = Math.min(this.cropperCanvasRect.width / this.maxSize.width, this.cropperCanvasRect.height / this.maxSize.height); + this.cropperSelection.$change(0, 0, Math.min(this.cropperCanvasRect.width, this.maxSize.width * selectionRatio), Math.min(this.cropperCanvasRect.height, this.maxSize.height * selectionRatio), this.configuration.aspectRatio, true); + this.cropperSelection.$center(); + this.cropperSelection.scrollIntoView({ block: "center", inline: "center" }); + } + getCropperTemplate() { + return ` + + + + + + + + + + + + + + + + +`; + } + } + class ExactImageCropper extends ImageCropper { + get minSize() { + return this.configuration.sizes[0]; + } + get maxSize() { + return this.configuration.sizes[this.configuration.sizes.length - 1]; + } + async showDialog() { + // The image already has the correct size, cropping is not necessary + if (this.configuration.sizes.filter((size) => { + return size.width == this.width && size.height == this.height; + }).length > 0 && + this.image instanceof HTMLCanvasElement) { + return this.resizer.saveFile({ exif: this.orientation ? undefined : this.exif, image: this.image }, this.file.name, this.file.type); + } + return super.showDialog(); + } + getCanvas() { + // Calculate the size of the image in relation to the window size + const selectionRatio = Math.min(this.cropperCanvasRect.width / this.width, this.cropperCanvasRect.height / this.height); + const width = this.cropperSelection.width / selectionRatio; + const height = width / this.configuration.aspectRatio; + const sizes = this.configuration.sizes + .filter((size) => { + return width >= size.width && height >= size.height; + }) + .reverse(); + const size = sizes.length > 0 ? sizes[0] : this.minSize; + return this.cropperSelection.$toCanvas({ + width: size.width, + height: size.height, + }); + } + async loadImage() { + await super.loadImage(); + const sizes = this.configuration.sizes + .filter((size) => { + return size.width <= this.width && size.height <= this.height; + }) + .sort((a, b) => { + if (this.configuration.aspectRatio >= 1) { + return a.width - b.width; + } + else { + return a.height - b.height; + } + }); + if (sizes.length === 0) { + const smallestSize = this.configuration.sizes.length > 1 ? this.configuration.sizes[this.configuration.sizes.length - 1] : undefined; + throw new Error((0, Language_1.getPhrase)("wcf.upload.error.image.tooSmall", { + width: smallestSize?.width, + height: smallestSize?.height, + })); + } + this.configuration.sizes = sizes; + } + } + class MinMaxImageCropper extends ImageCropper { + constructor(element, file, configuration) { + super(element, file, configuration); + if (configuration.sizes.length !== 2) { + throw new Error("MinMaxImageCropper requires exactly two sizes"); + } + } + get minSize() { + return this.configuration.sizes[0]; + } + get maxSize() { + return this.configuration.sizes[1]; + } + getDialogExtra() { + return (0, Language_1.getPhrase)("wcf.global.button.reset"); + } + async loadImage() { + await super.loadImage(); + if (this.width < this.minSize.width || this.height < this.minSize.height) { + throw new Error((0, Language_1.getPhrase)("wcf.upload.error.image.tooSmall", { + width: this.minSize.width, + height: this.minSize.height, + })); + } + } + createCropper() { + super.createCropper(); + this.dialog.addEventListener("extra", () => { + this.centerSelection(); + }); + } + } + async function cropImage(element, file, configuration) { + switch (file.type) { + case "image/jpeg": + case "image/png": + case "image/webp": + // Potential candidate for a resize operation. + break; + default: + // Not an image or an unsupported file type. + return file; + } + let imageCropper; + switch (configuration.type) { + case "exact": + imageCropper = new ExactImageCropper(element, file, configuration); + break; + case "minMax": + imageCropper = new MinMaxImageCropper(element, file, configuration); + break; + default: + throw new Error("Invalid configuration type"); + } + await imageCropper.loadImage(); + return imageCropper.showDialog(); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js index c5a472f2b54..736f5015fa7 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js @@ -81,6 +81,34 @@ define(["require", "exports", "tslib", "../StringUtil"], function (require, expo } return id; }, + /** + * Returns the inner height of an element including paddings. + */ + innerHeight(element, styles) { + styles = styles || window.getComputedStyle(element); + let height = element.clientHeight; + height -= ~~styles.paddingTop + ~~styles.paddingBottom; + return height; + }, + /** + * Returns the inner width of an element including paddings. + */ + innerWidth(element, styles) { + styles = styles || window.getComputedStyle(element); + let width = element.clientWidth; + width -= ~~parseInt(styles.paddingLeft) + ~~parseInt(styles.paddingRight); + return width; + }, + /** + * Returns the inner dimensions of an element including paddings. + */ + innerDimensions(element) { + const styles = window.getComputedStyle(element); + return { + height: DomUtil.innerHeight(element, styles), + width: DomUtil.innerWidth(element, styles), + }; + }, /** * Returns the outer height of an element including margins. */ diff --git a/wcfsetup/install/files/js/require.config.js b/wcfsetup/install/files/js/require.config.js index c4531f84760..8ff5950e0ec 100644 --- a/wcfsetup/install/files/js/require.config.js +++ b/wcfsetup/install/files/js/require.config.js @@ -19,6 +19,8 @@ requirejs.config({ "diff-match-patch": "3rdParty/diff-match-patch/diff_match_patch.min", "emoji-picker-element": "3rdParty/emoji-picker-element.min", sortablejs: "3rdParty/Sortable.min", + cropperjs: "3rdParty/cropper.min", + exifreader: "3rdParty/exif-reader", }, packages: [ { diff --git a/wcfsetup/install/files/lib/data/attachment/AttachmentList.class.php b/wcfsetup/install/files/lib/data/attachment/AttachmentList.class.php index f4b35143c15..77636895d63 100644 --- a/wcfsetup/install/files/lib/data/attachment/AttachmentList.class.php +++ b/wcfsetup/install/files/lib/data/attachment/AttachmentList.class.php @@ -3,8 +3,7 @@ namespace wcf\data\attachment; use wcf\data\DatabaseObjectList; -use wcf\data\file\FileList; -use wcf\data\file\thumbnail\FileThumbnailList; +use wcf\system\cache\runtime\FileRuntimeCache; /** * Represents a list of attachments. @@ -51,20 +50,10 @@ private function loadFiles(): void return; } - $fileList = new FileList(); - $fileList->getConditionBuilder()->add("fileID IN (?)", [$fileIDs]); - $fileList->readObjects(); - $files = $fileList->getObjects(); - - $thumbnailList = new FileThumbnailList(); - $thumbnailList->getConditionBuilder()->add("fileID IN (?)", [$fileIDs]); - $thumbnailList->readObjects(); - foreach ($thumbnailList as $thumbnail) { - $files[$thumbnail->fileID]->addThumbnail($thumbnail); - } + FileRuntimeCache::getInstance()->cacheObjectIDs($fileIDs); foreach ($this->objects as $attachment) { - $file = $files[$attachment->fileID] ?? null; + $file = FileRuntimeCache::getInstance()->getObject($attachment->fileID) ?? null; if ($file !== null) { $attachment->setFile($file); } diff --git a/wcfsetup/install/files/lib/data/file/FileEditor.class.php b/wcfsetup/install/files/lib/data/file/FileEditor.class.php index 83189370181..50564a6e8d8 100644 --- a/wcfsetup/install/files/lib/data/file/FileEditor.class.php +++ b/wcfsetup/install/files/lib/data/file/FileEditor.class.php @@ -5,7 +5,6 @@ use wcf\data\DatabaseObjectEditor; use wcf\data\file\temporary\FileTemporary; use wcf\data\file\thumbnail\FileThumbnailEditor; -use wcf\data\file\thumbnail\FileThumbnailList; use wcf\system\file\processor\FileProcessor; use wcf\system\image\ImageHandler; use wcf\util\ExifUtil; @@ -41,6 +40,7 @@ public function deleteFiles(): void public static function deleteAll(array $objectIDs = []) { $fileList = new FileList(); + $fileList->loadThumbnails = true; $fileList->getConditionBuilder()->add("fileID IN (?)", [$objectIDs]); $fileList->readObjects(); $files = $fileList->getObjects(); @@ -48,13 +48,6 @@ public static function deleteAll(array $objectIDs = []) return 0; } - $thumbnailList = new FileThumbnailList(); - $thumbnailList->getConditionBuilder()->add("fileID IN (?)", [$objectIDs]); - $thumbnailList->readObjects(); - foreach ($thumbnailList as $thumbnail) { - $files[$thumbnail->fileID]->addThumbnail($thumbnail); - } - foreach ($files as $file) { (new FileEditor($file))->deleteFiles(); } diff --git a/wcfsetup/install/files/lib/data/file/FileList.class.php b/wcfsetup/install/files/lib/data/file/FileList.class.php index 9dca494aefd..22b16c0a776 100644 --- a/wcfsetup/install/files/lib/data/file/FileList.class.php +++ b/wcfsetup/install/files/lib/data/file/FileList.class.php @@ -3,6 +3,7 @@ namespace wcf\data\file; use wcf\data\DatabaseObjectList; +use wcf\data\file\thumbnail\FileThumbnailList; /** * @author Alexander Ebert @@ -18,4 +19,27 @@ class FileList extends DatabaseObjectList { public $className = File::class; + public bool $loadThumbnails = false; + + #[\Override] + public function readObjects() + { + parent::readObjects(); + + $this->loadThumbnails(); + } + + public function loadThumbnails(): void + { + if (!$this->loadThumbnails || $this->getObjectIDs() === []) { + return; + } + + $thumbnailList = new FileThumbnailList(); + $thumbnailList->getConditionBuilder()->add("fileID IN (?)", [$this->getObjectIDs()]); + $thumbnailList->readObjects(); + foreach ($thumbnailList as $thumbnail) { + $this->objects[$thumbnail->fileID]->addThumbnail($thumbnail); + } + } } diff --git a/wcfsetup/install/files/lib/system/cache/runtime/FileRuntimeCache.class.php b/wcfsetup/install/files/lib/system/cache/runtime/FileRuntimeCache.class.php new file mode 100644 index 00000000000..d08713019a6 --- /dev/null +++ b/wcfsetup/install/files/lib/system/cache/runtime/FileRuntimeCache.class.php @@ -0,0 +1,35 @@ + + * @since 6.2 + * + * @method File[] getCachedObjects() + * @method File|null getObject($objectID) + * @method File[] getObjects(array $objectIDs) + */ +class FileRuntimeCache extends AbstractRuntimeCache +{ + /** + * @inheritDoc + */ + protected $listClassName = FileList::class; + + #[\Override] + protected function getObjectList() + { + $fileList = new FileList(); + $fileList->loadThumbnails = true; + + return $fileList; + } +} diff --git a/wcfsetup/install/files/lib/system/event/listener/PreloadPhrasesCollectingListener.class.php b/wcfsetup/install/files/lib/system/event/listener/PreloadPhrasesCollectingListener.class.php index 6bced647c05..01f659eea13 100644 --- a/wcfsetup/install/files/lib/system/event/listener/PreloadPhrasesCollectingListener.class.php +++ b/wcfsetup/install/files/lib/system/event/listener/PreloadPhrasesCollectingListener.class.php @@ -150,8 +150,10 @@ public function __invoke(PreloadPhrasesCollecting $event): void $event->preload('wcf.style.changeStyle'); + $event->preload('wcf.upload.crop.image'); $event->preload('wcf.upload.error.fileExtensionNotPermitted'); $event->preload('wcf.upload.error.fileSizeTooLarge'); + $event->preload('wcf.upload.error.image.tooSmall'); $event->preload('wcf.upload.error.maximumCountReached'); $event->preload('wcf.upload.error.delete.permissionDenied'); $event->preload('wcf.upload.error.delete.unknownError'); diff --git a/wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php index 750edfeb35a..21634bd5a02 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php @@ -90,4 +90,11 @@ public function trackDownload(File $file): void { // Do not track downloads. } + + #[\Override] + public function getImageCropperConfiguration(): ?ImageCropperConfiguration + { + // Do not crop images. + return null; + } } diff --git a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php index 8ecd6b751ed..a83f45acb4e 100644 --- a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php @@ -105,6 +105,8 @@ public function getHtmlElement(IFileProcessor $fileProcessor, array $context): s $maximumSize = -1; } + $cropperConfiguration = $fileProcessor->getImageCropperConfiguration(); + return \sprintf( <<<'HTML' @@ -120,6 +123,8 @@ public function getHtmlElement(IFileProcessor $fileProcessor, array $context): s StringUtil::encodeHTML(JSON::encode($context)), StringUtil::encodeHTML($allowedFileExtensions), StringUtil::encodeHTML(JSON::encode($fileProcessor->getResizeConfiguration())), + $cropperConfiguration === null ? '' + : 'data-cropper-configuration="' . StringUtil::encodeHTML(JSON::encode($cropperConfiguration)) . '"', $maximumCount, $maximumSize, ); diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index 701d22ce1a6..7bffa60c920 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -156,4 +156,11 @@ public function getUploadResponse(File $file): array; * file types that are served by the web server itself. */ public function trackDownload(File $file): void; + + /** + * Returns the image cropper configuration for this file processor. + * + * @since 6.2 + */ + public function getImageCropperConfiguration(): ?ImageCropperConfiguration; } diff --git a/wcfsetup/install/files/lib/system/file/processor/ImageCropSize.class.php b/wcfsetup/install/files/lib/system/file/processor/ImageCropSize.class.php new file mode 100644 index 00000000000..f26c9d1ba28 --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/ImageCropSize.class.php @@ -0,0 +1,35 @@ + + * @since 6.2 + */ +final class ImageCropSize implements \JsonSerializable +{ + public function __construct( + public readonly int $width, + public readonly int $height + ) { + if ($width <= 0 || $height <= 0) { + throw new \OutOfRangeException("The width and height values must be larger than 0."); + } + } + + public function aspectRatio(): float + { + return $this->width / $this->height; + } + + #[\Override] + public function jsonSerialize(): mixed + { + return [ + 'width' => $this->width, + 'height' => $this->height, + ]; + } +} diff --git a/wcfsetup/install/files/lib/system/file/processor/ImageCropperConfiguration.class.php b/wcfsetup/install/files/lib/system/file/processor/ImageCropperConfiguration.class.php new file mode 100644 index 00000000000..73b928e0a42 --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/ImageCropperConfiguration.class.php @@ -0,0 +1,92 @@ + + * @since 6.2 + */ +final class ImageCropperConfiguration implements \JsonSerializable +{ + public readonly float $aspectRatio; + + /** + * @var ImageCropSize[] + */ + public readonly array $sizes; + + private function __construct( + public readonly ImageCropperType $type, + ImageCropSize ...$sizes + ) { + if ($sizes === []) { + throw new \InvalidArgumentException('At least one size must be provided.'); + } + + $size = $sizes[0]; + $this->aspectRatio = $size->aspectRatio(); + + foreach ($sizes as $size) { + if ($size->aspectRatio() !== $this->aspectRatio) { + throw new \InvalidArgumentException('All sizes must have the same aspect ratio.'); + } + } + + \usort($sizes, function (ImageCropSize $a, ImageCropSize $b) { + if ($a->width > $a->height) { + return $a->width <=> $b->width; + } else { + return $a->height <=> $b->height; + } + }); + $this->sizes = $sizes; + } + + #[\Override] + public function jsonSerialize(): mixed + { + return [ + 'aspectRatio' => $this->aspectRatio, + 'sizes' => $this->sizes, + 'type' => $this->type->toString(), + ]; + } + + /** + * Creates an image cropper with minimum and maximum size with the same aspect ratio. + * The user can freely select, move and scale. + * However, the cropping area is limited to `$min` and `$max`. + */ + public static function forMinMax(ImageCropSize $min, ImageCropSize $max): self + { + return new self(ImageCropperType::MinMax, $min, $max); + } + + /** + * Creates an image cropper that reduces the image to a specific size + * and only allows the user to move the cropping area. + * The size is determined by `$sizes` and corresponds to the smallest side of the image that is the next smaller + * or equal size of `$sizes`. The aspect ratio of the uploaded image is retained. + * + * Example: + * `$sizes` is [128x128, 256x256] + * - Image is 100x200 + * - Image is rejected + * - Image is 200x150 + * - Uploaded image is 128x128 + * - Image is 150x200 + * - Uploaded image is 128x128 + * - Image is 300x300 + * - Uploaded can image is 128x128 or 256x256, depending on cropping selection from the user + * - Image is 256x256 + * - The image is uploaded directly without displaying the cropping dialog + */ + public static function forExact(ImageCropSize ...$sizes): self + { + return new self(ImageCropperType::Exact, ...$sizes); + } +} diff --git a/wcfsetup/install/files/lib/system/file/processor/ImageCropperType.class.php b/wcfsetup/install/files/lib/system/file/processor/ImageCropperType.class.php new file mode 100644 index 00000000000..3df5db1847a --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/ImageCropperType.class.php @@ -0,0 +1,31 @@ + + * @since 6.2 + */ +enum ImageCropperType +{ + case MinMax; + case Exact; + + public function toString(): string + { + return match ($this) { + self::MinMax => 'minMax', + self::Exact => 'exact', + }; + } + + public static function fromString(string $fileType): self + { + return match ($fileType) { + 'minMax' => self::MinMax, + 'exact' => self::Exact, + }; + } +} diff --git a/wcfsetup/install/files/style/ui/dialog.scss b/wcfsetup/install/files/style/ui/dialog.scss index 0a09771149f..627afc6bdaf 100644 --- a/wcfsetup/install/files/style/ui/dialog.scss +++ b/wcfsetup/install/files/style/ui/dialog.scss @@ -465,3 +465,19 @@ html[data-color-scheme="dark"] .dialog::backdrop { min-width: 0; } } + +.dialog cropper-canvas { + margin-left: auto; + margin-right: auto; + max-height: 100%; + max-width: 100%; + + /* overwrites the default values of `min-height: 100px` and `min-width: 200px` */ + min-height: 1px; + min-width: 1px; +} + +/* If the height of the image is many times greater than the width, a white area would be displayed at the bottom and/or top. */ +.dialog cropper-shade { + outline-width: max(100vh, 100vw) !important; +} diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 679aff414a9..0647427a88f 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -5560,6 +5560,7 @@ Benachrichtigungen auf {PAGE_TITLE|phra + @@ -5568,6 +5569,7 @@ Benachrichtigungen auf {PAGE_TITLE|phra + Ersetzen, um die Datei zu ersetzen.]]> diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index b2f50a292b7..c424ff3a02f 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -5562,6 +5562,7 @@ your notifications on {PAGE_TITLE|phras + @@ -5570,6 +5571,7 @@ your notifications on {PAGE_TITLE|phras + Replace instead to swap out the file.]]>