Refactor Draftail sources to single component

This commit is contained in:
Thibaud Colas 2018-01-17 18:43:05 +02:00
parent 19a6189d57
commit f8b99045a7
11 changed files with 207 additions and 307 deletions

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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.

View file

@ -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',
})
)

View file

@ -57,7 +57,7 @@ def register_embed_feature(features):
'type': ENTITY_TYPES.EMBED,
'icon': 'media',
'description': str(_('Embed')),
'source': 'EmbedSource',
'source': 'ModalWorkflowSource',
'block': 'EmbedBlock',
})
)

View file

@ -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.