UNPKG

@instructure/canvas-rce

Version:

A component wrapping Canvas's usage of Tinymce

401 lines (394 loc) 14.6 kB
/* * 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 React, { useCallback, useEffect, useRef, useState } from 'react'; import { bool, element, func, instanceOf, oneOfType, shape, string } from 'prop-types'; import { Tray } from '@instructure/ui-tray'; import { CloseButton, Button } from '@instructure/ui-buttons'; import { Heading } from '@instructure/ui-heading'; import { Flex } from '@instructure/ui-flex'; import { View } from '@instructure/ui-view'; import ErrorBoundary from './ErrorBoundary'; import Bridge from '../../../bridge/Bridge'; import formatMessage from '../../../format-message'; import Filter from './Filter'; import { useFilterSettings } from './useFilterSettings'; import { getTrayHeight } from './trayUtils'; import { ICON_MAKER_ICONS } from '../instructure_icon_maker/svg/constants'; import { getLinkContentFromEditor } from './ContentSelection'; import { LinkDisplay } from './LinkDisplay'; import { showFlashAlert } from '../../../common/FlashAlert'; import { FILTER_SETTINGS_BY_PLUGIN, DynamicPanel, isLoading } from './canvasContentUtils'; /** * Returns the translated tray label * @param {string} contentType - The type of content showing on tray * @param {string} contentSubtype - The current subtype of content loaded in the tray * @param {string} contextType - The user's context * @returns {string} */ function getTrayLabel(contentType, contentSubtype, contextType) { if (contentType === 'links' && contextType === 'course') { return formatMessage('Course Links'); } else if (contentType === 'links' && contextType === 'group') { return formatMessage('Group Links'); } switch (contentSubtype) { case ICON_MAKER_ICONS: return formatMessage('Icon Maker Icons'); case 'images': if (contentType === 'course_files') return formatMessage('Course Images'); if (contentType === 'group_files') return formatMessage('Group Images'); return formatMessage('User Images'); case 'media': if (contentType === 'course_files') return formatMessage('Course Media'); if (contentType === 'group_files') return formatMessage('Group Media'); return formatMessage('User Media'); case 'documents': if (contentType === 'course_files') return formatMessage('Course Documents'); if (contentType === 'group_files') return formatMessage('Group Documents'); return formatMessage('User Documents'); default: return formatMessage('Tray'); // Shouldn't ever get here } } /** * This component is used within various plugins to handle loading in content * from Canvas. It is essentially the main component. */ export default function CanvasContentTray(props) { // should the tray be rendered open? const [isOpen, setIsOpen] = useState(false); // has the tray fully opened. we use this to defer rendering the content // until the tray is open. const [hasOpened, setHasOpened] = useState(false); // should we close the tray after the user clicks on something in it? const [hidingTrayOnAction, setHidingTrayOnAction] = useState(true); const trayRef = useRef(null); const scrollingAreaRef = useRef(null); const [closeButtonRef, setCloseButtonRef] = useState(null); const [filterSettings, setFilterSettings] = useFilterSettings(); const [isEditTray, setIsEditTray] = useState(false); const [link, setLink] = useState(null); const [linkText, setLinkText] = useState(null); const [placeholderText, setPlaceholderText] = useState(null); const { bridge, editor, mountNode, onTrayClosing, storeProps } = { ...props }; const handleDismissTray = useCallback(() => { // return focus to the RCE if focus was on this tray if (trayRef.current && trayRef.current.contains(document.activeElement)) { bridge.focusActiveEditor(false); } onTrayClosing && onTrayClosing(CanvasContentTray.globalOpenCount); // tell RCEWrapper we're closing if we're open setIsOpen(false); }, [bridge, onTrayClosing]); // this shouldn't be necessary, but INSTUI isn't focusing the close button // like it should. useEffect(() => { if (isOpen && closeButtonRef) { closeButtonRef.focus(); } }, [closeButtonRef, isOpen]); useEffect(() => { const controller = { showTrayForPlugin(plugin) { // increment a counter that's used as the key when rendering // this gets us a new instance everytime, which is necessary // to get the queries run so we have up to date data. ++CanvasContentTray.globalOpenCount; setFilterSettings(FILTER_SETTINGS_BY_PLUGIN[plugin]); setIsOpen(true); if (plugin === 'course_link_edit') { setIsEditTray(true); const { fileName, contentType, url, published, text } = getLinkContentFromEditor(editor.editor); setLink({ title: fileName, type: contentType, href: url, published }); setLinkText(text); setPlaceholderText(fileName); } else { setIsEditTray(false); } }, hideTray(forceClose) { if (forceClose || hidingTrayOnAction) { handleDismissTray(); } } }; bridge.attachController(controller, editor.id); return () => { bridge.detachController(editor.id); }; // it's OK the setFilterSettings is not a dependency // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor.id, bridge, handleDismissTray, hidingTrayOnAction]); useEffect(() => { if (hasOpened && scrollingAreaRef.current && !scrollingAreaRef.current.style.overscrollBehaviorY) { scrollingAreaRef.current.style.overscrollBehaviorY = 'contain'; } }, [hasOpened]); useEffect(() => { if (!hasOpened) return; let c = document.querySelector('[role="main"]'); let target_w = 0; if (!c) return; const margin = window.getComputedStyle(c).direction === 'ltr' ? document.body.getBoundingClientRect().right - c.getBoundingClientRect().right : c.getBoundingClientRect().left; target_w = c.offsetWidth - trayRef.current?.offsetWidth + margin; if (target_w >= 320 && target_w < c.offsetWidth) { c.style.boxSizing = 'border-box'; c.style.width = `${target_w}px`; } setHidingTrayOnAction(target_w < 320); return () => { c = document.querySelector('[role="main"]'); if (!c) return; c.style.width = ''; }; }, [hasOpened]); function handleOpenTray() { bridge.focusEditor(editor); setHasOpened(true); } function handleExitTray() { onTrayClosing && onTrayClosing(true); // tell RCEWrapper we're closing } function handleCloseTray() { // clear the store's saved search string so the tray doesn't // reopen with a previous tray instance's search results storeProps.onChangeSearchString(''); setHasOpened(false); onTrayClosing && onTrayClosing(false); // tell RCEWrapper we're closed } function handleReplaceButton() { handleDismissTray(); const newLink = { ...link, forceRename: true, text: linkText }; bridge.insertLink(newLink); showFlashAlert({ message: formatMessage('Updated link'), type: 'success', srOnly: true }); } function renderFooter() { return /*#__PURE__*/React.createElement(Flex.Item, { background: "secondary", borderWidth: "small none none none", padding: "small medium", textAlign: "end" }, /*#__PURE__*/React.createElement(Button, { "data-testid": "cancel-replace-button", onClick: handleDismissTray }, formatMessage('Cancel')), /*#__PURE__*/React.createElement(Button, { margin: "0 0 0 x-small", color: "primary", onClick: handleReplaceButton, "data-testid": "replace-link-button" }, formatMessage('Replace'))); } function handleFilterChange(newFilter, onChangeContext, onChangeSearchString, onChangeSortBy) { const newFilterSettings = { ...newFilter }; if (newFilterSettings.sortValue) { newFilterSettings.sortDir = newFilterSettings.sortValue === 'alphabetical' ? 'asc' : 'desc'; onChangeSortBy({ sort: newFilterSettings.sortValue, dir: newFilterSettings.sortDir }); } if ('searchString' in newFilterSettings && filterSettings.searchString !== newFilterSettings.searchString) { onChangeSearchString(newFilterSettings.searchString); } setFilterSettings(newFilterSettings); if (newFilterSettings.contentType) { let contextType, contextId; switch (newFilterSettings.contentType) { case 'user_files': contextType = 'user'; contextId = props.containingContext.userId; break; case 'group_files': contextType = 'group'; contextId = props.containingContext.contextId; break; case 'course_files': contextType = props.contextType; contextId = props.containingContext.contextId; break; case 'links': contextType = props.containingContext.contextType; contextId = props.containingContext.contextId; } onChangeContext({ contextType, contextId }); } } function getHeader() { return isEditTray ? formatMessage('Edit Course Link') : formatMessage('Add'); } function renderLinkDisplay() { return isEditTray && /*#__PURE__*/React.createElement(LinkDisplay, { linkText: linkText, placeholderText: link?.title || placeholderText, linkFileName: link?.title || '', published: link?.published || false, handleTextChange: setLinkText, linkType: link?.type }); } return /*#__PURE__*/React.createElement(Tray, { "data-mce-component": true, "data-testid": "CanvasContentTray", label: getTrayLabel(filterSettings.contentType, filterSettings.contentSubtype, props.contextType), mountNode: mountNode, open: isOpen, placement: "end", size: "regular", shouldContainFocus: true, shouldReturnFocus: false, shouldCloseOnDocumentClick: false, onDismiss: handleDismissTray, onClose: handleCloseTray, onExit: handleExitTray, onOpen: handleOpenTray, contentRef: el => trayRef.current = el }, /*#__PURE__*/React.createElement(Flex, { direction: "column", as: "div", height: getTrayHeight(), overflowY: "hidden", tabIndex: -1, "data-canvascontenttray-content": true }, /*#__PURE__*/React.createElement(Flex.Item, { padding: "medium", shadow: "above" }, /*#__PURE__*/React.createElement(View, { as: "div", margin: "none none medium none" }, /*#__PURE__*/React.createElement(Heading, { level: "h2" }, getHeader()), /*#__PURE__*/React.createElement(CloseButton, { placement: "end", offset: "medium", onClick: handleDismissTray, "data-testid": "CloseButton_ContentTray", screenReaderLabel: formatMessage('Close'), elementRef: el => setCloseButtonRef(el) })), renderLinkDisplay(), /*#__PURE__*/React.createElement(Filter, Object.assign({}, filterSettings, { mountNode: props.mountNode, userContextType: props.contextType, containingContextType: props.containingContext.contextType, onChange: newFilter => { handleFilterChange(newFilter, storeProps.onChangeContext, storeProps.onChangeSearchString, storeProps.onChangeSortBy); }, isContentLoading: isLoading(storeProps), use_rce_icon_maker: props.use_rce_icon_maker }))), isOpen && hasOpened ? /*#__PURE__*/React.createElement(Flex.Item, { shouldGrow: true, shouldShrink: true, margin: "xx-small xxx-small 0", elementRef: el => scrollingAreaRef.current = el }, /*#__PURE__*/React.createElement(Flex, { justifyItems: "space-between", direction: "column", height: "100%" }, /*#__PURE__*/React.createElement(Flex.Item, { shouldGrow: true, shouldShrink: true }, /*#__PURE__*/React.createElement(ErrorBoundary, null, /*#__PURE__*/React.createElement(DynamicPanel, Object.assign({ contentType: filterSettings.contentType, contentSubtype: filterSettings.contentSubtype, sortBy: { sort: filterSettings.sortValue, order: filterSettings.sortDir }, searchString: filterSettings.searchString, canvasOrigin: props.canvasOrigin, context: { type: props.contextType, id: props.contextId }, editing: isEditTray, onEditClick: setLink, selectedLink: link }, storeProps)))), isEditTray && renderFooter())) : null)); } CanvasContentTray.globalOpenCount = 0; // Changes made here may need to be reflected in the trayProps type in CanvasContentPanel const trayPropsMap = { canUploadFiles: bool.isRequired, contextId: string.isRequired, // initial value indicating the user's context (e.g. student v teacher), not the tray's contextType: string.isRequired, // initial value indicating the user's context, not the tray's containingContext: shape({ contextType: string.isRequired, contextId: string.isRequired, userId: string.isRequired }), filesTabDisabled: bool, host: string, jwt: string, refreshToken: func, source: shape({ fetchImages: func.isRequired }), themeUrl: string }; export const trayPropTypes = shape(trayPropsMap); CanvasContentTray.propTypes = { bridge: instanceOf(Bridge).isRequired, editor: shape({ id: string }).isRequired, mountNode: oneOfType([element, func]), onTrayClosing: func, // called with true when the tray starts closing, false once closed onEditClick: func, ...trayPropsMap }; // the way we define trayProps, eslint doesn't recognize the following as props CanvasContentTray.defaultProps = { canUploadFiles: false, filesTabDisabled: false, refreshToken: null, source: null, themeUrl: null };