diff --git a/src/addons/mod/page/components/index/index.ts b/src/addons/mod/page/components/index/index.ts index bbcac903a..dd9dc2ff7 100644 --- a/src/addons/mod/page/components/index/index.ts +++ b/src/addons/mod/page/components/index/index.ts @@ -33,7 +33,7 @@ export class AddonModPageIndexComponent extends CoreCourseModuleMainResourceComp component = AddonModPageProvider.COMPONENT; contents?: string; - displayDescription = true; + displayDescription = false; displayTimemodified = true; timemodified?: number; page?: AddonModPagePage; diff --git a/src/addons/qtype/ddimageortext/classes/ddimageortext.ts b/src/addons/qtype/ddimageortext/classes/ddimageortext.ts index 8a9f75b64..5fd0cc61b 100644 --- a/src/addons/qtype/ddimageortext/classes/ddimageortext.ts +++ b/src/addons/qtype/ddimageortext/classes/ddimageortext.ts @@ -14,6 +14,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; +import { CoreEventObserver } from '@singletons/events'; import { CoreLogger } from '@singletons/logger'; import { AddonModQuizDdImageOrTextQuestionData } from '../component/ddimageortext'; @@ -28,7 +29,7 @@ export class AddonQtypeDdImageOrTextQuestion { protected afterImageLoadDone = false; protected proportion = 1; protected selected?: HTMLElement | null; // Selected element (being "dragged"). - protected resizeFunction?: (ev?: Event) => void; + protected resizeListener?: CoreEventObserver; /** * Create the this. @@ -174,9 +175,7 @@ export class AddonQtypeDdImageOrTextQuestion { destroy(): void { this.stopPolling(); - if (this.resizeFunction) { - window.removeEventListener('resize', this.resizeFunction); - } + this.resizeListener?.off(); } /** @@ -360,8 +359,9 @@ export class AddonQtypeDdImageOrTextQuestion { this.pollForImageLoad(); }); - this.resizeFunction = this.windowResized.bind(this); - window.addEventListener('resize', this.resizeFunction!); + this.resizeListener = CoreDomUtils.onWindowResize(() => { + this.windowResized(); + }); } /** diff --git a/src/addons/qtype/ddmarker/classes/ddmarker.ts b/src/addons/qtype/ddmarker/classes/ddmarker.ts index 1e747e0de..e1a1ddc06 100644 --- a/src/addons/qtype/ddmarker/classes/ddmarker.ts +++ b/src/addons/qtype/ddmarker/classes/ddmarker.ts @@ -14,6 +14,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; +import { CoreEventObserver } from '@singletons/events'; import { CoreLogger } from '@singletons/logger'; import { AddonQtypeDdMarkerQuestionData } from '../component/ddmarker'; import { AddonQtypeDdMarkerGraphicsApi } from './graphics_api'; @@ -41,7 +42,7 @@ export class AddonQtypeDdMarkerQuestion { protected proportion = 1; protected selected?: HTMLElement; // Selected element (being "dragged"). protected graphics: AddonQtypeDdMarkerGraphicsApi; - protected resizeFunction?: () => void; + protected resizeListener?: CoreEventObserver; doc!: AddonQtypeDdMarkerQuestionDocStructure; shapes: SVGElement[] = []; @@ -160,9 +161,7 @@ export class AddonQtypeDdMarkerQuestion { * Function to call when the instance is no longer needed. */ destroy(): void { - if (this.resizeFunction) { - window.removeEventListener('resize', this.resizeFunction); - } + this.resizeListener?.off(); } /** @@ -273,8 +272,18 @@ export class AddonQtypeDdMarkerQuestion { return; } - const width = CoreDomUtils.getElementMeasure(markerSpan, true, true, false, true); - const height = CoreDomUtils.getElementMeasure(markerSpan, false, true, false, true); + const computedStyle = getComputedStyle(markerSpan); + const width = markerSpan.getBoundingClientRect().width + + CoreDomUtils.getComputedStyleMeasure(computedStyle, 'borderLeftWidth') + + CoreDomUtils.getComputedStyleMeasure(computedStyle, 'borderRightWidth') + + CoreDomUtils.getComputedStyleMeasure(computedStyle, 'paddingLeft') + + CoreDomUtils.getComputedStyleMeasure(computedStyle, 'paddingRight'); + + const height = markerSpan.getBoundingClientRect().height + + CoreDomUtils.getComputedStyleMeasure(computedStyle, 'borderTopWidth') + + CoreDomUtils.getComputedStyleMeasure(computedStyle, 'borderBottomWidth') + + CoreDomUtils.getComputedStyleMeasure(computedStyle, 'paddingTop') + + CoreDomUtils.getComputedStyleMeasure(computedStyle, 'paddingBottom'); markerSpan.style.opacity = '0.6'; markerSpan.style.left = (xyForText.x - (width / 2)) + 'px'; markerSpan.style.top = (xyForText.y - (height / 2)) + 'px'; @@ -601,8 +610,9 @@ export class AddonQtypeDdMarkerQuestion { this.pollForImageLoad(); }); - this.resizeFunction = this.windowResized.bind(this); - window.addEventListener('resize', this.resizeFunction!); + this.resizeListener = CoreDomUtils.onWindowResize(() => { + this.windowResized(); + }); } /** diff --git a/src/addons/qtype/ddwtos/classes/ddwtos.ts b/src/addons/qtype/ddwtos/classes/ddwtos.ts index 7808cf602..9bb54147a 100644 --- a/src/addons/qtype/ddwtos/classes/ddwtos.ts +++ b/src/addons/qtype/ddwtos/classes/ddwtos.ts @@ -15,6 +15,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; +import { CoreEventObserver } from '@singletons/events'; import { CoreLogger } from '@singletons/logger'; import { AddonModQuizDdwtosQuestionData } from '../component/ddwtos'; @@ -28,13 +29,11 @@ export class AddonQtypeDdwtosQuestion { protected selectors!: AddonQtypeDdwtosQuestionCSSSelectors; // Result of cssSelectors. protected placed: {[no: number]: number} = {}; // Map that relates drag elements numbers with drop zones numbers. protected selected?: HTMLElement; // Selected element (being "dragged"). - protected resizeFunction?: () => void; + protected resizeListener?: CoreEventObserver; /** * Create the instance. * - * @param logger Logger provider. - * @param domUtils Dom Utils provider. * @param container The container HTMLElement of the question. * @param question The question instance. * @param readOnly Whether it's read only. @@ -122,9 +121,7 @@ export class AddonQtypeDdwtosQuestion { * Function to call when the instance is no longer needed. */ destroy(): void { - if (this.resizeFunction) { - window.removeEventListener('resize', this.resizeFunction); - } + this.resizeListener?.off(); } /** @@ -214,8 +211,9 @@ export class AddonQtypeDdwtosQuestion { this.positionDragItems(); - this.resizeFunction = this.windowResized.bind(this); - window.addEventListener('resize', this.resizeFunction!); + this.resizeListener = CoreDomUtils.onWindowResize(() => { + this.windowResized(); + }); } /** diff --git a/src/core/classes/tabs.ts b/src/core/classes/tabs.ts index ca644c64e..bd4cf9efe 100644 --- a/src/core/classes/tabs.ts +++ b/src/core/classes/tabs.ts @@ -32,6 +32,8 @@ import { Subscription } from 'rxjs'; import { Platform, Translate } from '@singletons'; import { CoreSettingsHelper } from '@features/settings/services/settings-helper'; import { CoreAriaRoleTab, CoreAriaRoleTabFindable } from './aria-role-tab'; +import { CoreEventObserver } from '@singletons/events'; +import { CoreDomUtils } from '@services/utils/dom'; /** * Class to abstract some common code for tabs. @@ -75,7 +77,7 @@ export class CoreTabsBaseComponent implements OnInit, Aft protected tabsElement?: HTMLElement; // The tabs parent element. It's the element that will be "scrolled" to hide tabs. protected tabBarElement?: HTMLIonTabBarElement; // The top tab bar element. protected tabsShown = true; - protected resizeFunction: EventListenerOrEventListenerObject; + protected resizeListener?: CoreEventObserver; protected isDestroyed = false; protected isCurrentView = true; protected shouldSlideToInitial = false; // Whether we need to slide to the initial slide because it's out of view. @@ -99,7 +101,6 @@ export class CoreTabsBaseComponent implements OnInit, Aft protected element: ElementRef, ) { this.backButtonFunction = this.backButtonClicked.bind(this); - this.resizeFunction = this.windowResized.bind(this); this.tabAction = new CoreTabsRoleTab(this); } @@ -134,7 +135,9 @@ export class CoreTabsBaseComponent implements OnInit, Aft await this.initializeTabs(); } - window.addEventListener('resize', this.resizeFunction); + this.resizeListener = CoreDomUtils.onWindowResize(() => { + this.windowResized(); + }); } /** @@ -419,24 +422,24 @@ export class CoreTabsBaseComponent implements OnInit, Aft */ async slideNext(): Promise { // Stop if slides are in transition. - if (!this.showNextButton || this.isInTransition) { + if (!this.showNextButton || this.isInTransition || !this.slides) { return; } - if (await this.slides!.isBeginning()) { + if (await this.slides.isBeginning()) { // Slide to the second page. - this.slides!.slideTo(this.maxSlides); + this.slides.slideTo(this.maxSlides); } else { - const currentIndex = await this.slides!.getActiveIndex(); + const currentIndex = await this.slides.getActiveIndex(); if (currentIndex !== undefined) { const nextSlideIndex = currentIndex + this.maxSlides; this.isInTransition = true; if (nextSlideIndex < this.numTabsShown) { // Slide to the next page. - await this.slides!.slideTo(nextSlideIndex); + await this.slides.slideTo(nextSlideIndex); } else { // Slide to the latest slide. - await this.slides!.slideTo(this.numTabsShown - 1); + await this.slides.slideTo(this.numTabsShown - 1); } } @@ -448,24 +451,24 @@ export class CoreTabsBaseComponent implements OnInit, Aft */ async slidePrev(): Promise { // Stop if slides are in transition. - if (!this.showPrevButton || this.isInTransition) { + if (!this.showPrevButton || this.isInTransition || !this.slides) { return; } - if (await this.slides!.isEnd()) { - this.slides!.slideTo(this.numTabsShown - this.maxSlides * 2); + if (await this.slides.isEnd()) { + this.slides.slideTo(this.numTabsShown - this.maxSlides * 2); // Slide to the previous of the latest page. } else { - const currentIndex = await this.slides!.getActiveIndex(); + const currentIndex = await this.slides.getActiveIndex(); if (currentIndex !== undefined) { const prevSlideIndex = currentIndex - this.maxSlides; this.isInTransition = true; if (prevSlideIndex >= 0) { // Slide to the previous page. - await this.slides!.slideTo(prevSlideIndex); + await this.slides.slideTo(prevSlideIndex); } else { // Slide to the first page. - await this.slides!.slideTo(0); + await this.slides.slideTo(0); } } } @@ -646,9 +649,7 @@ export class CoreTabsBaseComponent implements OnInit, Aft ngOnDestroy(): void { this.isDestroyed = true; - if (this.resizeFunction) { - window.removeEventListener('resize', this.resizeFunction); - } + this.resizeListener?.off(); this.languageChangedSubscription?.unsubscribe(); } diff --git a/src/core/components/infinite-loading/infinite-loading.ts b/src/core/components/infinite-loading/infinite-loading.ts index 33eb9d81b..370c2473a 100644 --- a/src/core/components/infinite-loading/infinite-loading.ts +++ b/src/core/components/infinite-loading/infinite-loading.ts @@ -14,7 +14,6 @@ import { Component, Input, Output, EventEmitter, OnChanges, SimpleChange, ViewChild, ElementRef } from '@angular/core'; import { IonInfiniteScroll } from '@ionic/angular'; -import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; const THRESHOLD = .15; // % of the scroll element height that must be close to the edge to consider loading more items necessary. @@ -36,15 +35,12 @@ export class CoreInfiniteLoadingComponent implements OnChanges { @Input() position: 'top' | 'bottom' = 'bottom'; @Output() action: EventEmitter<() => void>; // Will emit an event when triggered. - @ViewChild('topbutton') topButton?: ElementRef; - @ViewChild('bottombutton') bottomButton?: ElementRef; - @ViewChild('spinnercontainer') spinnerContainer?: ElementRef; @ViewChild(IonInfiniteScroll) infiniteScroll?: IonInfiniteScroll; loadingMore = false; // Hide button and avoid loading more. hostElement: HTMLElement; - constructor(protected element: ElementRef) { + constructor(element: ElementRef) { this.action = new EventEmitter(); this.hostElement = element.nativeElement; } @@ -142,11 +138,7 @@ export class CoreInfiniteLoadingComponent implements OnChanges { * @deprecated since 3.9.5 */ getHeight(): number { - return (this.position == 'top' ? - this.getElementHeight(this.topButton?.nativeElement) : - this.getElementHeight(this.bottomButton?.nativeElement)) + - this.getElementHeight(this.infiniteScrollElement) + - this.getElementHeight(this.spinnerContainer?.nativeElement); + return this.hostElement.getBoundingClientRect().height; } /** @@ -158,18 +150,4 @@ export class CoreInfiniteLoadingComponent implements OnChanges { return this.hostElement.querySelector('ion-infinite-scroll'); } - /** - * Get the height of an element. - * - * @param element Element ref. - * @return Height. - */ - protected getElementHeight(element?: HTMLElement | null): number { - if (element) { - return CoreDomUtils.getElementHeight(element, true, true, true); - } - - return 0; - } - } diff --git a/src/core/directives/collapsible-footer.ts b/src/core/directives/collapsible-footer.ts index dc1e2c55a..cbd07969d 100644 --- a/src/core/directives/collapsible-footer.ts +++ b/src/core/directives/collapsible-footer.ts @@ -19,8 +19,9 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreMath } from '@singletons/math'; import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreFormatTextDirective } from './format-text'; -import { CoreEventObserver } from '@singletons/events'; +import { CoreEventObserver, CoreSingleTimeEventObserver } from '@singletons/events'; import { CoreLoadingComponent } from '@components/loading/loading'; +import { CoreDomUtils } from '@services/utils/dom'; /** * Directive to make an element fixed at the bottom collapsible when scrolling. @@ -37,7 +38,7 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { @Input() appearOnBottom = false; protected element: HTMLElement; - protected initialHeight = 0; + protected initialHeight = 48; protected finalHeight = 0; protected initialPaddingBottom = '0px'; protected previousTop = 0; @@ -46,22 +47,43 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { protected loadingChangedListener?: CoreEventObserver; protected contentScrollListener?: EventListener; protected endContentScrollListener?: EventListener; + protected resizeListener?: CoreEventObserver; + protected domListener?: CoreSingleTimeEventObserver; constructor(el: ElementRef, protected ionContent: IonContent) { this.element = el.nativeElement; this.element.setAttribute('slot', 'fixed'); // Just in case somebody forgets to add it. } + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + // Only if not present or explicitly falsy it will be false. + this.appearOnBottom = !CoreUtils.isFalseOrZero(this.appearOnBottom); + + this.domListener = CoreDomUtils.waitToBeInDOM(this.element); + await this.domListener.promise; + + await this.waitLoadingsDone(); + await this.waitFormatTextsRendered(this.element); + + this.content = this.element.closest('ion-content'); + + await this.calculateHeight(); + + this.listenScrollEvents(); + } + /** * Calculate the height of the footer. */ protected async calculateHeight(): Promise { - await this.waitFormatTextsRendered(this.element); - + this.element.classList.remove('is-active'); await CoreUtils.nextTick(); // Set a minimum height value. - this.initialHeight = this.element.getBoundingClientRect().height || 48; + this.initialHeight = this.element.getBoundingClientRect().height || this.initialHeight; const moduleNav = this.element.querySelector('core-course-module-navigation'); if (moduleNav) { this.element.classList.add('has-module-nav'); @@ -71,6 +93,7 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { this.previousHeight = this.initialHeight; this.content?.style.setProperty('--core-collapsible-footer-max-height', this.initialHeight + 'px'); + this.element.classList.add('is-active'); this.setBarHeight(this.initialHeight); } @@ -79,13 +102,7 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { * Setup scroll event listener. */ protected async listenScrollEvents(): Promise { - if (this.content) { - return; - } - - this.content = this.element.closest('ion-content'); - - if (!this.content) { + if (!this.content || this.content?.classList.contains('has-collapsible-footer')) { return; } @@ -131,6 +148,10 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { this.setBarHeight(newHeight); } }); + + this.resizeListener = CoreDomUtils.onWindowResize(() => { + this.calculateHeight(); + }, 50); } /** @@ -180,20 +201,6 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { this.previousHeight = height; } - /** - * @inheritdoc - */ - async ngOnInit(): Promise { - // Only if not present or explicitly falsy it will be false. - this.appearOnBottom = !CoreUtils.isFalseOrZero(this.appearOnBottom); - - await this.waitLoadingsDone(); - - await this.calculateHeight(); - - this.listenScrollEvents(); - } - /** * Wait until all children inside the page. * @@ -225,6 +232,9 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { if (this.content && this.endContentScrollListener) { this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener); } + + this.resizeListener?.off(); + this.domListener?.off(); } } diff --git a/src/core/directives/collapsible-header.ts b/src/core/directives/collapsible-header.ts index 3b26e56a1..ba041f9ad 100644 --- a/src/core/directives/collapsible-header.ts +++ b/src/core/directives/collapsible-header.ts @@ -16,9 +16,12 @@ import { Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChang import { CorePromisedValue } from '@classes/promised-value'; import { CoreLoadingComponent } from '@components/loading/loading'; import { CoreTabsOutletComponent } from '@components/tabs-outlet/tabs-outlet'; +import { CoreSettingsHelper } from '@features/settings/services/settings-helper'; import { ScrollDetail } from '@ionic/core'; +import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreEventObserver } from '@singletons/events'; import { CoreMath } from '@singletons/math'; import { Subscription } from 'rxjs'; import { CoreFormatTextDirective } from './format-text'; @@ -66,6 +69,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest protected contentScrollListener?: EventListener; protected endContentScrollListener?: EventListener; protected pageDidEnterListener?: EventListener; + protected resizeListener?: CoreEventObserver; protected floatingTitle?: HTMLHeadingElement; protected scrollingHeight?: number; protected subscriptions: Subscription[] = []; @@ -120,6 +124,8 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest if (this.page && this.pageDidEnterListener) { this.page.removeEventListener('ionViewDidEnter', this.pageDidEnterListener); } + + this.resizeListener?.off(); } /** @@ -147,6 +153,14 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest const timeout = window.setTimeout(() => { this.enteredPromise.reject(new Error('[collapsible-header] Waiting for ionViewDidEnter timeout reached')); }, 5000); + + this.resizeListener = CoreDomUtils.onWindowResize(() => { + this.initializeFloatingTitle(); + }, 50); + + this.subscriptions.push(CoreSettingsHelper.onDarkModeChange().subscribe(() => { + this.initializeFloatingTitle(); + })); } /** @@ -190,9 +204,6 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest }; this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated)); - this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated)); - - onOutletUpdated(); onOutletUpdated(); @@ -217,16 +228,26 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest throw new Error('[collapsible-header] Couldn\'t create floating title'); } + this.page.classList.remove('is-active'); + CoreUtils.nextTick(); + // Add floating title and measure initial position. const collapsedHeaderTitle = this.collapsedHeader.querySelector('h1') as HTMLHeadingElement; - const originalTitle = this.expandedHeader.querySelector('h1') as HTMLHeadingElement; - const floatingTitleWrapper = originalTitle.parentElement as HTMLElement; - const floatingTitle = originalTitle.cloneNode(true) as HTMLHeadingElement; + const originalTitle = this.expandedHeader.querySelector('h1.collapsible-header-original-title') || + this.expandedHeader.querySelector('h1') as HTMLHeadingElement; - originalTitle.classList.add('collapsible-header-original-title'); - floatingTitle.classList.add('collapsible-header-floating-title'); - floatingTitleWrapper.classList.add('collapsible-header-floating-title-wrapper'); - floatingTitleWrapper.insertBefore(floatingTitle, originalTitle); + const floatingTitleWrapper = originalTitle.parentElement as HTMLElement; + let floatingTitle = floatingTitleWrapper.querySelector('.collapsible-header-floating-title') as HTMLHeadingElement; + if (!floatingTitle) { + // First time, create it. + floatingTitle = originalTitle.cloneNode(true) as HTMLHeadingElement; + floatingTitle.classList.add('collapsible-header-floating-title'); + + floatingTitleWrapper.classList.add('collapsible-header-floating-title-wrapper'); + floatingTitleWrapper.insertBefore(floatingTitle, originalTitle); + + originalTitle.classList.add('collapsible-header-original-title'); + } const floatingTitleBoundingBox = floatingTitle.getBoundingClientRect(); diff --git a/src/core/directives/collapsible-item.ts b/src/core/directives/collapsible-item.ts index 08cc6da18..d9ab8990e 100644 --- a/src/core/directives/collapsible-item.ts +++ b/src/core/directives/collapsible-item.ts @@ -12,12 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Directive, ElementRef, Input, OnInit } from '@angular/core'; +import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; import { CoreLoadingComponent } from '@components/loading/loading'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreEventObserver, CoreSingleTimeEventObserver } from '@singletons/events'; import { CoreFormatTextDirective } from './format-text'; const defaultMaxHeight = 80; @@ -33,7 +34,7 @@ const minMaxHeight = 56; @Directive({ selector: '[collapsible-item]', }) -export class CoreCollapsibleItemDirective implements OnInit { +export class CoreCollapsibleItemDirective implements OnInit, OnDestroy { /** * Max height in pixels to render the content box. It should be 56 at least to make sense. @@ -47,6 +48,8 @@ export class CoreCollapsibleItemDirective implements OnInit { protected expanded = false; protected maxHeight = defaultMaxHeight; protected expandedHeight = 0; + protected resizeListener?: CoreEventObserver; + protected domListener?: CoreSingleTimeEventObserver; constructor(el: ElementRef) { this.element = el.nativeElement; @@ -81,6 +84,10 @@ export class CoreCollapsibleItemDirective implements OnInit { await this.waitLoadingsDone(); await this.calculateHeight(); + + this.resizeListener = CoreDomUtils.onWindowResize(() => { + this.calculateHeight(); + }, 50); } /** @@ -89,7 +96,8 @@ export class CoreCollapsibleItemDirective implements OnInit { * @return Promise resolved when loadings are done. */ protected async waitLoadingsDone(): Promise { - await CoreDomUtils.waitToBeInDOM(this.element); + this.domListener = CoreDomUtils.waitToBeInDOM(this.element); + await this.domListener.promise; const page = this.element.closest('.ion-page'); @@ -126,7 +134,7 @@ export class CoreCollapsibleItemDirective implements OnInit { await this.waitFormatTextsRendered(this.element); - this.expandedHeight = CoreDomUtils.getElementHeight(this.element) || 0; + this.expandedHeight = this.element.getBoundingClientRect().height; // Restore the max height now. this.element.classList.remove('collapsible-loading-height'); @@ -229,4 +237,12 @@ export class CoreCollapsibleItemDirective implements OnInit { this.toggleExpand(); } + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.resizeListener?.off(); + this.domListener?.off(); + } + } diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index a879c3c83..12dd1f0ed 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -23,6 +23,7 @@ import { Optional, ViewContainerRef, ViewChild, + OnDestroy, } from '@angular/core'; import { IonContent } from '@ionic/angular'; @@ -41,6 +42,7 @@ import { CoreFilterHelper } from '@features/filter/services/filter-helper'; import { CoreSubscriptions } from '@singletons/subscriptions'; import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreCollapsibleItemDirective } from './collapsible-item'; +import { CoreSingleTimeEventObserver } from '@singletons/events'; /** * Directive to format text rendered. It renders the HTML and treats all links and media, using CoreLinkDirective @@ -54,7 +56,7 @@ import { CoreCollapsibleItemDirective } from './collapsible-item'; @Directive({ selector: 'core-format-text', }) -export class CoreFormatTextDirective implements OnChanges { +export class CoreFormatTextDirective implements OnChanges, OnDestroy { @ViewChild(CoreCollapsibleItemDirective) collapsible?: CoreCollapsibleItemDirective; @@ -88,6 +90,7 @@ export class CoreFormatTextDirective implements OnChanges { protected element: HTMLElement; protected emptyText = ''; protected contentSpan: HTMLElement; + protected domListener?: CoreSingleTimeEventObserver; constructor( element: ElementRef, @@ -126,6 +129,13 @@ export class CoreFormatTextDirective implements OnChanges { } } + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.domListener?.off(); + } + /** * Wait until the text is fully rendered. */ @@ -217,14 +227,14 @@ export class CoreFormatTextDirective implements OnChanges { /** * Add magnifying glass icons to view adapted images at full size. */ - addMagnifyingGlasses(): void { + async addMagnifyingGlasses(): Promise { const imgs = Array.from(this.contentSpan.querySelectorAll('.core-adapted-img-container > img')); if (!imgs.length) { return; } // If cannot calculate element's width, use viewport width to avoid false adapt image icons appearing. - const elWidth = this.getElementWidth(this.element) || window.innerWidth; + const elWidth = await this.getElementWidth(); imgs.forEach((img: HTMLImageElement) => { // Skip image if it's inside a link. @@ -543,41 +553,41 @@ export class CoreFormatTextDirective implements OnChanges { /** * Returns the element width in pixels. * - * @param element Element to get width from. - * @return The width of the element in pixels. When 0 is returned it means the element is not visible. + * @return The width of the element in pixels. */ - protected getElementWidth(element: HTMLElement): number { - let width = CoreDomUtils.getElementWidth(element); + protected async getElementWidth(): Promise { + this.domListener = CoreDomUtils.waitToBeInDOM(this.element); + await this.domListener.promise; + let width = this.element.getBoundingClientRect().width; if (!width) { // All elements inside are floating or inline. Change display mode to allow calculate the width. - const parentWidth = element.parentElement ? - CoreDomUtils.getElementWidth(element.parentElement, true, false, false, true) : 0; - const previousDisplay = getComputedStyle(element, null).display; + const previousDisplay = getComputedStyle(this.element).display; - element.style.display = 'inline-block'; + this.element.style.display = 'inline-block'; + await CoreUtils.nextTick(); - width = CoreDomUtils.getElementWidth(element); + width = this.element.getBoundingClientRect().width; - // If width is incorrectly calculated use parent width instead. - if (parentWidth > 0 && (!width || width > parentWidth)) { - width = parentWidth; - } - - element.style.display = previousDisplay; + this.element.style.display = previousDisplay; } - return width; - } + // Aproximate using parent elements. + let element = this.element; + while (!width && element.parentElement) { + element = element.parentElement; + const computedStyle = getComputedStyle(element); - /** - * Returns the element height in pixels. - * - * @param elementAng Element to get height from. - * @return The height of the element in pixels. When 0 is returned it means the element is not visible. - */ - protected getElementHeight(element: HTMLElement): number { - return CoreDomUtils.getElementHeight(element) || 0; + const padding = CoreDomUtils.getComputedStyleMeasure(computedStyle, 'paddingLeft') + + CoreDomUtils.getComputedStyleMeasure(computedStyle, 'paddingRight'); + + // Use parent width as an aproximation. + width = element.getBoundingClientRect().width - padding; + } + + return width > 0 && width < window.innerWidth + ? width + : window.innerWidth; } /** @@ -701,10 +711,12 @@ export class CoreFormatTextDirective implements OnChanges { let width: string | number; let height: string | number; + await CoreDomUtils.waitToBeInDOM(iframe, 5000).promise; + if (iframe.width) { width = iframe.width; } else { - width = this.getElementWidth(iframe); + width = iframe.getBoundingClientRect().width; if (!width) { width = window.innerWidth; } @@ -713,7 +725,7 @@ export class CoreFormatTextDirective implements OnChanges { if (iframe.height) { height = iframe.height; } else { - height = this.getElementHeight(iframe); + height = iframe.getBoundingClientRect().height; if (!height) { height = width; } diff --git a/src/core/directives/on-appear.ts b/src/core/directives/on-appear.ts index 52d7691b6..16519d58a 100644 --- a/src/core/directives/on-appear.ts +++ b/src/core/directives/on-appear.ts @@ -14,6 +14,7 @@ import { Directive, ElementRef, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreSingleTimeEventObserver } from '@singletons/events'; /** * Directive to listen when an element becomes visible. @@ -26,7 +27,7 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy { @Output() onAppear = new EventEmitter(); private element: HTMLElement; - private interval?: number; + protected domListener?: CoreSingleTimeEventObserver; constructor(element: ElementRef) { this.element = element.nativeElement; @@ -35,24 +36,18 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy { /** * @inheritdoc */ - ngOnInit(): void { - this.interval = window.setInterval(() => { - if (!CoreDomUtils.isElementVisible(this.element)) { - return; - } + async ngOnInit(): Promise { + this.domListener = CoreDomUtils.waitToBeInDOM(this.element); + await this.domListener.promise; - this.onAppear.emit(); - window.clearInterval(this.interval); - - delete this.interval; - }, 50); + this.onAppear.emit(); } /** * @inheritdoc */ ngOnDestroy(): void { - this.interval && window.clearInterval(this.interval); + this.domListener?.off(); } } diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss index d869f7aae..d990a190e 100644 --- a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss @@ -16,7 +16,7 @@ } :host { - height: 40vh; + height: var(--core-rte-height, auto); overflow: hidden; min-height: 200px; /* Just in case vh is not supported */ min-height: 40vh; diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts index 2b82866d7..eb4010db4 100644 --- a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts @@ -36,8 +36,10 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreUrlUtils } from '@services/utils/url'; import { CoreUtils } from '@services/utils/utils'; import { Platform, Translate } from '@singletons'; -import { CoreEventFormActionData, CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreEventFormActionData, CoreEventObserver, CoreEvents, CoreSingleTimeEventObserver } from '@singletons/events'; import { CoreEditorOffline } from '../../services/editor-offline'; +import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreLoadingComponent } from '@components/loading/loading'; /** * Component to display a rich text editor if enabled. @@ -101,7 +103,8 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn protected resizeFunction?: () => Promise; protected selectionChangeFunction?: () => void; protected languageChangedSubscription?: Subscription; - protected resizeObserver?: IntersectionObserver; + protected resizeListener?: CoreEventObserver; + protected domListener?: CoreSingleTimeEventObserver; rteEnabled = false; isPhone = false; @@ -140,14 +143,6 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn this.contentChanged = new EventEmitter(); this.element = elementRef.nativeElement as HTMLDivElement; this.pageInstance = 'app_' + Date.now(); // Generate a "unique" ID based on timestamp. - - if ('IntersectionObserver' in window) { - this.resizeObserver = new IntersectionObserver((observerEntry: IntersectionObserverEntry[]) => { - if (observerEntry[0].boundingClientRect.width > 0) { - this.updateToolbarButtons(); - } - }); - } } /** @@ -180,14 +175,9 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn // Use paragraph on enter. document.execCommand('DefaultParagraphSeparator', false, 'p'); - let i = 0; - this.initHeightInterval = window.setInterval(async () => { - const height = await this.maximizeEditorSize(); - if (i >= 5 || height != 0) { - clearInterval(this.initHeightInterval); - } - i++; - }, 750); + await this.waitLoadingsDone(); + + this.maximizeEditorSize(); this.setListeners(); this.updateToolbarButtons(); @@ -256,14 +246,11 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn ); }); - this.resizeFunction = this.windowResized.bind(this); - window.addEventListener('resize', this.resizeFunction!); + this.resizeListener = CoreDomUtils.onWindowResize(() => { + this.windowResized(); + }, 50); - // Start observing the target node for configured mutations - this.resizeObserver?.observe(this.element); - - this.selectionChangeFunction = this.updateToolbarStyles.bind(this); - document.addEventListener('selectionchange', this.selectionChangeFunction!); + document.addEventListener('selectionchange', this.selectionChangeFunction = this.updateToolbarStyles.bind(this)); this.keyboardObserver = CoreEvents.on(CoreEvents.KEYBOARD_CHANGE, () => { // Opening or closing the keyboard also calls the resize function, but sometimes the resize is called too soon. @@ -281,65 +268,58 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn /** * Resize editor to maximize the space occupied. - * - * @return Resolved with calculated editor size. */ - protected async maximizeEditorSize(): Promise { - const contentVisibleHeight = await CoreDomUtils.getContentHeight(this.content); - - if (contentVisibleHeight <= 0) { - return 0; - } - - await CoreUtils.wait(100); - + protected async maximizeEditorSize(): Promise { // Editor is ready, adjust Height if needed. - const contentHeight = await CoreDomUtils.getContentHeight(this.content); - const height = contentHeight - this.getSurroundingHeight(this.element); + const blankHeight = await this.getBlankHeightInContent(); + const newHeight = blankHeight + this.element.getBoundingClientRect().height; - if (height > this.minHeight) { - this.element.style.height = CoreDomUtils.formatPixelsSize(height - 1); + if (newHeight > this.minHeight) { + this.element.style.setProperty('--core-rte-height', (newHeight - 1) + 'px'); } else { - this.element.style.height = ''; + this.element.style.removeProperty('--core-rte-height'); } - - return height; } /** - * Get the height of the surrounding elements from the current to the top element. + * Wait until all children inside the page. * - * @param element Directive DOM element to get surroundings elements from. - * @return Surrounding height in px. + * @return Promise resolved when loadings are done. */ - protected getSurroundingHeight(element: HTMLElement): number { - let height = 0; + protected async waitLoadingsDone(): Promise { + this.domListener = CoreDomUtils.waitToBeInDOM(this.element); + await this.domListener.promise; - while (element.parentElement?.tagName != 'ION-CONTENT') { - const parent = element.parentElement!; - if (element.tagName && element.tagName != 'CORE-LOADING') { - for (let x = 0; x < parent.children.length; x++) { - const child = parent.children[x]; - if (child.tagName && child != element) { - height += CoreDomUtils.getElementHeight(child, false, true, true); - } - } - } - element = parent; + const page = this.element.closest('.ion-page'); + + await CoreComponentsRegistry.finishRenderingAllElementsInside(page, 'core-loading', 'whenLoaded'); + } + + /** + * Get the height of the space in blank at the end of the page. + * + * @return Blank height in px. Will be negative if no blank space. + */ + protected async getBlankHeightInContent(): Promise { + await CoreUtils.nextTicks(5); // Ensure content is completely loaded in the DOM. + + let content: Element | null = this.element.closest('ion-content'); + const contentHeight = await CoreDomUtils.getContentHeight(this.content); + + // Get first children with content, not fixed. + let scrollContentHeight = 0; + while (scrollContentHeight == 0 && content?.children) { + const children = Array.from(content.children) + .filter((element) => element.slot !== 'fixed' && !element.classList.contains('core-loading-container')); + + scrollContentHeight = children + .map((element) => element.getBoundingClientRect().height) + .reduce((a,b) => a + b, 0); + + content = children[0]; } - const computedStyle = getComputedStyle(element); - height += CoreDomUtils.getComputedStyleMeasure(computedStyle, 'paddingTop') + - CoreDomUtils.getComputedStyleMeasure(computedStyle, 'paddingBottom'); - - if (element.parentElement?.tagName == 'ION-CONTENT') { - const cs2 = getComputedStyle(element); - - height -= CoreDomUtils.getComputedStyleMeasure(cs2, 'paddingTop') + - CoreDomUtils.getComputedStyleMeasure(cs2, 'paddingBottom'); - } - - return height; + return contentHeight - scrollContentHeight; } /** @@ -625,7 +605,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn this.editorElement.innerHTML = '

'; this.textarea.value = ''; } else { - this.editorElement.innerHTML = value!; + this.editorElement.innerHTML = value || ''; this.textarea.value = value; this.treatExternalContent(); } @@ -759,10 +739,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn * Show the toolbar. */ showToolbar(event: Event): void { - if (!('IntersectionObserver' in window)) { - // Fallback if IntersectionObserver is not supported. - this.updateToolbarButtons(); - } + this.updateToolbarButtons(); this.element.classList.add('ion-touched'); this.element.classList.remove('ion-untouched'); @@ -851,14 +828,9 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn const length = await this.toolbarSlides.length(); - const width = CoreDomUtils.getElementWidth(this.toolbar.nativeElement); + await CoreDomUtils.waitToBeInDOM(this.toolbar.nativeElement, 5000).promise; - if (!width) { - // Width is not available yet, try later. - setTimeout(this.updateToolbarButtons.bind(this), 100); - - return; - } + const width = this.toolbar.nativeElement.getBoundingClientRect().width; if (length > 0 && width > length * this.toolbarButtonWidth) { this.slidesOpts = { ...this.slidesOpts, slidesPerView: length }; @@ -1102,20 +1074,20 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn } /** - * Component being destroyed. + * @inheritdoc */ ngOnDestroy(): void { this.valueChangeSubscription?.unsubscribe(); this.languageChangedSubscription?.unsubscribe(); - window.removeEventListener('resize', this.resizeFunction!); - document.removeEventListener('selectionchange', this.selectionChangeFunction!); + this.selectionChangeFunction && document.removeEventListener('selectionchange', this.selectionChangeFunction); clearInterval(this.initHeightInterval); clearInterval(this.autoSaveInterval); clearTimeout(this.hideMessageTimeout); - this.resizeObserver?.disconnect(); this.resetObserver?.off(); this.keyboardObserver?.off(); this.labelObserver?.disconnect(); + this.resizeListener?.off(); + this.domListener?.off(); } } diff --git a/src/core/features/emulator/services/network.ts b/src/core/features/emulator/services/network.ts index 7b1f79222..cca7dab97 100644 --- a/src/core/features/emulator/services/network.ts +++ b/src/core/features/emulator/services/network.ts @@ -24,6 +24,9 @@ export class NetworkMock extends Network { type!: string; + protected connectObservable = new Subject<'connected'>(); + protected disconnectObservable = new Subject<'disconnected'>(); + constructor() { super(); @@ -38,6 +41,14 @@ export class NetworkMock extends Network { CELL: 'cellular', // eslint-disable-line @typescript-eslint/naming-convention NONE: 'none', // eslint-disable-line @typescript-eslint/naming-convention }; + + window.addEventListener('online', () => { + this.connectObservable.next('connected'); + }, false); + + window.addEventListener('offline', () => { + this.disconnectObservable.next('disconnected'); + }, false); } /** @@ -45,8 +56,8 @@ export class NetworkMock extends Network { * * @return Observable. */ - onchange(): Observable { - return merge(this.onConnect(), this.onDisconnect()); + onChange(): Observable<'connected' | 'disconnected'> { + return merge(this.connectObservable, this.disconnectObservable); } /** @@ -54,14 +65,8 @@ export class NetworkMock extends Network { * * @return Observable. */ - onConnect(): Observable { - const observable = new Subject(); - - window.addEventListener('online', (ev) => { - observable.next(ev); - }, false); - - return observable; + onConnect(): Observable<'connected'> { + return this.connectObservable; } /** @@ -69,14 +74,8 @@ export class NetworkMock extends Network { * * @return Observable. */ - onDisconnect(): Observable { - const observable = new Subject(); - - window.addEventListener('offline', (ev) => { - observable.next(ev); - }, false); - - return observable; + onDisconnect(): Observable<'disconnected'> { + return this.disconnectObservable; } } diff --git a/src/core/features/mainmenu/pages/menu/menu.scss b/src/core/features/mainmenu/pages/menu/menu.scss index 4f0ebd805..349a0fe15 100644 --- a/src/core/features/mainmenu/pages/menu/menu.scss +++ b/src/core/features/mainmenu/pages/menu/menu.scss @@ -168,11 +168,11 @@ ion-tabs.placement-side { opacity: .8; z-index: 12; - .core-online { + .core-online-message { display: var(--network-message-online); } - .core-offline { + .core-offline-message { display: var(--network-message-offline); } } diff --git a/src/core/features/mainmenu/pages/menu/menu.ts b/src/core/features/mainmenu/pages/menu/menu.ts index bda5d03dc..47275c179 100644 --- a/src/core/features/mainmenu/pages/menu/menu.ts +++ b/src/core/features/mainmenu/pages/menu/menu.ts @@ -29,6 +29,7 @@ import { filter } from 'rxjs/operators'; import { NavigationEnd } from '@angular/router'; import { trigger, state, style, transition, animate } from '@angular/animations'; import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; /** * Page that displays the main menu of the app. @@ -73,7 +74,7 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { protected navSubscription?: Subscription; protected keyboardObserver?: CoreEventObserver; protected badgeUpdateObserver?: CoreEventObserver; - protected resizeFunction: () => void; + protected resizeListener?: CoreEventObserver; protected backButtonFunction: (event: BackButtonEvent) => void; protected selectHistory: string[] = []; protected firstSelectedTab?: string; @@ -86,7 +87,6 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { tabAction: CoreMainMenuRoleTab; constructor() { - this.resizeFunction = this.initHandlers.bind(this); this.backButtonFunction = this.backButtonClicked.bind(this); this.tabAction = new CoreMainMenuRoleTab(this); @@ -122,7 +122,9 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { } }); - window.addEventListener('resize', this.resizeFunction); + this.resizeListener = CoreDomUtils.onWindowResize(() => { + this.initHandlers(); + }); document.addEventListener('ionBackButton', this.backButtonFunction); if (CoreApp.isIOS()) { @@ -221,10 +223,10 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { ngOnDestroy(): void { this.subscription?.unsubscribe(); this.navSubscription?.unsubscribe(); - window.removeEventListener('resize', this.resizeFunction); document.removeEventListener('ionBackButton', this.backButtonFunction); this.keyboardObserver?.off(); this.badgeUpdateObserver?.off(); + this.resizeListener?.off(); } /** diff --git a/src/core/features/mainmenu/pages/more/more.ts b/src/core/features/mainmenu/pages/more/more.ts index 5a4f6d7b5..d3e91ebfa 100644 --- a/src/core/features/mainmenu/pages/more/more.ts +++ b/src/core/features/mainmenu/pages/more/more.ts @@ -26,6 +26,7 @@ import { CoreContentLinksHelper } from '@features/contentlinks/services/contentl import { CoreTextUtils } from '@services/utils/text'; import { Translate } from '@singletons'; import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager'; +import { CoreDomUtils } from '@services/utils/dom'; /** * Page that displays the more page of the app. @@ -46,6 +47,7 @@ export class CoreMainMenuMorePage implements OnInit, OnDestroy { protected subscription!: Subscription; protected langObserver: CoreEventObserver; protected updateSiteObserver: CoreEventObserver; + protected resizeListener?: CoreEventObserver; constructor() { this.langObserver = CoreEvents.on(CoreEvents.LANGUAGE_CHANGED, this.loadCustomMenuItems.bind(this)); @@ -71,7 +73,9 @@ export class CoreMainMenuMorePage implements OnInit, OnDestroy { this.initHandlers(); }); - window.addEventListener('resize', this.initHandlers.bind(this)); + this.resizeListener = CoreDomUtils.onWindowResize(() => { + this.initHandlers(); + }); const deepLinkManager = new CoreMainMenuDeepLinkManager(); deepLinkManager.treatLink(); @@ -81,10 +85,10 @@ export class CoreMainMenuMorePage implements OnInit, OnDestroy { * @inheritdoc */ ngOnDestroy(): void { - window.removeEventListener('resize', this.initHandlers.bind(this)); this.langObserver?.off(); this.updateSiteObserver?.off(); this.subscription?.unsubscribe(); + this.resizeListener?.off(); } /** diff --git a/src/core/features/mainmenu/services/mainmenu.ts b/src/core/features/mainmenu/services/mainmenu.ts index f1c09fef0..a27b4e943 100644 --- a/src/core/features/mainmenu/services/mainmenu.ts +++ b/src/core/features/mainmenu/services/mainmenu.ts @@ -22,6 +22,7 @@ import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from './mainmenu-d import { Device, makeSingleton } from '@singletons'; import { CoreArray } from '@singletons/array'; import { CoreTextUtils } from '@services/utils/text'; +import { CoreScreen } from '@services/screen'; declare module '@singletons/events' { @@ -47,12 +48,6 @@ export class CoreMainMenuProvider { static readonly MORE_PAGE_NAME = 'more'; static readonly MAIN_MENU_HANDLER_BADGE_UPDATED = 'main_menu_handler_badge_updated'; - protected tablet = false; - - constructor() { - this.tablet = !!(window?.innerWidth && window.innerWidth >= 576 && window.innerHeight >= 576); - } - /** * Get the current main menu handlers. * @@ -227,7 +222,7 @@ export class CoreMainMenuProvider { if (!this.isResponsiveMainMenuItemsDisabledInCurrentSite() && window && window.innerWidth) { let numElements: number; - if (this.tablet) { + if (CoreScreen.isTablet) { // Tablet, menu will be displayed vertically. numElements = Math.floor(window.innerHeight / CoreMainMenuProvider.ITEM_MIN_WIDTH); } else { @@ -250,20 +245,13 @@ export class CoreMainMenuProvider { * @return Tabs placement including side value. */ getTabPlacement(): 'bottom' | 'side' { - const tablet = !!(window.innerWidth && window.innerWidth >= 576 && (window.innerHeight >= 576 || - ((CoreApp.isKeyboardVisible() || CoreApp.isKeyboardOpening()) && window.innerHeight >= 200))); - - if (tablet != this.tablet) { - this.tablet = tablet; - } - - return tablet ? 'side' : 'bottom'; + return CoreScreen.isTablet ? 'side' : 'bottom'; } /** * Check if a certain page is the root of a main menu tab. * - * @param page Name of the page. + * @param pageName Name of the page. * @return Promise resolved with boolean: whether it's the root of a main menu tab. */ async isMainMenuTab(pageName: string): Promise { @@ -277,7 +265,7 @@ export class CoreMainMenuProvider { /** * Check if a certain page is the root of a main menu handler currently displayed. * - * @param page Name of the page. + * @param pageName Name of the page. * @return Promise resolved with boolean: whether it's the root of a main menu handler. */ async isCurrentMainMenuHandler(pageName: string): Promise { diff --git a/src/core/features/settings/services/settings-helper.ts b/src/core/features/settings/services/settings-helper.ts index d8b845583..9f8a1d46a 100644 --- a/src/core/features/settings/services/settings-helper.ts +++ b/src/core/features/settings/services/settings-helper.ts @@ -27,6 +27,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreCourse } from '@features/course/services/course'; import { makeSingleton, Translate } from '@singletons'; import { CoreError } from '@classes/errors/error'; +import { Observable, Subject } from 'rxjs'; /** * Object with space usage and cache entries that can be erased. @@ -64,6 +65,7 @@ export class CoreSettingsHelperProvider { protected prefersDark?: MediaQueryList; protected colorSchemes: CoreColorScheme[] = []; protected currentColorScheme = CoreColorScheme.LIGHT; + protected darkModeObservable = new Subject(); async initialize(): Promise { this.prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); @@ -93,7 +95,9 @@ export class CoreSettingsHelperProvider { } // Listen for changes to the prefers-color-scheme media query. - this.prefersDark.addEventListener && this.prefersDark.addEventListener('change', this.toggleDarkModeListener.bind(this)); + this.prefersDark.addEventListener && this.prefersDark.addEventListener('change', () => { + this.setColorScheme(this.currentColorScheme); + }); // Init zoom level. await this.upgradeZoomLevel(); @@ -444,22 +448,28 @@ export class CoreSettingsHelperProvider { return window.matchMedia('(prefers-color-scheme)').media !== 'not all'; } - /** - * Listener function to toggle dark mode. - */ - protected toggleDarkModeListener(): void { - this.setColorScheme(this.currentColorScheme); - }; - /** * Toggles dark mode based on enabled boolean. * * @param enable True to enable dark mode, false to disable. */ protected toggleDarkMode(enable: boolean = false): void { - document.body.classList.toggle('dark', enable); + const isDark = document.body.classList.contains('dark'); + if (isDark !== enable) { + document.body.classList.toggle('dark', enable); + this.darkModeObservable.next(enable); - CoreApp.setStatusBarColor(); + CoreApp.setStatusBarColor(); + } + } + + /** + * Returns dark mode change observable. + * + * @return Dark mode change observable. + */ + onDarkModeChange(): Observable { + return this.darkModeObservable; } } diff --git a/src/core/initializers/inject-ios-scripts.ts b/src/core/initializers/inject-ios-scripts.ts index a1a728bb2..64b26993d 100644 --- a/src/core/initializers/inject-ios-scripts.ts +++ b/src/core/initializers/inject-ios-scripts.ts @@ -22,5 +22,5 @@ export default async function(): Promise { } await Platform.ready(); - await CoreIframeUtils.injectiOSScripts(window); + CoreIframeUtils.injectiOSScripts(window); } diff --git a/src/core/services/app.ts b/src/core/services/app.ts index 4ab97df7f..cedb147db 100644 --- a/src/core/services/app.ts +++ b/src/core/services/app.ts @@ -437,7 +437,7 @@ export class CoreAppProvider { /** * Set keyboard shown or hidden. * - * @param Whether the keyboard is shown or hidden. + * @param shown Whether the keyboard is shown or hidden. */ protected setKeyboardShown(shown: boolean): void { this.isKeyboardShown = shown; diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index 390fe0c53..7b568abb8 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -53,6 +53,7 @@ import { NavigationStart } from '@angular/router'; import { filter } from 'rxjs/operators'; import { Subscription } from 'rxjs'; import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreEventObserver, CoreSingleTimeEventObserver } from '@singletons/events'; /* * "Utils" service with helper functions for UI, DOM elements and HTML code. @@ -95,36 +96,74 @@ export class CoreDomUtilsProvider { * 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. + * @param timeout If defined, timeout to wait before rejecting the promise. + * @return Promise CoreSingleTimeEventObserver with a promise. */ - async waitToBeInDOM( + waitToBeInDOM( element: Element, - ): Promise { + timeout?: number, + ): CoreSingleTimeEventObserver { let root = element.getRootNode({ composed: true }); if (root === document) { // Already in DOM. - return; + return { + off: (): void => { + // Nothing to do here. + }, + promise: Promise.resolve(), + }; } - return new Promise((resolve, reject) => { - // Disconnect observer for performance reasons. - const timeout = window.setTimeout(() => { - reject(new Error('Waiting for DOM timeout reached')); - observer.disconnect(); - }, 5000); + let observer: MutationObserver | undefined; + let observerTimeout: number | undefined; + if (timeout) { + observerTimeout = window.setTimeout(() => { + observer?.disconnect(); + throw new Error('Waiting for DOM timeout reached'); + }, timeout); + } - const observer = new MutationObserver(() => { - root = element.getRootNode({ composed: true }); - if (root === document) { - observer.disconnect(); - clearTimeout(timeout); - resolve(); - } - }); + return { + off: (): void => { + observer?.disconnect(); + clearTimeout(observerTimeout); + }, + promise: new Promise((resolve) => { + observer = new MutationObserver(() => { + root = element.getRootNode({ composed: true }); + if (root === document) { + observer?.disconnect(); + clearTimeout(observerTimeout); + resolve(); + } + }); - observer.observe(document.body, { subtree: true, childList: true }); - }); + observer.observe(document.body, { subtree: true, childList: true }); + }), + }; + } + + /** + * Window resize is widely checked and may have many performance issues, debouce usage is needed to avoid calling it too much. + * This function helps setting up the debounce feature and remove listener easily. + * + * @param resizeFunction Function to execute on resize. + * @param debounceDelay Debounce time in ms. + * @return Event observer to call off when finished. + */ + onWindowResize(resizeFunction: (ev?: Event) => void, debounceDelay = 20): CoreEventObserver { + const resizeListener = CoreUtils.debounce((ev?: Event) => { + resizeFunction(ev); + }, debounceDelay); + + window.addEventListener('resize', resizeListener); + + return { + off: (): void => { + window.removeEventListener('resize', resizeListener); + }, + }; } /** @@ -431,6 +470,7 @@ export class CoreDomUtilsProvider { * @param useBorder Whether to use borders to calculate the measure. * @param innerMeasure If inner measure is needed: padding, margin or borders will be substracted. * @return Height in pixels. + * @deprecated since app 4.0 Use getBoundingClientRect.height instead. */ getElementHeight( element: HTMLElement, @@ -452,6 +492,7 @@ export class CoreDomUtilsProvider { * @param useBorder Whether to use borders to calculate the measure. * @param innerMeasure If inner measure is needed: padding, margin or borders will be substracted. * @return Measure in pixels. + * @deprecated since app 4.0 Use getBoundingClientRect.height or width instead. */ getElementMeasure( element: HTMLElement, @@ -524,6 +565,7 @@ export class CoreDomUtilsProvider { * @param useBorder Whether to use borders to calculate the measure. * @param innerMeasure If inner measure is needed: padding, margin or borders will be substracted. * @return Width in pixels. + * @deprecated since app 4.0 Use getBoundingClientRect.width instead. */ getElementWidth( element: HTMLElement, @@ -703,6 +745,7 @@ export class CoreDomUtilsProvider { * * @param findFunction The function used to find the element. * @return Resolved if found, rejected if too many tries. + * @deprecated since app 4.0 Use waitToBeInDOM instead. */ waitElementToExist(findFunction: () => HTMLElement | null): Promise { const promiseInterval = CoreUtils.promiseDefer(); @@ -1054,7 +1097,7 @@ export class CoreDomUtilsProvider { const scrollElement = await content.getScrollElement(); return scrollElement.clientHeight || 0; - } catch (error) { + } catch { return 0; } } @@ -1070,7 +1113,7 @@ export class CoreDomUtilsProvider { const scrollElement = await content.getScrollElement(); return scrollElement.scrollHeight || 0; - } catch (error) { + } catch { return 0; } } diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 4045c8212..f69e0ea0a 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -31,6 +31,21 @@ export interface CoreEventObserver { off: () => void; } +/** + * Observer instance to stop listening to an observer. + */ +export interface CoreSingleTimeEventObserver { + /** + * Stop the observer. + */ + off: () => void; + + /** + * Promise Resolved when event is done (first time). + */ + promise: Promise; +} + /** * Event payloads. */ diff --git a/src/theme/components/collapsible-header.scss b/src/theme/components/collapsible-header.scss index c0650635a..a5bfd6a22 100644 --- a/src/theme/components/collapsible-header.scss +++ b/src/theme/components/collapsible-header.scss @@ -8,6 +8,7 @@ --collapsible-header-floating-title-width: 0px; --collapsible-header-floating-title-x-delta: 0px; --collapsible-header-floating-title-width-delta: 0px; + ion-header.core-header-shadow { --core-header-shadow: none; } @@ -19,9 +20,8 @@ .collapsible-header-floating-title { position: absolute; - top: var(--collapsible-header-floating-title-top); - left: var(--collapsible-header-floating-title-left); - transform: translateX(calc(var(--collapsible-header-floating-title-x-delta) * var(--collapsible-header-progress))); + top: 0; + left: 0; opacity: 0; } @@ -52,6 +52,9 @@ .collapsible-header-floating-title { opacity: 1; + top: var(--collapsible-header-floating-title-top); + left: var(--collapsible-header-floating-title-left); + transform: translateX(calc(var(--collapsible-header-floating-title-x-delta) * var(--collapsible-header-progress))); width: calc(var(--collapsible-header-floating-title-width) + var(--collapsible-header-progress) * var(--collapsible-header-floating-title-width-delta)); @include core-transition(width transform, 200ms, linear); diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 1396e9a71..250f07ba4 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -1421,37 +1421,42 @@ ion-grid.core-no-grid > ion-row { } [collapsible-footer] { - &.footer-collapsed { - --core-collapsible-footer-height: 0; - opacity: 0; - } - &.has-module-nav.footer-collapsed { - --core-collapsible-footer-height: auto; - opacity: 1; - core-course-module-navigation { - height: 0; - opacity: 0; - @include core-transition(all, 200ms); - } - - } - &.footer-expanded { - --core-collapsible-footer-height: auto; - } + box-shadow: var(--drop-shadow-top, none); + width: 100%; + bottom: 0; + z-index: 3; + display: block; + background-color: var(--core-collapsible-footer-background); .ion-margin { margin-top: 8px; margin-bottom: 8px; } - box-shadow: var(--drop-shadow-top, none); - width: 100%; - bottom: 0; - z-index: 3; - height: var(--core-collapsible-footer-height, auto); - background-color: var(--core-collapsible-footer-background); - display: block; - @include core-transition(all, 200ms); + &.is-active { + height: var(--core-collapsible-footer-height, auto); + @include core-transition(all, 200ms); + + &.footer-collapsed { + --core-collapsible-footer-height: 0; + opacity: 0; + } + &.has-module-nav.footer-collapsed { + --core-collapsible-footer-height: auto; + opacity: 1; + core-course-module-navigation { + height: 0; + opacity: 0; + @include core-transition(all, 200ms); + } + + } + &.footer-expanded { + --core-collapsible-footer-height: auto; + } + + + } } .core-iframe-fullscreen [collapsible-footer] { diff --git a/src/theme/theme.light.scss b/src/theme/theme.light.scss index c19cac3bf..d736a833c 100644 --- a/src/theme/theme.light.scss +++ b/src/theme/theme.light.scss @@ -329,7 +329,6 @@ --core-courseimage-on-course-size: 72px; --core-courseimage-radius: var(--medium-radius); - --core-collapsible-footer-height: 48px; --core-navigation-background: var(--contrast-background); --core-collapsible-footer-background: var(--contrast-background);