From 80961c23d9d66f9b657104dd23fa8f3d2919602f Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 24 Nov 2017 08:50:27 +0100 Subject: [PATCH] MOBILE-2253 core: Implement core directives for login site --- src/classes/site.ts | 2 +- src/directives/auto-focus.ts | 55 ++++ src/directives/directives.module.ts | 36 +++ src/directives/external-content.ts | 218 ++++++++++++++ src/directives/format-text.ts | 444 ++++++++++++++++++++++++++++ src/directives/link.ts | 142 +++++++++ src/providers/utils/dom.ts | 12 + 7 files changed, 908 insertions(+), 1 deletion(-) create mode 100644 src/directives/auto-focus.ts create mode 100644 src/directives/directives.module.ts create mode 100644 src/directives/external-content.ts create mode 100644 src/directives/format-text.ts create mode 100644 src/directives/link.ts diff --git a/src/classes/site.ts b/src/classes/site.ts index b816b08ad..a20b335a9 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -1031,7 +1031,7 @@ export class CoreSite { * @param {string} [alertMessage] If defined, an alert will be shown before opening the inappbrowser. * @return {Promise} Promise resolved when done, rejected otherwise. */ - openInAppWithAutoLoginIfSameSite(url: string, options: any, alertMessage?: string) : Promise { + openInAppWithAutoLoginIfSameSite(url: string, options?: any, alertMessage?: string) : Promise { return this.openWithAutoLoginIfSameSite(true, url, options, alertMessage); } diff --git a/src/directives/auto-focus.ts b/src/directives/auto-focus.ts new file mode 100644 index 000000000..13ad67418 --- /dev/null +++ b/src/directives/auto-focus.ts @@ -0,0 +1,55 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { CoreDomUtilsProvider } from '../providers/utils/dom'; + +/** + * Directive to auto focus an element when a view is loaded. + * + * You can apply it conditionallity assigning it a boolean value: + */ +@Directive({ + selector: '[core-auto-focus]' +}) +export class CoreAutoFocusDirective implements AfterViewInit { + @Input('core-auto-focus') coreAutoFocus: boolean = true; + + protected element: HTMLElement; + + constructor(element: ElementRef, private domUtils: CoreDomUtilsProvider) { + this.element = element.nativeElement || element; + } + + /** + * Function after the view is initialized. + */ + ngAfterViewInit() { + this.coreAutoFocus = typeof this.coreAutoFocus != 'boolean' ? true : this.coreAutoFocus; + if (this.coreAutoFocus) { + // If it's a ion-input or ion-textarea, search the right input to use. + let element = this.element; + if (this.element.tagName == 'ION-INPUT') { + element = this.element.querySelector('input') || element; + } else if (this.element.tagName == 'ION-TEXTAREA') { + element = this.element.querySelector('textarea') || element; + } + + // Wait a bit to make sure the view is loaded. + setTimeout(() => { + this.domUtils.focusElement(element); + }, 200); + } + } +} diff --git a/src/directives/directives.module.ts b/src/directives/directives.module.ts new file mode 100644 index 000000000..5f10d4699 --- /dev/null +++ b/src/directives/directives.module.ts @@ -0,0 +1,36 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { CoreAutoFocusDirective } from './auto-focus'; +import { CoreExternalContentDirective } from './external-content'; +import { CoreFormatTextDirective } from './format-text'; +import { CoreLinkDirective } from './link'; + +@NgModule({ + declarations: [ + CoreAutoFocusDirective, + CoreExternalContentDirective, + CoreFormatTextDirective, + CoreLinkDirective + ], + imports: [], + exports: [ + CoreAutoFocusDirective, + CoreExternalContentDirective, + CoreFormatTextDirective, + CoreLinkDirective + ] +}) +export class CoreDirectivesModule {} diff --git a/src/directives/external-content.ts b/src/directives/external-content.ts new file mode 100644 index 000000000..e89d550e5 --- /dev/null +++ b/src/directives/external-content.ts @@ -0,0 +1,218 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, OnInit, ElementRef } from '@angular/core'; +import { Platform } from 'ionic-angular'; +import { CoreAppProvider } from '../providers/app'; +import { CoreLoggerProvider } from '../providers/logger'; +import { CoreFilepoolProvider } from '../providers/filepool'; +import { CoreSitesProvider } from '../providers/sites'; +import { CoreDomUtilsProvider } from '../providers/utils/dom'; +import { CoreUrlUtilsProvider } from '../providers/utils/url'; + +/** + * 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. + */ +@Directive({ + selector: '[core-external-content]' +}) +export class CoreExternalContentDirective implements OnInit { + @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. + + protected element: HTMLElement; + protected logger; + + constructor(element: ElementRef, logger: CoreLoggerProvider, private filepoolProvider: CoreFilepoolProvider, + private platform: Platform, private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, + private urlUtils: CoreUrlUtilsProvider, private appProvider: CoreAppProvider) { + // This directive can be added dynamically. In that case, the first param is the HTMLElement. + this.element = element.nativeElement || element; + this.logger = logger.getInstance('CoreExternalContentDirective'); + } + + /** + * Function executed when the component is initialized. + */ + ngOnInit() { + let currentSite = this.sitesProvider.getCurrentSite(), + siteId = this.siteId || (currentSite && currentSite.getId()), + targetAttr, + sourceAttr, + tagName = this.element.tagName; + + if (tagName === 'A') { + targetAttr = 'href'; + sourceAttr = 'href'; + + } else if (tagName === 'IMG') { + targetAttr = 'src'; + sourceAttr = 'src'; + + } else if (tagName === 'AUDIO' || tagName === 'VIDEO' || tagName === 'SOURCE' || tagName === 'TRACK') { + targetAttr = 'src'; + sourceAttr = 'targetSrc'; + + if (tagName === 'VIDEO') { + let poster = (this.element).poster; + if (poster) { + // Handle poster. + this.handleExternalContent('poster', poster, siteId).catch(() => { + // Ignore errors. + }); + } + } + + } else { + // Unsupported tag. + this.logger.warn('Directive attached to non-supported tag: ' + tagName); + return; + } + + let url = this.element.getAttribute(sourceAttr) || this.element.getAttribute(targetAttr); + this.handleExternalContent(targetAttr, url, siteId).catch(() => { + // Ignore errors. + }); + } + + /** + * Add a new source with a certain URL as a sibling of the current element. + * + * @param {string} url URL to use in the source. + */ + protected addSource(url: string) : void { + if (this.element.tagName !== 'SOURCE') { + return; + } + + let newSource = document.createElement('source'), + type = this.element.getAttribute('type'); + + newSource.setAttribute('src', url); + + if (type) { + if (this.platform.is('android') && 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); + } + + /** + * Handle external content, setting the right URL. + * + * @param {string} targetAttr Attribute to modify. + * @param {string} url Original URL to treat. + * @param {string} [siteId] Site ID. + * @return {Promise} Promise resolved if the element is successfully treated. + */ + protected handleExternalContent(targetAttr: string, url: string, siteId?: string) : Promise { + + const tagName = this.element.tagName; + + if (tagName == 'VIDEO' && targetAttr != 'poster') { + let 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) => { + let track = event.track; + if (track) { + track.oncuechange = () => { + var line = this.platform.is('tablet') || this.platform.is('android') ? 90 : 80; + // Position all subtitles to a percentage of video height. + 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) || (tagName === 'A' && !this.urlUtils.isDownloadableUrl(url))) { + this.logger.debug('Ignoring non-downloadable URL: ' + url); + if (tagName === 'SOURCE') { + // Restoring original src. + this.addSource(url); + } + return Promise.reject(null); + } + + // Get the webservice pluginfile URL, we ignore failures here. + return this.sitesProvider.getSite(siteId).then((site) => { + if (!site.canDownloadFiles() && this.urlUtils.isPluginFileUrl(url)) { + this.element.parentElement.removeChild(this.element); // Remove element since it'll be broken. + return Promise.reject(null); + } + + // Download images, tracks and posters if size is unknown. + let promise, + dwnUnknown = tagName == 'IMG' || tagName == 'TRACK' || targetAttr == 'poster'; + + if (targetAttr === 'src' && tagName !== 'SOURCE' && tagName !== 'TRACK') { + promise = this.filepoolProvider.getSrcByUrl(siteId, url, this.component, this.componentId, 0, true, dwnUnknown); + } else { + promise = this.filepoolProvider.getUrlByUrl(siteId, url, this.component, this.componentId, 0, true, dwnUnknown); + } + + return promise.then((finalUrl) => { + 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. + // @todo: Check if changing src works in Android 4.4, maybe the problem was only in 4.1-4.3. + this.addSource(finalUrl); + } else { + this.element.setAttribute(targetAttr, finalUrl); + } + + // Set events to download big files (not downloaded automatically). + if (finalUrl.indexOf('http') === 0 && targetAttr != 'poster' && + (tagName == 'VIDEO' || tagName == 'AUDIO' || tagName == 'A' || tagName == 'SOURCE')) { + let eventName = tagName == 'A' ? 'click' : 'play', + clickableEl = this.element; + + if (tagName == 'SOURCE') { + clickableEl = this.domUtils.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 (!this.appProvider.isNetworkAccessLimited()) { + // We aren't using the result, so it doesn't matter which of the 2 functions we call. + this.filepoolProvider.getUrlByUrl(siteId, url, this.component, this.componentId, 0, false); + } + }); + } + }); + }); + } +} diff --git a/src/directives/format-text.ts b/src/directives/format-text.ts new file mode 100644 index 000000000..b15bec587 --- /dev/null +++ b/src/directives/format-text.ts @@ -0,0 +1,444 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Input, OnInit, Output, EventEmitter } from '@angular/core'; +import { Platform } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '../providers/app'; +import { CoreFilepoolProvider } from '../providers/filepool'; +import { CoreLoggerProvider } from '../providers/logger'; +import { CoreSitesProvider } from '../providers/sites'; +import { CoreDomUtilsProvider } from '../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../providers/utils/text'; +import { CoreUrlUtilsProvider } from '../providers/utils/url'; +import { CoreUtilsProvider } from '../providers/utils/utils'; +import { CoreSite } from '../classes/site'; +import { CoreLinkDirective } from '../directives/link'; +import { CoreExternalContentDirective } from '../directives/external-content'; + +/** + * Directive to format text rendered. It renders the HTML and treats all links and media, using CoreLinkDirective + * and CoreExternalContentDirective. + * + * Example usage: + * + * + */ +@Directive({ + selector: 'core-format-text' +}) +export class CoreFormatTextDirective implements OnInit { + @Input() text: string; // The text to format. + @Input() siteId?: string; // Site ID to use. + @Input() component?: string; // Component for CoreExternalContentDirective. + @Input() componentId?: string|number; // Component ID to use in conjunction with the component. + @Input() adaptImg?: boolean = true; // Whether to adapt images to screen width. + @Input() clean?: boolean; // Whether all the HTML tags should be removed. + @Input() singleLine?: boolean; // Whether new lines should be removed (all text in single line). Only valid if clean=true. + @Input() maxHeight?: number; // Max height in pixels to render the content box. It should be 50 at least to make sense. + // Using this parameter will force display: block to calculate height better. If you want to + // avoid this use class="inline" at the same time to use display: inline-block. + @Input() fullOnClick?: boolean; // Whether it should open a new page with the full contents on click. Only if "max-height" + // is set and the content has been collapsed. + @Input() brOnFull?: boolean; // Whether new lines should be replaced by
on full view. + @Input() fullTitle?: string; // Title to use in full view. Defaults to "Description". + @Output() afterRender?: EventEmitter; // Called when the data is rendered. + + protected tagsToIgnore = ['AUDIO', 'VIDEO', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'A']; + protected element: HTMLElement; + + constructor(element: ElementRef, private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, + private textUtils: CoreTextUtilsProvider, private translate: TranslateService, private platform: Platform, + private utils: CoreUtilsProvider, private urlUtils: CoreUrlUtilsProvider, private loggerProvider: CoreLoggerProvider, + private filepoolProvider: CoreFilepoolProvider, private appProvider: CoreAppProvider) { + this.element = element.nativeElement; + this.element.classList.add('opacity-hide'); // Hide contents until they're treated. + this.afterRender = new EventEmitter(); + } + + /** + * Function executed when the directive is initialized. + */ + ngOnInit() : void { + this.formatAndRenderContents(); + } + + /** + * Apply CoreExternalContentDirective to a certain element. + * + * @param {HTMLElement} element Element to add the attributes to. + */ + protected addExternalContent(element: HTMLElement) : void { + // Angular 2 doesn't let adding directives dynamically. Create the CoreExternalContentDirective manually. + let extContent = new CoreExternalContentDirective(element, this.loggerProvider, this.filepoolProvider, this.platform, + this.sitesProvider, this.domUtils, this.urlUtils, this.appProvider); + + extContent.component = this.component; + extContent.componentId = this.componentId; + extContent.siteId = this.siteId; + + extContent.ngOnInit(); + } + + /** + * Add class to adapt media to a certain element. + * + * @param {HTMLElement} element Element to add the class to. + */ + protected addMediaAdaptClass(element: HTMLElement) : void { + element.classList.add('mm-media-adapt-width'); + } + + /** + * Create a container for an image to adapt its width. + * + * @param {number} elWidth Width of the directive's element. + * @param {HTMLElement} img Image to adapt. + * @return {HTMLElement} Container. + */ + protected createMagnifyingGlassContainer(elWidth: number, img: HTMLElement) : HTMLElement { + // Check if image width has been adapted. If so, add an icon to view the image at full size. + let imgWidth = this.getElementWidth(img), + // Wrap the image in a new div with position relative. + container = document.createElement('span'); + + container.classList.add('mm-adapted-img-container'); + container.style.cssFloat = img.style.cssFloat; // Copy the float to correctly position the search icon. + if (img.classList.contains('atto_image_button_right')) { + container.classList.add('atto_image_button_right'); + } else if (img.classList.contains('atto_image_button_left')) { + container.classList.add('atto_image_button_left'); + } + container.appendChild(img); + + if (imgWidth > elWidth) { + let imgSrc = this.textUtils.escapeHTML(img.getAttribute('src')), + label = this.textUtils.escapeHTML(this.translate.instant('mm.core.openfullimage')); + + container.innerHTML += ''; + } + + return container; + } + + /** + * Finish the rendering, displaying the element again and calling afterRender. + */ + protected finishRender() : void { + // Show the element again. + this.element.classList.remove('opacity-hide'); + // Emit the afterRender output. + this.afterRender.emit(); + } + + /** + * Format contents and render. + */ + protected formatAndRenderContents() : void { + if (!this.text) { + this.finishRender(); + return; + } + + this.text = this.text.trim(); + + this.formatContents().then((div: HTMLElement) => { + if (this.maxHeight && div.innerHTML != "") { + // Move the children to the current element to be able to calculate the height. + // @todo: Display the element? + this.domUtils.moveChildren(div, this.element); + + // Height cannot be calculated if the element is not shown while calculating. + // Force shorten if it was previously shortened. + // @todo: Work on calculate this height better. + let height = this.element.style.maxHeight ? 0 : this.getElementHeight(this.element); + + // If cannot calculate height, shorten always. + if (!height || height > this.maxHeight) { + let expandInFullview = this.utils.isTrueOrOne(this.fullOnClick) || false; + + this.element.innerHTML += '
' + this.translate.instant('mm.core.showmore') + '
'; + + if (expandInFullview) { + this.element.classList.add('mm-expand-in-fullview'); + } + this.element.classList.add('mm-text-formatted mm-shortened'); + this.element.style.maxHeight = this.maxHeight + 'px'; + + this.element.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + let target = e.target; + + if (this.tagsToIgnore.indexOf(target.tagName) === -1 || (target.tagName === 'A' && + !target.getAttribute('href'))) { + if (!expandInFullview) { + // Change class. + this.element.classList.toggle('mm-shortened'); + return; + } + } + + // Open a new state with the contents. + this.textUtils.expandText(this.fullTitle || this.translate.instant('mm.core.description'), + this.text, this.brOnFull, this.component, this.componentId); + }); + } + } else { + this.domUtils.moveChildren(div, this.element); + } + + this.element.classList.add('mm-enabled-media-adapt'); + + this.finishRender(); + }); + } + + /** + * Apply formatText and set sub-directives. + * + * @return {Promise} Promise resolved with a div element containing the code. + */ + protected formatContents() : Promise { + + let site: CoreSite; + + // Retrieve the site since it might be needed later. + return this.sitesProvider.getSite(this.siteId).catch(() => { + // Error getting the site. This probably means that there is no current site and no siteId was supplied. + }).then((siteInstance: CoreSite) => { + site = siteInstance; + + // Apply format text function. + return this.textUtils.formatText(this.text, this.clean, this.singleLine); + }).then((formatted) => { + + let div = document.createElement('div'), + canTreatVimeo = site && site.isVersionGreaterEqualThan(['3.3.4', '3.4']), + images, + anchors, + audios, + videos, + iframes, + buttons; + + div.innerHTML = formatted; + images = Array.from(div.querySelectorAll('img')); + anchors = Array.from(div.querySelectorAll('a')); + audios = Array.from(div.querySelectorAll('audio')); + videos = Array.from(div.querySelectorAll('video')); + iframes = Array.from(div.querySelectorAll('iframe')); + buttons = Array.from(div.querySelectorAll('.button')); + + // Walk through the content to find the links and add our directive to it. + // Important: We need to look for links first because in 'img' we add new links without mm-link. + anchors.forEach((anchor) => { + // Angular 2 doesn't let adding directives dynamically. Create the CoreLinkDirective manually. + let linkDir = new CoreLinkDirective(anchor, this.domUtils, this.utils, this.sitesProvider, this.urlUtils); + linkDir.capture = true; + linkDir.ngOnInit(); + + this.addExternalContent(anchor); + }); + + if (images && images.length > 0) { + // If cannot calculate element's width, use a medium number to avoid false adapt image icons appearing. + let elWidth = this.getElementWidth(this.element) || 100; + + // Walk through the content to find images, and add our directive. + images.forEach((img: HTMLElement) => { + this.addMediaAdaptClass(img); + this.addExternalContent(img); + if (this.adaptImg) { + // Create a container for the image and use it instead of the image. + let container = this.createMagnifyingGlassContainer(elWidth, img); + div.replaceChild(container, img); + } + }); + } + + audios.forEach((audio) => { + this.treatMedia(audio); + if (this.platform.is('ios')) { + // Set data-tap-disabled="true" to make slider work in iOS. + audio.setAttribute('data-tap-disabled', true); + } + }); + + videos.forEach((video) => { + this.treatVideoFilters(video); + this.treatMedia(video); + // Set data-tap-disabled="true" to make controls work in Android (see MOBILE-1452). + video.setAttribute('data-tap-disabled', true); + }); + + iframes.forEach((iframe) => { + this.treatIframe(iframe, site, canTreatVimeo); + }); + + // Handle buttons with inner links. + buttons.forEach((button: HTMLElement) => { + // Check if it has a link inside. + if (button.querySelector('a')) { + button.classList.add('mm-button-with-inner-link'); + } + }); + + return div; + }); + } + + /** + * Returns the element width in pixels. + * + * @param {HTMLElement} element Element to get width from. + * @return {number} The width of the element in pixels. When 0 is returned it means the element is not visible. + */ + protected getElementWidth(element: HTMLElement) : number { + let width = this.domUtils.getElementWidth(element); + + if (!width) { + // All elements inside are floating or inline. Change display mode to allow calculate the width. + let parentWidth = this.domUtils.getElementWidth(element.parentNode, true, false, false, true), + previousDisplay = getComputedStyle(element, null).display; + + element.style.display = 'inline-block'; + + width = this.domUtils.getElementWidth(element); + + // If width is incorrectly calculated use parent width instead. + if (parentWidth > 0 && (!width || width > parentWidth)) { + width = parentWidth; + } + + element.style.display = previousDisplay; + } + + return width; + } + + /** + * Returns the element height in pixels. + * + * @param {HTMLElement} elementAng Element to get height from. + * @return {number} The height of the element in pixels. When 0 is returned it means the element is not visible. + */ + protected getElementHeight(element: HTMLElement) : number { + let height; + + // Disable media adapt to correctly calculate the height. + element.classList.remove('mm-enabled-media-adapt'); + + height = this.domUtils.getElementHeight(element); + + element.classList.add('mm-enabled-media-adapt'); + + return height || 0; + } + + /** + * Treat video filters. Currently only treating youtube video using video JS. + * + * @param {HTMLElement} el Video element. + */ + protected treatVideoFilters(video: HTMLElement) : void { + // Treat Video JS Youtube video links and translate them to iframes. + if (!video.classList.contains('video-js')) { + return; + } + + let data = JSON.parse(video.getAttribute('data-setup') || video.getAttribute('data-setup-lazy') || '{}'), + youtubeId = data.techOrder && data.techOrder[0] && data.techOrder[0] == 'youtube' && data.sources && data.sources[0] && + data.sources[0].src && this.youtubeGetId(data.sources[0].src); + + if (!youtubeId) { + return; + } + + let iframe = document.createElement('iframe'); + iframe.id = video.id; + iframe.src = 'https://www.youtube.com/embed/' + youtubeId; + iframe.setAttribute('frameborder', '0'); + iframe.width = '100%'; + iframe.height = '300'; + + // Replace video tag by the iframe. + video.parentNode.replaceChild(iframe, video); + } + + /** + * Add media adapt class and apply CoreExternalContentDirective to the media element and its sources and tracks. + * + * @param {HTMLElement} element Video or audio to treat. + */ + protected treatMedia(element: HTMLElement) : void { + this.addMediaAdaptClass(element); + this.addExternalContent(element); + + let sources = Array.from(element.querySelectorAll('source')), + tracks = Array.from(element.querySelectorAll('track')); + + sources.forEach((source) => { + source.setAttribute('target-src', source.getAttribute('src')); + source.removeAttribute('src'); + this.addExternalContent(source); + }); + + tracks.forEach((track) => { + this.addExternalContent(track); + }); + } + + /** + * Add media adapt class and treat the iframe source. + * + * @param {HTMLIFrameElement} iframe Iframe to treat. + * @param {CoreSite} site Site instance. + * @param {Boolean} canTreatVimeo Whether Vimeo videos can be treated in the site. + */ + protected treatIframe(iframe: HTMLIFrameElement, site: CoreSite, canTreatVimeo: boolean) : void { + this.addMediaAdaptClass(iframe); + + if (iframe.src && canTreatVimeo) { + // Check if it's a Vimeo video. If it is, use the wsplayer script instead to make restricted videos work. + let matches = iframe.src.match(/https?:\/\/player\.vimeo\.com\/video\/([^\/]*)/); + if (matches && matches[1]) { + let newUrl = this.textUtils.concatenatePaths(site.getURL(), '/media/player/vimeo/wsplayer.php?video=') + + matches[1] + '&token=' + site.getToken(); + if (iframe.width) { + newUrl = newUrl + '&width=' + iframe.width; + } + if (iframe.height) { + newUrl = newUrl + '&height=' + iframe.height; + } + + iframe.src = newUrl; + } + } + } + + /** + * Convenience function to extract YouTube Id to translate to embedded video. + * Based on http://stackoverflow.com/questions/3452546/javascript-regex-how-to-get-youtube-video-id-from-url + * + * @param {string} url URL of the video. + */ + protected youtubeGetId(url: string) : string { + let regExp = /^.*(?:(?:youtu.be\/)|(?:v\/)|(?:\/u\/\w\/)|(?:embed\/)|(?:watch\?))\??v?=?([^#\&\?]*).*/, + match = url.match(regExp); + return (match && match[1].length == 11) ? match[1] : ''; + } +} diff --git a/src/directives/link.ts b/src/directives/link.ts new file mode 100644 index 000000000..53dfd0840 --- /dev/null +++ b/src/directives/link.ts @@ -0,0 +1,142 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, OnInit, ElementRef } from '@angular/core'; +import { CoreSitesProvider } from '../providers/sites'; +import { CoreDomUtilsProvider } from '../providers/utils/dom'; +import { CoreUrlUtilsProvider } from '../providers/utils/url'; +import { CoreUtilsProvider } from '../providers/utils/utils'; +import { CoreConfigConstants } from '../configconstants'; + +/** + * Directive to open a link in external browser. + */ +@Directive({ + selector: '[core-link]' +}) +export class CoreLinkDirective implements OnInit { + @Input() capture?: boolean; // If the link needs to be captured by the app. + @Input() inApp?: boolean; // True to open in embedded browser, false to open in system browser. + @Input() autoLogin? = 'check'; // If the link should be open with auto-login. Accepts the following values: + // "yes" -> Always auto-login. + // "no" -> Never auto-login. + // "check" -> Auto-login only if it points to the current site. Default value. + + protected element: HTMLElement; + + constructor(element: ElementRef, private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider, + private sitesProvider: CoreSitesProvider, private urlUtils: CoreUrlUtilsProvider) { + // This directive can be added dynamically. In that case, the first param is the anchor HTMLElement. + this.element = element.nativeElement || element; + } + + /** + * Function executed when the component is initialized. + */ + ngOnInit() { + this.element.addEventListener('click', (event) => { + // If the event prevented default action, do nothing. + if (!event.defaultPrevented) { + const href = this.element.getAttribute('href'); + if (href) { + event.preventDefault(); + event.stopPropagation(); + + if (this.capture) { + // @todo: Handle link using content links helper. + // $mmContentLinksHelper.handleLink(href).then((treated) => { + // if (!treated) { + this.navigate(href); + // } + // }); + } else { + this.navigate(href); + } + } + } + }); + } + + /** + * Convenience function to correctly navigate, open file or url in the browser. + * + * @param {string} href HREF to be opened. + */ + protected navigate(href: string) : void { + const contentLinksScheme = CoreConfigConstants.customurlscheme + '://link='; + + if (href.indexOf('cdvfile://') === 0 || href.indexOf('file://') === 0) { + // We have a local file. + this.utils.openFile(href).catch((error) => { + this.domUtils.showErrorModal(error); + }); + } else if (href.charAt(0) == '#') { + href = href.substr(1); + // In site links + if (href.charAt(0) == '/') { + // @todo: Investigate how to achieve this behaviour. + // $location.url(href); + } else { + // Look for id or name. + let scrollEl = this.domUtils.closest(this.element, 'scroll-content'); + this.domUtils.scrollToElement(scrollEl, document.body, "#" + href + ", [name='" + href + "']"); + } + } else if (href.indexOf(contentLinksScheme) === 0) { + // Link should be treated by Custom URL Scheme. Encode the right part, otherwise ':' is removed in iOS. + href = contentLinksScheme + encodeURIComponent(href.replace(contentLinksScheme, '')); + this.utils.openInBrowser(href); + } else { + + // It's an external link, we will open with browser. Check if we need to auto-login. + if (!this.sitesProvider.isLoggedIn()) { + // Not logged in, cannot auto-login. + if (this.inApp) { + this.utils.openInApp(href); + } else { + this.utils.openInBrowser(href); + } + } else { + // Check if URL does not have any protocol, so it's a relative URL. + if (!this.urlUtils.isAbsoluteURL(href)) { + // Add the site URL at the begining. + if (href.charAt(0) == '/') { + href = this.sitesProvider.getCurrentSite().getURL() + href; + } else { + href = this.sitesProvider.getCurrentSite().getURL() + '/' + href; + } + } + + if (this.autoLogin == 'yes') { + if (this.inApp) { + this.sitesProvider.getCurrentSite().openInAppWithAutoLogin(href); + } else { + this.sitesProvider.getCurrentSite().openInBrowserWithAutoLogin(href); + } + } else if (this.autoLogin == 'no') { + if (this.inApp) { + this.utils.openInApp(href); + } else { + this.utils.openInBrowser(href); + } + } else { + if (this.inApp) { + this.sitesProvider.getCurrentSite().openInAppWithAutoLoginIfSameSite(href); + } else { + this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(href); + } + } + } + } + } +} diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index cbb94dc8b..b6ec92d47 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -436,6 +436,18 @@ export class CoreDomUtilsProvider { return !this.platform.is('ios'); } + /** + * Move children from one HTMLElement to another. + * + * @param {HTMLElement} oldParent The old parent. + * @param {HTMLElement} newParent The new parent. + */ + moveChildren(oldParent: HTMLElement, newParent: HTMLElement) : void { + while (oldParent.childNodes.length > 0) { + newParent.appendChild(oldParent.childNodes[0]); + } + } + /** * Search and remove a certain element from inside another element. *