UNPKG

@instructure/canvas-rce

Version:

A component wrapping Canvas's usage of Tinymce

403 lines (397 loc) 16.1 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, { useEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { arrayOf, bool, func, number, oneOf, string } from 'prop-types'; import { StyleSheet, css } from 'aphrodite'; import keycode from 'keycode'; import { Button, IconButton, CondensedButton } from '@instructure/ui-buttons'; import { Flex } from '@instructure/ui-flex'; import { View } from '@instructure/ui-view'; import { Badge } from '@instructure/ui-badge'; import { InstUISettingsProvider } from '@instructure/emotion'; import { Text } from '@instructure/ui-text'; import { SVGIcon } from '@instructure/ui-svg-images'; import { IconA11yLine, IconKeyboardShortcutsLine, IconMiniArrowEndLine, IconFullScreenLine, IconExitFullScreenLine } from '@instructure/ui-icons'; import formatMessage from '../format-message'; import ResizeHandle from './ResizeHandle'; import { FS_ENABLED } from '../util/fullscreenHelpers'; import { AIWandSVG } from './plugins/shared/ai_tools'; export const WYSIWYG_VIEW = 'WYSIWYG'; export const PRETTY_HTML_EDITOR_VIEW = 'PRETTY'; export const RAW_HTML_EDITOR_VIEW = 'RAW'; // I don't know why eslint is reporting this, the props are all used StatusBar.propTypes = { id: string.isRequired, rceIsFullscreen: bool, onChangeView: func.isRequired, path: arrayOf(string), wordCount: number, editorView: oneOf([WYSIWYG_VIEW, PRETTY_HTML_EDITOR_VIEW, RAW_HTML_EDITOR_VIEW]), onResize: func, // react-draggable onDrag handler. onKBShortcutModalOpen: func.isRequired, onA11yChecker: func.isRequired, onFullscreen: func.isRequired, preferredHtmlEditor: oneOf([PRETTY_HTML_EDITOR_VIEW, RAW_HTML_EDITOR_VIEW]), readOnly: bool, a11yBadgeColor: string, a11yErrorsCount: number, onWordcountModalOpen: func.isRequired, disabledPlugins: arrayOf(string), features: arrayOf(string), // StatusBarFeature[] onAI: func }; StatusBar.defaultProps = { a11yBadgeColor: '#0374B5', a11yErrorsCount: 0, disabledPlugins: [] }; // we use the array index because pathname may not be unique function renderPathString({ path }) { return path.reduce((result, pathName, index) => { return result.concat(/*#__PURE__*/React.createElement("span", { key: `${pathName}-${index}` }, /*#__PURE__*/React.createElement(Text, null, index > 0 ? /*#__PURE__*/React.createElement(IconMiniArrowEndLine, null) : null, pathName))); }, []); } function emptyTagIcon() { return /*#__PURE__*/React.createElement(SVGIcon, { viewBox: "0 0 24 24", fontSize: "24px" }, /*#__PURE__*/React.createElement("g", { role: "presentation" }, /*#__PURE__*/React.createElement("text", { textAnchor: "middle", x: "12px", y: "18px", fontSize: "16" }, "</>"))); } function findFocusable(el) { // eslint-disable-next-line react/no-find-dom-node const element = ReactDOM.findDOMNode(el); return element ? Array.from(element.querySelectorAll('[tabindex]')) : []; } export default function StatusBar(props) { const [focusedBtnId, setFocusedBtnId] = useState(null); const [includeEdtrDesc, setIncludeEdtrDesc] = useState(false); const statusBarRef = useRef(null); useEffect(() => { const buttons = findFocusable(statusBarRef.current); setFocusedBtnId(buttons[0].getAttribute('data-btn-id')); buttons[0].setAttribute('tabIndex', '0'); }, []); useEffect(() => { // the kbshortcut and a11y checker buttons are hidden when in html view // move focus to the next button over. if (isHtmlView() && /rce-kbshortcut-btn|rce-a11y-btn/.test(focusedBtnId)) { setFocusedBtnId('rce-edit-btn'); } // adding a delay before including the HTML Editor description to wait the focus moves to the RCE // and prevent JAWS from reading the aria-describedby element when switching back to RCE view const timerid = setTimeout(() => { setIncludeEdtrDesc(!isHtmlView()); }, 100); return () => clearTimeout(timerid); }, [props.editorView]); // eslint-disable-line react-hooks/exhaustive-deps function isAvailable(plugin) { return !props.disabledPlugins.includes(plugin); } function isFeature(feature_name) { return props.features.includes(feature_name); } function preferredHtmlEditor() { if (props.preferredHtmlEditor) return props.preferredHtmlEditor; return PRETTY_HTML_EDITOR_VIEW; } function getHtmlEditorView(event) { if (!event.shiftKey) return preferredHtmlEditor(); return preferredHtmlEditor() === RAW_HTML_EDITOR_VIEW ? PRETTY_HTML_EDITOR_VIEW : RAW_HTML_EDITOR_VIEW; } function handleKey(event) { const buttons = findFocusable(statusBarRef.current).filter(b => !b.disabled); const focusedIndex = buttons.findIndex(b => b.getAttribute('data-btn-id') === focusedBtnId); let newFocusedIndex; if (event.keyCode === keycode.codes.right) { newFocusedIndex = (focusedIndex + 1) % buttons.length; } else if (event.keyCode === keycode.codes.left) { newFocusedIndex = (focusedIndex + buttons.length - 1) % buttons.length; } else { return; } buttons[newFocusedIndex].focus(); setFocusedBtnId(buttons[newFocusedIndex].getAttribute('data-btn-id')); } function isHtmlView() { return props.editorView !== WYSIWYG_VIEW; } function tabIndexForBtn(itemId) { const tabindex = focusedBtnId === itemId ? 0 : -1; return tabindex; } function renderPath() { return /*#__PURE__*/React.createElement(View, { "data-testid": "whole-status-bar-path", style: { display: 'flex' } }, renderPathString(props)); } function renderA11yButton() { const a11y = formatMessage('Accessibility Checker'); const a11yButtonId = 'rce-a11y-btn'; const button = /*#__PURE__*/React.createElement(IconButton, { "data-btn-id": a11yButtonId, color: "secondary", title: a11y, tabIndex: tabIndexForBtn(a11yButtonId), onClick: event => { event.target.focus(); props.onA11yChecker(a11yButtonId); }, onFocus: () => setFocusedBtnId(a11yButtonId), screenReaderLabel: a11y, withBackground: false, withBorder: false }, /*#__PURE__*/React.createElement(IconA11yLine, null)); if (props.a11yErrorsCount <= 0) { return button; } return /*#__PURE__*/React.createElement(InstUISettingsProvider, { theme: { componentOverrides: { Badge: { colorPrimary: props.a11yBadgeColor } } } }, /*#__PURE__*/React.createElement(Badge, { count: props.a11yErrorsCount, countUntil: 100 }, button)); } function renderHtmlEditorMessage() { const message = props.editorView === PRETTY_HTML_EDITOR_VIEW ? formatMessage('Sadly, the pretty HTML editor is not keyboard accessible. Access the raw HTML editor here.') : formatMessage('Access the pretty HTML editor'); const label = props.editorView === PRETTY_HTML_EDITOR_VIEW ? formatMessage('Switch to raw HTML Editor') : formatMessage('Switch to pretty HTML Editor'); return /*#__PURE__*/React.createElement(View, { "data-testid": "html-editor-message", style: { display: 'flex' } }, /*#__PURE__*/React.createElement(Button, { "data-btn-id": "rce-editormessage-btn", margin: "0 small", title: message, color: "secondary", size: "small", tabIndex: tabIndexForBtn('rce-editormessage-btn'), onClick: event => { event.target.focus(); props.onChangeView(props.editorView === PRETTY_HTML_EDITOR_VIEW ? RAW_HTML_EDITOR_VIEW : PRETTY_HTML_EDITOR_VIEW); }, onFocus: () => setFocusedBtnId('rce-editormessage-btn') }, label)); } function renderIconButtons() { if (isHtmlView()) return null; const ai_tools = isFeature('ai_tools'); const kb_shortcuts = isFeature('keyboard_shortcuts'); const a11y_checker = isFeature('a11y_checker'); if (!(ai_tools || kb_shortcuts || a11y_checker)) return null; const kbshortcut = formatMessage('View keyboard shortcuts'); return /*#__PURE__*/React.createElement(View, { display: "inline-block", padding: "0 x-small" }, ai_tools && props.onAI && /*#__PURE__*/React.createElement(IconButton, { "data-btn-id": "rce-ai-btn", color: "secondary", "aria-haspopup": "dialog", title: formatMessage('AI Tools'), tabIndex: tabIndexForBtn('rce-ai-btn'), onClick: event => { event.target.focus(); // FF doesn't focus buttons on click props.onAI(); }, onFocus: () => setFocusedBtnId('rce-ai-btn'), screenReaderLabel: formatMessage('AI Tools'), withBackground: false, withBorder: false }, /*#__PURE__*/React.createElement("span", { style: { color: 'dodgerBlue' } }, /*#__PURE__*/React.createElement(SVGIcon, { src: AIWandSVG, size: "x-small" }))), kb_shortcuts && /*#__PURE__*/React.createElement(IconButton, { "data-btn-id": "rce-kbshortcut-btn", color: "secondary", "aria-haspopup": "dialog", title: kbshortcut, tabIndex: tabIndexForBtn('rce-kbshortcut-btn'), onClick: event => { event.target.focus(); // FF doesn't focus buttons on click props.onKBShortcutModalOpen(); }, onFocus: () => setFocusedBtnId('rce-kbshortcut-btn'), screenReaderLabel: kbshortcut, withBackground: false, withBorder: false }, /*#__PURE__*/React.createElement(IconKeyboardShortcutsLine, null)), a11y_checker && !props.readOnly && isAvailable('ally_checker') && renderA11yButton()); } function renderWordCount() { if (isHtmlView()) return null; const wordCount = formatMessage(`{count, plural, =0 {0 words} one {1 word} other {# words} }`, { count: props.wordCount }); return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { className: css(styles.separator) }), /*#__PURE__*/React.createElement(View, { display: "inline-block", padding: "0 small", "data-testid": "status-bar-word-count" }, /*#__PURE__*/React.createElement(CondensedButton, { "data-btn-id": "rce-wordcount-btn", color: "secondary", onClick: props.onWordcountModalOpen, tabIndex: tabIndexForBtn('rce-wordcount-btn'), title: formatMessage('View word and character counts') }, wordCount))); } function renderSection3(html_view, fullscreen, resize_handle) { return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { className: css(styles.separator) }), html_view && renderToggleHtml(), fullscreen && renderFullscreen(), resize_handle && renderResizeHandle()); } function descMsg() { return preferredHtmlEditor() === RAW_HTML_EDITOR_VIEW ? formatMessage('Shift-O to open the pretty html editor.') : formatMessage('The pretty html editor is not keyboard accessible. Press Shift O to open the raw html editor.'); } function renderToggleHtml() { const toggleToHtml = formatMessage('Switch to the html editor'); const toggleToRich = formatMessage('Switch to the rich text editor'); const toggleToHtmlTip = formatMessage('Click or shift-click for the html editor.'); const descText = isHtmlView() ? toggleToRich : toggleToHtml; const titleText = isHtmlView() ? toggleToRich : toggleToHtmlTip; return /*#__PURE__*/React.createElement(View, { display: "inline-block", padding: "0 0 0 x-small" }, !props.readOnly && /*#__PURE__*/React.createElement(IconButton, { "data-btn-id": "rce-edit-btn", color: "secondary", onClick: event => { props.onChangeView(isHtmlView() ? WYSIWYG_VIEW : getHtmlEditorView(event)); }, onKeyUp: event => { if (props.editorView === WYSIWYG_VIEW && event.shiftKey && event.keyCode === 79) { const html_view = preferredHtmlEditor() === RAW_HTML_EDITOR_VIEW ? PRETTY_HTML_EDITOR_VIEW : RAW_HTML_EDITOR_VIEW; props.onChangeView(html_view); } }, onFocus: () => setFocusedBtnId('rce-edit-btn'), title: titleText, tabIndex: tabIndexForBtn('rce-edit-btn'), "aria-describedby": includeEdtrDesc ? 'edit-button-desc' : undefined, screenReaderLabel: descText, withBackground: false, withBorder: false }, emptyTagIcon()), includeEdtrDesc && /*#__PURE__*/React.createElement("span", { style: { display: 'none' }, id: "edit-button-desc" }, descMsg())); } function renderFullscreen() { if (props.readOnly) return null; if (!document[FS_ENABLED]) return null; if (props.editorView === RAW_HTML_EDITOR_VIEW && !('requestFullscreen' in document.body)) { // this is safari, which refuses to fullscreen a textarea return null; } const fullscreen = props.rceIsFullscreen ? formatMessage('Exit Fullscreen') : formatMessage('Fullscreen'); return /*#__PURE__*/React.createElement(IconButton, { "data-btn-id": "rce-fullscreen-btn", color: "secondary", title: fullscreen, tabIndex: tabIndexForBtn('rce-fullscreen-btn'), onClick: event => { event.target.focus(); props.onFullscreen(); }, onFocus: () => setFocusedBtnId('rce-fullscreen-btn'), screenReaderLabel: fullscreen, withBackground: false, withBorder: false }, props.rceIsFullscreen ? /*#__PURE__*/React.createElement(IconExitFullScreenLine, null) : /*#__PURE__*/React.createElement(IconFullScreenLine, null)); } function renderResizeHandle() { if (props.rceIsFullscreen) return null; return /*#__PURE__*/React.createElement(ResizeHandle, { "data-btn-id": "rce-resize-handle", onDrag: props.onResize, tabIndex: tabIndexForBtn('rce-resize-handle'), onFocus: () => { setFocusedBtnId('rce-resize-handle'); } }); } const flexJustify = isHtmlView() ? 'end' : 'start'; const html_view = isFeature('html_view') && isAvailable('instructure_html_view'); const fullscreen = isFeature('fullscreen') && isAvailable('instructure_fullscreen'); const resize_handle = isFeature('resize_handle'); return /*#__PURE__*/React.createElement(InstUISettingsProvider, { theme: { componentOverrides: { IconButton: { secondaryGhostColor: 'rgb(34, 47, 62)' // to match tinymce's button color } } } }, /*#__PURE__*/React.createElement(Flex, { id: props.id, padding: "x-small 0 x-small x-small", "data-testid": "RCEStatusBar", justifyItems: flexJustify, ref: statusBarRef, onKeyDown: handleKey }, /*#__PURE__*/React.createElement(Flex.Item, { shouldGrow: true }, isHtmlView() ? renderHtmlEditorMessage() : renderPath()), /*#__PURE__*/React.createElement(Flex.Item, { role: "toolbar", title: formatMessage('Editor Status Bar') }, renderIconButtons(), isFeature('word_count') && isAvailable('instructure_wordcount') && renderWordCount(), (html_view || fullscreen || resize_handle) && renderSection3(html_view, fullscreen, resize_handle)))); } const styles = StyleSheet.create({ separator: { display: 'inline-block', 'box-sizing': 'border-box', 'border-right': '1px solid #ccc', width: '1px', height: '1.5rem', position: 'relative', top: '.5rem' } });