@instructure/canvas-rce
Version:
A component wrapping Canvas's usage of Tinymce
243 lines (239 loc) • 8.23 kB
JavaScript
/*
* 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, { useReducer, useEffect, useRef, Suspense } from 'react';
import _ from 'lodash';
import PropTypes from 'prop-types';
import formatMessage from '../../../../../../format-message';
import reducer, { actions, initialState, modes } from '../../../reducers/imageSection';
import { actions as svgActions } from '../../../reducers/svgSettings';
import { Flex } from '@instructure/ui-flex';
import { Group } from '../Group';
import { Spinner } from '@instructure/ui-spinner';
import { Text } from '@instructure/ui-text';
import Course from './Course';
import { ImageOptions } from './ImageOptions';
import { ColorInput } from '../../../../shared/ColorInput';
import { convertFileToBase64 } from '../../../../shared/fileUtils';
import { transformForShape } from '../../../svg/image';
import SingleColorSVG from './SingleColor/svg';
import { createCroppedImageSvg } from '../../../../shared/ImageCropper/imageCropUtils';
const IMAGE_SECTION_ID = 'icon-maker-tray-image-section';
const getImageSection = () => document.querySelector(`#${IMAGE_SECTION_ID}`);
const scrollToBottom = () => {
const section = getImageSection();
if (section?.scrollIntoView) {
section.scrollIntoView({
behavior: 'smooth'
});
}
};
const filterSectionStateMetadata = state => {
const {
mode,
image,
imageName,
icon,
iconFillColor,
cropperSettings
} = state;
return {
mode,
image,
imageName,
icon,
iconFillColor,
cropperSettings
};
};
export const ImageSection = ({
settings,
onChange,
editor,
canvasOrigin
}) => {
const [state, dispatch] = useReducer(reducer, initialState);
const Upload = /*#__PURE__*/React.lazy(() => import('./Upload'));
const SingleColor = /*#__PURE__*/React.lazy(() => import('./SingleColor'));
const MultiColor = /*#__PURE__*/React.lazy(() => import('./MultiColor'));
// This object maps image selection modes to the
// component that handles that selection.
//
// The selected component is dynamically rendered
// in this component's returned JSX
const allowedModes = {
[modes.courseImages.type]: Course,
[modes.uploadImages.type]: Upload,
[modes.singleColorImages.type]: SingleColor,
[modes.multiColorImages.type]: MultiColor
};
const metadata = filterSectionStateMetadata(state);
const isMetadataLoaded = useRef(false);
useEffect(() => {
const transform = transformForShape(settings.shape, settings.size);
// Set Q1 crop defaults
// TODO: Set these properties based on cropper
onChange({
type: svgActions.SET_X,
payload: transform.x
});
onChange({
type: svgActions.SET_Y,
payload: transform.y
});
onChange({
type: svgActions.SET_WIDTH,
payload: transform.width
});
onChange({
type: svgActions.SET_HEIGHT,
payload: transform.height
});
onChange({
type: svgActions.SET_TRANSLATE_X,
payload: transform.translateX
});
onChange({
type: svgActions.SET_TRANSLATE_Y,
payload: transform.translateY
});
}, [onChange, settings.shape, settings.size]);
useEffect(() => {
if (state.icon && state.icon in SingleColorSVG) {
dispatch({
...actions.START_LOADING
});
convertFileToBase64(new Blob([SingleColorSVG[state.icon].source(state.iconFillColor)], {
type: 'image/svg+xml'
})).then(base64Image => {
dispatch({
...actions.SET_IMAGE,
payload: base64Image
});
dispatch({
...actions.STOP_LOADING
});
onChange({
type: svgActions.SET_EMBED_IMAGE,
payload: base64Image
});
});
}
}, [onChange, state.icon, state.iconFillColor]);
// After a new shape is selected in shape section a new embedded image should be generated
useEffect(() => {
if (state.cropperSettings && settings.shape !== state.cropperSettings.shape) {
const newCropperSettings = {
...state.cropperSettings,
shape: settings.shape
};
dispatch({
type: actions.SET_CROPPER_SETTINGS.type,
payload: newCropperSettings
});
createCroppedImageSvg(newCropperSettings, settings.imageSettings.image).then(generatedSvg => convertFileToBase64(new Blob([generatedSvg.outerHTML], {
type: 'image/svg+xml'
}))).then(base64Image => {
onChange({
type: svgActions.SET_EMBED_IMAGE,
payload: base64Image
});
}).catch(error => console.error(error));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.shape]);
useEffect(() => {
if (settings.imageSettings && !isMetadataLoaded.current && !_.isEqual(settings.imageSettings, metadata)) {
isMetadataLoaded.current = true;
dispatch({
type: actions.UPDATE_SETTINGS.type,
payload: settings.imageSettings
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.imageSettings]);
useEffect(() => {
if (!_.isEqual(metadata, settings.imageSettings)) {
onChange({
type: svgActions.SET_IMAGE_SETTINGS,
payload: metadata
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, Object.values(metadata));
const modeIsAllowed = !!allowedModes[state.mode];
const ImageSelector = allowedModes[state.mode];
return /*#__PURE__*/React.createElement(Group, {
as: "section",
defaultExpanded: true,
summary: formatMessage('Image')
}, /*#__PURE__*/React.createElement(Flex, {
as: "section",
justifyItems: "space-between",
direction: "column",
id: IMAGE_SECTION_ID
}, /*#__PURE__*/React.createElement(Flex.Item, null, /*#__PURE__*/React.createElement(Flex, {
direction: "column"
}, /*#__PURE__*/React.createElement(Flex.Item, {
padding: "small 0 0 small"
}, /*#__PURE__*/React.createElement(Text, {
weight: "bold"
}, formatMessage('Current Image'))), /*#__PURE__*/React.createElement(Flex.Item, null, /*#__PURE__*/React.createElement(ImageOptions, {
state: state,
settings: settings,
dispatch: dispatch,
mountNode: getImageSection,
trayDispatch: onChange
})))), /*#__PURE__*/React.createElement(Suspense, {
fallback: /*#__PURE__*/React.createElement(Flex, {
justifyItems: "center"
}, /*#__PURE__*/React.createElement(Flex.Item, null, /*#__PURE__*/React.createElement(Spinner, {
renderTitle: formatMessage('Loading')
})))
}, modeIsAllowed && state.collectionOpen && /*#__PURE__*/React.createElement(Flex.Item, {
padding: "small"
}, /*#__PURE__*/React.createElement(ImageSelector, {
dispatch: dispatch,
editor: editor,
data: state,
onChange: onChange,
onLoading: scrollToBottom,
onLoaded: scrollToBottom,
canvasOrigin: canvasOrigin
}))), state.icon && state.mode === modes.singleColorImages.type && /*#__PURE__*/React.createElement(Flex.Item, {
padding: "small"
}, /*#__PURE__*/React.createElement(ColorInput, {
color: state.iconFillColor,
label: formatMessage('Single Color Image Color'),
name: "single-color-image-fill",
onChange: color => dispatch({
type: actions.SET_ICON_FILL_COLOR.type,
payload: color
}),
popoverMountNode: getImageSection,
requireColor: true
}))));
};
ImageSection.propTypes = {
settings: PropTypes.object.isRequired,
editor: PropTypes.object.isRequired,
onChange: PropTypes.func,
canvasOrigin: PropTypes.string
};
ImageSection.defaultProps = {
onChange: () => {}
};