From 70871a7896837de3c36244ee6a214df976275c84 Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Thu, 12 Dec 2024 14:54:04 +0100 Subject: [PATCH] Add `simpleReplace` and `hideDeleteButton` to the `FileProcessorFormField` --- .../shared_fileProcessorFormField.tpl | 2 + ts/WoltLabSuite/Core/Component/File/Upload.ts | 2 + .../Core/Component/Image/Cropper.ts | 21 +-- .../Builder/Field/Controller/FileProcessor.ts | 135 ++++++++++++------ .../Core/Component/File/Upload.js | 1 + .../Core/Component/Image/Cropper.js | 14 +- .../Builder/Field/Controller/FileProcessor.js | 123 ++++++++++------ .../lib/action/UserAvatarAction.class.php | 2 + .../field/FileProcessorFormField.class.php | 53 +++++++ 9 files changed, 251 insertions(+), 102 deletions(-) diff --git a/com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl b/com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl index b4dba63d9d..8757b4b10e 100644 --- a/com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl +++ b/com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl @@ -24,6 +24,8 @@ '{unsafe:$field->getPrefixedId()|encodeJS}', {if $field->isSingleFileUpload()}true{else}false{/if}, {if $field->isBigPreview()}true{else}false{/if}, + {if $field->isSimpleReplace()}true{else}false{/if}, + {if $field->isHideDeleteButton()}true{else}false{/if}, [{implode from=$actionButtons item=actionButton}{ title: '{unsafe:$actionButton['title']|encodeJS}', icon: {if $actionButton['icon'] === null}undefined{else}'{unsafe:$actionButton['icon']->toHtml()|encodeJS}'{/if}, diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 857bec5eeb..18b67b91cc 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -277,6 +277,8 @@ export function setup(): void { void upload(element, resizedFile); }) .catch((e) => { + element.dispatchEvent(new CustomEvent("cancel")); + if (e === undefined) { // User closed the dialog. return; diff --git a/ts/WoltLabSuite/Core/Component/Image/Cropper.ts b/ts/WoltLabSuite/Core/Component/Image/Cropper.ts index 9d9b942ce3..983e513ef9 100644 --- a/ts/WoltLabSuite/Core/Component/Image/Cropper.ts +++ b/ts/WoltLabSuite/Core/Component/Image/Cropper.ts @@ -89,18 +89,21 @@ abstract class ImageCropper { }; window.addEventListener("resize", resize, { passive: true }); - this.dialog.addEventListener( - "afterClose", - () => { - window.removeEventListener("resize", resize); - }, - { - once: 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 diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts index 039c734c97..e281dffa37 100644 --- a/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts +++ b/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts @@ -35,6 +35,8 @@ export class FileProcessor { readonly #fileInput: HTMLInputElement; readonly #useBigPreview: boolean; readonly #singleFileUpload: boolean; + readonly #simpleReplace: boolean; + readonly #hideDeleteButton: boolean; readonly #extraButtons: ExtraButton[]; #uploadResolve: undefined | (() => void); @@ -42,11 +44,15 @@ export class FileProcessor { fieldId: string, singleFileUpload: boolean = false, useBigPreview: boolean = false, + simpleReplace: boolean = false, + hideDeleteButton: boolean = false, extraButtons: ExtraButton[] = [], ) { this.#fieldId = fieldId; this.#useBigPreview = useBigPreview; this.#singleFileUpload = singleFileUpload; + this.#simpleReplace = simpleReplace; + this.#hideDeleteButton = hideDeleteButton; this.#extraButtons = extraButtons; this.#container = document.getElementById(fieldId + "Container")!; @@ -55,6 +61,18 @@ export class FileProcessor { } this.#uploadButton = this.#container.querySelector("woltlab-core-file-upload") as WoltlabCoreFileUploadElement; + + if (this.#simpleReplace) { + this.#uploadButton.addEventListener("shouldUpload", () => { + const file = this.#uploadButton.parentElement!.querySelector("woltlab-core-file"); + if (!file) { + return; + } + + this.#simpleFileReplace(file); + }); + } + this.#uploadButton.addEventListener("uploadStart", (event: CustomEvent) => { if (this.#uploadResolve !== undefined) { this.#uploadResolve(); @@ -80,12 +98,14 @@ export class FileProcessor { buttons.classList.add("buttonList"); buttons.classList.add(this.classPrefix + "item__buttons"); - let listItem = document.createElement("li"); - listItem.append(this.getDeleteButton(element)); - buttons.append(listItem); + if (!this.#hideDeleteButton) { + const listItem = document.createElement("li"); + listItem.append(this.getDeleteButton(element)); + buttons.append(listItem); + } - if (this.#singleFileUpload) { - listItem = document.createElement("li"); + if (this.#singleFileUpload && !this.#simpleReplace) { + const listItem = document.createElement("li"); listItem.append(this.getReplaceButton(element)); buttons.append(listItem); } @@ -118,6 +138,45 @@ export class FileProcessor { container.append(buttons); } + protected getReplaceButton(element: WoltlabCoreFileElement): HTMLButtonElement { + const replaceButton = document.createElement("button"); + replaceButton.type = "button"; + replaceButton.classList.add("button", "small"); + replaceButton.textContent = getPhrase("wcf.global.button.replace"); + replaceButton.addEventListener("click", () => { + const oldContext = this.#startReplaceFile(element); + + clearPreviousErrors(this.#uploadButton); + + const changeEventListener = () => { + this.#fileInput.removeEventListener("cancel", cancelEventListener); + + // Wait until the upload starts, + // the request to the server is not synchronized with the end of the `change` event. + // Otherwise, we would swap the context too soon. + void new Promise((resolve) => { + this.#uploadResolve = resolve; + }).then(() => { + this.#uploadResolve = undefined; + this.#uploadButton.dataset.context = oldContext; + }); + }; + const cancelEventListener = () => { + this.#uploadButton.dataset.context = oldContext; + this.#registerFile(this.#replaceElement!); + this.#replaceElement = undefined; + this.#fileInput.removeEventListener("change", changeEventListener); + }; + + this.#fileInput.addEventListener("cancel", cancelEventListener, { once: true }); + this.#fileInput.addEventListener("change", changeEventListener, { once: true }); + + this.#fileInput.click(); + }); + + return replaceButton; + } + #markElementUploadHasFailed(container: HTMLElement, element: WoltlabCoreFileElement, reason: unknown): void { fileInitializationFailed(container, element, reason); @@ -150,51 +209,39 @@ export class FileProcessor { return deleteButton; } - protected getReplaceButton(element: WoltlabCoreFileElement): HTMLButtonElement { - const replaceButton = document.createElement("button"); - replaceButton.type = "button"; - replaceButton.classList.add("button", "small"); - replaceButton.textContent = getPhrase("wcf.global.button.replace"); - replaceButton.addEventListener("click", () => { - // Add to context an extra attribute that the replace button is clicked. - // After the dialog is closed or the file is selected, the context will be reset to his old value. - // This is necessary as the serverside validation will otherwise fail. - const oldContext = this.#uploadButton.dataset.context!; - const context = JSON.parse(oldContext); - context.__replace = true; - this.#uploadButton.dataset.context = JSON.stringify(context); + #simpleFileReplace(oldFile: WoltlabCoreFileElement) { + const oldContext = this.#startReplaceFile(oldFile); - this.#replaceElement = element; - this.#unregisterFile(element); + const cropCancelledEvent = () => { + this.#uploadButton.dataset.context = oldContext; + this.#registerFile(this.#replaceElement!); + this.#replaceElement = undefined; + }; - clearPreviousErrors(this.#uploadButton); + this.#uploadButton.addEventListener("cancel", cropCancelledEvent, { once: true }); - const changeEventListener = () => { - this.#fileInput.removeEventListener("cancel", cancelEventListener); + void new Promise((resolve) => { + this.#uploadResolve = resolve; + }).then(() => { + this.#uploadResolve = undefined; + this.#uploadButton.dataset.context = oldContext; + this.#fileInput.removeEventListener("cancel", cropCancelledEvent); + }); + } - // Wait until the upload starts, - // the request to the server is not synchronized with the end of the `change` event. - // Otherwise, we would swap the context too soon. - void new Promise((resolve) => { - this.#uploadResolve = resolve; - }).then(() => { - this.#uploadResolve = undefined; - this.#uploadButton.dataset.context = oldContext; - }); - }; - const cancelEventListener = () => { - this.#uploadButton.dataset.context = oldContext; - this.#registerFile(this.#replaceElement!); - this.#replaceElement = undefined; - this.#fileInput.removeEventListener("change", changeEventListener); - }; + #startReplaceFile(element: WoltlabCoreFileElement): string { + // Add to context an extra attribute that the replace button is clicked. + // After the dialog is closed or the file is selected, the context will be reset to his old value. + // This is necessary as the serverside validation will otherwise fail. + const oldContext = this.#uploadButton.dataset.context!; + const context = JSON.parse(oldContext); + context.__replace = true; + this.#uploadButton.dataset.context = JSON.stringify(context); - this.#fileInput.addEventListener("cancel", cancelEventListener, { once: true }); - this.#fileInput.addEventListener("change", changeEventListener, { once: true }); - this.#fileInput.click(); - }); + this.#replaceElement = element; + this.#unregisterFile(element); - return replaceButton; + return oldContext; } #unregisterFile(element: WoltlabCoreFileElement): void { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js index 39545f10bb..9b00148b84 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -190,6 +190,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol void upload(element, resizedFile); }) .catch((e) => { + element.dispatchEvent(new CustomEvent("cancel")); if (e === undefined) { // User closed the dialog. return; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Image/Cropper.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Image/Cropper.js index 60bd94b1b2..f21d73b230 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Image/Cropper.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Image/Cropper.js @@ -67,13 +67,17 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Image/Resizer", "WoltL this.centerSelection(); }; window.addEventListener("resize", resize, { passive: true }); - this.dialog.addEventListener("afterClose", () => { - window.removeEventListener("resize", resize); - }, { - once: 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 diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js index b27cebfe29..d50e255dc3 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js @@ -19,18 +19,31 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui #fileInput; #useBigPreview; #singleFileUpload; + #simpleReplace; + #hideDeleteButton; #extraButtons; #uploadResolve; - constructor(fieldId, singleFileUpload = false, useBigPreview = false, extraButtons = []) { + constructor(fieldId, singleFileUpload = false, useBigPreview = false, simpleReplace = false, hideDeleteButton = false, extraButtons = []) { this.#fieldId = fieldId; this.#useBigPreview = useBigPreview; this.#singleFileUpload = singleFileUpload; + this.#simpleReplace = simpleReplace; + this.#hideDeleteButton = hideDeleteButton; this.#extraButtons = extraButtons; this.#container = document.getElementById(fieldId + "Container"); if (this.#container === null) { throw new Error("Unknown field with id '" + fieldId + "'"); } this.#uploadButton = this.#container.querySelector("woltlab-core-file-upload"); + if (this.#simpleReplace) { + this.#uploadButton.addEventListener("shouldUpload", () => { + const file = this.#uploadButton.parentElement.querySelector("woltlab-core-file"); + if (!file) { + return; + } + this.#simpleFileReplace(file); + }); + } this.#uploadButton.addEventListener("uploadStart", (event) => { if (this.#uploadResolve !== undefined) { this.#uploadResolve(); @@ -50,11 +63,13 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui const buttons = document.createElement("ul"); buttons.classList.add("buttonList"); buttons.classList.add(this.classPrefix + "item__buttons"); - let listItem = document.createElement("li"); - listItem.append(this.getDeleteButton(element)); - buttons.append(listItem); - if (this.#singleFileUpload) { - listItem = document.createElement("li"); + if (!this.#hideDeleteButton) { + const listItem = document.createElement("li"); + listItem.append(this.getDeleteButton(element)); + buttons.append(listItem); + } + if (this.#singleFileUpload && !this.#simpleReplace) { + const listItem = document.createElement("li"); listItem.append(this.getReplaceButton(element)); buttons.append(listItem); } @@ -82,50 +97,13 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui }); container.append(buttons); } - #markElementUploadHasFailed(container, element, reason) { - (0, Helper_1.fileInitializationFailed)(container, element, reason); - container.classList.add("innerError"); - } - getDeleteButton(element) { - const deleteButton = document.createElement("button"); - deleteButton.type = "button"; - deleteButton.classList.add("button", "small"); - deleteButton.textContent = (0, Language_1.getPhrase)("wcf.global.button.delete"); - deleteButton.addEventListener("click", async () => { - const result = await (0, DeleteFile_1.deleteFile)(element.fileId); - if (result.ok) { - this.#unregisterFile(element); - } - else { - let container = element; - if (!this.#useBigPreview) { - container = container.parentElement; - } - if (result.error.code === "permission_denied") { - (0, Util_1.innerError)(container, (0, Language_1.getPhrase)("wcf.upload.error.delete.permissionDenied"), true); - } - else { - (0, Util_1.innerError)(container, result.error.message ?? (0, Language_1.getPhrase)("wcf.upload.error.delete.unknownError")); - } - } - }); - return deleteButton; - } getReplaceButton(element) { const replaceButton = document.createElement("button"); replaceButton.type = "button"; replaceButton.classList.add("button", "small"); replaceButton.textContent = (0, Language_1.getPhrase)("wcf.global.button.replace"); replaceButton.addEventListener("click", () => { - // Add to context an extra attribute that the replace button is clicked. - // After the dialog is closed or the file is selected, the context will be reset to his old value. - // This is necessary as the serverside validation will otherwise fail. - const oldContext = this.#uploadButton.dataset.context; - const context = JSON.parse(oldContext); - context.__replace = true; - this.#uploadButton.dataset.context = JSON.stringify(context); - this.#replaceElement = element; - this.#unregisterFile(element); + const oldContext = this.#startReplaceFile(element); (0, Upload_1.clearPreviousErrors)(this.#uploadButton); const changeEventListener = () => { this.#fileInput.removeEventListener("cancel", cancelEventListener); @@ -151,6 +129,63 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui }); return replaceButton; } + #markElementUploadHasFailed(container, element, reason) { + (0, Helper_1.fileInitializationFailed)(container, element, reason); + container.classList.add("innerError"); + } + getDeleteButton(element) { + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.classList.add("button", "small"); + deleteButton.textContent = (0, Language_1.getPhrase)("wcf.global.button.delete"); + deleteButton.addEventListener("click", async () => { + const result = await (0, DeleteFile_1.deleteFile)(element.fileId); + if (result.ok) { + this.#unregisterFile(element); + } + else { + let container = element; + if (!this.#useBigPreview) { + container = container.parentElement; + } + if (result.error.code === "permission_denied") { + (0, Util_1.innerError)(container, (0, Language_1.getPhrase)("wcf.upload.error.delete.permissionDenied"), true); + } + else { + (0, Util_1.innerError)(container, result.error.message ?? (0, Language_1.getPhrase)("wcf.upload.error.delete.unknownError")); + } + } + }); + return deleteButton; + } + #simpleFileReplace(oldFile) { + const oldContext = this.#startReplaceFile(oldFile); + const cropCancelledEvent = () => { + this.#uploadButton.dataset.context = oldContext; + this.#registerFile(this.#replaceElement); + this.#replaceElement = undefined; + }; + this.#uploadButton.addEventListener("cancel", cropCancelledEvent, { once: true }); + void new Promise((resolve) => { + this.#uploadResolve = resolve; + }).then(() => { + this.#uploadResolve = undefined; + this.#uploadButton.dataset.context = oldContext; + this.#fileInput.removeEventListener("cancel", cropCancelledEvent); + }); + } + #startReplaceFile(element) { + // Add to context an extra attribute that the replace button is clicked. + // After the dialog is closed or the file is selected, the context will be reset to his old value. + // This is necessary as the serverside validation will otherwise fail. + const oldContext = this.#uploadButton.dataset.context; + const context = JSON.parse(oldContext); + context.__replace = true; + this.#uploadButton.dataset.context = JSON.stringify(context); + this.#replaceElement = element; + this.#unregisterFile(element); + return oldContext; + } #unregisterFile(element) { if (this.#useBigPreview) { element.parentElement.innerHTML = ""; diff --git a/wcfsetup/install/files/lib/action/UserAvatarAction.class.php b/wcfsetup/install/files/lib/action/UserAvatarAction.class.php index 1fd9f59d2c..153e5047c3 100644 --- a/wcfsetup/install/files/lib/action/UserAvatarAction.class.php +++ b/wcfsetup/install/files/lib/action/UserAvatarAction.class.php @@ -110,6 +110,8 @@ final class UserAvatarAction implements RequestHandlerInterface ->required() ->singleFileUpload() ->bigPreview() + ->simpleReplace() + ->hideDeleteButton() ->addDependency( ValueFormFieldDependency::create('avatarType') ->fieldId('avatarType') diff --git a/wcfsetup/install/files/lib/system/form/builder/field/FileProcessorFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/FileProcessorFormField.class.php index 0d1c14f6ad..0f2e2ff5d3 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/FileProcessorFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/FileProcessorFormField.class.php @@ -45,6 +45,8 @@ final class FileProcessorFormField extends AbstractFormField private array $files = []; private bool $singleFileUpload = false; private bool $bigPreview = false; + private bool $simpleReplace = false; + private bool $hideDeleteButton = false; private array $actionButtons = []; #[\Override] @@ -79,6 +81,7 @@ final class FileProcessorFormField extends AbstractFormField ), 'maxUploads' => $this->getFileProcessor()->getMaximumCount($this->context), 'actionButtons' => $this->actionButtons, + 'simpleReplace' => $this->simpleReplace, ]; } @@ -136,6 +139,12 @@ final class FileProcessorFormField extends AbstractFormField ); } + if (!$singleFileUpload && $this->simpleReplace) { + throw new \InvalidArgumentException( + "Single file upload can't be disabled if the simple replace is enabled for the field '{$this->getId()}'." + ); + } + $this->singleFileUpload = $singleFileUpload; return $this; @@ -302,4 +311,48 @@ final class FileProcessorFormField extends AbstractFormField return $this; } + + /** + * Returns whether the simple replace is enabled. + */ + public function isSimpleReplace(): bool + { + return $this->simpleReplace; + } + + /** + * Sets whether the simple replace is enabled. + * Simple replace can only be enabled if single file upload is true. + * If enabled, there is no replace button and the existing file is replaced when a new file is uploaded. + */ + public function simpleReplace(bool $simpleReplace = true): self + { + if ($simpleReplace && !$this->singleFileUpload) { + throw new \InvalidArgumentException( + "Simple replace can only be enabled for single file uploads for the field '{$this->getId()}'." + ); + } + + $this->simpleReplace = $simpleReplace; + + return $this; + } + + /** + * Sets whether the delete button should be hidden. + */ + public function hideDeleteButton(bool $hideDeleteButton = true): self + { + $this->hideDeleteButton = $hideDeleteButton; + + return $this; + } + + /** + * Returns whether the delete button is hidden. + */ + public function isHideDeleteButton(): bool + { + return $this->hideDeleteButton; + } } -- 2.20.1