UNPKG

@instructure/canvas-rce

Version:

A component wrapping Canvas's usage of Tinymce

343 lines (341 loc) 12.2 kB
import _pt from "prop-types"; /* * 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 { Button, CloseButton } from '@instructure/ui-buttons'; import { Flex } from '@instructure/ui-flex'; import { Heading } from '@instructure/ui-heading'; import { IconWarningSolid } from '@instructure/ui-icons'; import { getTrayHeight } from '../../shared/trayUtils'; import { View } from '@instructure/ui-view'; import { instuiPopupMountNodeFn } from '../../../../util/fullscreenHelpers'; import formatMessage from '../../../../format-message'; import { Tray } from '@instructure/ui-tray'; import { TextInput } from '@instructure/ui-text-input'; import { Text } from '@instructure/ui-text'; import { ScreenReaderContent } from '@instructure/ui-a11y-content'; import { Alert } from '@instructure/ui-alerts'; export default function FindReplaceTray({ onNext, onPrevious, onFind, onRequestClose, onReplace, index, max, initialText = '', selectionContext = ['', ''] }) { const [findText, setFindText] = useState(initialText); const [replaceText, setReplaceText] = useState(''); const [hasOpened, setHasOpened] = useState(false); const [showReplaceAlert, setShowReplaceAlert] = useState(''); const [alertFindText, setAlertFindText] = useState(''); const [alertReplaceText, setAlertReplaceText] = useState(''); const trayRef = useRef(null); const liveRegionKey = useRef(0); const srDupKey = useRef(0); // moves RCE when tray opens/closes, copied from CanvasContentTray 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`; } return () => { c = document.querySelector('[role="main"]'); if (!c) return; c.style.width = ''; }; }, [hasOpened]); useEffect(() => { if (initialText) { onFind(initialText); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }, [value]); return ref.current; } const prepend = selectionContext[0]; const append = selectionContext[1]; const srContextMsg = formatMessage('{prepend}{findText}{append}', { prepend, findText, append }); const previousSrMsg = usePrevious(srContextMsg); const previousFindText = usePrevious(findText); const handleTextChange = e => { setFindText(e.target.value); onFind(e.target.value); }; const handleFindKeyDown = e => { if (e.key !== 'Enter') return; if (e.shiftKey) { if (isButtonDisabled('previous')) return; onPrevious(); } else { // try to search when there are currently no results and user presses enter if (index === 0 && max === 0 && findText) { onFind(findText); return; } if (isButtonDisabled('next')) return; onNext(); } }; const handleReplaceKeyDown = e => { if (e.key !== 'Enter') return; if (isButtonDisabled('replace')) return; const forward = !e.shiftKey; replace(replaceText, forward, false); }; const replace = (newText, forward, all) => { onReplace(newText, forward, all); if (all) { setShowReplaceAlert('replaceAll'); } else { setShowReplaceAlert('replace'); } setAlertFindText(findText); setAlertReplaceText(newText); liveRegionKey.current++; }; const alertText = showReplaceAlert === '' ? '' : showReplaceAlert === 'replace' ? formatMessage('Replaced {alertFindText} with {alertReplaceText}', { alertFindText, alertReplaceText }) : formatMessage('Replaced all {alertFindText} with {alertReplaceText}', { alertFindText, alertReplaceText }); const renderReplaceAlert = () => { if (!showReplaceAlert) { liveRegionKey.current = 0; return /*#__PURE__*/React.createElement(React.Fragment, null); } return /*#__PURE__*/React.createElement(Alert, { variant: "success", renderCloseButtonLabel: "Close Alert", margin: "small", transition: "fade", onDismiss: () => setShowReplaceAlert(''), timeout: 3000 }, alertText); }; const renderScreenReaderAlert = () => { return /*#__PURE__*/React.createElement(ScreenReaderContent, null, alertText); }; const errMsg = /*#__PURE__*/React.createElement(Flex, { gap: "xx-small", alignItems: "center" }, /*#__PURE__*/React.createElement(IconWarningSolid, { color: "error", "data-testid": "error-icon" }), /*#__PURE__*/React.createElement(Text, { color: "danger", size: "small" }, formatMessage('No results found'))); const messages = findText && max === 0 ? [{ text: errMsg, type: 'error' }] : []; const resultText = () => { const srErrMsg = messages.length === 0 ? '' : errMsg; // resolves issue where focus is lost when this (dis)appears if (max === 0) { return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(ScreenReaderContent, { "aria-live": "polite" }, srErrMsg), /*#__PURE__*/React.createElement(Text, null)); } const msg = formatMessage('{index} of {max}', { index, max }); const srResultMsg = formatMessage('Result {index} of {max}.', { index, max }); // necessary to force screen reader to read the same message while typing if (srContextMsg === previousSrMsg && previousFindText != findText) { srDupKey.current++; } else srDupKey.current = 0; return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(View, { as: "span", key: srDupKey.current, "aria-live": "polite", "aria-atomic": "true", role: "alert" }, /*#__PURE__*/React.createElement(ScreenReaderContent, null, srResultMsg, " ", srContextMsg)), /*#__PURE__*/React.createElement(Text, { "aria-hidden": "true" }, msg)); }; const isButtonDisabled = button => { switch (button) { case 'next': case 'previous': return max < 2; case 'replace': return !replaceText || index === 0; case 'replaceAll': return !replaceText || max < 2 || index === 0; default: return false; } }; return /*#__PURE__*/React.createElement(Tray, { "data-mce-component": true, label: formatMessage('Find and Replace'), mountNode: instuiPopupMountNodeFn(), onDismiss: onRequestClose, open: true, placement: "end", size: "regular", shouldContainFocus: true, shouldReturnFocus: true, shouldCloseOnDocumentClick: true, onOpen: () => setHasOpened(true) }, /*#__PURE__*/React.createElement(View, { as: "div", margin: "none none medium none", key: liveRegionKey.current }, renderReplaceAlert(), /*#__PURE__*/React.createElement(View, { as: "div", role: "alert", "aria-live": "polite" }, renderScreenReaderAlert())), /*#__PURE__*/React.createElement(Flex, { direction: "column", height: getTrayHeight() }, /*#__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" }, formatMessage('Find and Replace'))), /*#__PURE__*/React.createElement(Flex.Item, null, /*#__PURE__*/React.createElement(CloseButton, { placement: "static", color: "primary", "data-testid": "close-button", screenReaderLabel: formatMessage('Close'), onClick: onRequestClose })))), /*#__PURE__*/React.createElement(Flex.Item, { as: "div", padding: "0 large large" }, /*#__PURE__*/React.createElement(View, { as: "div", margin: "large 0 medium 0" }, /*#__PURE__*/React.createElement(TextInput, { renderLabel: formatMessage('Find'), name: "findtext", onChange: e => handleTextChange(e), onKeyDown: e => handleFindKeyDown(e), value: findText, placeholder: formatMessage('enter search text'), renderAfterInput: resultText(), messages: messages, "data-testid": "find-text-input" })), /*#__PURE__*/React.createElement(View, { as: "div" }, /*#__PURE__*/React.createElement(Flex, null, /*#__PURE__*/React.createElement(Flex.Item, { size: "6.125rem" }, /*#__PURE__*/React.createElement(Button, { color: "secondary", margin: "0 medium 0 0", onClick: onPrevious, disabled: isButtonDisabled('previous'), "data-testid": "previous-button", "aria-label": formatMessage('Previous {findText}', { findText }) }, /*#__PURE__*/React.createElement("span", { "aria-hidden": "true" }, formatMessage('Previous')))), /*#__PURE__*/React.createElement(Flex.Item, null, /*#__PURE__*/React.createElement(Button, { color: "secondary", margin: "0 small 0 0", onClick: onNext, disabled: isButtonDisabled('next'), "data-testid": "next-button", "aria-label": formatMessage('Next {findText}', { findText }) }, /*#__PURE__*/React.createElement("span", { "aria-hidden": "true" }, formatMessage('Next')))))), /*#__PURE__*/React.createElement(View, { as: "div", margin: "large 0 medium 0" }, /*#__PURE__*/React.createElement(TextInput, { renderLabel: formatMessage('Replace with'), name: "replacetext", onChange: e => setReplaceText(e.target.value), onKeyDown: e => handleReplaceKeyDown(e), value: replaceText, placeholder: formatMessage('enter replacement text'), "data-testid": "replace-text-input" })), /*#__PURE__*/React.createElement(View, { as: "div" }, /*#__PURE__*/React.createElement(Flex, null, /*#__PURE__*/React.createElement(Flex.Item, null, /*#__PURE__*/React.createElement(Button, { color: "secondary", margin: "0 small 0 0", onClick: () => replace(replaceText, true, true), disabled: isButtonDisabled('replaceAll'), "data-testid": "replace-all-button", "aria-label": formatMessage('Replace all {findText} with {replaceText}', { findText, replaceText }) }, /*#__PURE__*/React.createElement("span", { "aria-hidden": "true" }, formatMessage('Replace All')))), /*#__PURE__*/React.createElement(Flex.Item, null, /*#__PURE__*/React.createElement(Button, { color: "secondary", margin: "0 small 0 0", onClick: () => { replace(replaceText, true, false); }, disabled: isButtonDisabled('replace'), "data-testid": "replace-button", "aria-label": formatMessage('Replace {findText} with {replaceText}', { findText, replaceText }) }, /*#__PURE__*/React.createElement("span", { "aria-hidden": "true" }, formatMessage('Replace'))))))))); } FindReplaceTray.propTypes = { onNext: _pt.func.isRequired, onPrevious: _pt.func.isRequired, onFind: _pt.func.isRequired, onRequestClose: _pt.func.isRequired, onReplace: _pt.func.isRequired, index: _pt.number.isRequired, max: _pt.number.isRequired, initialText: _pt.string, selectionContext: _pt.arrayOf(_pt.string) };