UNPKG

@instructure/canvas-rce

Version:

A component wrapping Canvas's usage of Tinymce

504 lines (502 loc) 17 kB
/* * Copyright (C) 2018 - 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/>. */ // our own imported describe function confuses eslint import React from 'react'; import { LiveAnnouncer, LiveMessage } from 'react-aria-live'; import { Button, CloseButton, IconButton } from '@instructure/ui-buttons'; import { Flex } from '@instructure/ui-flex'; import { Tray } from '@instructure/ui-tray'; import { Popover } from '@instructure/ui-popover'; import { View } from '@instructure/ui-view'; import { Grid, GridRow, GridCol } from '@instructure/ui-grid'; import { Heading } from '@instructure/ui-heading'; import { Link } from '@instructure/ui-link'; import { Spinner } from '@instructure/ui-spinner'; import { Text } from '@instructure/ui-text'; import { Checkbox } from '@instructure/ui-checkbox'; import { TextInput } from '@instructure/ui-text-input'; import { TextArea } from '@instructure/ui-text-area'; import { SimpleSelect } from '@instructure/ui-simple-select'; import { IconQuestionLine } from '@instructure/ui-icons'; import { InstUISettingsProvider } from '@instructure/emotion'; import { Alert } from '@instructure/ui-alerts'; import ColorField from './ColorField'; import PlaceholderSVG from './placeholder-svg'; import describe from '../utils/describe'; import * as dom from '../utils/dom'; import checkNode from '../node-checker'; import formatMessage from '../../../../format-message'; import { clearIndicators } from '../utils/indicate'; import { getTrayHeight } from '../../shared/trayUtils'; import { instuiPopupMountNodeFn } from '../../../../util/fullscreenHelpers'; // safari still doesn't support the standard api const FS_CHANGEEVENT = document.exitFullscreen ? 'fullscreenchange' : 'webkitfullscreenchange'; const noop = () => {}; export default class Checker extends React.Component { constructor(...args) { super(...args); this.state = { open: false, checking: false, errors: [], formState: {}, formStateValid: false, errorIndex: 0, config: {}, showWhyPopover: false }; this.onFullscreenChange = _event => { this.selectCurrent(); }; this.updateFormState = ({ target }) => { this.setState(prevState => { const formState = { ...prevState.formState }; if (target.type === 'checkbox') { formState[target.name] = target.checked; } else { formState[target.name] = target.value; } return { formState, formStateValid: this.formStateValid(formState) }; }); }; } componentDidMount() { this.props.editor.on('Remove', _editor => { this.setState({ open: false }, () => { this.props.onClose(); }); }); } componentDidUpdate(_prevProps, prevState) { if (prevState.open !== this.state.open) { if (this.state.open) { window.addEventListener(FS_CHANGEEVENT, this.onFullscreenChange); } else { window.removeEventListener(FS_CHANGEEVENT, this.onFullscreenChange); } } } setConfig(config) { this.setState({ config }); } check(done) { if (typeof done !== 'function') done = noop; this.setState({ open: true, checking: true, errors: [], errorIndex: 0 }, () => { window.webkit?.messageHandlers?.modalPresentation?.postMessage?.({ open: true }); if (typeof this.state.config.beforeCheck === 'function') { this.state.config.beforeCheck(this.props.editor, () => { this._check(() => { if (typeof this.state.config.afterCheck === 'function') { this.state.config.afterCheck(this.props.editor, done); } else { done(); } }); }); } else if (typeof this.state.config.afterCheck === 'function') { this._check(() => { this.state.config.afterCheck(this.props.editor, done); }); } else { this._check(done); } }); } _check(done) { const node = this.props.getBody(); const checkDone = errors => { this.setState({ errorIndex: 0, errors, checking: false }, () => { this.selectCurrent(); done(); }); }; checkNode(node, checkDone, this.state.config, this.props.additionalRules); } firstError() { if (this.state.errors.length > 0) { this.setErrorIndex(0); } } nextError() { const next = (this.state.errorIndex + 1) % this.state.errors.length; this.setErrorIndex(next); } prevError() { const len = this.state.errors.length; const prev = (len + this.state.errorIndex - 1) % len; this.setErrorIndex(prev); } setErrorIndex(errorIndex) { this.onLeaveError(); if (errorIndex >= this.state.errors.length) { errorIndex = 0; } this.setState({ errorIndex }, () => this.selectCurrent()); } selectCurrent() { clearIndicators(this.props.editor.dom.doc); const errorNode = this.errorNode(); if (errorNode) { this.getFormState(); dom.select(errorNode); } else { this.firstError(); } } error() { return this.state.errors[this.state.errorIndex]; } errorNode() { const error = this.error(); return error && error.node; } errorRootNode() { const rule = this.errorRule(); const rootNode = rule && rule.rootNode && rule.rootNode(this.errorNode()); return rootNode || this.errorNode(); } updateErrorNode(elem) { const error = this.error(); if (error) { error.node = elem; } } errorRule() { const error = this.error(); return error && error.rule; } errorMessage() { const rule = this.errorRule(); return rule && rule.message(); } getFormState() { const rule = this.errorRule(); const node = this.errorNode(); if (rule && node) { this.setState({ formState: rule.data(node), formStateValid: false }); } } formStateValid(formState) { formState = formState || this.state.formState; let node = this.tempNode(true); const rule = this.errorRule(); if (!node || !rule) { return false; } node = rule.update(node, formState); if (this._tempNode === this._tempTestNode) { this._tempNode = node; } this._tempTestNode = node; return rule.test(node); } fixIssue() { const rule = this.errorRule(); const node = this.errorNode(); if (rule && node) { this.removeTempNode(); rule.update(node, this.state.formState); this.updateErrorNode(node); if (this._closeButtonRef) { this._closeButtonRef.focus(); } const errorIndex = this.state.errorIndex; this.check(() => { this.setErrorIndex(errorIndex); this.props.onFixError(this.state.errors); }); } } newTempRootNode(rootNode) { const newTempRootNode = rootNode.cloneNode(true); const path = dom.pathForNode(rootNode, this.errorNode()); this._tempTestNode = dom.nodeByPath(newTempRootNode, path); return newTempRootNode; } tempNode(refresh = false) { if (!this._tempNode || refresh) { const rootNode = this.errorRootNode(); if (rootNode) { const newTempRtNode = this.newTempRootNode(rootNode); if (refresh && this._tempNode) { const parent = this._tempNode.parentNode; parent.insertBefore(newTempRtNode, this._tempNode); parent.removeChild(this._tempNode); } else { const parent = rootNode.parentNode; parent.insertBefore(newTempRtNode, rootNode); parent.removeChild(rootNode); } this._tempNode = newTempRtNode; } } return this._tempTestNode; } removeTempNode() { const node = this.errorRootNode(); if (this._tempNode && node) { const parent = this._tempNode.parentNode; parent.insertBefore(node, this._tempNode); parent.removeChild(this._tempNode); this._tempNode = null; this._tempTestNode = null; } } onLeaveError() { this.removeTempNode(); } handleClose() { this.onLeaveError(); clearIndicators(this.props.editor.dom.doc); this.setState({ open: false }, () => { this.props?.onClose(); window.webkit?.messageHandlers?.modalPresentation?.postMessage?.({ open: false }); }); } render() { const rule = this.errorRule(); const issueNumberMessage = formatMessage('Issue { num }/{ total }', { num: this.state.errorIndex + 1, total: this.state.errors.length }); return /*#__PURE__*/React.createElement(LiveAnnouncer, null, /*#__PURE__*/React.createElement(Tray, { "data-mce-component": true, label: formatMessage('Accessibility Checker'), mountNode: this.props.mountNode, open: this.state.open, onDismiss: () => this.handleClose(), placement: "end", contentRef: e => this.trayElement = e, size: "regular", themeOverride: { regularWidth: '22em' } }, /*#__PURE__*/React.createElement(Flex, { direction: "column", height: getTrayHeight() }, /*#__PURE__*/React.createElement(Flex.Item, { as: "header", 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('Accessibility Checker'))), /*#__PURE__*/React.createElement(Flex.Item, null, /*#__PURE__*/React.createElement(CloseButton, { screenReaderLabel: formatMessage('Close Accessibility Checker'), placement: "end", onClick: () => this.handleClose(), elementRef: ref => this._closeButtonRef = ref })))), /*#__PURE__*/React.createElement(Flex.Item, { as: "div", padding: "0 large large" }, this.state.errors.length > 0 && /*#__PURE__*/React.createElement(View, { as: "div" }, /*#__PURE__*/React.createElement(LiveMessage, { "aria-live": "polite", message: ` ${issueNumberMessage} ${describe(this.errorNode())} ${this.errorMessage()} ` }), /*#__PURE__*/React.createElement(View, { as: "div", margin: "large 0 medium 0" }, /*#__PURE__*/React.createElement(Grid, { vAlign: "middle", hAlign: "space-between", colSpacing: "none" }, /*#__PURE__*/React.createElement(GridRow, null, /*#__PURE__*/React.createElement(GridCol, null, /*#__PURE__*/React.createElement(Text, { weight: "bold" }, issueNumberMessage)), /*#__PURE__*/React.createElement(GridCol, { width: "auto" }, /*#__PURE__*/React.createElement(Popover, { on: "click", isShowingContent: this.state.showWhyPopover, shouldContainFocus: true, shouldReturnFocus: true, renderTrigger: () => /*#__PURE__*/React.createElement(IconButton, { screenReaderLabel: formatMessage('Why'), renderIcon: IconQuestionLine, onClick: () => this.setState({ showWhyPopover: true }), withBackground: false, withBorder: false }, /*#__PURE__*/React.createElement(IconQuestionLine, null)) }, /*#__PURE__*/React.createElement(View, { padding: "medium", display: "block", width: "16rem" }, /*#__PURE__*/React.createElement(CloseButton, { placement: "end", offset: "x-small", onClick: () => this.setState({ showWhyPopover: false }), screenReaderLabel: formatMessage('Close') }), /*#__PURE__*/React.createElement(Text, null, /*#__PURE__*/React.createElement("p", null, rule.why()), /*#__PURE__*/React.createElement("p", null, rule.link && rule.link.length && /*#__PURE__*/React.createElement(InstUISettingsProvider, { themeOverride: { componentOverrides: { [Link.componentId]: { textDecoration: 'underline' } } } }, /*#__PURE__*/React.createElement(Link, { href: rule.link, target: "_blank" }, rule.linkText())))))))))), /*#__PURE__*/React.createElement("form", { onSubmit: event => { event.preventDefault(); this.fixIssue(); } }, /*#__PURE__*/React.createElement(Text, { as: "div" }, this.errorMessage()), rule.form().map(f => /*#__PURE__*/React.createElement(View, { as: "div", key: f.dataKey, margin: "medium 0 0" }, this.renderField(f))), /*#__PURE__*/React.createElement(View, { as: "div", margin: "medium 0" }, /*#__PURE__*/React.createElement(Grid, { vAlign: "middle", hAlign: "space-between", colSpacing: "none" }, /*#__PURE__*/React.createElement(GridRow, null, /*#__PURE__*/React.createElement(GridCol, null, /*#__PURE__*/React.createElement(Button, { onClick: () => this.prevError(), margin: "0 small 0 0", "aria-label": "Previous", disabled: this.state.errors.length < 2 }, formatMessage('Prev')), /*#__PURE__*/React.createElement(Button, { onClick: () => this.nextError(), disabled: this.state.errors.length < 2 }, formatMessage('Next'))), /*#__PURE__*/React.createElement(GridCol, { width: "auto" }, /*#__PURE__*/React.createElement(Button, { type: "submit", color: "primary", disabled: !this.state.formStateValid }, formatMessage('Apply')))))))), this.state.errors.length === 0 && !this.state.checking && /*#__PURE__*/React.createElement(View, null, /*#__PURE__*/React.createElement(Text, null, /*#__PURE__*/React.createElement("p", null, formatMessage('No accessibility issues were detected.'))), /*#__PURE__*/React.createElement(PlaceholderSVG, null)), this.state.checking && /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement(LiveMessage, { message: formatMessage('Checking for accessibility issues'), "aria-live": "polite" }), /*#__PURE__*/React.createElement(Spinner, { renderTitle: formatMessage('Checking for accessibility issues'), margin: "medium auto" })))))); } renderField(f) { const disabled = !!f.disabledIf && f.disabledIf(this.state.formState); switch (true) { case !!f.options: return /*#__PURE__*/React.createElement(SimpleSelect, { mountNode: instuiPopupMountNodeFn(), disabled: disabled, onChange: (e, option) => { this.updateFormState({ target: { name: f.dataKey, value: option.value } }); }, value: this.state.formState[f.dataKey], renderLabel: () => f.label }, f.options.map(o => /*#__PURE__*/React.createElement(SimpleSelect.Option, { key: o[0], id: o[0], value: o[0] }, o[1]))); case f.checkbox: return /*#__PURE__*/React.createElement(Checkbox, { label: f.label, name: f.dataKey, checked: this.state.formState[f.dataKey], onChange: this.updateFormState, disabled: disabled }); case f.color: return /*#__PURE__*/React.createElement(ColorField, { label: f.label, name: f.dataKey, value: this.state.formState[f.dataKey] || '', onChange: this.updateFormState, key: this.state.formState.id }); case f.textarea: return /*#__PURE__*/React.createElement(TextArea, { label: f.label, name: f.dataKey, value: this.state.formState[f.dataKey], onChange: this.updateFormState, disabled: disabled }); case f.alert: return this.state.formState.action === 'elem-only' && /*#__PURE__*/React.createElement(Alert, { name: f.dataKey, variant: f.variant }, f.message); default: return /*#__PURE__*/React.createElement(TextInput, { renderLabel: f.label, name: f.dataKey, value: this.state.formState[f.dataKey] || '', onChange: this.updateFormState, disabled: disabled }); } } } Checker.defaultProps = { additionalRules: [], onFixError: noop };