UNPKG

@plone/volto

Version:
377 lines (351 loc) 10.3 kB
/** * Edit html block. * @module components/manage/Blocks/HTML/Edit */ import { compose } from 'redux'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { Button, Popup } from 'semantic-ui-react'; import { defineMessages, injectIntl } from 'react-intl'; import loadable from '@loadable/component'; import isEqual from 'lodash/isEqual'; import Icon from '@plone/volto/components/theme/Icon/Icon'; import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable'; import showSVG from '@plone/volto/icons/show.svg'; import clearSVG from '@plone/volto/icons/clear.svg'; import codeSVG from '@plone/volto/icons/code.svg'; import indentSVG from '@plone/volto/icons/indent.svg'; const Editor = loadable(() => import('react-simple-code-editor')); const messages = defineMessages({ source: { id: 'Source', defaultMessage: 'Source', }, preview: { id: 'Preview', defaultMessage: 'Preview', }, placeholder: { id: '<p>Add some HTML here</p>', defaultMessage: '<p>Add some HTML here</p>', }, prettier: { id: 'Prettify your code', defaultMessage: 'Prettify your code', }, clear: { id: 'Clear', defaultMessage: 'Clear', }, code: { id: 'Code', defaultMessage: 'Code', }, }); /** * Edit html block class. * @class Edit * @extends Component */ class Edit extends Component { /** * Property types. * @property {Object} propTypes Property types. * @static */ static propTypes = { selected: PropTypes.bool.isRequired, block: PropTypes.string.isRequired, index: PropTypes.number.isRequired, data: PropTypes.objectOf(PropTypes.any).isRequired, onChangeBlock: PropTypes.func.isRequired, onSelectBlock: PropTypes.func.isRequired, onDeleteBlock: PropTypes.func.isRequired, handleKeyDown: PropTypes.func.isRequired, editable: PropTypes.bool, }; /** * Default properties * @property {Object} defaultProps Default properties. * @static */ static defaultProps = { editable: true, }; /** * Constructor * @method constructor * @param {Object} props Component properties */ constructor(props) { super(props); this.state = { isPreview: false, }; this.onChangeCode = this.onChangeCode.bind(this); this.onPreview = this.onPreview.bind(this); this.onCodeEditor = this.onCodeEditor.bind(this); } codeEditorRef = React.createRef(); savedSelection = {}; componentDidUpdate(prevProps, prevState, snapshot) { // The selection is saved in the snapshot. this.savedSelection = snapshot; this.restoreSelectionAndFocus(this.codeEditorRef.current); } /** * @param {*} nextProps * @param {*} nextState * @returns {boolean} * @memberof Edit */ shouldComponentUpdate(nextProps) { // Always rerender when the DOM node is not created for the Editor (the // first call to shouldComponentUpdate). if (!this._input) { return true; } // Rerender the entire component when the Editor in it changes its selection // because this way we get a call to getSnapshotBeforeUpdate where we can // save the selection. return ( this.props.selected || !isEqual(this.props.data, nextProps.data) || this._input.selectionStart !== this.savedSelection.selectionStart || this._input.selectionEnd !== this.savedSelection.selectionEnd ); } /** * Change html handler * @method onChangeCode * @param {string} code New value html * @returns {undefined} */ onChangeCode(code) { this.props.onChangeBlock(this.props.block, { ...this.props.data, html: code, }); } getValue() { return this.props.data.html || ''; } /** * Preview mode handler * @method onPreview * @returns {undefined} */ async onPreview() { try { const code = ( await this.props.prettierStandalone.format(this.getValue(), { parser: 'html', plugins: [this.props.prettierParserHtml], }) ).trim(); this.setState( { isPreview: !this.state.isPreview, }, () => this.onChangeCode(code), ); } catch (ex) { // error while parsing the user-typed HTML // TODO: show a toast notification or something similar to the user } } /** * Prettify handler * @method onPrettify * @returns {undefined} */ onPrettify = async () => { try { const code = ( await this.props.prettierStandalone.format(this.getValue(), { parser: 'html', plugins: [this.props.prettierParserHtml], }) ).trim(); this.onChangeCode(code); } catch (ex) { // error while parsing the user-typed HTML // TODO: show a toast notification or something similar to the user } }; /** * Code Editor mode handler * @method onPreview * @returns {undefined} */ onCodeEditor() { this.setState({ isPreview: !this.state.isPreview }); } getSelection = (editor) => { if (!editor || !editor._input) { return {}; } const o = {}; if (editor._input.selectionStart) { o.selectionStart = editor._input.selectionStart; } if (editor._input.selectionEnd) { o.selectionEnd = editor._input.selectionEnd; } return o; }; getSnapshotBeforeUpdate(prevProps, prevState) { return this.getSelection(this.codeEditorRef.current); } restoreSelectionAndFocus = (editor) => { // Don't restore selection when the block is not selected. if ( this.props.selected && editor._input && typeof this.savedSelection?.selectionStart === 'number' && typeof this.savedSelection?.selectionEnd === 'number' ) { editor._input.selectionStart = this.savedSelection?.selectionStart; editor._input.selectionEnd = this.savedSelection?.selectionEnd; editor._input.focus(); } }; /** * Render method. * @method render * @returns {string} Markup for the component. */ render() { const placeholder = this.props.data.placeholder || this.props.intl.formatMessage(messages.placeholder); const value = this.getValue(); return ( <> {this.props.selected && value && ( <div className="toolbar"> <Popup trigger={ <Button type="button" icon basic aria-label={this.props.intl.formatMessage(messages.source)} active={!this.state.isPreview} onClick={this.onCodeEditor} > <Icon name={codeSVG} size="24px" /> </Button> } position="top center" content={this.props.intl.formatMessage(messages.code)} size="mini" /> <Popup trigger={ <Button type="button" icon basic aria-label={this.props.intl.formatMessage(messages.preview)} active={this.state.isPreview} onClick={this.onPreview} > <Icon name={showSVG} size="24px" /> </Button> } position="top center" content={this.props.intl.formatMessage(messages.preview)} size="mini" /> <Popup trigger={ <Button type="button" icon basic aria-label={this.props.intl.formatMessage(messages.prettier)} onClick={this.onPrettify} > <Icon name={indentSVG} size="24px" /> </Button> } position="top center" content={this.props.intl.formatMessage(messages.prettier)} size="mini" /> <div className="separator" /> <Popup trigger={ <Button.Group> <Button type="button" icon basic onClick={() => this.onChangeCode('')} > <Icon name={clearSVG} size="24px" color="#e40166" /> </Button> </Button.Group> } position="top center" content={this.props.intl.formatMessage(messages.clear)} size="mini" /> </div> )} {this.state.isPreview ? ( <div dangerouslySetInnerHTML={{ __html: value }} /> ) : ( <Editor value={this.getValue()} readOnly={!this.props.editable} placeholder={placeholder} onValueChange={(code) => this.onChangeCode(code)} highlight={ this.props.prismCore?.highlight && this.props.prismCore?.languages?.html ? (code) => this.props.prismCore.highlight( code, this.props.prismCore.languages.html, 'html', ) : () => {} } padding={8} className="html-editor" ref={(node) => { if (node) { this.codeEditorRef.current = node; } }} ignoreTabKey={true} /> )} </> ); } } const withPrismMarkup = (WrappedComponent) => (props) => { const [loaded, setLoaded] = React.useState(); const promise = React.useRef(null); const cancelled = React.useRef(false); React.useEffect(() => { promise.current = import('prismjs/components/prism-markup'); promise.current.then(() => { if (!cancelled.current) { setLoaded(true); } }); return () => { cancelled.current = true; }; }, []); return loaded ? <WrappedComponent {...props} /> : null; }; export default compose( injectLazyLibs(['prettierStandalone', 'prettierParserHtml', 'prismCore']), withPrismMarkup, injectIntl, )(Edit);