MOBILE-3833 usertours: Watch elements visibility
parent
771ee90d6a
commit
35433615f3
|
@ -32,8 +32,8 @@ import { CoreSwipeNavigationDirective } from './swipe-navigation';
|
||||||
import { CoreCollapsibleItemDirective } from './collapsible-item';
|
import { CoreCollapsibleItemDirective } from './collapsible-item';
|
||||||
import { CoreCollapsibleFooterDirective } from './collapsible-footer';
|
import { CoreCollapsibleFooterDirective } from './collapsible-footer';
|
||||||
import { CoreContentDirective } from './content';
|
import { CoreContentDirective } from './content';
|
||||||
import { CoreOnAppearDirective } from './on-appear';
|
|
||||||
import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-attributes';
|
import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-attributes';
|
||||||
|
import { CoreUserTourDirective } from './user-tour';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
|
@ -48,7 +48,6 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
|
||||||
CoreSupressEventsDirective,
|
CoreSupressEventsDirective,
|
||||||
CoreUserLinkDirective,
|
CoreUserLinkDirective,
|
||||||
CoreAriaButtonClickDirective,
|
CoreAriaButtonClickDirective,
|
||||||
CoreOnAppearDirective,
|
|
||||||
CoreOnResizeDirective,
|
CoreOnResizeDirective,
|
||||||
CoreDownloadFileDirective,
|
CoreDownloadFileDirective,
|
||||||
CoreCollapsibleHeaderDirective,
|
CoreCollapsibleHeaderDirective,
|
||||||
|
@ -57,6 +56,7 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
|
||||||
CoreCollapsibleFooterDirective,
|
CoreCollapsibleFooterDirective,
|
||||||
CoreContentDirective,
|
CoreContentDirective,
|
||||||
CoreUpdateNonReactiveAttributesDirective,
|
CoreUpdateNonReactiveAttributesDirective,
|
||||||
|
CoreUserTourDirective,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CoreAutoFocusDirective,
|
CoreAutoFocusDirective,
|
||||||
|
@ -70,7 +70,6 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
|
||||||
CoreSupressEventsDirective,
|
CoreSupressEventsDirective,
|
||||||
CoreUserLinkDirective,
|
CoreUserLinkDirective,
|
||||||
CoreAriaButtonClickDirective,
|
CoreAriaButtonClickDirective,
|
||||||
CoreOnAppearDirective,
|
|
||||||
CoreOnResizeDirective,
|
CoreOnResizeDirective,
|
||||||
CoreDownloadFileDirective,
|
CoreDownloadFileDirective,
|
||||||
CoreCollapsibleHeaderDirective,
|
CoreCollapsibleHeaderDirective,
|
||||||
|
@ -79,6 +78,7 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
|
||||||
CoreCollapsibleFooterDirective,
|
CoreCollapsibleFooterDirective,
|
||||||
CoreContentDirective,
|
CoreContentDirective,
|
||||||
CoreUpdateNonReactiveAttributesDirective,
|
CoreUpdateNonReactiveAttributesDirective,
|
||||||
|
CoreUserTourDirective,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreDirectivesModule {}
|
export class CoreDirectivesModule {}
|
||||||
|
|
|
@ -93,6 +93,7 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy {
|
||||||
await CoreUserTours.showIfPending({
|
await CoreUserTours.showIfPending({
|
||||||
id: 'swipe-navigation',
|
id: 'swipe-navigation',
|
||||||
component: CoreSwipeNavigationTourComponent,
|
component: CoreSwipeNavigationTourComponent,
|
||||||
|
watch: this.element,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,20 +12,22 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// 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 { CoreCancellablePromise } from '@classes/cancellable-promise';
|
||||||
|
import { CoreUserTours, CoreUserToursFocusedOptions, CoreUserToursUserTour } from '@features/usertours/services/user-tours';
|
||||||
import { CoreDom } from '@singletons/dom';
|
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({
|
@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;
|
private element: HTMLElement;
|
||||||
protected visiblePromise?: CoreCancellablePromise<void>;
|
protected visiblePromise?: CoreCancellablePromise<void>;
|
||||||
|
|
||||||
|
@ -41,14 +43,35 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy {
|
||||||
|
|
||||||
await this.visiblePromise;
|
await this.visiblePromise;
|
||||||
|
|
||||||
this.onAppear.emit();
|
const { getFocusedElement, ...options } = this.userTour;
|
||||||
|
|
||||||
|
this.tour = await CoreUserTours.showIfPending({
|
||||||
|
...options,
|
||||||
|
focus: getFocusedElement?.(this.element) ?? this.element,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
this.tour?.cancel();
|
||||||
this.visiblePromise?.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;
|
||||||
|
|
||||||
|
};
|
|
@ -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>
|
#button>
|
||||||
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
|
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
|
|
|
@ -14,7 +14,8 @@
|
||||||
|
|
||||||
import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||||
import { CoreCancellablePromise } from '@classes/cancellable-promise';
|
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 { CoreDomUtils } from '@services/utils/dom';
|
||||||
import { CoreDom } from '@singletons/dom';
|
import { CoreDom } from '@singletons/dom';
|
||||||
import { CoreBlockSideBlocksTourComponent } from '../side-blocks-tour/side-blocks-tour';
|
import { CoreBlockSideBlocksTourComponent } from '../side-blocks-tour/side-blocks-tour';
|
||||||
|
@ -34,6 +35,14 @@ export class CoreBlockSideBlocksButtonComponent implements OnInit, OnDestroy {
|
||||||
@Input() instanceId!: number;
|
@Input() instanceId!: number;
|
||||||
@ViewChild('button', { read: ElementRef }) button?: ElementRef<HTMLElement>;
|
@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 element: HTMLElement;
|
||||||
protected slotPromise?: CoreCancellablePromise<void>;
|
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
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -55,7 +55,7 @@
|
||||||
|
|
||||||
<!-- Course Index button. -->
|
<!-- Course Index button. -->
|
||||||
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && displayCourseIndex">
|
<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>
|
color="secondary" #courseIndexFab>
|
||||||
<ion-icon name="fas-list-ul" aria-hidden="true"></ion-icon>
|
<ion-icon name="fas-list-ul" aria-hidden="true"></ion-icon>
|
||||||
<span class="sr-only">{{'core.course.courseindex' | translate }}</span>
|
<span class="sr-only">{{'core.course.courseindex' | translate }}</span>
|
||||||
|
|
|
@ -46,9 +46,10 @@ import { CoreBlockHelper } from '@features/block/services/block-helper';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
import { CoreNavigator } from '@services/navigator';
|
||||||
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
|
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
|
||||||
import { CoreCourseViewedModulesDBRecord } from '@features/course/services/database/course';
|
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 { CoreCourseCourseIndexTourComponent } from '../course-index-tour/course-index-tour';
|
||||||
import { CoreDom } from '@singletons/dom';
|
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.
|
* 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;
|
canLoadMore = false;
|
||||||
showSectionId = 0;
|
showSectionId = 0;
|
||||||
data: Record<string, unknown> = {}; // Data to pass to the components.
|
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;
|
displayCourseIndex = false;
|
||||||
displayBlocks = 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.
|
* Detect changes on input properties.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<core-user-avatar *ngIf="isMainScreen && siteInfo" [user]="siteInfo" class="core-bar-button-image clickable" [linkProfile]="false"
|
<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>
|
[attr.aria-label]="'core.user.useraccount' | translate" #avatar>
|
||||||
</core-user-avatar>
|
</core-user-avatar>
|
||||||
|
|
|
@ -14,7 +14,8 @@
|
||||||
|
|
||||||
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
||||||
import { CoreSiteInfo } from '@classes/site';
|
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 { IonRouterOutlet } from '@ionic/angular';
|
||||||
import { CoreScreen } from '@services/screen';
|
import { CoreScreen } from '@services/screen';
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
|
@ -36,6 +37,12 @@ export class CoreMainMenuUserButtonComponent implements OnInit {
|
||||||
|
|
||||||
siteInfo?: CoreSiteInfo;
|
siteInfo?: CoreSiteInfo;
|
||||||
isMainScreen = false;
|
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>;
|
@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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,6 +55,7 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
|
||||||
popoverWrapperStyles?: string;
|
popoverWrapperStyles?: string;
|
||||||
popoverWrapperArrowStyles?: string;
|
popoverWrapperArrowStyles?: string;
|
||||||
private element: HTMLElement;
|
private element: HTMLElement;
|
||||||
|
private tour?: HTMLElement;
|
||||||
private wrapperTransform = '';
|
private wrapperTransform = '';
|
||||||
private wrapperElement = new CorePromisedValue<HTMLElement>();
|
private wrapperElement = new CorePromisedValue<HTMLElement>();
|
||||||
private backButtonListener?: (event: BackButtonEvent) => void;
|
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.
|
// Insert tour component and wait until it's ready.
|
||||||
const wrapper = await this.wrapperElement;
|
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.
|
// Calculate focus styles or dismiss if the element is gone.
|
||||||
if (this.focus && !CoreDom.isElementVisible(this.focus)) {
|
if (this.focus && !CoreDom.isElementVisible(this.focus)) {
|
||||||
|
@ -111,6 +116,19 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
|
||||||
await this.playEnterAnimation();
|
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.
|
* Dismiss User Tour.
|
||||||
*
|
*
|
||||||
|
@ -119,13 +137,15 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
|
||||||
async dismiss(acknowledge: boolean = true): Promise<void> {
|
async dismiss(acknowledge: boolean = true): Promise<void> {
|
||||||
this.beforeDismiss.emit();
|
this.beforeDismiss.emit();
|
||||||
|
|
||||||
await this.playLeaveAnimation();
|
if (this.active) {
|
||||||
await Promise.all<unknown>([
|
await this.playLeaveAnimation();
|
||||||
AngularFrameworkDelegate.removeViewFromDom(this.container, this.element),
|
await AngularFrameworkDelegate.removeViewFromDom(this.container, this.element);
|
||||||
acknowledge && CoreUserTours.acknowledge(this.id),
|
|
||||||
]);
|
this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
acknowledge && await CoreUserTours.acknowledge(this.id);
|
||||||
|
|
||||||
this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
|
|
||||||
this.afterDismiss.emit();
|
this.afterDismiss.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,12 +15,14 @@
|
||||||
import { CoreConstants } from '@/core/constants';
|
import { CoreConstants } from '@/core/constants';
|
||||||
import { asyncInstance } from '@/core/utils/async-instance';
|
import { asyncInstance } from '@/core/utils/async-instance';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CoreCancellablePromise } from '@classes/cancellable-promise';
|
||||||
import { CoreDatabaseTable } from '@classes/database/database-table';
|
import { CoreDatabaseTable } from '@classes/database/database-table';
|
||||||
import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy';
|
import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy';
|
||||||
import { CoreApp } from '@services/app';
|
import { CoreApp } from '@services/app';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { AngularFrameworkDelegate, makeSingleton } from '@singletons';
|
import { AngularFrameworkDelegate, makeSingleton } from '@singletons';
|
||||||
import { CoreComponentsRegistry } from '@singletons/components-registry';
|
import { CoreComponentsRegistry } from '@singletons/components-registry';
|
||||||
|
import { CoreDom } from '@singletons/dom';
|
||||||
import { CoreSubscriptions } from '@singletons/subscriptions';
|
import { CoreSubscriptions } from '@singletons/subscriptions';
|
||||||
import { CoreUserToursUserTourComponent } from '../components/user-tour/user-tour';
|
import { CoreUserToursUserTourComponent } from '../components/user-tour/user-tour';
|
||||||
import { APP_SCHEMA, CoreUserToursDBEntry, USER_TOURS_TABLE_NAME } from './database/user-tours';
|
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 {
|
export class CoreUserToursService {
|
||||||
|
|
||||||
protected table = asyncInstance<CoreDatabaseTable<CoreUserToursDBEntry>>();
|
protected table = asyncInstance<CoreDatabaseTable<CoreUserToursDBEntry>>();
|
||||||
protected tours: CoreUserToursUserTourComponent[] = [];
|
protected tours: { component: CoreUserToursUserTourComponent; visible: boolean }[] = [];
|
||||||
protected tourReadyCallbacks = new WeakMap<CoreUserToursUserTourComponent, () => void>();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize database.
|
* Initialize database.
|
||||||
|
@ -84,14 +85,17 @@ export class CoreUserToursService {
|
||||||
* Show a User Tour if it's pending.
|
* Show a User Tour if it's pending.
|
||||||
*
|
*
|
||||||
* @param options User Tour options.
|
* @param options User Tour options.
|
||||||
|
* @returns User tour controller, if any.
|
||||||
*/
|
*/
|
||||||
async showIfPending(options: CoreUserToursBasicOptions): Promise<void>;
|
async showIfPending(options: CoreUserToursBasicOptions): Promise<CoreUserToursUserTour | null>;
|
||||||
async showIfPending(options: CoreUserToursFocusedOptions): Promise<void>;
|
async showIfPending(options: CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour | null>;
|
||||||
async showIfPending(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<void> {
|
async showIfPending(
|
||||||
|
options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions,
|
||||||
|
): Promise<CoreUserToursUserTour | null> {
|
||||||
const isPending = await CoreUserTours.isPending(options.id);
|
const isPending = await CoreUserTours.isPending(options.id);
|
||||||
|
|
||||||
if (!isPending) {
|
if (!isPending) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.show(options);
|
return this.show(options);
|
||||||
|
@ -101,16 +105,15 @@ export class CoreUserToursService {
|
||||||
* Show a User Tour.
|
* Show a User Tour.
|
||||||
*
|
*
|
||||||
* @param options User Tour options.
|
* @param options User Tour options.
|
||||||
|
* @returns User tour controller.
|
||||||
*/
|
*/
|
||||||
protected async show(options: CoreUserToursBasicOptions): Promise<void>;
|
protected async show(options: CoreUserToursBasicOptions): Promise<CoreUserToursUserTour>;
|
||||||
protected async show(options: CoreUserToursFocusedOptions): Promise<void>;
|
protected async show(options: CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour>;
|
||||||
protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<void> {
|
protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour> {
|
||||||
const { delay, ...componentOptions } = options;
|
const { delay, ...componentOptions } = options;
|
||||||
|
|
||||||
// Delay start.
|
|
||||||
await CoreUtils.wait(delay ?? 200);
|
await CoreUtils.wait(delay ?? 200);
|
||||||
|
|
||||||
// Create tour.
|
|
||||||
const container = document.querySelector('ion-app') ?? document.body;
|
const container = document.querySelector('ion-app') ?? document.body;
|
||||||
const element = await AngularFrameworkDelegate.attachViewToDom(
|
const element = await AngularFrameworkDelegate.attachViewToDom(
|
||||||
container,
|
container,
|
||||||
|
@ -119,26 +122,7 @@ export class CoreUserToursService {
|
||||||
);
|
);
|
||||||
const tour = CoreComponentsRegistry.require(element, CoreUserToursUserTourComponent);
|
const tour = CoreComponentsRegistry.require(element, CoreUserToursUserTourComponent);
|
||||||
|
|
||||||
this.tours.push(tour);
|
return this.startTour(tour, options.watch ?? (options as CoreUserToursFocusedOptions).focus);
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -147,13 +131,144 @@ export class CoreUserToursService {
|
||||||
* @param acknowledge Whether to acknowledge that the user has seen this User Tour or not.
|
* @param acknowledge Whether to acknowledge that the user has seen this User Tour or not.
|
||||||
*/
|
*/
|
||||||
async dismiss(acknowledge: boolean = true): Promise<void> {
|
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);
|
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.
|
* User Tour side.
|
||||||
*/
|
*/
|
||||||
|
@ -202,6 +317,13 @@ export interface CoreUserToursBasicOptions {
|
||||||
*/
|
*/
|
||||||
delay?: number;
|
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;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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.
|
* Wait an element to be in dom and visible.
|
||||||
*
|
*
|
||||||
|
@ -362,48 +418,29 @@ export class CoreDom {
|
||||||
* @return Cancellable promise.
|
* @return Cancellable promise.
|
||||||
*/
|
*/
|
||||||
static waitToBeInViewport(element: HTMLElement, intersectionRatio = 1): CoreCancellablePromise<void> {
|
static waitToBeInViewport(element: HTMLElement, intersectionRatio = 1): CoreCancellablePromise<void> {
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
const visiblePromise = CoreDom.waitToBeVisible(element);
|
const visiblePromise = CoreDom.waitToBeVisible(element);
|
||||||
|
|
||||||
let intersectionObserver: IntersectionObserver;
|
|
||||||
let interval: number | undefined;
|
|
||||||
|
|
||||||
return new CoreCancellablePromise<void>(
|
return new CoreCancellablePromise<void>(
|
||||||
async (resolve) => {
|
async (resolve) => {
|
||||||
await visiblePromise;
|
await visiblePromise;
|
||||||
|
|
||||||
if (CoreDom.isElementInViewport(element, intersectionRatio)) {
|
if (CoreDom.isElementInViewport(element, intersectionRatio)) {
|
||||||
|
|
||||||
return resolve();
|
return resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('IntersectionObserver' in window) {
|
unsubscribe = this.watchElementInViewport(element, intersectionRatio, inViewport => {
|
||||||
intersectionObserver = new IntersectionObserver((observerEntries) => {
|
if (!inViewport) {
|
||||||
const isIntersecting = observerEntries
|
return;
|
||||||
.some((entry) => entry.isIntersecting && entry.intersectionRatio >= intersectionRatio);
|
}
|
||||||
if (!isIntersecting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve();
|
resolve();
|
||||||
intersectionObserver?.disconnect();
|
unsubscribe?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
intersectionObserver.observe(element);
|
|
||||||
} else {
|
|
||||||
interval = window.setInterval(() => {
|
|
||||||
if (!CoreDom.isElementInViewport(element, intersectionRatio)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
window.clearInterval(interval);
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
visiblePromise.cancel();
|
visiblePromise.cancel();
|
||||||
intersectionObserver?.disconnect();
|
unsubscribe?.();
|
||||||
window.clearInterval(interval);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue