Skip to content

Commit 6697f57

Browse files
authored
Merge pull request #2109 from umbraco/v14/feature/load-thumbnails-individually
Feature: Imaging thumbnail component to load thumbnails in parallel
2 parents 3c95bfe + b61680e commit 6697f57

File tree

10 files changed

+212
-108
lines changed

10 files changed

+212
-108
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { UmbImagingCropMode } from '../types.js';
2+
import { UmbImagingRepository } from '../imaging.repository.js';
3+
import { css, customElement, html, nothing, property, state, when } from '@umbraco-cms/backoffice/external/lit';
4+
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
5+
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
6+
7+
const ELEMENT_NAME = 'umb-imaging-thumbnail';
8+
9+
@customElement(ELEMENT_NAME)
10+
export class UmbImagingThumbnailElement extends UmbLitElement {
11+
/**
12+
* The unique identifier for the media item.
13+
* @remark This is also known as the media key and is used to fetch the resource.
14+
*/
15+
@property()
16+
unique = '';
17+
18+
/**
19+
* The width of the thumbnail in pixels.
20+
* @default 300
21+
*/
22+
@property({ type: Number })
23+
width = 300;
24+
25+
/**
26+
* The height of the thumbnail in pixels.
27+
* @default 300
28+
*/
29+
@property({ type: Number })
30+
height = 300;
31+
32+
/**
33+
* The mode of the thumbnail.
34+
* @remark The mode determines how the image is cropped.
35+
* @enum {UmbImagingCropMode}
36+
*/
37+
@property()
38+
mode: UmbImagingCropMode = UmbImagingCropMode.MIN;
39+
40+
/**
41+
* The alt text for the thumbnail.
42+
*/
43+
@property()
44+
alt = '';
45+
46+
/**
47+
* The fallback icon for the thumbnail.
48+
*/
49+
@property()
50+
icon = 'icon-picture';
51+
52+
/**
53+
* The `loading` state of the thumbnail.
54+
* @enum {'lazy' | 'eager'}
55+
* @default 'lazy'
56+
*/
57+
@property()
58+
loading: 'lazy' | 'eager' = 'lazy';
59+
60+
@state()
61+
private _isLoading = true;
62+
63+
@state()
64+
private _thumbnailUrl = '';
65+
66+
#imagingRepository = new UmbImagingRepository(this);
67+
68+
#intersectionObserver?: IntersectionObserver;
69+
70+
override render() {
71+
return html` ${this.#renderThumbnail()} ${when(this._isLoading, () => this.#renderLoading())} `;
72+
}
73+
74+
override connectedCallback() {
75+
super.connectedCallback();
76+
77+
if (this.loading === 'lazy') {
78+
this.#intersectionObserver = new IntersectionObserver((entries) => {
79+
if (entries[0].isIntersecting) {
80+
this.#generateThumbnailUrl();
81+
this.#intersectionObserver?.disconnect();
82+
}
83+
});
84+
this.#intersectionObserver.observe(this);
85+
} else {
86+
this.#generateThumbnailUrl();
87+
}
88+
}
89+
90+
override disconnectedCallback() {
91+
super.disconnectedCallback();
92+
this.#intersectionObserver?.disconnect();
93+
}
94+
95+
#renderLoading() {
96+
return html`<div id="loader"><uui-loader></uui-loader></div>`;
97+
}
98+
99+
#renderThumbnail() {
100+
if (this._isLoading) return nothing;
101+
102+
return when(
103+
this._thumbnailUrl,
104+
() =>
105+
html`<img
106+
id="figure"
107+
src="${this._thumbnailUrl}"
108+
alt="${this.alt}"
109+
loading="${this.loading}"
110+
draggable="false" />`,
111+
() => html`<umb-icon id="icon" name="${this.icon}"></umb-icon>`,
112+
);
113+
}
114+
115+
async #generateThumbnailUrl() {
116+
const { data } = await this.#imagingRepository.requestThumbnailUrls(
117+
[this.unique],
118+
this.height,
119+
this.width,
120+
this.mode,
121+
);
122+
123+
this._thumbnailUrl = data?.[0]?.url ?? '';
124+
this._isLoading = false;
125+
}
126+
127+
static override styles = [
128+
UmbTextStyles,
129+
css`
130+
:host {
131+
display: block;
132+
position: relative;
133+
overflow: hidden;
134+
display: flex;
135+
justify-content: center;
136+
align-items: center;
137+
width: 100%;
138+
height: 100%;
139+
}
140+
141+
#loader {
142+
display: flex;
143+
justify-content: center;
144+
align-items: center;
145+
height: 100%;
146+
width: 100%;
147+
}
148+
149+
#figure {
150+
display: block;
151+
width: 100%;
152+
height: 100%;
153+
object-fit: cover;
154+
155+
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>');
156+
background-size: 10px 10px;
157+
background-repeat: repeat;
158+
}
159+
160+
#icon {
161+
width: 100%;
162+
height: 100%;
163+
font-size: var(--uui-size-8);
164+
}
165+
`,
166+
];
167+
}
168+
169+
declare global {
170+
interface HTMLElementTagNameMap {
171+
[ELEMENT_NAME]: UmbImagingThumbnailElement;
172+
}
173+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './imaging-thumbnail.element.js';

src/packages/media/imaging/imaging.repository.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import type { UmbImagingModel } from './types.js';
1+
import { UmbImagingCropMode, type UmbImagingModel } from './types.js';
22
import { UmbImagingServerDataSource } from './imaging.server.data.js';
33
import { UMB_IMAGING_STORE_CONTEXT } from './imaging.store.token.js';
4-
import { ImageCropModeModel } from '@umbraco-cms/backoffice/external/backend-api';
54
import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository';
65
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
76
import type { UmbApi } from '@umbraco-cms/backoffice/extension-api';
@@ -68,7 +67,7 @@ export class UmbImagingRepository extends UmbRepositoryBase implements UmbApi {
6867
* @param {ImageCropModeModel} mode - The crop mode
6968
* @memberof UmbImagingRepository
7069
*/
71-
async requestThumbnailUrls(uniques: Array<string>, height: number, width: number, mode = ImageCropModeModel.MIN) {
70+
async requestThumbnailUrls(uniques: Array<string>, height: number, width: number, mode = UmbImagingCropMode.MIN) {
7271
const imagingModel: UmbImagingModel = { height, width, mode };
7372
return this.requestResizedItems(uniques, imagingModel);
7473
}

src/packages/media/imaging/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export * from './components/index.js';
12
export { UmbImagingRepository } from './imaging.repository.js';
23
export { UMB_IMAGING_REPOSITORY_ALIAS } from './constants.js';

src/packages/media/imaging/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import type { ImageCropModeModel } from '@umbraco-cms/backoffice/external/backend-api';
1+
import { ImageCropModeModel as UmbImagingCropMode } from '@umbraco-cms/backoffice/external/backend-api';
2+
3+
export { UmbImagingCropMode };
24

35
export interface UmbImagingModel {
46
height?: number;
57
width?: number;
6-
mode?: ImageCropModeModel;
8+
mode?: UmbImagingCropMode;
79
}

src/packages/media/media/collection/media-collection.context.ts

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,20 @@
11
import type { UmbMediaCollectionFilterModel, UmbMediaCollectionItemModel } from './types.js';
22
import { UMB_MEDIA_GRID_COLLECTION_VIEW_ALIAS } from './views/index.js';
3-
import { UmbImagingRepository } from '@umbraco-cms/backoffice/imaging';
4-
import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api';
53
import { UmbDefaultCollectionContext } from '@umbraco-cms/backoffice/collection';
64
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
75

86
export class UmbMediaCollectionContext extends UmbDefaultCollectionContext<
97
UmbMediaCollectionItemModel,
108
UmbMediaCollectionFilterModel
119
> {
12-
#imagingRepository: UmbImagingRepository;
13-
14-
#thumbnailItems = new UmbArrayState<UmbMediaCollectionItemModel>([], (x) => x.unique);
15-
public readonly thumbnailItems = this.#thumbnailItems.asObservable();
10+
/**
11+
* The thumbnail items that are currently displayed in the collection.
12+
* @deprecated Use the `<umb-imaging-thumbnail>` element instead.
13+
*/
14+
public readonly thumbnailItems = this.items;
1615

1716
constructor(host: UmbControllerHost) {
1817
super(host, UMB_MEDIA_GRID_COLLECTION_VIEW_ALIAS);
19-
this.#imagingRepository = new UmbImagingRepository(host);
20-
21-
this.observe(this.items, async (items) => {
22-
if (!items?.length) return;
23-
24-
const { data } = await this.#imagingRepository.requestThumbnailUrls(
25-
items.map((m) => m.unique),
26-
400,
27-
400,
28-
);
29-
30-
this.#thumbnailItems.setValue(
31-
items.map((item) => {
32-
const thumbnail = data?.find((m) => m.unique === item.unique)?.url;
33-
return { ...item, url: thumbnail };
34-
}),
35-
);
36-
});
3718
}
3819
}
3920

src/packages/media/media/collection/views/grid/media-grid-collection-view.element.ts

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
88
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/modal';
99
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
1010

11+
import '@umbraco-cms/backoffice/imaging';
12+
1113
@customElement('umb-media-grid-collection-view')
1214
export class UmbMediaGridCollectionViewElement extends UmbLitElement {
1315
@state()
@@ -52,7 +54,7 @@ export class UmbMediaGridCollectionViewElement extends UmbLitElement {
5254

5355
this.observe(this.#collectionContext.loading, (loading) => (this._loading = loading), '_observeLoading');
5456

55-
this.observe(this.#collectionContext.thumbnailItems, (items) => (this._items = items), '_observeItems');
57+
this.observe(this.#collectionContext.items, (items) => (this._items = items), '_observeItems');
5658

5759
this.observe(
5860
this.#collectionContext.selection.selection,
@@ -127,13 +129,7 @@ export class UmbMediaGridCollectionViewElement extends UmbLitElement {
127129
@selected=${() => this.#onSelect(item)}
128130
@deselected=${() => this.#onDeselect(item)}
129131
class="media-item">
130-
${when(
131-
item.url,
132-
() => html`<img src=${item.url!} alt=${item.name} draggable="false" />`,
133-
() => html`<umb-icon name=${item.icon}></umb-icon>`,
134-
)}
135-
<!-- TODO: [LK] I'd like to indicate a busy state when bulk actions are triggered. -->
136-
<!-- <div class="container"><uui-loader></uui-loader></div> -->
132+
<umb-imaging-thumbnail unique=${item.unique} alt=${item.name} icon=${item.icon}></umb-imaging-thumbnail>
137133
</uui-card-media>
138134
`;
139135
}
@@ -158,16 +154,6 @@ export class UmbMediaGridCollectionViewElement extends UmbLitElement {
158154
grid-auto-rows: 200px;
159155
gap: var(--uui-size-space-5);
160156
}
161-
162-
img {
163-
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>');
164-
background-size: 10px 10px;
165-
background-repeat: repeat;
166-
}
167-
168-
umb-icon {
169-
font-size: var(--uui-size-8);
170-
}
171157
`,
172158
];
173159
}

src/packages/media/media/components/input-media/input-media.element.ts

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { UmbMediaCardItemModel } from '../../modals/index.js';
22
import type { UmbMediaItemModel } from '../../repository/index.js';
33
import { UmbMediaPickerContext } from './input-media.context.js';
4-
import { UmbImagingRepository } from '@umbraco-cms/backoffice/imaging';
54
import { css, customElement, html, ifDefined, property, repeat, state } from '@umbraco-cms/backoffice/external/lit';
65
import { splitStringToArray } from '@umbraco-cms/backoffice/utils';
76
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
@@ -11,6 +10,8 @@ import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';
1110
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/modal';
1211
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
1312

13+
import '@umbraco-cms/backoffice/imaging';
14+
1415
const elementName = 'umb-input-media';
1516

1617
@customElement(elementName)
@@ -123,8 +124,6 @@ export class UmbInputMediaElement extends UmbFormControlMixin<string | undefined
123124

124125
#pickerContext = new UmbMediaPickerContext(this);
125126

126-
#imagingRepository = new UmbImagingRepository(this);
127-
128127
constructor() {
129128
super();
130129

@@ -143,22 +142,7 @@ export class UmbInputMediaElement extends UmbFormControlMixin<string | undefined
143142
const missingCards = selectedItems.filter((item) => !this._cards.find((card) => card.unique === item.unique));
144143
if (selectedItems?.length && !missingCards.length) return;
145144

146-
if (!selectedItems?.length) {
147-
this._cards = [];
148-
return;
149-
}
150-
151-
const uniques = selectedItems.map((x) => x.unique);
152-
153-
const { data: thumbnails } = await this.#imagingRepository.requestThumbnailUrls(uniques, 400, 400);
154-
155-
this._cards = selectedItems.map((item) => {
156-
const thumbnail = thumbnails?.find((x) => x.unique === item.unique);
157-
return {
158-
...item,
159-
src: thumbnail?.url,
160-
};
161-
});
145+
this._cards = selectedItems ?? [];
162146
});
163147

164148
this.addValidator(
@@ -228,9 +212,10 @@ export class UmbInputMediaElement extends UmbFormControlMixin<string | undefined
228212
name=${ifDefined(item.name === null ? undefined : item.name)}
229213
detail=${ifDefined(item.unique)}
230214
href="${this._editMediaPath}edit/${item.unique}">
231-
${item.src
232-
? html`<img src=${item.src} alt=${item.name} />`
233-
: html`<umb-icon name=${ifDefined(item.mediaType.icon)}></umb-icon>`}
215+
<umb-imaging-thumbnail
216+
unique=${item.unique}
217+
alt=${item.name}
218+
icon=${item.mediaType.icon}></umb-imaging-thumbnail>
234219
${this.#renderIsTrashed(item)}
235220
<uui-action-bar slot="actions">
236221
<uui-button

0 commit comments

Comments
 (0)