MOBILE-3833 usertours: Watch elements visibility

main
Noel De Martin 2022-03-30 14:33:07 +02:00
parent 771ee90d6a
commit 35433615f3
12 changed files with 312 additions and 140 deletions

View File

@ -32,8 +32,8 @@ import { CoreSwipeNavigationDirective } from './swipe-navigation';
import { CoreCollapsibleItemDirective } from './collapsible-item';
import { CoreCollapsibleFooterDirective } from './collapsible-footer';
import { CoreContentDirective } from './content';
import { CoreOnAppearDirective } from './on-appear';
import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-attributes';
import { CoreUserTourDirective } from './user-tour';
@NgModule({
declarations: [
@ -48,7 +48,6 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
CoreSupressEventsDirective,
CoreUserLinkDirective,
CoreAriaButtonClickDirective,
CoreOnAppearDirective,
CoreOnResizeDirective,
CoreDownloadFileDirective,
CoreCollapsibleHeaderDirective,
@ -57,6 +56,7 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
CoreCollapsibleFooterDirective,
CoreContentDirective,
CoreUpdateNonReactiveAttributesDirective,
CoreUserTourDirective,
],
exports: [
CoreAutoFocusDirective,
@ -70,7 +70,6 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
CoreSupressEventsDirective,
CoreUserLinkDirective,
CoreAriaButtonClickDirective,
CoreOnAppearDirective,
CoreOnResizeDirective,
CoreDownloadFileDirective,
CoreCollapsibleHeaderDirective,
@ -79,6 +78,7 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
CoreCollapsibleFooterDirective,
CoreContentDirective,
CoreUpdateNonReactiveAttributesDirective,
CoreUserTourDirective,
],
})
export class CoreDirectivesModule {}

View File

@ -93,6 +93,7 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy {
await CoreUserTours.showIfPending({
id: 'swipe-navigation',
component: CoreSwipeNavigationTourComponent,
watch: this.element,
});
}

View File

@ -12,20 +12,22 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Directive, ElementRef, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core';
import { CoreCancellablePromise } from '@classes/cancellable-promise';
import { CoreUserTours, CoreUserToursFocusedOptions, CoreUserToursUserTour } from '@features/usertours/services/user-tours';
import { CoreDom } from '@singletons/dom';
/**
* Directive to listen when an element becomes visible.
* Directive to control a User Tour linked to the lifecycle of the element where it's defined.
*/
@Directive({
selector: '[onAppear]',
selector: '[userTour]',
})
export class CoreOnAppearDirective implements OnInit, OnDestroy {
export class CoreUserTourDirective implements OnInit, OnDestroy {
@Output() onAppear = new EventEmitter();
@Input() userTour!: CoreUserTourDirectiveOptions;
private tour?: CoreUserToursUserTour | null;
private element: HTMLElement;
protected visiblePromise?: CoreCancellablePromise<void>;
@ -41,14 +43,35 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy {
await this.visiblePromise;
this.onAppear.emit();
const { getFocusedElement, ...options } = this.userTour;
this.tour = await CoreUserTours.showIfPending({
...options,
focus: getFocusedElement?.(this.element) ?? this.element,
});
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.tour?.cancel();
this.visiblePromise?.cancel();
}
}
/**
* User Tour options to control with this directive.
*/
export type CoreUserTourDirectiveOptions = Omit<CoreUserToursFocusedOptions, 'focus'> & {
/**
* Getter to obtain element to focus in the User Tour. If this isn't provided, the element where the
* directive is defined will be used.
*
* @param element Element where the directive is defined.
*/
getFocusedElement?(element: HTMLElement): HTMLElement;
};

View File

@ -1,4 +1,4 @@
<ion-button (click)="openBlocks()" (onAppear)="showTour()" [attr.aria-label]="'core.block.opendrawerblocks' | translate" color="secondary"
<ion-button (click)="openBlocks()" [userTour]="userTour" [attr.aria-label]="'core.block.opendrawerblocks' | translate" color="secondary"
#button>
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>

View File

@ -14,7 +14,8 @@
import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { CoreCancellablePromise } from '@classes/cancellable-promise';
import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
import { CoreUserTourDirectiveOptions } from '@directives/user-tour';
import { CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreDom } from '@singletons/dom';
import { CoreBlockSideBlocksTourComponent } from '../side-blocks-tour/side-blocks-tour';
@ -34,6 +35,14 @@ export class CoreBlockSideBlocksButtonComponent implements OnInit, OnDestroy {
@Input() instanceId!: number;
@ViewChild('button', { read: ElementRef }) button?: ElementRef<HTMLElement>;
userTour: CoreUserTourDirectiveOptions = {
id: 'side-blocks-button',
component: CoreBlockSideBlocksTourComponent,
side: CoreUserToursSide.Start,
alignment: CoreUserToursAlignment.Center,
getFocusedElement: nativeButton => nativeButton.shadowRoot?.children[0] as HTMLElement,
};
protected element: HTMLElement;
protected slotPromise?: CoreCancellablePromise<void>;
@ -61,25 +70,6 @@ export class CoreBlockSideBlocksButtonComponent implements OnInit, OnDestroy {
});
}
/**
* Show User Tour.
*/
async showTour(): Promise<void> {
const nativeButton = this.button?.nativeElement.shadowRoot?.children[0] as HTMLElement;
if (!nativeButton) {
return;
}
await CoreUserTours.showIfPending({
id: 'side-blocks-button',
component: CoreBlockSideBlocksTourComponent,
focus: nativeButton,
side: CoreUserToursSide.Start,
alignment: CoreUserToursAlignment.Center,
});
}
/**
* @inheritdoc
*/

View File

@ -55,7 +55,7 @@
<!-- Course Index button. -->
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && displayCourseIndex">
<ion-fab-button (click)="openCourseIndex()" (onAppear)="showCourseIndexTour()" [attr.aria-label]="'core.course.courseindex' | translate"
<ion-fab-button (click)="openCourseIndex()" [userTour]="courseIndexTour" [attr.aria-label]="'core.course.courseindex' | translate"
color="secondary" #courseIndexFab>
<ion-icon name="fas-list-ul" aria-hidden="true"></ion-icon>
<span class="sr-only">{{'core.course.courseindex' | translate }}</span>

View File

@ -46,9 +46,10 @@ import { CoreBlockHelper } from '@features/block/services/block-helper';
import { CoreNavigator } from '@services/navigator';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { CoreCourseViewedModulesDBRecord } from '@features/course/services/database/course';
import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
import { CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
import { CoreCourseCourseIndexTourComponent } from '../course-index-tour/course-index-tour';
import { CoreDom } from '@singletons/dom';
import { CoreUserTourDirectiveOptions } from '@directives/user-tour';
/**
* Component to display course contents using a certain format. If the format isn't found, use default one.
@ -87,6 +88,13 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
canLoadMore = false;
showSectionId = 0;
data: Record<string, unknown> = {}; // Data to pass to the components.
courseIndexTour: CoreUserTourDirectiveOptions = {
id: 'course-index',
component: CoreCourseCourseIndexTourComponent,
side: CoreUserToursSide.Top,
alignment: CoreUserToursAlignment.End,
getFocusedElement: nativeButton => nativeButton.shadowRoot?.children[0] as HTMLElement,
};
displayCourseIndex = false;
displayBlocks = false;
@ -166,25 +174,6 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
});
}
/**
* Show Course Index User Tour.
*/
async showCourseIndexTour(): Promise<void> {
const nativeButton = this.courseIndexFab?.nativeElement.shadowRoot?.children[0] as HTMLElement;
if (!nativeButton) {
return;
}
await CoreUserTours.showIfPending({
id: 'course-index',
component: CoreCourseCourseIndexTourComponent,
focus: nativeButton,
side: CoreUserToursSide.Top,
alignment: CoreUserToursAlignment.End,
});
}
/**
* Detect changes on input properties.
*/

View File

@ -1,4 +1,4 @@
<core-user-avatar *ngIf="isMainScreen && siteInfo" [user]="siteInfo" class="core-bar-button-image clickable" [linkProfile]="false"
(ariaButtonClick)="openUserMenu($event)" (onAppear)="showTour()" role="button" tabindex="0"
(ariaButtonClick)="openUserMenu($event)" [userTour]="userTour" role="button" tabindex="0"
[attr.aria-label]="'core.user.useraccount' | translate" #avatar>
</core-user-avatar>

View File

@ -14,7 +14,8 @@
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { CoreSiteInfo } from '@classes/site';
import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
import { CoreUserTourDirectiveOptions } from '@directives/user-tour';
import { CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
import { IonRouterOutlet } from '@ionic/angular';
import { CoreScreen } from '@services/screen';
import { CoreSites } from '@services/sites';
@ -36,6 +37,12 @@ export class CoreMainMenuUserButtonComponent implements OnInit {
siteInfo?: CoreSiteInfo;
isMainScreen = false;
userTour: CoreUserTourDirectiveOptions = {
id: 'user-menu',
component: CoreMainMenuUserMenuTourComponent,
alignment: CoreUserToursAlignment.Start,
side: CoreScreen.isMobile ? CoreUserToursSide.Start : CoreUserToursSide.End,
};
@ViewChild('avatar', { read: ElementRef }) avatar?: ElementRef<HTMLElement>;
@ -66,21 +73,4 @@ export class CoreMainMenuUserButtonComponent implements OnInit {
});
}
/**
* Show User Tour.
*/
async showTour(): Promise<void> {
if (!this.avatar) {
return;
}
await CoreUserTours.showIfPending({
id: 'user-menu',
component: CoreMainMenuUserMenuTourComponent,
focus: this.avatar.nativeElement,
alignment: CoreUserToursAlignment.Start,
side: CoreScreen.isMobile ? CoreUserToursSide.Start : CoreUserToursSide.End,
});
}
}

View File

@ -55,6 +55,7 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
popoverWrapperStyles?: string;
popoverWrapperArrowStyles?: string;
private element: HTMLElement;
private tour?: HTMLElement;
private wrapperTransform = '';
private wrapperElement = new CorePromisedValue<HTMLElement>();
private backButtonListener?: (event: BackButtonEvent) => void;
@ -77,14 +78,18 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
}
/**
* Present User Tour.
* Show User Tour.
*/
async present(): Promise<void> {
async show(): Promise<void> {
// Insert tour component and wait until it's ready.
const wrapper = await this.wrapperElement;
const tour = await AngularFrameworkDelegate.attachViewToDom(wrapper, this.component, this.componentProps ?? {});
this.tour = await AngularFrameworkDelegate.attachViewToDom(wrapper, this.component, this.componentProps ?? {});
await CoreDomUtils.waitForImages(tour);
if (!this.tour) {
return;
}
await CoreDomUtils.waitForImages(this.tour);
// Calculate focus styles or dismiss if the element is gone.
if (this.focus && !CoreDom.isElementVisible(this.focus)) {
@ -111,6 +116,19 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
await this.playEnterAnimation();
}
/**
* Hide User Tour temporarily.
*/
async hide(): Promise<void> {
const wrapper = await this.wrapperElement;
await this.playLeaveAnimation();
await AngularFrameworkDelegate.removeViewFromDom(wrapper, this.tour);
this.active = false;
this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
}
/**
* Dismiss User Tour.
*
@ -119,13 +137,15 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
async dismiss(acknowledge: boolean = true): Promise<void> {
this.beforeDismiss.emit();
await this.playLeaveAnimation();
await Promise.all<unknown>([
AngularFrameworkDelegate.removeViewFromDom(this.container, this.element),
acknowledge && CoreUserTours.acknowledge(this.id),
]);
if (this.active) {
await this.playLeaveAnimation();
await AngularFrameworkDelegate.removeViewFromDom(this.container, this.element);
this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
}
acknowledge && await CoreUserTours.acknowledge(this.id);
this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
this.afterDismiss.emit();
}

View File

@ -15,12 +15,14 @@
import { CoreConstants } from '@/core/constants';
import { asyncInstance } from '@/core/utils/async-instance';
import { Injectable } from '@angular/core';
import { CoreCancellablePromise } from '@classes/cancellable-promise';
import { CoreDatabaseTable } from '@classes/database/database-table';
import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy';
import { CoreApp } from '@services/app';
import { CoreUtils } from '@services/utils/utils';
import { AngularFrameworkDelegate, makeSingleton } from '@singletons';
import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreDom } from '@singletons/dom';
import { CoreSubscriptions } from '@singletons/subscriptions';
import { CoreUserToursUserTourComponent } from '../components/user-tour/user-tour';
import { APP_SCHEMA, CoreUserToursDBEntry, USER_TOURS_TABLE_NAME } from './database/user-tours';
@ -32,8 +34,7 @@ import { APP_SCHEMA, CoreUserToursDBEntry, USER_TOURS_TABLE_NAME } from './datab
export class CoreUserToursService {
protected table = asyncInstance<CoreDatabaseTable<CoreUserToursDBEntry>>();
protected tours: CoreUserToursUserTourComponent[] = [];
protected tourReadyCallbacks = new WeakMap<CoreUserToursUserTourComponent, () => void>();
protected tours: { component: CoreUserToursUserTourComponent; visible: boolean }[] = [];
/**
* Initialize database.
@ -84,14 +85,17 @@ export class CoreUserToursService {
* Show a User Tour if it's pending.
*
* @param options User Tour options.
* @returns User tour controller, if any.
*/
async showIfPending(options: CoreUserToursBasicOptions): Promise<void>;
async showIfPending(options: CoreUserToursFocusedOptions): Promise<void>;
async showIfPending(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<void> {
async showIfPending(options: CoreUserToursBasicOptions): Promise<CoreUserToursUserTour | null>;
async showIfPending(options: CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour | null>;
async showIfPending(
options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions,
): Promise<CoreUserToursUserTour | null> {
const isPending = await CoreUserTours.isPending(options.id);
if (!isPending) {
return;
return null;
}
return this.show(options);
@ -101,16 +105,15 @@ export class CoreUserToursService {
* Show a User Tour.
*
* @param options User Tour options.
* @returns User tour controller.
*/
protected async show(options: CoreUserToursBasicOptions): Promise<void>;
protected async show(options: CoreUserToursFocusedOptions): Promise<void>;
protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<void> {
protected async show(options: CoreUserToursBasicOptions): Promise<CoreUserToursUserTour>;
protected async show(options: CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour>;
protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour> {
const { delay, ...componentOptions } = options;
// Delay start.
await CoreUtils.wait(delay ?? 200);
// Create tour.
const container = document.querySelector('ion-app') ?? document.body;
const element = await AngularFrameworkDelegate.attachViewToDom(
container,
@ -119,26 +122,7 @@ export class CoreUserToursService {
);
const tour = CoreComponentsRegistry.require(element, CoreUserToursUserTourComponent);
this.tours.push(tour);
// Handle present/dismiss lifecycle.
CoreSubscriptions.once(tour.beforeDismiss, () => {
const index = this.tours.indexOf(tour);
if (index === -1) {
return;
}
this.tours.splice(index, 1);
const nextTour = this.tours[0] as CoreUserToursUserTourComponent | undefined;
nextTour?.present().then(() => this.tourReadyCallbacks.get(nextTour)?.());
});
this.tours.length > 1
? await new Promise<void>(resolve => this.tourReadyCallbacks.set(tour, resolve))
: await tour.present();
return this.startTour(tour, options.watch ?? (options as CoreUserToursFocusedOptions).focus);
}
/**
@ -147,13 +131,144 @@ export class CoreUserToursService {
* @param acknowledge Whether to acknowledge that the user has seen this User Tour or not.
*/
async dismiss(acknowledge: boolean = true): Promise<void> {
await this.tours[0]?.dismiss(acknowledge);
await this.tours.find(({ visible }) => visible)?.component.dismiss(acknowledge);
}
/**
* Activate a tour component and bind its lifecycle to an element if provided.
*
* @param tour User tour.
* @param watchElement Element to watch in order to update tour lifecycle.
* @returns User tour controller.
*/
protected startTour(tour: CoreUserToursUserTourComponent, watchElement?: HTMLElement | false): CoreUserToursUserTour {
if (!watchElement) {
this.activateTour(tour);
return {
cancel: () => tour.dismiss(false),
};
}
let unsubscribeVisible: (() => void) | undefined;
let visiblePromise: CoreCancellablePromise | undefined = CoreDom.waitToBeInViewport(watchElement);
// eslint-disable-next-line promise/catch-or-return, promise/always-return
visiblePromise.then(() => {
visiblePromise = undefined;
this.activateTour(tour);
unsubscribeVisible = CoreDom.watchElementInViewport(
watchElement,
visible => visible ? this.activateTour(tour) : this.deactivateTour(tour),
);
CoreSubscriptions.once(tour.beforeDismiss, () => {
unsubscribeVisible?.();
visiblePromise = undefined;
unsubscribeVisible = undefined;
});
});
return {
cancel: async () => {
visiblePromise?.cancel();
if (!unsubscribeVisible) {
return;
}
unsubscribeVisible();
await tour.dismiss(false);
},
};
}
/**
* Activate the given user tour.
*
* @param tour User tour.
*/
protected activateTour(tour: CoreUserToursUserTourComponent): void {
// Handle show/dismiss lifecycle.
CoreSubscriptions.once(tour.beforeDismiss, () => {
const index = this.tours.findIndex(({ component }) => component === tour);
if (index === -1) {
return;
}
this.tours.splice(index, 1);
const foregroundTour = this.tours.find(({ visible }) => visible);
foregroundTour?.component.show();
});
// Add to existing tours and show it if it's on top.
const index = this.tours.findIndex(({ component }) => component === tour);
const foregroundTour = this.tours.find(({ visible }) => visible);
if (index !== -1) {
this.tours[index].visible = true;
} else {
this.tours.push({
visible: true,
component: tour,
});
}
if (this.tours.find(({ visible }) => visible)?.component !== tour) {
return;
}
foregroundTour?.component.hide();
tour.show();
}
/**
* Hide User Tour if visible.
*
* @param tour User tour.
*/
protected deactivateTour(tour: CoreUserToursUserTourComponent): void {
const index = this.tours.findIndex(({ component }) => component === tour);
const foregroundTourIndex = this.tours.findIndex(({ visible }) => visible);
if (index === -1) {
return;
}
this.tours[index].visible = false;
if (index === foregroundTourIndex) {
tour.hide();
this.tours.find(({ visible }) => visible)?.component.show();
}
}
}
export const CoreUserTours = makeSingleton(CoreUserToursService);
/**
* User Tour controller.
*/
export interface CoreUserToursUserTour {
/**
* Cancelling a User Tours removed it from the queue if it was pending or dimisses it without
* acknowledging if it existed.
*/
cancel(): Promise<void>;
}
/**
* User Tour side.
*/
@ -202,6 +317,13 @@ export interface CoreUserToursBasicOptions {
*/
delay?: number;
/**
* Whether to watch an element to bind the User Tour lifecycle. Whenever this element appears or
* leaves the screen, the user tour will do it as well. Focused user tours do it by default with
* the focused element, but it can be disabled by explicitly using `false` here.
*/
watch?: HTMLElement | false;
}
/**

View File

@ -354,6 +354,62 @@ export class CoreDom {
);
}
/**
* Watch whenever an elements visibility changes within the viewport.
*
* @param element Element to watch.
* @param intersectionRatio Intersection ratio (From 0 to 1).
* @param callback Callback when visibility changes.
* @return Function to stop watching.
*/
static watchElementInViewport(
element: HTMLElement,
intersectionRatio: number,
callback: (visible: boolean) => void,
): () => void;
/**
* Watch whenever an elements visibility changes within the viewport.
*
* @param element Element to watch.
* @param callback Callback when visibility changes.
* @return Function to stop watching.
*/
static watchElementInViewport(element: HTMLElement, callback: (visible: boolean) => void): () => void;
static watchElementInViewport(
element: HTMLElement,
intersectionRatioOrCallback: number | ((visible: boolean) => void),
callback?: (visible: boolean) => void,
): () => void {
const visibleCallback = callback ?? intersectionRatioOrCallback as (visible: boolean) => void;
const intersectionRatio = typeof intersectionRatioOrCallback === 'number' ? intersectionRatioOrCallback : 1;
let visible = CoreDom.isElementInViewport(element, intersectionRatio);
const setVisible = (newValue: boolean) => {
if (visible === newValue) {
return;
}
visible = newValue;
visibleCallback(visible);
};
if (!('IntersectionObserver' in window)) {
const interval = setInterval(() => setVisible(CoreDom.isElementInViewport(element, intersectionRatio)), 50);
return () => clearInterval(interval);
}
const observer = new IntersectionObserver(([{ isIntersecting, intersectionRatio }]) => {
setVisible(isIntersecting && intersectionRatio >= intersectionRatio);
});
observer.observe(element);
return () => observer.disconnect();
}
/**
* Wait an element to be in dom and visible.
*
@ -362,48 +418,29 @@ export class CoreDom {
* @return Cancellable promise.
*/
static waitToBeInViewport(element: HTMLElement, intersectionRatio = 1): CoreCancellablePromise<void> {
let unsubscribe: (() => void) | undefined;
const visiblePromise = CoreDom.waitToBeVisible(element);
let intersectionObserver: IntersectionObserver;
let interval: number | undefined;
return new CoreCancellablePromise<void>(
async (resolve) => {
await visiblePromise;
if (CoreDom.isElementInViewport(element, intersectionRatio)) {
return resolve();
}
if ('IntersectionObserver' in window) {
intersectionObserver = new IntersectionObserver((observerEntries) => {
const isIntersecting = observerEntries
.some((entry) => entry.isIntersecting && entry.intersectionRatio >= intersectionRatio);
if (!isIntersecting) {
return;
}
unsubscribe = this.watchElementInViewport(element, intersectionRatio, inViewport => {
if (!inViewport) {
return;
}
resolve();
intersectionObserver?.disconnect();
});
intersectionObserver.observe(element);
} else {
interval = window.setInterval(() => {
if (!CoreDom.isElementInViewport(element, intersectionRatio)) {
return;
}
resolve();
window.clearInterval(interval);
}, 50);
}
resolve();
unsubscribe?.();
});
},
() => {
visiblePromise.cancel();
intersectionObserver?.disconnect();
window.clearInterval(interval);
unsubscribe?.();
},
);
}