Complete carousel refactor.

Reduces one layer of DOM nesting for simplication, uses the latest Embla
version, unittests for everything.
This commit is contained in:
Dermot Duffy
2023-09-04 16:25:32 -07:00
parent 48ece1e154
commit e830db1a77
56 changed files with 3561 additions and 1950 deletions
+8
View File
@@ -232,3 +232,11 @@ export const isTruthy = <T>(x: T | false | undefined | null | '' | 0): x is T =>
*/
export const isHTMLElement = (element: unknown): element is HTMLElement =>
element instanceof HTMLElement;
export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => {
const children =
parent instanceof HTMLSlotElement
? parent.assignedElements({ flatten: true })
: [...parent.children];
return children.filter(isHTMLElement);
};
+1 -3
View File
@@ -65,8 +65,6 @@ export function getAllDependentCameras(
}
}
};
if (cameraID) {
getDependentCameras(cameraID);
}
getDependentCameras(cameraID);
return cameraIDs;
}
+178
View File
@@ -0,0 +1,178 @@
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import isEqual from 'lodash-es/isEqual';
import { TransitionEffect } from '../../types';
import { dispatchFrigateCardEvent, getChildrenFromElement } from '../basic.js';
export interface CarouselSelected {
index: number;
element: HTMLElement;
}
type EmblaCarouselPlugins = CreatePluginType<LoosePluginType, Record<string, unknown>>[];
export type CarouselDirection = 'vertical' | 'horizontal';
export class CarouselController {
protected _parent: HTMLElement;
protected _root: HTMLElement;
protected _direction: CarouselDirection;
protected _startIndex: number;
protected _transitionEffect: TransitionEffect;
protected _loop: boolean;
protected _dragFree: boolean;
protected _draggable: boolean;
protected _plugins: EmblaCarouselPlugins;
protected _carousel: EmblaCarouselType;
protected _mutationObserver = new MutationObserver(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(_mutations: MutationRecord[], _observer: MutationObserver) =>
this._refreshCarouselContents(),
);
constructor(
root: HTMLElement,
parent: HTMLElement,
options?: {
direction?: CarouselDirection;
transitionEffect?: TransitionEffect;
startIndex?: number;
loop?: boolean;
dragEnabled?: boolean;
dragFree?: boolean;
plugins?: EmblaCarouselPlugins;
},
) {
this._root = root;
this._parent = parent;
this._direction = options?.direction ?? 'horizontal';
this._transitionEffect = options?.transitionEffect ?? 'slide';
this._startIndex = options?.startIndex ?? 0;
this._dragFree = options?.dragFree ?? false;
this._loop = options?.loop ?? false;
this._draggable = options?.dragEnabled ?? true;
this._plugins = options?.plugins ?? [];
this._carousel = this._createCarousel(getChildrenFromElement(this._parent));
// Need to separately listen for slotchanges since mutation observer will
// not be called for shadom DOM slotted changes.
if (parent instanceof HTMLSlotElement) {
parent.addEventListener('slotchange', this._refreshCarouselContents);
}
this._mutationObserver.observe(this._parent, { childList: true });
}
public destroy() {
if (this._parent instanceof HTMLSlotElement) {
this._parent.removeEventListener('slotchange', this._refreshCarouselContents);
}
this._mutationObserver.disconnect();
this._carousel.destroy();
}
public getSlide(index: number): HTMLElement | null {
return this._carousel.slideNodes()[index] ?? null;
}
public getSelectedSlide(): HTMLElement | null {
return this.getSlide(this.getSelectedIndex());
}
public getSelectedIndex(): number {
return this._carousel.selectedScrollSnap();
}
public selectSlide(index: number): void {
this._carousel.scrollTo(index, this._transitionEffect === 'none');
}
protected _refreshCarouselContents = (): void => {
const newSlides = getChildrenFromElement(this._parent);
const slidesChanged = !isEqual(this._carousel.slideNodes(), newSlides);
if (slidesChanged) {
this._carousel.destroy();
this._carousel = this._createCarousel(newSlides);
}
};
protected _createCarousel(slides: HTMLElement[]): EmblaCarouselType {
const carousel = EmblaCarousel(
this._root,
{
slides: slides,
axis: this._direction === 'horizontal' ? 'x' : 'y',
duration: 20,
startIndex: this._startIndex,
dragFree: this._dragFree,
loop: this._loop,
containScroll: 'trimSnaps',
// This controller manages slide changes (including shadow DOM
// assignments, which the stock watcher does not handle).
watchSlides: false,
// We use the auto-size plugin to manage resizes without carousel resets
// mid-scroll.
watchResize: false,
watchDrag: this._draggable,
},
[
...this._plugins,
...(slides.length > 1
? [
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
// gestures as scrolling for the carousel.
forceWheelAxis: 'y',
}),
]
: []),
],
);
const getCarouselSelectedObject = (): CarouselSelected | null => {
const selectedIndex = this.getSelectedIndex();
const slide = this.getSlide(selectedIndex);
if (selectedIndex !== null && slide) {
return {
index: selectedIndex,
element: slide,
};
}
return null;
};
const selectSlide = (): void => {
const carouselSelected = getCarouselSelectedObject();
if (carouselSelected) {
dispatchFrigateCardEvent<CarouselSelected>(
this._parent,
'carousel:select',
carouselSelected,
);
}
};
carousel.on('init', () => selectSlide());
carousel.on('select', () => selectSlide());
carousel.on('settle', () => {
const carouselSelected = getCarouselSelectedObject();
if (carouselSelected) {
dispatchFrigateCardEvent<CarouselSelected>(
this._parent,
'carousel:settle',
carouselSelected,
);
}
});
return carousel;
}
}
@@ -0,0 +1,139 @@
import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
import { CreateOptionsType } from 'embla-carousel/components/Options';
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import { LazyUnloadCondition } from '../../../../types';
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
lazyload?: AutoLazyLoadType;
}
}
type OptionsType = CreateOptionsType<{
// Number of slides to lazyload left/right of selected (0 == only selected
// slide).
lazyLoadCount: number;
lazyUnloadCondition?: LazyUnloadCondition;
lazyLoadCallback?: (index: number, slide: HTMLElement) => void;
lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
}>;
type AutoLazyLoadOptionsType = Partial<OptionsType>;
type AutoLazyLoadType = CreatePluginType<LoosePluginType, AutoLazyLoadOptionsType>;
const defaultOptions: OptionsType = {
active: true,
breakpoints: {},
lazyLoadCount: 0,
};
export function AutoLazyLoad(
userOptions: AutoLazyLoadOptionsType = {},
): AutoLazyLoadType {
let options: OptionsType;
let emblaApi: EmblaCarouselType;
let slides: HTMLElement[];
const lazyLoadedSlides: Set<number> = new Set();
const loadEvents: EmblaEventType[] = ['init', 'select'];
const unloadEvents: EmblaEventType[] = ['select'];
function init(
emblaApiInstance: EmblaCarouselType,
optionsHandler: OptionsHandlerType,
): void {
const { mergeOptions, optionsAtMedia } = optionsHandler;
const allOptions = mergeOptions(defaultOptions, userOptions);
options = optionsAtMedia(allOptions);
emblaApi = emblaApiInstance;
slides = emblaApi.slideNodes();
if (options.lazyLoadCallback) {
loadEvents.forEach((evt) => emblaApi.on(evt, lazyLoadHandler));
}
if (
options.lazyUnloadCallback &&
options.lazyUnloadCondition &&
['all', 'unselected'].includes(options.lazyUnloadCondition)
) {
unloadEvents.forEach((evt) => emblaApi.on(evt, lazyUnloadPreviousHandler));
}
document.addEventListener('visibilitychange', visibilityHandler);
}
function destroy(): void {
if (options.lazyLoadCallback) {
loadEvents.forEach((evt) => emblaApi.off(evt, lazyLoadHandler));
}
if (options.lazyUnloadCallback) {
unloadEvents.forEach((evt) => emblaApi.off(evt, lazyUnloadPreviousHandler));
}
document.removeEventListener('visibilitychange', visibilityHandler);
}
function visibilityHandler(): void {
if (
document.visibilityState === 'hidden' &&
options.lazyUnloadCondition &&
['all', 'hidden'].includes(options.lazyUnloadCondition)
) {
lazyUnloadAllHandler();
} else if (document.visibilityState === 'visible' && options.lazyLoadCallback) {
lazyLoadHandler();
}
}
function hasLazyloaded(index: number): boolean {
return lazyLoadedSlides.has(index);
}
function lazyLoadHandler(): void {
const lazyLoadCount = options.lazyLoadCount;
const currentIndex = emblaApi.selectedScrollSnap();
const slidesToLoad = new Set<number>();
// Lazily load 'count' slides on either side of the slides in view.
for (let i = 1; i <= lazyLoadCount && currentIndex - i >= 0; i++) {
slidesToLoad.add(currentIndex - i);
}
slidesToLoad.add(currentIndex);
for (let i = 1; i <= lazyLoadCount && currentIndex + i < slides.length; i++) {
slidesToLoad.add(currentIndex + i);
}
slidesToLoad.forEach((index) => {
if (!hasLazyloaded(index) && options.lazyLoadCallback) {
lazyLoadedSlides.add(index);
options.lazyLoadCallback(index, slides[index]);
}
});
}
function lazyUnloadAllHandler(): void {
lazyLoadedSlides.forEach((index) => {
if (options.lazyUnloadCallback) {
options.lazyUnloadCallback(index, slides[index]);
lazyLoadedSlides.delete(index);
}
});
}
function lazyUnloadPreviousHandler(): void {
const index = emblaApi.previousScrollSnap();
if (hasLazyloaded(index) && options.lazyUnloadCallback) {
options.lazyUnloadCallback(index, slides[index]);
lazyLoadedSlides.delete(index);
}
}
const self: AutoLazyLoadType = {
name: 'autoLazyLoad',
options: userOptions,
init,
destroy,
};
return self;
}
@@ -0,0 +1,232 @@
import { EmblaCarouselType } from 'embla-carousel';
import { CreateOptionsType } from 'embla-carousel/components/Options.js';
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler.js';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins.js';
import {
AutoMuteCondition,
AutoPauseCondition,
AutoPlayCondition,
AutoUnmuteCondition,
FrigateCardMediaPlayer,
} from '../../../../types.js';
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
autoMediaActions?: AutoMediaActionsType;
}
}
type OptionsType = CreateOptionsType<{
playerSelector?: string;
autoPlayCondition?: AutoPlayCondition;
autoUnmuteCondition?: AutoUnmuteCondition;
autoPauseCondition?: AutoPauseCondition;
autoMuteCondition?: AutoMuteCondition;
}>;
export type AutoMediaActionsOptionsType = Partial<OptionsType>;
const defaultOptions: OptionsType = {
active: true,
breakpoints: {},
};
export type AutoMediaActionsType = CreatePluginType<
LoosePluginType,
AutoMediaActionsOptionsType
>;
export function AutoMediaActions(
userOptions: AutoMediaActionsOptionsType = {},
): AutoMediaActionsType {
let options: OptionsType;
let emblaApi: EmblaCarouselType;
let slides: HTMLElement[];
let hadInitialIntersectionCall: boolean | null = false;
const intersectionObserver: IntersectionObserver = new IntersectionObserver(
intersectionHandler,
);
function init(
emblaApiInstance: EmblaCarouselType,
optionsHandler: OptionsHandlerType,
): void {
emblaApi = emblaApiInstance;
const { mergeOptions, optionsAtMedia } = optionsHandler;
options = optionsAtMedia(mergeOptions(defaultOptions, userOptions));
slides = emblaApi.slideNodes();
if (
options.autoPlayCondition &&
['all', 'selected'].includes(options.autoPlayCondition)
) {
// Auto play when the media loads not necessarily when the slide is
// selected (to allow for lazyloading).
emblaApi.containerNode().addEventListener('frigate-card:media:loaded', play);
}
if (
options.autoUnmuteCondition &&
['all', 'selected'].includes(options.autoUnmuteCondition)
) {
// Auto unmute when the media loads not necessarily when the slide is
// selected (to allow for lazyloading).
emblaApi.containerNode().addEventListener('frigate-card:media:loaded', unmute);
}
if (
options.autoPauseCondition &&
['all', 'unselected'].includes(options.autoPauseCondition)
) {
emblaApi.on('select', pausePrevious);
}
if (
options.autoMuteCondition &&
['all', 'unselected'].includes(options.autoMuteCondition)
) {
emblaApi.on('select', mutePrevious);
}
emblaApi.on('destroy', pause);
emblaApi.on('destroy', mute);
document.addEventListener('visibilitychange', visibilityHandler);
intersectionObserver.observe(emblaApi.containerNode());
}
function destroy(): void {
if (
options.autoPlayCondition &&
['all', 'selected'].includes(options.autoPlayCondition)
) {
emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', play);
}
if (
options.autoUnmuteCondition &&
['all', 'selected'].includes(options.autoUnmuteCondition)
) {
emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', unmute);
}
if (
options.autoPauseCondition &&
['all', 'unselected'].includes(options.autoPauseCondition)
) {
emblaApi.off('select', pausePrevious);
}
if (
options.autoMuteCondition &&
['all', 'unselected'].includes(options.autoMuteCondition)
) {
emblaApi.off('select', mutePrevious);
}
emblaApi.off('destroy', pause);
emblaApi.off('destroy', mute);
document.removeEventListener('visibilitychange', visibilityHandler);
intersectionObserver.disconnect();
}
function actOnVisibilityChange(visible: boolean): void {
if (visible) {
if (
options.autoPlayCondition &&
['all', 'visible'].includes(options.autoPlayCondition)
) {
play();
}
if (
options.autoUnmuteCondition &&
['all', 'visible'].includes(options.autoUnmuteCondition)
) {
unmute();
}
} else {
if (
options.autoPauseCondition &&
['all', 'hidden'].includes(options.autoPauseCondition)
) {
pauseAll();
}
if (
options.autoMuteCondition &&
['all', 'hidden'].includes(options.autoMuteCondition)
) {
muteAll();
}
}
}
function visibilityHandler(): void {
actOnVisibilityChange(document.visibilityState === 'visible');
}
function intersectionHandler(entries: IntersectionObserverEntry[]): void {
if (!hadInitialIntersectionCall) {
hadInitialIntersectionCall = true;
return;
}
// If the live view is preloaded (i.e. in the background) we may need to
// take media actions, e.g. muting a live stream that is now running in the
// background.
actOnVisibilityChange(entries.some((entry) => entry.isIntersecting));
}
function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null {
return options.playerSelector
? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null)
: null;
}
function play(): void {
getPlayer(slides[emblaApi.selectedScrollSnap()])?.play();
}
function pause(): void {
getPlayer(slides[emblaApi.selectedScrollSnap()])?.pause();
}
function pausePrevious(): void {
getPlayer(slides[emblaApi.previousScrollSnap()])?.pause();
}
function pauseAll(): void {
for (const slide of slides) {
getPlayer(slide)?.pause();
}
}
function unmute(): void {
getPlayer(slides[emblaApi.selectedScrollSnap()])?.unmute();
}
function mute(): void {
getPlayer(slides[emblaApi.selectedScrollSnap()])?.mute();
}
function mutePrevious(): void {
getPlayer(slides[emblaApi.previousScrollSnap()])?.mute();
}
function muteAll(): void {
for (const slide of slides) {
getPlayer(slide)?.mute();
}
}
const self: AutoMediaActionsType = {
name: 'autoMediaActions',
options: userOptions,
init,
destroy,
};
return self;
}
@@ -0,0 +1,94 @@
import { EmblaCarouselType } from 'embla-carousel';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import { MediaLoadedInfo } from '../../../../types';
import {
dispatchExistingMediaLoadedInfoAsEvent,
FrigateMediaLoadedEventTarget,
} from '../../../media-info';
import { LooseOptionsType } from 'embla-carousel/components/Options';
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
autoMediaLoadedInfo?: AutoMediaLoadedInfoType;
}
}
type AutoMediaLoadedInfoType = CreatePluginType<LoosePluginType, LooseOptionsType>;
function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType {
let emblaApi: EmblaCarouselType;
let slides: (HTMLElement & FrigateMediaLoadedEventTarget)[] = [];
const mediaLoadedInfo: MediaLoadedInfo[] = [];
function init(emblaApiInstance: EmblaCarouselType): void {
emblaApi = emblaApiInstance;
slides = emblaApi.slideNodes();
for (const slide of slides) {
slide.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
slide.addEventListener('frigate-card:media:unloaded', mediaUnloadedInfoHandler);
}
emblaApi.on('init', slideSelectHandler);
emblaApi.on('select', slideSelectHandler);
}
function destroy(): void {
for (const slide of slides) {
slide.removeEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
slide.removeEventListener('frigate-card:media:unloaded', mediaUnloadedInfoHandler);
}
emblaApi.off('init', slideSelectHandler);
emblaApi.off('select', slideSelectHandler);
}
function mediaLoadedInfoHandler(ev: CustomEvent<MediaLoadedInfo>): void {
const eventPath = ev.composedPath();
for (const [index, slide] of slides.entries()) {
if (eventPath.includes(slide)) {
mediaLoadedInfo[index] = ev.detail;
if (index !== emblaApi.selectedScrollSnap()) {
ev.stopPropagation();
}
break;
}
}
}
function mediaUnloadedInfoHandler(ev: CustomEvent): void {
const eventPath = ev.composedPath();
for (const [index, slide] of slides.entries()) {
if (eventPath.includes(slide)) {
delete mediaLoadedInfo[index];
if (index !== emblaApi.selectedScrollSnap()) {
ev.stopPropagation();
}
break;
}
}
}
function slideSelectHandler(): void {
const index = emblaApi.selectedScrollSnap();
const savedMediaLoadedInfo: MediaLoadedInfo | undefined = mediaLoadedInfo[index];
if (savedMediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(
emblaApi.containerNode(),
savedMediaLoadedInfo,
);
}
}
const self: AutoMediaLoadedInfoType = {
name: 'autoMediaLoadedInfo',
options: {},
init,
destroy,
};
return self;
}
export default AutoMediaLoadedInfo;
@@ -0,0 +1,143 @@
import { EmblaCarouselType } from 'embla-carousel';
import { LooseOptionsType } from 'embla-carousel/components/Options';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import { EmblaReInitController } from '../../reinit-controller';
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
AutoSize?: AutoSizeType;
}
}
type AutoSizeType = CreatePluginType<LoosePluginType, LooseOptionsType>;
interface SlideDimensions {
height: number;
width: number;
}
/**
* This plugin offers the following functionality:
* - Auto-height: Automatically resize the container to fit the largest slide on
* view. Unlike the stock `auto-height` plugin, this version will use active
* DOM sizing vs the internal engine sizes to account for pre-reinit resize
* detection.
* - Resize and intersection re-initializing: Re-initialize the carousel on
* slide or container resizes, or container intersection changes.
*/
function AutoSize(): AutoSizeType {
let emblaApi: EmblaCarouselType;
let reInitController: EmblaReInitController | null = null;
let previousContainerIntersecting: boolean | null = null;
const previousDimensions: Map<Element, SlideDimensions> = new Map();
const resizeObserver: ResizeObserver = new ResizeObserver(resizeHandler);
const intersectionObserver: IntersectionObserver = new IntersectionObserver(
intersectionHandler,
);
function init(emblaApiInstance: EmblaCarouselType): void {
emblaApi = emblaApiInstance;
reInitController = new EmblaReInitController(emblaApi);
intersectionObserver.observe(emblaApi.containerNode());
resizeObserver.observe(emblaApi.containerNode());
for (const slide of emblaApi.slideNodes()) {
resizeObserver.observe(slide);
}
emblaApi.on('settle', setContainerHeight);
}
function destroy(): void {
intersectionObserver.disconnect();
resizeObserver.disconnect();
reInitController?.destroy();
emblaApi.off('settle', setContainerHeight);
}
function intersectionHandler(entries: IntersectionObserverEntry[]): void {
/**
* - If the DOM that contains this carousel changes such that it causes
* slides to entirely appear/disappear (e.g. `display: none` or hidden),
* then the displayed slide sizes will significantly change and the
* carousel will need to be reinitialized. Without this, odd bugs may
* occur for some users in some circumstances causing the carousel to
* appear 'stuck'.
* - Example bug when this reinitialization is not performed:
* https://github.com/dermotduffy/frigate-hass-card/issues/651
*/
const isContainerIntersectingNow = entries.some((entry) => entry.isIntersecting);
if (isContainerIntersectingNow !== previousContainerIntersecting) {
// Don't reinitialize on first call (intersectionHandler is always called
// on initial observation).
const callReInit = previousContainerIntersecting !== null;
previousContainerIntersecting = isContainerIntersectingNow;
if (callReInit) {
reInitController?.reinit();
}
}
}
function resizeHandler(entries: ResizeObserverEntry[]): void {
let callReInit = false;
for (const entry of entries) {
const newDimensions: SlideDimensions = {
height: entry.contentRect.height,
width: entry.contentRect.width,
};
const oldDimensions = previousDimensions.get(entry.target);
if (
newDimensions.width &&
newDimensions.height &&
(oldDimensions?.height !== newDimensions.height ||
oldDimensions?.width !== newDimensions.width)
) {
previousDimensions.set(entry.target, newDimensions);
callReInit = true;
}
}
if (callReInit) {
reInitController?.reinit();
}
}
function setContainerHeight(): void {
const {
slideRegistry,
options: { axis },
} = emblaApi.internalEngine();
if (axis === 'y') {
return;
}
emblaApi.containerNode().style.removeProperty('max-height');
const selectedIndexes = slideRegistry[emblaApi.selectedScrollSnap()];
const slides = emblaApi.slideNodes();
const highest = Math.max(
...selectedIndexes.map((i) => slides[i].getBoundingClientRect().height),
);
if (!isNaN(highest) && highest > 0) {
emblaApi.containerNode().style.maxHeight = `${highest}px`;
}
}
const self: AutoSizeType = {
name: 'autoSize',
options: {},
init,
destroy,
};
return self;
}
export default AutoSize;
+63
View File
@@ -0,0 +1,63 @@
import { EmblaCarouselType } from 'embla-carousel';
import debounce from 'lodash-es/debounce';
/**
* This class takes care of "safe re-initializing": Only re-initializing the
* carousel when it is not scrolling (unlike the builtin Embla reinitializations,
* e.g. slide additions or resizes). Without this class the carousel is visually
* jarring as in-progress transitions are skipped (vs completing prior to
* reinit).
*/
export class EmblaReInitController {
protected _emblaApi: EmblaCarouselType;
protected _scrolling = false;
protected _shouldReInitOnScrollStop = false;
constructor(emblaApi: EmblaCarouselType) {
this._emblaApi = emblaApi;
this._emblaApi.on('scroll', this._scrollingStart);
this._emblaApi.on('settle', this._scrollingStop);
this._emblaApi.on('destroy', this.destroy);
}
public destroy(): void {
this._emblaApi.off('scroll', this._scrollingStart);
this._emblaApi.off('settle', this._scrollingStop);
this._emblaApi.off('destroy', this.destroy);
}
public reinit(): void {
if (this._scrolling) {
this._shouldReInitOnScrollStop = true;
} else {
this._debouncedReInit();
}
}
protected _scrollingStart = (): void => {
this._scrolling = true;
};
protected _scrollingStop = (): void => {
this._scrolling = false;
if (this._shouldReInitOnScrollStop) {
this._shouldReInitOnScrollStop = false;
this._debouncedReInit();
}
};
protected _debouncedReInit = debounce(
() => {
// Allow the browser a moment to paint components that are inflight, to
// ensure accurate measurements are taken during the carousel
// reinitialization.
this._scrolling = false;
this._shouldReInitOnScrollStop = false;
this._emblaApi?.reInit();
},
200,
{ trailing: true },
);
}
+7 -14
View File
@@ -2,7 +2,7 @@ import isEqual from 'lodash-es/isEqual';
import throttle from 'lodash-es/throttle';
import Masonry from 'masonry-layout';
import { MediaLoadedInfo, ViewDisplayConfig } from '../types';
import { dispatchFrigateCardEvent, setOrRemoveAttribute } from './basic';
import { dispatchFrigateCardEvent, getChildrenFromElement, setOrRemoveAttribute } from './basic';
import {
FrigateMediaLoadedEventTarget,
dispatchExistingMediaLoadedInfoAsEvent,
@@ -144,20 +144,11 @@ export class MediaGridController {
}
protected _calculateGridContentsFromHost = (): void => {
let childrenElements: Element[];
if (this._host instanceof HTMLSlotElement) {
childrenElements = this._host.assignedElements();
} else {
childrenElements = [...this._host.children];
}
const children = getChildrenFromElement(this._host);
const gridContents: MediaGridContents = new Map();
for (const child of childrenElements) {
if (child instanceof HTMLElement) {
const id = child.getAttribute(this._idAttribute) || String(gridContents.size);
gridContents.set(id, child);
}
for (const child of children) {
const id = child.getAttribute(this._idAttribute) || String(gridContents.size);
gridContents.set(id, child);
}
this._setGridContents(gridContents);
@@ -200,6 +191,7 @@ export class MediaGridController {
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) {
@@ -271,6 +263,7 @@ export class MediaGridController {
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);
+11 -1
View File
@@ -112,7 +112,7 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean {
);
}
// Facilities correct Typescript typing of media:loaded event handlers.
// Facilitates correct Typescript typing of media:loaded/unloaded event handlers.
export interface FrigateMediaLoadedEventTarget extends EventTarget {
addEventListener(
event: 'frigate-card:media:loaded',
@@ -122,6 +122,11 @@ export interface FrigateMediaLoadedEventTarget extends EventTarget {
) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
event: 'frigate-card:media:unloaded',
listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
@@ -135,6 +140,11 @@ export interface FrigateMediaLoadedEventTarget extends EventTarget {
) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
event: 'frigate-card:media:unloaded',
listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
+2 -1
View File
@@ -82,7 +82,7 @@ export class MenuButtonController {
) as FrigateCardCustomAction,
});
if (visibleCameras) {
if (visibleCameras.size) {
const menuItems = Array.from(visibleCameras, ([cameraID, config]) => {
const action = createFrigateCardCustomAction('camera_select', {
camera: cameraID,
@@ -399,6 +399,7 @@ export class MenuButtonController {
const action = createFrigateCardCustomAction('display_mode_select', {
display_mode: isGrid ? 'single' : 'grid',
});
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (action) {
buttons.push({
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
+8 -9
View File
@@ -74,23 +74,22 @@ export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> | null
* available unions). This usually suggests the user specified an incorrect
* type name entirely. */
const contenders = new Set<string>();
if (error && error.issues) {
for (let i = 0; i < error.issues.length; i++) {
const issue = error.issues[i];
if (issue.code == 'invalid_union') {
if (error.issues.length) {
for (const issue of error.issues) {
if (issue.code === 'invalid_union') {
const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors;
for (let j = 0; j < unionErrors.length; j++) {
const nestedErrors = getParseErrorPaths(unionErrors[j]);
for (const unionError of unionErrors) {
const nestedErrors = getParseErrorPaths(unionError);
if (nestedErrors && nestedErrors.size) {
nestedErrors.forEach(contenders.add, contenders);
}
}
} else if (issue.code == 'invalid_type') {
if (issue.path[issue.path.length - 1] == 'type') {
} else if (issue.code === 'invalid_type') {
if (issue.path[issue.path.length - 1] === 'type') {
return null;
}
contenders.add(getParseErrorPathString(issue.path));
} else if (issue.code != 'custom') {
} else {
contenders.add(getParseErrorPathString(issue.path));
}
}