@instructure/canvas-rce
Version:
A component wrapping Canvas's usage of Tinymce
226 lines (221 loc) • 10.2 kB
JavaScript
/*
* Copyright (C) 2022 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { createRoot } from 'react-dom/client';
import formatMessage from '../format-message';
import { Spinner } from '@instructure/ui-spinner';
import { getData, setData } from './jqueryish_funcs';
export const previewableMimeTypes = ['application/vnd.openxmlformats-officedocument.wordprocessingml.template', 'application/vnd.oasis.opendocument.spreadsheet', 'application/vnd.sun.xml.writer', 'application/excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/rtf', 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', 'application/vnd.sun.xml.impress', 'application/vnd.sun.xml.calc', 'application/vnd.ms-excel', 'application/msword', 'application/mspowerpoint', 'application/rtf', 'application/vnd.oasis.opendocument.presentation', 'application/vnd.oasis.opendocument.text', 'application/vnd.openxmlformats-officedocument.presentationml.template', 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', 'text/plain', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/postscript', 'application/pdf', 'application/vnd.ms-powerpoint'];
// check to see if a file of a certan mimeType is previewable inline in the browser
// ex: isPreviewable("application/mspowerpoint") -> true
export function isPreviewable(mimeType) {
return previewableMimeTypes.includes(mimeType);
}
// Show a loading spinner
// The typical use is to show the spinner next to a canvas file link
// while the inline preview is loading. It's also used when previewing
// student submitted files in speedgrader, in which case $link is really
// the div containing the preview iframe. We handle these 2 cases by
// telling the function to put the spinner adjacent to $link,
// or to put it in the center of the preview container.
//
// $link: the DOM node that serves as the reference for locating the spinner
// position: one of 'adjacent' or 'centered'
export function showLoadingImage($link, position = 'adjacent') {
const dir = $link && window.getComputedStyle($link).direction || 'ltr';
const boundingBox = $link.getBoundingClientRect();
const offsetLeft = boundingBox.left + (position === 'adjacent' ? dir === 'ltr' ? boundingBox.width : -24 : boundingBox.width / 2);
$link.style.marginInlineEnd = '28px';
const imageMarginInlineStart = position === 'adjacent' ? '5px' : '-12px';
const imageMarginTop = position === 'adjacent' ? 0 : '-12px';
const zIndex = parseInt($link.style.zIndex || 0, 10) + 1;
const $imageHolder = document.createElement('div');
$imageHolder.setAttribute('class', 'loading_image_holder');
const list = getData($link, 'loading_images') || [];
list.push($imageHolder);
setData($link, 'loading_images', list);
if (!$link.style.position || $link.style.position === 'static') {
const top = `${boundingBox.top + window.scrollY + (position === 'adjacent' ? 0 : boundingBox.height / 2)}px`;
const left = `${offsetLeft}px`;
$imageHolder.setAttribute('style', `z-index: ${zIndex}; position: absolute; top: ${top}; left: ${left}; margin-inline-start: ${imageMarginInlineStart}; margin-top: ${imageMarginTop}`);
document.body.appendChild($imageHolder);
} else {
const top = `${position === 'adjacent' ? 0 : boundingBox.height / 2}px`;
const left = `${offsetLeft}px`;
$imageHolder.setAttribute('style', `z-index:${zIndex}; position: absolute; top: ${top}; left: ${left}; margin-inline-start:${imageMarginInlineStart}; margin-top: ${imageMarginTop}`);
$link.appendChild($imageHolder);
}
const root = createRoot($imageHolder);
root.render(/*#__PURE__*/React.createElement(Spinner, {
size: "x-small",
renderTitle: formatMessage('Loading')
}));
return $link;
}
export function removeLoadingImage($link) {
$link.querySelector('.loading_image')?.remove();
const list = getData($link, 'loading_images') || [];
list.forEach(item => {
if (item) {
const root = item._reactRoot;
if (root) {
root.unmount();
}
item.remove();
}
});
setData($link, 'loading_images', null);
$link.style.marginInlineEnd = '';
return $link;
}
export function loadDocPreview($container, options) {
let opts = {
width: '100%',
height: '400px',
...getData($container),
...options
};
if (!$container) return; // an issue in specs
if (!($container instanceof HTMLElement)) {
throw new Error('loadDocPreview requires a DOM element as first argument');
}
function tellAppIViewedThisInline() {
// if I have a url to ping back to the app that I viewed this file inline, ping it.
if (opts.attachment_view_inline_ping_url) {
fetch(opts.attachment_view_inline_ping_url, {
method: 'POST'
});
}
}
if (opts.crocodoc_session_url) {
const sanitizedUrl = sanitizeUrl(opts.crocodoc_session_url);
const iframe = document.createElement('iframe');
iframe.setAttribute('src', sanitizedUrl);
iframe.setAttribute('width', opts.width);
iframe.setAttribute('height', opts.height);
iframe.setAttribute('allowfullscreen', '1');
iframe.id = opts.id;
$container.appendChild(iframe);
iframe.load(() => {
tellAppIViewedThisInline('crocodoc');
if (typeof opts.ready === 'function') opts.ready();
});
} else if (opts.canvadoc_session_url) {
const canvadocWrapper = document.createElement('div');
canvadocWrapper.setAttribute('style', 'overflow: auto; resize: vertical; border: 1px solid transparent; height: 100%;');
$container.appendChild(canvadocWrapper);
const minHeight = opts.iframe_min_height !== undefined ? opts.iframe_min_height : '800px';
const sanitizedUrl = sanitizeUrl(opts.canvadoc_session_url);
const iframe = document.createElement('iframe');
iframe.addEventListener('load', () => {
tellAppIViewedThisInline('canvadocs');
if (typeof opts.ready === 'function') opts.ready();
});
iframe.setAttribute('src', sanitizedUrl);
iframe.setAttribute('width', opts.width);
iframe.setAttribute('allowfullscreen', '1');
iframe.setAttribute('style', `border: 0; overflow: auto; height: 99%; min-height: ${minHeight}`);
iframe.id = opts.id;
canvadocWrapper.appendChild(iframe);
} else if (!opts.disableGooglePreviews && (!opts.mimetype || isPreviewable(opts.mimetype)) && opts.attachment_id || opts.public_url) {
// else if it's something google docs preview can handle and we can get a public url to this document.
const loadGooglePreview = function () {
// this handles both ssl and plain http.
const googleDocPreviewUrl = `//docs.google.com/viewer?${new URLSearchParams({
embedded: true,
url: opts.public_url
}).toString()}`;
if (!opts.ajax_valid || opts.ajax_valid()) {
const iframe = document.createElement('iframe');
iframe.addEventListener('load', () => {
tellAppIViewedThisInline('google');
if (typeof opts.ready === 'function') {
opts.ready();
}
});
iframe.setAttribute('src', googleDocPreviewUrl);
iframe.setAttribute('height', opts.height);
iframe.setAttribute('width', '100%');
$container.appendChild(iframe);
}
};
if (opts.public_url) {
loadGooglePreview();
} else if (opts.attachment_id) {
let url = `/api/v1/files/${opts.attachment_id}/public_url.json`;
if (opts.submission_id) {
url += '?' + new URLSearchParams({
submission_id: opts.submission_id
}).toString();
}
if (opts.verifier) {
url += `${opts.submission_id ? '&' : '?'}verifier=${opts.verifier}`;
} else {
const match = window.location.search.match(/verifier=([^&]+)(?:&|$)/);
const ver = match && match[1];
if (ver) {
url += `${opts.submission_id ? '&' : '?'}verifier=${ver}`;
}
}
showLoadingImage($container, 'centered');
fetch(url).then(response => {
if (!response.ok) throw new Error(`${response.status}: ${response.statusText}`);
return response;
}).then(response => response.json()).then(data => {
if (data.public_url) {
opts = {
...opts,
...data
};
loadGooglePreview();
}
}).catch(ex => {
console.error(ex);
}).finally(() => {
removeLoadingImage($container);
});
}
} else {
// else fall back with a message that the document can't be viewed inline
const paragraph = document.createElement('p');
if (opts.attachment_preview_processing) {
paragraph.textContent = formatMessage('The document preview is currently being processed. Please try again later.');
} else {
paragraph.textContent = formatMessage('This document cannot be displayed within Canvas.');
}
$container.replaceChildren();
$container.appendChild(paragraph);
}
}
/**
* Replaces bad urls with harmless urls in cases where bad urls might cause harm
* @param {string} url
*/
export function sanitizeUrl(url) {
const defaultUrl = 'about:blank';
try {
const parsedUrl = new URL(url, window.location.origin);
if (parsedUrl.protocol === 'javascript:') {
return defaultUrl;
}
return url;
} catch (e) {
// URL() throws TypeError if url is not a valid URL
return defaultUrl;
}
}