UNPKG

@instructure/canvas-rce

Version:

A component wrapping Canvas's usage of Tinymce

306 lines (301 loc) 11.5 kB
/* * Copyright (C) 2021 - 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, useState, useEffect, useRef, useMemo } from 'react'; import PropTypes from 'prop-types'; import { CloseButton } from '@instructure/ui-buttons'; import { Heading } from '@instructure/ui-heading'; import { Flex } from '@instructure/ui-flex'; import { View } from '@instructure/ui-view'; import { Alert } from '@instructure/ui-alerts'; import { Spinner } from '@instructure/ui-spinner'; import { Preview } from './CreateIconMakerForm/Preview'; import { CreateIconMakerForm } from './CreateIconMakerForm'; import { Footer } from './CreateIconMakerForm/Footer'; import { buildStylesheet, buildSvg } from '../svg'; import { statuses, useSvgSettings } from '../svg/settings'; import { defaultState, actions } from '../reducers/svgSettings'; import { FixedContentTray } from '../../shared/FixedContentTray'; import { useStoreProps } from '../../shared/StoreContext'; import formatMessage from '../../../../format-message'; import addIconMakerAttributes from '../utils/addIconMakerAttributes'; import { validIcon } from '../utils/iconValidation'; import { IconMakerFormHasChanges } from '../utils/IconMakerFormHasChanges'; import bridge from '../../../../bridge'; import { shouldIgnoreClose } from '../utils/IconMakerClose'; import { instuiPopupMountNodeFn } from '../../../../util/fullscreenHelpers'; const INVALID_MESSAGE = formatMessage('One of the following styles must be added to save an icon: Icon Color, Outline Size, Icon Text, or Image'); const UNSAVED_CHANGES_MESSAGE = formatMessage('You have unsaved changes in the Icon Maker tray. Do you want to continue without saving these changes?'); function renderHeader(title, settings, onKeyDown, onAlertDismissal, onClose) { return /*#__PURE__*/React.createElement(View, { as: "div", background: "primary" }, settings.error && /*#__PURE__*/React.createElement(Alert, { variant: "error", margin: "small", timeout: 10000, onDismiss: onAlertDismissal, renderCloseButtonLabel: "Close" }, settings.error), /*#__PURE__*/React.createElement(Flex, { direction: "column" }, /*#__PURE__*/React.createElement(Flex.Item, { padding: "medium medium small" }, /*#__PURE__*/React.createElement(Flex, { direction: "row" }, /*#__PURE__*/React.createElement(Flex.Item, { shouldGrow: true, shouldShrink: true }, /*#__PURE__*/React.createElement(Heading, { as: "h2" }, title)), /*#__PURE__*/React.createElement(Flex.Item, null, /*#__PURE__*/React.createElement(CloseButton, { placement: "static", color: "primary", onClick: onClose, onKeyDown: onKeyDown, "data-testid": "icon-maker-close-button", screenReaderLabel: formatMessage('Close') })))), /*#__PURE__*/React.createElement(Flex.Item, { as: "div", padding: "small" }, /*#__PURE__*/React.createElement(Preview, { settings: settings })))); } function renderBody(settings, dispatch, editor, editing, allowNameChange, nameRef, canvasOrigin, isLoading) { return isLoading() ? /*#__PURE__*/React.createElement(Flex, { justifyItems: "center" }, /*#__PURE__*/React.createElement(Spinner, { renderTitle: formatMessage('Loading...'), size: "large" })) : /*#__PURE__*/React.createElement(CreateIconMakerForm, { settings: settings, dispatch: dispatch, editor: editor, editing: editing, allowNameChange: allowNameChange, nameRef: nameRef, canvasOrigin: canvasOrigin }); } function renderFooter(status, onClose, handleSubmit, editing, replaceAll, setReplaceAll, applyRef, isModified) { return /*#__PURE__*/React.createElement(View, { as: "div", background: "primary" }, /*#__PURE__*/React.createElement(Footer, { disabled: status === statuses.LOADING, onCancel: onClose, onSubmit: () => handleSubmit({ replaceFile: replaceAll }), replaceAll: replaceAll, onReplaceAllChanged: setReplaceAll, editing: editing, applyRef: applyRef, isModified: isModified })); } export function IconMakerTray({ editor, onUnmount, editing, canvasOrigin }) { const nameRef = useRef(); const applyRef = useRef(); const [isOpen, setIsOpen] = useState(true); const [replaceAll, setReplaceAll] = useState(false); const title = editing ? formatMessage('Edit Icon') : formatMessage('Create Icon'); const [settings, settingsStatus, dispatch] = useSvgSettings(editor, editing, canvasOrigin); const [status, setStatus] = useState(statuses.IDLE); const [initialSettings, setInitialSettings] = useState({ ...defaultState }); const isModified = useRef(false); const [mountNode, setMountNode] = useState(instuiPopupMountNodeFn()); const handleFullscreenChange = useCallback(() => { setMountNode(instuiPopupMountNodeFn()); }, []); // These useRef objects are needed because when the tray is closed using the escape key // objects created by useState are not available, causing the comparison between // initialSettings and settings to behave unexpectedly const initialSettingsRef = useRef(initialSettings); const settingsRef = useRef(settings); const statusRef = useRef(status); settingsRef.current = useMemo(() => settings, [settings]); statusRef.current = useMemo(() => status, [status]); initialSettingsRef.current = useMemo(() => initialSettings, [initialSettings]); useEffect(() => { editor?.rceWrapper?._elementRef?.current?.addEventListener('fullscreenchange', handleFullscreenChange); return () => { editor?.rceWrapper?._elementRef?.current?.removeEventListener('fullscreenchange', handleFullscreenChange); }; }, [editor, handleFullscreenChange]); useEffect(() => { const formHasChanges = new IconMakerFormHasChanges(initialSettingsRef.current, settingsRef.current); isModified.current = formHasChanges.hasChanges(); }, [settings, initialSettings]); const storeProps = useStoreProps(); const onClose = event => { if (shouldIgnoreClose(event?.target, editor?.id)) return; if (statusRef?.current === statuses.LOADING) return; // Uploading an image creates a modal on the page. If that modal is open, we don't want to close the tray // eslint-disable-next-line no-extra-boolean-cast if (!!hasOpenModal()) return; // RCE already uses browser's confirm dialog for unsaved changes // Its use here in the Icon Maker tray keeps that consistency if (isModified.current && !confirm(UNSAVED_CHANGES_MESSAGE)) { return; } setIsOpen(false); }; const hasOpenModal = () => document.querySelector('[data-cid="Modal"]'); const isLoading = () => status === statuses.LOADING; const onKeyDown = event => { if (event.keyCode !== 9) return; event.preventDefault(); event.shiftKey ? applyRef.current?.focus() : nameRef.current?.focus(); }; useEffect(() => { setReplaceAll(false); }, [settings.name]); useEffect(() => { if (validIcon(settings)) { dispatch({ type: actions.SET_ERROR, payload: null }); setStatus(statuses.IDLE); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [settings.color, settings.textColor, settings.text, settings.textSize, settings.textBackgroundColor, settings.textPosition, settings.imageSettings, settings.outlineColor, settings.outlineSize, settings.name]); const handleSubmit = ({ replaceFile = false }) => { setStatus(statuses.LOADING); if (!validIcon(settings)) { dispatch({ type: actions.SET_ERROR, payload: INVALID_MESSAGE }); setStatus(statuses.ERROR); return; } const svg = buildSvg(settings, { isPreview: false }); svg.appendChild(buildStylesheet()); return storeProps.startIconMakerUpload({ name: `${settings.name || formatMessage('untitled')}.svg`, domElement: svg }, { onDuplicate: replaceFile && 'overwrite' }).then(writeIconToRCE).then(() => setIsOpen(false)).catch(err => { console.error(err); setStatus(statuses.ERROR); }); }; const writeIconToRCE = ({ url, display_name }) => { const { alt, isDecorative, externalStyle, externalWidth, externalHeight } = settings; const imageAttributes = { alt_text: alt, display_name, height: externalHeight, isDecorativeImage: isDecorative, src: url, // React wants this to be an object but we are just // passing along a string here. Using the style attribute // with all caps makes React ignore this fact STYLE: externalStyle, // DON'T CHANGE BEFORE READING COMMENT ABOVE width: externalWidth }; // Mark the image as an icon maker icon. addIconMakerAttributes(imageAttributes); bridge.embedImage(imageAttributes); }; const defaultImageSettings = () => { return { mode: '', image: '', imageName: '', icon: '', iconFillColor: '#000000', cropperSettings: null }; }; const replaceInitialSettings = () => { const name = editing ? settings.name : undefined; const textPosition = editing ? settings.textPosition : defaultState.textPosition; const imageSettings = settings.imageSettings || defaultImageSettings(); setInitialSettings({ name, alt: editing ? settings.alt : defaultState.alt, shape: editing ? settings.shape : defaultState.shape, size: settings.size, color: settings.color, outlineSize: settings.outlineSize, outlineColor: settings.outlineColor, text: settings.text, textSize: settings.textSize, textColor: settings.textColor, textBackgroundColor: settings.textBackgroundColor, textPosition, imageSettings }); }; useEffect(() => { setStatus(settingsStatus); replaceInitialSettings(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [settingsStatus]); const handleAlertDismissal = () => dispatch({ type: actions.SET_ERROR, payload: null }); return /*#__PURE__*/React.createElement(FixedContentTray, { title: title, isOpen: isOpen, onDismiss: onClose, onUnmount: onUnmount, mountNode: mountNode, shouldCloseOnDocumentClick: false, renderHeader: () => renderHeader(title, settings, onKeyDown, handleAlertDismissal, onClose), renderBody: () => renderBody(settings, dispatch, editor, editing, !replaceAll, nameRef, canvasOrigin, isLoading), renderFooter: () => renderFooter(status, onClose, handleSubmit, editing, replaceAll, setReplaceAll, applyRef, isModified.current), bodyAs: "form", shouldJoinBodyAndFooter: true }); } IconMakerTray.propTypes = { editor: PropTypes.object.isRequired, onUnmount: PropTypes.func, editing: PropTypes.bool, canvasOrigin: PropTypes.string.isRequired }; IconMakerTray.defaultProps = { onUnmount: () => {}, editing: false };