@instructure/canvas-rce
Version:
A component wrapping Canvas's usage of Tinymce
435 lines (402 loc) • 13.8 kB
JavaScript
/*
* Copyright (C) 2023 - 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 { isAudio, isImage, isVideo } from '../rce/plugins/shared/fileTypeUtils';
import { AUDIO_PLAYER_SIZE, videoDefaultSize } from '../rce/plugins/instructure_record/VideoOptionsTray/TrayController';
import formatMessage from '../format-message';
import { trimmedOrNull } from './string-util';
import { assertNever } from './assertNever';
import { isTextNode } from './elem-util';
/**
* Determines what type of placeholder is appropriate for a given file information.
*/
export async function placeholderInfoFor(fileMetaProps) {
var _fileMetaProps$title, _trimmedOrNull;
const fileName = (_fileMetaProps$title = fileMetaProps.title) !== null && _fileMetaProps$title !== void 0 ? _fileMetaProps$title : fileMetaProps.name;
const visibleLabel = (_trimmedOrNull = trimmedOrNull(fileName)) !== null && _trimmedOrNull !== void 0 ? _trimmedOrNull : formatMessage('Loading...');
const ariaLabel = formatMessage('Loading placeholder for {fileName}', {
fileName: fileName !== null && fileName !== void 0 ? fileName : 'unknown filename'
});
if (typeof fileMetaProps.contentType !== 'string') {
throw new Error('Invalid fileMetaProps.contentType');
}
const type = fileMetaProps.contentType || fileMetaProps.type;
if (isImage(fileMetaProps.contentType) && fileMetaProps.displayAs !== 'link') {
var _trimmedOrNull2;
const imageUrl = (_trimmedOrNull2 = trimmedOrNull(fileMetaProps.domObject.preview)) !== null && _trimmedOrNull2 !== void 0 ? _trimmedOrNull2 : URL.createObjectURL(fileMetaProps.domObject);
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve({
type: 'block',
visibleLabel,
ariaLabel,
width: image.width + 'px',
height: image.height + 'px',
vAlign: 'middle',
backgroundImageUrl: image.src
});
image.onerror = () => reject(new Error('Failed to load image: ' + imageUrl));
image.src = imageUrl;
});
} else if (typeof type === 'string' && isVideo(type)) {
const videoSize = videoDefaultSize();
return {
type: 'block',
visibleLabel,
ariaLabel,
width: videoSize.width,
height: videoSize.height,
vAlign: 'bottom'
};
} else if (typeof type === 'string' && isAudio(type)) {
return {
type: 'block',
visibleLabel,
ariaLabel,
width: AUDIO_PLAYER_SIZE.width,
height: AUDIO_PLAYER_SIZE.height,
vAlign: 'bottom'
};
} else {
return {
type: 'inline',
visibleLabel,
ariaLabel
};
}
}
export function removePlaceholder(editor, unencodedName) {
const placeholderElem = editor.dom.doc.querySelector(`[data-placeholder-for="${encodeURIComponent(unencodedName)}"]`);
// Fail gracefully
if (!placeholderElem) return;
editor.undoManager.ignore(() => {
editor.dom.remove(placeholderElem);
// Cleanup data URIs
placeholderElem.querySelectorAll('img').forEach(
// Revoking non-object URLs is safe
img => URL.revokeObjectURL(img.src));
});
}
/**
* Inserts a placeholder into a TinyMCE editor. It should be removed by calling removePlaceholder, to ensure
* image resources are cleaned up.
*/
export async function insertPlaceholder(editor, unencodedName, placeholderInfoPromise) {
const placeholderId = `placeholder-${placeholderIdCounter++}`;
// Insert a minimal placeholder element into the editor.
editor.undoManager.ignore(() => editor.execCommand('mceInsertContent', false, `<span
aria-label="${formatMessage('Loading')}"
data-placeholder-for="${encodeURIComponent(unencodedName || '')}"
id="${placeholderId}"
class="mceNonEditable"
style="user-select: none; pointer-events: none; user-focus: none; display: inline-flex;"
></span> `
// Without the trailing , tinymce will place the cursor inside the placeholder, which we don't want.
));
const placeholderElem = editor.dom.doc.querySelector(`#${placeholderId}`);
if (placeholderElem) {
editor.undoManager.ignore(() => {
// Remove the trailing space
const nextNode = placeholderElem.nextSibling;
placeholderElem.contentEditable = 'false';
if (isTextNode(nextNode) && nextNode?.data?.startsWith('\xA0' /* nbsp */)) {
// Split out the non-breaking-space which only counts as length 1 for splitText
nextNode.splitText(1);
// Remove the now split text node
if (placeholderElem.nextSibling) {
editor.dom.remove(placeholderElem.nextSibling);
}
}
});
} else {
throw new Error('Failed to find placeholder element after inserting it into the editor.');
}
const placeholderInfo = await placeholderInfoPromise;
// Fully initialize the placeholder. Done separately from inserting to avoid TinyMCE mangling the HTML
editor.undoManager.ignore(() => {
// Set up the overall placeholder container
placeholderElem.setAttribute('aria-label', placeholderInfo.ariaLabel);
Object.assign(placeholderElem.style, {
// Placeholder has absolute children
position: 'relative',
// Layout
display: 'inline-flex',
alignItems: 'center',
borderRadius: '10px',
overflow: 'hidden'
});
// Create the spinner
placeholderElem.innerHTML = spinnerSvg(placeholderInfo.type === 'inline' ? 'x-small' : 'medium', placeholderId + '-label');
const spinnerElem = placeholderElem.firstElementChild;
if (!spinnerElem) {
throw new Error("Couldn't find the Spinner element in the placeholder");
}
// Create the label
const labelElem = editor.dom.doc.createElement('div');
placeholderElem.appendChild(labelElem);
Object.assign(labelElem.style, {
color: '#2D3B45',
zIndex: '1000',
/* Restrict text to one line */
display: 'inline-block',
maxWidth: 'calc(100% - 10px)',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis'
});
labelElem.appendChild(editor.dom.doc.createTextNode(placeholderInfo.visibleLabel));
// Handle type specific stying
switch (placeholderInfo.type) {
case 'inline':
Object.assign(placeholderElem.style, {
flexDirection: 'row',
justifyContent: 'start',
padding: '5px',
verticalAlign: 'baseline',
gap: '8px',
backgroundColor: '#F5F5F5'
});
break;
case 'block':
{
const {
width,
height,
vAlign,
backgroundImageUrl
} = placeholderInfo;
Object.assign(placeholderElem.style, {
flexDirection: 'column',
justifyContent: 'center',
minWidth: '128px',
width,
maxWidth: '100%',
minHeight: '128px',
height,
maxHeight: '90vh',
verticalAlign: vAlign,
backgroundColor: '#FFFFFF'
});
if (backgroundImageUrl != null) {
const imageElem = document.createElement('img');
imageElem.src = backgroundImageUrl;
placeholderElem.insertBefore(imageElem, placeholderElem.firstElementChild);
Object.assign(imageElem.style, {
// The image should fill the placeholder
position: 'absolute',
left: '0px',
top: '0px',
width: '100%',
height: '100%',
// Aspect ratio should be maintained, though
objectFit: 'cover',
objectPosition: 'center center',
// UI calls for a 80% white overlay, which is equivalent to a 20% opacity image on white
opacity: '.2'
});
}
}
break;
default:
// Ensure all valid placeholderInfo types are handled above.
assertNever(placeholderInfo);
}
});
// Prevent user interaction with the placeholder elements
placeholderElem.querySelectorAll('*').forEach(elem => {
if (elem instanceof HTMLElement) {
elem.style.pointerEvents = 'none';
elem.style.userSelect = 'none';
elem.setAttribute('aria-hidden', 'true');
}
});
return placeholderElem;
}
/**
* Something for which a placeholder can be added to the editor.
*/
/**
* Style of placeholder to be inserted into the editor.
*/
let placeholderIdCounter = 0;
/**
* A fully standalone version of InstUI <Spinner> that can be used inside TinyMCE's iframe without access to
* Canvas's CSS or JS.
*/
// language=html
function spinnerSvg(size, labelId) {
const radius = (() => {
switch (size) {
case 'x-small':
return '0.5em';
case 'small':
return '1em';
case 'large':
return '2.25em';
default:
return '1.75em';
}
})();
return `
<span class="Spinner-root Spinner-default Spinner-${size}" role="presentation">
<svg class="Spinner-circle"
role="img"
focusable="false"
aria-labelledby="${labelId}"
>
<style>
@keyframes Spinner-rotate {
to {
transform: rotate(360deg);
}
}
@keyframes Spinner-morph {
0% {
stroke-dashoffset: 190%;
}
50% {
stroke-dashoffset: 50%;
transform: rotate(90deg);
}
100% {
stroke-dashoffset: 190%;
transform: rotate(360deg);
}
}
.Spinner-root {
display: inline-block;
vertical-align: middle;
position: relative;
box-sizing: border-box;
overflow: hidden;
--Spinner-trackColor: #F5F5F5;
--Spinner-color: #0374B5;
--Spinner-xSmallSize: 1.5em;
--Spinner-xSmallBorderWidth: 0.25em;
--Spinner-smallSize: 3em;
--Spinner-smallBorderWidth: 0.375em;
--Spinner-mediumSize: 5em;
--Spinner-mediumBorderWidth: 0.5em;
--Spinner-largeSize: 7em;
--Spinner-largeBorderWidth: 0.75em;
--Spinner-inverseColor: #0374B5;
}
.Spinner-circleTrack {
stroke: var(--Spinner-trackColor);
/* Give the track extra width per UI */
stroke-width: calc(var(--Spinner-trackWidth) + 4px);
}
.Spinner-circleSpin {
stroke-width: var(--Spinner-trackWidth);
}
.Spinner-x-small {
width: var(--Spinner-xSmallSize);
height: var(--Spinner-xSmallSize);
--Spinner-trackWidth: var(--Spinner-xSmallBorderWidth);
}
.Spinner-x-small .Spinner-circle {
width: var(--Spinner-xSmallSize);
height: var(--Spinner-xSmallSize);
}
.Spinner-x-small .Spinner-circleSpin {
stroke-dasharray: 3em;
transform-origin: 50% 50%;
}
.Spinner-small {
width: var(--Spinner-smallSize);
height: var(--Spinner-smallSize);
--Spinner-trackWidth: var(--Spinner-smallBorderWidth);
}
.Spinner-small .Spinner-circle {
width: var(--Spinner-smallSize);
height: var(--Spinner-smallSize);
}
.Spinner-small .Spinner-circleTrack,
.Spinner-small .Spinner-circleSpin {
stroke-dasharray: 6em;
transform-origin: 50% 50%;
}
.Spinner-medium {
width: var(--Spinner-mediumSize);
height: var(--Spinner-mediumSize);
--Spinner-trackWidth: var(--Spinner-mediumBorderWidth);
}
.Spinner-medium .Spinner-circle {
stroke-width: var(--Spinner-mediumBorderWidth);
width: var(--Spinner-mediumSize);
height: var(--Spinner-mediumSize);
}
.Spinner-medium .Spinner-circleSpin {
stroke-dasharray: 10.5em;
transform-origin: 50% 50%;
}
.Spinner-large {
width: var(--Spinner-largeSize);
height: var(--Spinner-largeSize);
--Spinner-trackWidth: var(--Spinner-largeBorderWidth);
}
.Spinner-large .Spinner-circle {
stroke-width: var(--Spinner-largeBorderWidth);
width: var(--Spinner-largeSize);
height: var(--Spinner-largeSize);
}
.Spinner-large .Spinner-circleSpin {
stroke-dasharray: 14em;
transform-origin: 50% 50%;
}
.Spinner-circle {
display: block;
position: absolute;
top: 0;
left: 0;
/* stylelint-disable-line property-blacklist */
animation-name: Spinner-rotate;
animation-duration: 2.25s;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
.Spinner-circleTrack,
.Spinner-circleSpin {
fill: none;
}
.Spinner-circleSpin {
stroke-linecap: round;
}
.Spinner-root:not(.ie11) .Spinner-circleSpin {
animation-name: Spinner-morph;
animation-duration: 1.75s;
animation-iteration-count: infinite;
animation-timing-function: ease;
}
.Spinner-root.ie11 .Spinner-circleSpin {
stroke-dashoffset: 100%;
}
.Spinner-default .Spinner-circleSpin {
stroke: var(--Spinner-color);
}
.Spinner-inverse .Spinner-circleSpin {
stroke: var(--Spinner-inverseColor);
}
</style>
<g role="presentation">
<circle class="Spinner-circleTrack" cx="50%" cy="50%" r="${radius}"></circle>
<circle class="Spinner-circleSpin" cx="50%" cy="50%" r="${radius}"></circle>
</g>
</svg>
</span>
`;
}