UNPKG

@instructure/canvas-rce

Version:

A component wrapping Canvas's usage of Tinymce

183 lines (174 loc) 7.41 kB
/* * 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'; };