Merge pull request #3172 from crazyserver/MOBILE-3814

Mobile 3814
main
Dani Palou 2022-03-14 11:10:16 +01:00 committed by GitHub
commit ab3d55529b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 270 additions and 180 deletions

View File

@ -60,24 +60,23 @@
[instanceId]="database.coursemodule" component="mod_data" [itemId]="entry.id" area="database_entry" [courseId]="courseId" [instanceId]="database.coursemodule" component="mod_data" [itemId]="entry.id" area="database_entry" [courseId]="courseId"
(onLoading)="setLoadingComments($event)" [showItem]="true"> (onLoading)="setLoadingComments($event)" [showItem]="true">
</core-comments> </core-comments>
<div collapsible-footer *ngIf="entryLoaded && hasPrevious || hasNext" slot="fixed" appearOnBottom>
<ion-row class="ion-justify-content-between ion-align-items-center ion-no-padding ion-wrap">
<ion-col class="ion-text-start ion-no-padding core-navigation-arrow" size="auto">
<ion-button [disabled]="!hasPrevious" fill="clear" [attr.aria-label]="'core.previous' | translate"
(click)="gotoEntry(offset! -1)">
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-col>
<ion-col class="ion-text-center">
</ion-col>
<ion-col class="ion-text-end ion-no-padding core-navigation-arrow" size="auto">
<ion-button [disabled]="!hasNext" fill="clear" [attr.aria-label]=" 'core.next' | translate"
(click)="gotoEntry(offset! + 1)">
<ion-icon name="fas-chevron-right" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-col>
</ion-row>
</div>
</core-loading> </core-loading>
<div collapsible-footer *ngIf="entryLoaded && hasPrevious || hasNext" slot="fixed" appearOnBottom>
<ion-row class="ion-justify-content-between ion-align-items-center ion-no-padding ion-wrap">
<ion-col class="ion-text-start ion-no-padding core-navigation-arrow" size="auto">
<ion-button [disabled]="!hasPrevious" fill="clear" [attr.aria-label]="'core.previous' | translate"
(click)="gotoEntry(offset! -1)">
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-col>
<ion-col class="ion-text-center">
</ion-col>
<ion-col class="ion-text-end ion-no-padding core-navigation-arrow" size="auto">
<ion-button [disabled]="!hasNext" fill="clear" [attr.aria-label]=" 'core.next' | translate"
(click)="gotoEntry(offset! + 1)">
<ion-icon name="fas-chevron-right" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-col>
</ion-row>
</div>
</ion-content> </ion-content>

View File

@ -18,6 +18,8 @@ import { CoreEventLoadingChangedData, CoreEvents } from '@singletons/events';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreAnimations } from '@components/animations'; import { CoreAnimations } from '@components/animations';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CorePromisedValue } from '@classes/promised-value';
/** /**
* Component to show a loading spinner and message while data is being loaded. * Component to show a loading spinner and message while data is being loaded.
@ -56,16 +58,18 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit {
uniqueId: string; uniqueId: string;
protected element: HTMLElement; // Current element. protected element: HTMLElement; // Current element.
loaded = false; // Only comes true once. loaded = false; // Only comes true once.
protected firstLoadedPromise = new CorePromisedValue<string>();
constructor(element: ElementRef) { constructor(element: ElementRef) {
this.element = element.nativeElement; this.element = element.nativeElement;
CoreComponentsRegistry.register(this.element, this);
// Calculate the unique ID. // Calculate the unique ID.
this.uniqueId = 'core-loading-content-' + CoreUtils.getUniqueId('CoreLoadingComponent'); this.uniqueId = 'core-loading-content-' + CoreUtils.getUniqueId('CoreLoadingComponent');
} }
/** /**
* Component being initialized. * @inheritdoc
*/ */
ngOnInit(): void { ngOnInit(): void {
if (!this.message) { if (!this.message) {
@ -77,50 +81,54 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit {
} }
/** /**
* View has been initialized. * @inheritdoc
*/ */
ngAfterViewInit(): void { ngAfterViewInit(): void {
// Add class if loaded on init. this.changeState(!!this.hideUntil);
if (this.hideUntil) {
this.element.classList.add('core-loading-loaded');
}
this.loaded = !!this.hideUntil;
this.content?.nativeElement.classList.toggle('core-loading-content', !!this.hideUntil);
} }
/** /**
* Component input changed. * @inheritdoc
*
* @param changes Changes.
*/ */
ngOnChanges(changes: { [name: string]: SimpleChange }): void { ngOnChanges(changes: { [name: string]: SimpleChange }): void {
if (changes.hideUntil) { if (changes.hideUntil) {
if (!this.loaded) { this.changeState(!!this.hideUntil);
this.loaded = !!this.hideUntil; // Only comes true once.
}
if (this.hideUntil) {
setTimeout(() => {
// Content is loaded so, center the spinner on the content itself.
this.element.classList.add('core-loading-loaded');
// Change CSS to force calculate height.
// Removed 500ms timeout to avoid reallocating html.
this.content?.nativeElement.classList.add('core-loading-content');
});
} else {
this.element.classList.remove('core-loading-loaded');
this.content?.nativeElement.classList.remove('core-loading-content');
}
// Trigger the event after a timeout since the elements inside ngIf haven't been added to DOM yet.
setTimeout(() => {
CoreEvents.trigger(CoreEvents.CORE_LOADING_CHANGED, <CoreEventLoadingChangedData> {
loaded: !!this.hideUntil,
uniqueId: this.uniqueId,
});
});
} }
} }
/**
* Change loaded state.
*
* @param loaded True to load, false otherwise.
* @return Promise resolved when done.
*/
async changeState(loaded: boolean): Promise<void> {
await CoreUtils.nextTick();
this.element.classList.toggle('core-loading-loaded', loaded);
this.content?.nativeElement.classList.add('core-loading-content', loaded);
await CoreUtils.nextTick();
// Wait for next tick before triggering the event to make sure ngIf elements have been added to the DOM.
CoreEvents.trigger(CoreEvents.CORE_LOADING_CHANGED, <CoreEventLoadingChangedData> {
loaded: loaded,
uniqueId: this.uniqueId,
});
if (!this.loaded && loaded) {
this.loaded = true; // Only comes true once.
this.firstLoadedPromise.resolve(this.uniqueId);
}
}
/**
* Wait the loading to finish.
*
* @return Promise resolved with the uniqueId when done.
*/
async whenLoaded(): Promise<string> {
return await this.firstLoadedPromise;
}
} }

View File

@ -198,7 +198,7 @@ export class CoreNavBarButtonsComponent implements OnInit, OnDestroy {
* @return Promise resolved with the header element. * @return Promise resolved with the header element.
*/ */
protected async searchHeader(retries: number = 0): Promise<HTMLElement> { protected async searchHeader(retries: number = 0): Promise<HTMLElement> {
let parentPage: HTMLElement = this.element; let parentPage: HTMLElement | null = this.element;
while (parentPage) { while (parentPage) {
if (!parentPage.parentElement) { if (!parentPage.parentElement) {
@ -207,7 +207,7 @@ export class CoreNavBarButtonsComponent implements OnInit, OnDestroy {
} }
// Get the next parent page. // Get the next parent page.
parentPage = <HTMLElement> CoreDomUtils.closest(parentPage.parentElement, '.ion-page'); parentPage = parentPage.parentElement.closest('.ion-page');
if (parentPage) { if (parentPage) {
// Check if the page has a header. If it doesn't, search the next parent page. // Check if the page has a header. If it doesn't, search the next parent page.
const header = this.searchHeaderInPage(parentPage); const header = this.searchHeaderInPage(parentPage);

View File

@ -19,8 +19,8 @@ import { CoreUtils } from '@services/utils/utils';
import { CoreMath } from '@singletons/math'; import { CoreMath } from '@singletons/math';
import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreFormatTextDirective } from './format-text'; import { CoreFormatTextDirective } from './format-text';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreEventObserver } from '@singletons/events';
import { CoreEventLoadingChangedData, CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreLoadingComponent } from '@components/loading/loading';
/** /**
* Directive to make an element fixed at the bottom collapsible when scrolling. * Directive to make an element fixed at the bottom collapsible when scrolling.
@ -139,11 +139,11 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy {
* @param element Element. * @param element Element.
*/ */
protected async waitFormatTextsRendered(element: Element): Promise<void> { protected async waitFormatTextsRendered(element: Element): Promise<void> {
const formatTexts = Array await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreFormatTextDirective>(
.from(element.querySelectorAll('core-format-text')) element,
.map(element => CoreComponentsRegistry.resolve(element, CoreFormatTextDirective)); 'core-format-text',
'rendered',
await Promise.all(formatTexts.map(formatText => formatText?.rendered())); );
} }
/** /**
@ -184,24 +184,33 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy {
* @inheritdoc * @inheritdoc
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
// Calculate the height now.
await this.calculateHeight();
setTimeout(() => this.calculateHeight(), 200); // Try again, sometimes the first calculation is wrong.
this.listenScrollEvents();
// Only if not present or explicitly falsy it will be false. // Only if not present or explicitly falsy it will be false.
this.appearOnBottom = !CoreUtils.isFalseOrZero(this.appearOnBottom); this.appearOnBottom = !CoreUtils.isFalseOrZero(this.appearOnBottom);
// Recalculate the height if a parent core-loading displays the content. await this.waitLoadingsDone();
this.loadingChangedListener =
CoreEvents.on(CoreEvents.CORE_LOADING_CHANGED, async (data: CoreEventLoadingChangedData) => { await this.calculateHeight();
if (data.loaded && CoreDomUtils.closest(this.element.parentElement, '#' + data.uniqueId)) {
// The format-text is inside the loading, re-calculate the height. this.listenScrollEvents();
await this.calculateHeight(); }
setTimeout(() => this.calculateHeight(), 200);
} /**
}); * Wait until all <core-loading> children inside the page.
*
* @return Promise resolved when loadings are done.
*/
protected async waitLoadingsDone(): Promise<void> {
const scrollElement = await this.ionContent.getScrollElement();
await Promise.all([
await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreLoadingComponent>
(scrollElement, 'core-loading', 'whenLoaded'),
await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreLoadingComponent>(
this.element,
'core-loading',
'whenLoaded',
),
]);
} }
/** /**

View File

@ -13,6 +13,8 @@
// limitations under the License. // limitations under the License.
import { Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChange } from '@angular/core'; import { Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChange } from '@angular/core';
import { CorePromisedValue } from '@classes/promised-value';
import { CoreLoadingComponent } from '@components/loading/loading';
import { CoreTabsOutletComponent } from '@components/tabs-outlet/tabs-outlet'; import { CoreTabsOutletComponent } from '@components/tabs-outlet/tabs-outlet';
import { ScrollDetail } from '@ionic/core'; import { ScrollDetail } from '@ionic/core';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
@ -55,32 +57,36 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
@Input() collapsible = true; @Input() collapsible = true;
protected page?: HTMLElement; protected page?: HTMLElement;
protected collapsedHeader?: Element; protected collapsedHeader: HTMLIonHeaderElement;
protected collapsedFontStyles?: Partial<CSSStyleDeclaration>; protected collapsedFontStyles?: Partial<CSSStyleDeclaration>;
protected expandedHeader?: Element; protected expandedHeader?: HTMLIonItemElement;
protected expandedHeaderHeight?: number; protected expandedHeaderHeight?: number;
protected expandedFontStyles?: Partial<CSSStyleDeclaration>; protected expandedFontStyles?: Partial<CSSStyleDeclaration>;
protected content?: HTMLIonContentElement; protected content?: HTMLIonContentElement;
protected contentScrollListener?: EventListener; protected contentScrollListener?: EventListener;
protected endContentScrollListener?: EventListener; protected endContentScrollListener?: EventListener;
protected floatingTitle?: HTMLElement; protected pageDidEnterListener?: EventListener;
protected floatingTitle?: HTMLHeadingElement;
protected scrollingHeight?: number; protected scrollingHeight?: number;
protected subscriptions: Subscription[] = []; protected subscriptions: Subscription[] = [];
protected enabled = true; protected enabled = true;
protected isWithinContent = false; protected isWithinContent = false;
protected enteredPromise = new CorePromisedValue<void>();
constructor(protected el: ElementRef) {} constructor(el: ElementRef) {
this.collapsedHeader = el.nativeElement;
}
/** /**
* @inheritdoc * @inheritdoc
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
this.collapsedHeader = this.el.nativeElement; this.initializePage();
await Promise.all([ await Promise.all([
this.initializePage(),
this.initializeCollapsedHeader(), this.initializeCollapsedHeader(),
this.initializeExpandedHeader(), this.initializeExpandedHeader(),
await this.enteredPromise,
]); ]);
this.initializeFloatingTitle(); this.initializeFloatingTitle();
@ -111,30 +117,42 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
if (this.content && this.endContentScrollListener) { if (this.content && this.endContentScrollListener) {
this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener); this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener);
} }
if (this.page && this.pageDidEnterListener) {
this.page.removeEventListener('ionViewDidEnter', this.pageDidEnterListener);
}
} }
/** /**
* Search the page element, initialize it, and wait until it's ready for the transition to trigger on scroll. * Search the page element, initialize it, and wait until it's ready for the transition to trigger on scroll.
*/ */
protected async initializePage(): Promise<void> { protected initializePage(): void {
if (!this.collapsedHeader?.parentElement) { if (!this.collapsedHeader.parentElement) {
throw new Error('[collapsible-header] Couldn\'t get page'); throw new Error('[collapsible-header] Couldn\'t get page');
} }
// Find element and prepare classes. // Find element and prepare classes.
this.page = this.collapsedHeader.parentElement; this.page = this.collapsedHeader.parentElement;
this.page.classList.add('collapsible-header-page'); this.page.classList.add('collapsible-header-page');
this.page.addEventListener(
'ionViewDidEnter',
this.pageDidEnterListener = () => {
clearTimeout(timeout);
this.enteredPromise.resolve();
},
{ once: true },
);
// Timeout in case event is never fired.
const timeout = window.setTimeout(() => {
this.enteredPromise.reject(new Error('[collapsible-header] Waiting for ionViewDidEnter timeout reached'));
}, 5000);
} }
/** /**
* Search the collapsed header element, initialize it, and wait until it's ready for the transition to trigger on scroll. * Search the collapsed header element, initialize it, and wait until it's ready for the transition to trigger on scroll.
*/ */
protected async initializeCollapsedHeader(): Promise<void> { protected async initializeCollapsedHeader(): Promise<void> {
if (!this.collapsedHeader) {
throw new Error('[collapsible-header] Couldn\'t initialize collapsed header');
}
this.collapsedHeader.classList.add('collapsible-header-collapsed'); this.collapsedHeader.classList.add('collapsible-header-collapsed');
await this.waitFormatTextsRendered(this.collapsedHeader); await this.waitFormatTextsRendered(this.collapsedHeader);
@ -144,23 +162,16 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
* Search the expanded header element, initialize it, and wait until it's ready for the transition to trigger on scroll. * Search the expanded header element, initialize it, and wait until it's ready for the transition to trigger on scroll.
*/ */
protected async initializeExpandedHeader(): Promise<void> { protected async initializeExpandedHeader(): Promise<void> {
do { await this.waitLoadingsDone();
await CoreUtils.wait(50);
this.expandedHeader = this.page?.querySelector('ion-item[collapsible]') ?? undefined; this.expandedHeader = this.page?.querySelector('ion-item[collapsible]') ?? undefined;
if (!this.expandedHeader) {
continue;
}
await this.waitFormatTextsRendered(this.expandedHeader);
} while (
!this.expandedHeader ||
this.expandedHeader.clientHeight === 0 ||
this.expandedHeader.closest('ion-content.animating')
);
if (!this.expandedHeader) {
throw new Error('[collapsible-header] Couldn\'t initialize expanded header');
}
this.expandedHeader.classList.add('collapsible-header-expanded'); this.expandedHeader.classList.add('collapsible-header-expanded');
await this.waitFormatTextsRendered(this.expandedHeader);
} }
/** /**
@ -180,6 +191,8 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated)); this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated));
onOutletUpdated();
return; return;
} }
@ -197,15 +210,15 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
* Initialize a floating title to mimic transitioning the title from one state to the other. * Initialize a floating title to mimic transitioning the title from one state to the other.
*/ */
protected initializeFloatingTitle(): void { protected initializeFloatingTitle(): void {
if (!this.page || !this.collapsedHeader || !this.expandedHeader) { if (!this.page || !this.expandedHeader) {
throw new Error('[collapsible-header] Couldn\'t create floating title'); throw new Error('[collapsible-header] Couldn\'t create floating title');
} }
// Add floating title and measure initial position. // Add floating title and measure initial position.
const collapsedHeaderTitle = this.collapsedHeader.querySelector('h1') as HTMLElement; const collapsedHeaderTitle = this.collapsedHeader.querySelector('h1') as HTMLHeadingElement;
const originalTitle = this.expandedHeader.querySelector('h1') as HTMLElement; const originalTitle = this.expandedHeader.querySelector('h1') as HTMLHeadingElement;
const floatingTitleWrapper = originalTitle.parentElement as HTMLElement; const floatingTitleWrapper = originalTitle.parentElement as HTMLElement;
const floatingTitle = originalTitle.cloneNode(true) as HTMLElement; const floatingTitle = originalTitle.cloneNode(true) as HTMLHeadingElement;
originalTitle.classList.add('collapsible-header-original-title'); originalTitle.classList.add('collapsible-header-original-title');
floatingTitle.classList.add('collapsible-header-floating-title'); floatingTitle.classList.add('collapsible-header-floating-title');
@ -265,17 +278,31 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
this.expandedHeaderHeight = expandedHeaderHeight; this.expandedHeaderHeight = expandedHeaderHeight;
} }
/**
* Wait until all <core-loading> children inside the page.
*
* @return Promise resolved when loadings are done.
*/
protected async waitLoadingsDone(): Promise<void> {
await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreLoadingComponent>(
this.page,
'core-loading',
'whenLoaded',
);
}
/** /**
* Wait until all <core-format-text> children inside the element are done rendering. * Wait until all <core-format-text> children inside the element are done rendering.
* *
* @param element Element. * @param element Element.
* @return Promise resolved when texts are rendered.
*/ */
protected async waitFormatTextsRendered(element: Element): Promise<void> { protected async waitFormatTextsRendered(element: Element): Promise<void> {
const formatTexts = Array await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreFormatTextDirective>(
.from(element.querySelectorAll('core-format-text')) element,
.map(element => CoreComponentsRegistry.resolve(element, CoreFormatTextDirective)); 'core-format-text',
'rendered',
await Promise.all(formatTexts.map(formatText => formatText?.rendered())); );
} }
/** /**
@ -399,6 +426,10 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
return; return;
} }
if (page.classList.contains('is-frozen')) {
return;
}
const progress = parseFloat(page.style.getPropertyValue('--collapsible-header-progress')); const progress = parseFloat(page.style.getPropertyValue('--collapsible-header-progress'));
const scrollTop = contentScroll.scrollTop; const scrollTop = contentScroll.scrollTop;
const collapse = progress > 0.5; const collapse = progress > 0.5;

View File

@ -13,11 +13,11 @@
// limitations under the License. // limitations under the License.
import { Directive, ElementRef, Input, OnInit } from '@angular/core'; import { Directive, ElementRef, Input, OnInit } from '@angular/core';
import { CoreLoadingComponent } from '@components/loading/loading';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreEventLoadingChangedData, CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreFormatTextDirective } from './format-text'; import { CoreFormatTextDirective } from './format-text';
const defaultMaxHeight = 80; const defaultMaxHeight = 80;
@ -47,7 +47,6 @@ export class CoreCollapsibleItemDirective implements OnInit {
protected expanded = false; protected expanded = false;
protected maxHeight = defaultMaxHeight; protected maxHeight = defaultMaxHeight;
protected expandedHeight = 0; protected expandedHeight = 0;
protected loadingChangedListener?: CoreEventObserver;
constructor(el: ElementRef<HTMLElement>) { constructor(el: ElementRef<HTMLElement>) {
this.element = el.nativeElement; this.element = el.nativeElement;
@ -79,17 +78,22 @@ export class CoreCollapsibleItemDirective implements OnInit {
this.element.classList.add('collapsible-item'); this.element.classList.add('collapsible-item');
// Calculate the height now. await this.waitLoadingsDone();
await this.calculateHeight();
// Recalculate the height if a parent core-loading displays the content. await this.calculateHeight();
this.loadingChangedListener = }
CoreEvents.on(CoreEvents.CORE_LOADING_CHANGED, async (data: CoreEventLoadingChangedData) => {
if (data.loaded && CoreDomUtils.closest(this.element.parentElement, '#' + data.uniqueId)) { /**
// The element is inside the loading, re-calculate the height. * Wait until all <core-loading> children inside the page.
await this.calculateHeight(); *
} * @return Promise resolved when loadings are done.
}); */
protected async waitLoadingsDone(): Promise<void> {
await CoreDomUtils.waitToDom(this.element);
const page = this.element.closest('.ion-page');
await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreLoadingComponent>(page, 'core-loading', 'whenLoaded');
} }
/** /**
@ -109,19 +113,19 @@ export class CoreCollapsibleItemDirective implements OnInit {
const formatTexts = formatTextElements.map(element => CoreComponentsRegistry.resolve(element, CoreFormatTextDirective)); const formatTexts = formatTextElements.map(element => CoreComponentsRegistry.resolve(element, CoreFormatTextDirective));
await Promise.all(formatTexts.map(formatText => formatText?.rendered())); await Promise.all(formatTexts.map(formatText => formatText?.rendered()));
await CoreUtils.nextTick();
} }
/** /**
* Calculate the height and check if we need to display show more or not. * Calculate the height and check if we need to display show more or not.
*/ */
protected async calculateHeight(retries = 3): Promise<void> { protected async calculateHeight(): Promise<void> {
// Remove max-height (if any) to calculate the real height. // Remove max-height (if any) to calculate the real height.
this.element.classList.add('collapsible-loading-height'); this.element.classList.add('collapsible-loading-height');
await this.waitFormatTextsRendered(this.element); await this.waitFormatTextsRendered(this.element);
await CoreUtils.nextTick();
this.expandedHeight = CoreDomUtils.getElementHeight(this.element) || 0; this.expandedHeight = CoreDomUtils.getElementHeight(this.element) || 0;
// Restore the max height now. // Restore the max height now.
@ -129,10 +133,6 @@ export class CoreCollapsibleItemDirective implements OnInit {
// If cannot calculate height, shorten always. // If cannot calculate height, shorten always.
this.setExpandButtonEnabled(!this.expandedHeight || this.expandedHeight >= this.maxHeight); this.setExpandButtonEnabled(!this.expandedHeight || this.expandedHeight >= this.maxHeight);
if (this.expandedHeight == 0 && retries > 0) {
setTimeout(() => this.calculateHeight(retries - 1), 200);
}
} }
/** /**

View File

@ -27,7 +27,6 @@ import { CoreApp } from '@services/app';
import { CoreFile } from '@services/file'; import { CoreFile } from '@services/file';
import { CoreFilepool, CoreFilepoolFileActions, CoreFilepoolFileEventData } from '@services/filepool'; import { CoreFilepool, CoreFilepoolFileActions, CoreFilepoolFileEventData } from '@services/filepool';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUrlUtils } from '@services/utils/url'; import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { Platform } from '@singletons'; import { Platform } from '@singletons';
@ -418,13 +417,14 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O
// Set events to download big files (not downloaded automatically). // Set events to download big files (not downloaded automatically).
if (targetAttr !== 'poster' && (tagName === 'VIDEO' || tagName === 'AUDIO' || tagName === 'A' || tagName === 'SOURCE')) { if (targetAttr !== 'poster' && (tagName === 'VIDEO' || tagName === 'AUDIO' || tagName === 'A' || tagName === 'SOURCE')) {
const eventName = tagName == 'A' ? 'click' : 'play'; const eventName = tagName == 'A' ? 'click' : 'play';
let clickableEl = this.element; let clickableEl: Element | null = this.element;
if (tagName == 'SOURCE') { if (tagName == 'SOURCE') {
clickableEl = <HTMLElement> CoreDomUtils.closest(this.element, 'video,audio'); clickableEl = this.element.closest('video,audio');
if (!clickableEl) { }
return;
} if (!clickableEl) {
return;
} }
clickableEl.addEventListener(eventName, () => { clickableEl.addEventListener(eventName, () => {

View File

@ -458,7 +458,7 @@ export class CoreQuestionBaseComponent {
name: input.name, name: input.name,
value: input.value, value: input.value,
readOnly: input.readOnly, readOnly: input.readOnly,
isInline: !!CoreDomUtils.closest(input, '.qtext'), // The answer can be inside the question text. isInline: !!input.closest('.qtext'), // The answer can be inside the question text.
}; };
// Check if question is marked as correct. // Check if question is marked as correct.

View File

@ -258,7 +258,7 @@ export class CoreQuestionHelperProvider {
// Get the last element and check it's not in the question contents. // Get the last element and check it's not in the question contents.
let last = matches.pop(); let last = matches.pop();
while (last) { while (last) {
if (!CoreDomUtils.closest(last, '.formulation')) { if (!last.closest('.formulation')) {
// Not in question contents. Add it to a separate attribute and remove it from the HTML. // Not in question contents. Add it to a separate attribute and remove it from the HTML.
question[attrName] = last.innerHTML; question[attrName] = last.innerHTML;
last.parentElement?.removeChild(last); last.parentElement?.removeChild(last);

View File

@ -53,6 +53,7 @@ import { NavigationStart } from '@angular/router';
import { filter } from 'rxjs/operators'; import { filter } from 'rxjs/operators';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CorePromisedValue } from '@classes/promised-value';
/* /*
* "Utils" service with helper functions for UI, DOM elements and HTML code. * "Utils" service with helper functions for UI, DOM elements and HTML code.
@ -91,6 +92,45 @@ export class CoreDomUtilsProvider {
this.debugDisplay = debugDisplay != 0; this.debugDisplay = debugDisplay != 0;
} }
/**
* Wait an element to be in dom of another element.
*
* @param element Element to wait.
* @return Promise resolved when added. It will be rejected after a timeout of 5s.
*/
waitToDom(
element: Element,
): CorePromisedValue<void> {
let root = element.getRootNode({ composed: true });
const inDomPromise = new CorePromisedValue<void>();
if (root === document) {
// Already in DOM.
inDomPromise.resolve();
return inDomPromise;
}
// Disconnect observer for performance reasons.
const timeout = window.setTimeout(() => {
inDomPromise.reject(new Error('Waiting for DOM timeout reached'));
observer.disconnect();
}, 5000);
const observer = new MutationObserver(() => {
root = element.getRootNode({ composed: true });
if (root === document) {
observer.disconnect();
clearTimeout(timeout);
inDomPromise.resolve();
}
});
observer.observe(document.body, { subtree: true, childList: true });
return inDomPromise;
}
/** /**
* Equivalent to element.closest(). If the browser doesn't support element.closest, it will * Equivalent to element.closest(). If the browser doesn't support element.closest, it will
* traverse the parents to achieve the same functionality. * traverse the parents to achieve the same functionality.
@ -99,45 +139,10 @@ export class CoreDomUtilsProvider {
* @param element DOM Element. * @param element DOM Element.
* @param selector Selector to search. * @param selector Selector to search.
* @return Closest ancestor. * @return Closest ancestor.
* @deprecated Not needed anymore since it's supported on both Android and iOS. Use closest instead.
*/ */
closest(element: Element | undefined | null, selector: string): Element | null { closest(element: Element | undefined | null, selector: string): Element | null {
if (!element) { return element?.closest(selector) ?? null;
return null;
}
// Try to use closest if the browser supports it.
if (typeof element.closest == 'function') {
return element.closest(selector);
}
if (!this.matchesFunctionName) {
// Find the matches function supported by the browser.
['matches', 'webkitMatchesSelector', 'mozMatchesSelector', 'msMatchesSelector', 'oMatchesSelector'].some((fn) => {
if (typeof document.body[fn] == 'function') {
this.matchesFunctionName = fn;
return true;
}
return false;
});
if (!this.matchesFunctionName) {
return null;
}
}
// Traverse parents.
let elementToTreat: Element | null = element;
while (elementToTreat) {
if (elementToTreat[this.matchesFunctionName](selector)) {
return elementToTreat;
}
elementToTreat = elementToTreat.parentElement;
}
return null;
} }
/** /**

View File

@ -12,6 +12,9 @@
// 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 { Component } from '@angular/core';
import { CoreUtils } from '@services/utils/utils';
/** /**
* Registry to keep track of component instances. * Registry to keep track of component instances.
*/ */
@ -36,7 +39,7 @@ export class CoreComponentsRegistry {
* @param componentClass Component class. * @param componentClass Component class.
* @returns Component instance. * @returns Component instance.
*/ */
static resolve<T>(element?: Element | null, componentClass?: ComponentConstructor<T>): T | null { static resolve<T = Component>(element?: Element | null, componentClass?: ComponentConstructor<T>): T | null {
const instance = (element && this.instances.get(element) as T) ?? null; const instance = (element && this.instances.get(element) as T) ?? null;
return instance && (!componentClass || instance instanceof componentClass) return instance && (!componentClass || instance instanceof componentClass)
@ -44,6 +47,41 @@ export class CoreComponentsRegistry {
: null; : null;
} }
/**
* Waits all elements to be rendered.
*
* @param element Parent element where to search.
* @param selector Selector to search on parent.
* @param fnName Component function that have to be resolved when rendered.
* @param params Params of function that have to be resolved when rendered.
* @return Promise resolved when done.
*/
static async finishRenderingAllElementsInside<T = Component>(
element: Element | undefined | null,
selector: string,
fnName: string,
params?: unknown[],
): Promise<void> {
if (!element) {
return;
}
const components = Array
.from(element.querySelectorAll(selector))
.map(element => CoreComponentsRegistry.resolve<T>(element));
await Promise.all(components.map(component => {
if (!component) {
return;
}
return component[fnName].apply(component, params);
}));
// Wait for next tick to ensure components are completely rendered.
await CoreUtils.nextTick();
}
} }
/** /**