diff --git a/src/config/RichTextEditor/LinkEntity.jsx b/src/config/RichTextEditor/LinkEntity.jsx new file mode 100644 index 0000000000..2b3c5d8438 --- /dev/null +++ b/src/config/RichTextEditor/LinkEntity.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import UniversalLink from '@plone/volto/components/manage/UniversalLink/UniversalLink'; + +const LinkEntity = connect((state) => ({ + token: state.userSession.token, +}))((props) => { + const { token, url, target, targetUrl, download, children, dataElement } = + props; + const to = token ? url : targetUrl || url; + + return ( + + {children} + + ); +}); + +export default LinkEntity; diff --git a/src/config/RichTextEditor/Plugins/AnchorPlugin/components/Link/index.jsx b/src/config/RichTextEditor/Plugins/AnchorPlugin/components/Link/index.jsx new file mode 100644 index 0000000000..21f908ab60 --- /dev/null +++ b/src/config/RichTextEditor/Plugins/AnchorPlugin/components/Link/index.jsx @@ -0,0 +1,43 @@ +/** + * Questo รจ il link quando viene mostrato dentro l'editor nelle viste di edit + * Customizzato + * - aggiunto data-element + */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { isInternalURL, flattenToAppURL } from '@plone/volto/helpers'; + +const propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + entityKey: PropTypes.string, + getEditorState: PropTypes.func.isRequired, + target: PropTypes.string, +}; + +const Link = ({ children, className, entityKey, getEditorState, target }) => { + const entity = getEditorState().getCurrentContent().getEntity(entityKey); + const entityData = entity ? entity.get('data') : undefined; + const href = (entityData && entityData.url) || undefined; + + return ( + + {children} + + ); +}; + +Link.propTypes = propTypes; +Link.defaultProps = { + className: null, + entityKey: null, + target: null, +}; +export default Link; diff --git a/src/config/RichTextEditor/Plugins/AnchorPlugin/components/LinkButton/AddLinkForm.jsx b/src/config/RichTextEditor/Plugins/AnchorPlugin/components/LinkButton/AddLinkForm.jsx new file mode 100644 index 0000000000..c3c5538bc7 --- /dev/null +++ b/src/config/RichTextEditor/Plugins/AnchorPlugin/components/LinkButton/AddLinkForm.jsx @@ -0,0 +1,346 @@ +/** + * Add link form. + * @module components/manage/AnchorPlugin/components/LinkButton/AddLinkForm + * Customizzato + * - Aggiunta gestione data-element + * - Aggiunte opzioni per la select del data-element + * - Modificate icone ed elementi per stilizzare il tooltip del link + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { compose } from 'redux'; + +import cx from 'classnames'; +import { + addAppURL, + isInternalURL, + flattenToAppURL, + URLUtils, +} from '@plone/volto/helpers'; + +import { doesNodeContainClick } from 'semantic-ui-react/dist/commonjs/lib'; +import { Input, Form, Button } from 'semantic-ui-react'; +import { defineMessages, injectIntl } from 'react-intl'; + +import clearSVG from '@plone/volto/icons/clear.svg'; +import navTreeSVG from '@plone/volto/icons/nav.svg'; +import aheadSVG from '@plone/volto/icons/ahead.svg'; + +import withObjectBrowser from '@plone/volto/components/manage/Sidebar/ObjectBrowser'; +import { withRouter } from 'react-router'; + +import { Icon, SelectWidget } from '@plone/volto/components'; + +const messages = defineMessages({ + placeholder: { + id: 'Enter URL or select an item', + defaultMessage: 'Enter URL or select an item', + }, + placeholderLighthouse: { + id: 'Select a data-element ID', + defaultMessage: 'Seleziona un data-element ID', // TO DO: da sistemare quando si risolve il bug su i18n + }, +}); + +/** + * Add link form class. + * @class AddLinkForm + * @extends Component + */ +class AddLinkForm extends Component { + static propTypes = { + onChangeValue: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onOverrideContent: PropTypes.func.isRequired, + theme: PropTypes.objectOf(PropTypes.any).isRequired, + openObjectBrowser: PropTypes.func.isRequired, + }; + + /** + * Constructor + * @method constructor + * @param {Object} props Component properties + * @constructs AddLinkForm + */ + constructor(props) { + super(props); + + this.state = { + dataElement: props.data.dataElement, + value: isInternalURL(props.data.url) + ? flattenToAppURL(props.data.url) + : props.data.url || '', + isInvalid: false, + }; + this.onRef = this.onRef.bind(this); + this.onChange = this.onChange.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onSubmit = this.onSubmit.bind(this); + + // Options to data-element select + this.selectOptions = [ + ['appointment-booking', 'appointment-booking'], + ['faq', 'faq'], + ['report-inefficency', 'report-inefficency'], + ['accessibility-link', 'accessibility-link'], + ['privacy-policy-link', 'privacy-policy-link'], + ]; + } + + /** + * Component did mount + * @method componentDidMount + * @returns {undefined} + */ + componentDidMount() { + setTimeout(() => this.input.focus(), 50); + document.addEventListener('mousedown', this.handleClickOutside, false); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClickOutside, false); + } + + handleClickOutside = (e) => { + if ( + this.linkFormContainer.current && + doesNodeContainClick(this.linkFormContainer.current, e) + ) + return; + if (this.linkFormContainer.current && this.props.isObjectBrowserOpen) + return; + this.onClose(); + }; + + /** + * Ref handler + * @method onRef + * @param {Object} node Node + * @returns {undefined} + */ + onRef(node) { + this.input = node; + } + + linkFormContainer = React.createRef(); + + /** + * Change handler + * @method onChange + * @param {Object} value Value + * @returns {undefined} + */ + onChange(value, clear) { + let nextState = { value }; + if (!clear) { + if ( + this.state.isInvalid && + URLUtils.isUrl(URLUtils.normalizeUrl(value)) + ) { + nextState.isInvalid = false; + } + + if (isInternalURL(value)) { + nextState = { value: flattenToAppURL(value) }; + } + } + this.setState(nextState); + + if (clear) this.props.onClear(); + } + + /** + * Select item handler + * @method onSelectItem + * @param {string} e event + * @param {string} url Url + * @returns {undefined} + */ + onSelectItem = (e, url) => { + e.preventDefault(); + this.setState({ + value: url, + isInvalid: false, + }); + this.props.onChangeValue(addAppURL(url)); + }; + + /** + * Clear handler + * @method clear + * @param {Object} value Value + * @returns {undefined} + */ + clear() { + const nextState = { value: '' }; + this.setState(nextState); + + this.props.onClear(); + } + + /** + * Close handler + * @method onClose + * @returns {undefined} + */ + onClose = () => this.props.onOverrideContent(undefined); + + /** + * Keydown handler + * @method onKeyDown + * @param {Object} e Event object + * @returns {undefined} + */ + onKeyDown(e) { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + this.onSubmit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + this.onClose(); + } + } + + /** + * Submit handler + * @method onSubmit + * @returns {undefined} + */ + onSubmit() { + let { value: url, dataElement } = this.state; + + const checkedURL = URLUtils.checkAndNormalizeUrl(url); + url = checkedURL.url; + if (!checkedURL.isValid) { + this.setState({ isInvalid: true }); + return; + } + + const editorStateUrl = isInternalURL(url) ? addAppURL(url) : url; + + this.props.onChangeValue(editorStateUrl, dataElement); + this.onClose(); + } + + /** + * Render method. + * @method render + * @returns {string} Markup for the component. + */ + render() { + const { value, isInvalid, dataElement } = this.state; + const className = isInvalid + ? cx( + 'ui input editor-link', + 'input-anchorlink-theme', + 'input-anchorlink-theme-Invalid', + ) + : cx('ui input editor-link', 'input-anchorlink-theme'); + + return ( +
+ +
+ {/* INPUT LINK */} +
+ this.onChange(target.value)} + placeholder={this.props.intl.formatMessage( + messages.placeholder, + )} + onKeyDown={this.onKeyDown} + ref={this.onRef} + /> + {value.length > 0 ? ( + + + + ) : ( + + + + )} +
+ + {/* DATA-ELEMENT SELECT */} +
+ { + this.setState({ dataElement: value }); + }} + choices={this.selectOptions} + noValueOption={false} + wrapped={false} + /> +
+
+ + {/* BUTTON SUBMIT */} +
+ + + +
+
+
+ ); + } +} + +export default compose(injectIntl, withRouter, withObjectBrowser)(AddLinkForm); diff --git a/src/config/RichTextEditor/Plugins/AnchorPlugin/components/LinkButton/index.js b/src/config/RichTextEditor/Plugins/AnchorPlugin/components/LinkButton/index.js new file mode 100644 index 0000000000..d416a4d945 --- /dev/null +++ b/src/config/RichTextEditor/Plugins/AnchorPlugin/components/LinkButton/index.js @@ -0,0 +1,150 @@ +/** + * Customizzato: + * - Aggiunta gestione data-element + * - Importato AddLinkForm file customizzato + */ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable'; +import EditorUtils from '@plone/volto/components/manage/AnchorPlugin/utils/EditorUtils'; +import Icon from '@plone/volto/components/theme/Icon/Icon'; + +import AddLinkForm from 'design-comuni-plone-theme/config/RichTextEditor/Plugins/AnchorPlugin/components/LinkButton/AddLinkForm'; + +import linkSVG from '@plone/volto/icons/link.svg'; +import unlinkSVG from '@plone/volto/icons/unlink.svg'; + +/** + * Add link form class. + * @class LinkButton + * @extends Component + */ +class LinkButton extends Component { + static propTypes = { + placeholder: PropTypes.string, + theme: PropTypes.shape({}).isRequired, + ownTheme: PropTypes.shape({}).isRequired, + onRemoveLinkAtSelection: PropTypes.func.isRequired, + onOverrideContent: PropTypes.func.isRequired, + }; + + constructor(props) { + super(props); + + this.DraftEditorUtils = props.draftJsPluginsUtils.default; + this.EditorUtils = EditorUtils(props); + } + + static defaultProps = { + placeholder: '', + }; + + onMouseDown = (event) => { + event.preventDefault(); + }; + + onAddLinkClick = (e) => { + e.preventDefault(); + e.stopPropagation(); + const { ownTheme, placeholder, onOverrideContent } = this.props; + const data = + this.EditorUtils.getCurrentEntity( + this.props.getEditorState(), + )?.getData() ?? {}; + const link = data.url || ''; + const dataElement = data.dataElement || data['data-element'] || ''; + + const content = (props) => ( + {}} + onClear={() => { + this.props.setEditorState( + this.DraftEditorUtils.removeLinkAtSelection( + this.props.getEditorState(), + ), + ); + }} + onChangeValue={(url, dataElement) => { + this.props.setEditorState( + createLinkAtSelection( + this.props.draftJs, + this.props.getEditorState(), + url, + dataElement, + ), + ); + }} + /> + ); + onOverrideContent(content); + }; + + /** + * Render method. + * @method render + * @returns {string} Markup for the component. + */ + render() { + const { theme } = this.props; + const hasLinkSelected = this.EditorUtils.hasEntity( + this.props.getEditorState(), + 'LINK', + ); + const className = hasLinkSelected + ? cx(theme.button, theme.active) + : theme.button; + + return ( +
+ +
+ ); + } +} + +export default injectLazyLibs(['draftJs', 'draftJsPluginsUtils'])(LinkButton); + +function createLinkAtSelection(draftJs, editorState, url, dataElement) { + const contentState = editorState + .getCurrentContent() + .createEntity('LINK', 'MUTABLE', { url, dataElement }); + const entityKey = contentState.getLastCreatedEntityKey(); + const withLink = draftJs.RichUtils.toggleLink( + editorState, + editorState.getSelection(), + entityKey, + ); + return draftJs.EditorState.forceSelection( + withLink, + editorState.getSelection(), + ); +} diff --git a/src/config/RichTextEditor/Plugins/AnchorPlugin/index.js b/src/config/RichTextEditor/Plugins/AnchorPlugin/index.js new file mode 100644 index 0000000000..0d9ac5b3d2 --- /dev/null +++ b/src/config/RichTextEditor/Plugins/AnchorPlugin/index.js @@ -0,0 +1,88 @@ +/** + * Customizzato + * - Cambiato import dei link per poter customizzare i file + */ +import decorateComponentWithProps from 'decorate-component-with-props'; +import linkStrategy, { + matchesEntityType, +} from '@plone/volto/components/manage/AnchorPlugin/linkStrategy'; + +import DefaultLink from 'design-comuni-plone-theme/config/RichTextEditor/Plugins/AnchorPlugin/components/Link'; +import LinkButton from 'design-comuni-plone-theme/config/RichTextEditor/Plugins/AnchorPlugin/components/LinkButton'; + +function unboundRemoveEntity(editorState) { + const contentState = editorState.getCurrentContent(); + const selectionState = editorState.getSelection(); + const startKey = selectionState.getStartKey(); + const contentBlock = contentState.getBlockForKey(startKey); + const startOffset = selectionState.getStartOffset(); + const entity = contentBlock.getEntityAt(startOffset); + + if (!entity) { + return editorState; + } + + let entitySelection = null; + + contentBlock.findEntityRanges( + (character) => character.getEntity() === entity, + (start, end) => { + entitySelection = selectionState.merge({ + anchorOffset: start, + focusOffset: end, + isBackward: false, + }); + }, + ); + + const newContentState = this.Modifier.applyEntity( + contentState, + entitySelection, + null, + ); + + const newEditorState = this.EditorState.push( + editorState, + newContentState, + 'apply-entity', + ); + + return newEditorState; +} + +export default (config = {}) => { + // ToDo: Get rif of the remainings of having the original CSS modules + const defaultTheme = {}; + + const { + theme = defaultTheme, + placeholder, + Link, + linkTarget, + libraries, + } = config; + + const removeEntity = unboundRemoveEntity.bind(libraries); + + return { + decorators: [ + { + strategy: linkStrategy, + matchesEntityType, + component: + Link || + decorateComponentWithProps(DefaultLink, { + className: 'link-anchorlink-theme', + target: linkTarget, + }), + }, + ], + + LinkButton: decorateComponentWithProps(LinkButton, { + ownTheme: theme, + placeholder, + onRemoveLinkAtSelection: (setEditorState, getEditorState) => + setEditorState(removeEntity(getEditorState())), + }), + }; +}; diff --git a/src/config/RichTextEditor/config.js b/src/config/RichTextEditor/config.js index c3ac60e905..6d0b7e673a 100644 --- a/src/config/RichTextEditor/config.js +++ b/src/config/RichTextEditor/config.js @@ -1,5 +1,4 @@ import React from 'react'; - import Styles from '@plone/volto/config/RichTextEditor/Styles'; import ToHTMLRenderers from '@plone/volto/config/RichTextEditor/ToHTML'; @@ -15,6 +14,8 @@ import CalloutsButton from 'design-comuni-plone-theme/config/RichTextEditor/Tool import ButtonsButton from 'design-comuni-plone-theme/config/RichTextEditor/ToolbarButtons/ButtonsButton'; import TextSizeButton from 'design-comuni-plone-theme/config/RichTextEditor/ToolbarButtons/TextSizeButton'; +import LinkEntity from 'design-comuni-plone-theme/config/RichTextEditor/LinkEntity'; + const ItaliaRichTextEditorPlugins = (props) => []; const ItaliaRichTextEditorInlineToolbarButtons = (props, plugins) => { const linkPlugin = plugins.filter((p) => p.LinkButton != null)[0]; @@ -214,6 +215,16 @@ export default function applyConfig(config) { // TODO: rimuovere questa customizzazione quando sistemano https://github.com/plone/volto/issues/1601 config.settings.richtextViewSettings.ToHTMLRenderers = { ...config.settings.richtextViewSettings.ToHTMLRenderers, + entities: { + ...config.settings.richtextViewSettings.ToHTMLRenderers.entities, + LINK: (children, props, other) => { + return ( + + {children} + + ); + }, + }, blocks: { ...ToHTMLRenderers.blocks, ...ItaliaBlocksHtmlRenderers, diff --git a/src/config/Widgets/widgets.js b/src/config/Widgets/widgets.js index c71357d63a..762ba96e01 100644 --- a/src/config/Widgets/widgets.js +++ b/src/config/Widgets/widgets.js @@ -1,7 +1,7 @@ import React from 'react'; import CharCounterDescriptionWidget from 'design-comuni-plone-theme/components/ItaliaTheme/manage/Widgets/CharCounterDescriptionWidget'; import { DatetimeWidget } from '@plone/volto/config/Widgets'; -import { ArrayWidget } from '@plone/volto/components'; +import { ArrayWidget, WysiwygWidget } from '@plone/volto/components'; import { MultilingualWidget } from 'volto-multilingual-widget'; import IconWidget from 'design-comuni-plone-theme/components/ItaliaTheme/manage/Widgets/IconWidget'; import SubsiteSocialLinksWidget from 'design-comuni-plone-theme/components/ItaliaTheme/manage/Widgets/SubsiteSocialLinksWidget'; @@ -58,7 +58,7 @@ const getItaliaWidgets = (config) => { }, widget: { ...config.widgets.widget, - // richtext: TinymceWidget + richtext: WysiwygWidget, color_list: ColorListWidget, path_filters: PathFiltersWidget, location_filter: LocationFiltersWidget, diff --git a/src/customizations/volto/config/RichTextEditor/Plugins.jsx b/src/customizations/volto/config/RichTextEditor/Plugins.jsx new file mode 100644 index 0000000000..4cbb573d1a --- /dev/null +++ b/src/customizations/volto/config/RichTextEditor/Plugins.jsx @@ -0,0 +1,64 @@ +/** + * Customizzato: + * - cambiato l'import di AnchorPlugin per poterlo customizzare + */ +import createLinkPlugin from '@italia/config/RichTextEditor/Plugins/AnchorPlugin'; + +import Styles from '@plone/volto/config/RichTextEditor/Styles'; + +const breakOutOptions = { + doubleBreakoutBlocks: [ + 'unordered-list-item', + 'ordered-list-item', + 'code-block', + ], + breakoutBlocks: [ + 'header-one', + 'header-two', + 'header-three', + 'highlight', + 'blockquote', + 'callout', + ], +}; + +//const linkDetectionPlugin = createLinkDetectionPlugin(); + +const plugins = (props) => { + const { draftJsInlineToolbarPlugin, draftJsBlockBreakoutPlugin } = props; + const { Separator } = draftJsInlineToolbarPlugin; + const blockBreakoutPlugin = draftJsBlockBreakoutPlugin.default( + breakOutOptions, + ); + + const linkPlugin = createLinkPlugin({ libraries: props }); + + const buttons = Styles(props); + const { + BoldButton, + ItalicButton, + HeadlineTwoButton, + HeadlineThreeButton, + UnorderedListButton, + OrderedListButton, + BlockquoteButton, + CalloutButton, + } = buttons; + + const inlineToolbarButtons = [ + BoldButton, + ItalicButton, + linkPlugin.LinkButton, + Separator, + HeadlineTwoButton, + HeadlineThreeButton, + UnorderedListButton, + OrderedListButton, + BlockquoteButton, + CalloutButton, + ]; + + return { inlineToolbarButtons, plugins: [linkPlugin, blockBreakoutPlugin] }; //linkDetectionPlugin +}; + +export default plugins; diff --git a/theme/_cms-ui.scss b/theme/_cms-ui.scss index 7193a219ab..2365e4e2b3 100644 --- a/theme/_cms-ui.scss +++ b/theme/_cms-ui.scss @@ -118,6 +118,78 @@ body.cms-ui { } } + // Link button and tooltip + .link-form-container { + display: flex; + padding: 0.3rem 0; + + .field { + display: flex; + + .wrap-field { + display: flex; + align-items: center; + + .input-anchorlink-theme input:focus, + .ui.icon.button:focus, + .ui.basic.button:focus { + outline: solid 2px $focus-outline-color; + border-radius: 0; + } + + .cancel .icon { + fill: #e40166 !important; + } + } + + input#field-link { + color: #444; + font-weight: normal; + height: auto; + } + + #field-data-element-select { + min-width: 220px; + width: 100%; + height: 34px; + font-family: 'Poppins', 'Helvetica Neue', Arial, Helvetica, sans-serif; + font-size: 0.8rem; + position: relative; + font-weight: normal; + font-style: normal; + padding: 0 7px 0 12px; + margin-top: 0.2rem; + background-color: transparent; + color: #444; + + .react-select__control { + min-height: 0; + background-color: #fafafa; + + .react-select__single-value { + color: #444; + font-weight: normal; + } + + .react-select__placeholder { + opacity: 0.4; + } + } + } + + .wrapper-submit { + display: flex; + justify-content: flex-end; + padding-left: 0.8rem; + + .ui.basic.button:focus { + outline: solid 2px $focus-outline-color; + border-radius: 0; + } + } + } + } + .skiplinks { position: absolute; top: -100%;