@instructure/canvas-rce
Version:
A component wrapping Canvas's usage of Tinymce
302 lines (296 loc) • 11.9 kB
JavaScript
import _pt from "prop-types";
// TODO: we get complaints about <Overlay> because it can be either a Modal or a Tray
// and they have different props. I don't have time to fix this the right way now.
/*
* Copyright (C) 2019 - 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 { replaceTags } from '../../helpers/tags';
import React, { createRef } from 'react';
import { Alert } from '@instructure/ui-alerts';
import { Spinner } from '@instructure/ui-spinner';
import { Flex } from '@instructure/ui-flex';
import ToolLaunchIframe from '../util/ToolLaunchIframe';
import processEditorContentItems from '../../lti13-content-items/processEditorContentItems';
import { RceLti11ContentItem } from '../../lti11-content-items/RceLti11ContentItem';
import formatMessage from '../../../../../format-message';
import { instuiPopupMountNodeFn } from '../../../../../util/fullscreenHelpers';
import { ExternalToolDialogTray } from './ExternalToolDialogTray';
import { ExternalToolDialogModal } from './ExternalToolDialogModal';
import { showFlashAlert } from '../../../../../common/FlashAlert';
import { parseUrlOrNull } from '../../../../../util/url-util';
export default class ExternalToolDialog extends React.Component {
constructor(...args) {
super(...args);
this.state = {
open: false,
button: null,
infoAlert: null,
form: EMPTY_FORM,
iframeLoaded: false
};
this.formRef = /*#__PURE__*/createRef();
this.beforeInfoAlertRef = /*#__PURE__*/createRef();
this.afterInfoAlertRef = /*#__PURE__*/createRef();
this.iframeRef = /*#__PURE__*/createRef();
this.handleBeforeUnload = ev => ev.returnValue = formatMessage('Changes you made may not be saved.');
this.handleExternalContentReady = data => {
const env = this.props.env;
// a2DataReady listener will insert the data to the editor,
// So only close the modal is needed, only if assignments_2_student flag is enabled,
// is readable by current user and it is a student assignment view.
if (env.isA2StudentView) {
this.close();
return;
}
const contentItems = data.contentItems;
if (contentItems.length === 1 && contentItems[0]['@type'] === 'lti_replace') {
const code = contentItems[0].text;
// @ts-expect-error
env.rceWrapper?.setCode(code);
} else {
contentItems.forEach(contentData => {
const code = RceLti11ContentItem.fromJSON({
...contentData,
class: 'lti-embed'
}, env).codePayload;
// @ts-expect-error
env.rceWrapper?.insertCode(code);
});
}
this.close();
};
this.handlePostedMessage = ev => {
if (ev.origin === this.resourceSelectionOrigin) {
const data = ev.data;
if (data?.subject === 'LtiDeepLinkingResponse') {
processEditorContentItems(ev, this.props.env, this);
} else if (data?.subject === 'externalContentReady') {
// 'externalContentReady' is EXTERNAL_CONTENT_READY in
// ui/shared/external-tools/externalContentEvents.ts
// where events are also described/used
this.handleExternalContentReady(ev.data);
}
}
};
this.handleClose = () => {
const msg = formatMessage('Are you sure you want to cancel? Changes you made may not be saved.');
if (window.confirm(msg)) {
this.close();
}
};
this.handleOpen = () => {
if (this.state.open) this.formRef.current?.submit();
};
this.handleRemove = () => {
this.setState({
button: null
});
this.props.env.editor?.focus();
// force tinyMCE to redraw sticky toolbar otherwise it never goes away
window.dispatchEvent(new Event('resize'));
};
this.handleInfoAlertFocus = ev => this.setState({
infoAlert: ev.target
});
this.handleInfoAlertBlur = () => this.setState({
infoAlert: null
});
this.calcIFrameHeight = () => {
if (this.state.button?.use_tray) {
return '100%';
}
const toolDefinedHeight = this.state.button?.height;
const iFrameHeight = toolDefinedHeight !== null && toolDefinedHeight !== void 0 ? toolDefinedHeight : Math.max(Math.min(window.innerHeight - 100, 550), 100);
const modalMaxHeight = '95';
const modalHeaderHeightWithPadding = '5.5rem';
const complexHeightWithDVH = `min(${iFrameHeight}px, calc(${modalMaxHeight}dvh - ${modalHeaderHeightWithPadding}))`;
if (CSS.supports('height', complexHeightWithDVH)) {
return complexHeightWithDVH;
} else {
return `${iFrameHeight}px`;
}
};
}
open(button) {
var _env$editorSelection, _env$editorContent;
const {
env,
resourceSelectionUrlOverride
} = this.props;
let urlStr = replaceTags(resourceSelectionUrlOverride, 'id', button.id);
const selection = (_env$editorSelection = env?.editorSelection) !== null && _env$editorSelection !== void 0 ? _env$editorSelection : '';
const contents = (_env$editorContent = env?.editorContent) !== null && _env$editorContent !== void 0 ? _env$editorContent : '';
if (urlStr == null) {
// if we don't have a url on the page, build one using the current context.
// url should look like: /courses/2/external_tools/15/resource_selection?editor=1
const contextAssetInfo = env.contextAssetInfo;
if (contextAssetInfo == null) {
showFlashAlert({
message: formatMessage('Unable to determine resource selection url'),
type: 'error',
err: undefined
});
return;
}
const {
contextType,
contextId
} = contextAssetInfo;
const canvasOrigin = env.canvasOrigin;
urlStr = `${canvasOrigin}/${contextType}s/${contextId}/external_tools/${encodeURIComponent(button.id)}/resource_selection`;
}
this.setState({
open: true,
button,
form: {
url: urlStr,
selection,
contents,
parent_frame_context: env.containingCanvasLtiToolId
}
});
window.addEventListener('beforeunload', this.handleBeforeUnload);
window.addEventListener('message', this.handlePostedMessage);
}
close() {
window.removeEventListener('beforeunload', this.handleBeforeUnload);
window.removeEventListener('message', this.handlePostedMessage);
this.setState({
open: false,
form: EMPTY_FORM
});
}
get resourceSelectionOrigin() {
if (this.props.resourceSelectionUrlOverride) {
const resourceSelectionUrl = parseUrlOrNull(this.props.resourceSelectionUrlOverride);
if (resourceSelectionUrl != null) {
return resourceSelectionUrl.origin;
}
}
return this.props.env.canvasOrigin;
}
render() {
var _state$button$title, _state$button$width;
const state = this.state;
const props = this.props;
const label = formatMessage('Embed content from External Tool');
const Overlay = state.button?.use_tray ? ExternalToolDialogTray : ExternalToolDialogModal;
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("form", {
ref: this.formRef,
method: "POST",
action: state.form.url,
target: "external_tool_launch",
style: {
margin: 0
}
}, /*#__PURE__*/React.createElement("input", {
type: "hidden",
name: "editor",
value: "1"
}), /*#__PURE__*/React.createElement("input", {
type: "hidden",
name: "selection",
value: state.form.selection
}), /*#__PURE__*/React.createElement("input", {
type: "hidden",
name: "editor_contents",
value: state.form.contents
}), /*#__PURE__*/React.createElement("input", {
type: "hidden",
name: "com_instructure_course_canvas_resource_type"
// @ts-expect-error
,
value: props.env.rceWrapper?.getResourceIdentifiers().resourceType
}), /*#__PURE__*/React.createElement("input", {
type: "hidden",
name: "com_instructure_course_canvas_resource_id"
// @ts-expect-error
,
value: props.env.rceWrapper?.getResourceIdentifiers().resourceId
}), state.form.parent_frame_context != null && /*#__PURE__*/React.createElement("input", {
type: "hidden",
name: "parent_frame_context",
value: state.form.parent_frame_context
})), /*#__PURE__*/React.createElement(Overlay, {
open: state.open,
mountNode: instuiPopupMountNodeFn(),
label: label,
onOpen: this.handleOpen,
onClose: this.handleRemove,
onCloseButton: this.handleClose,
name: (_state$button$title = state.button?.title) !== null && _state$button$title !== void 0 ? _state$button$title : ' '
}, /*#__PURE__*/React.createElement("div", {
ref: this.beforeInfoAlertRef,
tabIndex: 0 // eslint-disable-line jsx-a11y/no-noninteractive-tabindex
,
onFocus: this.handleInfoAlertFocus,
onBlur: this.handleInfoAlertBlur,
className: this.beforeInfoAlertRef.current != null && state.infoAlert === this.beforeInfoAlertRef.current ? '' : 'screenreader-only'
}, /*#__PURE__*/React.createElement(Alert, {
margin: "small"
}, formatMessage('The following content is partner provided'))), !state.iframeLoaded && /*#__PURE__*/React.createElement(Flex, {
alignItems: "center",
justifyItems: "center"
}, /*#__PURE__*/React.createElement(Flex.Item, null, /*#__PURE__*/React.createElement(Spinner, {
renderTitle: formatMessage('Loading External Tool'),
size: "large",
margin: "0 0 0 medium"
}))), /*#__PURE__*/React.createElement(ToolLaunchIframe, {
title: label,
ref: this.iframeRef,
name: "external_tool_launch",
src: "",
id: "external_tool_button_frame",
style: {
height: this.calcIFrameHeight(),
width: state.button?.use_tray ? '100%' : (_state$button$width = state.button?.width) !== null && _state$button$width !== void 0 ? _state$button$width : 800,
border: '0',
display: 'block',
visibility: state.iframeLoaded ? 'visible' : 'hidden'
},
allow: props.iframeAllowances,
onLoad: () => this.setState({
iframeLoaded: true
})
}), /*#__PURE__*/React.createElement("div", {
ref: this.afterInfoAlertRef,
tabIndex: 0 // eslint-disable-line jsx-a11y/no-noninteractive-tabindex
,
onFocus: this.handleInfoAlertFocus,
onBlur: this.handleInfoAlertBlur,
style: this.afterInfoAlertRef.current != null && state.infoAlert === this.afterInfoAlertRef.current ? {} : {
bottom: '0'
},
className: this.afterInfoAlertRef.current != null && state.infoAlert === this.afterInfoAlertRef.current ? '' : 'screenreader-only'
}, /*#__PURE__*/React.createElement(Alert, {
margin: "small"
}, formatMessage('The preceding content is partner provided')))));
}
}
ExternalToolDialog.propTypes = {
iframeAllowances: _pt.string.isRequired,
resourceSelectionUrlOverride: _pt.oneOfType([_pt.string, _pt.oneOf([null])])
};
ExternalToolDialog.defaultProps = {
resourceSelectionUrlOverride: undefined
};
const EMPTY_FORM = {
url: '',
selection: '',
contents: '',
parent_frame_context: null
};