diff --git a/client/src/components/Draftail/index.js b/client/src/components/Draftail/index.js index 6cbdcdef7..d9bf0ab7c 100644 --- a/client/src/components/Draftail/index.js +++ b/client/src/components/Draftail/index.js @@ -11,10 +11,7 @@ import Document from './decorators/Document'; import ImageBlock from './blocks/ImageBlock'; import EmbedBlock from './blocks/EmbedBlock'; -import LinkSource from './sources/LinkSource'; -import DocumentSource from './sources/DocumentSource'; -import ImageSource from './sources/ImageSource'; -import EmbedSource from './sources/EmbedSource'; +import ModalWorkflowSource from './sources/ModalWorkflowSource'; import registry from './registry'; @@ -90,10 +87,7 @@ export const initEditor = (fieldName, options = {}) => { }; registry.registerSources({ - LinkSource, - DocumentSource, - ImageSource, - EmbedSource, + ModalWorkflowSource, }); registry.registerDecorators({ Link, diff --git a/client/src/components/Draftail/sources/DocumentSource.js b/client/src/components/Draftail/sources/DocumentSource.js deleted file mode 100644 index 13477e7c4..000000000 --- a/client/src/components/Draftail/sources/DocumentSource.js +++ /dev/null @@ -1,42 +0,0 @@ -import ModalSource from './ModalSource'; - -import { STRINGS } from '../../../config/wagtailConfig'; - -const $ = global.jQuery; - -class DocumentSource extends ModalSource { - constructor(props) { - super(props); - this.parseData = this.parseData.bind(this); - } - - parseData(data) { - this.onConfirm({ - id: data.id, - url: data.url, - }, data.title); - } - - componentDidMount() { - const { onClose } = this.props; - const documentChooser = global.chooserUrls.documentChooser; - const url = documentChooser; - - $(document.body).on('hidden.bs.modal', this.onClose); - - // eslint-disable-next-line new-cap - window.ModalWorkflow({ - url, - responses: { - documentChosen: this.parseData, - }, - onError: () => { - // eslint-disable-next-line no-alert - window.alert(STRINGS.SERVER_ERROR); - onClose(); - }, - }); - } -} - -export default DocumentSource; diff --git a/client/src/components/Draftail/sources/EmbedSource.js b/client/src/components/Draftail/sources/EmbedSource.js deleted file mode 100644 index 626622af7..000000000 --- a/client/src/components/Draftail/sources/EmbedSource.js +++ /dev/null @@ -1,44 +0,0 @@ -import ModalSource from './ModalSource'; - -import { STRINGS } from '../../../config/wagtailConfig'; - -const $ = global.jQuery; - -class EmbedSource extends ModalSource { - constructor(props) { - super(props); - this.parseData = this.parseData.bind(this); - } - - parseData(html, embed) { - this.onConfirmAtomicBlock({ - embedType: embed.embedType, - url: embed.url, - providerName: embed.providerName, - authorName: embed.authorName, - thumbnail: embed.thumbnail, - title: embed.title, - }); - } - - componentDidMount() { - const { onClose } = this.props; - - $(document.body).on('hidden.bs.modal', this.onClose); - - // eslint-disable-next-line new-cap - window.ModalWorkflow({ - url: global.chooserUrls.embedsChooser, - responses: { - embedChosen: this.parseData, - }, - onError: () => { - // eslint-disable-next-line no-alert - window.alert(STRINGS.SERVER_ERROR); - onClose(); - }, - }); - } -} - -export default EmbedSource; diff --git a/client/src/components/Draftail/sources/ImageSource.js b/client/src/components/Draftail/sources/ImageSource.js deleted file mode 100644 index 9b3e44bde..000000000 --- a/client/src/components/Draftail/sources/ImageSource.js +++ /dev/null @@ -1,43 +0,0 @@ -import ModalSource from './ModalSource'; - -import { STRINGS } from '../../../config/wagtailConfig'; - -const $ = global.jQuery; - -class ImageSource extends ModalSource { - constructor(props) { - super(props); - this.parseData = this.parseData.bind(this); - } - - parseData(imageData) { - this.onConfirmAtomicBlock({ - id: imageData.id, - src: imageData.preview.url, - alt: imageData.alt, - format: imageData.format, - }); - } - - componentDidMount() { - const { onClose } = this.props; - - const imageChooser = global.chooserUrls.imageChooser; - $(document.body).on('hidden.bs.modal', this.onClose); - - // eslint-disable-next-line new-cap - window.ModalWorkflow({ - url: imageChooser + '?select_format=true', - responses: { - imageChosen: this.parseData, - }, - onError: () => { - // eslint-disable-next-line no-alert - window.alert(STRINGS.SERVER_ERROR); - onClose(); - }, - }); - } -} - -export default ImageSource; diff --git a/client/src/components/Draftail/sources/LinkSource.js b/client/src/components/Draftail/sources/LinkSource.js deleted file mode 100644 index e9fb3d69c..000000000 --- a/client/src/components/Draftail/sources/LinkSource.js +++ /dev/null @@ -1,93 +0,0 @@ -import ModalSource from './ModalSource'; - -import { STRINGS } from '../../../config/wagtailConfig'; - -const $ = global.jQuery; - -// Plaster over Wagtail internals. -const buildInitialUrl = (entity, openAtParentId, canChooseRoot, pageTypes) => { - // We can't destructure from the window object yet - const pageChooser = global.chooserUrls.pageChooser; - const emailLinkChooser = global.chooserUrls.emailLinkChooser; - const externalLinkChooser = global.chooserUrls.externalLinkChooser; - let url = pageChooser; - - if (openAtParentId) { - url = `${url}${openAtParentId}/`; - } - - const urlParams = { - page_type: pageTypes.join(','), - allow_external_link: true, - allow_email_link: true, - can_choose_root: canChooseRoot ? 'true' : 'false', - // This does not initialise the modal with the currently selected text. - // This will need to be implemented in the future. - // See https://github.com/jpuri/draftjs-utils/blob/e81c0ae19c3b0fdef7e0c1b70d924398956be126/js/block.js#L106. - link_text: '', - }; - - if (entity) { - const data = entity.getData(); - - // urlParams.link_text = data.title; - - if (data.id) { - url = ` ${pageChooser}${data.parentId}/`; - } else if (data.url.startsWith('mailto:')) { - url = emailLinkChooser; - urlParams.link_url = data.url.replace('mailto:', ''); - } else { - url = externalLinkChooser; - urlParams.link_url = data.url; - } - } - - return { url, urlParams }; -}; - -class LinkSource extends ModalSource { - constructor(props) { - super(props); - this.parseData = this.parseData.bind(this); - } - - parseData(data) { - const parsedData = { - url: data.url, - }; - - if (data.id) { - parsedData.id = data.id; - parsedData.parentId = data.parentId; - } - - this.onConfirm(parsedData, data.title, data.prefer_this_title_as_link_text); - } - - componentDidMount() { - const { entity, onClose } = this.props; - const openAtParentId = false; - const canChooseRoot = false; - const pageTypes = ['wagtailcore.page']; - const { url, urlParams } = buildInitialUrl(entity, openAtParentId, canChooseRoot, pageTypes); - - $(document.body).on('hidden.bs.modal', this.onClose); - - // eslint-disable-next-line new-cap - window.ModalWorkflow({ - url, - urlParams, - responses: { - pageChosen: this.parseData, - }, - onError: () => { - // eslint-disable-next-line no-alert - window.alert(STRINGS.SERVER_ERROR); - onClose(); - }, - }); - } -} - -export default LinkSource; diff --git a/client/src/components/Draftail/sources/ModalSource.js b/client/src/components/Draftail/sources/ModalSource.js deleted file mode 100644 index c68ecf39b..000000000 --- a/client/src/components/Draftail/sources/ModalSource.js +++ /dev/null @@ -1,73 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { AtomicBlockUtils, RichUtils, Modifier, EditorState } from 'draft-js'; - -const $ = global.jQuery; - -class ModalSource extends React.Component { - constructor(props) { - super(props); - this.onClose = this.onClose.bind(this); - this.onConfirm = this.onConfirm.bind(this); - this.onConfirmAtomicBlock = this.onConfirmAtomicBlock.bind(this); - } - - componentWillUnmount() { - $(document.body).off('hidden.bs.modal', this.onClose); - } - - onConfirm(data, text = null, overrideText = false) { - const { editorState, entityType, onComplete } = this.props; - const contentState = editorState.getCurrentContent(); - const contentStateWithEntity = contentState.createEntity(entityType.type, 'MUTABLE', data); - const entityKey = contentStateWithEntity.getLastCreatedEntityKey(); - const selection = editorState.getSelection(); - const shouldOverrideText = overrideText || selection.isCollapsed(); - let nextState; - - if (shouldOverrideText) { - const newContent = Modifier.replaceText(editorState.getCurrentContent(), selection, text, null, entityKey); - nextState = EditorState.push(editorState, newContent, 'insert-characters'); - } else { - nextState = RichUtils.toggleLink(editorState, selection, entityKey); - } - - onComplete(nextState); - } - - onConfirmAtomicBlock(data) { - const { editorState, entityType, onComplete } = this.props; - const contentState = editorState.getCurrentContent(); - const contentStateWithEntity = contentState.createEntity(entityType.type, 'IMMUTABLE', data); - const entityKey = contentStateWithEntity.getLastCreatedEntityKey(); - const nextState = AtomicBlockUtils.insertAtomicBlock(editorState, entityKey, ' '); - - onComplete(nextState); - } - - onClose(e) { - const { onClose } = this.props; - e.preventDefault(); - - onClose(); - } - - render() { - return null; - } -} - -ModalSource.propTypes = { - editorState: PropTypes.object.isRequired, - entityType: PropTypes.object.isRequired, - // eslint-disable-next-line - entity: PropTypes.object, - onComplete: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - -ModalSource.defaultProps = { - entity: null, -}; - -export default ModalSource; diff --git a/client/src/components/Draftail/sources/ModalWorkflowSource.js b/client/src/components/Draftail/sources/ModalWorkflowSource.js new file mode 100644 index 000000000..3311fc33f --- /dev/null +++ b/client/src/components/Draftail/sources/ModalWorkflowSource.js @@ -0,0 +1,201 @@ +import PropTypes from 'prop-types'; +import { Component } from 'react'; +import { AtomicBlockUtils, Modifier, RichUtils, EditorState } from 'draft-js'; +import { ENTITY_TYPE } from 'draftail'; + +import { STRINGS } from '../../../config/wagtailConfig'; + +const $ = global.jQuery; + +const EMBED = 'EMBED'; +const DOCUMENT = 'DOCUMENT'; + +const MUTABILITY = {}; +MUTABILITY[ENTITY_TYPE.LINK] = 'MUTABLE'; +MUTABILITY[DOCUMENT] = 'MUTABLE'; +MUTABILITY[ENTITY_TYPE.IMAGE] = 'IMMUTABLE'; +MUTABILITY[EMBED] = 'IMMUTABLE'; + +const getChooserConfig = (entityType, entity) => { + const chooserURL = {}; + chooserURL[ENTITY_TYPE.IMAGE] = `${global.chooserUrls.imageChooser}?select_format=true`; + chooserURL[EMBED] = global.chooserUrls.embedsChooser; + chooserURL[ENTITY_TYPE.LINK] = global.chooserUrls.pageChooser; + chooserURL[DOCUMENT] = global.chooserUrls.documentChooser; + + let url = chooserURL[entityType.type]; + let urlParams = {}; + + if (entityType.type === ENTITY_TYPE.LINK) { + urlParams = { + page_type: 'wagtailcore.page', + allow_external_link: true, + allow_email_link: true, + can_choose_root: 'false', + // This does not initialise the modal with the currently selected text. + // This will need to be implemented in the future. + // See https://github.com/jpuri/draftjs-utils/blob/e81c0ae19c3b0fdef7e0c1b70d924398956be126/js/block.js#L106. + link_text: '', + }; + + if (entity) { + const data = entity.getData(); + + if (data.id) { + url = `${global.chooserUrls.pageChooser}${data.parentId}/`; + } else if (data.url.startsWith('mailto:')) { + url = global.chooserUrls.emailLinkChooser; + urlParams.link_url = data.url.replace('mailto:', ''); + } else { + url = global.chooserUrls.externalLinkChooser; + urlParams.link_url = data.url; + } + } + } + + return { + url, + urlParams, + }; +}; + +const filterEntityData = (entityType, data) => { + switch (entityType.type) { + case ENTITY_TYPE.IMAGE: + return { + id: data.id, + src: data.preview.url, + alt: data.alt, + format: data.format, + }; + case EMBED: + return { + embedType: data.embedType, + url: data.url, + providerName: data.providerName, + authorName: data.authorName, + thumbnail: data.thumbnail, + title: data.title, + }; + case ENTITY_TYPE.LINK: + if (data.id) { + return { + url: data.url, + id: data.id, + parentId: data.parentId, + }; + } + + return { + url: data.url, + }; + case DOCUMENT: + return { + url: data.url, + id: data.id, + }; + default: + return {}; + } +}; + +/** + * Interfaces with Wagtail's ModalWorkflow to open the chooser, + * and create new content in Draft.js based on the data. + */ +class ModalWorkflowSource extends Component { + constructor(props) { + super(props); + + this.onChosen = this.onChosen.bind(this); + this.onClose = this.onClose.bind(this); + } + + componentDidMount() { + const { onClose, entityType, entity } = this.props; + const { url, urlParams } = getChooserConfig(entityType, entity); + + $(document.body).on('hidden.bs.modal', this.onClose); + + // eslint-disable-next-line new-cap + global.ModalWorkflow({ + url, + urlParams, + responses: { + imageChosen: this.onChosen, + // Discard the first parameter (HTML) to only transmit the data. + embedChosen: (_, data) => this.onChosen(data), + documentChosen: this.onChosen, + pageChosen: this.onChosen, + }, + onError: () => { + // eslint-disable-next-line no-alert + window.alert(STRINGS.SERVER_ERROR); + onClose(); + }, + }); + } + + componentWillUnmount() { + $(document.body).off('hidden.bs.modal', this.onClose); + } + + onChosen(data) { + const { editorState, entityType, onComplete } = this.props; + const content = editorState.getCurrentContent(); + const selection = editorState.getSelection(); + + const entityData = filterEntityData(entityType, data); + const mutability = MUTABILITY[entityType.type]; + const contentWithEntity = content.createEntity(entityType.type, mutability, entityData); + const entityKey = contentWithEntity.getLastCreatedEntityKey(); + + let nextState; + + if (entityType.block) { + // Only supports adding entities at the moment, not editing existing ones. + // See https://github.com/springload/draftail/blob/cdc8988fe2e3ac32374317f535a5338ab97e8637/examples/sources/ImageSource.js#L44-L62. + // See https://github.com/springload/draftail/blob/cdc8988fe2e3ac32374317f535a5338ab97e8637/examples/sources/EmbedSource.js#L64-L91 + nextState = AtomicBlockUtils.insertAtomicBlock(editorState, entityKey, ' '); + } else { + // Replace text if the chooser demands it, or if there is no selected text in the first place. + const shouldReplaceText = data.prefer_this_title_as_link_text || selection.isCollapsed(); + + if (shouldReplaceText) { + // If there is a title attribute, use it. Otherwise we inject the URL. + const newText = data.title || data.url; + const newContent = Modifier.replaceText(content, selection, newText, null, entityKey); + nextState = EditorState.push(editorState, newContent, 'insert-characters'); + } else { + nextState = RichUtils.toggleLink(editorState, selection, entityKey); + } + } + + onComplete(nextState); + } + + onClose(e) { + const { onClose } = this.props; + e.preventDefault(); + + onClose(); + } + + render() { + return null; + } +} + +ModalWorkflowSource.propTypes = { + editorState: PropTypes.object.isRequired, + entityType: PropTypes.object.isRequired, + entity: PropTypes.object, + onComplete: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; + +ModalWorkflowSource.defaultProps = { + entity: null, +}; + +export default ModalWorkflowSource; diff --git a/wagtail/admin/wagtail_hooks.py b/wagtail/admin/wagtail_hooks.py index e79d727e3..ca2898aa7 100644 --- a/wagtail/admin/wagtail_hooks.py +++ b/wagtail/admin/wagtail_hooks.py @@ -432,7 +432,7 @@ def register_core_features(features): 'type': ENTITY_TYPES.LINK, 'icon': 'link', 'description': str(_('Link')), - 'source': 'LinkSource', + 'source': 'ModalWorkflowSource', 'decorator': 'Link', # We want to enforce constraints on which links can be pasted into rich text. # Keep only the attributes Wagtail needs. diff --git a/wagtail/documents/wagtail_hooks.py b/wagtail/documents/wagtail_hooks.py index d9eebd212..0c0065b83 100644 --- a/wagtail/documents/wagtail_hooks.py +++ b/wagtail/documents/wagtail_hooks.py @@ -92,7 +92,7 @@ def register_document_feature(features): 'type': ENTITY_TYPES.DOCUMENT, 'icon': 'doc-full', 'description': str(_('Document')), - 'source': 'DocumentSource', + 'source': 'ModalWorkflowSource', 'decorator': 'Document', }) ) diff --git a/wagtail/embeds/wagtail_hooks.py b/wagtail/embeds/wagtail_hooks.py index bc7677d4b..083b7d721 100644 --- a/wagtail/embeds/wagtail_hooks.py +++ b/wagtail/embeds/wagtail_hooks.py @@ -57,7 +57,7 @@ def register_embed_feature(features): 'type': ENTITY_TYPES.EMBED, 'icon': 'media', 'description': str(_('Embed')), - 'source': 'EmbedSource', + 'source': 'ModalWorkflowSource', 'block': 'EmbedBlock', }) ) diff --git a/wagtail/images/wagtail_hooks.py b/wagtail/images/wagtail_hooks.py index 14a1c53b3..d6ff4c329 100644 --- a/wagtail/images/wagtail_hooks.py +++ b/wagtail/images/wagtail_hooks.py @@ -92,7 +92,7 @@ def register_image_feature(features): 'type': ENTITY_TYPES.IMAGE, 'icon': 'image', 'description': str(_('Image')), - 'source': 'ImageSource', + 'source': 'ModalWorkflowSource', 'block': 'ImageBlock', # We do not want users to be able to copy-paste hotlinked images into rich text. # Keep only the attributes Wagtail needs.