MOBILE-4173 iframe: Launch PDF and some iframes in external app

main
Dani Palou 2024-01-29 15:06:16 +01:00
parent 1143d4d695
commit 327fe019a4
8 changed files with 173 additions and 9 deletions

View File

@ -2231,6 +2231,7 @@
"core.ok": "moodle", "core.ok": "moodle",
"core.online": "message", "core.online": "message",
"core.openfile": "local_moodlemobileapp", "core.openfile": "local_moodlemobileapp",
"core.openfilewithextension": "local_moodlemobileapp",
"core.openfullimage": "local_moodlemobileapp", "core.openfullimage": "local_moodlemobileapp",
"core.openinbrowser": "local_moodlemobileapp", "core.openinbrowser": "local_moodlemobileapp",
"core.openinbrowserdescription": "local_moodlemobileapp", "core.openinbrowserdescription": "local_moodlemobileapp",

View File

@ -16,8 +16,6 @@ import { ElementController } from './ElementController';
/** /**
* Possible types of frame elements. * Possible types of frame elements.
*
* @todo Remove frame TAG support.
*/ */
export type FrameElement = HTMLIFrameElement | HTMLObjectElement | HTMLEmbedElement; export type FrameElement = HTMLIFrameElement | HTMLObjectElement | HTMLEmbedElement;

View File

@ -1,9 +1,9 @@
<!-- Display a loading until the iframe page is loaded. --> <!-- Display a loading until the iframe page is loaded. -->
<core-loading [hideUntil]="!loading && safeUrl" /> <core-loading [hideUntil]="!loading && (safeUrl || launchExternalLabel)" />
<!--The iframe needs to be outside of core-loading, otherwise the request is canceled while loading. --> <!--The iframe needs to be outside of core-loading, otherwise the request is canceled while loading. -->
<!-- Don't add the iframe until safeUrl is set, adding an iframe with null as src causes the iframe to load the whole app. --> <!-- Don't add the iframe until safeUrl is set, adding an iframe with null as src causes the iframe to load the whole app. -->
<ng-container *ngIf="safeUrl"> <ng-container *ngIf="safeUrl && !launchExternalLabel">
<core-navbar-buttons slot="end" prepend *ngIf="initialized && showFullscreenOnToolbar && !loading"> <core-navbar-buttons slot="end" prepend *ngIf="initialized && showFullscreenOnToolbar && !loading">
<ion-button fill="clear" (click)="toggleFullscreen()" <ion-button fill="clear" (click)="toggleFullscreen()"
[attr.aria-label]="(fullscreen ? 'core.disablefullscreen' : 'core.fullscreen') | translate"> [attr.aria-label]="(fullscreen ? 'core.disablefullscreen' : 'core.fullscreen') | translate">
@ -28,3 +28,8 @@
{{ 'core.iframehelp' | translate }} {{ 'core.iframehelp' | translate }}
</ion-button> </ion-button>
</ng-container> </ng-container>
<!-- Iframe content needs to be launched in an external app. -->
<ion-button *ngIf="launchExternalLabel" expand="block" class="ion-text-wrap ion-margin" (click)="launchExternal()">
{{ launchExternalLabel }}
</ion-button>

View File

@ -55,6 +55,7 @@ export class CoreIframeComponent implements OnChanges, OnDestroy {
safeUrl?: SafeResourceUrl; safeUrl?: SafeResourceUrl;
displayHelp = false; displayHelp = false;
fullscreen = false; fullscreen = false;
launchExternalLabel?: string; // Text to set to the button to launch external app.
initialized = false; initialized = false;
@ -173,6 +174,19 @@ export class CoreIframeComponent implements OnChanges, OnDestroy {
let url = this.src; let url = this.src;
if (url) {
const { launchExternal, label } = CoreIframeUtils.frameShouldLaunchExternal(url);
if (launchExternal) {
this.launchExternalLabel = label;
this.loading = false;
return;
}
}
this.launchExternalLabel = undefined;
if (url && !CoreUrlUtils.isLocalFileUrl(url)) { if (url && !CoreUrlUtils.isLocalFileUrl(url)) {
url = CoreUrlUtils.getYoutubeEmbedUrl(url) || url; url = CoreUrlUtils.getYoutubeEmbedUrl(url) || url;
this.displayHelp = CoreIframeUtils.shouldDisplayHelpForUrl(url); this.displayHelp = CoreIframeUtils.shouldDisplayHelpForUrl(url);
@ -260,4 +274,17 @@ export class CoreIframeComponent implements OnChanges, OnDestroy {
} }
} }
/**
* Launch content in an external app.
*/
launchExternal(): void {
if (!this.src) {
return;
}
CoreIframeUtils.frameLaunchExternal(this.src, {
site: CoreSites.getCurrentSite(),
});
}
} }

View File

@ -523,10 +523,15 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirec
}); });
const iframeControllers = iframes.map(iframe => { 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)); promises.push(this.treatIframe(iframe, site));
return new FrameElementController(iframe, !this.disabled); return new FrameElementController(iframe, !this.disabled);
}); }).filter((controller): controller is FrameElementController => controller !== undefined);
svgImages.forEach((image) => { svgImages.forEach((image) => {
this.addExternalContent(image); this.addExternalContent(image);
@ -562,11 +567,16 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirec
}); });
// Handle all kind of frames. // Handle all kind of frames.
const frameControllers = frames.map<FrameElementController>((frame) => { const frameControllers = frames.map((frame) => {
const { launchExternal, label } = CoreIframeUtils.frameShouldLaunchExternal(frame);
if (launchExternal && this.replaceFrameWithButton(frame, site, label)) {
return;
}
CoreIframeUtils.treatFrame(frame, false); CoreIframeUtils.treatFrame(frame, false);
return new FrameElementController(frame, !this.disabled); return new FrameElementController(frame, !this.disabled);
}); }).filter((controller): controller is FrameElementController => controller !== undefined);
CoreDomUtils.handleBootstrapTooltips(div); CoreDomUtils.handleBootstrapTooltips(div);
@ -863,6 +873,38 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirec
CoreIframeUtils.treatFrame(iframe, false); 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. * Add iframe help option.
* *

View File

@ -233,6 +233,7 @@
"ok": "OK", "ok": "OK",
"online": "Online", "online": "Online",
"openfile": "Open file", "openfile": "Open file",
"openfilewithextension": "Open {{extension}} file",
"openfullimage": "Click here to display the full size image", "openfullimage": "Click here to display the full size image",
"openinbrowser": "Open in browser", "openinbrowser": "Open in browser",
"openinbrowserdescription": "You will be taken to a web browser", "openinbrowserdescription": "You will be taken to a web browser",

View File

@ -687,7 +687,7 @@ export class CoreDomUtilsProvider {
const element = this.convertToElement(html); const element = this.convertToElement(html);
// Treat elements with src (img, audio, video, ...). // Treat elements with src (img, audio, video, ...).
const media = Array.from(element.querySelectorAll<HTMLElement>('img, video, audio, source, track')); const media = Array.from(element.querySelectorAll<HTMLElement>('img, video, audio, source, track, iframe, embed'));
media.forEach((media: HTMLElement) => { media.forEach((media: HTMLElement) => {
const currentSrc = media.getAttribute('src'); const currentSrc = media.getAttribute('src');
const newSrc = currentSrc ? const newSrc = currentSrc ?

View File

@ -33,6 +33,9 @@ import { CorePath } from '@singletons/path';
import { CorePromisedValue } from '@classes/promised-value'; import { CorePromisedValue } from '@classes/promised-value';
import { CorePlatform } from '@services/platform'; import { CorePlatform } from '@services/platform';
import { FrameElement } from '@classes/element-controllers/FrameElementController'; import { FrameElement } from '@classes/element-controllers/FrameElementController';
import { CoreMimetypeUtils } from './mimetype';
import { CoreFilepool } from '@services/filepool';
import { CoreSite } from '@classes/sites/site';
type CoreFrameElement = FrameElement & { type CoreFrameElement = FrameElement & {
window?: Window; window?: Window;
@ -45,7 +48,7 @@ type CoreFrameElement = FrameElement & {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class CoreIframeUtilsProvider { export class CoreIframeUtilsProvider {
static readonly FRAME_TAGS = ['iframe', 'frame', 'object', 'embed']; static readonly FRAME_TAGS = ['iframe', 'object', 'embed'];
protected logger: CoreLogger; protected logger: CoreLogger;
protected waitAutoLoginDefer?: CorePromisedValue<void>; protected waitAutoLoginDefer?: CorePromisedValue<void>;
@ -631,6 +634,84 @@ export class CoreIframeUtilsProvider {
}); });
} }
/**
* Check if a frame content should be opened with an external app (PDF reader, browser, etc.).
*
* @param urlOrFrame Either a URL of a frame, or the frame to check.
* @returns Whether it should be opened with an external app, and the label for the action to launch in external.
*/
frameShouldLaunchExternal(urlOrFrame: string | FrameElement): { launchExternal: boolean; label: string } {
const url = typeof urlOrFrame === 'string' ?
urlOrFrame :
('src' in urlOrFrame ? urlOrFrame.src : urlOrFrame.data);
const frame = typeof urlOrFrame !== 'string' && urlOrFrame;
const extension = url && CoreMimetypeUtils.guessExtensionFromUrl(url);
const launchExternal = extension === 'pdf' || (frame && frame.getAttribute('data-open-external') === 'true');
let label = '';
if (launchExternal) {
const mimetype = extension && CoreMimetypeUtils.getMimeType(extension);
label = mimetype && mimetype !== 'text/html' && mimetype !== 'text/plain' ?
Translate.instant('core.openfilewithextension', { extension: extension.toUpperCase() }) :
Translate.instant('core.openinbrowser');
}
return {
launchExternal,
label,
};
}
/**
* Launch a frame content in an external app.
*
* @param url Frame URL.
* @param options Options
*/
async frameLaunchExternal(url: string, options: LaunchExternalOptions = {}): Promise<void> {
const modal = await CoreDomUtils.showModalLoading();
try {
if (!CoreNetwork.isOnline()) {
// User is offline, try to open a local copy of the file if present.
const localUrl = options.site ?
await CoreUtils.ignoreErrors(CoreFilepool.getInternalUrlByUrl(options.site.getId(), url)) :
undefined;
if (localUrl) {
CoreUtils.openFile(localUrl);
} else {
CoreDomUtils.showErrorModal('core.networkerrormsg', true);
}
return;
}
const mimetype = await CoreUtils.ignoreErrors(CoreUtils.getMimeTypeFromUrl(url));
if (!mimetype || mimetype === 'text/html' || mimetype === 'text/plain') {
// It's probably a web page, open in browser.
options.site ? options.site.openInBrowserWithAutoLogin(url) : CoreUtils.openInBrowser(url);
return;
}
// Open the file using the online URL and try to download it in background for offline usage.
if (options.site) {
CoreFilepool.getUrlByUrl(options.site.getId(), url, options.component, options.componentId, 0, false);
url = await options.site.checkAndFixPluginfileURL(url);
}
CoreUtils.openOnlineFile(url);
} finally {
modal.dismiss();
}
}
} }
export const CoreIframeUtils = makeSingleton(CoreIframeUtilsProvider); export const CoreIframeUtils = makeSingleton(CoreIframeUtilsProvider);
@ -641,3 +722,12 @@ export const CoreIframeUtils = makeSingleton(CoreIframeUtilsProvider);
type CoreIframeHTMLAnchorElement = HTMLAnchorElement & { type CoreIframeHTMLAnchorElement = HTMLAnchorElement & {
treated?: boolean; // Whether the element has been treated already. treated?: boolean; // Whether the element has been treated already.
}; };
/**
* Options to pass to frameLaunchExternal.
*/
type LaunchExternalOptions = {
site?: CoreSite; // Site the frame belongs to.
component?: string; // Component to download the file if needed.
componentId?: string | number; // Component ID to use in conjunction with the component.
};