import { isEqual, throttle } from 'lodash-es'; import Masonry from 'masonry-layout'; import { ViewDisplayConfig } from '../config/schema/common/display'; import { MediaLoadedInfo } from '../types'; import { forceReflow, getChildrenFromElement, setOrRemoveAttribute, setOrRemoveStyleProperty, } from '../utils/basic'; import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event'; import { AdvancedCameraCardMediaLoadedEventTarget, dispatchExistingMediaLoadedInfoAsEvent, dispatchMediaUnloadedEvent, } from '../utils/media-info'; // The default minimum cell width: if the columns are not specified this value // is used to compute the number of columns, always trying to keep each cell as // at least this width. On Android, a card in portrait mode is 396 pixels, and // we'd like to support two cells wide in that configuration. const MEDIA_GRID_DEFAULT_MIN_CELL_WIDTH = 190; const MEDIA_GRID_DEFAULT_IDEAL_CELL_WIDTH = 600; const MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR = 2.0; const MEDIA_GRID_HORIZONTAL_GUTTER_WIDTH = 1; type GridID = string; type MediaGridChild = HTMLElement & AdvancedCameraCardMediaLoadedEventTarget; type MediaGridContents = Map; export interface MediaGridSelected { selected: GridID; } export interface MediaGridConstructorOptions { selected?: GridID; idAttribute?: string; widthFactorAttribute?: string; displayConfig?: ViewDisplayConfig; } export interface ExtendedMasonry extends Masonry { // Expose the items array to allow for custom ordering (used for the // `grid_selected_position` parameter). items: { element: MediaGridChild; }[]; } export class MediaGridController { private _host: HTMLElement; private _selected: GridID | null; private _mediaLoadedInfoMap: Map = new Map(); private _gridContents: MediaGridContents = new Map(); private _masonry: ExtendedMasonry | null = null; private _displayConfig: ViewDisplayConfig | null = null; private _hostWidth: number; private _idAttribute: string; private _widthFactorAttribute: string; private _throttledLayout = throttle(() => this._masonry?.layout?.(), 300, { leading: true, trailing: true, }); // If the order in which the observers are declared changes, the unittest must // be updated in triggerResizeObserver and triggerMutationObserver. private _hostMutationObserver = new MutationObserver( // eslint-disable-next-line @typescript-eslint/no-unused-vars (_mutations: MutationRecord[], _observer: MutationObserver) => this._calculateGridContentsFromHost(), ); private _cellMutationObserver = new MutationObserver( // eslint-disable-next-line @typescript-eslint/no-unused-vars (_mutations: MutationRecord[], _observer: MutationObserver) => this._calculateGridContentsFromHost(), ); private _hostResizeObserver = new ResizeObserver(this._hostResizeHandler.bind(this)); private _cellResizeObserver = new ResizeObserver(this._cellResizeHandler.bind(this)); constructor(host: HTMLElement, options?: MediaGridConstructorOptions) { this._host = host; this._selected = options?.selected ?? null; this._idAttribute = options?.idAttribute ?? 'grid-id'; this._widthFactorAttribute = options?.widthFactorAttribute ?? 'grid-width-factor'; this._hostWidth = this._host.getBoundingClientRect().width; this._hostResizeObserver.observe(host); this._displayConfig = options?.displayConfig ?? null; this._hostMutationObserver.observe(host, { childList: true, }); // Need to separately listen for slotchanges since mutation observer will // not be called for shadom DOM slotted changes. if (host instanceof HTMLSlotElement) { host.addEventListener('slotchange', this._calculateGridContentsFromHost); } this._calculateGridContentsFromHost(); } public destroy(): void { this._hostResizeObserver.disconnect(); this._cellResizeObserver.disconnect(); this._hostMutationObserver.disconnect(); this._cellMutationObserver.disconnect(); if (this._host instanceof HTMLSlotElement) { this._host.removeEventListener('slotchange', this._calculateGridContentsFromHost); } this._mediaLoadedInfoMap.clear(); this._masonry?.destroy?.(); this._masonry = null; for (const child of this._gridContents.values()) { this._removeChildEventListeners(child); } this._gridContents.clear(); } public setDisplayConfig(displayConfig: ViewDisplayConfig | null): void { if (!isEqual(displayConfig, this._displayConfig)) { this._displayConfig = displayConfig; this._calculateGridContentsFromHost(); } } public getGridContents(): MediaGridContents { return this._gridContents; } public getGridSize(): number { return this._gridContents.size; } public getSelected(): GridID | null { return this._selected; } private _sortItemsInGrid(): void { const existingItems = this._masonry?.items; const selectedItem = existingItems?.find( (item) => item.element.getAttribute(this._idAttribute) === this._selected, ); // If `grid_selected_position` is set to 'first' or 'last', move the // selected item to the start or end of the list respectively. if ( !!this._displayConfig?.grid_selected_position && ['first', 'last'].includes(this._displayConfig.grid_selected_position) && existingItems && selectedItem && this._masonry ) { // Implementation note: With the latest version of the Masonry library // (4.2.2) using the prepended() and appended() methods in quick succession // causes the layout to not show the newly added items. Instead, access // the items in place and swap them around. const otherItems = existingItems?.filter((item) => item !== selectedItem); const newItems = this._displayConfig.grid_selected_position === 'first' ? [selectedItem, ...otherItems] : [...otherItems, selectedItem]; this._masonry.items = newItems; } } public selectCell(id: GridID) { if (this._selected === id) { return; } this._selected = id; fireAdvancedCameraCardEvent(this._host, 'media-grid:selected', { selected: id }); const mediaLoadedInfo = this._mediaLoadedInfoMap.get(id); if (mediaLoadedInfo) { dispatchExistingMediaLoadedInfoAsEvent(this._host, mediaLoadedInfo); } this._sortItemsInGrid(); this._updateSelectedStylesOnElements(); this._forceLayout(); } public unselectAll() { if (this._selected !== null) { dispatchMediaUnloadedEvent(this._host); fireAdvancedCameraCardEvent(this._host, 'media-grid:unselected'); } this._selected = null; this._updateSelectedStylesOnElements(); this._forceLayout(); } protected _forceLayout(): void { // Cancel possible pending layout this._throttledLayout.cancel(); // Force browser reflow so masonry measures the updated element size forceReflow(this._host); // Sizes and positions may change when an element is selected, so re-do the layout this._masonry?.layout?.(); } private _calculateGridContentsFromHost = (): void => { const children = getChildrenFromElement(this._host); const gridContents: MediaGridContents = new Map(); for (const child of children) { const id = child.getAttribute(this._idAttribute) || String(gridContents.size); gridContents.set(id, child); } this._setGridContents(gridContents); }; private _setGridContents(gridContents: MediaGridContents): void { this._gridContents = gridContents; // Remove media loaded info objects that belong to objects no longer in the // grid. for (const key of this._mediaLoadedInfoMap.keys()) { if (!gridContents.has(key)) { this._mediaLoadedInfoMap.delete(key); } } if (this._selected !== null && !this._gridContents.has(this._selected)) { this.unselectAll(); } for (const element of gridContents.values()) { this._removeChildEventListeners(element); this._addChildEventListeners(element); } this._setColumnSizeStyles(); this._createMasonry(); // Observe grid elements for size or id changes. this._cellMutationObserver.disconnect(); this._cellResizeObserver.disconnect(); for (const child of gridContents.values()) { this._cellMutationObserver.observe(child, { attributeFilter: [this._idAttribute, this._widthFactorAttribute], attributes: true, }); this._cellResizeObserver.observe(child); } this._sortItemsInGrid(); this._updateSelectedStylesOnElements(); this._updateWidthFactorStyles(); this._setColumnSizeStyles(); } private _handleMediaLoadedInfoEvent = (ev: CustomEvent): void => { const eventPath = ev.composedPath(); for (const [id, element] of this._gridContents.entries()) { /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (eventPath.includes(element)) { this._mediaLoadedInfoMap.set(id, ev.detail); if (id !== this._selected) { ev.stopPropagation(); } break; } } }; private _hostResizeHandler(): void { const dimensions = this._host.getBoundingClientRect(); // Only resize things if the width has changed. It is expected that the // height may change during the layout. if (dimensions.width !== this._hostWidth) { this._hostWidth = dimensions.width; // Reset the column CSS sizes first. this._setColumnSizeStyles(); // Update the column width on the existing Masonry instance rather than // destroying and recreating it. A destroy resets the container height to // 0, which can cause an ancestor scrollbar to appear/disappear, changing // the available width and triggering an infinite resize oscillation. this._masonry?.option?.({ columnWidth: this._getColumnSize() }); this._throttledLayout(); } } private _cellResizeHandler(): void { this._throttledLayout(); } private _addChildEventListeners(child: MediaGridChild): void { child.addEventListener('click', this._handleSelectGridCellEvent, { capture: true, }); child.addEventListener( 'advanced-camera-card:media:loaded', this._handleMediaLoadedInfoEvent, ); } private _removeChildEventListeners(child: MediaGridChild): void { child.removeEventListener('click', this._handleSelectGridCellEvent, { capture: true, }); child.removeEventListener( 'advanced-camera-card:media:loaded', this._handleMediaLoadedInfoEvent, ); } private _createMasonry(): void { if (this._masonry) { this._masonry.destroy?.(); } this._masonry = new Masonry(this._host, { columnWidth: this._getColumnSize(), initLayout: false, percentPosition: true, transitionDuration: 0, stagger: 0, // This controller handles resizes. resize: false, gutter: MEDIA_GRID_HORIZONTAL_GUTTER_WIDTH, }) as ExtendedMasonry; this._masonry.addItems?.([...this._gridContents.values()]); this._throttledLayout(); } private _handleSelectGridCellEvent = (ev: Event): void => { const eventPath = ev.composedPath(); for (const [id, element] of this._gridContents.entries()) { /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (eventPath.includes(element)) { if (this._selected !== id) { this.selectCell(id); ev.stopPropagation(); } break; } } }; private _updateSelectedStylesOnElements(): void { for (const [id, element] of this._gridContents.entries()) { setOrRemoveAttribute(element, id === this._selected, 'selected'); // Explicitly use an 'unselected' attribute vs a :not(selected) such that // a carousel with neither selected nor unselected will behave normally. // This matches a css selector in viewer-carousel.scss . setOrRemoveAttribute(element, id !== this._selected, 'unselected'); } } private _updateWidthFactorStyles(): void { for (const element of this._gridContents.values()) { const widthFactor = element.getAttribute(this._widthFactorAttribute); setOrRemoveStyleProperty( element, !!widthFactor, '--advanced-camera-card-grid-width-factor', widthFactor ?? undefined, ); } } private _getColumnSize(): number { const columns = this._getColumns(); if (columns === 1) { return this._hostWidth; } return Math.max(0, this._hostWidth / columns - MEDIA_GRID_HORIZONTAL_GUTTER_WIDTH); } private _getColumns(): number { if (this._displayConfig?.grid_columns) { return this._displayConfig?.grid_columns; } const maxColumns = this._displayConfig?.grid_max_columns ?? Infinity; // See if we can get a multi-column layout using the ideal cell width. const idealColumns = Math.min( maxColumns, Math.floor(this._hostWidth / MEDIA_GRID_DEFAULT_IDEAL_CELL_WIDTH), ); if (idealColumns > 1) { return idealColumns; } // If not, get a multi-column view using the minimum cell width. const minColumns = Math.floor( Math.min(maxColumns, this._hostWidth / MEDIA_GRID_DEFAULT_MIN_CELL_WIDTH), ); // Last result use at least 1 column. return Math.max(1, minColumns); } private _setColumnSizeStyles(): void { this._host.style.setProperty( '--advanced-camera-card-grid-column-size', `${this._getColumnSize()}px`, ); this._host.style.setProperty( '--advanced-camera-card-grid-selected-width-factor', `${ this._displayConfig?.grid_selected_width_factor ?? MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR }`, ); } }