993 lines
		
	
	
		
			37 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			993 lines
		
	
	
		
			37 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 {
 | |
|     Directive,
 | |
|     ElementRef,
 | |
|     Input,
 | |
|     Output,
 | |
|     EventEmitter,
 | |
|     OnChanges,
 | |
|     SimpleChange,
 | |
|     Optional,
 | |
|     ViewContainerRef,
 | |
|     ViewChild,
 | |
|     OnDestroy,
 | |
|     Inject,
 | |
|     ChangeDetectorRef,
 | |
| } from '@angular/core';
 | |
| 
 | |
| import { CoreSites } from '@services/sites';
 | |
| import { CoreDomUtils } from '@services/utils/dom';
 | |
| import { CoreIframeUtils, CoreIframeUtilsProvider } from '@services/utils/iframe';
 | |
| import { CoreText } from '@singletons/text';
 | |
| import { CoreErrorHelper } from '@services/error-helper';
 | |
| import { CoreSite } from '@classes/sites/site';
 | |
| import { NgZone, Translate } from '@singletons';
 | |
| import { CoreExternalContentDirective } from './external-content';
 | |
| import { CoreLinkDirective } from './link';
 | |
| import { CoreFilter, CoreFilterFilter, CoreFilterFormatTextOptions } from '@features/filter/services/filter';
 | |
| import { CoreFilterDelegate } from '@features/filter/services/filter-delegate';
 | |
| import { CoreFilterHelper } from '@features/filter/services/filter-helper';
 | |
| import { CoreSubscriptions } from '@singletons/subscriptions';
 | |
| import { CoreDirectivesRegistry } from '@singletons/directives-registry';
 | |
| import { CoreCollapsibleItemDirective } from './collapsible-item';
 | |
| import { CoreCancellablePromise } from '@classes/cancellable-promise';
 | |
| import { AsyncDirective } from '@classes/async-directive';
 | |
| import { CoreDom } from '@singletons/dom';
 | |
| import { CoreEvents } from '@singletons/events';
 | |
| import { CoreRefreshContext, CORE_REFRESH_CONTEXT } from '@/core/utils/refresh-context';
 | |
| import { CorePlatform } from '@services/platform';
 | |
| import { ElementController } from '@classes/element-controllers/ElementController';
 | |
| import { MediaElementController } from '@classes/element-controllers/MediaElementController';
 | |
| import { FrameElement, FrameElementController } from '@classes/element-controllers/FrameElementController';
 | |
| import { CoreUrl } from '@singletons/url';
 | |
| import { CoreIcons } from '@singletons/icons';
 | |
| import { ContextLevel } from '../constants';
 | |
| import { CoreWait } from '@singletons/wait';
 | |
| import { toBoolean } from '../transforms/boolean';
 | |
| import { CoreViewer } from '@features/viewer/services/viewer';
 | |
| import { CorePromiseUtils } from '@singletons/promise-utils';
 | |
| 
 | |
| /**
 | |
|  * Directive to format text rendered. It renders the HTML and treats all links and media, using CoreLinkDirective
 | |
|  * and CoreExternalContentDirective. It also applies filters if needed.
 | |
|  *
 | |
|  * Please use this directive if your text needs to be filtered or it can contain links or media (images, audio, video).
 | |
|  *
 | |
|  * Example usage:
 | |
|  * <core-format-text [text]="myText" [component]="component" [componentId]="componentId"></core-format-text>
 | |
|  */
 | |
| @Directive({
 | |
|     selector: 'core-format-text',
 | |
| })
 | |
| export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirective {
 | |
| 
 | |
|     @ViewChild(CoreCollapsibleItemDirective) collapsible?: CoreCollapsibleItemDirective;
 | |
| 
 | |
|     @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({ transform: toBoolean }) adaptImg = true; // Whether to adapt images to screen width.
 | |
|     @Input({ transform: toBoolean }) clean = false; // Whether all the HTML tags should be removed.
 | |
|     @Input({ transform: toBoolean }) singleLine = false; // Whether new lines should be removed. Only if clean=true.
 | |
|     @Input() highlight?: string; // Text to highlight.
 | |
|     @Input({ transform: toBoolean }) filter?: boolean; // Whether to filter the text.
 | |
|                                                        // If not defined, true if contextLevel and instanceId are set.
 | |
|     @Input() contextLevel?: ContextLevel; // The context level of the text.
 | |
|     @Input() contextInstanceId?: number; // The instance ID related to the context.
 | |
|     @Input() courseId?: number; // Course ID the text belongs to. It can be used to improve performance with filters.
 | |
|     @Input({ transform: toBoolean }) wsNotFiltered = false; // If true it means the WS didn't filter the text for some reason.
 | |
|     @Input({ transform: toBoolean }) captureLinks = true; // Whether links should tried to be opened inside the app.
 | |
|     @Input({ transform: toBoolean }) openLinksInApp = false; // Whether links should be opened in InAppBrowser.
 | |
|     @Input({ transform: toBoolean }) hideIfEmpty = false; // If true, the tag will contain nothing if text is empty.
 | |
|     @Input({ transform: toBoolean }) disabled = false; // If disabled, autoplay elements will be disabled.
 | |
| 
 | |
|     @Output() afterRender = new EventEmitter<void>(); // Called when the data is rendered.
 | |
|     @Output() filterContentRenderingComplete = new EventEmitter<void>(); // Called when the filters have finished rendering content.
 | |
|     @Output() onClick: EventEmitter<void> = new EventEmitter(); // Called when clicked.
 | |
| 
 | |
|     protected element: HTMLElement;
 | |
|     protected elementControllers: ElementController[] = [];
 | |
|     protected emptyText = '';
 | |
|     protected domPromises: CoreCancellablePromise<void>[] = [];
 | |
|     protected domElementPromise?: CoreCancellablePromise<void>;
 | |
|     protected externalContentInstances: CoreExternalContentDirective[] = [];
 | |
| 
 | |
|     constructor(
 | |
|         element: ElementRef,
 | |
|         protected viewContainerRef: ViewContainerRef,
 | |
|         @Optional() @Inject(CORE_REFRESH_CONTEXT) protected refreshContext?: CoreRefreshContext,
 | |
|     ) {
 | |
|         CoreDirectivesRegistry.register(element.nativeElement, this);
 | |
| 
 | |
|         this.element = element.nativeElement;
 | |
|         this.element.classList.add('core-loading'); // Hide contents until they're treated.
 | |
| 
 | |
|         this.emptyText = this.hideIfEmpty ? '' : ' ';
 | |
|         this.element.innerHTML = this.emptyText;
 | |
| 
 | |
|         this.element.addEventListener('click', (event) => this.elementClicked(event));
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * @inheritdoc
 | |
|      */
 | |
|     ngOnChanges(changes: { [name: string]: SimpleChange }): void {
 | |
|         this.siteId = this.siteId || CoreSites.getCurrentSiteId();
 | |
| 
 | |
|         if (changes.text || changes.filter || changes.contextLevel || changes.contextInstanceId) {
 | |
|             this.formatAndRenderContents();
 | |
| 
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         if ('disabled' in changes) {
 | |
|             const disabled = changes['disabled'].currentValue;
 | |
| 
 | |
|             this.elementControllers.forEach(controller => disabled ? controller.disable() : controller.enable());
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * @inheritdoc
 | |
|      */
 | |
|     ngOnDestroy(): void {
 | |
|         this.domElementPromise?.cancel();
 | |
|         this.domPromises.forEach((promise) => { promise.cancel();});
 | |
|         this.elementControllers.forEach(controller => controller.destroy());
 | |
|         this.externalContentInstances.forEach(extContent => extContent.ngOnDestroy());
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * @inheritdoc
 | |
|      */
 | |
|     async ready(): Promise<void> {
 | |
|         if (!this.element.classList.contains('core-loading')) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         await new Promise<void>(resolve => {
 | |
|             const subscription = this.afterRender.subscribe(() => {
 | |
|                 subscription.unsubscribe();
 | |
|                 resolve();
 | |
|             });
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Apply CoreExternalContentDirective to a certain element.
 | |
|      *
 | |
|      * @param element Element to add the attributes to.
 | |
|      * @param onlyInlineStyles Whether to only handle inline styles.
 | |
|      * @returns External content instance or undefined if siteId is not provided.
 | |
|      */
 | |
|     protected addExternalContent(element: Element, onlyInlineStyles = false): CoreExternalContentDirective | undefined {
 | |
|         if (!this.siteId) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         // 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.url = element.getAttribute('src') ?? element.getAttribute('href') ?? element.getAttribute('xlink:href');
 | |
|         extContent.posterUrl = element.getAttribute('poster');
 | |
| 
 | |
|         if (!onlyInlineStyles) {
 | |
|             // Remove the original attributes to avoid performing requests to untreated URLs.
 | |
|             element.removeAttribute('src');
 | |
|             element.removeAttribute('href');
 | |
|             element.removeAttribute('xlink:href');
 | |
|             element.removeAttribute('poster');
 | |
|         }
 | |
| 
 | |
|         extContent.ngAfterViewInit();
 | |
| 
 | |
|         this.externalContentInstances.push(extContent);
 | |
| 
 | |
|         const changeDetectorRef = this.viewContainerRef.injector.get(ChangeDetectorRef);
 | |
|         changeDetectorRef.markForCheck();
 | |
| 
 | |
|         return extContent;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Add class to adapt media to a certain element.
 | |
|      *
 | |
|      * @param element Element to add the class to.
 | |
|      */
 | |
|     protected addMediaAdaptClass(element: HTMLElement): void {
 | |
|         element.classList.add('core-media-adapt-width');
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Wrap an image with a container to adapt its width.
 | |
|      *
 | |
|      * @param img Image to adapt.
 | |
|      */
 | |
|     protected adaptImage(img: HTMLElement): void {
 | |
|         if (img.classList.contains('texrender')) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         // Element to wrap the image.
 | |
|         const container = document.createElement('span');
 | |
|         const originalWidth = img.attributes.getNamedItem('width');
 | |
| 
 | |
|         const forcedWidth = Number(originalWidth?.value);
 | |
|         if (originalWidth && !isNaN(forcedWidth)) {
 | |
|             if (originalWidth.value.indexOf('%') < 0) {
 | |
|                 img.style.width = forcedWidth + 'px';
 | |
|             } else {
 | |
|                 img.style.width = forcedWidth + '%';
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         container.classList.add('core-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');
 | |
|         } else if (img.classList.contains('atto_image_button_text-top')) {
 | |
|             container.classList.add('atto_image_button_text-top');
 | |
|         } else if (img.classList.contains('atto_image_button_middle')) {
 | |
|             container.classList.add('atto_image_button_middle');
 | |
|         } else if (img.classList.contains('atto_image_button_text-bottom')) {
 | |
|             container.classList.add('atto_image_button_text-bottom');
 | |
|         }
 | |
| 
 | |
|         CoreDomUtils.wrapElement(img, container);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Add image viewer button to view adapted images at full size.
 | |
|      */
 | |
|     protected async addImageViewerButton(): Promise<void> {
 | |
|         const imgs = Array.from(this.element.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 = await this.getElementWidth();
 | |
| 
 | |
|         imgs.forEach((img: HTMLImageElement) => {
 | |
|             // Skip image if it's inside a link.
 | |
|             if (img.closest('a')) {
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             let imgWidth = Number(img.getAttribute('width'));
 | |
|             if (!imgWidth) {
 | |
|                 // No width attribute, use real size.
 | |
|                 imgWidth = img.naturalWidth;
 | |
|             }
 | |
| 
 | |
|             if (imgWidth <= elWidth) {
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             const label = Translate.instant('core.openfullimage');
 | |
|             const button = document.createElement('button');
 | |
| 
 | |
|             button.classList.add('core-image-viewer-icon');
 | |
|             button.classList.add('hidden');
 | |
|             button.setAttribute('aria-label', label);
 | |
|             const iconName = 'up-right-and-down-left-from-center';
 | |
|             const src = CoreIcons.getIconSrc('font-awesome', 'solid', iconName);
 | |
|             // Add an ion-icon item to apply the right styles, but the ion-icon component won't be executed.
 | |
|             button.innerHTML = `<ion-icon name="fas-${iconName}" aria-hidden="true" src="${src}"></ion-icon>`;
 | |
| 
 | |
|             button.addEventListener('click', (e: Event) => {
 | |
|                 const imgSrc = CoreText.escapeHTML(img.getAttribute('data-original-src') || img.getAttribute('src'));
 | |
| 
 | |
|                 e.preventDefault();
 | |
|                 e.stopPropagation();
 | |
|                 CoreViewer.viewImage(imgSrc, img.getAttribute('alt'), this.component, this.componentId);
 | |
|             });
 | |
| 
 | |
|             img.parentNode?.appendChild(button);
 | |
| 
 | |
|             if (img.complete && img.naturalWidth > 0) {
 | |
|                 // Image has already loaded, show the button.
 | |
|                 button.classList.remove('hidden');
 | |
|             } else {
 | |
|                 // Show the button when the image is loaded.
 | |
|                 img.onload = () => button.classList.remove('hidden');
 | |
|             }
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Listener to call when the element is clicked.
 | |
|      *
 | |
|      * @param e Click event.
 | |
|      */
 | |
|     protected elementClicked(e: MouseEvent): void {
 | |
|         if (e.defaultPrevented) {
 | |
|             // Ignore it if the event was prevented by some other listener.
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         if (this.onClick.observed) {
 | |
|             this.onClick.emit();
 | |
| 
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         if (!this.text) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         this.collapsible?.elementClicked(e);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Finish the rendering, displaying the element again and calling afterRender.
 | |
|      *
 | |
|      * @param triggerFilterRender Whether to emit the filterContentRenderingComplete output too.
 | |
|      */
 | |
|     protected async finishRender(triggerFilterRender = true): Promise<void> {
 | |
|         // Show the element again.
 | |
|         this.element.classList.remove('core-loading');
 | |
| 
 | |
|         await CoreWait.nextTick();
 | |
| 
 | |
|         // Emit the afterRender output.
 | |
|         this.afterRender.emit();
 | |
|         if (triggerFilterRender) {
 | |
|             this.filterContentRenderingComplete.emit();
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Format contents and render.
 | |
|      */
 | |
|     protected async formatAndRenderContents(): Promise<void> {
 | |
|         // Destroy previous instances of external-content.
 | |
|         this.externalContentInstances.forEach(extContent => extContent.ngOnDestroy());
 | |
|         this.externalContentInstances = [];
 | |
| 
 | |
|         if (!this.text) {
 | |
|             this.element.innerHTML = this.emptyText; // Remove current contents.
 | |
| 
 | |
|             await this.finishRender();
 | |
| 
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         if (!this.element.getAttribute('singleLine')) {
 | |
|             this.element.setAttribute('singleLine', String(this.singleLine));
 | |
|         }
 | |
| 
 | |
|         this.text = this.text ? this.text.trim() : '';
 | |
| 
 | |
|         const result = await this.formatContents();
 | |
| 
 | |
|         // Disable media adapt to correctly calculate the height.
 | |
|         this.element.classList.add('core-disable-media-adapt');
 | |
| 
 | |
|         this.element.innerHTML = ''; // Remove current contents.
 | |
| 
 | |
|         // Move the children to the current element to be able to calculate the height.
 | |
|         CoreDomUtils.moveChildren(result.div, this.element);
 | |
| 
 | |
|         this.elementControllers.forEach(controller => controller.destroy());
 | |
|         this.elementControllers = result.elementControllers;
 | |
| 
 | |
|         await CoreWait.nextTick();
 | |
| 
 | |
|         // Add magnifying glasses to images.
 | |
|         this.addImageViewerButton();
 | |
| 
 | |
|         if (result.options.filter) {
 | |
|             // Let filters handle HTML. We do it here because we don't want them to block the render of the text.
 | |
|             CoreFilterDelegate.handleHtml(
 | |
|                 this.element,
 | |
|                 result.filters,
 | |
|                 this.viewContainerRef,
 | |
|                 result.options,
 | |
|                 [],
 | |
|                 this.component,
 | |
|                 this.componentId,
 | |
|                 result.siteId,
 | |
|             ).finally(() => {
 | |
|                 this.filterContentRenderingComplete.emit();
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         this.element.classList.remove('core-disable-media-adapt');
 | |
|         await this.finishRender(!result.options.filter);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Apply formatText and set sub-directives.
 | |
|      *
 | |
|      * @returns Promise resolved with a div element containing the code.
 | |
|      */
 | |
|     protected async formatContents(): Promise<FormatContentsResult> {
 | |
|         // Retrieve the site since it might be needed later.
 | |
|         const site = await CorePromiseUtils.ignoreErrors(CoreSites.getSite(this.siteId));
 | |
| 
 | |
|         const siteId = site?.getId();
 | |
| 
 | |
|         if (
 | |
|             site && this.contextLevel === ContextLevel.COURSE && this.contextInstanceId !== undefined && this.contextInstanceId <= 0
 | |
|         ) {
 | |
|             this.contextInstanceId = site.getSiteHomeId();
 | |
|         }
 | |
| 
 | |
|         if (this.contextLevel === ContextLevel.COURSE && this.contextInstanceId === undefined && this.courseId !== undefined) {
 | |
|             this.contextInstanceId = this.courseId;
 | |
|         }
 | |
| 
 | |
|         const filter = this.filter ?? !!(this.contextLevel && this.contextInstanceId !== undefined);
 | |
| 
 | |
|         const options: CoreFilterFormatTextOptions = {
 | |
|             clean: this.clean,
 | |
|             singleLine: this.singleLine,
 | |
|             highlight: this.highlight,
 | |
|             courseId: this.courseId,
 | |
|             wsNotFiltered: this.wsNotFiltered,
 | |
|         };
 | |
| 
 | |
|         let formatted: string;
 | |
|         let filters: CoreFilterFilter[] = [];
 | |
| 
 | |
|         if (filter && siteId) {
 | |
|             const filterResult = await CoreFilterHelper.getFiltersAndFormatText(
 | |
|                 this.text || '',
 | |
|                 this.contextLevel || ContextLevel.SYSTEM,
 | |
|                 this.contextInstanceId ?? -1,
 | |
|                 options,
 | |
|                 siteId,
 | |
|             );
 | |
| 
 | |
|             filters = filterResult.filters;
 | |
|             formatted = filterResult.text;
 | |
|         } else {
 | |
|             formatted = await CoreFilter.formatText(this.text || '', options, [], siteId);
 | |
|         }
 | |
| 
 | |
|         formatted = this.treatWindowOpen(formatted);
 | |
| 
 | |
|         const div = document.createElement('div');
 | |
| 
 | |
|         div.innerHTML = formatted;
 | |
| 
 | |
|         const elementControllers = this.treatHTMLElements(div, site);
 | |
| 
 | |
|         return {
 | |
|             div,
 | |
|             filters,
 | |
|             options,
 | |
|             siteId,
 | |
|             elementControllers,
 | |
|         };
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Treat HTML elements when formatting contents.
 | |
|      *
 | |
|      * @param div Div element.
 | |
|      * @param site Site instance.
 | |
|      * @returns Promise resolved when done.
 | |
|      */
 | |
|     protected treatHTMLElements(div: HTMLElement, site?: CoreSite): ElementController[] {
 | |
|         const images = Array.from(div.querySelectorAll('img'));
 | |
|         const anchors = Array.from(div.querySelectorAll('a'));
 | |
|         const audios = Array.from(div.querySelectorAll('audio'));
 | |
|         const videos = Array.from(div.querySelectorAll('video'));
 | |
|         const iframes = Array.from(div.querySelectorAll('iframe'));
 | |
|         const buttons = Array.from(div.querySelectorAll<HTMLElement>('.button'));
 | |
|         const elementsWithInlineStyles = Array.from(div.querySelectorAll<HTMLElement>('*[style]'));
 | |
|         const stopClicksElements = Array.from(div.querySelectorAll<HTMLElement>('button,input,select,textarea'));
 | |
|         const frames = Array.from(
 | |
|             div.querySelectorAll<FrameElement>(CoreIframeUtilsProvider.FRAME_TAGS.join(',').replace(/iframe,?/, '')),
 | |
|         );
 | |
|         const svgImages = Array.from(div.querySelectorAll('image'));
 | |
|         const promises: Promise<void>[] = [];
 | |
| 
 | |
|         this.treatAppUrlElements(div, site);
 | |
| 
 | |
|         // 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 core-link.
 | |
|         anchors.forEach((anchor) => {
 | |
|             if (anchor.dataset.appUrl) {
 | |
|                 // Link already treated in treatAppUrlElements, ignore it.
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             // Angular doesn't let adding directives dynamically. Create the CoreLinkDirective manually.
 | |
|             const linkDir = new CoreLinkDirective(new ElementRef(anchor));
 | |
|             linkDir.capture = this.captureLinks ?? true;
 | |
|             linkDir.inApp = this.openLinksInApp;
 | |
|             linkDir.ngOnInit();
 | |
| 
 | |
|             this.addExternalContent(anchor);
 | |
|         });
 | |
| 
 | |
|         const externalImages: CoreExternalContentDirective[] = [];
 | |
|         if (images && images.length > 0) {
 | |
|             // Walk through the content to find images, and add our directive.
 | |
|             images.forEach((img: HTMLElement) => {
 | |
|                 this.addMediaAdaptClass(img);
 | |
| 
 | |
|                 const externalImage = this.addExternalContent(img);
 | |
|                 if (externalImage && !externalImage.invalid) {
 | |
|                     externalImages.push(externalImage);
 | |
|                 }
 | |
| 
 | |
|                 if (this.adaptImg && !img.classList.contains('icon')) {
 | |
|                     this.adaptImage(img);
 | |
|                 }
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         const audioControllers = audios.map(audio => {
 | |
|             this.treatMedia(audio);
 | |
| 
 | |
|             return new MediaElementController(audio, !this.disabled);
 | |
|         });
 | |
| 
 | |
|         const videoControllers = videos.map(video => {
 | |
|             this.treatMedia(video, true);
 | |
| 
 | |
|             return new MediaElementController(video, !this.disabled);
 | |
|         });
 | |
| 
 | |
|         const iframeControllers = iframes.map(iframe => {
 | |
|             const { launchExternal, label } = CoreIframeUtils.frameShouldLaunchExternal(iframe);
 | |
|             if (launchExternal && this.replaceFrameWithButton(iframe, site, label)) {
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             promises.push(this.treatIframe(iframe, site));
 | |
| 
 | |
|             return new FrameElementController(iframe, !this.disabled);
 | |
|         }).filter((controller): controller is FrameElementController => controller !== undefined);
 | |
| 
 | |
|         svgImages.forEach((image) => {
 | |
|             this.addExternalContent(image);
 | |
|         });
 | |
| 
 | |
|         // Handle buttons with inner links.
 | |
|         buttons.forEach((button: HTMLElement) => {
 | |
|             // Check if it has a link inside.
 | |
|             if (button.querySelector('a')) {
 | |
|                 button.classList.add('core-button-with-inner-link');
 | |
|             }
 | |
|         });
 | |
| 
 | |
|         // Handle Font Awesome icons to be rendered by the app.
 | |
|         const icons = Array.from(div.querySelectorAll('.fa,.fas,.far,.fab,.fa-solid,.fa-regular,.fa-brands'));
 | |
|         icons.forEach((icon) => {
 | |
|             CoreIcons.replaceCSSIcon(icon);
 | |
|         });
 | |
| 
 | |
|         // Handle inline styles.
 | |
|         elementsWithInlineStyles.forEach((el: HTMLElement) => {
 | |
|             // Only add external content for tags that haven't been treated already.
 | |
|             if (el.tagName !== 'A' && el.tagName !== 'IMG' && el.tagName !== 'AUDIO' && el.tagName !== 'VIDEO'
 | |
|                     && el.tagName !== 'SOURCE' && el.tagName !== 'TRACK' && el.tagName !== 'IMAGE') {
 | |
|                 this.addExternalContent(el, true);
 | |
|             }
 | |
|         });
 | |
| 
 | |
|         // Stop propagating click events.
 | |
|         stopClicksElements.forEach((element: HTMLElement) => {
 | |
|             element.addEventListener('click', (e) => {
 | |
|                 e.stopPropagation();
 | |
|             });
 | |
|         });
 | |
| 
 | |
|         // Handle all kind of frames.
 | |
|         const frameControllers = frames.map((frame) => {
 | |
|             const { launchExternal, label } = CoreIframeUtils.frameShouldLaunchExternal(frame);
 | |
|             if (launchExternal && this.replaceFrameWithButton(frame, site, label)) {
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             CoreIframeUtils.treatFrame(frame, false);
 | |
| 
 | |
|             return new FrameElementController(frame, !this.disabled);
 | |
|         }).filter((controller): controller is FrameElementController => controller !== undefined);
 | |
| 
 | |
|         CoreDomUtils.handleBootstrapTooltips(div);
 | |
| 
 | |
|         if (externalImages.length) {
 | |
|             // Wait for images to load.
 | |
|             const promise = CorePromiseUtils.allPromises(externalImages.map((externalImage) => {
 | |
|                 if (externalImage.loaded) {
 | |
|                     // Image has already been loaded, no need to wait.
 | |
|                     return Promise.resolve();
 | |
|                 }
 | |
| 
 | |
|                 return new Promise(resolve => CoreSubscriptions.once(externalImage.onLoad, resolve));
 | |
|             }));
 | |
| 
 | |
|             // Automatically reject the promise after 5 seconds to prevent blocking the user forever.
 | |
|             promises.push(CorePromiseUtils.ignoreErrors(CorePromiseUtils.timeoutPromise(promise, 5000)));
 | |
|         }
 | |
| 
 | |
|         // Run asynchronous operations in the background to avoid blocking rendering.
 | |
|         Promise.all(promises).catch(error => CoreErrorHelper.logUnhandledError('Error treating format-text elements', error));
 | |
| 
 | |
|         return [
 | |
|             ...videoControllers,
 | |
|             ...audioControllers,
 | |
|             ...iframeControllers,
 | |
|             ...frameControllers,
 | |
|         ];
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Treat elements with an app-url data attribute.
 | |
|      *
 | |
|      * @param div Div containing the elements.
 | |
|      * @param site Site.
 | |
|      */
 | |
|     protected treatAppUrlElements(div: HTMLElement, site?: CoreSite): void {
 | |
|         const appUrlElements = Array.from(div.querySelectorAll<HTMLElement>('*[data-app-url]'));
 | |
| 
 | |
|         appUrlElements.forEach((element) => {
 | |
|             const url = element.dataset.appUrl;
 | |
|             if (!url) {
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             CoreDom.initializeClickableElementA11y(element, async (event) => {
 | |
|                 event.preventDefault();
 | |
|                 event.stopPropagation();
 | |
| 
 | |
|                 site = site || CoreSites.getCurrentSite();
 | |
|                 if (!site) {
 | |
|                     return;
 | |
|                 }
 | |
| 
 | |
|                 const confirmMessage = element.dataset.appUrlConfirm;
 | |
|                 const openInApp = element.dataset.openIn === 'app';
 | |
|                 const refreshOnResume = element.dataset.appUrlResumeAction === 'refresh';
 | |
| 
 | |
|                 if (confirmMessage) {
 | |
|                     try {
 | |
|                         await CoreDomUtils.showConfirm(Translate.instant(confirmMessage));
 | |
|                     } catch {
 | |
|                         return;
 | |
|                     }
 | |
|                 }
 | |
| 
 | |
|                 if (openInApp) {
 | |
|                     site.openInAppWithAutoLogin(url);
 | |
| 
 | |
|                     if (refreshOnResume && this.refreshContext) {
 | |
|                         // Refresh the context when the IAB is closed.
 | |
|                         CoreEvents.once(CoreEvents.IAB_EXIT, () => {
 | |
|                             this.refreshContext?.refreshContext();
 | |
|                         });
 | |
|                     }
 | |
|                 } else {
 | |
|                     site.openInBrowserWithAutoLogin(url, undefined, {
 | |
|                         showBrowserWarning: !confirmMessage,
 | |
|                     });
 | |
| 
 | |
|                     if (refreshOnResume && this.refreshContext) {
 | |
|                         // Refresh the context when the app is resumed.
 | |
|                         CoreSubscriptions.once(CorePlatform.resume, () => {
 | |
|                             NgZone.run(async () => {
 | |
|                                 this.refreshContext?.refreshContext();
 | |
|                             });
 | |
|                         });
 | |
|                     }
 | |
|                 }
 | |
|             });
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Returns the element width in pixels.
 | |
|      *
 | |
|      * @returns The width of the element in pixels.
 | |
|      */
 | |
|     protected async getElementWidth(): Promise<number> {
 | |
|         if (!this.domElementPromise) {
 | |
|             this.domElementPromise = CoreDom.waitToBeInDOM(this.element);
 | |
|         }
 | |
|         await this.domElementPromise;
 | |
| 
 | |
|         let width = this.element.getBoundingClientRect().width;
 | |
|         if (!width) {
 | |
|             // All elements inside are floating or inline. Change display mode to allow calculate the width.
 | |
|             const previousDisplay = getComputedStyle(this.element).display;
 | |
| 
 | |
|             this.element.style.display = 'inline-block';
 | |
|             await CoreWait.nextTick();
 | |
| 
 | |
|             width = this.element.getBoundingClientRect().width;
 | |
| 
 | |
|             this.element.style.display = previousDisplay;
 | |
|         }
 | |
| 
 | |
|         // Aproximate using parent elements.
 | |
|         let element = this.element;
 | |
|         while (!width && element.parentElement) {
 | |
|             element = element.parentElement;
 | |
|             const computedStyle = getComputedStyle(element);
 | |
| 
 | |
|             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;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Add media adapt class and apply CoreExternalContentDirective to the media element and its sources and tracks.
 | |
|      *
 | |
|      * @param element Video or audio to treat.
 | |
|      * @param isVideo Whether it's a video.
 | |
|      */
 | |
|     protected treatMedia(element: HTMLElement, isVideo: boolean = false): void {
 | |
|         if (isVideo) {
 | |
|             this.fixVideoSrcPlaceholder(element);
 | |
|         }
 | |
| 
 | |
|         this.addMediaAdaptClass(element);
 | |
|         this.addExternalContent(element);
 | |
| 
 | |
|         // Hide download button if not hidden already.
 | |
|         let controlsList = element.getAttribute('controlsList') || '';
 | |
|         if (!controlsList.includes('nodownload')) {
 | |
|             if (!controlsList.trim()) {
 | |
|                 controlsList = 'nodownload';
 | |
|             } else {
 | |
|                 controlsList = controlsList.split(' ').concat('nodownload').join(' ');
 | |
|             }
 | |
| 
 | |
|             element.setAttribute('controlsList', controlsList);
 | |
|         }
 | |
| 
 | |
|         const sources = Array.from(element.querySelectorAll('source'));
 | |
|         const tracks = Array.from(element.querySelectorAll('track'));
 | |
| 
 | |
|         sources.forEach((source) => {
 | |
|             this.addExternalContent(source);
 | |
|         });
 | |
| 
 | |
|         tracks.forEach((track) => {
 | |
|             this.addExternalContent(track);
 | |
|         });
 | |
| 
 | |
|         // Stop propagating click events.
 | |
|         element.addEventListener('click', (e) => {
 | |
|             e.stopPropagation();
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Try to fix the placeholder displayed when a video doesn't have a poster.
 | |
|      *
 | |
|      * @param videoElement Element to fix.
 | |
|      */
 | |
|     protected fixVideoSrcPlaceholder(videoElement: HTMLElement): void {
 | |
|         if (videoElement.getAttribute('poster')) {
 | |
|             // Video has a poster, nothing to fix.
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         // Fix the video and its sources.
 | |
|         [videoElement].concat(Array.from(videoElement.querySelectorAll('source'))).forEach((element) => {
 | |
|             const src = element.getAttribute('src');
 | |
|             if (!src || src.match(/#t=\d/)) {
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             element.setAttribute('src', src + '#t=0.001');
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Add media adapt class and treat the iframe source.
 | |
|      *
 | |
|      * @param iframe Iframe to treat.
 | |
|      * @param site Site instance.
 | |
|      */
 | |
|     protected async treatIframe(iframe: HTMLIFrameElement, site: CoreSite | undefined): Promise<void> {
 | |
|         const src = iframe.src;
 | |
|         const currentSite = CoreSites.getCurrentSite();
 | |
| 
 | |
|         this.addMediaAdaptClass(iframe);
 | |
| 
 | |
|         if (CoreIframeUtils.shouldDisplayHelpForUrl(src)) {
 | |
|             this.addIframeHelp(iframe);
 | |
|         }
 | |
| 
 | |
|         if (currentSite?.containsUrl(src)) {
 | |
|             // URL points to current site, try to use auto-login.
 | |
|             // Remove iframe src, otherwise it can cause auto-login issues if there are several iframes with auto-login.
 | |
|             iframe.src = '';
 | |
| 
 | |
|             const finalUrl = await CoreIframeUtils.getAutoLoginUrlForIframe(iframe, src);
 | |
|             await CoreIframeUtils.fixIframeCookies(finalUrl);
 | |
| 
 | |
|             iframe.src = finalUrl;
 | |
|             CoreIframeUtils.treatFrame(iframe, false);
 | |
| 
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         await CoreIframeUtils.fixIframeCookies(src);
 | |
| 
 | |
|         if (site && src) {
 | |
|             let vimeoUrl = CoreUrl.getVimeoPlayerUrl(src, site);
 | |
|             if (vimeoUrl) {
 | |
|                 const domPromise = CoreDom.waitToBeInDOM(iframe);
 | |
|                 this.domPromises.push(domPromise);
 | |
| 
 | |
|                 await domPromise;
 | |
| 
 | |
|                 // Width and height are mandatory, we need to calculate them.
 | |
|                 let width: string | number;
 | |
|                 let height: string | number;
 | |
| 
 | |
|                 if (iframe.width) {
 | |
|                     width = iframe.width;
 | |
|                 } else {
 | |
|                     width = iframe.getBoundingClientRect().width;
 | |
|                     if (!width) {
 | |
|                         width = window.innerWidth;
 | |
|                     }
 | |
|                 }
 | |
| 
 | |
|                 if (iframe.height) {
 | |
|                     height = iframe.height;
 | |
|                 } else {
 | |
|                     height = iframe.getBoundingClientRect().height;
 | |
|                     if (!height) {
 | |
|                         height = width;
 | |
|                     }
 | |
|                 }
 | |
| 
 | |
|                 // Width and height parameters are required in 3.6 and older sites.
 | |
|                 if (site && !site.isVersionGreaterEqualThan('3.7')) {
 | |
|                     vimeoUrl += '&width=' + width + '&height=' + height;
 | |
|                 }
 | |
| 
 | |
|                 await CoreIframeUtils.fixIframeCookies(vimeoUrl);
 | |
| 
 | |
|                 iframe.src = vimeoUrl;
 | |
| 
 | |
|                 if (!iframe.width) {
 | |
|                     iframe.width = String(width);
 | |
|                 }
 | |
|                 if (!iframe.height) {
 | |
|                     iframe.height = String(height);
 | |
|                 }
 | |
| 
 | |
|                 // Do the iframe responsive.
 | |
|                 if (iframe.parentElement?.classList.contains('embed-responsive')) {
 | |
|                     iframe.addEventListener('load', () => {
 | |
|                         if (iframe.contentDocument) {
 | |
|                             const css = document.createElement('style');
 | |
|                             css.setAttribute('type', 'text/css');
 | |
|                             css.innerHTML = 'iframe {width: 100%;height: 100%;position:absolute;top:0; left:0;}';
 | |
|                             iframe.contentDocument.head.appendChild(css);
 | |
|                         }
 | |
|                     });
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         CoreIframeUtils.treatFrame(iframe, false);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Replace a frame with a button to open the frame's URL in an external app.
 | |
|      *
 | |
|      * @param frame Frame element to replace.
 | |
|      * @param site Site instance.
 | |
|      * @param label The text to put in the button.
 | |
|      * @returns Whether iframe was replaced.
 | |
|      */
 | |
|     protected replaceFrameWithButton(frame: FrameElement, site: CoreSite | undefined, label: string): boolean {
 | |
|         const url = 'src' in frame ? frame.src : frame.data;
 | |
|         if (!url) {
 | |
|             return false;
 | |
|         }
 | |
| 
 | |
|         const button = document.createElement('ion-button');
 | |
|         button.setAttribute('expand', 'block');
 | |
|         button.classList.add('ion-text-wrap');
 | |
|         button.innerHTML = label;
 | |
| 
 | |
|         button.addEventListener('click', () => {
 | |
|             CoreIframeUtils.frameLaunchExternal(url, {
 | |
|                 site,
 | |
|                 component: this.component,
 | |
|                 componentId: this.componentId,
 | |
|             });
 | |
|         });
 | |
| 
 | |
|         frame.replaceWith(button);
 | |
| 
 | |
|         return true;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Add iframe help option.
 | |
|      *
 | |
|      * @param iframe Iframe.
 | |
|      */
 | |
|     protected addIframeHelp(iframe: HTMLIFrameElement): void {
 | |
|         const helpDiv = document.createElement('div');
 | |
| 
 | |
|         helpDiv.classList.add('ion-text-center', 'ion-text-wrap');
 | |
| 
 | |
|         const button = document.createElement('ion-button');
 | |
|         button.setAttribute('fill', 'clear');
 | |
|         button.setAttribute('aria-haspopup', 'dialog');
 | |
|         button.classList.add('core-iframe-help', 'core-button-as-link');
 | |
|         button.innerHTML = Translate.instant('core.iframehelp');
 | |
| 
 | |
|         button.addEventListener('click', () => {
 | |
|             CoreIframeUtils.openIframeHelpModal();
 | |
|         });
 | |
| 
 | |
|         helpDiv.appendChild(button);
 | |
| 
 | |
|         iframe.after(helpDiv);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Convert window.open to window.openWindowSafely inside HTML tags.
 | |
|      *
 | |
|      * @param text Text to treat.
 | |
|      * @returns Treated text.
 | |
|      */
 | |
|     protected treatWindowOpen(text: string): string {
 | |
|         // Get HTML tags that include window.open. Script tags aren't executed so there's no need to treat them.
 | |
|         const matches = text.match(/<[^>]+window\.open\([^)]*\)[^>]*>/g);
 | |
| 
 | |
|         if (matches) {
 | |
|             matches.forEach((match) => {
 | |
|                 // Replace all the window.open inside the tag.
 | |
|                 const treated = match.replace(/window\.open\(/g, 'window.openWindowSafely(');
 | |
| 
 | |
|                 text = text.replace(match, treated);
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         return text;
 | |
|     }
 | |
| 
 | |
| }
 | |
| 
 | |
| type FormatContentsResult = {
 | |
|     div: HTMLElement;
 | |
|     filters: CoreFilterFilter[];
 | |
|     elementControllers: ElementController[];
 | |
|     options: CoreFilterFormatTextOptions;
 | |
|     siteId?: string;
 | |
| };
 |