Merge pull request #2109 from umbraco/v14/feature/load-thumbnails-individually

Feature: Imaging thumbnail component to load thumbnails in parallel
This commit is contained in:
Jacob Overgaard
2024-07-10 12:05:41 +02:00
committed by GitHub
10 changed files with 212 additions and 108 deletions

View File

@@ -0,0 +1,173 @@
import { UmbImagingCropMode } from '../types.js';
import { UmbImagingRepository } from '../imaging.repository.js';
import { css, customElement, html, nothing, property, state, when } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
const ELEMENT_NAME = 'umb-imaging-thumbnail';
@customElement(ELEMENT_NAME)
export class UmbImagingThumbnailElement extends UmbLitElement {
/**
* The unique identifier for the media item.
* @remark This is also known as the media key and is used to fetch the resource.
*/
@property()
unique = '';
/**
* The width of the thumbnail in pixels.
* @default 300
*/
@property({ type: Number })
width = 300;
/**
* The height of the thumbnail in pixels.
* @default 300
*/
@property({ type: Number })
height = 300;
/**
* The mode of the thumbnail.
* @remark The mode determines how the image is cropped.
* @enum {UmbImagingCropMode}
*/
@property()
mode: UmbImagingCropMode = UmbImagingCropMode.MIN;
/**
* The alt text for the thumbnail.
*/
@property()
alt = '';
/**
* The fallback icon for the thumbnail.
*/
@property()
icon = 'icon-picture';
/**
* The `loading` state of the thumbnail.
* @enum {'lazy' | 'eager'}
* @default 'lazy'
*/
@property()
loading: 'lazy' | 'eager' = 'lazy';
@state()
private _isLoading = true;
@state()
private _thumbnailUrl = '';
#imagingRepository = new UmbImagingRepository(this);
#intersectionObserver?: IntersectionObserver;
override render() {
return html` ${this.#renderThumbnail()} ${when(this._isLoading, () => this.#renderLoading())} `;
}
override connectedCallback() {
super.connectedCallback();
if (this.loading === 'lazy') {
this.#intersectionObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
this.#generateThumbnailUrl();
this.#intersectionObserver?.disconnect();
}
});
this.#intersectionObserver.observe(this);
} else {
this.#generateThumbnailUrl();
}
}
override disconnectedCallback() {
super.disconnectedCallback();
this.#intersectionObserver?.disconnect();
}
#renderLoading() {
return html`<div id="loader"><uui-loader></uui-loader></div>`;
}
#renderThumbnail() {
if (this._isLoading) return nothing;
return when(
this._thumbnailUrl,
() =>
html`<img
id="figure"
src="${this._thumbnailUrl}"
alt="${this.alt}"
loading="${this.loading}"
draggable="false" />`,
() => html`<umb-icon id="icon" name="${this.icon}"></umb-icon>`,
);
}
async #generateThumbnailUrl() {
const { data } = await this.#imagingRepository.requestThumbnailUrls(
[this.unique],
this.height,
this.width,
this.mode,
);
this._thumbnailUrl = data?.[0]?.url ?? '';
this._isLoading = false;
}
static override styles = [
UmbTextStyles,
css`
:host {
display: block;
position: relative;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
#loader {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}
#figure {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill-opacity=".1"><path d="M50 0h50v50H50zM0 50h50v50H0z"/></svg>');
background-size: 10px 10px;
background-repeat: repeat;
}
#icon {
width: 100%;
height: 100%;
font-size: var(--uui-size-8);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
[ELEMENT_NAME]: UmbImagingThumbnailElement;
}
}

View File

@@ -0,0 +1 @@
export * from './imaging-thumbnail.element.js';

View File

@@ -1,7 +1,6 @@
import type { UmbImagingModel } from './types.js';
import { UmbImagingCropMode, type UmbImagingModel } from './types.js';
import { UmbImagingServerDataSource } from './imaging.server.data.js';
import { UMB_IMAGING_STORE_CONTEXT } from './imaging.store.token.js';
import { ImageCropModeModel } from '@umbraco-cms/backoffice/external/backend-api';
import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbApi } from '@umbraco-cms/backoffice/extension-api';
@@ -68,7 +67,7 @@ export class UmbImagingRepository extends UmbRepositoryBase implements UmbApi {
* @param {ImageCropModeModel} mode - The crop mode
* @memberof UmbImagingRepository
*/
async requestThumbnailUrls(uniques: Array<string>, height: number, width: number, mode = ImageCropModeModel.MIN) {
async requestThumbnailUrls(uniques: Array<string>, height: number, width: number, mode = UmbImagingCropMode.MIN) {
const imagingModel: UmbImagingModel = { height, width, mode };
return this.requestResizedItems(uniques, imagingModel);
}

View File

@@ -1,2 +1,3 @@
export * from './components/index.js';
export { UmbImagingRepository } from './imaging.repository.js';
export { UMB_IMAGING_REPOSITORY_ALIAS } from './constants.js';

View File

@@ -1,7 +1,9 @@
import type { ImageCropModeModel } from '@umbraco-cms/backoffice/external/backend-api';
import { ImageCropModeModel as UmbImagingCropMode } from '@umbraco-cms/backoffice/external/backend-api';
export { UmbImagingCropMode };
export interface UmbImagingModel {
height?: number;
width?: number;
mode?: ImageCropModeModel;
mode?: UmbImagingCropMode;
}

View File

@@ -1,7 +1,5 @@
import type { UmbMediaCollectionFilterModel, UmbMediaCollectionItemModel } from './types.js';
import { UMB_MEDIA_GRID_COLLECTION_VIEW_ALIAS } from './views/index.js';
import { UmbImagingRepository } from '@umbraco-cms/backoffice/imaging';
import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api';
import { UmbDefaultCollectionContext } from '@umbraco-cms/backoffice/collection';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
@@ -9,31 +7,14 @@ export class UmbMediaCollectionContext extends UmbDefaultCollectionContext<
UmbMediaCollectionItemModel,
UmbMediaCollectionFilterModel
> {
#imagingRepository: UmbImagingRepository;
#thumbnailItems = new UmbArrayState<UmbMediaCollectionItemModel>([], (x) => x.unique);
public readonly thumbnailItems = this.#thumbnailItems.asObservable();
/**
* The thumbnail items that are currently displayed in the collection.
* @deprecated Use the `<umb-imaging-thumbnail>` element instead.
*/
public readonly thumbnailItems = this.items;
constructor(host: UmbControllerHost) {
super(host, UMB_MEDIA_GRID_COLLECTION_VIEW_ALIAS);
this.#imagingRepository = new UmbImagingRepository(host);
this.observe(this.items, async (items) => {
if (!items?.length) return;
const { data } = await this.#imagingRepository.requestThumbnailUrls(
items.map((m) => m.unique),
400,
400,
);
this.#thumbnailItems.setValue(
items.map((item) => {
const thumbnail = data?.find((m) => m.unique === item.unique)?.url;
return { ...item, url: thumbnail };
}),
);
});
}
}

View File

@@ -8,6 +8,8 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/modal';
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import '@umbraco-cms/backoffice/imaging';
@customElement('umb-media-grid-collection-view')
export class UmbMediaGridCollectionViewElement extends UmbLitElement {
@state()
@@ -52,7 +54,7 @@ export class UmbMediaGridCollectionViewElement extends UmbLitElement {
this.observe(this.#collectionContext.loading, (loading) => (this._loading = loading), '_observeLoading');
this.observe(this.#collectionContext.thumbnailItems, (items) => (this._items = items), '_observeItems');
this.observe(this.#collectionContext.items, (items) => (this._items = items), '_observeItems');
this.observe(
this.#collectionContext.selection.selection,
@@ -127,13 +129,7 @@ export class UmbMediaGridCollectionViewElement extends UmbLitElement {
@selected=${() => this.#onSelect(item)}
@deselected=${() => this.#onDeselect(item)}
class="media-item">
${when(
item.url,
() => html`<img src=${item.url!} alt=${item.name} draggable="false" />`,
() => html`<umb-icon name=${item.icon}></umb-icon>`,
)}
<!-- TODO: [LK] I'd like to indicate a busy state when bulk actions are triggered. -->
<!-- <div class="container"><uui-loader></uui-loader></div> -->
<umb-imaging-thumbnail unique=${item.unique} alt=${item.name} icon=${item.icon}></umb-imaging-thumbnail>
</uui-card-media>
`;
}
@@ -158,16 +154,6 @@ export class UmbMediaGridCollectionViewElement extends UmbLitElement {
grid-auto-rows: 200px;
gap: var(--uui-size-space-5);
}
img {
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill-opacity=".1"><path d="M50 0h50v50H50zM0 50h50v50H0z"/></svg>');
background-size: 10px 10px;
background-repeat: repeat;
}
umb-icon {
font-size: var(--uui-size-8);
}
`,
];
}

View File

@@ -1,7 +1,6 @@
import type { UmbMediaCardItemModel } from '../../modals/index.js';
import type { UmbMediaItemModel } from '../../repository/index.js';
import { UmbMediaPickerContext } from './input-media.context.js';
import { UmbImagingRepository } from '@umbraco-cms/backoffice/imaging';
import { css, customElement, html, ifDefined, property, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import { splitStringToArray } from '@umbraco-cms/backoffice/utils';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
@@ -11,6 +10,8 @@ import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/modal';
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
import '@umbraco-cms/backoffice/imaging';
const elementName = 'umb-input-media';
@customElement(elementName)
@@ -123,8 +124,6 @@ export class UmbInputMediaElement extends UmbFormControlMixin<string | undefined
#pickerContext = new UmbMediaPickerContext(this);
#imagingRepository = new UmbImagingRepository(this);
constructor() {
super();
@@ -143,22 +142,7 @@ export class UmbInputMediaElement extends UmbFormControlMixin<string | undefined
const missingCards = selectedItems.filter((item) => !this._cards.find((card) => card.unique === item.unique));
if (selectedItems?.length && !missingCards.length) return;
if (!selectedItems?.length) {
this._cards = [];
return;
}
const uniques = selectedItems.map((x) => x.unique);
const { data: thumbnails } = await this.#imagingRepository.requestThumbnailUrls(uniques, 400, 400);
this._cards = selectedItems.map((item) => {
const thumbnail = thumbnails?.find((x) => x.unique === item.unique);
return {
...item,
src: thumbnail?.url,
};
});
this._cards = selectedItems ?? [];
});
this.addValidator(
@@ -228,9 +212,10 @@ export class UmbInputMediaElement extends UmbFormControlMixin<string | undefined
name=${ifDefined(item.name === null ? undefined : item.name)}
detail=${ifDefined(item.unique)}
href="${this._editMediaPath}edit/${item.unique}">
${item.src
? html`<img src=${item.src} alt=${item.name} />`
: html`<umb-icon name=${ifDefined(item.mediaType.icon)}></umb-icon>`}
<umb-imaging-thumbnail
unique=${item.unique}
alt=${item.name}
icon=${item.mediaType.icon}></umb-imaging-thumbnail>
${this.#renderIsTrashed(item)}
<uui-action-bar slot="actions">
<uui-button

View File

@@ -4,11 +4,10 @@ import { UMB_IMAGE_CROPPER_EDITOR_MODAL, UMB_MEDIA_PICKER_MODAL } from '../../mo
import type { UmbCropModel, UmbMediaPickerPropertyValue } from '../../property-editors/index.js';
import type { UmbMediaItemModel } from '../../repository/index.js';
import type { UmbUploadableFileModel } from '../../dropzone/index.js';
import { customElement, html, ifDefined, nothing, property, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import { customElement, html, nothing, property, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import { umbConfirmModal, UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbId } from '@umbraco-cms/backoffice/id';
import { UmbImagingRepository } from '@umbraco-cms/backoffice/imaging';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';
@@ -17,6 +16,8 @@ import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal';
import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router';
import type { UmbVariantId } from '@umbraco-cms/backoffice/variant';
import '@umbraco-cms/backoffice/imaging';
type UmbRichMediaCardModel = {
unique: string;
media: string;
@@ -162,8 +163,6 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
#itemRepository = new UmbMediaItemRepository(this);
#imagingRepository = new UmbImagingRepository(this);
#modalRouter: UmbModalRouteRegistrationController;
#modalManager?: UmbModalManagerContext;
@@ -249,16 +248,13 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
const uniques = this.items.map((item) => item.mediaKey);
const { data: items } = await this.#itemRepository.requestItems(uniques);
const { data: thumbnails } = await this.#imagingRepository.requestThumbnailUrls(uniques, 400, 400);
this._cards = this.items.map((item) => {
const media = items?.find((x) => x.unique === item.mediaKey);
const thumbnail = thumbnails?.find((x) => x.unique === item.mediaKey);
return {
unique: item.key,
media: item.mediaKey,
name: media?.name ?? '',
src: thumbnail?.url,
icon: media?.mediaType?.icon,
isTrashed: media?.isTrashed ?? false,
};
@@ -366,9 +362,10 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
const href = this._routeBuilder?.({ key: item.unique });
return html`
<uui-card-media id=${item.unique} name=${item.name} .href=${href}>
${item.src
? html`<img src=${item.src} alt=${item.name} />`
: html`<umb-icon name=${ifDefined(item.icon)}></umb-icon>`}
<umb-imaging-thumbnail
unique=${item.media}
alt=${item.name}
icon=${item.icon ?? 'icon-picture'}></umb-imaging-thumbnail>
${this.#renderIsTrashed(item)}
<uui-action-bar slot="actions">
<uui-button

View File

@@ -2,17 +2,17 @@ import { UmbMediaItemRepository } from '../../repository/index.js';
import { UmbMediaTreeRepository } from '../../tree/media-tree.repository.js';
import { UMB_MEDIA_ROOT_ENTITY_TYPE } from '../../entity.js';
import type { UmbDropzoneElement } from '../../dropzone/dropzone.element.js';
import type { UmbMediaItemModel } from '../../repository/index.js';
import type { UmbMediaCardItemModel, UmbMediaPathModel } from './types.js';
import type { UmbMediaPickerFolderPathElement } from './components/media-picker-folder-path.element.js';
import type { UmbMediaPickerModalData, UmbMediaPickerModalValue } from './media-picker-modal.token.js';
import { css, html, customElement, state, repeat, ifDefined, query } from '@umbraco-cms/backoffice/external/lit';
import { debounce } from '@umbraco-cms/backoffice/utils';
import { UmbImagingRepository } from '@umbraco-cms/backoffice/imaging';
import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
import { UMB_CONTENT_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/content';
import type { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui';
import '@umbraco-cms/backoffice/imaging';
const root: UmbMediaPathModel = { name: 'Media', unique: null, entityType: UMB_MEDIA_ROOT_ENTITY_TYPE };
@customElement('umb-media-picker-modal')
@@ -22,13 +22,9 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement<
> {
#mediaTreeRepository = new UmbMediaTreeRepository(this); // used to get file structure
#mediaItemRepository = new UmbMediaItemRepository(this); // used to search
#imagingRepository = new UmbImagingRepository(this); // used to get image renditions
#dataType?: { unique: string };
@state()
private _filter: (item: UmbMediaCardItemModel) => boolean = () => true;
@state()
private _selectableFilter: (item: UmbMediaCardItemModel) => boolean = () => true;
@@ -62,7 +58,6 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement<
override async connectedCallback(): Promise<void> {
super.connectedCallback();
if (this.data?.filter) this._filter = this.data?.filter;
if (this.data?.pickableFilter) this._selectableFilter = this.data?.pickableFilter;
if (this.data?.startNode) {
@@ -87,27 +82,10 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement<
take: 100,
});
this.#mediaItemsCurrentFolder = await this.#mapMediaUrls(data?.items ?? []);
this.#mediaItemsCurrentFolder = data?.items ?? [];
this.#filterMediaItems();
}
async #mapMediaUrls(items: Array<UmbMediaItemModel>): Promise<Array<UmbMediaCardItemModel>> {
if (!items.length) return [];
const { data } = await this.#imagingRepository.requestThumbnailUrls(
items.map((item) => item.unique),
400,
400,
);
return items
.map((item): UmbMediaCardItemModel => {
const src = data?.find((media) => media.unique === item.unique)?.url;
return { ...item, src };
})
.filter((item) => this._filter(item));
}
#onOpen(item: UmbMediaCardItemModel) {
this._currentMediaEntity = {
name: item.name,
@@ -152,7 +130,7 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement<
}
// Map urls for search results as we are going to show for all folders (as long they aren't trashed).
this._mediaFilteredList = await this.#mapMediaUrls(data.filter((found) => found.isTrashed === false));
this._mediaFilteredList = data.filter((found) => found.isTrashed === false);
}
#debouncedSearch = debounce(() => {
@@ -240,9 +218,10 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement<
@deselected=${() => this.#onDeselected(item)}
?selected=${this.value?.selection?.find((value) => value === item.unique)}
?selectable=${!disabled}>
${item.src
? html`<img src=${item.src} alt=${ifDefined(item.name)} />`
: html`<umb-icon .name=${item.mediaType.icon}></umb-icon>`}
<umb-imaging-thumbnail
unique=${item.unique}
alt=${item.name}
icon=${item.mediaType.icon}></umb-imaging-thumbnail>
</uui-card-media>
`;
}