Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Imaging thumbnail component to load thumbnails in parallel #2109

Merged
merged 13 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions src/packages/media/imaging/components/imaging-thumbnail.element.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions src/packages/media/imaging/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './imaging-thumbnail.element.js';
5 changes: 2 additions & 3 deletions src/packages/media/imaging/imaging.repository.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/packages/media/imaging/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './components/index.js';
export { UmbImagingRepository } from './imaging.repository.js';
export { UMB_IMAGING_REPOSITORY_ALIAS } from './constants.js';
6 changes: 4 additions & 2 deletions src/packages/media/imaging/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
29 changes: 5 additions & 24 deletions src/packages/media/media/collection/media-collection.context.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,20 @@
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';

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 };
}),
);
});
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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>
`;
}
Expand All @@ -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);
}
`,
];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)
Expand Down Expand Up @@ -123,8 +124,6 @@ export class UmbInputMediaElement extends UmbFormControlMixin<string | undefined

#pickerContext = new UmbMediaPickerContext(this);

#imagingRepository = new UmbImagingRepository(this);

constructor() {
super();

Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading