291 lines
9.6 KiB
TypeScript

// (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 {
Component, Input, Output, ViewChild, ElementRef, EventEmitter, OnChanges, SimpleChange, OnDestroy,
} from '@angular/core';
import { SafeResourceUrl } from '@angular/platform-browser';
import { CoreFile } from '@services/file';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUrl } from '@singletons/url';
import { CoreIframeUtils } from '@services/utils/iframe';
import { DomSanitizer, Router, StatusBar } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreScreen, CoreScreenOrientation } from '@services/screen';
import { Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import { NavigationStart } from '@angular/router';
import { CoreSites } from '@services/sites';
import { toBoolean } from '@/core/transforms/boolean';
@Component({
selector: 'core-iframe',
templateUrl: 'core-iframe.html',
styleUrls: ['iframe.scss'],
})
export class CoreIframeComponent implements OnChanges, OnDestroy {
static loadingTimeout = 15000;
@ViewChild('iframe') set iframeElement(iframeRef: ElementRef | undefined) {
this.iframe = iframeRef?.nativeElement;
this.initIframeElement();
}
@Input() src?: string;
@Input() id: string | null = null;
@Input() iframeWidth = '100%';
@Input() iframeHeight = '100%';
@Input({ transform: toBoolean }) allowFullscreen = false;
@Input({ transform: toBoolean }) showFullscreenOnToolbar = false;
@Input({ transform: toBoolean }) autoFullscreenOnRotate = false;
@Input({ transform: toBoolean }) allowAutoLogin = true;
@Output() loaded: EventEmitter<HTMLIFrameElement> = new EventEmitter<HTMLIFrameElement>();
loading?: boolean;
safeUrl?: SafeResourceUrl;
displayHelp = false;
fullscreen = false;
launchExternalLabel?: string; // Text to set to the button to launch external app.
initialized = false;
protected iframe?: HTMLIFrameElement;
protected style?: HTMLStyleElement;
protected orientationObs?: CoreEventObserver;
protected navSubscription?: Subscription;
protected messageListenerFunction: (event: MessageEvent) => Promise<void>;
constructor(protected elementRef: ElementRef<HTMLElement>) {
this.loaded = new EventEmitter<HTMLIFrameElement>();
// Listen for messages from the iframe.
window.addEventListener('message', this.messageListenerFunction = (event) => this.onIframeMessage(event));
}
/**
* Init the data.
*/
protected init(): void {
if (this.initialized) {
return;
}
this.initialized = true;
if (this.showFullscreenOnToolbar || this.autoFullscreenOnRotate) {
// Leave fullscreen when navigating.
this.navSubscription = Router.events
.pipe(filter(event => event instanceof NavigationStart))
.subscribe(async () => {
if (this.fullscreen) {
this.toggleFullscreen(false);
}
});
const shadow =
this.elementRef.nativeElement.closest('.ion-page')?.querySelector('ion-header ion-toolbar')?.shadowRoot;
if (shadow) {
this.style = document.createElement('style');
shadow.appendChild(this.style);
}
if (this.autoFullscreenOnRotate) {
this.toggleFullscreen(CoreScreen.isLandscape);
this.orientationObs = CoreEvents.on(CoreEvents.ORIENTATION_CHANGE, (data) => {
if (this.isInHiddenPage()) {
return;
}
this.toggleFullscreen(data.orientation == CoreScreenOrientation.LANDSCAPE);
});
}
}
// Show loading only with external URLs.
this.loading = !this.src || !CoreUrl.isLocalFileUrl(this.src);
if (this.loading) {
setTimeout(() => {
this.loading = false;
}, CoreIframeComponent.loadingTimeout);
}
}
/**
* Initialize things related to the iframe element.
*/
protected initIframeElement(): void {
if (!this.iframe) {
return;
}
CoreIframeUtils.treatFrame(this.iframe, false);
this.iframe.addEventListener('load', () => {
this.loading = false;
this.loaded.emit(this.iframe); // Notify iframe was loaded.
});
this.iframe.addEventListener('error', () => {
this.loading = false;
CoreDomUtils.showErrorModal('core.errorloadingcontent', true);
});
}
/**
* Check if the element is in a hidden page.
*
* @returns Whether the element is in a hidden page.
*/
protected isInHiddenPage(): boolean {
// If we can't find the parent ion-page, consider it to be hidden too.
return !this.elementRef.nativeElement.closest('.ion-page') || !!this.elementRef.nativeElement.closest('.ion-page-hidden');
}
/**
* Detect changes on input properties.
*/
async ngOnChanges(changes: {[name: string]: SimpleChange }): Promise<void> {
if (changes.iframeWidth) {
this.iframeWidth = (this.iframeWidth && CoreDomUtils.formatPixelsSize(this.iframeWidth)) || '100%';
}
if (changes.iframeHeight) {
this.iframeHeight = (this.iframeHeight && CoreDomUtils.formatPixelsSize(this.iframeHeight)) || '100%';
}
if (!changes.src) {
return;
}
let url = this.src;
if (url) {
const { launchExternal, label } = CoreIframeUtils.frameShouldLaunchExternal(url);
if (launchExternal) {
this.launchExternalLabel = label;
this.loading = false;
return;
}
}
this.launchExternalLabel = undefined;
if (url && !CoreUrl.isLocalFileUrl(url)) {
url = CoreUrl.getYoutubeEmbedUrl(url) || url;
this.displayHelp = CoreIframeUtils.shouldDisplayHelpForUrl(url);
const currentSite = CoreSites.getCurrentSite();
if (this.allowAutoLogin && currentSite) {
// Format the URL to add auto-login if needed.
url = await currentSite.getAutoLoginUrl(url, false);
}
if (currentSite?.isVersionGreaterEqualThan('3.7') && CoreUrl.isVimeoVideoUrl(url)) {
// Only treat the Vimeo URL if site is 3.7 or bigger. In older sites the width and height params were mandatory,
// and there was no easy way to make the iframe responsive.
url = CoreUrl.getVimeoPlayerUrl(url, currentSite) ?? url;
}
await CoreIframeUtils.fixIframeCookies(url);
}
this.safeUrl = url ? DomSanitizer.bypassSecurityTrustResourceUrl(CoreFile.convertFileSrc(url)) : undefined;
// Now that the URL has been set, initialize the iframe. Wait for the iframe to the added to the DOM.
setTimeout(() => {
this.init();
});
}
/**
* Open help modal for iframes.
*/
openIframeHelpModal(): void {
CoreIframeUtils.openIframeHelpModal();
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.orientationObs?.off();
this.navSubscription?.unsubscribe();
window.removeEventListener('message', this.messageListenerFunction);
}
/**
* Toggle fullscreen mode.
*/
toggleFullscreen(enable?: boolean, notifyIframe = true): void {
if (enable !== undefined) {
this.fullscreen = enable;
} else {
this.fullscreen = !this.fullscreen;
}
this.fullscreen ? StatusBar.hide() : StatusBar.show();
if (this.style) {
// Done this way because of the shadow DOM.
this.style.textContent = this.fullscreen
? '@media screen and (orientation: landscape) {\
.core-iframe-fullscreen .toolbar-container { flex-direction: column-reverse !important; height: 100%; } }'
: '';
}
document.body.classList.toggle('core-iframe-fullscreen', this.fullscreen);
if (notifyIframe && this.iframe) {
this.iframe.contentWindow?.postMessage(
this.fullscreen ? 'enterFullScreen' : 'exitFullScreen',
'*',
);
}
}
/**
* Treat an iframe message event.
*
* @param event Event.
* @returns Promise resolved when done.
*/
protected async onIframeMessage(event: MessageEvent): Promise<void> {
if (event.data == 'enterFullScreen' && this.showFullscreenOnToolbar && !this.fullscreen) {
this.toggleFullscreen(true, false);
} else if (event.data == 'exitFullScreen' && this.fullscreen) {
this.toggleFullscreen(false, false);
}
}
/**
* Launch content in an external app.
*/
launchExternal(): void {
if (!this.src) {
return;
}
CoreIframeUtils.frameLaunchExternal(this.src, {
site: CoreSites.getCurrentSite(),
});
}
}