@instructure/canvas-rce
Version:
A component wrapping Canvas's usage of Tinymce
1,371 lines (1,333 loc) • 70.9 kB
JavaScript
import _pt from "prop-types";
/*
* Copyright (C) 2018 - 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 React, { Suspense } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import tinymce from 'tinymce';
import _ from 'lodash';
import { StoreProvider } from './plugins/shared/StoreContext';
import { IconKeyboardShortcutsLine } from '@instructure/ui-icons';
import { Alert } from '@instructure/ui-alerts';
import { Spinner } from '@instructure/ui-spinner';
import { View } from '@instructure/ui-view';
import { debounce } from '@instructure/debounce';
import { uid } from '@instructure/uid';
import { FocusRegionManager } from '@instructure/ui-a11y-utils';
import getCookie from '../common/getCookie';
import formatMessage from '../format-message';
import * as contentInsertion from './contentInsertion';
import indicatorRegion from './indicatorRegion';
import { editorLanguage } from './editorLanguage';
import normalizeLocale from './normalizeLocale';
import { sanitizePlugins } from './sanitizePlugins';
import RCEGlobals from './RCEGlobals';
import defaultTinymceConfig from '../defaultTinymceConfig';
import { FS_CHANGEEVENT, FS_ELEMENT, FS_ENABLED, FS_EXIT, FS_REQUEST, instuiPopupMountNodeFn } from '../util/fullscreenHelpers';
import indicate from '../common/indicate';
import bridge from '../bridge';
import CanvasContentTray from './plugins/shared/CanvasContentTray';
import StatusBar, { PRETTY_HTML_EDITOR_VIEW, RAW_HTML_EDITOR_VIEW, WYSIWYG_VIEW } from './StatusBar';
import { VIEW_CHANGE } from './customEvents';
import ShowOnFocusButton from './ShowOnFocusButton';
import KeyboardShortcutModal from './KeyboardShortcutModal';
import AlertMessageArea from './AlertMessageArea';
import alertHandler from './alertHandler';
import { isFileLink, isImageEmbed } from './plugins/shared/ContentSelection';
import { countShouldIgnore } from './plugins/instructure_wordcount/utils/countContent';
import launchWordcountModal from './plugins/instructure_wordcount/clickCallback';
import { determineOSDependentKey } from './userOS';
import skinCSS from './tinymce.oxide.skin.min.css';
import contentCSS from './tinymce.oxide.content.min.css';
import { rceWrapperPropTypes } from './RCEWrapperProps';
import { insertPlaceholder, placeholderInfoFor, removePlaceholder } from '../util/loadingPlaceholder';
import { transformRceContentForEditing } from './transformContent';
// @ts-expect-error
import { IconMoreSolid } from '@instructure/ui-icons/es/svg';
import EncryptedStorage from '../util/encrypted-storage';
import buildStyle from './style';
import { getMenubarForVariant, getMenuForVariant, getToolbarForVariant, getStatusBarFeaturesForVariant } from './RCEVariants';
import { focusFirstMenuButton, focusToolbar, isElementWithinTable, mergeMenu, mergeMenuItems, mergePlugins, mergeToolbar, parsePluginsToExclude, patchAutosavedContent } from './RCEWrapper.utils';
import { externalToolsForToolbar } from './plugins/instructure_rce_external_tools/util/externalToolsForToolbar';
import { initScreenreaderOnFormat } from './screenreaderOnFormat';
import { normalizeContainingContext } from '../util/contextHelper';
const RestoreAutoSaveModal = /*#__PURE__*/React.lazy(() => import('./RestoreAutoSaveModal'));
const RceHtmlEditor = /*#__PURE__*/React.lazy(() => import('./RceHtmlEditor'));
const ASYNC_FOCUS_TIMEOUT = 250;
const DEFAULT_RCE_HEIGHT = '400px';
function addKebabIcon(editor) {
// This has to be done here instead of of in plugins/instructure-ui-icons/plugin.ts
// presumably because the toolbar gets created before that plugin is loaded?
editor.ui.registry.addIcon('more-drawer', IconMoreSolid.src);
}
// Get oxide the default skin injected into the DOM before the overrides loaded by themeable
let inserted = false;
function injectTinySkin() {
if (inserted) return;
inserted = true;
const style = document.createElement('style');
style.setAttribute('data-skin', 'tiny oxide skin');
style.appendChild(document.createTextNode(skinCSS));
// there's CSS from discussions that turns the instui Selectors bold
// and in classic quizzes that also mucks with padding
style.appendChild(document.createTextNode(`
#discussion-edit-view .rce-wrapper input[readonly] {font-weight: normal;}
#quiz_edit_wrapper .rce-wrapper input[readonly] {font-weight: normal; padding-left: .75rem;}
`));
const beforeMe = document.head.querySelector('style[data-glamor]') ||
// find instui's themeable stylesheet
document.head.querySelector('style') ||
// find any stylesheet
document.head.firstElementChild;
document.head.insertBefore(style, beforeMe);
}
const editorWrappers = new WeakMap();
// determines if localStorage is available for our use.
// see https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API
export function storageAvailable() {
let storage = null;
try {
storage = window.localStorage;
const x = '__storage_test__';
storage.setItem(x, x);
storage.removeItem(x);
return true;
} catch (e) {
return e instanceof DOMException && (
// everything except Firefox
e.code === 22 ||
// Firefox
e.code === 1014 ||
// test name field too, because code might not be present
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
// acknowledge QuotaExceededError only if there's something already stored
storage && storage.length !== 0;
}
}
function renderLoading() {
return formatMessage('Loading');
}
let alertIdValue = 0;
class RCEWrapper extends React.Component {
static getByEditor(editor) {
return editorWrappers.get(editor);
}
constructor(props) {
super(props);
this._destroyCalled = false;
this._editorPlaceholderRef = void 0;
this._elementRef = void 0;
this._focusRegio = void 0;
this._focusRegion = void 0;
this._mceSerializedInitialHtmlCached = void 0;
this._showOnFocusButton = void 0;
this._statusBarId = void 0;
this._textareaEl = void 0;
this._effectiveContainingContext = void 0;
this.AIToolsTray = void 0;
this.editor = void 0;
this.initialContent = void 0;
this.intersectionObserver = void 0;
this.language = void 0;
this.ltiToolFavorites = void 0;
this.mutationObserver = void 0;
this.pendingEventHandlers = void 0;
this.pluginsToExclude = void 0;
this.resizeObserver = void 0;
this.storage = void 0;
this.variant = void 0;
this.style = void 0;
this.insert_code = void 0;
this.get_code = void 0;
this.set_code = void 0;
this.onRemove = () => {
bridge.detachEditor(this);
if (this.props.onRemove) {
this.props.onRemove(this);
}
};
this.toggleView = newView => {
// coming from the menubar, we don't have a newView,
let newState;
switch (this.state.editorView) {
case WYSIWYG_VIEW:
{
newState = {
editorView: newView || PRETTY_HTML_EDITOR_VIEW
};
break;
}
case PRETTY_HTML_EDITOR_VIEW:
{
newState = {
editorView: newView || WYSIWYG_VIEW
};
break;
}
case RAW_HTML_EDITOR_VIEW:
{
newState = {
editorView: newView || WYSIWYG_VIEW
};
break;
}
default:
return;
}
// @ts-expect-error
this.setState(newState);
this.checkAccessibility();
if (newView === PRETTY_HTML_EDITOR_VIEW || newView === RAW_HTML_EDITOR_VIEW) {
this.storage?.setItem?.('rce.htmleditor', newView);
}
// Emit view change event
this.mceInstance().fire(VIEW_CHANGE, {
target: this.editor,
newView: newState.editorView
});
};
this.toggleFullscreen = () => {
this.handleClickFullscreen();
};
// @ts-expect-error
this._onFullscreenChange = event => {
if (document[FS_ELEMENT]) {
// @ts-expect-error
this.resizeObserver.observe(document[FS_ELEMENT]);
window.visualViewport?.addEventListener('resize', this._handleFullscreenResize);
this._handleFullscreenResize();
// @ts-expect-error
this._focusRegion = FocusRegionManager.activateRegion(
// @ts-expect-error
document[FS_ELEMENT], {
shouldContainFocus: true
});
} else {
event.target.removeEventListener(FS_CHANGEEVENT, this._onFullscreenChange);
this.resizeObserver.unobserve(event.target);
window.visualViewport?.removeEventListener('resize', this._handleFullscreenResize);
this._setHeight(this.state.fullscreenState.prevHeight);
if (this._focusRegion) {
FocusRegionManager.blurRegion(event.target, this._focusRegion.id);
}
}
this.focusCurrentView();
};
this._handleFullscreenResize = () => {
const ht = window.visualViewport?.height || document[FS_ELEMENT]?.offsetHeight;
this._setHeight((ht || 0) - this._getStatusBarHeight());
};
this.contentTrayClosing = false;
this.blurTimer = 0;
this.handleFocusRCE = () => {
this.handleFocus();
};
// @ts-expect-error
this.handleBlurRCE = event => {
if (event.relatedTarget === null) {
// focus might be moving to tinymce
this.handleBlur(event);
}
if (!this._elementRef.current?.contains(event.relatedTarget)) {
this.handleBlur(event);
}
};
this.handleFocusEditor = _event => {
// use .active to put a focus ring around the content area
// when the editor has focus. This isn't perfect, but it's
// what we've got for now.
const ifr = this.iframe;
if (ifr?.parentElement) {
ifr.parentElement.classList.add('active');
}
this.handleFocus();
};
this.handleBlurEditor = event => {
const ifr = this.iframe;
if (ifr?.parentElement) {
ifr.parentElement.classList.remove('active');
}
this.handleBlur(event);
};
this.handleKey = event => {
if (event.code === 'F9' && event.altKey) {
event.preventDefault();
event.stopPropagation();
// @ts-expect-error
focusFirstMenuButton(this._elementRef.current);
} else if (event.code === 'F10' && event.altKey) {
event.preventDefault();
event.stopPropagation();
// @ts-expect-error
focusToolbar(this._elementRef.current);
} else if (event.code === 'F8' && event.altKey) {
event.preventDefault();
event.stopPropagation();
this.openKBShortcutModal();
} else if (event.code === 'Escape') {
bridge.hideTrays();
} else if (['n', 'N', 'd', 'D'].indexOf(event.key) !== -1) {
// Prevent key events from bubbling up on touch screen device
event.stopPropagation();
}
};
this.handleClickFullscreen = () => {
if (this._isFullscreen()) {
this._exitFullscreen();
} else {
this._enterFullscreen();
}
};
this.handleInputChange = () => {
this.checkAccessibility();
};
this.onInit = (_event, editor) => {
// @ts-expect-error
editor.rceWrapper = this;
this.editor = editor;
const textarea = this.editor.getElement();
// expected by canvas
// @ts-expect-error
textarea.dataset.rich_text = true;
// start with the textarea and tinymce in sync
// @ts-expect-error
textarea.value = this.getCode();
textarea.style.height = this.state.height;
textarea.removeAttribute('aria-hidden');
if (document.body.classList.contains('Underline-All-Links__enabled')) {
if (this.iframe?.contentDocument) {
this.iframe.contentDocument.body.classList.add('Underline-All-Links__enabled');
}
}
editor.on('wordCountUpdate', this.onWordCountUpdate);
// add an aria-label to the application div that wraps RCE
// and change role from "application" to "document" to ensure
// the editor gets properly picked up by screen readers
const tinyapp = document.querySelector('.tox-tinymce[role="application"]');
if (tinyapp) {
tinyapp.setAttribute('aria-label', formatMessage('Rich Content Editor'));
tinyapp.setAttribute('role', 'document');
tinyapp.setAttribute('tabIndex', '-1');
}
// remove role="aplication" attribute from the iframe body
// tinymce adds this when the editor is wrapped in an iframe
// which makes RCE input fields inaccessible to screen readers
const iframe = tinyapp?.querySelector('iframe');
const body = iframe?.contentDocument?.body;
if (body) {
const observer = new MutationObserver(() => {
try {
if (body && body.getAttribute('role') === 'application') {
body.removeAttribute('role');
}
} catch (_) {
/* pass */
}
});
observer.observe(body, {
attributes: true,
childList: false,
subtree: false
});
body.setAttribute('data-role-checked', 'true'); // to trigger observer
setTimeout(() => observer.disconnect(), 10000);
}
// Probably should do this in tinymce.scss, but we only want it in new rce
textarea.style.resize = 'none';
editor.on('keydown', this.handleKey);
editor.on('FullscreenStateChanged', this._onFullscreenChange);
// This propagates click events on the editor out of the iframe to the parent
// document. We need this so that click events get captured properly by instui
// focus-trapping components, so they properly ignore trapping focus on click.
editor.on('click', () => window.document.body.click(), true);
editor.on('Cut Change input Undo Redo', debounce(this.handleInputChange, 1000));
initScreenreaderOnFormat(editor);
this.announceContextToolbars(editor);
if (this.isAutoSaving) {
this.initAutoSave(editor);
}
// first view
this.setEditorView(this.state.editorView);
// readonly should have been handled via the init property passed
// to <Editor>, but it's not.
editor.mode.set(this.props.readOnly ? 'readonly' : 'design');
// Not using iframe_aria_text because compatibility issues.
// Not using iframe_attrs because library overwriting.
if (this.iframe) {
this.iframe.setAttribute('title', formatMessage('Rich Text Area. Press {OSKey}+F8 for Rich Content Editor shortcuts.', {
OSKey: determineOSDependentKey()
}));
}
this._setupSelectionSaving(editor);
this.checkAccessibility();
this.fixToolbarKeyboardNavigation();
if (this.props.onInitted) {
this.props.onInitted(editor);
}
// cleans up highlight artifacts from findreplace plugin
if (this.getRequiredFeatureStatuses().rce_find_replace) {
editor.on('undo redo', _e => {
if (editor?.dom?.doc?.getElementsByClassName?.('mce-match-marker')?.length > 0) {
editor.plugins?.searchreplace?.done();
}
});
}
};
/**
* Fix keyboard navigation in the expanded toolbar
*
* NOTE: This is a workaround for https://github.com/tinymce/tinymce/issues/8618
* and should be removed once that issue is resolved and the tinymce dependency is updated to include it.
*/
this.fixToolbarKeyboardNavigation = () => {
// The keyboard navigation config in tinymce for the expanded toolbar is incorrectly configured,
// and stops at [data-alloy-tabstop] elements.
// It should be configured to stop on .tox-toolbar__group elements.
// This workaround removes attribute, thusly causing navigation to work correctly again.
// For the correct solution, Keying.config should have { selector: '.tox-toolbar__group' }
// in https://github.com/tinymce/tinymce/blob/develop/modules/alloy/src/main/ts/ephox/alloy/ui/schema/SplitSlidingToolbarSchema.ts
this._elementRef.current?.querySelectorAll('.tox-toolbar-overlord button[data-alloy-tabstop]').forEach(it => it.removeAttribute('data-alloy-tabstop'));
};
/**
* Sets up selection saving and restoration logic.
*
* There are certain actions a user can take when the RCE is not focused that clear the selection inside the
* editor, such as invoking the Find feature of the browser. If the user then tries to insert content without
* going back to the editor, the content would be inserted at the top of the RCE, instead of where their cursor
* was.
*
* This method adds logic that saves and restores the selection to work around the issue.
*
* @private
*/
// @ts-expect-error
this._setupSelectionSaving = editor => {
// @ts-expect-error
let savedSelection = null;
let selectionWasReset = false;
let editorHasFocus = false;
const restoreSelectionIfNecessary = () => {
// @ts-expect-error
if (this.editor && savedSelection && selectionWasReset) {
this.editor.selection.setRng(savedSelection.range, savedSelection.isForward);
selectionWasReset = false;
}
};
editor.on('blur', () => {
editorHasFocus = false;
selectionWasReset = false;
if (!this.editor) return;
savedSelection = {
range: this.editor.selection.getRng().cloneRange(),
isForward: this.editor.selection.isForward()
};
});
editor.on('focus', () => {
// We need to restore the selection when the editor regains focus because sometimes the editor regains
// focus without the user setting the selection themselves (such as when they interact with the toolbar)
// and if we didn't, we would end up saving the reset selection before a user managed to actually insert
// content.
restoreSelectionIfNecessary();
editorHasFocus = true;
selectionWasReset = false;
});
editor.on('SelectionChange', () => {
if (editorHasFocus) {
// We don't care if a selection reset occurs when the editor has focus, the user probably intended that
// At least they will see the effect
return;
}
if (!this.editor) return;
const selection = this.editor.selection.normalize();
// Detect a browser-reset selection (e.g. From invoking the Find command)
if (selection.startContainer?.nodeName === 'BODY' && selection.startContainer === selection.endContainer && selection.startOffset === 0 && selection.endOffset === 0) {
selectionWasReset = true;
}
});
editor.on('BeforeExecCommand', () => {
restoreSelectionIfNecessary();
});
editor.on('ExecCommand', (/* event */
) => {
if (!this.editor) return;
// Commands may have modified the selection, we need to recapture it
savedSelection = {
range: this.editor.selection.getRng().cloneRange(),
isForward: this.editor.selection.isForward()
};
});
};
this.announcing = 0;
this._isMounted = false;
/* ********** autosave support *************** */
this.initAutoSave = editor => {
var _this$props$userCache;
this.storage = new EncryptedStorage((_this$props$userCache = this.props.userCacheKey) !== null && _this$props$userCache !== void 0 ? _this$props$userCache : '');
if (this.storage) {
editor.on('change Undo Redo', this.doAutoSave);
editor.on('blur', this.doAutoSave);
this.cleanupAutoSave();
try {
const autosaved = this.getAutoSaved(this.autoSaveKey);
if (autosaved && autosaved.content) {
// We'll compare just the text of the autosave content, since
// Canvas is prone to swizzling images and iframes which will
// make the editor content and autosave content never match up
const editorContent = patchAutosavedContent(editor.getContent({
no_events: true
}), true);
const autosavedContent = patchAutosavedContent(autosaved.content, true);
if (autosavedContent !== editorContent) {
this.setState({
confirmAutoSave: true,
// @ts-expect-error
autoSavedContent: patchAutosavedContent(autosaved.content)
});
} else {
this.storage.removeItem(this.autoSaveKey);
}
}
} catch (ex) {
// log and ignore
console.error('Failed initializing rce autosave', ex);
}
}
};
// remove any autosaved value that's too old
this.cleanupAutoSave = (deleteAll = false) => {
if (this.storage) {
const expiry = deleteAll ? Date.now() : Date.now() - (this.props.autosave?.maxAge || 0);
let i = 0;
let key;
while (key = this.storage.key(i++)) {
if (/^rceautosave:/.test(key)) {
const autosaved = this.getAutoSaved(key);
if (autosaved && autosaved.autosaveTimestamp < expiry) {
this.storage.removeItem(key);
}
}
}
}
};
// @ts-expect-error
this.restoreAutoSave = ans => {
this.setState({
confirmAutoSave: false
}, () => {
const editor = this.mceInstance();
if (ans) {
editor.setContent(this.state.autoSavedContent, {});
}
// @ts-expect-error
this.storage.removeItem(this.autoSaveKey);
});
// let the content be restored
debounce(this.checkAccessibility, 1000)();
};
// @ts-expect-error
this.doAutoSave = (e, retry = false) => {
if (this.storage) {
const editor = this.mceInstance();
// if the editor is empty don't save
if (editor.dom.isEmpty(editor.getBody())) {
return;
}
const content = editor.getContent({
no_events: true
});
try {
this.storage.setItem(this.autoSaveKey, content);
} catch (ex) {
if (!retry) {
// probably failed because there's not enough space
// delete up all the other entries and try again
this.cleanupAutoSave(true);
this.doAutoSave(e, true);
} else {
console.error('Autosave failed:', ex);
}
}
}
};
/* *********** end autosave support *************** */
this.onWordCountUpdate = e => {
if (!this.editor) return;
const shouldIgnore = countShouldIgnore(this.editor, 'body', 'words');
const updatedCount = e.wordCount.words - shouldIgnore;
this.setState(state => {
if (updatedCount !== state.wordCount) {
return {
wordCount: updatedCount
};
} else return null;
});
};
// @ts-expect-error
this.onNodeChange = e => {
// This is basically copied out of the tinymce silver theme code for the status bar
const path = e.parents.filter(p => p.nodeName !== 'BR' && !p.getAttribute('data-mce-bogus') && p.getAttribute('data-mce-type') !== 'bookmark')
// @ts-expect-error
.map(p => p.nodeName.toLowerCase()).reverse();
this.setState({
path
});
};
this.onEditorChange = (content, _editor) => {
this.props.onContentChange?.(content);
// check accessibility when clearing the editor,
// all other times should be checked by handleInputChange
if (content === '') {
this.checkAccessibility();
}
};
this.onResize = (_e, coordinates) => {
const editor = this.mceInstance();
if (editor) {
const container = editor.getContainer();
if (!container) return;
const currentContainerHeight = Number.parseInt(container.style.height, 10);
if (isNaN(currentContainerHeight)) return;
const modifiedHeight = currentContainerHeight + coordinates.deltaY;
const newHeight = `${modifiedHeight}px`;
container.style.height = newHeight;
const textarea = this.getTextarea();
if (textarea) {
textarea.style.height = newHeight;
}
this.setState({
height: newHeight
});
// play nice and send the same event that the silver theme would send
editor.fire('ResizeEditor', {
deltaY: coordinates.deltaY
});
}
};
this.onA11yChecker = triggerElementId => {
const editor = this.mceInstance();
editor.execCommand('openAccessibilityChecker', false, {
mountNode: instuiPopupMountNodeFn,
triggerElementId,
onFixError: errors => {
this.setState({
a11yErrorsCount: errors.length
});
}
}, {
skip_focus: true
});
};
this.checkAccessibility = () => {
const editor = this.mceInstance();
editor.execCommand('checkAccessibility', false, {
// @ts-expect-error
done: errors => {
this.setState({
a11yErrorsCount: errors.length
});
}
}, {
skip_focus: true
});
};
this.openKBShortcutModal = () => {
this.setState({
KBShortcutModalOpen: true,
// @ts-expect-error
KBShortcutFocusReturn: document.activeElement
});
};
this.closeKBShortcutModal = () => {
this.setState({
KBShortcutModalOpen: false
});
};
this.KBShortcutModalExited = () => {
if (this.state.KBShortcutFocusReturn === this.iframe) {
// launched using a kb shortcut
// the iframe has focus so we need to forward it on to tinymce editor
if (this.editor) {
this.editor.focus(false);
}
} else if (this.state.KBShortcutFocusReturn === document.getElementById(`show-on-focus-btn-${this.id}`)) {
// launched from showOnFocus button
// edge case where focusing KBShortcutFocusReturn doesn't work
this._showOnFocusButton?.focus();
} else {
// launched from kb shortcut button on status bar
this.state.KBShortcutFocusReturn?.focus();
}
};
this.handleAIClick = () => {
import('./plugins/shared/ai_tools').then(module => {
// @ts-expect-error
this.AIToolsTray = module.AIToolsTray;
this.setState({
AIToolsOpen: true,
AITToolsFocusReturn: document.activeElement
});
}).catch(ex => {
console.error('Failed loading the AIToolsTray', ex);
});
};
this.closeAITools = () => {
this.setState({
AIToolsOpen: false
});
};
this.AIToolsExited = () => {
if (this.state.AITToolsFocusReturn === this.iframe) {
// launched using a kb shortcut
// the iframe has focus so we need to forward it on to tinymce editor
if (this.editor) {
this.editor.focus(false);
}
} else if (this.state.AITToolsFocusReturn === document.getElementById(`show-on-focus-btn-${this.id}`)) {
// launched from showOnFocus button
// edge case where focusing KBShortcutFocusReturn doesn't work
this._showOnFocusButton?.focus();
} else {
// launched from kb shortcut button on status bar
// @ts-expect-error
this.state.AITToolsFocusReturn?.focus();
}
};
this.handleInsertAIContent = content => {
const editor = this.mceInstance();
contentInsertion.insertContent(editor, content);
};
this.handleReplaceAIContent = content => {
const ed = this.mceInstance();
const selection = ed.selection;
if (selection.getContent().length > 0) {
selection.setContent(content);
} else {
ed.selection.select(ed.getBody(), true);
selection.setContent(content);
}
};
this.getCurrentContentForAI = () => {
const selected = this.mceInstance().selection.getContent();
return selected ? {
type: 'selection',
content: selected
} : {
type: 'full',
content: this.mceInstance().getContent()
};
};
this.handleTextareaChange = () => {
if (this.isHidden()) {
this.setCode(this.textareaValue());
// @ts-expect-error
this.doAutoSave();
}
};
this.addAlert = alert => {
alert.id = alertIdValue++;
this.setState(state => {
let messages = state.messages.concat(alert);
messages = _.uniqBy(messages, 'text'); // Don't show the same message twice
return {
messages
};
});
};
this.removeAlert = messageId => {
this.setState(state => {
const messages = state.messages.filter(message => message.id !== messageId);
return {
messages
};
});
};
/**
* Used for reseting the value during tests
*/
this.resetAlertId = () => {
if (this.state.messages.length > 0) {
throw new Error('There are messages currently, you cannot reset when they are non-zero');
}
alertIdValue = 0;
};
this.style = buildStyle();
// Set up some limited global state that can be referenced
// as needed in RCE's components and function / plugin definitions
// Not intended to be dynamically changed!
RCEGlobals.setFeatures(this.getRequiredFeatureStatuses());
RCEGlobals.setConfig(this.getRequiredConfigValues());
this.editor = null; // my tinymce editor instance
this.language = normalizeLocale(this.props.language);
// interface consistent with editorBox
this.get_code = this.getCode;
this.set_code = this.setCode;
this.insert_code = this.insertCode;
// test override points
// @ts-expect-error
this.indicator = false;
this._elementRef = /*#__PURE__*/React.createRef();
this._editorPlaceholderRef = /*#__PURE__*/React.createRef();
// @ts-expect-error
this._prettyHtmlEditorRef = /*#__PURE__*/React.createRef();
// @ts-expect-error
this._showOnFocusButton = null;
// Process initial content
// @ts-expect-error
this.initialContent = this.getRequiredFeatureStatuses().rce_transform_loaded_content ? transformRceContentForEditing(this.props.defaultContent, {
origin: this.props.canvasOrigin || window?.location?.origin
}) : this.props.defaultContent;
injectTinySkin();
// FWIW, for historic reaasons, the height does not include the
// height of the status bar (which used to be tinymce's)
let _ht = props.editorOptions?.height || DEFAULT_RCE_HEIGHT;
if (!Number.isNaN(_ht)) {
_ht = `${_ht}px`;
}
const currentRCECount = document.querySelectorAll('.rce-wrapper').length;
const maxInitRenderedRCEs = Number.isNaN(props.maxInitRenderedRCEs) ? RCEWrapper.defaultProps.maxInitRenderedRCEs : props.maxInitRenderedRCEs;
this.state = {
path: [],
wordCount: 0,
editorView: props.editorView || WYSIWYG_VIEW,
shouldShowOnFocusButton: props.renderKBShortcutModal === undefined ? true : props.renderKBShortcutModal,
KBShortcutModalOpen: false,
messages: [],
announcement: null,
confirmAutoSave: false,
autoSavedContent: '',
// @ts-expect-error
id: this.props.id || this.props.textareaId || `${uid('rce', 2)}`,
// @ts-expect-error
height: _ht,
fullscreenState: {
// @ts-expect-error
prevHeight: _ht
},
a11yErrorsCount: 0,
shouldShowEditor: typeof IntersectionObserver === 'undefined' || maxInitRenderedRCEs <= 0 || currentRCECount < maxInitRenderedRCEs,
AIToolsOpen: false
};
this._statusBarId = `${this.state.id}_statusbar`;
this.pendingEventHandlers = [];
// @ts-expect-error
this.ltiToolFavorites = externalToolsForToolbar(this.props.ltiTools).map(e => `instructure_external_button_${e.id}`);
this.pluginsToExclude = parsePluginsToExclude(props.editorOptions?.plugins || []);
// @ts-expect-error
this.resourceType = props.resourceType;
// @ts-expect-error
this.resourceId = props.resourceId;
// @ts-expect-error
this.variant = window.RCE_VARIANT || props.variant; // to facilitate testing
// @ts-expect-error
this.tinymceInitOptions = this.wrapOptions(props.editorOptions);
alertHandler.alertFunc = this.addAlert;
this.handleContentTrayClosing = this.handleContentTrayClosing.bind(this);
this.resizeObserver = new ResizeObserver(() => {
this._handleFullscreenResize();
});
this.AIToolsTray = undefined;
this._effectiveContainingContext = normalizeContainingContext(this.props.trayProps?.containingContext);
}
// when the RCE is put into fullscreen we need to move the div
// tinymce mounts popup menus into from the body to the rce-wrapper
// or the menus wind up behind the RCE. I can't find a way to
// configure tinymce to say where that div is mounted, do this
// is a bit of a hack to tag the div that is this RCE's
_tagTinymceAuxDiv() {
const tinyauxlist = document.querySelectorAll('.tox-tinymce-aux');
if (tinyauxlist.length) {
const myaux = tinyauxlist[tinyauxlist.length - 1];
if (myaux.id) {
console.error('Unexpected ID on my tox-tinymce-aux element');
}
myaux.id = `tinyaux-${this.id}`;
}
}
_myTinymceAuxDiv() {
return document.getElementById(`tinyaux-${this.id}`);
}
getRequiredFeatureStatuses() {
const {
new_math_equation_handling = false,
explicit_latex_typesetting = false,
rce_transform_loaded_content = false,
rce_find_replace = false,
rce_studio_embed_improvements = false,
file_verifiers_for_quiz_links = false,
consolidated_media_player = false
} = this.props.features;
return {
new_math_equation_handling,
explicit_latex_typesetting,
rce_transform_loaded_content,
rce_studio_embed_improvements,
file_verifiers_for_quiz_links,
rce_find_replace,
consolidated_media_player
};
}
getRequiredConfigValues() {
return {
locale: normalizeLocale(this.props.language),
// @ts-expect-error
flashAlertTimeout: this.props.flashAlertTimeout,
// @ts-expect-error
timezone: this.props.timezone
};
}
getCanvasUrl() {
return this.props.canvasOrigin;
}
getResourceIdentifiers() {
return {
// @ts-expect-error
resourceType: this.resourceType,
// @ts-expect-error
resourceId: this.resourceId
};
}
// getCode and setCode naming comes from tinyMCE
// kind of strange but want to be consistent
getCode() {
return this.isHidden() ? this.textareaValue() : this.mceInstance().getContent();
}
// @ts-expect-error
checkReadyToGetCode(promptFunc) {
let status = true;
// Check for remaining placeholders
if (this.mceInstance().dom.doc.querySelector(`[data-placeholder-for]`)) {
status = promptFunc(formatMessage('Content is still being uploaded, if you continue it will not be embedded properly.'));
}
return status;
}
setCode(newContent) {
this.mceInstance()?.setContent(newContent);
}
// This function is called imperatively by the page that renders the RCE.
// It should be called when the RCE content is done being edited.
RCEClosed() {
// We want to clear the autosaved content, since the page was legitimately closed.
if (this.storage) {
this.storage.removeItem(this.autoSaveKey);
}
}
indicateEditor(element) {
if (document.querySelector('[role="dialog"][data-mce-component]')) {
// there is a modal open, which zeros out the vertical scroll
// so the indicator is in the wrong place. Give it a chance to close
window.setTimeout(() => {
this.indicateEditor(element);
}, 100);
return;
}
const editor = this.mceInstance();
// @ts-expect-error
if (this.indicator) {
// @ts-expect-error
this.indicator(editor, element);
} else if (!this.isHidden()) {
indicate(indicatorRegion(editor, element));
}
}
contentInserted(element) {
this.indicateEditor(element);
this.checkImageLoadError(element);
this.sizeEditorForContent(element);
}
// make a attempt at sizing the editor so that the new content fits.
// works under the assumptions the body's box-sizing is not content-box
// and that the content is w/in a <p> whose margin is 12px top and bottom
// (which, in canvas, is set in app/stylesheets/components/_ic-typography.scss)
sizeEditorForContent(elem) {
let height;
if (elem && elem.nodeType === 1) {
height = elem.clientHeight;
}
if (height) {
const ifr = this.iframe;
if (ifr) {
// @ts-expect-error
const editor_body_style = ifr.contentWindow.getComputedStyle(
// @ts-expect-error
this.iframe.contentDocument.body);
const editor_ht =
// @ts-expect-error
ifr.contentDocument.body.clientHeight -
// @ts-expect-error
parseInt(editor_body_style['padding-top'], 10) -
// @ts-expect-error
parseInt(editor_body_style['padding-bottom'], 10);
const para_margin_ht = 24;
const reserve_ht = Math.ceil(height + para_margin_ht);
if (reserve_ht > editor_ht) {
this.onResize(null, {
deltaY: reserve_ht - editor_ht
});
}
}
}
}
checkImageLoadError(element) {
if (!element || element.tagName !== 'IMG') {
return;
}
// @ts-expect-error
if (!element.complete) {
// @ts-expect-error
element.onload = () => this.checkImageLoadError(element);
return;
}
// checking naturalWidth in a future event loop run prevents a race
// condition between the onload callback and naturalWidth being set.
setTimeout(() => {
// @ts-expect-error
if (element.naturalWidth === 0) {
// @ts-expect-error
element.style.border = '1px solid #000';
// @ts-expect-error
element.style.padding = '2px';
}
}, 0);
}
insertCode(code) {
const editor = this.mceInstance();
const element = contentInsertion.insertContent(editor, code);
this.contentInserted(element);
}
replaceCode(code) {
if (code !== '' && window.confirm(formatMessage('Content in the editor will be changed. Press Cancel to keep the original content.'))) {
this.mceInstance().setContent(code);
}
}
insertEmbedCode(code) {
const editor = this.mceInstance();
// don't replace selected text, but embed after
editor.selection.collapse();
// tinymce treats iframes uniquely, and doesn't like adding attributes
// once it's in the editor, and I'd rather not parse the incomming html
// string with a regex, so let's create a temp copy, then add a title
// attribute if one doesn't exist. This will let screenreaders announce
// that there's some embedded content helper
// From what I've read, "title" is more reliable than "aria-label" for
// elements like iframes and embeds.
const temp = document.createElement('div');
temp.innerHTML = code;
const code_elem = temp.firstElementChild;
if (code_elem) {
if (!code_elem.hasAttribute('title') && !code_elem.hasAttribute('aria-label')) {
code_elem.setAttribute('title', formatMessage('embedded content'));
}
code = code_elem.outerHTML;
}
// inserting an iframe in tinymce (as is often the case with
// embedded content) causes it to wrap it in a span
// and it's often inserted into a <p> on top of that. Find the
// iframe and use it to flash the indicator.
const element = contentInsertion.insertContent(editor, code);
const ifr = element && element.querySelector && element.querySelector('iframe');
if (ifr) {
this.contentInserted(ifr);
} else {
this.contentInserted(element);
}
}
insertImage(image) {
const editor = this.mceInstance();
const element = contentInsertion.insertImage(editor, image, this.getCanvasUrl());
// Removes TinyMCE's caret text if exists.
if (element?.nextSibling?.data?.startsWith('\xA0' /* nbsp */)) {
element.nextSibling.splitText(1);
element.nextSibling.remove();
}
return {
imageElem: element,
loadingPromise: new Promise((resolve, reject) => {
if (element && element.complete) {
this.contentInserted(element);
resolve();
} else if (element) {
element.onload = () => {
this.contentInserted(element);
resolve();
};
element.onerror = e => {
this.checkImageLoadError(element);
reject(e);
};
}
})
};
}
insertImagePlaceholder(fileMetaProps) {
return insertPlaceholder(this.mceInstance(), fileMetaProps.name, placeholderInfoFor(fileMetaProps));
}
insertVideo(video) {
const editor = this.mceInstance();
const element = contentInsertion.insertVideo(editor, video, this.getCanvasUrl());
this.contentInserted(element);
}
insertAudio(audio) {
const editor = this.mceInstance();
const element = contentInsertion.insertAudio(editor, audio, this.getCanvasUrl());
this.contentInserted(element);
}
insertMathEquation(tex) {
const editor = this.mceInstance();
contentInsertion.insertEquation(editor, tex);
}
removePlaceholders(name) {
removePlaceholder(this.mceInstance(), name);
}
insertLink(link) {
const editor = this.mceInstance();
const element = contentInsertion.insertLink(editor, link, this.getCanvasUrl());
this.contentInserted(element);
}
existingContentToLink() {
const editor = this.mceInstance();
return contentInsertion.existingContentToLink(editor);
}
existingContentToLinkIsImg() {
const editor = this.mceInstance();
return contentInsertion.existingContentToLinkIsImg(editor);
}
// since we may defer rendering tinymce, queue up any tinymce event handlers
// @ts-expect-error
tinymceOn(tinymceEventName, handler) {
if (this.state.shouldShowEditor) {
this.mceInstance().on(tinymceEventName, handler);
} else {
// @ts-expect-error
this.pendingEventHandlers.push({
name: tinymceEventName,
handler
});
}
}
mceInstance() {
if (this.editor) {
return this.editor;
}
return this.props.tinymce.get(this.props.textareaId);
}
// @ts-expect-error
onTinyMCEInstance(command, ...args) {
const editor = this.mceInstance();
if (editor) {
if (command === 'mceRemoveEditor') {
editor.execCommand('mceNewDocument');
} // makes sure content can't persist past removal
editor.execCommand(command, false, ...args);
}
}
destroy() {
this._destroyCalled = true;
this.unhandleTextareaChange();
if (this.props.handleUnmount) {
this.props.handleUnmount();
}
}
getTextarea() {
const node = this.props.textareaId && document.getElementById(this.props.textareaId);
if (node instanceof HTMLTextAreaElement) {
return node;
}
return null;
}
textareaValue() {
return this.getTextarea()?.value || '';
}
get id() {
return this.state.id;
}
getHtmlEditorStorage() {
const cookieValue = getCookie('rce.htmleditor');
if (cookieValue) {
document.cookie = `rce.htmleditor=${cookieValue};path=/;max-age=0`;
}
const value = cookieValue || this.storage?.getItem?.('rce.htmleditor')?.content;
return value === RAW_HTML_EDITOR_VIEW || value === PRETTY_HTML_EDITOR_VIEW ? value : PRETTY_HTML_EDITOR_VIEW;
}
_isFullscreen() {
return !!(this.state.fullscreenState.isTinyFullscreen || document[FS_ELEMENT]);
}
_enterFullscreen() {
// tinymce mounts its menus and toolbars in this element, which is in the DOM
// at the bottom of the body. When we're fullscreen the menus need to be mounted
// in the fullscreen element or they won't show up. Let's move tinymce's mount point
// when we go into fullscreen, then put it back when we're finished.
const tinymenuhost = this._myTinymceAuxDiv();
if (tinymenuhost) {
tinymenuhost.remove();
this._elementRef.current?.appendChild(tinymenuhost);
}
this._elementRef.current?.addEventListener(FS_CHANGEEVENT, this._onFullscreenChange);
if (typeof this._elementRef.current?.offsetHeight === 'number') {
this.setState({
fullscreenState: {
prevHeight: this._elementRef.current.offsetHeight - this._getStatusBarHeight()
}
});
}
// @ts-expect-error
this._elementRef.current[FS_REQUEST]();
}
_exitFullscreen() {
if (document[FS_ELEMENT]) {
const tinymenuhost = this._myTinymceAuxDiv();
if (tinymenuhost) {
tinymenuhost.remove();
document.body.appendChild(tinymenuhost);
}
document[FS_EXIT]();
}
}
_getStatusBarHeight() {
// the height prop is the height of the editor and does not include
// the status bar. we'll need this later.
const node = document.getElementById(this._statusBarId);
return node?.offsetHeight || 0;
}
_setHeight(newHeight) {
const cssHeight = `${newHeight}px`;
const ed = this.mceInstance();
const container = ed.getContainer();
if (container) {
container.style.height = cssHeight;
ed.fire('ResizeEditor');
}
const textarea = this.getTextarea();
if (textarea) {
textarea.style.height = cssHeight;
}
this.setState({
height: cssHeight
});
}
focus() {
this.onTinyMCEInstance('mceFocus');
// tinymce doesn't always call the focus handler.
// @ts-expect-error
this.handleFocusEditor(new Event('focus', {
target: this.mceInstance()
}));
}
focusCurrentView() {
switch (this.state.editorView) {
case WYSIWYG_VIEW:
{
this.mceInstance().focus();
break;
}
case PRETTY_HTML_EDITOR_VIEW:
{
break;
}
case RAW_HTML_EDITOR_VIEW:
{
const textarea = this.getTextarea();
if (textarea) {
textarea.focus();
}
break;
}
}
}
is_dirty() {
if (this.mceInstance().isDirty()) {
return true;
}
const currentHtml = this.isHidden() ? this.textareaValue() : this.mceInstance()?.getContent();
return currentHtml !== this._mceSerializedInitialHtml;
}
/**
* Holds a copy of the initial content of the editor as serialized by tinyMCE to normalize it.
*/
get _mceSerializedInitialHtml() {
if (!this._mceSerializedInitialHtmlCached) {
const el = window.document.createElement('div');
// @ts-expect-error
el.innerHTML = this.initialContent;
const serializer = this.mceInstance().serializer;
this._mceSerializedInitialHtmlCached = serializer.serialize(el, {
getInner: true
});
}
return this._mceSerializedInitialHtmlCached;
}
isHtmlView() {
return this.state.editorView !== WYSIWYG_VIEW;
}
isHidden() {
return this.mceInstance().isHidden();
}
get iframe() {
return document.getElementById(`${this.props.textareaId}_ifr`);
}
// these focus and blur event handlers work together so that RCEWrapper
// can report focus and blur events from the RCE at-large
get focused() {
return this === bridge.getEditor();
}
handleFocus() {
if (!this.focused) {
bridge.focusEditor(this);
if (this.props.onFocus) {
this.props.onFocus(this);
}
}
}
handleContentTrayClosing(isClosing) {
this.contentTrayClosing = isClosing;
}
handleBlur(event) {
if (this.blurTimer) return;
if (this.focused) {
// because the old active element fires blur before the next element gets focus
// we often need a moment to see if focus comes back
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
event && event.persist && event.persist();
this.blurTimer = window.setTimeout(() => {
this.blurTimer = 0;
if (this.contentTrayClosing) {
// the CanvasContentTray is in the process of closing
// wait until it finishes
return;
}
if (this._elementRef.current?.contains(document.activeElement)) {
// focus is still somewhere w/in me
return;
}
const activeClass = document.activeElement?.getAttribute('class');
if (
// @ts-expect-error
(event.focusedEditor === undefined ||
// @ts-expect-error
event.target.id === event.focusedEditor?.id) && activeClass?.includes('tox-')) {
// if a toolbar button has focus, then the user clicks on the "more" button
// focus jumps to the body, then eventually to the popped up toolbar. This
// catches that case.
return;
}
if (event?.relatedTarget?.getAttribute('class')?.includes('tox-')) {
// a tinymce popup has focus
return;
}
const popups = document.querySelectorAll('[data