From 44da5c36d12964be694bf3e4481f84dc1133ca81 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 22 Oct 2020 09:45:39 +0200 Subject: [PATCH] MOBILE-3565 directives: Implement external-content directive --- src/app/directives/directives.module.ts | 3 + src/app/directives/external-content.ts | 364 ++++++++++++++++++++++++ src/app/directives/format-text.ts | 22 +- 3 files changed, 384 insertions(+), 5 deletions(-) create mode 100644 src/app/directives/external-content.ts diff --git a/src/app/directives/directives.module.ts b/src/app/directives/directives.module.ts index 5e8a40358..e23a738a3 100644 --- a/src/app/directives/directives.module.ts +++ b/src/app/directives/directives.module.ts @@ -15,6 +15,7 @@ import { NgModule } from '@angular/core'; import { CoreAutoFocusDirective } from './auto-focus'; +import { CoreExternalContentDirective } from './external-content'; import { CoreFormatTextDirective } from './format-text'; import { CoreLongPressDirective } from './long-press.directive'; import { CoreSupressEventsDirective } from './supress-events'; @@ -22,6 +23,7 @@ import { CoreSupressEventsDirective } from './supress-events'; @NgModule({ declarations: [ CoreAutoFocusDirective, + CoreExternalContentDirective, CoreFormatTextDirective, CoreLongPressDirective, CoreSupressEventsDirective, @@ -29,6 +31,7 @@ import { CoreSupressEventsDirective } from './supress-events'; imports: [], exports: [ CoreAutoFocusDirective, + CoreExternalContentDirective, CoreFormatTextDirective, CoreLongPressDirective, CoreSupressEventsDirective, diff --git a/src/app/directives/external-content.ts b/src/app/directives/external-content.ts new file mode 100644 index 000000000..0b228312f --- /dev/null +++ b/src/app/directives/external-content.ts @@ -0,0 +1,364 @@ +// (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, Input, AfterViewInit, ElementRef, OnChanges, SimpleChange, Output, EventEmitter } from '@angular/core'; + +import { CoreApp } from '@services/app'; +import { CoreFile } from '@services/file'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { Platform } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreError } from '@classes/errors/error'; + +/** + * Directive to handle external content. + * + * This directive should be used with any element that links to external content + * which we want to have available when the app is offline. Typically media and links. + * + * If a file is downloaded, its URL will be replaced by the local file URL. + * + * From v3.5.2 this directive will also download inline styles, so it can be used in any element as long as it has inline styles. + */ +@Directive({ + selector: '[core-external-content]', +}) +export class CoreExternalContentDirective implements AfterViewInit, OnChanges { + + @Input() siteId?: string; // Site ID to use. + @Input() component?: string; // Component to link the file to. + @Input() componentId?: string | number; // Component ID to use in conjunction with the component. + @Input() src?: string; + @Input() href?: string; + @Input('target-src') targetSrc?: string; // eslint-disable-line @angular-eslint/no-input-rename + @Input() poster?: string; + // eslint-disable-next-line @angular-eslint/no-output-on-prefix + @Output() onLoad = new EventEmitter(); // Emitted when content is loaded. Only for images. + + loaded = false; + invalid = false; + protected element: Element; + protected logger: CoreLogger; + protected initialized = false; + + constructor(element: ElementRef) { + + this.element = element.nativeElement; + this.logger = CoreLogger.getInstance('CoreExternalContentDirective'); + } + + /** + * View has been initialized + */ + ngAfterViewInit(): void { + this.checkAndHandleExternalContent(); + + this.initialized = true; + } + + /** + * Listen to changes. + * + * * @param {{[name: string]: SimpleChange}} changes Changes. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes && this.initialized) { + // If any of the inputs changes, handle the content again. + this.checkAndHandleExternalContent(); + } + } + + /** + * Add a new source with a certain URL as a sibling of the current element. + * + * @param url URL to use in the source. + */ + protected addSource(url: string): void { + if (this.element.tagName !== 'SOURCE') { + return; + } + + const newSource = document.createElement('source'); + const type = this.element.getAttribute('type'); + + newSource.setAttribute('src', url); + + if (type) { + if (CoreApp.instance.isAndroid() && type == 'video/quicktime') { + // Fix for VideoJS/Chrome bug https://github.com/videojs/video.js/issues/423 . + newSource.setAttribute('type', 'video/mp4'); + } else { + newSource.setAttribute('type', type); + } + } + + this.element.parentNode?.insertBefore(newSource, this.element); + } + + /** + * Get the URL that should be handled and, if valid, handle it. + */ + protected async checkAndHandleExternalContent(): Promise { + const currentSite = CoreSites.instance.getCurrentSite(); + const siteId = this.siteId || currentSite?.getId(); + const tagName = this.element.tagName.toUpperCase(); + let targetAttr; + let url; + + // Always handle inline styles (if any). + this.handleInlineStyles(siteId); + + if (tagName === 'A' || tagName == 'IMAGE') { + targetAttr = 'href'; + url = this.href; + + } else if (tagName === 'IMG') { + targetAttr = 'src'; + url = this.src; + + } else if (tagName === 'AUDIO' || tagName === 'VIDEO' || tagName === 'SOURCE' || tagName === 'TRACK') { + targetAttr = 'src'; + url = this.targetSrc || this.src; + + if (tagName === 'VIDEO') { + if (this.poster) { + // Handle poster. + this.handleExternalContent('poster', this.poster, siteId).catch(() => { + // Ignore errors. + }); + } + } + + } else { + this.invalid = true; + + return; + } + + // Avoid handling data url's. + if (url && url.indexOf('data:') === 0) { + this.invalid = true; + this.onLoad.emit(); + this.loaded = true; + + return; + } + + try { + await this.handleExternalContent(targetAttr, url, siteId); + } catch (error) { + // Error handling content. Make sure the loaded event is triggered for images. + if (tagName === 'IMG') { + if (url) { + this.waitForLoad(); + } else { + this.onLoad.emit(); + this.loaded = true; + } + } + } + } + + /** + * Handle external content, setting the right URL. + * + * @param targetAttr Attribute to modify. + * @param url Original URL to treat. + * @param siteId Site ID. + * @return Promise resolved if the element is successfully treated. + */ + protected async handleExternalContent(targetAttr: string, url: string, siteId?: string): Promise { + + const tagName = this.element.tagName; + + if (tagName == 'VIDEO' && targetAttr != 'poster') { + const video = this.element; + if (video.textTracks) { + // It's a video with subtitles. In iOS, subtitles position is wrong so it needs to be fixed. + video.textTracks.onaddtrack = (event): void => { + const track = event.track; + if (track) { + track.oncuechange = (): void => { + if (!track.cues) { + return; + } + + const line = Platform.instance.is('tablet') || CoreApp.instance.isAndroid() ? 90 : 80; + // Position all subtitles to a percentage of video height. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Array.from(track.cues).forEach((cue: any) => { + cue.snapToLines = false; + cue.line = line; + cue.size = 100; // This solves some Android issue. + }); + // Delete listener. + track.oncuechange = null; + }; + } + }; + } + + } + + if (!url || !url.match(/^https?:\/\//i) || CoreUrlUtils.instance.isLocalFileUrl(url) || + (tagName === 'A' && !CoreUrlUtils.instance.isDownloadableUrl(url))) { + + this.logger.debug('Ignoring non-downloadable URL: ' + url); + if (tagName === 'SOURCE') { + // Restoring original src. + this.addSource(url); + } + + throw new CoreError('Non-downloadable URL'); + } + + const site = await CoreSites.instance.getSite(siteId); + + if (!site.canDownloadFiles() && CoreUrlUtils.instance.isPluginFileUrl(url)) { + this.element.parentElement?.removeChild(this.element); // Remove element since it'll be broken. + + throw 'Site doesn\'t allow downloading files.'; + } + + // Download images, tracks and posters if size is unknown. + const downloadUnknown = tagName == 'IMG' || tagName == 'TRACK' || targetAttr == 'poster'; + let finalUrl: string; + + if (targetAttr === 'src' && tagName !== 'SOURCE' && tagName !== 'TRACK' && tagName !== 'VIDEO' && tagName !== 'AUDIO') { + finalUrl = await CoreFilepool.instance.getSrcByUrl( + site.getId(), + url, + this.component, + this.componentId, + 0, + true, + downloadUnknown, + ); + } else { + finalUrl = await CoreFilepool.instance.getUrlByUrl( + site.getId(), + url, + this.component, + this.componentId, + 0, + true, + downloadUnknown, + ); + + finalUrl = CoreFile.instance.convertFileSrc(finalUrl); + } + + if (!CoreUrlUtils.instance.isLocalFileUrl(finalUrl)) { + /* In iOS, if we use the same URL in embedded file and background download then the download only + downloads a few bytes (cached ones). Add a hash to the URL so both URLs are different. */ + finalUrl = finalUrl + '#moodlemobile-embedded'; + } + + this.logger.debug('Using URL ' + finalUrl + ' for ' + url); + if (tagName === 'SOURCE') { + // The browser does not catch changes in SRC, we need to add a new source. + this.addSource(finalUrl); + } else { + if (tagName === 'IMG') { + this.loaded = false; + this.waitForLoad(); + } + this.element.setAttribute(targetAttr, finalUrl); + this.element.setAttribute('data-original-' + targetAttr, url); + } + + // Set events to download big files (not downloaded automatically). + if (!CoreUrlUtils.instance.isLocalFileUrl(finalUrl) && targetAttr != 'poster' && + (tagName == 'VIDEO' || tagName == 'AUDIO' || tagName == 'A' || tagName == 'SOURCE')) { + const eventName = tagName == 'A' ? 'click' : 'play'; + let clickableEl = this.element; + + if (tagName == 'SOURCE') { + clickableEl = CoreDomUtils.instance.closest(this.element, 'video,audio'); + if (!clickableEl) { + return; + } + } + + clickableEl.addEventListener(eventName, () => { + // User played media or opened a downloadable link. + // Download the file if in wifi and it hasn't been downloaded already (for big files). + if (CoreApp.instance.isWifi()) { + // We aren't using the result, so it doesn't matter which of the 2 functions we call. + CoreFilepool.instance.getUrlByUrl(site.getId(), url, this.component, this.componentId, 0, false); + } + }); + } + } + + /** + * Handle inline styles, trying to download referenced files. + * + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async handleInlineStyles(siteId?: string): Promise { + if (!siteId) { + return; + } + + let inlineStyles = this.element.getAttribute('style'); + + if (!inlineStyles) { + return; + } + + let urls = inlineStyles.match(/https?:\/\/[^"') ;]*/g); + if (!urls || !urls.length) { + return; + } + + urls = CoreUtils.instance.uniqueArray(urls); // Remove duplicates. + + const promises = urls.map(async (url) => { + const finalUrl = await CoreFilepool.instance.getUrlByUrl(siteId, url, this.component, this.componentId, 0, true, true); + + this.logger.debug('Using URL ' + finalUrl + ' for ' + url + ' in inline styles'); + inlineStyles = inlineStyles!.replace(new RegExp(url, 'gi'), finalUrl); + }); + + try { + await CoreUtils.instance.allPromises(promises); + + this.element.setAttribute('style', inlineStyles); + } catch (error) { + this.logger.error('Error treating inline styles.', this.element); + } + } + + /** + * Wait for the image to be loaded or error, and emit an event when it happens. + */ + protected waitForLoad(): void { + const listener = (): void => { + this.element.removeEventListener('load', listener); + this.element.removeEventListener('error', listener); + this.onLoad.emit(); + this.loaded = true; + }; + + this.element.addEventListener('load', listener); + this.element.addEventListener('error', listener); + } + +} diff --git a/src/app/directives/format-text.ts b/src/app/directives/format-text.ts index 7c679563e..e12d6479d 100644 --- a/src/app/directives/format-text.ts +++ b/src/app/directives/format-text.ts @@ -23,6 +23,7 @@ import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; import { CoreSite } from '@classes/site'; import { Translate } from '@singletons/core.singletons'; +import { CoreExternalContentDirective } from './external-content'; /** * Directive to format text rendered. It renders the HTML and treats all links and media, using CoreLinkDirective @@ -91,10 +92,21 @@ export class CoreFormatTextDirective implements OnChanges { * @param element Element to add the attributes to. * @return External content instance. */ - protected addExternalContent(element: Element): any { - // Angular 2 doesn't let adding directives dynamically. Create the CoreExternalContentDirective manually. - // @todo - return null; + protected addExternalContent(element: Element): CoreExternalContentDirective { + // Angular doesn't let adding directives dynamically. Create the CoreExternalContentDirective manually. + const extContent = new CoreExternalContentDirective(new ElementRef(element)); + + extContent.component = this.component; + extContent.componentId = this.componentId; + extContent.siteId = this.siteId; + extContent.src = element.getAttribute('src') || undefined; + extContent.href = element.getAttribute('href') || element.getAttribute('xlink:href') || undefined; + extContent.targetSrc = element.getAttribute('target-src') || undefined; + extContent.poster = element.getAttribute('poster') || undefined; + + extContent.ngAfterViewInit(); + + return extContent; } /** @@ -442,7 +454,7 @@ export class CoreFormatTextDirective implements OnChanges { this.addExternalContent(anchor); }); - const externalImages: any[] = []; + const externalImages: CoreExternalContentDirective[] = []; if (images && images.length > 0) { // Walk through the content to find images, and add our directive. images.forEach((img: HTMLElement) => {