From 35433615f3b6cc3400bf21499f1e131ce8e2f475 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 30 Mar 2022 14:33:07 +0200 Subject: [PATCH] MOBILE-3833 usertours: Watch elements visibility --- src/core/directives/directives.module.ts | 6 +- src/core/directives/swipe-navigation.ts | 1 + .../directives/{on-appear.ts => user-tour.ts} | 35 +++- .../side-blocks-button.html | 2 +- .../side-blocks-button/side-blocks-button.ts | 30 +-- .../course-format/course-format.html | 2 +- .../components/course-format/course-format.ts | 29 +-- .../user-menu-button/user-menu-button.html | 2 +- .../user-menu-button/user-menu-button.ts | 26 +-- .../components/user-tour/user-tour.ts | 40 +++- .../features/usertours/services/user-tours.ts | 186 +++++++++++++++--- src/core/singletons/dom.ts | 93 ++++++--- 12 files changed, 312 insertions(+), 140 deletions(-) rename src/core/directives/{on-appear.ts => user-tour.ts} (50%) diff --git a/src/core/directives/directives.module.ts b/src/core/directives/directives.module.ts index 3a89d007f..d004ce3cd 100644 --- a/src/core/directives/directives.module.ts +++ b/src/core/directives/directives.module.ts @@ -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 {} diff --git a/src/core/directives/swipe-navigation.ts b/src/core/directives/swipe-navigation.ts index ee2f6c6a1..b2aa69c6b 100644 --- a/src/core/directives/swipe-navigation.ts +++ b/src/core/directives/swipe-navigation.ts @@ -93,6 +93,7 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy { await CoreUserTours.showIfPending({ id: 'swipe-navigation', component: CoreSwipeNavigationTourComponent, + watch: this.element, }); } diff --git a/src/core/directives/on-appear.ts b/src/core/directives/user-tour.ts similarity index 50% rename from src/core/directives/on-appear.ts rename to src/core/directives/user-tour.ts index 4dff653c9..04d3636ea 100644 --- a/src/core/directives/on-appear.ts +++ b/src/core/directives/user-tour.ts @@ -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; @@ -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 & { + + /** + * 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; + +}; diff --git a/src/core/features/block/components/side-blocks-button/side-blocks-button.html b/src/core/features/block/components/side-blocks-button/side-blocks-button.html index 13cd1b7fe..69fdcdf8f 100644 --- a/src/core/features/block/components/side-blocks-button/side-blocks-button.html +++ b/src/core/features/block/components/side-blocks-button/side-blocks-button.html @@ -1,4 +1,4 @@ - diff --git a/src/core/features/block/components/side-blocks-button/side-blocks-button.ts b/src/core/features/block/components/side-blocks-button/side-blocks-button.ts index 3bec41f02..03efee0ae 100644 --- a/src/core/features/block/components/side-blocks-button/side-blocks-button.ts +++ b/src/core/features/block/components/side-blocks-button/side-blocks-button.ts @@ -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; + 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; @@ -61,25 +70,6 @@ export class CoreBlockSideBlocksButtonComponent implements OnInit, OnDestroy { }); } - /** - * Show User Tour. - */ - async showTour(): Promise { - 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 */ diff --git a/src/core/features/course/components/course-format/course-format.html b/src/core/features/course/components/course-format/course-format.html index 323c1dbfc..4b86d5ce7 100644 --- a/src/core/features/course/components/course-format/course-format.html +++ b/src/core/features/course/components/course-format/course-format.html @@ -55,7 +55,7 @@ - {{'core.course.courseindex' | translate }} diff --git a/src/core/features/course/components/course-format/course-format.ts b/src/core/features/course/components/course-format/course-format.ts index c1bee68f6..ca8f8b29c 100644 --- a/src/core/features/course/components/course-format/course-format.ts +++ b/src/core/features/course/components/course-format/course-format.ts @@ -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 = {}; // 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 { - 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. */ diff --git a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html index bd0e01204..e24ded5c4 100644 --- a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html +++ b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.html @@ -1,4 +1,4 @@ diff --git a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.ts b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.ts index 8c8efb728..54e11f46f 100644 --- a/src/core/features/mainmenu/components/user-menu-button/user-menu-button.ts +++ b/src/core/features/mainmenu/components/user-menu-button/user-menu-button.ts @@ -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; @@ -66,21 +73,4 @@ export class CoreMainMenuUserButtonComponent implements OnInit { }); } - /** - * Show User Tour. - */ - async showTour(): Promise { - 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, - }); - } - } diff --git a/src/core/features/usertours/components/user-tour/user-tour.ts b/src/core/features/usertours/components/user-tour/user-tour.ts index fae112402..fc32a9f99 100644 --- a/src/core/features/usertours/components/user-tour/user-tour.ts +++ b/src/core/features/usertours/components/user-tour/user-tour.ts @@ -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(); private backButtonListener?: (event: BackButtonEvent) => void; @@ -77,14 +78,18 @@ export class CoreUserToursUserTourComponent implements AfterViewInit { } /** - * Present User Tour. + * Show User Tour. */ - async present(): Promise { + async show(): Promise { // 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 { + 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 { this.beforeDismiss.emit(); - await this.playLeaveAnimation(); - await Promise.all([ - 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(); } diff --git a/src/core/features/usertours/services/user-tours.ts b/src/core/features/usertours/services/user-tours.ts index 749287f1b..fac59c705 100644 --- a/src/core/features/usertours/services/user-tours.ts +++ b/src/core/features/usertours/services/user-tours.ts @@ -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>(); - protected tours: CoreUserToursUserTourComponent[] = []; - protected tourReadyCallbacks = new WeakMap 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; - async showIfPending(options: CoreUserToursFocusedOptions): Promise; - async showIfPending(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise { + async showIfPending(options: CoreUserToursBasicOptions): Promise; + async showIfPending(options: CoreUserToursFocusedOptions): Promise; + async showIfPending( + options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions, + ): Promise { 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; - protected async show(options: CoreUserToursFocusedOptions): Promise; - protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise { + protected async show(options: CoreUserToursBasicOptions): Promise; + protected async show(options: CoreUserToursFocusedOptions): Promise; + protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise { 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(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 { - 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; + +} + /** * 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; + } /** diff --git a/src/core/singletons/dom.ts b/src/core/singletons/dom.ts index 35306cc12..914cc6ff2 100644 --- a/src/core/singletons/dom.ts +++ b/src/core/singletons/dom.ts @@ -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 { + let unsubscribe: (() => void) | undefined; const visiblePromise = CoreDom.waitToBeVisible(element); - let intersectionObserver: IntersectionObserver; - let interval: number | undefined; - return new CoreCancellablePromise( 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?.(); }, ); }