UNPKG

@instructure/canvas-rce

Version:

A component wrapping Canvas's usage of Tinymce

314 lines (312 loc) 10.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/>. */ // NOTE: if you're looking in here for the ComputerPanel that's used for // the RCE's Media > Upload/Record Media function, it's not this one // (though this panel can handle video with the right "accept" prop). // See @instructure/canvas-media/src/ComputerPanel.js import React, { useCallback, useEffect, useRef, useState } from 'react'; import { arrayOf, func, number, object, oneOfType, shape, string } from 'prop-types'; import { StyleSheet, css } from 'aphrodite'; import { FileDrop } from '@instructure/ui-file-drop'; import { Billboard } from '@instructure/ui-billboard'; import { Alert } from '@instructure/ui-alerts'; import { IconButton } from '@instructure/ui-buttons'; import { px } from '@instructure/ui-utils'; import { PresentationContent } from '@instructure/ui-a11y-content'; import { IconTrashLine } from '@instructure/ui-icons'; import { Img } from '@instructure/ui-img'; import { Text } from '@instructure/ui-text'; import { TruncateText } from '@instructure/ui-truncate-text'; import { Flex } from '@instructure/ui-flex'; import { View } from '@instructure/ui-view'; import { MediaPlayer } from '@instructure/ui-media-player'; import { RocketSVG, useComputerPanelFocus, isAudio, isPreviewable, sizeMediaPlayer } from '@instructure/canvas-media'; import formatMessage from '../../../../format-message'; import { getIconFromType, isAudioOrVideo, isImage, isText, isIWork, getIWorkType } from '../fileTypeUtils'; function isPreviewableAudioOrVideo(type) { return isPreviewable(type) && isAudioOrVideo(type); } function readFile(theFile) { const p = new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { let result = reader.result; if (isText(theFile.type) && result.length > 1000) { result = `${result.substr(0, 1000)}...`; } resolve(result); }; reader.onerror = () => { reject(new Error(formatMessage('An error occured reading the file'))); }; if (theFile.size === 0) { // canvas will reject uploading an empty file reject(new Error(formatMessage('You may not upload an empty file.'))); } if (isImage(theFile.type)) { reader.readAsDataURL(theFile); } else if (isText(theFile.type)) { reader.readAsText(theFile); } else if (isPreviewableAudioOrVideo(theFile.type)) { const sources = [{ label: theFile.name, src: URL.createObjectURL(theFile), type: theFile.type }]; resolve(sources); } else { let type = theFile.type; // Native JS File API returns empty string if it can't determine the type if (type === '' && isIWork(theFile.name)) { type = getIWorkType(theFile.name); } const icon = getIconFromType(type); resolve(icon); } }); return p; } export default function ComputerPanel({ theFile, setFile, setError, accept, label, bounds }) { const [messages, setMessages] = useState([]); const [preview, setPreview] = useState({ preview: null, isLoading: false }); // the trashcan is 38px tall and the 1.5rem margin-bottom // the 350 is to guarantee the video doesn't oveflow into the copyright UI, // which should probably be rendered here and not up in the modal because // dealing with Tabs and size is nearly impossible const height = Math.min(350, 0.8 * (bounds.height - 38 - px('1.5rem'))); const width = 0.8 * bounds.width; useEffect(() => { return () => { if (Array.isArray(preview?.preview)) { URL?.revokeObjectURL?.(preview.preview[0].src); } }; }, [preview]); useEffect(() => { if (!theFile || preview.isLoading || preview.preview || preview.error) return; async function getPreview() { setPreview({ preview: null, isLoading: true }); try { const previewer = await readFile(theFile); setPreview({ preview: previewer, isLoading: false }); setError(null); if (isImage(theFile.type)) { // we need the preview to know the image size to show the placeholder theFile.preview = previewer; setFile(theFile); } } catch (ex) { setError(ex); setPreview({ preview: null, error: ex.message, isLoading: false }); } } getPreview(); }); const handleLoadedMetadata = useCallback(event => { const player = event.target; const sz = sizeMediaPlayer(player, theFile.type, { width, height }); player.style.width = sz.width; player.style.height = sz.height; player.style.margin = '0 auto'; // from this sub-package, I don't have a URL to use as the // audio player's poster image. We can give it a background image though player.classList.add(isAudio(theFile.type) ? 'audio-player' : 'video-player'); }, [theFile, width, height]); const previewPanelRef = useRef(null); const clearButtonRef = useRef(null); const panelRef = useRef(null); useComputerPanelFocus(theFile, panelRef, clearButtonRef); function renderPreview() { if (preview.isLoading) { return /*#__PURE__*/React.createElement("div", { "aria-live": "polite" }, /*#__PURE__*/React.createElement(Text, { color: "secondary" }, formatMessage('Generating preview...'))); } else if (preview.error) { return /*#__PURE__*/React.createElement("div", { className: css(styles.previewContainer), "aria-live": "polite" }, /*#__PURE__*/React.createElement(Alert, { variant: "error" }, preview.error)); } else if (preview.preview) { if (isImage(theFile.type)) { return /*#__PURE__*/React.createElement(Img, { "aria-label": formatMessage('{filename} image preview', { filename: theFile.name }), src: preview.preview, constrain: "contain", display: "block" }); } else if (isText(theFile.type)) { return /*#__PURE__*/React.createElement(View, { as: "pre", display: "block", padding: "x-small", textAlign: "start", "aria-label": formatMessage('{filename} text preview', { filename: theFile.name }) }, /*#__PURE__*/React.createElement(TruncateText, { maxLines: 21 }, preview.preview)); } else if (isPreviewableAudioOrVideo(theFile.type)) { return /*#__PURE__*/React.createElement(MediaPlayer, { sources: preview.preview, onLoadedMetadata: handleLoadedMetadata }); } else { return /*#__PURE__*/React.createElement("div", { "aria-label": formatMessage('{filename} file icon', { filename: theFile.name }), className: css(styles.previewContainer), style: { textAlign: 'center' } }, /*#__PURE__*/React.createElement(preview.preview, { size: "medium" }), /*#__PURE__*/React.createElement(Text, { as: "p", weight: "normal" }, formatMessage('No preview is available for this file.'))); } } } if (theFile) { const filename = theFile.name; return /*#__PURE__*/React.createElement("div", { style: { position: 'relative' }, ref: previewPanelRef }, /*#__PURE__*/React.createElement(Flex, { direction: "row-reverse", margin: "none none medium" }, /*#__PURE__*/React.createElement(Flex.Item, null, /*#__PURE__*/React.createElement(IconButton, { elementRef: el => { clearButtonRef.current = el; }, onClick: () => { setFile(null); setPreview({ preview: null, isLoading: false, error: null }); }, renderIcon: IconTrashLine, screenReaderLabel: formatMessage('Remove {filename}', { filename }) })), /*#__PURE__*/React.createElement(Flex.Item, { shouldGrow: true, shouldShrink: true }, /*#__PURE__*/React.createElement(PresentationContent, null, /*#__PURE__*/React.createElement(Text, null, filename)))), /*#__PURE__*/React.createElement(View, { as: "div", width: `${width}px`, height: `${height}px`, textAlign: "center", margin: "0 auto" }, renderPreview())); } return /*#__PURE__*/React.createElement("div", { ref: panelRef }, /*#__PURE__*/React.createElement(FileDrop, { "data-testid": "filedrop", accept: accept, onDropAccepted: ([file]) => { if (messages.length) { setMessages([]); } setFile(file); }, onDropRejected: () => { setMessages(messages.concat({ text: formatMessage('Invalid file type'), type: 'error' })); }, messages: messages, renderLabel: /*#__PURE__*/React.createElement(Billboard, { heading: label, hero: /*#__PURE__*/React.createElement(RocketSVG, { width: "3em", height: "3em" }), message: formatMessage('Drag and drop, or click to browse your computer') }) })); } ComputerPanel.propTypes = { // instanceof File or the File object from DataTransfer which seems to be different theFile: object, setFile: func.isRequired, setError: func.isRequired, accept: oneOfType([string, arrayOf(string)]), label: string.isRequired, bounds: shape({ width: number, height: number }) }; ComputerPanel.defaultProps = { bounds: {} }; export const styles = StyleSheet.create({ previewContainer: { maxHeight: '250px', overflow: 'hidden', boxSizing: 'border-box', margin: '5rem .375rem 0', position: 'relative' }, previewArea: { width: '100%', height: '100%', maxHeight: '250px', boxSizing: 'border-box', objectFit: 'contain', overflow: 'hidden' } });