UNPKG

@instructure/canvas-rce

Version:

A component wrapping Canvas's usage of Tinymce

170 lines (159 loc) 6.95 kB
/* * 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 tinymce from 'tinymce'; import bridge from '../../../bridge'; import configureStore from '../../../sidebar/store/configureStore'; import { get as getSession } from '../../../sidebar/actions/session'; import { uploadToMediaFolder } from '../../../sidebar/actions/upload'; import doFileUpload from '../shared/Upload/doFileUpload'; import formatMessage from '../../../format-message'; import { isAudioOrVideo, isImage } from '../shared/fileTypeUtils'; import { showFlashAlert } from '../../../common/FlashAlert'; import { isMicrosoftWordContentInEvent } from '../shared/EventUtils'; // assume that if there are multiple RCEs on the page, // they all talk to the same canvas const config = { store: null, session: null, // null: we haven't gotten it yet, false: we don't need it sessionPromise: null }; // when UploadFile renders // <StoreProvider {...trayProps}> // {contentProps => { // return ( // <UploadFileModal // The StoreProvider function calls configureStore which creates a new store // (We don't seem to have a way to grab an existing store) // So the configureStore and getSession logic gets repeated here for the // automatic file upload when pasting or dropping a file on the RCE // @ts-expect-error function initStore(initProps) { if (config.store === null) { config.store = configureStore(initProps); } if (config.session === null) { if (initProps.host && initProps.jwt) { config.sessionPromise = getSession(config.store.dispatch, config.store.getState).then(() => { config.session = config.store.getState().session; }) // @ts-expect-error .catch(_err => { console.error('The Paste plugin failed to get canvas session data.'); }); } else { // RCEWrapper will keep us from getting here, but we really should do something anyway. config.session = false; config.sessionPromise = Promise.resolve(); } } return config.store; } tinymce.PluginManager.add('instructure_paste', function (editor) { const store = initStore(bridge.trayProps.get(editor)); /** * Starts the file upload (and insertion) process for the given file. * * If usage rights are required, a dialog will be displayed. * * @returns a promise that resolves when the user has made their choice about uploading the file */ async function requestFileInsertion(file) { // it's very doubtful that we won't have retrieved the session data yet, // since it takes a while for the RCE to initialize, but if we haven't // wait until we do to carry on and finish pasting. await config.sessionPromise; if (config.session === null) { // we failed to get the session and don't know if usage rights are required in this course|group // In all probability, the file upload will fail too, but I feel like we have to do something here. showFlashAlert({ message: formatMessage('If Usage Rights are required, the file will not publish until enabled in the Files page.'), type: 'info' }); } // even though usage rights might be required by the course, canvas has no place // on the user to store it. Only Group and Course. const requiresUsageRights = config.session.usageRightsRequired && /course|group/.test(bridge.trayProps.get(editor).contextType); if (requiresUsageRights) { return doFileUpload(editor, document, { accept: file.type, panels: ['COMPUTER'], preselectedFile: file }).closedPromise; } else { const fileMetaProps = { altText: file.name, contentType: file.type, displayAs: 'embed', isDecorativeImage: false, name: file.name, parentFolderId: 'media', size: file.size, domObject: file }; let tabContext = 'documents'; if (isImage(file.type)) { tabContext = 'images'; } else if (isAudioOrVideo(file.type)) { tabContext = 'media'; } store.dispatch(uploadToMediaFolder(tabContext, fileMetaProps)); return 'submitted'; } } async function handlePasteOrDrop(event) { const isPaste = event.type === 'paste'; const dataTransfer = isPaste ? event.clipboardData : event.dataTransfer; const files = Array.from(dataTransfer?.files || []); const types = dataTransfer?.types || []; const isAudioVideoDisabled = bridge.activeEditor()?.props?.instRecordDisabled; // delegate to tiny if there aren't any files to handle if (!types.includes('Files')) return; // delegate to tiny if there is Microsoft Word content, because it may contain an image // rendering of the content and we don't want to incorrectly paste the image // instead of the actual rich content, which TinyMCE has special handing for if (isMicrosoftWordContentInEvent(event)) return; // we're pasting file(s), prevent the default tinymce pasting behavior event.preventDefault(); // Ensure the editor has focus, because downstream code requires that it does, and drag-n-drop // events can be started when the editor doesn't have focus. if (!editor.hasFocus()) editor.rceWrapper?.focus(); // Checking if we've encountered an issue with file processing for paste events in the browser // Specifically implementing due to this bug in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=1699743 // However, there could be other issues that cause this condition so it's a nice safety net regardless if (isPaste && files.some(file => file.size === 0)) { // @ts-expect-error showFlashAlert({ message: formatMessage('One or more files failed to paste. Please try uploading or dragging and dropping files.'), type: 'error' }); return; } for (const file of files) { if (isAudioVideoDisabled && isAudioOrVideo(file.type)) { // Skip audio and video files when disabled continue; } // This will finish once the dialog is closed, if one was created, putting this in a loop allows us // to show a dialog for each file without them conflicting. await requestFileInsertion(file); } } editor.on('paste', handlePasteOrDrop); editor.on('drop', handlePasteOrDrop); });