kopia lustrzana https://github.com/wagtail/wagtail
Add Draftail component from https://github.com/springload/wagtaildraftail/
rodzic
00009252ac
commit
c163d93b72
client/src/components/Draftail
|
@ -0,0 +1,29 @@
|
|||
@import '../../../../node_modules/draftail/dist/draftail';
|
||||
|
||||
// Give each block element eg. paragraph some spacing so we don't end up
|
||||
// with empty paragraphs in code when user double enters because they think
|
||||
// there are no paragraphs.
|
||||
div[data-block="true"] {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
/* Override Wagtail styles. */
|
||||
.DraftEditor-editorContainer h2 {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Override Wagtail styles. */
|
||||
.DraftEditor-editorContainer h3,
|
||||
.DraftEditor-editorContainer h4,
|
||||
.DraftEditor-editorContainer h5,
|
||||
.DraftEditor-editorContainer h6 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Get rid of cray number 6 that appears above h2 elements in draft editor. */
|
||||
.object.collapsible .DraftEditor-editorContainer h2:before,
|
||||
.object.collapsible .DraftEditor-editorContainer h2 label:before {
|
||||
// TODO Assess whether !important is necessary.
|
||||
// stylelint-disable declaration-no-important
|
||||
content: none !important;
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Icon } from 'draftail';
|
||||
|
||||
const Document = ({ entityKey, contentState, children }) => {
|
||||
const { title } = contentState.getEntity(entityKey).getData();
|
||||
return (
|
||||
<span data-tooltip={entityKey} className="RichEditor-link" title={title}>
|
||||
<Icon name="icon-doc-full" />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
Document.propTypes = {
|
||||
entityKey: PropTypes.string.isRequired,
|
||||
contentState: PropTypes.object.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default Document;
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { convertFromHTML, ContentState } from 'draft-js';
|
||||
import Document from './Document';
|
||||
|
||||
describe('Document', () => {
|
||||
it('exists', () => {
|
||||
expect(Document).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
const contentBlocks = convertFromHTML('<h1>aaaaaaaaaa</h1>');
|
||||
const contentState = ContentState.createFromBlockArray(contentBlocks);
|
||||
const contentStateWithEntity = contentState.createEntity('DOCUMENT', 'MUTABLE', { title: 'Test title' });
|
||||
const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
|
||||
expect(shallow((
|
||||
<Document
|
||||
entityKey={entityKey}
|
||||
contentState={contentStateWithEntity}
|
||||
>
|
||||
<span>Test children</span>
|
||||
</Document>
|
||||
))).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Icon } from 'draftail';
|
||||
|
||||
const Link = ({ entityKey, contentState, children }) => {
|
||||
const { url } = contentState.getEntity(entityKey).getData();
|
||||
|
||||
return (
|
||||
<span data-tooltip={entityKey} className="RichEditor-link">
|
||||
<Icon name={`icon-${url.indexOf('mailto:') !== -1 ? 'mail' : 'link'}`} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
Link.propTypes = {
|
||||
entityKey: PropTypes.string.isRequired,
|
||||
contentState: PropTypes.object.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default Link;
|
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { convertFromHTML, ContentState } from 'draft-js';
|
||||
import Link from './Link';
|
||||
|
||||
describe('Link', () => {
|
||||
it('exists', () => {
|
||||
expect(Link).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
const contentBlocks = convertFromHTML('<h1>aaaaaaaaaa</h1>');
|
||||
const contentState = ContentState.createFromBlockArray(contentBlocks);
|
||||
const contentStateWithEntity = contentState.createEntity('LINK', 'MUTABLE', { url: 'http://example.com/' });
|
||||
const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
|
||||
expect(shallow((
|
||||
<Link
|
||||
entityKey={entityKey}
|
||||
contentState={contentStateWithEntity}
|
||||
>
|
||||
<span>Test children</span>
|
||||
</Link>
|
||||
))).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders email', () => {
|
||||
const contentBlocks = convertFromHTML('<h1>aaaaaaaaaa</h1>');
|
||||
const contentState = ContentState.createFromBlockArray(contentBlocks);
|
||||
const contentStateWithEntity = contentState.createEntity('LINK', 'MUTABLE', { url: 'mailto:test@example.com' });
|
||||
const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
|
||||
expect(shallow((
|
||||
<Link
|
||||
entityKey={entityKey}
|
||||
contentState={contentStateWithEntity}
|
||||
>
|
||||
<span>Test children</span>
|
||||
</Link>
|
||||
))).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,18 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Document renders 1`] = `
|
||||
<span
|
||||
className="RichEditor-link"
|
||||
data-tooltip="1"
|
||||
title="Test title"
|
||||
>
|
||||
<Icon
|
||||
className=""
|
||||
name="icon-doc-full"
|
||||
title={null}
|
||||
/>
|
||||
<span>
|
||||
Test children
|
||||
</span>
|
||||
</span>
|
||||
`;
|
|
@ -0,0 +1,33 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Link renders 1`] = `
|
||||
<span
|
||||
className="RichEditor-link"
|
||||
data-tooltip="1"
|
||||
>
|
||||
<Icon
|
||||
className=""
|
||||
name="icon-link"
|
||||
title={null}
|
||||
/>
|
||||
<span>
|
||||
Test children
|
||||
</span>
|
||||
</span>
|
||||
`;
|
||||
|
||||
exports[`Link renders email 1`] = `
|
||||
<span
|
||||
className="RichEditor-link"
|
||||
data-tooltip="2"
|
||||
>
|
||||
<Icon
|
||||
className=""
|
||||
name="icon-mail"
|
||||
title={null}
|
||||
/>
|
||||
<span>
|
||||
Test children
|
||||
</span>
|
||||
</span>
|
||||
`;
|
|
@ -0,0 +1,10 @@
|
|||
import Link from './Link';
|
||||
import Document from './Document';
|
||||
|
||||
/**
|
||||
* Mapping object from name to component.
|
||||
*/
|
||||
export default {
|
||||
Link,
|
||||
Document,
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import entities from './index';
|
||||
|
||||
describe('entities', () => {
|
||||
it('exists', () => {
|
||||
expect(entities).toBeInstanceOf(Object);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,56 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import DraftailEditor from 'draftail';
|
||||
|
||||
import decorators from './decorators';
|
||||
import sources from './sources';
|
||||
import registry from './registry';
|
||||
|
||||
export const initEditor = (fieldName, options = {}) => {
|
||||
const field = document.querySelector(`[name="${fieldName}"]`);
|
||||
const editorWrapper = document.createElement('div');
|
||||
field.parentNode.appendChild(editorWrapper);
|
||||
|
||||
const serialiseInputValue = (rawContentState) => {
|
||||
// TODO Remove default {} when finishing https://github.com/springload/wagtaildraftail/issues/32.
|
||||
field.value = JSON.stringify(rawContentState || {});
|
||||
};
|
||||
|
||||
if (options.entityTypes) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
options.entityTypes = options.entityTypes.map(entity => Object.assign(entity, {
|
||||
source: registry.getSource(entity.source),
|
||||
strategy: registry.getStrategy(entity.type) || null,
|
||||
decorator: registry.getDecorator(entity.decorator),
|
||||
}));
|
||||
}
|
||||
|
||||
const fieldValue = JSON.parse(field.value);
|
||||
// TODO Remove default null when finishing https://github.com/springload/wagtaildraftail/issues/32.
|
||||
const rawContentState = fieldValue && Object.keys(fieldValue).length === 0 ? null : fieldValue;
|
||||
|
||||
const editor = (
|
||||
<DraftailEditor
|
||||
rawContentState={rawContentState}
|
||||
onSave={serialiseInputValue}
|
||||
placeholder="Write here…"
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
|
||||
ReactDOM.render(editor, editorWrapper);
|
||||
};
|
||||
|
||||
// Register default Decorators and Sources
|
||||
registry.registerDecorators(decorators);
|
||||
registry.registerSources(sources);
|
||||
|
||||
const draftail = Object.assign({
|
||||
initEditor: initEditor,
|
||||
// Expose basic React methods for basic needs
|
||||
// TODO Expose React as global as part of Wagtail vendor file instead of doing this.
|
||||
// createClass: React.createClass,
|
||||
// createElement: React.createElement,
|
||||
}, registry);
|
||||
|
||||
export default draftail;
|
|
@ -0,0 +1,32 @@
|
|||
const registry = {
|
||||
decorators: {},
|
||||
sources: {},
|
||||
strategies: {},
|
||||
};
|
||||
|
||||
const registerDecorators = (decorators) => {
|
||||
Object.assign(registry.decorators, decorators);
|
||||
};
|
||||
|
||||
const getDecorator = name => registry.decorators[name];
|
||||
|
||||
const registerSources = (sources) => {
|
||||
Object.assign(registry.sources, sources);
|
||||
};
|
||||
|
||||
const getSource = name => registry.sources[name];
|
||||
|
||||
const registerStrategies = (strategies) => {
|
||||
Object.assign(registry.strategies, strategies);
|
||||
};
|
||||
|
||||
const getStrategy = name => registry.strategies[name];
|
||||
|
||||
export default {
|
||||
registerDecorators,
|
||||
getDecorator,
|
||||
registerSources,
|
||||
getSource,
|
||||
registerStrategies,
|
||||
getStrategy,
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
import ModalSource from './ModalSource';
|
||||
|
||||
const $ = global.jQuery;
|
||||
|
||||
class DocumentSource extends ModalSource {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.parseData = this.parseData.bind(this);
|
||||
}
|
||||
|
||||
parseData(documentData) {
|
||||
this.onConfirm(documentData);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { entity } = this.props;
|
||||
const documentChooser = global.chooserUrls.documentChooser;
|
||||
const url = documentChooser;
|
||||
|
||||
$(document.body).on('hidden.bs.modal', this.onClose);
|
||||
|
||||
// TODO: wagtail should support passing params to this endpoint.
|
||||
if (entity) {
|
||||
// const entityData = entity.getData();
|
||||
// console.log(entityData);
|
||||
// if (entityData.title) {
|
||||
// url = url + `?q=${entityData.title}`
|
||||
// }
|
||||
}
|
||||
|
||||
global.ModalWorkflow({
|
||||
url,
|
||||
responses: {
|
||||
documentChosen: this.parseData,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default DocumentSource;
|
|
@ -0,0 +1,36 @@
|
|||
import ModalSource from './ModalSource';
|
||||
|
||||
const $ = global.jQuery;
|
||||
|
||||
class EmbedSource extends ModalSource {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.parseData = this.parseData.bind(this);
|
||||
}
|
||||
|
||||
parseData(html) {
|
||||
const embed = $.parseHTML(html)[0];
|
||||
|
||||
this.onConfirmAtomicBlock({
|
||||
embedType: embed.getAttribute('data-embedtype'),
|
||||
url: embed.getAttribute('data-url'),
|
||||
providerName: embed.getAttribute('data-provider-name'),
|
||||
authorName: embed.getAttribute('data-author-name'),
|
||||
thumbnail: embed.getAttribute('data-thumbnail-url'),
|
||||
title: embed.getAttribute('data-title'),
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
$(document.body).on('hidden.bs.modal', this.onClose);
|
||||
|
||||
global.ModalWorkflow({
|
||||
url: global.chooserUrls.embedsChooser,
|
||||
responses: {
|
||||
embedChosen: this.parseData,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default EmbedSource;
|
|
@ -0,0 +1,30 @@
|
|||
import ModalSource from './ModalSource';
|
||||
|
||||
const $ = global.jQuery;
|
||||
|
||||
class ImageSource extends ModalSource {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.parseData = this.parseData.bind(this);
|
||||
}
|
||||
|
||||
parseData(imageData) {
|
||||
this.onConfirmAtomicBlock(Object.assign({}, imageData, {
|
||||
src: imageData.preview.url,
|
||||
}));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const imageChooser = global.chooserUrls.imageChooser;
|
||||
$(document.body).on('hidden.bs.modal', this.onClose);
|
||||
|
||||
global.ModalWorkflow({
|
||||
url: imageChooser,
|
||||
responses: {
|
||||
imageChosen: this.parseData,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageSource;
|
|
@ -0,0 +1,100 @@
|
|||
import ModalSource from './ModalSource';
|
||||
|
||||
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',
|
||||
link_text: '',
|
||||
};
|
||||
|
||||
if (entity) {
|
||||
let data = entity.getData();
|
||||
|
||||
if (typeof data === 'string') {
|
||||
data = { url: data, linkType: 'external', title: '' };
|
||||
}
|
||||
|
||||
urlParams.link_text = data.title;
|
||||
|
||||
switch (data.linkType) {
|
||||
case 'page':
|
||||
url = ` ${pageChooser}${data.parentId}/`;
|
||||
break;
|
||||
|
||||
case 'email':
|
||||
url = emailLinkChooser;
|
||||
urlParams.link_url = data.url.replace('mailto:', '');
|
||||
break;
|
||||
|
||||
default:
|
||||
url = externalLinkChooser;
|
||||
urlParams.link_url = data.url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { url, urlParams };
|
||||
};
|
||||
|
||||
class LinkSource extends ModalSource {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.parseData = this.parseData.bind(this);
|
||||
}
|
||||
|
||||
// Plaster over more Wagtail internals.
|
||||
parseData(pageData) {
|
||||
const data = Object.assign({}, pageData);
|
||||
|
||||
if (data.id) {
|
||||
data.linkType = 'page';
|
||||
} else if (data.url.indexOf('mailto:') === 0) {
|
||||
data.linkType = 'email';
|
||||
} else {
|
||||
data.linkType = 'external';
|
||||
}
|
||||
|
||||
// We do not want each link to have the page's title as an attr.
|
||||
// nor links to have the link URL as a title.
|
||||
if (data.linkType === 'page' || data.url.replace('mailto:', '') === data.title) {
|
||||
delete data.title;
|
||||
}
|
||||
|
||||
this.onConfirm(data);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { entity } = 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);
|
||||
|
||||
global.ModalWorkflow({
|
||||
url,
|
||||
urlParams,
|
||||
responses: {
|
||||
pageChosen: this.parseData,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default LinkSource;
|
|
@ -0,0 +1,64 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { AtomicBlockUtils, RichUtils } 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) {
|
||||
const { editorState, options, onUpdate } = this.props;
|
||||
const contentState = editorState.getCurrentContent();
|
||||
const contentStateWithEntity = contentState.createEntity(options.type, 'MUTABLE', data);
|
||||
const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
|
||||
const nextState = RichUtils.toggleLink(editorState, editorState.getSelection(), entityKey);
|
||||
|
||||
onUpdate(nextState);
|
||||
}
|
||||
|
||||
onConfirmAtomicBlock(data) {
|
||||
const { editorState, options, onUpdate } = this.props;
|
||||
const contentState = editorState.getCurrentContent();
|
||||
const contentStateWithEntity = contentState.createEntity(options.type, 'IMMUTABLE', data);
|
||||
const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
|
||||
const nextState = AtomicBlockUtils.insertAtomicBlock(editorState, entityKey, ' ');
|
||||
|
||||
onUpdate(nextState);
|
||||
}
|
||||
|
||||
onClose(e) {
|
||||
const { onClose } = this.props;
|
||||
e.preventDefault();
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ModalSource.propTypes = {
|
||||
editorState: PropTypes.object.isRequired,
|
||||
options: PropTypes.object.isRequired,
|
||||
// eslint-disable-next-line
|
||||
entity: PropTypes.object,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
ModalSource.defaultProps = {
|
||||
entity: null,
|
||||
};
|
||||
|
||||
export default ModalSource;
|
|
@ -0,0 +1,14 @@
|
|||
import LinkSource from './LinkSource';
|
||||
import ImageSource from './ImageSource';
|
||||
import DocumentSource from './DocumentSource';
|
||||
import EmbedSource from './EmbedSource';
|
||||
|
||||
/**
|
||||
* Mapping object from name to component.
|
||||
*/
|
||||
export default {
|
||||
LinkSource,
|
||||
ImageSource,
|
||||
DocumentSource,
|
||||
EmbedSource,
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import sources from './index';
|
||||
|
||||
describe('sources', () => {
|
||||
it('exists', () => {
|
||||
expect(sources).toBeInstanceOf(Object);
|
||||
});
|
||||
});
|
Ładowanie…
Reference in New Issue