@instructure/canvas-rce
Version:
A component wrapping Canvas's usage of Tinymce
363 lines (354 loc) • 15.2 kB
JavaScript
/*
* Copyright (C) 2011 - 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 { IconDownloadLine } from '@instructure/ui-icons/es/svg';
import formatMessage from '../format-message';
import { closest, getData, hide, insertAfter, setData, show } from './jqueryish_funcs';
import { isExternalLink, showFilePreview, youTubeID } from './instructure_helper';
import mediaCommentThumbnail from './media_comment_thumbnail';
import { addParentFrameContextToUrl } from '../rce/plugins/instructure_rce_external_tools/util/addParentFrameContextToUrl';
import { MathJaxDirective, Mathml } from './mathml';
import { makeExternalLinkIcon } from './external_links';
import getTranslations from '../getTranslations';
// in jest the es directory doesn't exist so stub the undefined svg
const IconDownloadSVG = IconDownloadLine?.src || '<svg></svg>';
function makeDownloadButton(download_url, filename) {
const a = document.createElement('a');
a.setAttribute('class', 'file_download_btn');
a.setAttribute('role', 'button');
a.setAttribute('download', '');
a.setAttribute('style', 'margin-inline-start: 5px; text-decoration: none;');
a.setAttribute('href', download_url);
const $icon = document.createElement('span');
$icon.setAttribute('role', 'presentation');
$icon.innerHTML = IconDownloadSVG;
$icon.firstChild.setAttribute('style', 'width:1em; height:1em; vertical-align:middle; fill:currentColor');
a.appendChild($icon);
const srspan = document.createElement('span');
srspan.setAttribute('class', 'screenreader-only');
srspan.textContent = formatMessage('Download {filename}', {
filename
});
a.appendChild(srspan);
return a;
}
function handleYoutubeLink($link) {
const href = $link.getAttribute('href');
const id = youTubeID(href || '');
if (id && !$link.classList.contains('inline_disabled')) {
const $after = document.createElement('a');
$after.setAttribute('href', href);
$after.setAttribute('class', 'youtubed');
const img = document.createElement('img');
img.src = '/images/play_overlay.png';
img.className = 'media_comment_thumbnail';
img.alt = getData($link, 'preview-alt') || '';
img.style.backgroundImage = `url(//img.youtube.com/vi/${id}/2.jpg)`;
$after.appendChild(img);
$after.addEventListener('click', function (event) {
event.preventDefault();
const $this = event.currentTarget;
const $video = document.createElement('span');
$video.setAttribute('class', 'youtube_holder');
$video.style.display = 'block';
const iframe = document.createElement('iframe');
iframe.src = `//www.youtube.com/embed/${id}?autoplay=1&rel=0&hl=en_US&fs=1`;
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('width', '425');
iframe.setAttribute('height', '344');
iframe.setAttribute('allowfullscreen', '');
$video.appendChild(iframe);
const br = document.createElement('br');
$video.appendChild(br);
const link = document.createElement('a');
link.href = '#';
link.setAttribute('style', 'font-size: 0.8em;');
link.setAttribute('class', 'hide_youtube_embed_link');
link.textContent = formatMessage('Minimize Video');
$video.appendChild(link);
$video.querySelectorAll('.hide_youtube_embed_link').forEach($elem => {
$elem.addEventListener('click', function (event2) {
event2.preventDefault();
$video.parentElement.removeChild($video);
show($after);
});
});
insertAfter($video, $this);
hide($this);
});
$link.classList.add('youtubed');
insertAfter($after, $link);
}
}
let preview_counter = 0;
function previewId() {
return `preview_${++preview_counter}`;
}
function buildUrl(url) {
try {
return new URL(url);
} catch (e) {
// Don't raise an error
}
}
const addResourceIdentifiersToStudioContent = content => {
content.querySelectorAll('iframe.lti-embed').forEach(iframe => {
const url = buildUrl(iframe.getAttribute('src'));
if (!url || !url.pathname.includes('external_tools/retrieve') || !url.search.includes('instructuremedia.com') || !url.search.includes('custom_arc_media_id')) {
return;
}
const userContentContainer = iframe.closest('.user_content');
if (userContentContainer?.dataset?.resourceType && userContentContainer?.dataset?.resourceId) {
url.searchParams.set('com_instructure_course_canvas_resource_type', userContentContainer.dataset.resourceType);
url.searchParams.set('com_instructure_course_canvas_resource_id', userContentContainer.dataset.resourceId);
iframe.src = url.href;
}
});
};
export function enhanceUserContent(container = document, opts = {}) {
const {
customEnhanceFunc,
canvasOrigin,
kalturaSettings,
disableGooglePreviews,
canvasLinksTarget,
/**
* For MathML configuration
*/
new_math_equation_handling,
explicit_latex_typesetting,
locale,
/**
* When used inside of an LTI tool, this contains the canvas global id of the tool.
*/
containingCanvasLtiToolId,
/**
* Contingency plan in case new instfs media links cause problems in rich content.
*/
replaceInstFSLinksWithOldLinks
} = opts;
getTranslations(locale).then(() => {
formatMessage.setup({
locale: locale || 'en'
});
}).catch(_err => {
console.error('Failed loading the language file for', locale, '. Falling back to English.');
});
const content = container instanceof HTMLElement && container || document.getElementById('content') || document;
const showFilePreviewEx = event => showFilePreview(event, {
canvasOrigin,
disableGooglePreviews
});
content.querySelectorAll('.user_content:not(.enhanced)').forEach(elem => {
elem.classList.add('unenhanced');
explicit_latex_typesetting && elem.classList.add(MathJaxDirective.Process);
});
const mathml = new Mathml({
new_math_equation_handling,
explicit_latex_typesetting
}, {
locale
});
content.querySelectorAll('.unenhanced').forEach(unenhanced_elem => {
explicit_latex_typesetting && mathml.processNewMathInElem(unenhanced_elem);
unenhanced_elem.querySelectorAll('img').forEach(img => {
const src = img.getAttribute('src');
if (!/^\/[^/]/.test(src)) {
return;
}
// if the image file is unpublished it's replaced with the lock image
// and canvas adds hidden=1 to the URL.
// we also need to strip the alt text
if (/hidden=1$/.test(src)) {
img.setAttribute('alt', formatMessage('This image is currently unavailable'));
}
});
setData(unenhanced_elem, 'unenhanced_content_html', unenhanced_elem.innerHTML);
// If instfs links are causing content problems,
// use this to show users old links instead
if (replaceInstFSLinksWithOldLinks) {
const attributes = ['href', 'src'];
const selector = '[href], [src]';
const oldLinkAttribute = 'data-old-link';
unenhanced_elem.querySelectorAll(selector).forEach(element => {
const oldLink = element.getAttribute(oldLinkAttribute);
if (!oldLink) {
return;
}
for (const a of attributes) {
const newLink = element.getAttribute(a);
if (newLink && newLink != oldLink) {
element.setAttribute(a, oldLink);
}
}
});
}
// guarantee relative links point to canvas
if (canvasOrigin) {
const attributes = ['href', 'src'];
const selector = '[href], [src]';
unenhanced_elem.querySelectorAll(selector).forEach(element => {
try {
for (const a of attributes) {
const potentialUrl = element.getAttribute(a);
if (!/^\/[^/]/.test(potentialUrl)) {
continue;
}
const absoluteUrl = new URL(potentialUrl, canvasOrigin);
element.setAttribute(a, absoluteUrl.href);
if (canvasLinksTarget && element.tagName === 'A' && (!element.getAttribute('target') || element.getAttribute('target') === '_blank')) {
element.setAttribute('target', canvasLinksTarget);
}
}
} catch (_ignore) {
// canvasOrigin probably isn't a valid base url
}
});
}
// add parent_frame_context to canvas iframes to allow them loading inside another LTI tool
if (containingCanvasLtiToolId != null) {
unenhanced_elem.querySelectorAll('iframe[src]').forEach(iframeElem => {
const src = iframeElem.getAttribute('src');
if (src.startsWith(canvasOrigin)) {
iframeElem.setAttribute('src', addParentFrameContextToUrl(src, containingCanvasLtiToolId));
}
});
}
// tell LTI tools that they are launching from within the active RCE
unenhanced_elem.querySelectorAll('iframe[src]').forEach(iframeElem => {
const src = iframeElem.getAttribute('src');
if (src.startsWith(canvasOrigin)) {
iframeElem.setAttribute('src', src.replace('display=in_rce', 'display=borderless'));
}
});
unenhanced_elem.querySelectorAll('a:not(.not_external):not(.external)').forEach(childLink => {
if (!isExternalLink(childLink, canvasOrigin)) return;
if (childLink.tagName === 'IMG' || childLink.querySelectorAll('img').length > 0) return;
childLink.classList.add('external');
childLink.setAttribute('target', '_blank');
childLink.setAttribute('rel', 'noreferrer noopener');
const $linkSpan = document.createElement('span');
const $linkText = childLink.innerHTML;
$linkSpan.innerHTML = $linkText;
while (childLink.firstChild) childLink.removeChild(childLink.firstChild);
childLink.appendChild($linkSpan);
const externalLinkIcon = makeExternalLinkIcon(childLink);
childLink.appendChild(externalLinkIcon);
});
addResourceIdentifiersToStudioContent(unenhanced_elem);
});
content.querySelectorAll('a.instructure_file_link, a.instructure_scribd_file').forEach(file_link => {
const href = buildUrl(file_link.href);
// Don't attempt to enhance links with no href
if (!href) return;
const matchesCanvasFile = href.pathname.match(/(?:\/(courses|groups|users)\/\d+)?\/files\/([\d~]+)(?=[!*'();:@&=+$,/?#\[\]]|$)/);
if (!matchesCanvasFile) {
// a bug in the new RCE added instructure_file_link class name to all links
// only proceed if this is a canvas file link
return;
}
if (file_link.textContent.trim()) {
file_link.addEventListener('click', showFilePreviewEx);
const filename = file_link.textContent;
// instructure_file_link_holder is used to find file_preview_link
const $span = document.createElement('span');
$span.setAttribute('class', 'instructure_file_holder link_holder instructure_file_link_holder');
const qs = href.searchParams;
qs.delete('wrap');
qs.append('download_frd', '1');
const download_url = `${href.origin}${href.pathname.replace(/(?:\/(download|preview))?$/, '/download')}?${qs}`;
const $download_btn = makeDownloadButton(download_url, filename);
if (file_link.classList.contains('instructure_scribd_file')) {
if (file_link.classList.contains('no_preview')) {
// link downloads
file_link.setAttribute('href', download_url);
file_link.removeAttribute('target');
} else if (file_link.classList.contains('inline_disabled')) {
// link opens in overlay
file_link.classList.add('preview_in_overlay');
} else {
// link previews
file_link.classList.add('file_preview_link');
}
}
file_link.classList.remove('instructure_file_link');
file_link.classList.remove('instructure_scribd_file');
file_link.parentElement.replaceChild($span, file_link);
$span.appendChild(file_link);
if ($download_btn) $span.appendChild($download_btn);
}
});
// Some schools have been using 'file_preview_link' for inline previews
// outside of the RCE so find them all after we've gone through and
// added our own (above)
content.querySelectorAll('.instructure_file_link_holder a.file_preview_link, .instructure_file_link_holder a.scribd_file_preview_link').forEach($link => {
if ($link.classList.contains('previewable')) {
return;
}
const preview_id = previewId();
$link.setAttribute('aria-expanded', 'false');
$link.setAttribute('aria-controls', preview_id);
$link.classList.add('previewable');
$link.addEventListener('click', showFilePreviewEx);
const $preview_container = document.createElement('div');
$preview_container.setAttribute('role', 'region');
$preview_container.setAttribute('class', 'preview_container');
$preview_container.id = preview_id;
$preview_container.setAttribute('style', 'display: none;');
$link.parentElement.appendChild($preview_container);
if ($link.classList.contains('auto_open')) {
$link.click();
}
});
const unenhanced_anchors = content.querySelectorAll('.user_content.unenhanced a, .user_content.unenhanced+div.answers a');
unenhanced_anchors.forEach($anchor => {
$anchor.querySelectorAll('img.media_comment_thumbnail').forEach($thumbnail => {
const a = closest($thumbnail, 'a', content);
a?.classList.add('instructure_inline_media_comment');
});
if ($anchor.matches('.instructure_inline_media_comment')) {
$anchor.classList.remove('no-underline');
mediaCommentThumbnail($anchor, 'normal', false, kalturaSettings);
}
if ($anchor.matches('.instructure_video_link, .instructure_audio_link')) {
mediaCommentThumbnail($anchor, 'normal', true, kalturaSettings);
}
if (!$anchor.matches('.youtubed')) {
handleYoutubeLink($anchor);
}
});
if (customEnhanceFunc) {
customEnhanceFunc();
}
content.querySelectorAll('.user_content.unenhanced').forEach($elem => {
$elem.classList.remove('unenhanced');
$elem.classList.add('enhanced');
});
setTimeout(() => {
content.querySelectorAll('.user_content form.user_content_post_form:not(.submitted)').forEach($elem => {
$elem.submit();
$elem.classList.add('submitted');
});
}, 10);
// Remove sandbox attribute from user content iframes to fix busted
// third-party content, like Google Drive documents.
document.querySelectorAll('.user_content iframe[sandbox="allow-scripts allow-forms allow-same-origin"]').forEach(frame => {
frame.removeAttribute('sandbox');
const src = frame.src;
frame.src = src;
});
}