Complete carousel refactor.
Reduces one layer of DOM nesting for simplication, uses the latest Embla version, unittests for everything.
This commit is contained in:
@@ -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;
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user