@instructure/canvas-rce
Version:
A component wrapping Canvas's usage of Tinymce
183 lines (174 loc) • 7.41 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 PropTypes, { bool, shape } from 'prop-types';
import { findMediaPlayerIframe } from './iframeUtils';
/**
* Interface for content item's 'custom' field, specifically for what is expected to come from Studio
*
* Used to determine whether or not Studio embedded media should be resizable, and whether or not we
* present controls for the user to modify the embedded media.
*/
export const parsedStudioOptionsPropType = shape({
resizable: bool.isRequired,
convertibleToLink: bool.isRequired
});
export function isStudioContentItemCustomJson(input) {
return typeof input === 'object' && input.source === 'studio';
}
export function studioAttributesFrom(customJson) {
var _customJson$resizable, _customJson$enableMed;
return {
'data-studio-resizable': (_customJson$resizable = customJson.resizable) !== null && _customJson$resizable !== void 0 ? _customJson$resizable : false,
'data-studio-tray-enabled': (_customJson$enableMed = customJson.enableMediaOptions) !== null && _customJson$enableMed !== void 0 ? _customJson$enableMed : false,
'data-studio-convertible-to-link': true
};
}
export function displayStyleFrom(studioAttributes) {
if (!studioAttributes) return '';
return studioAttributes['data-studio-resizable'] || studioAttributes['data-studio-tray-enabled'] ? 'inline-block' : '';
}
export function isStudioEmbeddedMedia(element) {
// Borrowing this structure from isMediaElement in ContentSelection.js
const tinymceIframeShim = element?.tagName === 'IFRAME' ? element?.parentElement : element;
if (tinymceIframeShim?.firstElementChild?.tagName !== 'IFRAME') {
return false;
}
return tinymceIframeShim.getAttribute('data-mce-p-data-studio-tray-enabled') === 'true';
}
export function parseStudioOptions(element) {
const tinymceIframeShim = element?.tagName === 'IFRAME' ? element?.parentElement : element;
return {
resizable: tinymceIframeShim?.getAttribute('data-mce-p-data-studio-resizable') === 'true',
convertibleToLink: tinymceIframeShim?.getAttribute('data-mce-p-data-studio-convertible-to-link') === 'true'
};
}
/**
* Tinymce adds an overlay when you click on an iframe inside the editor. It will by default
* add resize handles to the corners of the overlay. The code that adds these handles won't
* if the overlay has `data-mce-resize='false'` on it. Here, we force that behavior when the
* underlying iframe has a `data-studio-resizable='false'`
*/
export function handleBeforeObjectSelected(e) {
const targetElement = e.target;
if (targetElement.getAttribute('data-mce-p-data-studio-resizable') === 'false') {
targetElement.setAttribute('data-mce-resize', 'false');
}
}
export function findStudioLtiIframeFromSelection(selectedNode) {
let outerIframe = null;
// First, find the outer iframe
if (selectedNode.nodeName === 'IFRAME') {
outerIframe = selectedNode;
} else if (selectedNode.nodeType === Node.ELEMENT_NODE) {
// Look for iframe inside the selected element (the span)
outerIframe = selectedNode.querySelector('iframe');
}
if (!outerIframe) {
console.error('No outer iframe found');
return null;
}
// Now try to access the content document of the outer iframe
try {
const outerIframeDoc = outerIframe.contentDocument || outerIframe.contentWindow?.document;
if (!outerIframeDoc) {
return outerIframe; // Return outer iframe as fallback
}
// Search for nested iframe with data-lti-launch attribute
const nestedIframe = outerIframeDoc.querySelector('iframe[data-lti-launch="true"]');
if (nestedIframe) {
return nestedIframe;
} else {
// Try to find any iframe inside
const anyNestedIframe = outerIframeDoc.querySelector('iframe');
if (anyNestedIframe) {
return anyNestedIframe;
}
}
} catch (error) {
console.error('>> Cannot access outer iframe content (cross-origin):', error);
// Return the outer iframe as fallback since we can't access its contents
return outerIframe;
}
return outerIframe;
}
export const notifyStudioEmbedTypeChange = (editor, embedType) => {
const studioIframe = findStudioLtiIframeFromSelection(editor.selection.getNode());
if (studioIframe && studioIframe.contentWindow) {
studioIframe.contentWindow.postMessage({
subject: 'studio.embedTypeChanged',
embedType: embedType,
timestamp: Date.now()
}, '*');
}
};
export const updateStudioIframeDimensions = (editor, width, height, embedType, resizable) => {
const selectedNode = editor.selection.getNode();
const videoContainer = findMediaPlayerIframe(selectedNode);
if (videoContainer?.tagName !== 'IFRAME') {
return;
}
const tinymceIframeShim = videoContainer.parentElement;
if (!tinymceIframeShim) {
return;
}
editor.dom.setStyles(tinymceIframeShim, {
width: `${width}px`,
height: `${height}px`
});
editor.dom.setStyles(videoContainer, {
width: `${width}px`,
height: `${height}px`
});
if (resizable !== undefined) {
// Update both the actual attribute and the TinyMCE prefixed version
// This ensures they stay in sync when content is saved and reloaded
editor.dom.setAttrib(tinymceIframeShim, 'data-studio-resizable', String(resizable));
editor.dom.setAttrib(tinymceIframeShim, 'data-mce-p-data-studio-resizable', String(resizable));
// Force TinyMCE to update the overlay by setting/removing data-mce-resize
if (!resizable) {
tinymceIframeShim.setAttribute('data-mce-resize', 'false');
} else {
tinymceIframeShim.removeAttribute('data-mce-resize');
}
}
const href = editor.dom.getAttrib(tinymceIframeShim, 'data-mce-p-src');
if (href && embedType) {
if (embedType) {
// Replace thumbnail_embed, learn_embed, or collaboration_embed with the new embed type
const updatedHref = href.replace(/(thumbnail_embed|learn_embed|collaboration_embed)/g, embedType);
// updating only mce-p-src as in we only want to update the real src whenever we step out of the editor or save it
editor.dom.setAttrib(tinymceIframeShim, 'data-mce-p-src', updatedHref);
editor.nodeChanged();
}
}
editor.fire('ObjectResized', {
// @ts-expect-error - needed for aligning tooltip with new iframe size
target: videoContainer,
width,
height
});
};
export const isValidEmbedType = embedType => {
return typeof embedType === 'string' && ['thumbnail_embed', 'learn_embed', 'collaboration_embed'].includes(embedType);
};
export const isValidDimension = value => {
return typeof value === 'number' && !isNaN(value) && isFinite(value) && value > 0;
};
export const isValidResizable = value => {
return typeof value === 'boolean';
};