V15: Improve the dropzone for Image Cropper (#18838)

* feat: uses the umb-dropzone-input to render the dropzone

* feat: loads in the blob url rather than reading the file into memory AND appends the server url

* chore: lit 3 compat

* feat: uses the umb-dropzone-input to render the dropzone

* Revert "feat: uses the umb-dropzone-input to render the dropzone"

This reverts commit bc1a6ae7df2e3230a132ce1a3756c7b2348647f9.

* feat: creates an object url directly from the File rather than the Blob

* feat: revokes the file data url from object storage

* feat: revokes object url on disconnect
This commit is contained in:
Jacob Overgaard
2025-03-27 11:11:37 +01:00
committed by GitHub
parent 94f0add4d9
commit a71ebe1902
3 changed files with 81 additions and 82 deletions

View File

@@ -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,14 @@ 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 { 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.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 +47,19 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement {
currentCrop?: UmbImageCropperCrop;
@property({ attribute: false })
file?: File;
set file(file: File | undefined) {
this.#file = file;
if (file) {
this.fileDataUrl = URL.createObjectURL(file);
} else if (this.fileDataUrl) {
URL.revokeObjectURL(this.fileDataUrl);
this.fileDataUrl = undefined;
}
}
get file() {
return this.#file;
}
#file?: File;
@property()
fileDataUrl?: string;
@@ -60,25 +73,29 @@ 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 = '';
get source(): string {
if (this.src) {
return `${this._serverUrl}${this.src}`;
}
return this.fileDataUrl ?? '';
}
override updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
constructor() {
super();
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;
}
this.consumeContext(UMB_APP_CONTEXT, (context) => {
this._serverUrl = context.getServerUrl();
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
if (this.fileDataUrl) {
URL.revokeObjectURL(this.fileDataUrl);
}
}

View File

@@ -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 };

View File

@@ -1,21 +1,26 @@
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 { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTemporaryFileManager } from '@umbraco-cms/backoffice/temporary-file';
import { css, customElement, html, ifDefined, property, state } from '@umbraco-cms/backoffice/external/lit';
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.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 +33,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 +47,7 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin<
crops: UmbImageCropperPropertyEditorValue['crops'] = [];
@state()
file?: File;
@state()
fileUnique?: string;
private _file?: UmbUploadableItem;
@state()
private _accept?: string;
@@ -56,7 +55,7 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin<
@state()
private _loading = true;
#manager = new UmbTemporaryFileManager(this);
#config = new UmbTemporaryFileConfigRepository(this);
constructor() {
super();
@@ -76,9 +75,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 +86,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.value = assignToFrozenObject(this.value ?? DefaultValue, {
temporaryFileId: file.temporaryFile?.temporaryUnique,
});
this.dispatchEvent(new UmbChangeEvent());
}
#onBrowse(e: Event) {
if (!this._dropzone) return;
e.stopImmediatePropagation();
this._dropzone.browse();
}
#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 +136,7 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin<
return html`<div id="loader"><uui-loader></uui-loader></div>`;
}
if (this.value?.src || this.file) {
if (this.value?.src || this._file) {
return this.#renderImageCropper();
}
@@ -153,14 +145,11 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin<
#renderDropzone() {
return html`
<uui-file-dropzone
<umb-input-dropzone
id="dropzone"
label="dropzone"
accept=${ifDefined(this._accept)}
@change="${this.#onUpload}"
@click=${this.#onBrowse}>
<uui-button label=${this.localize.term('media_clickToUpload')} @click="${this.#onBrowse}"></uui-button>
</uui-file-dropzone>
disable-folder-upload
@change="${this.#onUpload}"></umb-input-dropzone>
`;
}
@@ -184,31 +173,24 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin<
}
#renderImageCropper() {
return html`<umb-image-cropper-field .value=${this.value} .file=${this.file as File} @change=${this.#onChange}>
return html`<umb-image-cropper-field
.value=${this.value}
.file=${this._file?.temporaryFile?.file}
@change=${this.#onChange}>
<uui-button slot="actions" @click=${this.#onRemove} label=${this.localize.term('content_uploadClear')}>
<uui-icon name="icon-trash"></uui-icon>${this.localize.term('content_uploadClear')}
</uui-button>
</umb-image-cropper-field> `;
}
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);
}
`,
];
}