2021-11-19 12:20:00 +00:00
|
|
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
|
|
|
//
|
|
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
// you may not use this file except in compliance with the License.
|
|
|
|
// You may obtain a copy of the License at
|
|
|
|
//
|
|
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
//
|
|
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
// See the License for the specific language governing permissions and
|
|
|
|
// limitations under the License.
|
|
|
|
|
|
|
|
import { Directive, ElementRef, OnDestroy } from '@angular/core';
|
|
|
|
import { ScrollDetail } from '@ionic/core';
|
|
|
|
import { CoreUtils } from '@services/utils/utils';
|
|
|
|
import { Platform } from '@singletons';
|
|
|
|
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
|
|
|
import { CoreMath } from '@singletons/math';
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Directive to make ion-header collapsible.
|
|
|
|
* Ion content should have h1 tag inside.
|
|
|
|
*
|
|
|
|
* Example usage:
|
|
|
|
*
|
|
|
|
* <ion-header collapsible>
|
|
|
|
*/
|
|
|
|
@Directive({
|
|
|
|
selector: 'ion-header[collapsible]',
|
|
|
|
})
|
|
|
|
export class CoreCollapsibleHeaderDirective implements OnDestroy {
|
|
|
|
|
|
|
|
protected loadingObserver: CoreEventObserver;
|
|
|
|
protected content?: HTMLIonContentElement | null;
|
2022-02-15 11:43:14 +00:00
|
|
|
protected contentScroll?: HTMLElement;
|
2021-11-19 12:20:00 +00:00
|
|
|
protected header: HTMLIonHeaderElement;
|
|
|
|
protected titleTopDifference = 1;
|
|
|
|
protected h1StartDifference = 0;
|
|
|
|
protected headerH1FontSize = 0;
|
|
|
|
protected contentH1FontSize = 0;
|
|
|
|
protected headerSubHeadingFontSize = 0;
|
|
|
|
protected contentSubHeadingFontSize = 0;
|
|
|
|
protected subHeadingStartDifference = 0;
|
2022-01-24 10:19:34 +00:00
|
|
|
protected inContent = true;
|
|
|
|
protected title?: HTMLElement | null;
|
|
|
|
protected titleHeight = 0;
|
|
|
|
protected contentH1?: HTMLElement | null;
|
2022-02-15 11:43:14 +00:00
|
|
|
protected debouncedUpdateCollapseProgress: () => void;
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-01-18 13:44:43 +00:00
|
|
|
constructor(el: ElementRef<HTMLIonHeaderElement>) {
|
2021-11-19 12:20:00 +00:00
|
|
|
this.header = el.nativeElement;
|
2022-02-15 11:43:14 +00:00
|
|
|
this.debouncedUpdateCollapseProgress = CoreUtils.debounce(() => this.updateCollapseProgress(), 50);
|
2021-11-19 12:20:00 +00:00
|
|
|
|
|
|
|
this.loadingObserver = CoreEvents.on(CoreEvents.CORE_LOADING_CHANGED, async (data) => {
|
2021-12-01 15:19:26 +00:00
|
|
|
if (!data.loaded) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-11-19 12:20:00 +00:00
|
|
|
const loadingId = await this.getLoadingId();
|
2021-12-01 15:19:26 +00:00
|
|
|
if (loadingId && data.uniqueId == loadingId) {
|
2021-11-19 12:20:00 +00:00
|
|
|
// Remove event when loading is done.
|
|
|
|
this.loadingObserver.off();
|
|
|
|
|
|
|
|
// Wait to render.
|
|
|
|
await CoreUtils.nextTick();
|
|
|
|
this.setupRealTitle();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2022-02-15 11:43:14 +00:00
|
|
|
/**
|
|
|
|
* Set content element.
|
|
|
|
*
|
|
|
|
* @param content Content element.
|
|
|
|
*/
|
|
|
|
protected async setContent(content?: HTMLIonContentElement | null): Promise<void> {
|
|
|
|
this.content = content;
|
|
|
|
|
|
|
|
if (content) {
|
|
|
|
this.contentScroll = await content.getScrollElement();
|
|
|
|
} else {
|
|
|
|
delete this.contentScroll;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-11-19 12:20:00 +00:00
|
|
|
/**
|
|
|
|
* Gets the loading content id to wait for the loading to finish.
|
|
|
|
*
|
|
|
|
* @return Promise resolved with Loading Id, if any.
|
|
|
|
*/
|
|
|
|
protected async getLoadingId(): Promise<string | undefined> {
|
|
|
|
if (!this.content) {
|
2022-02-15 11:43:14 +00:00
|
|
|
this.setContent(this.header.parentElement?.querySelector('ion-content:not(.disable-scroll-y)'));
|
2021-11-19 12:20:00 +00:00
|
|
|
|
|
|
|
if (!this.content) {
|
|
|
|
this.cannotCollapse();
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
2022-01-24 10:19:34 +00:00
|
|
|
|
|
|
|
const title = this.header.parentElement?.querySelector('.collapsible-title') || null;
|
|
|
|
|
|
|
|
if (title) {
|
|
|
|
// Title already found, no need to wait for loading.
|
|
|
|
this.loadingObserver.off();
|
|
|
|
this.setupRealTitle();
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
2021-12-01 15:19:26 +00:00
|
|
|
return this.content.querySelector('core-loading.core-loading-loaded:not(.core-loading-inline) .core-loading-content')?.id;
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Call this function when header is not collapsible.
|
|
|
|
*/
|
|
|
|
protected cannotCollapse(): void {
|
2022-02-15 11:43:14 +00:00
|
|
|
this.setContent();
|
2021-11-19 12:20:00 +00:00
|
|
|
this.loadingObserver.off();
|
|
|
|
this.header.classList.add('core-header-collapsed');
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gets the real title on ion content to watch scroll.
|
|
|
|
*
|
|
|
|
* @return Promise resolved when done.
|
|
|
|
*/
|
|
|
|
protected async setupRealTitle(): Promise<void> {
|
|
|
|
if (!this.content) {
|
|
|
|
this.cannotCollapse();
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-12-09 11:28:44 +00:00
|
|
|
// Wait animations to finish.
|
2021-12-15 11:59:27 +00:00
|
|
|
const animations = (this.content.getAnimations && this.content.getAnimations()) || [];
|
2021-12-09 11:28:44 +00:00
|
|
|
await Promise.all(animations.map(async (animation) => {
|
|
|
|
await animation.finished;
|
|
|
|
}));
|
|
|
|
|
2022-01-24 10:19:34 +00:00
|
|
|
let title = this.content.querySelector<HTMLElement>('.collapsible-title');
|
|
|
|
if (!title) {
|
|
|
|
// Title is outside the ion-content.
|
|
|
|
title = this.header.parentElement?.querySelector('.collapsible-title') || null;
|
|
|
|
this.inContent = false;
|
|
|
|
}
|
|
|
|
this.contentH1 = title?.querySelector<HTMLElement>('h1');
|
2021-11-19 12:20:00 +00:00
|
|
|
const headerH1 = this.header.querySelector<HTMLElement>('h1');
|
2022-01-24 10:19:34 +00:00
|
|
|
if (!title || !this.contentH1 || !headerH1 || !this.contentH1.parentElement) {
|
2021-11-19 12:20:00 +00:00
|
|
|
this.cannotCollapse();
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-01-24 10:19:34 +00:00
|
|
|
this.title = title;
|
|
|
|
this.titleHeight = title.getBoundingClientRect().height;
|
|
|
|
this.titleTopDifference = this.contentH1.getBoundingClientRect().top - headerH1.getBoundingClientRect().top;
|
|
|
|
|
2022-01-18 13:44:43 +00:00
|
|
|
if (this.titleTopDifference <= 0) {
|
|
|
|
this.cannotCollapse();
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
2021-11-19 12:20:00 +00:00
|
|
|
|
|
|
|
// Split view part.
|
|
|
|
const contentAux = this.header.parentElement?.querySelector<HTMLElement>('ion-content.disable-scroll-y');
|
|
|
|
if (contentAux) {
|
|
|
|
if (contentAux.querySelector('core-split-view.menu-and-content')) {
|
|
|
|
this.cannotCollapse();
|
|
|
|
title.remove();
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
contentAux.style.setProperty('--offset-top', this.header.clientHeight + 'px');
|
|
|
|
}
|
|
|
|
|
|
|
|
const headerH1Styles = getComputedStyle(headerH1);
|
2022-01-24 10:19:34 +00:00
|
|
|
const contentH1Styles = getComputedStyle(this.contentH1);
|
2021-11-19 12:20:00 +00:00
|
|
|
|
|
|
|
if (Platform.isRTL) {
|
2021-12-09 11:28:44 +00:00
|
|
|
// Checking position over parent because transition may not be finished.
|
2022-01-24 10:19:34 +00:00
|
|
|
const contentH1Position = this.contentH1.getBoundingClientRect().right - this.content.getBoundingClientRect().right;
|
2021-12-09 11:28:44 +00:00
|
|
|
const headerH1Position = headerH1.getBoundingClientRect().right - this.header.getBoundingClientRect().right;
|
|
|
|
|
|
|
|
this.h1StartDifference = Math.round(contentH1Position - (headerH1Position - parseFloat(headerH1Styles.paddingRight)));
|
2021-11-19 12:20:00 +00:00
|
|
|
} else {
|
2021-12-09 11:28:44 +00:00
|
|
|
// Checking position over parent because transition may not be finished.
|
2022-01-24 10:19:34 +00:00
|
|
|
const contentH1Position = this.contentH1.getBoundingClientRect().left - this.content.getBoundingClientRect().left;
|
2021-12-09 11:28:44 +00:00
|
|
|
const headerH1Position = headerH1.getBoundingClientRect().left - this.header.getBoundingClientRect().left;
|
|
|
|
|
|
|
|
this.h1StartDifference = Math.round(contentH1Position - (headerH1Position + parseFloat(headerH1Styles.paddingLeft)));
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
this.headerH1FontSize = parseFloat(headerH1Styles.fontSize);
|
|
|
|
this.contentH1FontSize = parseFloat(contentH1Styles.fontSize);
|
|
|
|
|
|
|
|
// Transfer font styles.
|
|
|
|
Array.from(headerH1Styles).forEach((styleName) => {
|
2021-12-09 11:28:44 +00:00
|
|
|
if (styleName != 'font-size' &&
|
|
|
|
styleName != 'font-family' &&
|
|
|
|
(styleName.startsWith('font-') || styleName.startsWith('letter-'))) {
|
2022-01-24 10:19:34 +00:00
|
|
|
this.contentH1?.style.setProperty(styleName, headerH1Styles.getPropertyValue(styleName));
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
});
|
2022-01-24 10:19:34 +00:00
|
|
|
this.contentH1.style.setProperty(
|
2021-11-19 12:20:00 +00:00
|
|
|
'--max-width',
|
|
|
|
(parseFloat(headerH1Styles.width)
|
|
|
|
-parseFloat(headerH1Styles.paddingLeft)
|
|
|
|
-parseFloat(headerH1Styles.paddingRight)
|
|
|
|
+'px'),
|
|
|
|
);
|
|
|
|
|
2022-01-24 10:19:34 +00:00
|
|
|
this.contentH1.setAttribute('aria-hidden', 'true');
|
|
|
|
|
|
|
|
// Clone element to let the other elements be static.
|
|
|
|
const contentH1Clone = this.contentH1.cloneNode(true) as HTMLElement;
|
|
|
|
contentH1Clone.classList.add('cloned');
|
|
|
|
this.contentH1.parentElement.insertBefore(contentH1Clone, this.contentH1);
|
|
|
|
this.contentH1.style.setProperty(
|
|
|
|
'top',
|
|
|
|
(contentH1Clone.getBoundingClientRect().top -
|
|
|
|
this.contentH1.parentElement.getBoundingClientRect().top +
|
|
|
|
parseInt(getComputedStyle(this.contentH1.parentElement).marginTop || '0', 10)) + 'px',
|
|
|
|
);
|
|
|
|
this.contentH1.style.setProperty('position', 'absolute');
|
2022-02-11 12:28:12 +00:00
|
|
|
this.contentH1.parentElement.style.setProperty('position', 'relative');
|
2022-01-24 10:19:34 +00:00
|
|
|
|
|
|
|
this.setupContent();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Setup content scroll.
|
|
|
|
*
|
|
|
|
* @param parentId Parent id to recalculate content
|
|
|
|
* @param retries Retries to find content in case it's loading.
|
|
|
|
*/
|
|
|
|
async setupContent(parentId?: string, retries = 5): Promise<void> {
|
|
|
|
if (parentId) {
|
2022-02-15 11:43:14 +00:00
|
|
|
this.setContent(this.header.parentElement?.querySelector(`#${parentId} ion-content:not(.disable-scroll-y)`));
|
2022-01-24 10:19:34 +00:00
|
|
|
this.inContent = false;
|
|
|
|
if (!this.content && retries > 0) {
|
|
|
|
await CoreUtils.nextTick();
|
|
|
|
await this.setupContent(parentId, --retries);
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-02-15 11:43:14 +00:00
|
|
|
this.updateCollapseProgress();
|
2022-01-24 10:19:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (!this.title || !this.content) {
|
|
|
|
return;
|
|
|
|
}
|
2021-11-19 12:20:00 +00:00
|
|
|
|
|
|
|
// Add something under the hood to change the page background.
|
2022-01-24 10:19:34 +00:00
|
|
|
let color = getComputedStyle(this.title).getPropertyValue('backgroundColor').trim();
|
2021-11-19 12:20:00 +00:00
|
|
|
if (color == '') {
|
2022-01-24 10:19:34 +00:00
|
|
|
color = getComputedStyle(this.title).getPropertyValue('--background').trim();
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const underHeader = document.createElement('div');
|
|
|
|
underHeader.classList.add('core-underheader');
|
|
|
|
underHeader.style.setProperty('height', this.header.clientHeight + 'px');
|
|
|
|
underHeader.style.setProperty('background', color);
|
2022-01-24 10:19:34 +00:00
|
|
|
if (this.inContent) {
|
|
|
|
this.content.shadowRoot?.querySelector('#background-content')?.prepend(underHeader);
|
|
|
|
this.content.style.setProperty('--offset-top', this.header.clientHeight + 'px');
|
|
|
|
} else {
|
|
|
|
if (!this.header.closest('.ion-page')?.querySelector('.core-underheader')) {
|
|
|
|
this.header.closest('.ion-page')?.insertBefore(underHeader, this.header);
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.content.scrollEvents = true;
|
2022-02-15 11:43:14 +00:00
|
|
|
this.content.addEventListener('ionScroll', ({ target }: CustomEvent<ScrollDetail>): void => {
|
|
|
|
if (target !== this.content) {
|
|
|
|
return;
|
2022-01-18 13:44:43 +00:00
|
|
|
}
|
2022-02-15 11:43:14 +00:00
|
|
|
|
|
|
|
this.updateCollapseProgress();
|
|
|
|
this.debouncedUpdateCollapseProgress();
|
2021-11-19 12:20:00 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2022-02-15 11:43:14 +00:00
|
|
|
* Update collapse progress according to the current scroll position.
|
2021-11-19 12:20:00 +00:00
|
|
|
*/
|
2022-02-15 11:43:14 +00:00
|
|
|
protected updateCollapseProgress(): void {
|
|
|
|
if (!this.contentScroll || !this.title || !this.contentH1) {
|
2022-01-24 10:19:34 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-02-15 11:43:14 +00:00
|
|
|
const collapsibleHeaderHeight = this.title.shadowRoot?.children[0].clientHeight ?? this.title.clientHeight;
|
|
|
|
const scrollableHeight = this.contentScroll.scrollHeight - this.contentScroll.clientHeight;
|
|
|
|
const collapsedHeight = collapsibleHeaderHeight - this.title.clientHeight;
|
2022-02-17 13:16:49 +00:00
|
|
|
let progress = 0;
|
|
|
|
if (scrollableHeight !== 0) {
|
|
|
|
progress = CoreMath.clamp(
|
|
|
|
scrollableHeight + collapsedHeight <= 2 * collapsibleHeaderHeight
|
|
|
|
? this.contentScroll.scrollTop / scrollableHeight
|
|
|
|
: this.contentScroll.scrollTop / collapsibleHeaderHeight,
|
|
|
|
0,
|
|
|
|
1,
|
|
|
|
);
|
|
|
|
}
|
2022-02-15 11:43:14 +00:00
|
|
|
const collapsed = progress === 1;
|
2021-11-19 12:20:00 +00:00
|
|
|
|
2022-01-24 10:19:34 +00:00
|
|
|
if (!this.inContent) {
|
2022-02-15 11:43:14 +00:00
|
|
|
this.title.style.transform = `translateY(-${this.titleTopDifference * progress}px)`;
|
|
|
|
this.title.style.height = `${collapsibleHeaderHeight * (1 - progress)}px`;
|
2022-01-24 10:19:34 +00:00
|
|
|
}
|
|
|
|
|
2021-11-19 12:20:00 +00:00
|
|
|
// Check total collapse.
|
|
|
|
this.header.classList.toggle('core-header-collapsed', collapsed);
|
2022-01-24 10:19:34 +00:00
|
|
|
this.title.classList.toggle('collapsible-title-collapsed', collapsed);
|
2022-02-15 11:43:14 +00:00
|
|
|
this.title.classList.toggle('collapsible-title-collapse-started', progress > 0);
|
2022-01-24 10:19:34 +00:00
|
|
|
this.title.classList.toggle('collapsible-title-collapse-nowrap', progress > 0.5);
|
2022-02-15 11:43:14 +00:00
|
|
|
this.title.style.setProperty('--collapse-opacity', `${1 - progress}`);
|
2021-11-19 12:20:00 +00:00
|
|
|
|
|
|
|
if (collapsed) {
|
2022-02-15 11:43:14 +00:00
|
|
|
this.contentH1.style.transform = `translateX(-${this.h1StartDifference}px)`;
|
|
|
|
this.contentH1.style.setProperty('font-size', `${this.headerH1FontSize}px`);
|
2021-11-19 12:20:00 +00:00
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Zoom font-size out.
|
|
|
|
const newFontSize = this.contentH1FontSize - ((this.contentH1FontSize - this.headerH1FontSize) * progress);
|
2022-02-15 11:43:14 +00:00
|
|
|
this.contentH1.style.setProperty('font-size', `${newFontSize}px`);
|
2021-11-19 12:20:00 +00:00
|
|
|
|
|
|
|
// Move.
|
2022-02-15 11:43:14 +00:00
|
|
|
const newStart = -this.h1StartDifference * progress;
|
|
|
|
this.contentH1.style.transform = `translateX(${newStart}px)`;
|
2021-11-19 12:20:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @inheritdoc
|
|
|
|
*/
|
|
|
|
ngOnDestroy(): void {
|
|
|
|
this.loadingObserver.off();
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|