From bc1a6ae7df2e3230a132ce1a3756c7b2348647f9 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 27 Mar 2025 08:33:53 +0100 Subject: [PATCH 1/9] feat: uses the umb-dropzone-input to render the dropzone --- .../input-upload-field.element.ts | 237 ++++-------------- 1 file changed, 50 insertions(+), 187 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts index 7c7cd25841ca..ed7c7437d921 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts @@ -2,26 +2,20 @@ import type { MediaValueType } from '../../property-editors/upload-field/types.j import type { ManifestFileUploadPreview } from './file-upload-preview.extension.js'; import { getMimeTypeFromExtension } from './utils.js'; import { - css, - html, - nothing, - ifDefined, - customElement, - property, - query, - state, - when, -} from '@umbraco-cms/backoffice/external/lit'; -import { formatBytes, stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; + UmbFileDropzoneItemStatus, + UmbInputDropzoneDashedStyles, + type UmbDropzoneChangeEvent, + type UmbInputDropzoneElement, + type UmbUploadableFile, +} from '@umbraco-cms/backoffice/dropzone'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbExtensionsManifestInitializer } from '@umbraco-cms/backoffice/extension-api'; -import { UmbId } from '@umbraco-cms/backoffice/id'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTemporaryFileManager, TemporaryFileStatus } from '@umbraco-cms/backoffice/temporary-file'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; -import type { UmbTemporaryFileModel } from '@umbraco-cms/backoffice/temporary-file'; -import type { UUIFileDropzoneElement, UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui'; +import { css, customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils'; @customElement('umb-input-upload-field') export class UmbInputUploadFieldElement extends UmbLitElement { @@ -32,7 +26,7 @@ export class UmbInputUploadFieldElement extends UmbLitElement { get value(): MediaValueType { return { src: this.#src, - temporaryFileId: this.temporaryFile?.temporaryUnique, + temporaryFileId: this._file?.temporaryFile.temporaryUnique, }; } #src = ''; @@ -42,19 +36,20 @@ export class UmbInputUploadFieldElement extends UmbLitElement { * @type {Array} * @default */ - @property({ type: Array }) - set allowedFileExtensions(value: Array) { - this.#setExtensions(value); - } - get allowedFileExtensions(): Array | undefined { - return this._extensions; - } - - @state() - public temporaryFile?: UmbTemporaryFileModel; + @property({ + type: Array, + attribute: 'allowed-file-extensions', + converter(value) { + if (typeof value === 'string') { + return value.split(',').map((ext) => ext.trim()); + } + return value; + }, + }) + allowedFileExtensions?: Array; @state() - private _progress = 0; + private _file?: UmbUploadableFile; @state() private _extensions?: string[]; @@ -62,11 +57,6 @@ export class UmbInputUploadFieldElement extends UmbLitElement { @state() private _previewAlias?: string; - @query('#dropzone') - private _dropzone?: UUIFileDropzoneElement; - - #manager = new UmbTemporaryFileManager(this); - #manifests: Array = []; override updated(changedProperties: PropertyValueMap | Map) { @@ -87,15 +77,6 @@ export class UmbInputUploadFieldElement extends UmbLitElement { return this.#manifests; } - #setExtensions(extensions: Array) { - if (!extensions?.length) { - this._extensions = undefined; - return; - } - // TODO: The dropzone uui component does not support file extensions without a dot. Remove this when it does. - this._extensions = extensions?.map((extension) => `.${extension}`); - } - async #setPreviewAlias(): Promise { this._previewAlias = await this.#getPreviewElementAlias(); } @@ -108,8 +89,8 @@ export class UmbInputUploadFieldElement extends UmbLitElement { )?.alias; let mimeType: string | null = null; - if (this.temporaryFile?.file) { - mimeType = this.temporaryFile.file.type; + if (this._file?.temporaryFile.file) { + mimeType = this._file.temporaryFile.file.type; } else { mimeType = this.#getMimeTypeFromPath(this.value.src); } @@ -151,113 +132,39 @@ export class UmbInputUploadFieldElement extends UmbLitElement { return getMimeTypeFromExtension('.' + extension); } - async #onUpload(e: UUIFileDropzoneEvent) { - try { - //Property Editor for Upload field will always only have one file. - this.temporaryFile = { - temporaryUnique: UmbId.new(), - status: TemporaryFileStatus.WAITING, - file: e.detail.files[0], - onProgress: (p) => { - this._progress = Math.ceil(p); - }, - abortController: new AbortController(), - }; - - const uploaded = await this.#manager.uploadOne(this.temporaryFile); - - if (uploaded.status === TemporaryFileStatus.SUCCESS) { - this.temporaryFile.status = TemporaryFileStatus.SUCCESS; - - const blobUrl = URL.createObjectURL(this.temporaryFile.file); - this.value = { src: blobUrl }; - - this.dispatchEvent(new UmbChangeEvent()); - } else { - this.temporaryFile.status = TemporaryFileStatus.ERROR; - this.requestUpdate('temporaryFile'); - } - } catch { - // If we still have a temporary file, set it to error. - if (this.temporaryFile) { - this.temporaryFile.status = TemporaryFileStatus.ERROR; - this.requestUpdate('temporaryFile'); - } + async #onUpload(e: UmbDropzoneChangeEvent) { + e.stopImmediatePropagation(); - // If the error was caused by the upload being aborted, do not show an error message. - } - } + const target = e.target as UmbInputDropzoneElement; + const file = target.value?.[0]; - #handleBrowse(e: Event) { - if (!this._dropzone) return; - e.stopImmediatePropagation(); - this._dropzone.browse(); + if (file?.status !== UmbFileDropzoneItemStatus.COMPLETE) return; + + this._file = file as UmbUploadableFile; + + const blobUrl = URL.createObjectURL(this._file.temporaryFile.file); + this.value = { src: blobUrl }; + + this.dispatchEvent(new UmbChangeEvent()); } override render() { - if (!this.temporaryFile && !this.value.src) { - return this.#renderDropzone(); + if (!this._file && !this.value.src) { + //return this.#renderDropzone(); } return html` - ${this.temporaryFile ? this.#renderUploader() : nothing} - ${this.value.src && this._previewAlias ? this.#renderFile(this.value.src) : nothing} + ${this.#renderDropzone()} ${this.value.src && this._previewAlias ? this.#renderFile(this.value.src) : nothing} `; } #renderDropzone() { return html` - - - - `; - } - - #renderUploader() { - if (!this.temporaryFile) return nothing; - - return html` -
-
- ${when( - this.temporaryFile.status === TemporaryFileStatus.SUCCESS, - () => html``, - )} - ${when( - this.temporaryFile.status === TemporaryFileStatus.ERROR, - () => html``, - )} -
-
-
${this.temporaryFile.file.name}
-
${formatBytes(this.temporaryFile.file.size, { decimals: 2 })}: ${this._progress}%
- ${when( - this.temporaryFile.status === TemporaryFileStatus.WAITING, - () => html`
`, - )} - ${when( - this.temporaryFile.status === TemporaryFileStatus.ERROR, - () => html`
An error occured
`, - )} -
-
- ${when( - this.temporaryFile.status === TemporaryFileStatus.WAITING, - () => html` - - ${this.localize.term('general_cancel')} - - `, - () => this.#renderButtonRemove(), - )} -
-
+ disable-folder-upload + accept=${ifDefined(this._extensions?.join(','))} + @change=${this.#onUpload}> `; } @@ -267,7 +174,7 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
manifest.alias === this._previewAlias}>
@@ -286,15 +193,16 @@ export class UmbInputUploadFieldElement extends UmbLitElement { #handleRemove() { // If the upload promise happens to be in progress, cancel it. - this.temporaryFile?.abortController?.abort(); + this._file?.temporaryFile.abortController?.abort(); this.value = { src: undefined }; - this.temporaryFile = undefined; - this._progress = 0; + this._file = undefined; this.dispatchEvent(new UmbChangeEvent()); } static override readonly styles = [ + UmbTextStyles, + UmbInputDropzoneDashedStyles, css` :host { position: relative; @@ -323,51 +231,6 @@ export class UmbInputUploadFieldElement extends UmbLitElement { width: fit-content; max-width: 100%; } - - #temporaryFile { - display: grid; - grid-template-columns: auto auto auto; - width: fit-content; - max-width: 100%; - margin: var(--uui-size-layout-1) 0; - padding: var(--uui-size-space-3); - border: 1px dashed var(--uui-color-divider-emphasis); - } - - #fileIcon, - #fileActions { - place-self: center center; - padding: 0 var(--uui-size-layout-1); - } - - #fileName { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: var(--uui-size-5); - } - - #fileSize { - font-size: var(--uui-font-size-small); - color: var(--uui-color-text-alt); - } - - #error { - color: var(--uui-color-danger); - } - - uui-file-dropzone { - position: relative; - display: block; - padding: 3px; /** Dropzone background is blurry and covers slightly into other elements. Hack to avoid this */ - } - uui-file-dropzone::after { - content: ''; - position: absolute; - inset: 0; - cursor: pointer; - border: 1px dashed var(--uui-color-divider-emphasis); - } `, ]; } From a9e6c841c9c9f8722655f0498996070e17c0ecef Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 27 Mar 2025 08:34:19 +0100 Subject: [PATCH 2/9] feat: loads in the blob url rather than reading the file into memory AND appends the server url --- .../image-cropper-field.element.ts | 57 +++++++++++-------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts index 5ba36c22b847..806c3bd017e1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts @@ -1,3 +1,5 @@ +import type { UmbImageCropChangeEvent } from './crop-change.event.js'; +import type { UmbFocalPointChangeEvent } from './focalpoint-change.event.js'; import type { UmbImageCropperElement } from './image-cropper.element.js'; import type { UmbImageCropperCrop, @@ -5,15 +7,15 @@ import type { UmbImageCropperFocalPoint, UmbImageCropperPropertyEditorValue, } from './types.js'; -import type { UmbImageCropChangeEvent } from './crop-change.event.js'; -import type { UmbFocalPointChangeEvent } from './focalpoint-change.event.js'; -import { css, customElement, html, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; + +import { UMB_APP_CONTEXT } from '@umbraco-cms/backoffice/app'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { css, customElement, html, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import './image-cropper.element.js'; import './image-cropper-focus-setter.element.js'; import './image-cropper-preview.element.js'; +import './image-cropper.element.js'; @customElement('umb-image-cropper-field') export class UmbInputImageCropperFieldElement extends UmbLitElement { @@ -46,7 +48,19 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement { currentCrop?: UmbImageCropperCrop; @property({ attribute: false }) - file?: File; + set file(file: File | undefined) { + this.#file = file; + if (file) { + const blob = new Blob([file]); + this.fileDataUrl = URL.createObjectURL(blob); + } else { + this.fileDataUrl = undefined; + } + } + get file() { + return this.#file; + } + #file?: File; @property() fileDataUrl?: string; @@ -60,26 +74,23 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement { @state() src = ''; - get source() { - if (this.fileDataUrl) return this.fileDataUrl; - if (this.src) return this.src; - return ''; - } + @state() + private _serverUrl = ''; - override updated(changedProperties: Map) { - super.updated(changedProperties); - - if (changedProperties.has('file')) { - if (this.file) { - const reader = new FileReader(); - reader.onload = (event) => { - this.fileDataUrl = event.target?.result as string; - }; - reader.readAsDataURL(this.file); - } else { - this.fileDataUrl = undefined; - } + get source(): string { + if (this.src) { + return `${this._serverUrl}${this.src}`; } + + return this.fileDataUrl ?? ''; + } + + constructor() { + super(); + + this.consumeContext(UMB_APP_CONTEXT, (context) => { + this._serverUrl = context.getServerUrl(); + }); } protected onCropClick(crop: any) { From 0f7dab8d601b89d4f1439dd78a8db9f8606f6bf4 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 27 Mar 2025 08:34:31 +0100 Subject: [PATCH 3/9] chore: lit 3 compat --- .../input-image-cropper/image-cropper-preview.element.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-preview.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-preview.element.ts index a092f0868c56..d203a1af7eee 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-preview.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-preview.element.ts @@ -18,13 +18,13 @@ export class UmbImageCropperPreviewElement extends UmbLitElement { label?: string; @property({ attribute: false }) - get focalPoint() { - return this.#focalPoint; - } set focalPoint(value) { this.#focalPoint = value; this.#onFocalPointUpdated(); } + get focalPoint() { + return this.#focalPoint; + } #focalPoint: UmbImageCropperFocalPoint = { left: 0.5, top: 0.5 }; From 06bff24e8264fb1d6c9f93c179eeee92bb756054 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 27 Mar 2025 08:34:51 +0100 Subject: [PATCH 4/9] feat: uses the umb-dropzone-input to render the dropzone --- .../input-image-cropper.element.ts | 100 ++++++++---------- 1 file changed, 42 insertions(+), 58 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts index b90bd8dd2003..a9523f0074ec 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts @@ -1,21 +1,28 @@ -import type { UmbImageCropperPropertyEditorValue } from './types.js'; import type { UmbInputImageCropperFieldElement } from './image-cropper-field.element.js'; -import { html, customElement, property, query, state, css, ifDefined } from '@umbraco-cms/backoffice/external/lit'; -import type { UUIFileDropzoneElement, UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui'; -import { UmbId } from '@umbraco-cms/backoffice/id'; +import type { UmbImageCropperPropertyEditorValue } from './types.js'; + +import { + type UmbDropzoneChangeEvent, + type UmbInputDropzoneElement, + type UmbUploadableItem, + UmbFileDropzoneItemStatus, + UmbInputDropzoneDashedStyles, +} from '@umbraco-cms/backoffice/dropzone'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { css, customElement, html, ifDefined, property, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTemporaryFileManager } from '@umbraco-cms/backoffice/temporary-file'; import { assignToFrozenObject } from '@umbraco-cms/backoffice/observable-api'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UmbTemporaryFileConfigRepository } from '@umbraco-cms/backoffice/temporary-file'; import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; -import './image-cropper.element.js'; +import './image-cropper-field.element.js'; import './image-cropper-focus-setter.element.js'; import './image-cropper-preview.element.js'; -import './image-cropper-field.element.js'; +import './image-cropper.element.js'; const DefaultFocalPoint = { left: 0.5, top: 0.5 }; -const DefaultValue = { +const DefaultValue: UmbImageCropperPropertyEditorValue = { temporaryFileId: null, src: '', crops: [], @@ -28,9 +35,6 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< typeof UmbLitElement, undefined >(UmbLitElement, undefined) { - @query('#dropzone') - private _dropzone?: UUIFileDropzoneElement; - /** * Sets the input to required, meaning validation will fail if the value is empty. * @type {boolean} @@ -45,10 +49,7 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< crops: UmbImageCropperPropertyEditorValue['crops'] = []; @state() - file?: File; - - @state() - fileUnique?: string; + private _file?: UmbUploadableItem; @state() private _accept?: string; @@ -56,7 +57,7 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< @state() private _loading = true; - #manager = new UmbTemporaryFileManager(this); + #config = new UmbTemporaryFileConfigRepository(this); constructor() { super(); @@ -76,9 +77,9 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< } async #observeAcceptedFileTypes() { - const config = await this.#manager.getConfiguration(); + await this.#config.initialized; this.observe( - config.part('imageFileTypes'), + this.#config.part('imageFileTypes'), (imageFileTypes) => { this._accept = imageFileTypes.join(','); this._loading = false; @@ -87,34 +88,27 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< ); } - #onUpload(e: UUIFileDropzoneEvent) { - const file = e.detail.files[0]; - if (!file) return; - const unique = UmbId.new(); + #onUpload(e: UmbDropzoneChangeEvent) { + e.stopImmediatePropagation(); - this.file = file; - this.fileUnique = unique; + const target = e.target as UmbInputDropzoneElement; + const file = target.value?.[0]; - this.value = assignToFrozenObject(this.value ?? DefaultValue, { temporaryFileId: unique }); + if (file?.status !== UmbFileDropzoneItemStatus.COMPLETE) return; - this.#manager?.uploadOne({ temporaryUnique: unique, file }); + this._file = file; - this.dispatchEvent(new UmbChangeEvent()); - } + this.value = assignToFrozenObject(this.value ?? DefaultValue, { + temporaryFileId: file.temporaryFile?.temporaryUnique, + }); - #onBrowse(e: Event) { - if (!this._dropzone) return; - e.stopImmediatePropagation(); - this._dropzone.browse(); + this.dispatchEvent(new UmbChangeEvent()); } #onRemove = () => { this.value = undefined; - if (this.fileUnique) { - this.#manager?.removeOne(this.fileUnique); - } - this.fileUnique = undefined; - this.file = undefined; + this._file?.temporaryFile?.abortController?.abort(); + this._file = undefined; this.dispatchEvent(new UmbChangeEvent()); }; @@ -144,7 +138,7 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< return html`
`; } - if (this.value?.src || this.file) { + if (this.value?.src || this._file) { return this.#renderImageCropper(); } @@ -153,14 +147,11 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< #renderDropzone() { return html` - - - + disable-folder-upload + @change="${this.#onUpload}"> `; } @@ -184,31 +175,24 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< } #renderImageCropper() { - return html` + return html` ${this.localize.term('content_uploadClear')} `; } - static override styles = [ + static override readonly styles = [ + UmbTextStyles, + UmbInputDropzoneDashedStyles, css` #loader { display: flex; justify-content: center; } - - uui-file-dropzone { - position: relative; - display: block; - } - uui-file-dropzone::after { - content: ''; - position: absolute; - inset: 0; - cursor: pointer; - border: 1px dashed var(--uui-color-divider-emphasis); - } `, ]; } From 3669d000a67511a1f8b173ff9a409de2b1c74824 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 27 Mar 2025 08:35:17 +0100 Subject: [PATCH 5/9] Revert "feat: uses the umb-dropzone-input to render the dropzone" This reverts commit bc1a6ae7df2e3230a132ce1a3756c7b2348647f9. --- .../input-upload-field.element.ts | 237 ++++++++++++++---- 1 file changed, 187 insertions(+), 50 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts index ed7c7437d921..7c7cd25841ca 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts @@ -2,20 +2,26 @@ import type { MediaValueType } from '../../property-editors/upload-field/types.j import type { ManifestFileUploadPreview } from './file-upload-preview.extension.js'; import { getMimeTypeFromExtension } from './utils.js'; import { - UmbFileDropzoneItemStatus, - UmbInputDropzoneDashedStyles, - type UmbDropzoneChangeEvent, - type UmbInputDropzoneElement, - type UmbUploadableFile, -} from '@umbraco-cms/backoffice/dropzone'; + css, + html, + nothing, + ifDefined, + customElement, + property, + query, + state, + when, +} from '@umbraco-cms/backoffice/external/lit'; +import { formatBytes, stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbExtensionsManifestInitializer } from '@umbraco-cms/backoffice/extension-api'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; -import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; -import { css, customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbId } from '@umbraco-cms/backoffice/id'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils'; +import { UmbTemporaryFileManager, TemporaryFileStatus } from '@umbraco-cms/backoffice/temporary-file'; +import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbTemporaryFileModel } from '@umbraco-cms/backoffice/temporary-file'; +import type { UUIFileDropzoneElement, UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui'; @customElement('umb-input-upload-field') export class UmbInputUploadFieldElement extends UmbLitElement { @@ -26,7 +32,7 @@ export class UmbInputUploadFieldElement extends UmbLitElement { get value(): MediaValueType { return { src: this.#src, - temporaryFileId: this._file?.temporaryFile.temporaryUnique, + temporaryFileId: this.temporaryFile?.temporaryUnique, }; } #src = ''; @@ -36,20 +42,19 @@ export class UmbInputUploadFieldElement extends UmbLitElement { * @type {Array} * @default */ - @property({ - type: Array, - attribute: 'allowed-file-extensions', - converter(value) { - if (typeof value === 'string') { - return value.split(',').map((ext) => ext.trim()); - } - return value; - }, - }) - allowedFileExtensions?: Array; + @property({ type: Array }) + set allowedFileExtensions(value: Array) { + this.#setExtensions(value); + } + get allowedFileExtensions(): Array | undefined { + return this._extensions; + } @state() - private _file?: UmbUploadableFile; + public temporaryFile?: UmbTemporaryFileModel; + + @state() + private _progress = 0; @state() private _extensions?: string[]; @@ -57,6 +62,11 @@ export class UmbInputUploadFieldElement extends UmbLitElement { @state() private _previewAlias?: string; + @query('#dropzone') + private _dropzone?: UUIFileDropzoneElement; + + #manager = new UmbTemporaryFileManager(this); + #manifests: Array = []; override updated(changedProperties: PropertyValueMap | Map) { @@ -77,6 +87,15 @@ export class UmbInputUploadFieldElement extends UmbLitElement { return this.#manifests; } + #setExtensions(extensions: Array) { + if (!extensions?.length) { + this._extensions = undefined; + return; + } + // TODO: The dropzone uui component does not support file extensions without a dot. Remove this when it does. + this._extensions = extensions?.map((extension) => `.${extension}`); + } + async #setPreviewAlias(): Promise { this._previewAlias = await this.#getPreviewElementAlias(); } @@ -89,8 +108,8 @@ export class UmbInputUploadFieldElement extends UmbLitElement { )?.alias; let mimeType: string | null = null; - if (this._file?.temporaryFile.file) { - mimeType = this._file.temporaryFile.file.type; + if (this.temporaryFile?.file) { + mimeType = this.temporaryFile.file.type; } else { mimeType = this.#getMimeTypeFromPath(this.value.src); } @@ -132,39 +151,113 @@ export class UmbInputUploadFieldElement extends UmbLitElement { return getMimeTypeFromExtension('.' + extension); } - async #onUpload(e: UmbDropzoneChangeEvent) { - e.stopImmediatePropagation(); - - const target = e.target as UmbInputDropzoneElement; - const file = target.value?.[0]; - - if (file?.status !== UmbFileDropzoneItemStatus.COMPLETE) return; - - this._file = file as UmbUploadableFile; + async #onUpload(e: UUIFileDropzoneEvent) { + try { + //Property Editor for Upload field will always only have one file. + this.temporaryFile = { + temporaryUnique: UmbId.new(), + status: TemporaryFileStatus.WAITING, + file: e.detail.files[0], + onProgress: (p) => { + this._progress = Math.ceil(p); + }, + abortController: new AbortController(), + }; + + const uploaded = await this.#manager.uploadOne(this.temporaryFile); + + if (uploaded.status === TemporaryFileStatus.SUCCESS) { + this.temporaryFile.status = TemporaryFileStatus.SUCCESS; + + const blobUrl = URL.createObjectURL(this.temporaryFile.file); + this.value = { src: blobUrl }; + + this.dispatchEvent(new UmbChangeEvent()); + } else { + this.temporaryFile.status = TemporaryFileStatus.ERROR; + this.requestUpdate('temporaryFile'); + } + } catch { + // If we still have a temporary file, set it to error. + if (this.temporaryFile) { + this.temporaryFile.status = TemporaryFileStatus.ERROR; + this.requestUpdate('temporaryFile'); + } - const blobUrl = URL.createObjectURL(this._file.temporaryFile.file); - this.value = { src: blobUrl }; + // If the error was caused by the upload being aborted, do not show an error message. + } + } - this.dispatchEvent(new UmbChangeEvent()); + #handleBrowse(e: Event) { + if (!this._dropzone) return; + e.stopImmediatePropagation(); + this._dropzone.browse(); } override render() { - if (!this._file && !this.value.src) { - //return this.#renderDropzone(); + if (!this.temporaryFile && !this.value.src) { + return this.#renderDropzone(); } return html` - ${this.#renderDropzone()} ${this.value.src && this._previewAlias ? this.#renderFile(this.value.src) : nothing} + ${this.temporaryFile ? this.#renderUploader() : nothing} + ${this.value.src && this._previewAlias ? this.#renderFile(this.value.src) : nothing} `; } #renderDropzone() { return html` - + label="dropzone" + disallowFolderUpload + accept=${ifDefined(this._extensions?.join(', '))} + @change=${this.#onUpload} + @click=${this.#handleBrowse}> + + + `; + } + + #renderUploader() { + if (!this.temporaryFile) return nothing; + + return html` +
+
+ ${when( + this.temporaryFile.status === TemporaryFileStatus.SUCCESS, + () => html``, + )} + ${when( + this.temporaryFile.status === TemporaryFileStatus.ERROR, + () => html``, + )} +
+
+
${this.temporaryFile.file.name}
+
${formatBytes(this.temporaryFile.file.size, { decimals: 2 })}: ${this._progress}%
+ ${when( + this.temporaryFile.status === TemporaryFileStatus.WAITING, + () => html`
`, + )} + ${when( + this.temporaryFile.status === TemporaryFileStatus.ERROR, + () => html`
An error occured
`, + )} +
+
+ ${when( + this.temporaryFile.status === TemporaryFileStatus.WAITING, + () => html` + + ${this.localize.term('general_cancel')} + + `, + () => this.#renderButtonRemove(), + )} +
+
`; } @@ -174,7 +267,7 @@ export class UmbInputUploadFieldElement extends UmbLitElement {
manifest.alias === this._previewAlias}>
@@ -193,16 +286,15 @@ export class UmbInputUploadFieldElement extends UmbLitElement { #handleRemove() { // If the upload promise happens to be in progress, cancel it. - this._file?.temporaryFile.abortController?.abort(); + this.temporaryFile?.abortController?.abort(); this.value = { src: undefined }; - this._file = undefined; + this.temporaryFile = undefined; + this._progress = 0; this.dispatchEvent(new UmbChangeEvent()); } static override readonly styles = [ - UmbTextStyles, - UmbInputDropzoneDashedStyles, css` :host { position: relative; @@ -231,6 +323,51 @@ export class UmbInputUploadFieldElement extends UmbLitElement { width: fit-content; max-width: 100%; } + + #temporaryFile { + display: grid; + grid-template-columns: auto auto auto; + width: fit-content; + max-width: 100%; + margin: var(--uui-size-layout-1) 0; + padding: var(--uui-size-space-3); + border: 1px dashed var(--uui-color-divider-emphasis); + } + + #fileIcon, + #fileActions { + place-self: center center; + padding: 0 var(--uui-size-layout-1); + } + + #fileName { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--uui-size-5); + } + + #fileSize { + font-size: var(--uui-font-size-small); + color: var(--uui-color-text-alt); + } + + #error { + color: var(--uui-color-danger); + } + + uui-file-dropzone { + position: relative; + display: block; + padding: 3px; /** Dropzone background is blurry and covers slightly into other elements. Hack to avoid this */ + } + uui-file-dropzone::after { + content: ''; + position: absolute; + inset: 0; + cursor: pointer; + border: 1px dashed var(--uui-color-divider-emphasis); + } `, ]; } From 4ce5cbeeb58f4f17acbbedd4437a62e71a29e9a9 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 27 Mar 2025 08:46:57 +0100 Subject: [PATCH 6/9] feat: creates an object url directly from the File rather than the Blob --- .../input-image-cropper/image-cropper-field.element.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts index 806c3bd017e1..e561ae691e45 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts @@ -51,8 +51,7 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement { set file(file: File | undefined) { this.#file = file; if (file) { - const blob = new Blob([file]); - this.fileDataUrl = URL.createObjectURL(blob); + this.fileDataUrl = URL.createObjectURL(file); } else { this.fileDataUrl = undefined; } From 76c64050af4c56370515424adff8922bf60ca387 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 27 Mar 2025 08:48:32 +0100 Subject: [PATCH 7/9] feat: revokes the file data url from object storage --- .../input-image-cropper/image-cropper-field.element.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts index e561ae691e45..a9d6e13ca0a9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts @@ -52,7 +52,8 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement { this.#file = file; if (file) { this.fileDataUrl = URL.createObjectURL(file); - } else { + } else if (this.fileDataUrl) { + URL.revokeObjectURL(this.fileDataUrl); this.fileDataUrl = undefined; } } From 1fdc380f069a80cb14b39756d3e9ababaadde2f3 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 27 Mar 2025 08:54:04 +0100 Subject: [PATCH 8/9] feat: revokes object url on disconnect --- .../input-image-cropper/image-cropper-field.element.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts index a9d6e13ca0a9..eb917505aff7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts @@ -93,6 +93,13 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement { }); } + override disconnectedCallback(): void { + super.disconnectedCallback(); + if (this.fileDataUrl) { + URL.revokeObjectURL(this.fileDataUrl); + } + } + protected onCropClick(crop: any) { const index = this.crops.findIndex((c) => c.alias === crop.alias); From dca11f71c0fc06336162949174811d797a14381c Mon Sep 17 00:00:00 2001 From: leekelleher Date: Thu, 27 Mar 2025 09:37:58 +0000 Subject: [PATCH 9/9] Import type ordering --- .../image-cropper-field.element.ts | 5 ++--- .../input-image-cropper.element.ts | 20 +++++++++---------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts index eb917505aff7..b8d403ba09e0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts @@ -7,11 +7,10 @@ import type { UmbImageCropperFocalPoint, UmbImageCropperPropertyEditorValue, } from './types.js'; - -import { UMB_APP_CONTEXT } from '@umbraco-cms/backoffice/app'; -import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { css, customElement, html, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UMB_APP_CONTEXT } from '@umbraco-cms/backoffice/app'; import './image-cropper-focus-setter.element.js'; import './image-cropper-preview.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts index a9523f0074ec..1bf0a945c4c3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts @@ -1,20 +1,18 @@ -import type { UmbInputImageCropperFieldElement } from './image-cropper-field.element.js'; import type { UmbImageCropperPropertyEditorValue } from './types.js'; - -import { - type UmbDropzoneChangeEvent, - type UmbInputDropzoneElement, - type UmbUploadableItem, - UmbFileDropzoneItemStatus, - UmbInputDropzoneDashedStyles, -} from '@umbraco-cms/backoffice/dropzone'; -import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import type { UmbInputImageCropperFieldElement } from './image-cropper-field.element.js'; import { css, customElement, html, ifDefined, property, state } from '@umbraco-cms/backoffice/external/lit'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { assignToFrozenObject } from '@umbraco-cms/backoffice/observable-api'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UmbFileDropzoneItemStatus, UmbInputDropzoneDashedStyles } from '@umbraco-cms/backoffice/dropzone'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbTemporaryFileConfigRepository } from '@umbraco-cms/backoffice/temporary-file'; import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import type { + UmbDropzoneChangeEvent, + UmbInputDropzoneElement, + UmbUploadableItem, +} from '@umbraco-cms/backoffice/dropzone'; import './image-cropper-field.element.js'; import './image-cropper-focus-setter.element.js';