diff --git a/src/packages/media/imaging/components/imaging-thumbnail.element.ts b/src/packages/media/imaging/components/imaging-thumbnail.element.ts new file mode 100644 index 0000000000..81ae2ff903 --- /dev/null +++ b/src/packages/media/imaging/components/imaging-thumbnail.element.ts @@ -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`
`; + } + + #renderThumbnail() { + if (this._isLoading) return nothing; + + return when( + this._thumbnailUrl, + () => + html`${this.alt}`, + () => html``, + ); + } + + 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,'); + 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; + } +} diff --git a/src/packages/media/imaging/components/index.ts b/src/packages/media/imaging/components/index.ts new file mode 100644 index 0000000000..60818ea906 --- /dev/null +++ b/src/packages/media/imaging/components/index.ts @@ -0,0 +1 @@ +export * from './imaging-thumbnail.element.js'; diff --git a/src/packages/media/imaging/imaging.repository.ts b/src/packages/media/imaging/imaging.repository.ts index d6f7935283..c602078a52 100644 --- a/src/packages/media/imaging/imaging.repository.ts +++ b/src/packages/media/imaging/imaging.repository.ts @@ -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, height: number, width: number, mode = ImageCropModeModel.MIN) { + async requestThumbnailUrls(uniques: Array, height: number, width: number, mode = UmbImagingCropMode.MIN) { const imagingModel: UmbImagingModel = { height, width, mode }; return this.requestResizedItems(uniques, imagingModel); } diff --git a/src/packages/media/imaging/index.ts b/src/packages/media/imaging/index.ts index afd4abe3b5..d05152d5dc 100644 --- a/src/packages/media/imaging/index.ts +++ b/src/packages/media/imaging/index.ts @@ -1,2 +1,3 @@ +export * from './components/index.js'; export { UmbImagingRepository } from './imaging.repository.js'; export { UMB_IMAGING_REPOSITORY_ALIAS } from './constants.js'; diff --git a/src/packages/media/imaging/types.ts b/src/packages/media/imaging/types.ts index 41df2f5b95..0411433515 100644 --- a/src/packages/media/imaging/types.ts +++ b/src/packages/media/imaging/types.ts @@ -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; } diff --git a/src/packages/media/media/collection/media-collection.context.ts b/src/packages/media/media/collection/media-collection.context.ts index 60ca5a16f5..4785fc9b8c 100644 --- a/src/packages/media/media/collection/media-collection.context.ts +++ b/src/packages/media/media/collection/media-collection.context.ts @@ -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([], (x) => x.unique); - public readonly thumbnailItems = this.#thumbnailItems.asObservable(); + /** + * The thumbnail items that are currently displayed in the collection. + * @deprecated Use the `` 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 }; - }), - ); - }); } } diff --git a/src/packages/media/media/collection/views/grid/media-grid-collection-view.element.ts b/src/packages/media/media/collection/views/grid/media-grid-collection-view.element.ts index 2345df4307..27042a9738 100644 --- a/src/packages/media/media/collection/views/grid/media-grid-collection-view.element.ts +++ b/src/packages/media/media/collection/views/grid/media-grid-collection-view.element.ts @@ -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`${item.name}`, - () => html``, - )} - - + `; } @@ -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,'); - background-size: 10px 10px; - background-repeat: repeat; - } - - umb-icon { - font-size: var(--uui-size-8); - } `, ]; } diff --git a/src/packages/media/media/components/input-media/input-media.element.ts b/src/packages/media/media/components/input-media/input-media.element.ts index 7affdb7650..8377fa87d1 100644 --- a/src/packages/media/media/components/input-media/input-media.element.ts +++ b/src/packages/media/media/components/input-media/input-media.element.ts @@ -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 !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 - ${item.src - ? html`${item.name}` - : html``} + ${this.#renderIsTrashed(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` - ${item.src - ? html`${item.name}` - : html``} + ${this.#renderIsTrashed(item)} { #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 { 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): Promise> { - 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`${ifDefined(item.name)}` - : html``} + `; }