Merge pull request #254 from KyleAMathews/cerealize

Migrate rich text editor to Slate backed by Unified
This commit is contained in:
Shawn Erquhart 2017-08-25 19:35:11 -04:00 committed by GitHub
commit 79c30b9048
71 changed files with 6139 additions and 3355 deletions

View File

@ -51,38 +51,38 @@
"devDependencies": {
"babel": "^6.5.2",
"babel-cli": "^6.18.0",
"babel-core": "^6.5.1",
"babel-core": "^6.23.1",
"babel-jest": "^20.0.3",
"babel-loader": "^7.0.0",
"babel-plugin-lodash": "^3.2.0",
"babel-preset-es2015": "^6.5.0",
"babel-preset-react": "^6.5.0",
"babel-preset-stage-1": "^6.16.0",
"babel-runtime": "^6.5.0",
"babel-preset-es2015": "^6.22.0",
"babel-preset-react": "^6.23.0",
"babel-preset-stage-1": "^6.22.0",
"babel-runtime": "^6.23.0",
"cross-env": "^5.0.2",
"css-loader": "^0.28.4",
"enzyme": "^2.4.1",
"eslint": "^3.7.1",
"eslint-config-netlify": "github:netlify/eslint-config-netlify",
"eslint-import-resolver-webpack": "^0.8.3",
"exports-loader": "^0.6.3",
"exports-loader": "^0.6.4",
"extract-text-webpack-plugin": "^2.1.2",
"file-loader": "^0.11.2",
"identity-obj-proxy": "^3.0.0",
"imports-loader": "^0.7.1",
"jest": "^20.0.4",
"jest-cli": "^20.0.4",
"lint-staged": "^3.1.0",
"lint-staged": "^3.3.1",
"node-sass": "^3.10.0",
"npm-check": "^5.2.3",
"postcss-cssnext": "^2.7.0",
"postcss-import": "^10.0.0",
"postcss-loader": "^2.0.5",
"react-addons-test-utils": "^15.3.2",
"react-addons-test-utils": "^15.4.2",
"sass-loader": "^6.0.5",
"style-loader": "^0.18.2",
"stylefmt": "^4.3.1",
"stylelint": "^7.3.1",
"stylelint": "^7.9.0",
"stylelint-config-css-modules": "^0.1.0",
"stylelint-config-standard": "^13.0.2",
"stylelint-declaration-block-order": "^0.1.0",
@ -110,8 +110,9 @@
"jwt-decode": "^2.1.0",
"localforage": "^1.4.2",
"lodash": "^4.13.1",
"markup-it": "^2.0.0",
"material-design-icons": "^3.0.1",
"mdast-util-definitions": "^1.2.2",
"mdast-util-to-string": "^1.0.4",
"moment": "^2.11.2",
"netlify-auth-js": "^0.5.5",
"normalize.css": "^4.2.0",
@ -119,18 +120,6 @@
"preliminaries-parser-toml": "1.1.0",
"preliminaries-parser-yaml": "1.1.0",
"prismjs": "^1.5.1",
"prosemirror-commands": "^0.16.0",
"prosemirror-history": "^0.16.0",
"prosemirror-inputrules": "^0.16.0",
"prosemirror-keymap": "^0.16.0",
"prosemirror-markdown": "^0.16.0",
"prosemirror-model": "^0.16.0",
"prosemirror-schema-basic": "^0.16.0",
"prosemirror-schema-list": "^0.16.0",
"prosemirror-schema-table": "^0.16.0",
"prosemirror-state": "^0.16.0",
"prosemirror-transform": "^0.16.0",
"prosemirror-view": "^0.16.0",
"react": "^15.1.0",
"react-addons-css-transition-group": "^15.3.1",
"react-autosuggest": "^7.0.1",
@ -157,12 +146,20 @@
"redux-notifications": "^2.1.1",
"redux-optimist": "^0.0.2",
"redux-thunk": "^1.0.3",
"selection-position": "^1.0.0",
"rehype-parse": "^3.1.0",
"rehype-remark": "^2.0.0",
"rehype-stringify": "^3.0.0",
"remark-parse": "^3.0.1",
"remark-rehype": "^2.0.0",
"remark-stringify": "^3.0.1",
"semaphore": "^1.0.5",
"slate": "^0.14.14",
"slate-drop-or-paste-images": "^0.2.0",
"slate": "^0.21.0",
"slate-edit-list": "^0.7.1",
"slate-edit-table": "^0.10.1",
"slug": "^0.9.1",
"textarea-caret-position": "^0.1.1",
"unified": "^6.1.4",
"unist-builder": "^1.0.2",
"unist-util-visit-parents": "^1.1.1",
"uuid": "^2.0.3",
"whatwg-fetch": "^1.0.0"
},

View File

@ -228,20 +228,28 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
if (!entryDraft.get('fieldsErrors').isEmpty()) return Promise.resolve();
const backend = currentBackend(state.config);
const transactionID = uuid.v4();
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
const entry = entryDraft.get('entry');
const transactionID = uuid.v4();
dispatch(unpublishedEntryPersisting(collection, entry, transactionID));
/**
* Serialize the values of any fields with registered serializers, and
* update the entry and entryDraft with the serialized values.
*/
const serializedData = serializeValues(entryDraft.getIn(['entry', 'data']), collection.get('fields'));
const serializedEntry = entry.set('data', serializedData);
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
dispatch(unpublishedEntryPersisting(collection, serializedEntry, transactionID));
const persistAction = existingUnpublishedEntry ? backend.persistUnpublishedEntry : backend.persistEntry;
return persistAction.call(backend, state.config, collection, entryDraft, assetProxies.toJS())
return persistAction.call(backend, state.config, collection, serializedEntryDraft, assetProxies.toJS())
.then(() => {
dispatch(notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}));
return dispatch(unpublishedEntryPersisted(collection, entry, transactionID));
return dispatch(unpublishedEntryPersisted(collection, serializedEntry, transactionID));
})
.catch((error) => {
dispatch(notifSend({

View File

@ -1,5 +1,6 @@
import { List } from 'immutable';
import { actions as notifActions } from 'redux-notifications';
import { serializeValues } from '../lib/serializeEntryValues';
import { closeEntry } from './editor';
import { currentBackend } from '../backends/backend';
import { getIntegrationProvider } from '../integrations';
@ -216,10 +217,11 @@ export function loadEntry(collection, slug) {
const backend = currentBackend(state.config);
dispatch(entryLoading(collection, slug));
return backend.getEntry(collection, slug)
.then(loadedEntry => (
dispatch(entryLoaded(collection, loadedEntry))
))
.then(loadedEntry => {
return dispatch(entryLoaded(collection, loadedEntry))
})
.catch((error) => {
console.error(error);
dispatch(notifSend({
message: `Failed to load entry: ${ error.message }`,
kind: 'danger',
@ -265,28 +267,37 @@ export function persistEntry(collection) {
// Early return if draft contains validation errors
if (!entryDraft.get('fieldsErrors').isEmpty()) return Promise.reject();
const backend = currentBackend(state.config);
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
const entry = entryDraft.get('entry');
dispatch(entryPersisting(collection, entry));
/**
* Serialize the values of any fields with registered serializers, and
* update the entry and entryDraft with the serialized values.
*/
const serializedData = serializeValues(entryDraft.getIn(['entry', 'data']), collection.get('fields'));
const serializedEntry = entry.set('data', serializedData);
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
dispatch(entryPersisting(collection, serializedEntry));
return backend
.persistEntry(state.config, collection, entryDraft, assetProxies.toJS())
.persistEntry(state.config, collection, serializedEntryDraft, assetProxies.toJS())
.then(() => {
dispatch(notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}));
return dispatch(entryPersisted(collection, entry));
return dispatch(entryPersisted(collection, serializedEntry));
})
.catch((error) => {
console.error(error);
dispatch(notifSend({
message: `Failed to persist entry: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
return dispatch(entryPersistFail(collection, entry, error));
return dispatch(entryPersistFail(collection, serializedEntry, error));
});
};
}

View File

@ -8,8 +8,8 @@
& input,
& textarea,
& select {
font-family: 'SFMono-Regular', Consolas, "Liberation Mono", Menlo, Courier, monospace;
& select,
& div[contenteditable=true] {
display: block;
width: 100%;
padding: 12px;
@ -28,6 +28,12 @@
border-color: var(--primaryColor);
}
}
& input,
& textarea,
& select {
font-family: var(--fontFamilyMono);
}
}
.label {

View File

@ -1,264 +0,0 @@
/* eslint max-len:0 */
import React from 'react';
import { shallow } from 'enzyme';
import { padStart } from 'lodash';
import { Map } from 'immutable';
import MarkupIt from 'markup-it';
import markdownSyntax from 'markup-it/syntaxes/markdown';
import htmlSyntax from 'markup-it/syntaxes/html';
import reInline from 'markup-it/syntaxes/markdown/re/inline';
import MarkupItReactRenderer from '../';
function getAsset(path) {
return path;
}
describe('MarkitupReactRenderer', () => {
describe('basics', () => {
it('should re-render properly after a value and syntax update', () => {
const component = shallow(
<MarkupItReactRenderer
value="# Title"
syntax={markdownSyntax}
getAsset={getAsset}
/>
);
const tree1 = component.html();
component.setProps({
value: '<h1>Title</h1>',
syntax: htmlSyntax,
});
const tree2 = component.html();
expect(tree1).toEqual(tree2);
});
it('should not update the parser if syntax didn\'t change', () => {
const component = shallow(
<MarkupItReactRenderer
value="# Title"
syntax={markdownSyntax}
getAsset={getAsset}
/>
);
const syntax1 = component.instance().props.syntax;
component.setProps({
value: '## Title',
});
const syntax2 = component.instance().props.syntax;
expect(syntax1).toEqual(syntax2);
});
});
describe('Markdown rendering', () => {
describe('General', () => {
it('should render markdown', () => {
const value = `
# H1
Text with **bold** & _em_ elements
## H2
* ul item 1
* ul item 2
### H3
1. ol item 1
1. ol item 2
1. ol item 3
#### H4
[link title](http://google.com)
##### H5
![alt text](https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg)
###### H6
`;
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
getAsset={getAsset}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
describe('Headings', () => {
for (const heading of [...Array(6).keys()]) {
it(`should render Heading ${ heading + 1 }`, () => {
const value = padStart(' Title', heading + 7, '#');
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
getAsset={getAsset}
/>
);
expect(component.html()).toMatchSnapshot();
});
}
});
describe('Lists', () => {
it('should render lists', () => {
const value = `
1. ol item 1
1. ol item 2
* Sublist 1
* Sublist 2
* Sublist 3
1. Sub-Sublist 1
1. Sub-Sublist 2
1. Sub-Sublist 3
1. ol item 3
`;
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
getAsset={getAsset}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
describe('Links', () => {
it('should render links', () => {
const value = `
I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3].
[1]: http://google.com/ "Google"
[2]: http://search.yahoo.com/ "Yahoo Search"
[3]: http://search.msn.com/ "MSN Search"
`;
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
getAsset={getAsset}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
describe('Code', () => {
it('should render code', () => {
const value = 'Use the `printf()` function.';
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
getAsset={getAsset}
/>
);
expect(component.html()).toMatchSnapshot();
});
it('should render code 2', () => {
const value = '``There is a literal backtick (`) here.``';
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
getAsset={getAsset}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
describe('HTML', () => {
it('should render HTML as is when using Markdown', () => {
const value = `
# Title
<form action="test">
<label for="input">
<input type="checkbox" checked="checked" id="input"/> My label
</label>
<dl class="test-class another-class" style="width: 100%">
<dt data-attr="test">Test HTML content</dt>
<dt>Testing HTML in Markdown</dt>
</dl>
</form>
<h1 style="display: block; border: 10px solid #f00; width: 100%">Test</h1>
`;
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
getAsset={getAsset}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
});
describe('custom elements', () => {
it('should extend default renderers with custom ones', () => {
const myRule = MarkupIt.Rule('mediaproxy') // eslint-disable-line
.regExp(reInline.link, (state, match) => {
if (match[0].charAt(0) !== '!') {
return null;
}
return {
data: Map({
alt: match[1],
src: match[2],
title: match[3],
}).filter(Boolean),
};
});
const myCustomSchema = {
mediaproxy: ({ token }) => { //eslint-disable-line
const src = token.getIn(['data', 'src']);
const alt = token.getIn(['data', 'alt']);
return <img src={src} alt={alt} />;
},
};
const myMarkdownSyntax = markdownSyntax.addInlineRules(myRule);
const value = `
## Title
![mediaproxy test](http://url.to.image)
`;
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={myMarkdownSyntax}
schema={myCustomSchema}
getAsset={getAsset}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
describe('HTML rendering', () => {
it('should render HTML', () => {
const value = '<p>Paragraph with <em>inline</em> element</p>';
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={htmlSyntax}
getAsset={getAsset}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
});

View File

@ -1,42 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MarkitupReactRenderer HTML rendering should render HTML 1`] = `"<article><p>Paragraph with <em>inline</em> element</p></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Code should render code 1`] = `"<article><p>Use the <code>printf()</code> function.</p></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Code should render code 2 1`] = `"<article><p><code>There is a literal backtick (\`) here.</code></p></article>"`;
exports[`MarkitupReactRenderer Markdown rendering General should render markdown 1`] = `"<article><h1>H1</h1><p>Text with <strong>bold</strong> &amp; <em>em</em> elements</p><h2>H2</h2><ul><li>ul item 1</li><li>ul item 2</li></ul><h3>H3</h3><ol><li>ol item 1</li><li>ol item 2</li><li>ol item 3</li></ol><h4>H4</h4><p><a href=\\"http://google.com\\">link title</a></p><h5>H5</h5><p><img alt=\\"alt text\\" src=\\"https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg\\"/></p><h6>H6</h6></article>"`;
exports[`MarkitupReactRenderer Markdown rendering HTML should render HTML as is when using Markdown 1`] = `
"<article><h1>Title</h1><div><form action=\\"test\\">
<label for=\\"input\\">
<input type=\\"checkbox\\" checked=\\"checked\\" id=\\"input\\"/> My label
</label>
<dl class=\\"test-class another-class\\" style=\\"width: 100%\\">
<dt data-attr=\\"test\\">Test HTML content</dt>
<dt>Testing HTML in Markdown</dt>
</dl>
</form>
</div><div><h1 style=\\"display: block; border: 10px solid #f00; width: 100%\\">Test</h1>
</div></article>"
`;
exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 1 1`] = `"<article><h1>Title</h1></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 2 1`] = `"<article><h2>Title</h2></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 3 1`] = `"<article><h3>Title</h3></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 4 1`] = `"<article><h4>Title</h4></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 5 1`] = `"<article><h5>Title</h5></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 6 1`] = `"<article><h6>Title</h6></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Links should render links 1`] = `"<article><p>I get 10 times more traffic from <a href=\\"http://google.com/\\" title=\\"Google\\">Google</a> than from <a href=\\"http://search.yahoo.com/\\" title=\\"Yahoo Search\\">Yahoo</a> or <a href=\\"http://search.msn.com/\\" title=\\"MSN Search\\">MSN</a>.</p></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Lists should render lists 1`] = `"<article><ol><li>ol item 1</li><li>ol item 2<ul><li>Sublist 1</li><li>Sublist 2</li><li>Sublist 3<ol><li>Sub-Sublist 1</li><li>Sub-Sublist 2</li><li>Sub-Sublist 3</li></ol></li></ul></li><li>ol item 3</li></ol></article>"`;
exports[`MarkitupReactRenderer custom elements should extend default renderers with custom ones 1`] = `"<article><h2>Title</h2><p><img src=\\"http://url.to.image\\" alt=\\"mediaproxy test\\"/></p></article>"`;

View File

@ -1,130 +0,0 @@
import React, { PropTypes } from 'react';
import MarkupIt, { Syntax, BLOCKS, STYLES, ENTITIES } from 'markup-it';
import { omit } from 'lodash';
import registry from '../../lib/registry';
const defaultSchema = {
[BLOCKS.DOCUMENT]: 'article',
[BLOCKS.TEXT]: null,
[BLOCKS.CODE]: ({ token }) => {
const className = token.getIn(['data', 'syntax']) && `language-${ token.getIn(['data', 'syntax']) }`;
return <pre><code className={className} dangerouslySetInnerHTML={{ __html: token.get('tokens').map(token => token.text).join('') }} /></pre>;
},
[BLOCKS.BLOCKQUOTE]: 'blockquote',
[BLOCKS.PARAGRAPH]: 'p',
[BLOCKS.FOOTNOTE]: 'footnote',
[BLOCKS.HTML]: ({ token }) => <div dangerouslySetInnerHTML={{ __html: token.get('raw') }} />,
[BLOCKS.HR]: 'hr',
[BLOCKS.HEADING_1]: 'h1',
[BLOCKS.HEADING_2]: 'h2',
[BLOCKS.HEADING_3]: 'h3',
[BLOCKS.HEADING_4]: 'h4',
[BLOCKS.HEADING_5]: 'h5',
[BLOCKS.HEADING_6]: 'h6',
[BLOCKS.TABLE]: 'table',
[BLOCKS.TABLE_ROW]: 'tr',
[BLOCKS.TABLE_CELL]: 'td',
[BLOCKS.OL_LIST]: 'ol',
[BLOCKS.UL_LIST]: 'ul',
[BLOCKS.LIST_ITEM]: 'li',
[STYLES.TEXT]: null,
[STYLES.BOLD]: 'strong',
[STYLES.ITALIC]: 'em',
[STYLES.CODE]: 'code',
[STYLES.STRIKETHROUGH]: 'del',
[ENTITIES.LINK]: 'a',
[ENTITIES.IMAGE]: 'img',
[ENTITIES.FOOTNOTE_REF]: 'sup',
[ENTITIES.HARD_BREAK]: 'br',
};
const notAllowedAttributes = ['loose', 'image'];
export default class MarkupItReactRenderer extends React.Component {
constructor(props) {
super(props);
const { syntax } = props;
this.parser = new MarkupIt(syntax);
this.plugins = {};
registry.getEditorComponents().forEach((component) => {
this.plugins[component.get('id')] = component;
});
}
componentWillReceiveProps(nextProps) {
if (nextProps.syntax != this.props.syntax) {
this.parser = new MarkupIt(nextProps.syntax);
}
}
sanitizeProps(props) {
const { getAsset } = this.props;
if (props.image) {
props = Object.assign({}, props, { src: getAsset(props.image).toString() });
}
return omit(props, notAllowedAttributes);
}
renderToken(schema, token, index = 0, key = '0') {
const type = token.get('type');
const data = token.get('data');
const text = token.get('text');
const tokens = token.get('tokens');
const nodeType = schema[type];
key = `${ key }.${ index }`;
// Only render if type is registered as renderer
if (typeof nodeType !== 'undefined') {
let children = null;
if (tokens.size) {
children = tokens.map((token, idx) => this.renderToken(schema, token, idx, key));
} else if (type === 'text') {
children = text;
}
if (nodeType !== null) {
let props = { key, token };
if (typeof nodeType !== 'function') {
props = { key, ...this.sanitizeProps(data.toJS()) };
}
// If this is a react element
return React.createElement(nodeType, props, children);
} else {
// If this is a text node
return children;
}
}
const plugin = this.plugins[token.get('type')];
if (plugin) {
const output = plugin.toPreview(token.get('data').toJS());
return typeof output === 'string' ?
<span dangerouslySetInnerHTML={{ __html: output }} /> :
output;
}
return null;
}
render() {
const { value, schema, getAsset } = this.props;
const content = this.parser.toContent(value);
return this.renderToken({ ...defaultSchema, ...schema }, content.get('token'));
}
}
MarkupItReactRenderer.propTypes = {
value: PropTypes.string,
syntax: PropTypes.instanceOf(Syntax).isRequired,
schema: PropTypes.objectOf(PropTypes.oneOfType([
PropTypes.string,
PropTypes.func,
])),
getAsset: PropTypes.func.isRequired,
};

View File

@ -9,15 +9,22 @@ const style = {
fontFamily: 'Roboto, "Helvetica Neue", HelveticaNeue, Helvetica, Arial, sans-serif',
};
export default function Preview({ collection, fields, widgetFor }) {
if (!collection || !fields) {
return null;
/**
* Use a stateful component so that child components can effectively utilize
* `shouldComponentUpdate`.
*/
export default class Preview extends React.Component {
render() {
const { collection, fields, widgetFor } = this.props;
if (!collection || !fields) {
return null;
}
return (
<div style={style}>
{fields.filter(isVisible).map(field => widgetFor(field.get('name')))}
</div>
);
}
return (
<div style={style}>
{fields.filter(isVisible).map(field => widgetFor(field.get('name')))}
</div>
);
}
Preview.propTypes = {

View File

@ -0,0 +1,24 @@
import React, { PropTypes } from 'react';
import { ScrollSyncPane } from '../ScrollSync';
/**
* We need to create a lightweight component here so that we can access the
* context within the Frame. This allows us to attach the ScrollSyncPane to the
* body.
*/
class PreviewContent extends React.Component {
render() {
const { previewComponent, previewProps } = this.props;
return (
<ScrollSyncPane attachTo={this.context.document.scrollingElement}>
{React.createElement(previewComponent, previewProps)}
</ScrollSyncPane>
);
}
}
PreviewContent.contextTypes = {
document: PropTypes.any,
};
export default PreviewContent;

View File

@ -2,11 +2,12 @@ import React, { PropTypes } from 'react';
import { List, Map } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Frame from 'react-frame-component';
import { ScrollSyncPane } from '../ScrollSync';
import registry from '../../lib/registry';
import { resolveWidget } from '../Widgets';
import { selectTemplateName, selectInferedField } from '../../reducers/collections';
import { INFERABLE_FIELDS } from '../../constants/fieldInference';
import PreviewContent from './PreviewContent.js';
import PreviewHOC from '../Widgets/PreviewHOC';
import Preview from './Preview';
import styles from './PreviewPane.css';
@ -16,15 +17,21 @@ export default class PreviewPane extends React.Component {
const { fieldsMetaData, getAsset, entry } = props;
const widget = resolveWidget(field.get('widget'));
return !widget.preview ? null : React.createElement(widget.preview, {
field,
key: field.get('name'),
value: value && Map.isMap(value) ? value.get(field.get('name')) : value,
metadata: fieldsMetaData && fieldsMetaData.get(field.get('name')),
getAsset,
entry,
fieldsMetaData,
});
/**
* Use an HOC to provide conditional updates for all previews.
*/
return !widget.preview ? null : (
<PreviewHOC
previewComponent={widget.preview}
key={field.get('name')}
field={field}
getAsset={getAsset}
value={value && Map.isMap(value) ? value.get(field.get('name')) : value}
metadata={fieldsMetaData && fieldsMetaData.get(field.get('name'))}
entry={entry}
fieldsMetaData={fieldsMetaData}
/>
);
};
inferedFields = {};
@ -118,7 +125,9 @@ export default class PreviewPane extends React.Component {
return null;
}
const component = registry.getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) || Preview;
const previewComponent =
registry.getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) ||
Preview;
this.inferFields();
@ -135,18 +144,6 @@ export default class PreviewPane extends React.Component {
return <Frame className={styles.frame} head={styleEls} />;
}
// We need to create a lightweight component here so that we can
// access the context within the Frame. This allows us to attach
// the ScrollSyncPane to the body.
const PreviewContent = (props, { document: iFrameDocument }) => (
<ScrollSyncPane attachTo={iFrameDocument.scrollingElement}>
{React.createElement(component, previewProps)}
</ScrollSyncPane>);
PreviewContent.contextTypes = {
document: PropTypes.any,
};
return (<Frame
className={styles.frame}
head={styleEls}
@ -156,7 +153,7 @@ export default class PreviewPane extends React.Component {
<head><base target="_blank"/></head>
<body><div></div></body>
</html>`}
><PreviewContent /></Frame>);
><PreviewContent {...{ previewComponent, previewProps }}/></Frame>);
}
}

View File

@ -1,4 +1,6 @@
:root {
--fontFamily: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--fontFamilyMono: 'SFMono-Regular', Consolas, "Liberation Mono", Menlo, Courier, monospace;
--defaultColor: #333;
--defaultColorLight: #fff;
--backgroundColor: #fff;

View File

@ -9,8 +9,8 @@ import ListControl from './Widgets/ListControl';
import ListPreview from './Widgets/ListPreview';
import TextControl from './Widgets/TextControl';
import TextPreview from './Widgets/TextPreview';
import MarkdownControl from './Widgets/MarkdownControl';
import MarkdownPreview from './Widgets/MarkdownPreview';
import MarkdownControl from './Widgets/Markdown/MarkdownControl';
import MarkdownPreview from './Widgets/Markdown/MarkdownPreview';
import ImageControl from './Widgets/ImageControl';
import ImagePreview from './Widgets/ImagePreview';
import FileControl from './Widgets/FileControl';

View File

@ -22,6 +22,10 @@ class ControlHOC extends Component {
getAsset: PropTypes.func.isRequired,
};
shouldComponentUpdate(nextProps) {
return this.props.value !== nextProps.value;
}
processInnerControlRef = (wrappedControl) => {
if (!wrappedControl) return;
this.wrappedControlValid = wrappedControl.isValid || truthy;

View File

@ -0,0 +1,21 @@
@import "../../../../UI/theme";
.rawWrapper {
position: relative;
}
.editorControlBar {
composes: editorControlBar from "../VisualEditor/index.css";
}
.editorControlBarSticky {
composes: editorControlBarSticky from "../VisualEditor/index.css";
}
.rawEditor {
position: relative;
overflow: hidden;
overflow-x: auto;
min-height: var(--richTextEditorMinHeight);
font-family: var(--fontFamilyMono);
}

View File

@ -0,0 +1,83 @@
import React, { PropTypes } from 'react';
import { Editor as Slate, Plain } from 'slate';
import { markdownToRemark, remarkToMarkdown } from '../../serializers';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../../UI/Sticky/Sticky';
import styles from './index.css';
export default class RawEditor extends React.Component {
constructor(props) {
super(props);
/**
* The value received is a Remark AST (MDAST), and must be stringified
* to plain text before Slate's Plain serializer can convert it to the
* Slate AST.
*/
const value = remarkToMarkdown(this.props.value);
this.state = {
editorState: Plain.deserialize(value || ''),
};
}
shouldComponentUpdate(nextProps, nextState) {
return !this.state.editorState.equals(nextState.editorState);
}
handleChange = editorState => {
this.setState({ editorState });
}
/**
* When the document value changes, serialize from Slate's AST back to plain
* text (which is Markdown), and then deserialize from that to a Remark MDAST,
* before passing up as the new value.
*/
handleDocumentChange = (doc, editorState) => {
const value = Plain.serialize(editorState);
const mdast = markdownToRemark(value);
this.props.onChange(mdast);
};
/**
* If a paste contains plain text, deserialize it to Slate's AST and insert
* to the document. Selection logic (where to insert, whether to replace) is
* handled by Slate.
*/
handlePaste = (e, data, state) => {
if (data.text) {
const fragment = Plain.deserialize(data.text).document;
return state.transform().insertFragment(fragment).apply();
}
};
handleToggleMode = () => {
this.props.onMode('visual');
};
render() {
return (
<div className={styles.rawWrapper}>
<Sticky
className={styles.editorControlBar}
classNameActive={styles.editorControlBarSticky}
fillContainerWidth
>
<Toolbar onToggleMode={this.handleToggleMode} disabled rawMode />
</Sticky>
<Slate
className={styles.rawEditor}
state={this.state.editorState}
onChange={this.handleChange}
onDocumentChange={this.handleDocumentChange}
onPaste={this.handlePaste}
/>
</div>
);
}
}
RawEditor.propTypes = {
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
value: PropTypes.object,
};

View File

@ -1,4 +1,4 @@
@import "../../../UI/theme";
@import "../../../../UI/theme";
.Toolbar {
composes: clearfix;

View File

@ -5,24 +5,20 @@ import Switch from 'react-toolbox/lib/switch';
import ToolbarButton from './ToolbarButton';
import ToolbarComponentsMenu from './ToolbarComponentsMenu';
import ToolbarPluginForm from './ToolbarPluginForm';
import { Icon } from '../../../UI';
import { Icon } from '../../../../UI';
import styles from './Toolbar.css';
export default class Toolbar extends React.Component {
static propTypes = {
selectionPosition: PropTypes.object,
onH1: PropTypes.func.isRequired,
onH2: PropTypes.func.isRequired,
onBold: PropTypes.func.isRequired,
onItalic: PropTypes.func.isRequired,
onLink: PropTypes.func.isRequired,
buttons: PropTypes.object,
onToggleMode: PropTypes.func.isRequired,
rawMode: PropTypes.bool,
plugins: ImmutablePropTypes.listOf(ImmutablePropTypes.record),
onSubmit: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
plugins: ImmutablePropTypes.map,
onSubmit: PropTypes.func,
onAddAsset: PropTypes.func,
onRemoveAsset: PropTypes.func,
getAsset: PropTypes.func,
disabled: PropTypes.bool,
};
constructor(props) {
@ -47,31 +43,47 @@ export default class Toolbar extends React.Component {
render() {
const {
onH1,
onH2,
onBold,
onItalic,
onLink,
onToggleMode,
rawMode,
plugins,
onAddAsset,
onRemoveAsset,
getAsset,
disabled,
} = this.props;
const buttons = this.props.buttons || {};
const { activePlugin } = this.state;
const buttonsConfig = [
{ label: 'Bold', icon: 'bold', state: buttons.bold },
{ label: 'Italic', icon: 'italic', state: buttons.italic },
{ label: 'Code', icon: 'code-alt', state: buttons.code },
{ label: 'Header 1', icon: 'h1', state: buttons.h1 },
{ label: 'Header 2', icon: 'h2', state: buttons.h2 },
{ label: 'Code Block', icon: 'code', state: buttons.codeBlock },
{ label: 'Quote', icon: 'quote', state: buttons.quote },
{ label: 'Bullet List', icon: 'list-bullet', state: buttons.list },
{ label: 'Numbered List', icon: 'list-numbered', state: buttons.listNumbered },
{ label: 'Link', icon: 'link', state: buttons.link },
];
return (
<div className={styles.Toolbar}>
<ToolbarButton label="Header 1" icon="h1" action={onH1}/>
<ToolbarButton label="Header 2" icon="h2" action={onH2}/>
<ToolbarButton label="Bold" icon="bold" action={onBold}/>
<ToolbarButton label="Italic" icon="italic" action={onItalic}/>
<ToolbarButton label="Link" icon="link" action={onLink}/>
{ buttonsConfig.map((btn, i) => (
<ToolbarButton
key={i}
action={btn.state && btn.state.onAction || (() => {})}
active={btn.state && btn.state.active}
disabled={disabled}
{...btn}
/>
))}
<ToolbarComponentsMenu
plugins={plugins}
onComponentMenuItemClick={this.handlePluginFormDisplay}
disabled={disabled}
/>
{activePlugin &&
<ToolbarPluginForm

View File

@ -1,11 +1,14 @@
@import "../../../UI/theme";
@import "../../../../UI/theme";
.button {
display: inline-block;
padding: 6px;
border: none;
background-color: transparent;
cursor: pointer;
&:not(:disabled) {
cursor: pointer;
}
}
.active {

View File

@ -1,13 +1,14 @@
import React, { PropTypes } from 'react';
import classnames from 'classnames';
import { Icon } from '../../../UI';
import { Icon } from '../../../../UI';
import styles from './ToolbarButton.css';
const ToolbarButton = ({ label, icon, action, active }) => (
const ToolbarButton = ({ label, icon, action, active, disabled }) => (
<button
className={classnames(styles.button, { [styles.active]: active })}
onClick={action}
title={label}
disabled={disabled}
>
{ icon ? <Icon type={icon} /> : label }
</button>

View File

@ -6,7 +6,7 @@ import styles from './ToolbarComponentsMenu.css';
export default class ToolbarComponentsMenu extends React.Component {
static PropTypes = {
plugins: ImmutablePropTypes.list.isRequired,
plugins: ImmutablePropTypes.map,
onComponentMenuItemClick: PropTypes.func.isRequired,
};
@ -26,17 +26,22 @@ export default class ToolbarComponentsMenu extends React.Component {
};
render() {
const { plugins, onComponentMenuItemClick } = this.props;
const { plugins, onComponentMenuItemClick, disabled } = this.props;
return (
<div className={styles.root}>
<ToolbarButton label="Add Component" icon="plus" action={this.handleComponentsMenuToggle}/>
<ToolbarButton
label="Add Component"
icon="plus"
action={this.handleComponentsMenuToggle}
disabled={disabled}
/>
<Menu
active={this.state.componentsMenuActive}
position="auto"
onHide={this.handleComponentsMenuHide}
ripple={false}
>
{plugins.map(plugin => (
{plugins && plugins.map(plugin => (
<MenuItem
key={plugin.get('id')}
value={plugin.get('id')}

View File

@ -1,4 +1,4 @@
@import "../../../UI/theme";
@import "../../../../UI/theme";
.pluginForm {
position: absolute;

View File

@ -0,0 +1,7 @@
.control {
composes: control from "../../../../ControlPanel/ControlPane.css"
}
.label {
composes: label from "../../../../ControlPanel/ControlPane.css";
}

View File

@ -1,5 +1,5 @@
import React, { PropTypes } from 'react';
import { resolveWidget } from '../../../Widgets';
import { resolveWidget } from '../../../../Widgets';
import styles from './ToolbarPluginFormControl.css';
const ToolbarPluginFormControl = ({

View File

@ -0,0 +1,269 @@
import { fromJS } from 'immutable';
import { markdownToRemark, remarkToSlate } from '../../../serializers';
// Temporary plugins test, uses preloaded plugins from ../parser
// TODO: make the parser more testable
const testPlugins = fromJS([
{
label: 'Image',
id: 'image',
fromBlock: match => match && {
image: match[2],
alt: match[1],
},
toBlock: data => `![${ data.alt }](${ data.image })`,
toPreview: data => <img src={data.image} alt={data.alt} />,
pattern: /^!\[([^\]]+)]\(([^)]+)\)$/,
fields: [{
label: 'Image',
name: 'image',
widget: 'image',
}, {
label: 'Alt Text',
name: 'alt',
}],
},
{
id: "youtube",
label: "Youtube",
fields: [{name: 'id', label: 'Youtube Video ID'}],
pattern: /^{{<\s?youtube (\S+)\s?>}}/,
fromBlock: function(match) {
return {
id: match[1]
};
},
toBlock: function(obj) {
return '{{< youtube ' + obj.id + ' >}}';
},
toPreview: function(obj) {
return (
'<img src="http://img.youtube.com/vi/' + obj.id + '/maxresdefault.jpg" alt="Youtube Video"/>'
);
}
},
]);
const parser = markdown => remarkToSlate(markdownToRemark(markdown));
describe("Compile markdown to Prosemirror document structure", () => {
it("should compile simple markdown", () => {
const value = `
# H1
sweet body
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile a markdown ordered list", () => {
const value = `
# H1
1. yo
2. bro
3. fro
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile bulleted lists", () => {
const value = `
# H1
* yo
* bro
* fro
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile multiple header levels", () => {
const value = `
# H1
## H2
### H3
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile horizontal rules", () => {
const value = `
# H1
---
blue moon
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile horizontal rules", () => {
const value = `
# H1
---
blue moon
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile hard breaks (double space)", () => {
const value = `
blue moon
footballs
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile images", () => {
const value = `
![super](duper.jpg)
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile code blocks", () => {
const value = `
\`\`\`javascript
var a = 1;
\`\`\`
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile nested inline markup", () => {
const value = `
# Word
This is **some *hot* content**
perhaps **scalding** even
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile inline code", () => {
const value = `
# Word
This is some sweet \`inline code\` yo!
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile links", () => {
const value = `
# Word
How far is it to [Google](https://google.com) land?
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile plugins", () => {
const value = `
![test](test.png)
{{< test >}}
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile kitchen sink example", () => {
const value = `
# An exhibit of Markdown
This note demonstrates some of what Markdown is capable of doing.
*Note: Feel free to play with this page. Unlike regular notes, this doesn't
automatically save itself.*
## Basic formatting
Paragraphs can be written like so. A paragraph is the basic block of Markdown.
A paragraph is what text will turn into when there is no reason it should
become anything else.
Paragraphs must be separated by a blank line. Basic formatting of *italics* and
**bold** is supported. This *can be **nested** like* so.
## Lists
### Ordered list
1. Item 1 2. A second item 3. Number 3 4.
*Note: the fourth item uses the Unicode character for Roman numeral four.*
### Unordered list
* An item Another item Yet another item And there's more...
## Paragraph modifiers
### Code block
Code blocks are very useful for developers and other people who look at
code or other things that are written in plain text. As you can see, it
uses a fixed-width font.
You can also make \`inline code\` to add code into other things.
### Quote
> Here is a quote. What this is should be self explanatory. Quotes are
automatically indented when they are used.
## Headings
There are six levels of headings. They correspond with the six levels of HTML
headings. You've probably noticed them already in the page. Each level down
uses one more hash character.
### Headings *can* also contain **formatting**
### They can even contain \`inline code\`
Of course, demonstrating what headings look like messes up the structure of the
page.
I don't recommend using more than three or four levels of headings here,
because, when you're smallest heading isn't too small, and you're largest
heading isn't too big, and you want each size up to look noticeably larger and
more important, there there are only so many sizes that you can use.
## URLs
URLs can be made in a handful of ways:
* A named link to MarkItDown. The easiest way to do these is to select what you
* want to make a link and hit \`Ctrl+L\`. Another named link to
* [MarkItDown](http://www.markitdown.net/) Sometimes you just want a URL like
* <http://www.markitdown.net/>.
## Horizontal rule
A horizontal rule is a line that goes across the middle of the page.
---
It's sometimes handy for breaking things up.
## Images
Markdown can also contain images. I'll need to add something here sometime.
## Finally
There's actually a lot more to Markdown than this. See the official
introduction and syntax for more information. However, be aware that this is
not using the official implementation, and this might work subtly differently
in some of the little things.
`;
expect(parser(value)).toMatchSnapshot();
});
});

View File

@ -0,0 +1,48 @@
import React from 'react';
import cn from 'classnames';
import styles from './index.css';
/**
* Slate uses React components to render each type of node that it receives.
* This is the closest thing Slate has to a schema definition. The types are set
* by us when we manually deserialize from Remark's MDAST to Slate's AST.
*/
export const MARK_COMPONENTS = {
bold: props => <strong>{props.children}</strong>,
italic: props => <em>{props.children}</em>,
strikethrough: props => <s>{props.children}</s>,
code: props => <code>{props.children}</code>,
};
export const NODE_COMPONENTS = {
'paragraph': props => <p {...props.attributes}>{props.children}</p>,
'list-item': props => <li {...props.attributes}>{props.children}</li>,
'quote': props => <blockquote {...props.attributes}>{props.children}</blockquote>,
'code': props => <pre><code {...props.attributes}>{props.children}</code></pre>,
'heading-one': props => <h1 {...props.attributes}>{props.children}</h1>,
'heading-two': props => <h2 {...props.attributes}>{props.children}</h2>,
'heading-three': props => <h3 {...props.attributes}>{props.children}</h3>,
'heading-four': props => <h4 {...props.attributes}>{props.children}</h4>,
'heading-five': props => <h5 {...props.attributes}>{props.children}</h5>,
'heading-six': props => <h6 {...props.attributes}>{props.children}</h6>,
'table': props => <table><tbody {...props.attributes}>{props.children}</tbody></table>,
'table-row': props => <tr {...props.attributes}>{props.children}</tr>,
'table-cell': props => <td {...props.attributes}>{props.children}</td>,
'thematic-break': props => <hr {...props.attributes}/>,
'bulleted-list': props => <ul {...props.attributes}>{props.children}</ul>,
'numbered-list': props =>
<ol {...props.attributes} start={props.node.data.get('start') || 1}>{props.children}</ol>,
'link': props => {
const data = props.node.get('data');
const url = data.get('url');
const title = data.get('title');
return <a href={url} title={title} {...props.attributes}>{props.children}</a>;
},
'shortcode': props => {
const { attributes, node, state: editorState } = props;
const isSelected = editorState.selection.hasFocusIn(node);
const className = cn(styles.shortcode, { [styles.shortcodeSelected]: isSelected });
return <div {...attributes} className={className} draggable >{node.data.get('shortcode')}</div>;
},
};

View File

@ -0,0 +1,133 @@
@import "../../../../UI/theme";
.editorControlBar {
z-index: 1;
border: 2px solid transparent;
border-top: 0;
background-color: var(--controlBGColor);
}
.editorControlBarSticky {
border-color: var(--textFieldBorderColor);
}
.wrapper {
position: relative;
}
.editor {
position: relative;
overflow: hidden;
overflow-x: auto;
min-height: var(--richTextEditorMinHeight);
font-family: var(--fontFamily);
& h1 {
font-size: 32px;
margin-top: 16px;
}
& h2 {
font-size: 24px;
margin-top: 12px;
}
& h3 {
font-size: 20px;
margin-top: 8px;
}
& h4 {
font-size: 18px;
margin-top: 8px;
}
& h5,
& h6 {
font-size: 16px;
margin-top: 8px;
}
& h1, & h2, & h3, & h4, & h5, & h6 {
font-weight: 700;
line-height: 1;
}
& p,
& pre,
& blockquote,
& ul,
& ol {
margin-top: 16px;
margin-bottom: 16px;
}
& a {
text-decoration: underline;
}
& hr {
border: 1px solid;
margin-bottom: 16px;
}
& li > p {
margin: 0;
}
& ul,
& ol {
padding-left: 30px;
}
& pre {
white-space: pre-wrap;
}
& pre > code {
display: block;
width: 100%;
overflow-y: auto;
background-color: #000;
color: #ccc;
border-radius: var(--borderRadius);
padding: 10px;
}
& code {
background-color: var(--backgroundColorShaded);
border-radius: var(--borderRadius);
padding: 0 2px;
font-size: 85%;
}
& blockquote {
padding-left: 16px;
border-left: 3px solid var(--backgroundColorShaded);
margin-left: 0;
margin-right: 0;
}
& table {
border-collapse: collapse;
}
& td,
& th {
border: 2px solid black;
padding: 8px;
text-align: left;
}
}
.shortcode {
border: 2px solid black;
padding: 8px;
margin: 2px 0;
cursor: pointer;
}
.shortcodeSelected {
border-color: var(--primaryColor);
color: var(--primaryColor);
}

View File

@ -0,0 +1,215 @@
import React, { Component, PropTypes } from 'react';
import { get, isEmpty } from 'lodash';
import { Editor as Slate, Raw, Block, Text } from 'slate';
import { slateToRemark, remarkToSlate, htmlToSlate } from '../../serializers';
import registry from '../../../../../lib/registry';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../../UI/Sticky/Sticky';
import { MARK_COMPONENTS, NODE_COMPONENTS } from './components';
import RULES from './rules';
import plugins, { EditListConfigured } from './plugins';
import onKeyDown from './keys';
import styles from './index.css';
export default class Editor extends Component {
constructor(props) {
super(props);
const emptyBlock = Block.create({ kind: 'block', type: 'paragraph'});
const emptyRaw = { nodes: [emptyBlock] };
const mdast = this.props.value && remarkToSlate(this.props.value);
const mdastHasNodes = !isEmpty(get(mdast, 'nodes'))
const editorState = Raw.deserialize(mdastHasNodes ? mdast : emptyRaw, { terse: true });
this.state = {
editorState,
schema: {
nodes: NODE_COMPONENTS,
marks: MARK_COMPONENTS,
rules: RULES,
},
shortcodePlugins: registry.getEditorComponents(),
};
}
shouldComponentUpdate(nextProps, nextState) {
return !this.state.editorState.equals(nextState.editorState);
}
handlePaste = (e, data, state) => {
if (data.type !== 'html' || data.isShift) {
return;
}
const ast = htmlToSlate(data.html);
const { document: doc } = Raw.deserialize(ast, { terse: true });
return state.transform().insertFragment(doc).apply();
}
handleDocumentChange = (doc, editorState) => {
const raw = Raw.serialize(editorState, { terse: true });
const plugins = this.state.shortcodePlugins;
const mdast = slateToRemark(raw, plugins);
this.props.onChange(mdast);
};
hasMark = type => this.state.editorState.marks.some(mark => mark.type === type);
hasBlock = type => this.state.editorState.blocks.some(node => node.type === type);
handleMarkClick = (event, type) => {
event.preventDefault();
const resolvedState = this.state.editorState.transform().focus().toggleMark(type).apply();
this.ref.onChange(resolvedState);
this.setState({ editorState: resolvedState });
};
handleBlockClick = (event, type) => {
event.preventDefault();
let { editorState } = this.state;
const { document: doc, selection } = editorState;
const transform = editorState.transform();
// Handle everything except list buttons.
if (!['bulleted-list', 'numbered-list'].includes(type)) {
const isActive = this.hasBlock(type);
const transformed = transform.setBlock(isActive ? 'paragraph' : type);
}
// Handle the extra wrapping required for list buttons.
else {
const isSameListType = editorState.blocks.some(block => {
return !!doc.getClosest(block.key, parent => parent.type === type);
});
const isInList = EditListConfigured.utils.isSelectionInList(editorState);
if (isInList && isSameListType) {
EditListConfigured.transforms.unwrapList(transform, type);
} else if (isInList) {
const currentListType = type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list';
EditListConfigured.transforms.unwrapList(transform, currentListType);
EditListConfigured.transforms.wrapInList(transform, type);
} else {
EditListConfigured.transforms.wrapInList(transform, type);
}
}
const resolvedState = transform.focus().apply();
this.ref.onChange(resolvedState);
this.setState({ editorState: resolvedState });
};
hasLinks = () => {
return this.state.editorState.inlines.some(inline => inline.type === 'link');
};
handleLink = () => {
let { editorState } = this.state;
// If the current selection contains links, clicking the "link" button
// should simply unlink them.
if (this.hasLinks()) {
editorState = editorState.transform().unwrapInline('link').apply();
}
else {
const url = window.prompt('Enter the URL of the link');
// If nothing is entered in the URL prompt, do nothing.
if (!url) return;
let transform = editorState.transform();
// If no text is selected, use the entered URL as text.
if (editorState.isCollapsed) {
transform = transform
.insertText(url)
.extend(0 - url.length);
}
editorState = transform
.wrapInline({ type: 'link', data: { url } })
.collapseToEnd()
.apply();
}
this.ref.onChange(editorState);
this.setState({ editorState });
};
handlePluginSubmit = (plugin, shortcodeData) => {
const { editorState } = this.state;
const data = {
shortcode: plugin.id,
shortcodeData,
};
const nodes = [Text.createFromString('')];
const block = { kind: 'block', type: 'shortcode', data, isVoid: true, nodes };
const resolvedState = editorState.transform().insertBlock(block).focus().apply();
this.ref.onChange(resolvedState);
this.setState({ editorState: resolvedState });
};
handleToggle = () => {
this.props.onMode('raw');
};
getButtonProps = (type, opts = {}) => {
const { isBlock } = opts;
const handler = opts.handler || (isBlock ? this.handleBlockClick: this.handleMarkClick);
const isActive = opts.isActive || (isBlock ? this.hasBlock : this.hasMark);
return { onAction: e => handler(e, type), active: isActive(type) };
};
render() {
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
return (
<div className={styles.wrapper}>
<Sticky
className={styles.editorControlBar}
classNameActive={styles.editorControlBarSticky}
fillContainerWidth
>
<Toolbar
buttons={{
bold: this.getButtonProps('bold'),
italic: this.getButtonProps('italic'),
code: this.getButtonProps('code'),
link: this.getButtonProps('link', { handler: this.handleLink, isActive: this.hasLinks }),
h1: this.getButtonProps('heading-one', { isBlock: true }),
h2: this.getButtonProps('heading-two', { isBlock: true }),
list: this.getButtonProps('bulleted-list', { isBlock: true }),
listNumbered: this.getButtonProps('numbered-list', { isBlock: true }),
codeBlock: this.getButtonProps('code', { isBlock: true }),
quote: this.getButtonProps('quote', { isBlock: true }),
}}
onToggleMode={this.handleToggle}
plugins={this.state.shortcodePlugins}
onSubmit={this.handlePluginSubmit}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
/>
</Sticky>
<Slate
className={styles.editor}
state={this.state.editorState}
schema={this.state.schema}
plugins={plugins}
onChange={editorState => this.setState({ editorState })}
onDocumentChange={this.handleDocumentChange}
onKeyDown={onKeyDown}
onPaste={this.handlePaste}
ref={ref => this.ref = ref}
spellCheck
/>
</div>
);
}
}
Editor.propTypes = {
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
value: PropTypes.object,
};

View File

@ -0,0 +1,67 @@
import { Block, Text } from 'slate';
export default onKeyDown;
function onKeyDown(e, data, state) {
const createDefaultBlock = () => {
return Block.create({
type: 'paragraph',
nodes: [Text.createFromString('')]
});
};
if (data.key === 'enter') {
/**
* If "Enter" is pressed while a single void block is selected, a new
* paragraph should be added above or below it, and the current selection
* should be collapsed to the start of the new paragraph.
*
* If the selected block is the first block in the document, create the
* new block above it. If not, create the new block below it.
*/
const { document: doc, selection, anchorBlock, focusBlock } = state;
const singleBlockSelected = anchorBlock === focusBlock;
if (!singleBlockSelected || !focusBlock.isVoid) return;
e.preventDefault();
const focusBlockParent = doc.getParent(focusBlock.key);
const focusBlockIndex = focusBlockParent.nodes.indexOf(focusBlock);
const focusBlockIsFirstChild = focusBlockIndex === 0;
const newBlock = createDefaultBlock();
const newBlockIndex = focusBlockIsFirstChild ? 0 : focusBlockIndex + 1;
return state.transform()
.insertNodeByKey(focusBlockParent.key, newBlockIndex, newBlock)
.collapseToStartOf(newBlock)
.apply();
}
if (data.isMod) {
if (data.key === 'y') {
e.preventDefault();
return state.transform().redo().focus().apply({ save: false });
}
if (data.key === 'z') {
e.preventDefault();
return state.transform()[data.isShift ? 'redo' : 'undo']().focus().apply({ save: false });
}
const marks = {
b: 'bold',
i: 'italic',
u: 'underlined',
s: 'strikethrough',
'`': 'code',
};
const mark = marks[data.key];
if (mark) {
e.preventDefault();
return state.transform().toggleMark(mark).apply();
}
}
};

View File

@ -0,0 +1,90 @@
import EditList from 'slate-edit-list';
import EditTable from 'slate-edit-table';
const SoftBreak = (options = {}) => ({
onKeyDown(e, data, state) {
if (data.key != 'enter') return;
if (options.shift && e.shiftKey == false) return;
const { onlyIn, ignoreIn, closeAfter, unwrapBlocks, defaultBlock = 'paragraph' } = options;
const { type, nodes } = state.startBlock;
if (onlyIn && !onlyIn.includes(type)) return;
if (ignoreIn && ignoreIn.includes(type)) return;
const shouldClose = nodes.last().characters.takeLast(closeAfter).every(c => c.text === '\n');
if (closeAfter && shouldClose) {
const trimmed = state.transform().deleteBackward(closeAfter);
const unwrapped = unwrapBlocks
? unwrapBlocks.reduce((acc, blockType) => acc.unwrapBlock(blockType), trimmed)
: trimmed;
return unwrapped.insertBlock(defaultBlock).apply();
}
return state.transform().insertText('\n').apply();
}
});
const SoftBreakOpts = {
onlyIn: ['quote', 'code'],
closeAfter: 1
};
export const SoftBreakConfigured = SoftBreak(SoftBreakOpts);
const BackspaceCloseBlock = (options = {}) => ({
onKeyDown(e, data, state) {
if (data.key != 'backspace') return;
const { defaultBlock = 'paragraph', ignoreIn, onlyIn } = options;
const { startBlock } = state;
const { type } = startBlock;
if (onlyIn && !onlyIn.includes(type)) return;
if (ignoreIn && ignoreIn.includes(type)) return;
const characters = startBlock.getFirstText().characters;
const isEmpty = !characters || characters.isEmpty();
if (isEmpty) {
return state.transform().insertBlock(defaultBlock).focus().apply();
}
}
});
const BackspaceCloseBlockOpts = {
ignoreIn: [
'paragraph',
'list-item',
'bulleted-list',
'numbered-list',
'table',
'table-row',
'table-cell',
],
};
export const BackspaceCloseBlockConfigured = BackspaceCloseBlock(BackspaceCloseBlockOpts);
const EditListOpts = {
types: ['bulleted-list', 'numbered-list'],
typeItem: 'list-item',
};
export const EditListConfigured = EditList(EditListOpts);
const EditTableOpts = {
typeTable: 'table',
typeRow: 'table-row',
typeCell: 'table-cell',
};
export const EditTableConfigured = EditTable(EditTableOpts);
const plugins = [
SoftBreakConfigured,
BackspaceCloseBlockConfigured,
EditListConfigured,
EditTableConfigured,
];
export default plugins;

View File

@ -0,0 +1,45 @@
import { Block, Text } from 'slate';
/**
* Rules are used to validate the editor state each time it changes, to ensure
* it is never rendered in an undesirable state.
*/
/**
* If the editor is ever in an empty state, insert an empty
* paragraph block.
*/
const enforceNeverEmpty = {
match: object => object.kind === 'document',
validate: doc => {
const hasBlocks = !doc.getBlocks().isEmpty();
return hasBlocks ? null : {};
},
normalize: transform => {
const block = Block.create({
type: 'paragraph',
nodes: [Text.createFromString('')],
});
const { key } = transform.state.document;
return transform.insertNodeByKey(key, 0, block).focus();
},
};
/**
* Ensure that shortcodes are children of the root node.
*/
const shortcodesAtRoot = {
match: object => object.kind === 'document',
validate: doc => {
return doc.findDescendant(node => {
return node.type === 'shortcode' && doc.getParent(node.key).key !== doc.key;
});
},
normalize: (transform, doc, node) => {
return transform.unwrapNodeByKey(node.key);
},
};
const rules = [ enforceNeverEmpty, shortcodesAtRoot ];
export default rules;

View File

@ -1,19 +1,30 @@
import React, { PropTypes } from 'react';
import registry from '../../lib/registry';
import RawEditor from './MarkdownControlElements/RawEditor';
import VisualEditor from './MarkdownControlElements/VisualEditor';
import { processEditorPlugins } from './richText';
import { StickyContainer } from '../UI/Sticky/Sticky';
import registry from '../../../../lib/registry';
import { markdownToRemark, remarkToMarkdown } from '../serializers'
import RawEditor from './RawEditor';
import VisualEditor from './VisualEditor';
import { StickyContainer } from '../../../UI/Sticky/Sticky';
const MODE_STORAGE_KEY = 'cms.md-mode';
/**
* The markdown field value is persisted as a markdown string, but stringifying
* on every keystroke is a big perf hit, so we'll register functions to perform
* those actions only when necessary, such as after loading and before
* persisting.
*/
registry.registerWidgetValueSerializer('markdown', {
serialize: remarkToMarkdown,
deserialize: markdownToRemark,
});
export default class MarkdownControl extends React.Component {
static propTypes = {
onChange: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
value: PropTypes.node,
value: PropTypes.object,
};
constructor(props) {
@ -21,10 +32,6 @@ export default class MarkdownControl extends React.Component {
this.state = { mode: localStorage.getItem(MODE_STORAGE_KEY) || 'visual' };
}
componentWillMount() {
processEditorPlugins(registry.getEditorComponents());
}
handleMode = (mode) => {
this.setState({ mode });
localStorage.setItem(MODE_STORAGE_KEY, mode);

View File

@ -0,0 +1,78 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Markdown Preview renderer HTML rendering should render HTML 1`] = `"<div style=\\"margin:15px 2px;\\"><p>Paragraph with <em>inline</em> element</p></div>"`;
exports[`Markdown Preview renderer Markdown rendering Code should render code 1`] = `"<div style=\\"margin:15px 2px;\\"><p>Use the <code>printf()</code> function.</p></div>"`;
exports[`Markdown Preview renderer Markdown rendering Code should render code 2 1`] = `"<div style=\\"margin:15px 2px;\\"><p><code>There is a literal backtick (\`) here.</code></p></div>"`;
exports[`Markdown Preview renderer Markdown rendering General should render markdown 1`] = `
"<div style=\\"margin:15px 2px;\\"><h1>H1</h1>
<p>Text with <strong>bold</strong> &#x26; <em>em</em> elements</p>
<h2>H2</h2>
<ul>
<li>ul item 1</li>
<li>ul item 2</li>
</ul>
<h3>H3</h3>
<ol>
<li>ol item 1</li>
<li>ol item 2</li>
<li>ol item 3</li>
</ol>
<h4>H4</h4>
<p><a href=\\"http://google.com\\">link title</a></p>
<h5>H5</h5>
<p>![alt text](https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg)</p>
<h6>H6</h6></div>"
`;
exports[`Markdown Preview renderer Markdown rendering HTML should render HTML as is when using Markdown 1`] = `
"<div style=\\"margin:15px 2px;\\"><h1>Title</h1>
<form action=\\"test\\">
<label for=\\"input\\">
<input type=\\"checkbox\\" checked=\\"checked\\" id=\\"input\\"/> My label
</label>
<dl class=\\"test-class another-class\\" style=\\"width: 100%\\">
<dt data-attr=\\"test\\">Test HTML content</dt>
<dt>Testing HTML in Markdown</dt>
</dl>
</form>
<h1 style=\\"display: block; border: 10px solid #f00; width: 100%\\">Test</h1></div>"
`;
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 1 1`] = `"<div style=\\"margin:15px 2px;\\"><h1>Title</h1></div>"`;
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 2 1`] = `"<div style=\\"margin:15px 2px;\\"><h2>Title</h2></div>"`;
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 3 1`] = `"<div style=\\"margin:15px 2px;\\"><h3>Title</h3></div>"`;
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 4 1`] = `"<div style=\\"margin:15px 2px;\\"><h4>Title</h4></div>"`;
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 5 1`] = `"<div style=\\"margin:15px 2px;\\"><h5>Title</h5></div>"`;
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 6 1`] = `"<div style=\\"margin:15px 2px;\\"><h6>Title</h6></div>"`;
exports[`Markdown Preview renderer Markdown rendering Links should render links 1`] = `"<div style=\\"margin:15px 2px;\\"><p>I get 10 times more traffic from <a href=\\"http://google.com/\\" title=\\"Google\\">Google</a> than from <a href=\\"http://search.yahoo.com/\\" title=\\"Yahoo Search\\">Yahoo</a> or <a href=\\"http://search.msn.com/\\" title=\\"MSN Search\\">MSN</a>.</p></div>"`;
exports[`Markdown Preview renderer Markdown rendering Lists should render lists 1`] = `
"<div style=\\"margin:15px 2px;\\"><ol>
<li>ol item 1</li>
<li>
<p>ol item 2</p>
<ul>
<li>Sublist 1</li>
<li>Sublist 2</li>
<li>
<p>Sublist 3</p>
<ol>
<li>Sub-Sublist 1</li>
<li>Sub-Sublist 2</li>
<li>Sub-Sublist 3</li>
</ol>
</li>
</ul>
</li>
<li>ol item 3</li>
</ol></div>"
`;

View File

@ -0,0 +1,130 @@
/* eslint max-len:0 */
import React from 'react';
import { shallow } from 'enzyme';
import { padStart } from 'lodash';
import MarkdownPreview from '../index';
import { markdownToRemark } from '../../serializers';
describe('Markdown Preview renderer', () => {
describe('Markdown rendering', () => {
describe('General', () => {
it('should render markdown', () => {
const value = `
# H1
Text with **bold** & _em_ elements
## H2
* ul item 1
* ul item 2
### H3
1. ol item 1
1. ol item 2
1. ol item 3
#### H4
[link title](http://google.com)
##### H5
![alt text](https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg)
###### H6
`;
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
expect(component.html()).toMatchSnapshot();
});
});
describe('Headings', () => {
for (const heading of [...Array(6).keys()]) {
it(`should render Heading ${ heading + 1 }`, () => {
const value = padStart(' Title', heading + 7, '#');
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
expect(component.html()).toMatchSnapshot();
});
}
});
describe('Lists', () => {
it('should render lists', () => {
const value = `
1. ol item 1
1. ol item 2
* Sublist 1
* Sublist 2
* Sublist 3
1. Sub-Sublist 1
1. Sub-Sublist 2
1. Sub-Sublist 3
1. ol item 3
`;
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
expect(component.html()).toMatchSnapshot();
});
});
describe('Links', () => {
it('should render links', () => {
const value = `
I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3].
[1]: http://google.com/ "Google"
[2]: http://search.yahoo.com/ "Yahoo Search"
[3]: http://search.msn.com/ "MSN Search"
`;
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
expect(component.html()).toMatchSnapshot();
});
});
describe('Code', () => {
it('should render code', () => {
const value = 'Use the `printf()` function.';
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
expect(component.html()).toMatchSnapshot();
});
it('should render code 2', () => {
const value = '``There is a literal backtick (`) here.``';
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
expect(component.html()).toMatchSnapshot();
});
});
describe('HTML', () => {
it('should render HTML as is when using Markdown', () => {
const value = `
# Title
<form action="test">
<label for="input">
<input type="checkbox" checked="checked" id="input"/> My label
</label>
<dl class="test-class another-class" style="width: 100%">
<dt data-attr="test">Test HTML content</dt>
<dt>Testing HTML in Markdown</dt>
</dl>
</form>
<h1 style="display: block; border: 10px solid #f00; width: 100%">Test</h1>
`;
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
expect(component.html()).toMatchSnapshot();
});
});
});
describe('HTML rendering', () => {
it('should render HTML', () => {
const value = '<p>Paragraph with <em>inline</em> element</p>';
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
expect(component.html()).toMatchSnapshot();
});
});
});

View File

@ -0,0 +1,18 @@
import React, { PropTypes } from 'react';
import { remarkToHtml } from '../serializers';
import previewStyle from '../../defaultPreviewStyle';
const MarkdownPreview = ({ value, getAsset }) => {
if (value === null) {
return null;
}
const html = remarkToHtml(value, getAsset);
return <div style={previewStyle} dangerouslySetInnerHTML={{__html: html}}></div>;
};
MarkdownPreview.propTypes = {
getAsset: PropTypes.func.isRequired,
value: PropTypes.object,
};
export default MarkdownPreview;

View File

@ -0,0 +1,204 @@
import u from 'unist-builder';
import remarkAssertParents from '../remarkAssertParents';
const transform = remarkAssertParents();
describe('remarkAssertParents', () => {
it('should unnest invalidly nested blocks', () => {
const input = u('root', [
u('paragraph', [
u('paragraph', [ u('text', 'Paragraph text.') ]),
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
u('code', 'someCode()'),
u('blockquote', [ u('text', 'Quote text.') ]),
u('list', [ u('listItem', [ u('text', 'A list item.') ]) ]),
u('table', [ u('tableRow', [ u('tableCell', [ u('text', 'Text in a table cell.') ]) ]) ]),
u('thematicBreak'),
]),
]);
const output = u('root', [
u('paragraph', [ u('text', 'Paragraph text.') ]),
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
u('code', 'someCode()'),
u('blockquote', [ u('text', 'Quote text.') ]),
u('list', [ u('listItem', [ u('text', 'A list item.') ]) ]),
u('table', [ u('tableRow', [ u('tableCell', [ u('text', 'Text in a table cell.') ]) ]) ]),
u('thematicBreak'),
]);
expect(transform(input)).toEqual(output);
});
it('should unnest deeply nested blocks', () => {
const input = u('root', [
u('paragraph', [
u('paragraph', [
u('paragraph', [
u('paragraph', [ u('text', 'Paragraph text.') ]),
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
u('code', 'someCode()'),
u('blockquote', [
u('paragraph', [
u('strong', [
u('heading', [
u('text', 'Quote text.'),
]),
]),
]),
]),
u('list', [ u('listItem', [ u('text', 'A list item.') ]) ]),
u('table', [ u('tableRow', [ u('tableCell', [ u('text', 'Text in a table cell.') ]) ]) ]),
u('thematicBreak'),
]),
]),
]),
]);
const output = u('root', [
u('paragraph', [ u('text', 'Paragraph text.') ]),
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
u('code', 'someCode()'),
u('blockquote', [
u('heading', [
u('text', 'Quote text.'),
]),
]),
u('list', [ u('listItem', [ u('text', 'A list item.') ]) ]),
u('table', [ u('tableRow', [ u('tableCell', [ u('text', 'Text in a table cell.') ]) ]) ]),
u('thematicBreak'),
]);
expect(transform(input)).toEqual(output);
});
it('should remove blocks that are emptied as a result of denesting', () => {
const input = u('root', [
u('paragraph', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]),
]);
const output = u('root', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]);
expect(transform(input)).toEqual(output);
});
it('should remove blocks that are emptied as a result of denesting', () => {
const input = u('root', [
u('paragraph', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]),
]);
const output = u('root', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]);
expect(transform(input)).toEqual(output);
});
it('should handle assymetrical splits', () => {
const input = u('root', [
u('paragraph', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]),
]);
const output = u('root', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]);
expect(transform(input)).toEqual(output);
});
it('should nest invalidly nested blocks in the nearest valid ancestor', () => {
const input = u('root', [
u('paragraph', [
u('blockquote', [
u('strong', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]),
]),
]),
]);
const output = u('root', [
u('blockquote', [
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
]),
]);
expect(transform(input)).toEqual(output);
});
it('should preserve validly nested siblings of invalidly nested blocks', () => {
const input = u('root', [
u('paragraph', [
u('blockquote', [
u('strong', [
u('text', 'Deep validly nested text a.'),
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
u('text', 'Deep validly nested text b.'),
]),
]),
u('text', 'Validly nested text.'),
]),
]);
const output = u('root', [
u('blockquote', [
u('strong', [
u('text', 'Deep validly nested text a.'),
]),
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
u('strong', [
u('text', 'Deep validly nested text b.'),
]),
]),
u('paragraph', [
u('text', 'Validly nested text.'),
]),
]);
expect(transform(input)).toEqual(output);
});
it('should allow intermediate parents like list and table to contain required block children', () => {
const input = u('root', [
u('blockquote', [
u('list', [
u('listItem', [
u('table', [
u('tableRow', [
u('tableCell', [
u('heading', { depth: 1 }, [ u('text', 'Validly nested heading text.') ]),
]),
]),
]),
]),
]),
]),
]);
const output = u('root', [
u('blockquote', [
u('list', [
u('listItem', [
u('table', [
u('tableRow', [
u('tableCell', [
u('heading', { depth: 1 }, [ u('text', 'Validly nested heading text.') ]),
]),
]),
]),
]),
]),
]),
]);
expect(transform(input)).toEqual(output);
});
});

View File

@ -0,0 +1,45 @@
import unified from 'unified';
import markdownToRemark from 'remark-parse';
import remarkToMarkdown from 'remark-stringify';
import remarkPaddedLinks from '../remarkPaddedLinks';
const input = markdown =>
unified()
.use(markdownToRemark)
.use(remarkPaddedLinks)
.use(remarkToMarkdown)
.processSync(markdown)
.contents;
const output = markdown =>
unified()
.use(markdownToRemark)
.use(remarkToMarkdown)
.processSync(markdown)
.contents;
describe('remarkPaddedLinks', () => {
it('should move leading and trailing spaces outside of a link', () => {
expect(input('[ a ](b)')).toEqual(output(' [a](b) '));
});
it('should convert multiple leading or trailing spaces to a single space', () => {
expect(input('[ a ](b)')).toEqual(output(' [a](b) '));
});
it('should work with only a leading space or only a trailing space', () => {
expect(input('[ a](b)[c ](d)')).toEqual(output(' [a](b)[c](d) '));
});
it('should work for nested links', () => {
expect(input('* # a[ b ](c)d')).toEqual(output('* # a [b](c) d'));
});
it('should work for parents with multiple links that are not siblings', () => {
expect(input('# a[ b ](c)d **[ e ](f)**')).toEqual(output('# a [b](c) d ** [e](f) **'));
});
it('should work for links with arbitrarily nested children', () => {
expect(input('[ a __*b*__ _c_ ](d)')).toEqual(output(' [a __*b*__ _c_](d) '));
});
});

View File

@ -0,0 +1,244 @@
import { get, isEmpty, reduce, pull } from 'lodash';
import unified from 'unified';
import u from 'unist-builder';
import markdownToRemarkPlugin from 'remark-parse';
import remarkToMarkdownPlugin from 'remark-stringify';
import remarkToRehype from 'remark-rehype';
import rehypeToHtml from 'rehype-stringify';
import htmlToRehype from 'rehype-parse';
import rehypeToRemark from 'rehype-remark';
import remarkToRehypeShortcodes from './remarkRehypeShortcodes';
import rehypePaperEmoji from './rehypePaperEmoji';
import remarkAssertParents from './remarkAssertParents';
import remarkPaddedLinks from './remarkPaddedLinks';
import remarkWrapHtml from './remarkWrapHtml';
import remarkToSlatePlugin from './remarkSlate';
import remarkSquashReferences from './remarkSquashReferences';
import remarkImagesToText from './remarkImagesToText';
import remarkShortcodes from './remarkShortcodes';
import slateToRemarkParser from './slateRemark';
import registry from '../../../../lib/registry';
/**
* This module contains all serializers for the Markdown widget.
*
* The value of a Markdown widget is transformed to various formats during
* editing, and these formats are referenced throughout serializer source
* documentation. Below is brief glossary of the formats used.
*
* - Markdown {string}
* The stringified Markdown value. The value of the field is persisted
* (stored) in this format, and the stringified value is also used when the
* editor is in "raw" Markdown mode.
*
* - MDAST {object}
* Also loosely referred to as "Remark". MDAST stands for MarkDown AST
* (Abstract Syntax Tree), and is an object representation of a Markdown
* document. Underneath, it's a Unist tree with a Markdown-specific schema. An
* MDAST is used as the source of truth for any Markdown field within the CMS
* once the Markdown string value is loaded. MDAST syntax is a part of the
* Unified ecosystem, and powers the Remark processor, so Remark plugins may
* be used.
*
* - HAST {object}
* Also loosely referred to as "Rehype". HAST, similar to MDAST, is an object
* representation of an HTML document. The field value takes this format
* temporarily before the document is stringified to HTML.
*
* - HTML {string}
* The field value is stringifed to HTML for preview purposes - the HTML value
* is never parsed, it is output only.
*
* - Slate Raw AST {object}
* Slate's Raw AST is a very simple and unopinionated object representation of
* a document in a Slate editor. We define our own Markdown-specific schema
* for serialization to/from Slate's Raw AST and MDAST.
*
* Overview of the Markdown widget serialization life cycle:
*
* - Entry Load
* When an entry is loaded, all Markdown widget values are serialized to
* MDAST within the entry draft.
*
* - Visual Editor Render
* When a Markdown widget using the visual editor renders, it converts the
* MDAST value from the entry draft to Slate's Raw AST, and renders that.
*
* - Visual Editor Update
* When the value of a Markdown field is changed in the visual editor, the
* resulting Slate Raw AST is converted back to MDAST, and the MDAST value is
* set as the new state of the field in the entry draft.
*
* - Visual Editor Paste
* When a value is pasted to the visual editor, the pasted value is checked
* for HTML data. If HTML is found, the value is deserialized to an HAST, then
* to MDAST, and finally to Slate's Raw AST. If no HTML is found, the plain
* text value of the paste is serialized to Slate's Raw AST via the Slate
* Plain serializer. The deserialized fragment is then inserted to the Slate
* document.
*
* - Raw Editor Render
* When a Markdown widget using the raw editor (Markdown switch activated),
* it stringifies the MDAST from the entry draft to Markdown, and runs the
* stringified Markdown through Slate's Plain serializer, which outputs a
* Slate Raw AST of the plain text, which is then rendered in the editor.
*
* - Raw Editor Update
* When the value of a Markdown field is changed in the raw editor, the
* resulting Slate Raw AST is stringified back to a string, and the string
* value is then parsed as Markdown into an MDAST. The MDAST value is
* set as the new state of the field in the entry draft.
*
* - Raw Editor Paste
* When a value is pasted to the raw editor, the text value of the paste is
* serialized to Slate's Raw AST via the Slate Plain serializer. The
* deserialized fragment is then inserted to the Slate document.
*
* - Preview Pane Render
* When the preview pane renders the value of a Markdown widget, it first
* converts the MDAST value to HAST, stringifies the HAST to HTML, and
* renders that.
*
* - Entry Persist (Save)
* On persist, the MDAST value in the entry draft is stringified back to
* a Markdown string for storage.
*/
/**
* Deserialize a Markdown string to an MDAST.
*/
export const markdownToRemark = markdown => {
/**
* Disabling tokenizers allows us to turn off features within the Remark
* parser.
*/
function disableTokenizers() {
/**
* Turn off soft breaks until we can properly support them across both
* editors.
*/
pull(this.Parser.prototype.inlineMethods, 'break');
}
/**
* Parse the Markdown string input to an MDAST.
*/
const parsed = unified()
.use(markdownToRemarkPlugin, { fences: true, pedantic: true, commonmark: true })
.use(disableTokenizers)
.parse(markdown);
/**
* Further transform the MDAST with plugins.
*/
const result = unified()
.use(remarkSquashReferences)
.use(remarkImagesToText)
.use(remarkShortcodes, { plugins: registry.getEditorComponents() })
.runSync(parsed);
return result;
};
/**
* Serialize an MDAST to a Markdown string.
*/
export const remarkToMarkdown = obj => {
/**
* Rewrite the remark-stringify text visitor to simply return the text value,
* without encoding or escaping any characters. This means we're completely
* trusting the markdown that we receive.
*/
function remarkAllowAllText() {
const Compiler = this.Compiler;
const visitors = Compiler.prototype.visitors;
visitors.text = node => node.value;
};
/**
* Provide an empty MDAST if no value is provided.
*/
const mdast = obj || u('root', [u('paragraph', [u('text', '')])]);
const markdown = unified()
.use(remarkToMarkdownPlugin, { listItemIndent: '1', fences: true, pedantic: true, commonmark: true })
.use(remarkAllowAllText)
.stringify(mdast);
return markdown;
};
/**
* Convert an MDAST to an HTML string.
*/
export const remarkToHtml = (mdast, getAsset) => {
const hast = unified()
.use(remarkToRehypeShortcodes, { plugins: registry.getEditorComponents(), getAsset })
.use(remarkToRehype, { allowDangerousHTML: true })
.runSync(mdast);
const html = unified()
.use(rehypeToHtml, { allowDangerousHTML: true, allowDangerousCharacters: true })
.stringify(hast);
return html;
}
/**
* Deserialize an HTML string to Slate's Raw AST. Currently used for HTML
* pastes.
*/
export const htmlToSlate = html => {
const hast = unified()
.use(htmlToRehype, { fragment: true })
.parse(html);
const mdast = unified()
.use(rehypePaperEmoji)
.use(rehypeToRemark, { minify: false })
.runSync(hast);
const slateRaw = unified()
.use(remarkAssertParents)
.use(remarkPaddedLinks)
.use(remarkImagesToText)
.use(remarkShortcodes, { plugins: registry.getEditorComponents() })
.use(remarkWrapHtml)
.use(remarkToSlatePlugin)
.runSync(mdast);
return slateRaw;
};
/**
* Convert an MDAST to Slate's Raw AST.
*/
export const remarkToSlate = mdast => {
const result = unified()
.use(remarkWrapHtml)
.use(remarkToSlatePlugin)
.runSync(mdast);
return result;
};
/**
* Convert a Slate Raw AST to MDAST.
*
* Requires shortcode plugins to parse shortcode nodes back to text.
*
* Note that Unified is not utilized for the conversion from Slate's Raw AST to
* MDAST. The conversion is manual because Unified can only operate on Unist
* trees.
*/
export const slateToRemark = (raw) => {
const mdast = slateToRemarkParser(raw, { shortcodePlugins: registry.getEditorComponents() });
return mdast;
};

View File

@ -0,0 +1,15 @@
/**
* Dropbox Paper outputs emoji characters as images, and stores the actual
* emoji character in a `data-emoji-ch` attribute on the image. This plugin
* replaces the images with the emoji characters.
*/
export default function rehypePaperEmoji() {
const transform = node => {
if (node.tagName === 'img' && node.properties.dataEmojiCh) {
return { type: 'text', value: node.properties.dataEmojiCh };
}
node.children = node.children ? node.children.map(transform) : node.children;
return node;
};
return transform;
}

View File

@ -0,0 +1,83 @@
import { concat, last, nth, isEmpty, set } from 'lodash';
import visitParents from 'unist-util-visit-parents';
/**
* remarkUnwrapInvalidNest
*
* Some MDAST node types can only be nested within specific node types - for
* example, a paragraph can't be nested within another paragraph, and a heading
* can't be nested in a "strong" type node. This kind of invalid MDAST can be
* generated by rehype-remark from invalid HTML.
*
* This plugin finds instances of invalid nesting, and unwraps the invalidly
* nested nodes as far up the parental line as necessary, splitting parent nodes
* along the way. The resulting node has no invalidly nested nodes, and all
* validly nested nodes retain their ancestry. Nodes that are emptied as a
* result of unnesting nodes are removed from the tree.
*/
export default function remarkUnwrapInvalidNest() {
return transform;
function transform(tree) {
const invalidNest = findInvalidNest(tree);
if (!invalidNest) return tree;
splitTreeAtNest(tree, invalidNest);
return transform(tree);
}
/**
* visitParents uses unist-util-visit-parent to check every node in the
* tree while having access to every ancestor of the node. This is ideal
* for determining whether a block node has an ancestor that should not
* contain a block node. Note that it operates in a mutable fashion.
*/
function findInvalidNest(tree) {
/**
* Node types that are considered "blocks".
*/
const blocks = ['paragraph', 'heading', 'code', 'blockquote', 'list', 'table', 'thematicBreak'];
/**
* Node types that can contain "block" nodes as direct children. We check
*/
const canContainBlocks = ['root', 'blockquote', 'listItem', 'tableCell'];
let invalidNest;
visitParents(tree, (node, parents) => {
const parentType = !isEmpty(parents) && last(parents).type;
const isInvalidNest = blocks.includes(node.type) && !canContainBlocks.includes(parentType);
if (isInvalidNest) {
invalidNest = concat(parents, node);
return false;
}
});
return invalidNest;
}
function splitTreeAtNest(tree, nest) {
const grandparent = nth(nest, -3) || tree;
const parent = nth(nest, -2);
const node = last(nest);
const splitIndex = grandparent.children.indexOf(parent);
const splitChildren = grandparent.children;
const splitChildIndex = parent.children.indexOf(node);
const childrenBefore = parent.children.slice(0, splitChildIndex);
const childrenAfter = parent.children.slice(splitChildIndex + 1);
const nodeBefore = !isEmpty(childrenBefore) && { ...parent, children: childrenBefore };
const nodeAfter = !isEmpty(childrenAfter) && { ...parent, children: childrenAfter };
const childrenToInsert = [nodeBefore, node, nodeAfter].filter(val => !isEmpty(val));
const beforeChildren = splitChildren.slice(0, splitIndex);
const afterChildren = splitChildren.slice(splitIndex + 1);
const newChildren = concat(beforeChildren, childrenToInsert, afterChildren);
grandparent.children = newChildren;
}
}

View File

@ -0,0 +1,18 @@
/**
* Images must be parsed as shortcodes for asset proxying. This plugin converts
* MDAST image nodes back to text to allow shortcode pattern matching.
*/
export default function remarkImagesToText() {
return transform;
function transform(node) {
const children = node.children ? node.children.map(transform) : node.children;
if (node.type === 'image') {
const alt = node.alt || '';
const url = node.url || '';
const title = node.title ? ` "${node.title}"` : '';
return { type: 'text', value: `![${alt}](${url}${title})` };
}
return { ...node, children };
}
}

View File

@ -0,0 +1,120 @@
import {
get,
set,
find,
findLast,
startsWith,
endsWith,
trimStart,
trimEnd,
concat,
flatMap
} from 'lodash';
import u from 'unist-builder';
import toString from 'mdast-util-to-string';
/**
* Convert leading and trailing spaces in a link to single spaces outside of the
* link. MDASTs derived from pasted Google Docs HTML require this treatment.
*
* Note that, because we're potentially replacing characters in a link node's
* children with character's in a link node's siblings, we have to operate on a
* parent (link) node and its children at once, rather than just processing
* children one at a time.
*/
export default function remarkPaddedLinks() {
function transform(node) {
/**
* Because we're operating on link nodes and their children at once, we can
* exit if the current node has no children.
*/
if (!node.children) return node;
/**
* Process a node's children if any of them are links. If a node is a link
* with leading or trailing spaces, we'll get back an array of nodes instead
* of a single node, so we use `flatMap` to keep those nodes as siblings
* with the other children.
*
* If performance improvements are found desirable, we could change this to
* only pass in the link nodes instead of the entire array of children, but
* this seems unlikely to produce a noticeable perf gain.
*/
const hasLinkChild = node.children.some(child => child.type === 'link');
const processedChildren = hasLinkChild ? flatMap(node.children, transformChildren) : node.children;
/**
* Run all children through the transform recursively.
*/
const children = processedChildren.map(transform);
return { ...node, children };
};
function transformChildren(node) {
if (node.type !== 'link') return node;
/**
* Get the node's complete string value, check for leading and trailing
* whitespace, and get nodes from each edge where whitespace is found.
*/
const text = toString(node);
const leadingWhitespaceNode = startsWith(text, ' ') && getEdgeTextChild(node);
const trailingWhitespaceNode = endsWith(text, ' ') && getEdgeTextChild(node, true);
if (!leadingWhitespaceNode && !trailingWhitespaceNode) return node;
/**
* Trim the edge nodes in place. Unified handles everything in a mutable
* fashion, so it's often simpler to do the same when working with Unified
* ASTs.
*/
if (leadingWhitespaceNode) {
leadingWhitespaceNode.value = trimStart(leadingWhitespaceNode.value);
}
if (trailingWhitespaceNode) {
trailingWhitespaceNode.value = trimEnd(trailingWhitespaceNode.value);
}
/**
* Create an array of nodes. The first and last child will either be `false`
* or a text node. We filter out the false values before returning.
*/
const nodes = [
leadingWhitespaceNode && u('text', ' '),
node,
trailingWhitespaceNode && u('text', ' ')
];
return nodes.filter(val => val);
}
/**
* Get the first or last non-blank text child of a node, regardless of
* nesting. If `end` is truthy, get the last node, otherwise first.
*/
function getEdgeTextChild(node, end) {
const findFn = end ? findLast : find;
let edgeChildWithValue;
setEdgeChildWithValue(node);
return edgeChildWithValue;
/**
* searchChildren checks a node and all of it's children deeply to find a
* non-blank text value. When the text node is found, we set it in an outside
* variable, as it may be deep in the tree and therefore wouldn't be returned
* by `find`/`findLast`.
*/
function setEdgeChildWithValue(child) {
if (!edgeChildWithValue && child.value) {
edgeChildWithValue = child;
}
findFn(child.children, setEdgeChildWithValue);
}
}
return transform;
}

View File

@ -0,0 +1,50 @@
import { map, has } from 'lodash';
import { renderToString } from 'react-dom/server';
import u from 'unist-builder';
/**
* This plugin doesn't actually transform Remark (MDAST) nodes to Rehype
* (HAST) nodes, but rather, it prepares an MDAST shortcode node for HAST
* conversion by replacing the shortcode text with stringified HTML for
* previewing the shortcode output.
*/
export default function remarkToRehypeShortcodes({ plugins, getAsset }) {
return transform;
function transform(root) {
const transformedChildren = map(root.children, processShortcodes);
return { ...root, children: transformedChildren };
}
/**
* Mapping function to transform nodes that contain shortcodes.
*/
function processShortcodes(node) {
/**
* If the node doesn't contain shortcode data, return the original node.
*/
if (!has(node, ['data', 'shortcode'])) return node;
/**
* Get shortcode data from the node, and retrieve the matching plugin by
* key.
*/
const { shortcode, shortcodeData } = node.data;
const plugin = plugins.get(shortcode);
/**
* Run the shortcode plugin's `toPreview` method, which will return either
* an HTML string or a React component. If a React component is returned,
* render it to an HTML string.
*/
const value = plugin.toPreview(shortcodeData, getAsset);
const valueHtml = typeof value === 'string' ? value : renderToString(value);
/**
* Return a new 'html' type node containing the shortcode preview markup.
*/
const textNode = u('html', valueHtml);
const children = [ textNode ];
return { ...node, children };
}
}

View File

@ -0,0 +1,99 @@
import { map, every } from 'lodash';
import u from 'unist-builder';
import mdastToString from 'mdast-util-to-string';
/**
* Parse shortcodes from an MDAST.
*
* Shortcodes are plain text, and must be the lone content of a paragraph. The
* paragraph must also be a direct child of the root node. When a shortcode is
* found, we just need to add data to the node so the shortcode can be
* identified and processed when serializing to a new format. The paragraph
* containing the node is also recreated to ensure normalization.
*/
export default function remarkShortcodes({ plugins }) {
return transform;
/**
* Map over children of the root node and convert any found shortcode nodes.
*/
function transform(root) {
const transformedChildren = map(root.children, processShortcodes);
return { ...root, children: transformedChildren };
}
/**
* Mapping function to transform nodes that contain shortcodes.
*/
function processShortcodes(node) {
/**
* If the node is not eligible to contain a shortcode, return the original
* node unchanged.
*/
if (!nodeMayContainShortcode(node)) return node;
/**
* Combine the text values of all children to a single string, check the
* string for a shortcode pattern match, and validate the match.
*/
const text = mdastToString(node).trim();
const { plugin, match } = matchTextToPlugin(text);
const matchIsValid = validateMatch(text, match);
/**
* If a valid match is found, return a new node with shortcode data
* included. Otherwise, return the original node.
*/
return matchIsValid ? createShortcodeNode(text, plugin, match) : node;
};
/**
* Ensure that the node and it's children are acceptable types to contain
* shortcodes. Currently, only a paragraph containing text and/or html nodes
* may contain shortcodes.
*/
function nodeMayContainShortcode(node) {
const validNodeTypes = ['paragraph'];
const validChildTypes = ['text', 'html'];
if (validNodeTypes.includes(node.type)) {
return every(node.children, child => {
return validChildTypes.includes(child.type);
});
}
}
/**
* Return the plugin and RegExp.match result from the first plugin with a
* pattern that matches the given text.
*/
function matchTextToPlugin(text) {
let match;
const plugin = plugins.find(p => {
match = text.match(p.pattern);
return !!match;
});
return { plugin, match };
}
/**
* A match is only valid if it takes up the entire paragraph.
*/
function validateMatch(text, match) {
return match && match[0].length === text.length;
}
/**
* Create a new node with shortcode data included. Use an 'html' node instead
* of a 'text' node as the child to ensure the node content is not parsed by
* Remark or Rehype. Include the child as an array because an MDAST paragraph
* node must have it's children in an array.
*/
function createShortcodeNode(text, plugin, match) {
const shortcode = plugin.id;
const shortcodeData = plugin.fromBlock(match);
const data = { shortcode, shortcodeData };
const textNode = u('html', text);
return u('paragraph', { data }, [textNode]);
}
}

View File

@ -0,0 +1,293 @@
import { get, isEmpty, isArray } from 'lodash';
import u from 'unist-builder';
/**
* Map of MDAST node types to Slate node types.
*/
const typeMap = {
root: 'root',
paragraph: 'paragraph',
blockquote: 'quote',
code: 'code',
listItem: 'list-item',
table: 'table',
tableRow: 'table-row',
tableCell: 'table-cell',
thematicBreak: 'thematic-break',
link: 'link',
image: 'image',
shortcode: 'shortcode',
};
/**
* Map of MDAST node types to Slate mark types.
*/
const markMap = {
strong: 'bold',
emphasis: 'italic',
delete: 'strikethrough',
inlineCode: 'code',
};
/**
* Create a Slate Inline node.
*/
function createBlock(type, nodes, props = {}) {
if (!isArray(nodes)) {
props = nodes;
nodes = undefined;
}
return { kind: 'block', type, nodes, ...props };
}
/**
* Create a Slate Block node.
*/
function createInline(type, nodes, props = {}) {
return { kind: 'inline', type, nodes, ...props };
}
/**
* Create a Slate Raw text node.
*/
function createText(value, data) {
const node = { kind: 'text', data };
if (isArray(value)) {
return { ...node, ranges: value };
}
return {...node, text: value };
}
function convertMarkNode(node, parentMarks = []) {
/**
* Add the current node's mark type to the marks collected from parent
* mark nodes, if any.
*/
const markType = markMap[node.type];
const marks = markType ? [...parentMarks, { type: markMap[node.type] }] : parentMarks;
/**
* Set an array to collect sections of text.
*/
const ranges = [];
node.children && node.children.forEach(childNode => {
/**
* If a text node is a direct child of the current node, it should be
* set aside as a range, and all marks that have been collected in the
* `marks` array should apply to that specific range.
*/
if (['html', 'text'].includes(childNode.type)) {
ranges.push({ text: childNode.value, marks });
return;
}
/**
* Any non-text child node should be processed as a parent node. The
* recursive results should be pushed into the ranges array. This way,
* every MDAST nested text structure becomes a flat array of ranges
* that can serve as the value of a single Slate Raw text node.
*/
const nestedRanges = convertMarkNode(childNode, marks);
ranges.push(...nestedRanges);
});
return ranges;
}
/**
* Convert a single MDAST node to a Slate Raw node. Uses local node factories
* that mimic the unist-builder function utilized in the slateRemark
* transformer.
*/
function convertNode(node, nodes) {
/**
* Unified/Remark processors use mutable operations, so we don't want to
* change a node's type directly for conversion purposes, as that tends to
* unexpected errors.
*/
const type = get(node, ['data', 'shortcode']) ? 'shortcode' : node.type;
switch (type) {
/**
* General
*
* Convert simple cases that only require a type and children, with no
* additional properties.
*/
case 'root':
case 'paragraph':
case 'listItem':
case 'blockquote':
case 'tableRow':
case 'tableCell': {
return createBlock(typeMap[type], nodes);
}
/**
* Shortcodes
*
* Shortcode nodes are represented as "void" blocks in the Slate AST. They
* maintain the same data as MDAST shortcode nodes. Slate void blocks must
* contain a blank text node.
*/
case 'shortcode': {
const { data } = node;
const nodes = [ createText('') ];
return createBlock(typeMap[type], nodes, { data, isVoid: true });
}
/**
* Text
*
* Text and HTML nodes are both used to render text, and should be treated
* the same. HTML is treated as text because we never want to escape or
* encode it.
*/
case 'text':
case 'html': {
return createText(node.value, node.data);
}
/**
* Inline Code
*
* Inline code nodes from an MDAST are represented in our Slate schema as
* text nodes with a "code" mark. We manually create the "range" containing
* the inline code value and a "code" mark, and place it in an array for use
* as a Slate text node's children array.
*/
case 'inlineCode': {
const range = {
text: node.value,
marks: [{ type: 'code' }],
};
return createText([ range ]);
}
/**
* Marks
*
* Marks are typically decorative sub-types that apply to text nodes. In an
* MDAST, marks are nodes that can contain other nodes. This nested
* hierarchy has to be flattened and split into distinct text nodes with
* their own set of marks.
*/
case 'strong':
case 'emphasis':
case 'delete': {
return createText(convertMarkNode(node));
}
/**
* Headings
*
* MDAST headings use a single type with a separate "depth" property to
* indicate the heading level, while the Slate schema uses a separate node
* type for each heading level. Here we get the proper Slate node name based
* on the MDAST node depth.
*/
case 'heading': {
const depthMap = { 1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six' };
const slateType = `heading-${depthMap[node.depth]}`;
return createBlock(slateType, nodes);
}
/**
* Code Blocks
*
* MDAST code blocks are a distinct node type with a simple text value. We
* convert that value into a nested child text node for Slate. We also carry
* over the "lang" data property if it's defined.
*/
case 'code': {
const data = { lang: node.lang };
const text = createText(node.value);
const nodes = [text];
return createBlock(typeMap[type], nodes, { data });
}
/**
* Lists
*
* MDAST has a single list type and an "ordered" property. We derive that
* information into the Slate schema's distinct list node types. We also
* include the "start" property, which indicates the number an ordered list
* starts at, if defined.
*/
case 'list': {
const slateType = node.ordered ? 'numbered-list' : 'bulleted-list';
const data = { start: node.start };
return createBlock(slateType, nodes, { data });
}
/**
* Thematic Breaks
*
* Thematic breaks are void nodes in the Slate schema.
*/
case 'thematicBreak': {
return createBlock(typeMap[type], { isVoid: true });
}
/**
* Links
*
* MDAST stores the link attributes directly on the node, while our Slate
* schema references them in the data object.
*/
case 'link': {
const { title, url } = node;
const data = { title, url };
return createInline(typeMap[type], nodes, { data });
}
/**
* Tables
*
* Tables are parsed separately because they may include an "align"
* property, which should be passed to the Slate node.
*/
case 'table': {
const data = { align: node.align };
return createBlock(typeMap[type], nodes, { data });
}
}
}
/**
* A Remark plugin for converting an MDAST to Slate Raw AST. Remark plugins
* return a `transform` function that receives the MDAST as it's first argument.
*/
export default function remarkToSlatePlugin() {
function transform(node) {
/**
* Call `transform` recursively on child nodes.
*
* If a node returns a falsey value, filter it out. Some nodes do not
* translate from MDAST to Slate, such as definitions for link/image
* references or footnotes.
*/
const children = !isEmpty(node.children) && node.children.map(transform).filter(val => val);
/**
* Run individual nodes through the conversion factory.
*/
return convertNode(node, children);
}
return transform;
}

View File

@ -0,0 +1,65 @@
import { without } from 'lodash';
import u from 'unist-builder';
import mdastDefinitions from 'mdast-util-definitions';
/**
* Raw markdown may contain image references or link references. Because there
* is no way to maintain these references within the Slate AST, we convert image
* and link references to standard images and links by putting their url's
* inline. The definitions are then removed from the document.
*
* For example, the following markdown:
*
* ```
* ![alpha][bravo]
*
* [bravo]: http://example.com/example.jpg
* ```
*
* Yields:
*
* ```
* ![alpha](http://example.com/example.jpg)
* ```
*
*/
export default function remarkSquashReferences() {
return getTransform;
function getTransform(node) {
const getDefinition = mdastDefinitions(node);
return transform.call(null, getDefinition, node);
}
function transform(getDefinition, node) {
/**
* Bind the `getDefinition` function to `transform` and recursively map all
* nodes.
*/
const boundTransform = transform.bind(null, getDefinition);
const children = node.children ? node.children.map(boundTransform) : node.children;
/**
* Combine reference and definition nodes into standard image and link
* nodes.
*/
if (['imageReference', 'linkReference'].includes(node.type)) {
const type = node.type === 'imageReference' ? 'image' : 'link';
const { title, url } = getDefinition(node.identifier) || {};
return u(type, { title, url, alt: node.alt }, children);
}
/**
* Remove definition nodes and filter the resulting null values from the
* filtered children array.
*/
if(node.type === 'definition') {
return null;
}
const filteredChildren = without(children, null);
return { ...node, children: filteredChildren };
}
}

View File

@ -0,0 +1,21 @@
import u from 'unist-builder';
/**
* Ensure that top level 'html' type nodes are wrapped in paragraphs. Html nodes
* are used for text nodes that we don't want Remark or Rehype to parse.
*/
export default function remarkWrapHtml() {
function transform(tree) {
tree.children = tree.children.map(node => {
if (node.type === 'html') {
return u('paragraph', [node]);
}
return node;
});
return tree;
}
return transform;
}

View File

@ -0,0 +1,330 @@
import { get, isEmpty, concat, without, flatten } from 'lodash';
import u from 'unist-builder';
/**
* Map of Slate node types to MDAST/Remark node types.
*/
const typeMap = {
'root': 'root',
'paragraph': 'paragraph',
'heading-one': 'heading',
'heading-two': 'heading',
'heading-three': 'heading',
'heading-four': 'heading',
'heading-five': 'heading',
'heading-six': 'heading',
'quote': 'blockquote',
'code': 'code',
'numbered-list': 'list',
'bulleted-list': 'list',
'list-item': 'listItem',
'table': 'table',
'table-row': 'tableRow',
'table-cell': 'tableCell',
'thematic-break': 'thematicBreak',
'link': 'link',
'image': 'image',
};
/**
* Map of Slate mark types to MDAST/Remark node types.
*/
const markMap = {
bold: 'strong',
italic: 'emphasis',
strikethrough: 'delete',
code: 'inlineCode',
};
/**
* Slate treats inline code decoration as a standard mark, but MDAST does
* not allow inline code nodes to contain children, only a single text
* value. An MDAST inline code node can be nested within mark nodes such
* as "emphasis" and "strong", but it cannot contain them.
*
* Because of this, if a "code" mark (translated to MDAST "inlineCode") is
* in the markTypes array, we make the base text node an "inlineCode" type
* instead of a standard text node.
*/
function processCodeMark(markTypes) {
const isInlineCode = markTypes.includes('inlineCode');
const filteredMarkTypes = isInlineCode ? without(markTypes, 'inlineCode') : markTypes;
const textNodeType = isInlineCode ? 'inlineCode' : 'html';
return { filteredMarkTypes, textNodeType };
}
/**
* Wraps a text node in one or more mark nodes by placing the text node in an
* array and using that as the `children` value of a mark node. The resulting
* mark node is then placed in an array and used as the child of a mark node for
* the next mark type in `markTypes`. This continues for each member of
* `markTypes`. If `markTypes` is empty, the original text node is returned.
*/
function wrapTextWithMarks(textNode, markTypes) {
const wrapTextWithMark = (childNode, markType) => u(markType, [childNode]);
return markTypes.reduce(wrapTextWithMark, textNode);
}
/**
* Converts a Slate Raw text node to an MDAST text node.
*
* Slate text nodes without marks often simply have a "text" property with
* the value. In this case the conversion to MDAST is simple. If a Slate
* text node does not have a "text" property, it will instead have a
* "ranges" property containing an array of objects, each with an array of
* marks, such as "bold" or "italic", along with a "text" property.
*
* MDAST instead expresses such marks in a nested structure, with individual
* nodes for each mark type nested until the deepest mark node, which will
* contain the text node.
*
* To convert a Slate text node's marks to MDAST, we treat each "range" as a
* separate text node, convert the text node itself to an MDAST text node,
* and then recursively wrap the text node for each mark, collecting the results
* of each range in a single array of child nodes.
*
* For example, this Slate text node:
*
* {
* kind: 'text',
* ranges: [
* {
* text: 'test',
* marks: ['bold', 'italic']
* },
* {
* text: 'test two'
* }
* ]
* }
*
* ...would be converted to this MDAST nested structure:
*
* [
* {
* type: 'strong',
* children: [{
* type: 'emphasis',
* children: [{
* type: 'text',
* value: 'test'
* }]
* }]
* },
* {
* type: 'text',
* value: 'test two'
* }
* ]
*
* This example also demonstrates how a single Slate node may need to be
* replaced with multiple MDAST nodes, so the resulting array must be flattened.
*/
function convertTextNode(node) {
/**
* If the Slate text node has no "ranges" property, just return an equivalent
* MDAST node.
*/
if (!node.ranges) {
return u('html', node.text);
}
/**
* If there is no "text" property, convert the text range(s) to an array of
* one or more nested MDAST nodes.
*/
const textNodes = node.ranges.map(range => {
/**
* Get an array of the mark types, converted to their MDAST equivalent
* types.
*/
const { marks = [], text } = range;
const markTypes = marks.map(mark => markMap[mark.type]);
/**
* Code marks must be removed from the marks array, and the presence of a
* code mark changes the text node type that should be used.
*/
const { filteredMarkTypes, textNodeType } = processCodeMark(markTypes);
/**
* Create the base text node.
*/
const textNode = u(textNodeType, text);
/**
* Recursively wrap the base text node in the individual mark nodes, if
* any exist.
*/
return wrapTextWithMarks(textNode, filteredMarkTypes);
});
/**
* Since each range will be mapped into an array, we flatten the result to
* return a single array of all nodes.
*/
return flatten(textNodes);
}
/**
* Convert a single Slate Raw node to an MDAST node. Uses the unist-builder `u`
* function to create MDAST nodes and parses shortcodes.
*/
function convertNode(node, children, shortcodePlugins) {
switch (node.type) {
/**
* General
*
* Convert simple cases that only require a type and children, with no
* additional properties.
*/
case 'root':
case 'paragraph':
case 'quote':
case 'list-item':
case 'table':
case 'table-row':
case 'table-cell': {
return u(typeMap[node.type], children);
}
/**
* Shortcodes
*
* Shortcode nodes only exist in Slate's Raw AST if they were inserted
* via the plugin toolbar in memory, so they should always have
* shortcode data attached. The "shortcode" data property contains the
* name of the registered shortcode plugin, and the "shortcodeData" data
* property contains the data received from the shortcode plugin's
* `fromBlock` method when the shortcode node was created.
*
* Here we get the shortcode plugin from the registry and use it's
* `toBlock` method to recreate the original markdown shortcode. We then
* insert that text into a new "html" type node (a "text" type node
* might get encoded or escaped by remark-stringify). Finally, we wrap
* the "html" node in a "paragraph" type node, as shortcode nodes must
* be alone in their own paragraph.
*/
case 'shortcode': {
const { data } = node;
const plugin = shortcodePlugins.get(data.shortcode);
const text = plugin.toBlock(data.shortcodeData);
const textNode = u('html', text);
return u('paragraph', { data }, [ textNode ]);
}
/**
* Headings
*
* Slate schemas don't usually infer basic type info from data, so each
* level of heading is a separately named type. The MDAST schema just
* has a single "heading" type with the depth stored in a "depth"
* property on the node. Here we derive the depth from the Slate node
* type - e.g., for "heading-two", we need a depth value of "2".
*/
case 'heading-one':
case 'heading-two':
case 'heading-three':
case 'heading-four':
case 'heading-five':
case 'heading-six': {
const depthMap = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
const depthText = node.type.split('-')[1];
const depth = depthMap[depthText];
return u(typeMap[node.type], { depth }, children);
}
/**
* Code Blocks
*
* Code block nodes have a single text child, and may have a code language
* stored in the "lang" data property. Here we transfer both the node
* value and the "lang" data property to the new MDAST node.
*/
case 'code': {
const value = get(node.nodes, [0, 'text']);
const lang = get(node.data, 'lang');
return u(typeMap[node.type], { lang }, value);
}
/**
* Lists
*
* Our Slate schema has separate node types for ordered and unordered
* lists, but the MDAST spec uses a single type with a boolean "ordered"
* property to indicate whether the list is numbered. The MDAST spec also
* allows for a "start" property to indicate the first number used for an
* ordered list. Here we translate both values to our Slate schema.
*/
case 'numbered-list':
case 'bulleted-list': {
const ordered = node.type === 'numbered-list';
const props = { ordered, start: get(node.data, 'start') || 1 };
return u(typeMap[node.type], props, children);
}
/**
* Thematic Breaks
*
* Thematic breaks don't have children. We parse them separately for
* clarity.
*/
case 'thematic-break': {
return u(typeMap[node.type]);
}
/**
* Links
*
* The url and title attributes of link nodes are stored in properties on
* the node for both Slate and Remark schemas.
*/
case 'link': {
const { url, title } = get(node, 'data', {});
return u(typeMap[node.type], { url, title }, children);
}
/**
* No default case is supplied because an unhandled case should never
* occur. In the event that it does, let the error throw (for now).
*/
}
}
export default function slateToRemark(raw, { shortcodePlugins }) {
/**
* The transform function mimics the approach of a Remark plugin for
* conformity with the other serialization functions. This function converts
* Slate nodes to MDAST nodes, and recursively calls itself to process child
* nodes to arbitrary depth.
*/
function transform(node) {
/**
* Call `transform` recursively on child nodes, and flatten the resulting
* array.
*/
const children = !isEmpty(node.nodes) && flatten(node.nodes.map(transform));
/**
* Run individual nodes through conversion factories.
*/
return node.kind === 'text' ? convertTextNode(node) : convertNode(node, children, shortcodePlugins);
}
/**
* The Slate Raw AST generally won't have a top level type, so we set it to
* "root" for clarity.
*/
raw.type = 'root';
const mdast = transform(raw);
return mdast;
}

View File

@ -1,38 +0,0 @@
@import "../../../UI/theme";
.root {
position: relative;
}
.editorControlBar {
composes: editorControlBar from "../VisualEditor/index.css";
}
.editorControlBarSticky {
composes: editorControlBarSticky from "../VisualEditor/index.css";
}
.dragging { }
.shim {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: none;
border: 2px dashed #aaa;
background: rgba(0,0,0,0.2);
}
.dragging .shim {
z-index: 1000;
display: block;
pointer-events: none;
}
.textarea {
overflow: hidden;
resize: none;
min-height: var(--richTextEditorMinHeight);
}

View File

@ -1,365 +0,0 @@
import React, { PropTypes } from 'react';
import MarkupIt from 'markup-it';
import markdownSyntax from 'markup-it/syntaxes/markdown';
import htmlSyntax from 'markup-it/syntaxes/html';
import CaretPosition from 'textarea-caret-position';
import TextareaAutosize from 'react-textarea-autosize';
import registry from '../../../../lib/registry';
import { createAssetProxy } from '../../../../valueObjects/AssetProxy';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../UI/Sticky/Sticky';
import styles from './index.css';
const HAS_LINE_BREAK = /\n/m;
const markdown = new MarkupIt(markdownSyntax);
const html = new MarkupIt(htmlSyntax);
function processUrl(url) {
if (url.match(/^(https?:\/\/|mailto:|\/)/)) {
return url;
}
if (url.match(/^[^/]+\.[^/]+/)) {
return `https://${ url }`;
}
return `/${ url }`;
}
function cleanupPaste(paste) {
const content = html.toContent(paste);
return markdown.toText(content);
}
function getCleanPaste(e) {
const transfer = e.clipboardData;
return new Promise((resolve) => {
const isHTML = !!Array.from(transfer.types).find(type => type === 'text/html');
if (isHTML) {
const data = transfer.getData('text/html');
// Avoid trying to clean up full HTML documents with head/body/etc
if (!data.match(/^\s*<!doctype/i)) {
e.preventDefault();
resolve(cleanupPaste(data));
} else {
// Handle complex pastes by stealing focus with a contenteditable div
const div = document.createElement('div');
div.contentEditable = true;
div.setAttribute(
'style', 'opacity: 0; overflow: hidden; width: 1px; height: 1px; position: fixed; top: 50%; left: 0;'
);
document.body.appendChild(div);
div.focus();
setTimeout(() => {
resolve(cleanupPaste(div.innerHTML));
document.body.removeChild(div);
}, 50);
return null;
}
}
e.preventDefault();
return resolve(transfer.getData(transfer.types[0]));
});
}
export default class RawEditor extends React.Component {
constructor(props) {
super(props);
const plugins = registry.getEditorComponents();
this.state = { plugins };
this.shortcuts = {
meta: {
b: this.handleBold,
i: this.handleItalic,
},
};
}
componentDidMount() {
this.updateHeight();
this.element.addEventListener('paste', this.handlePaste, false);
}
componentDidUpdate() {
if (this.newSelection) {
this.element.selectionStart = this.newSelection.start;
this.element.selectionEnd = this.newSelection.end;
this.newSelection = null;
}
}
componentWillUnmount() {
this.element.removeEventListener('paste', this.handlePaste);
}
getSelection() {
const start = this.element.selectionStart;
const end = this.element.selectionEnd;
const selected = (this.props.value || '').substr(start, end - start);
return { start, end, selected };
}
surroundSelection(chars) {
const selection = this.getSelection();
const newSelection = Object.assign({}, selection);
const { value } = this.props;
const escapedChars = chars.replace(/\*/g, '\\*');
const regexp = new RegExp(`^${ escapedChars }.*${ escapedChars }$`);
let changed = chars + selection.selected + chars;
if (regexp.test(selection.selected)) {
changed = selection.selected.substr(chars.length, selection.selected.length - (chars.length * 2));
newSelection.end = selection.end - (chars.length * 2);
} else if (
value.substr(selection.start - chars.length, chars.length) === chars &&
value.substr(selection.end, chars.length) === chars
) {
newSelection.start = selection.start - chars.length;
newSelection.end = selection.end + chars.length;
changed = selection.selected;
} else {
newSelection.end = selection.end + (chars.length * 2);
}
const beforeSelection = value.substr(0, selection.start);
const afterSelection = value.substr(selection.end);
this.newSelection = newSelection;
this.props.onChange(beforeSelection + changed + afterSelection);
}
replaceSelection(chars) {
const value = this.props.value || '';
const selection = this.getSelection();
const newSelection = Object.assign({}, selection);
const beforeSelection = value.substr(0, selection.start);
const afterSelection = value.substr(selection.end);
newSelection.end = selection.start + chars.length;
this.newSelection = newSelection;
this.props.onChange(beforeSelection + chars + afterSelection);
}
toggleHeader(header) {
const value = this.props.value || '';
const selection = this.getSelection();
const newSelection = Object.assign({}, selection);
const lastNewline = value.lastIndexOf('\n', selection.start);
const currentMatch = value.substr(lastNewline + 1).match(/^(#+)\s/);
const beforeHeader = value.substr(0, lastNewline + 1);
let afterHeader;
let chars;
if (currentMatch) {
afterHeader = value.substr(lastNewline + 1 + currentMatch[0].length);
chars = currentMatch[1] === header ? '' : `${ header } `;
const diff = chars.length - currentMatch[0].length;
newSelection.start += diff;
newSelection.end += diff;
} else {
afterHeader = value.substr(lastNewline + 1);
chars = `${ header } `;
newSelection.start += header.length + 1;
newSelection.end += header.length + 1;
}
this.newSelection = newSelection;
this.props.onChange(beforeHeader + chars + afterHeader);
}
updateHeight() {
if (this.element.scrollHeight > this.element.clientHeight) {
this.element.style.height = `${ this.element.scrollHeight }px`;
}
}
handleRef = (ref) => {
this.element = ref;
if (ref) {
this.caretPosition = new CaretPosition(ref);
}
};
handleKey = (e) => {
if (e.metaKey) {
const action = this.shortcuts.meta[e.key];
if (action) {
e.preventDefault();
action();
}
}
};
handleBold = () => {
this.surroundSelection('**');
};
handleItalic = () => {
this.surroundSelection('*');
};
handleLink = () => {
const url = prompt('URL:'); // eslint-disable-line no-alert
const selection = this.getSelection();
this.replaceSelection(`[${ selection.selected }](${ processUrl(url) })`);
};
handleSelection = () => {
const value = this.props.value || '';
const selection = this.getSelection();
if (selection.start !== selection.end && !HAS_LINE_BREAK.test(selection.selected)) {
try {
const selectionPosition = this.caretPosition.get(selection.start, selection.end);
this.setState({ selectionPosition });
} catch (e) {
console.log(e); // eslint-disable-line no-console
}
} else if (selection.start === selection.end) {
const newBlock =
(
(selection.start === 0 && value.substr(0, 1).match(/^\n?$/)) ||
value.substr(selection.start - 2, 2) === '\n\n') &&
(
selection.end === (value.length - 1) ||
value.substr(selection.end, 2) === '\n\n' ||
value.substr(selection.end).match(/\n*$/m)
);
if (newBlock) {
const position = this.caretPosition.get(selection.start, selection.end);
this.setState({ selectionPosition: position });
}
}
};
handleChange = (e) => {
this.props.onChange(e.target.value);
this.updateHeight();
};
handlePluginSubmit = (plugin, data) => {
const toBlock = plugin.get('toBlock');
this.replaceSelection(toBlock.call(toBlock, data.toJS()));
};
handleHeader(header) {
return () => {
this.toggleHeader(header);
};
}
handleDragEnter = (e) => {
e.preventDefault();
this.setState({ dragging: true });
};
handleDragLeave = (e) => {
e.preventDefault();
this.setState({ dragging: false });
};
handleDragOver = (e) => {
e.preventDefault();
};
handleDrop = (e) => {
e.preventDefault();
this.setState({ dragging: false });
let data;
if (e.dataTransfer.files && e.dataTransfer.files.length) {
data = Array.from(e.dataTransfer.files).map((file) => {
const link = `[Uploading ${ file.name }...]()`;
if (file.type.split('/')[0] === 'image') {
return `!${ link }`;
}
createAssetProxy(file.name, file)
.then((assetProxy) => {
this.props.onAddAsset(assetProxy);
// TODO: Change the link text
});
return link;
}).join('\n\n');
} else {
data = e.dataTransfer.getData('text/plain');
}
this.replaceSelection(data);
};
handlePaste = (e) => {
const { value, onChange } = this.props;
const selection = this.getSelection();
const beforeSelection = value.substr(0, selection.start);
const afterSelection = value.substr(selection.end);
getCleanPaste(e).then((paste) => {
const newSelection = Object.assign({}, selection);
newSelection.start = newSelection.end = beforeSelection.length + paste.length;
this.newSelection = newSelection;
onChange(beforeSelection + paste + afterSelection);
});
};
handleToggle = () => {
this.props.onMode('visual');
};
render() {
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
const { plugins, selectionPosition, dragging } = this.state;
const classNames = [styles.root];
if (dragging) {
classNames.push(styles.dragging);
}
return (<div
className={classNames.join(' ')}
onDragEnter={this.handleDragEnter}
onDragLeave={this.handleDragLeave}
onDragOver={this.handleDragOver}
onDrop={this.handleDrop}
>
<Sticky
className={styles.editorControlBar}
classNameActive={styles.editorControlBarSticky}
fillContainerWidth
>
<Toolbar
selectionPosition={selectionPosition}
onH1={this.handleHeader('#')}
onH2={this.handleHeader('##')}
onBold={this.handleBold}
onItalic={this.handleItalic}
onLink={this.handleLink}
onToggleMode={this.handleToggle}
plugins={plugins}
onSubmit={this.handlePluginSubmit}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
rawMode
/>
</Sticky>
<TextareaAutosize
className={styles.textarea}
inputRef={this.handleRef}
className={styles.textarea}
value={this.props.value || ''}
onKeyDown={this.handleKey}
onChange={this.handleChange}
onSelect={this.handleSelection}
/>
<div className={styles.shim} />
</div>);
}
}
RawEditor.propTypes = {
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
value: PropTypes.node,
};

View File

@ -1,116 +0,0 @@
const marks = {
'blockquote': {
// > ...
pattern: /^>(?:[\t ]*>)*/m,
alias: 'punctuation'
},
'code': [
{
// Prefixed by 4 spaces or 1 tab
pattern: /^(?: {4}|\t).+/m,
alias: 'keyword'
},
{
// `code`
// ``code``
pattern: /``.+?``|`[^`\n]+`/,
alias: 'keyword'
}
],
'title': [
{
// title 1
// =======
// title 2
// -------
pattern: /\w+.*(?:\r?\n|\r)(?:==+|--+)/,
alias: 'important',
inside: {
punctuation: /==+$|--+$/
}
},
{
// # title 1
// ###### title 6
pattern: /(^\s*)#+.+/m,
lookbehind: true,
alias: 'important',
inside: {
punctuation: /^#+|#+$/
}
}
],
'hr': {
// ***
// ---
// * * *
// -----------
pattern: /(^\s*)([*-])([\t ]*\2){2,}(?=\s*$)/m,
lookbehind: true,
alias: 'punctuation'
},
'list': {
// * item
// + item
// - item
// 1. item
pattern: /(^\s*)(?:[*+-]|\d+\.)(?=[\t ].)/m,
lookbehind: true,
alias: 'punctuation'
},
'url-reference': {
// [id]: http://example.com "Optional title"
// [id]: http://example.com 'Optional title'
// [id]: http://example.com (Optional title)
// [id]: <http://example.com> "Optional title"
pattern: /!?\[[^\]]+\]:[\t ]+(?:\S+|<(?:\\.|[^>\\])+>)(?:[\t ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\)))?/,
inside: {
'variable': {
pattern: /^(!?\[)[^\]]+/,
lookbehind: true
},
'string': /(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\))$/,
'punctuation': /^[\[\]!:]|[<>]/
},
alias: 'url'
},
'bold': {
// **strong**
// __strong__
// Allow only one line break
pattern: /(^|[^\\])(\*\*|__)(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/,
lookbehind: true,
inside: {
'punctuation': /^\*\*|^__|\*\*$|__$/
}
},
'italic': {
// *em*
// _em_
// Allow only one line break
pattern: /(^|[^\\])([*_])(?:(?:\r?\n|\r)(?!\r?\n|\r)|.)+?\2/,
lookbehind: true,
inside: {
'punctuation': /^[*_]|[*_]$/
}
},
'url': {
// [example](http://example.com "Optional title")
// [example] [id]
pattern: /!?\[[^\]]+\](?:\([^\s)]+(?:[\t ]+"(?:\\.|[^"\\])*")?\)| ?\[[^\]\n]*\])/,
inside: {
'variable': {
pattern: /(!?\[)[^\]]+(?=\]$)/,
lookbehind: true
},
'string': {
pattern: /"(?:\\.|[^"\\])*"(?=\)$)/
}
}
}
};
export default marks;

View File

@ -1,7 +0,0 @@
.control {
composes: control from "../../../ControlPanel/ControlPane.css"
}
.label {
composes: label from "../../../ControlPanel/ControlPane.css";
}

View File

@ -1,153 +0,0 @@
@import "../../../UI/theme";
.editorControlBar {
z-index: 1;
border: 2px solid transparent;
border-top: 0;
background-color: var(--controlBGColor);
}
.editorControlBarSticky {
border-color: var(--textFieldBorderColor);
}
.editor {
position: relative;
& h1, & h2, & h3 {
padding: 0;
color: #7c8382;
text-decoration: none;
border-bottom: none;
margin-bottom: 20px;
line-height: 1.45;
}
& h1 {
font-size: 2.5rem;
}
& h2 {
font-size: 2rem;
}
& h3 {
font-size: 1.8rem;
}
& p {
margin-top: 20px;
margin-bottom: 20px;
}
& hr {
border: 1px solid;
margin-bottom: 20px;
}
& li > p {
margin: 0;
}
& div[data-plugin] {
background: #fff;
border: 1px solid #aaa;
padding: 10px;
margin-bottom: 20px;
}
}
.dragging { }
.shim {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: none;
border: 2px dashed #aaa;
background: rgba(0,0,0,0.2);
}
.dragging .shim {
z-index: 1000;
display: block;
pointer-events: none;
}
:global {
& .ProseMirror {
position: relative;
background-color: var(--controlBGColor);
padding: 12px;
overflow: hidden;
border-radius: var(--borderRadius);
overflow-x: auto;
border: var(--textFieldBorder);
min-height: var(--richTextEditorMinHeight);
& ul,
& ol {
padding-left: 20px;
}
& pre > code {
display: block;
width: 100%;
overflow-y: auto;
background-color: #000;
color: #ccc;
border-radius: var(--borderRadius);
padding: 10px;
}
}
& .ProseMirror-content {
white-space: pre-wrap;
}
& .ProseMirror-drop-target {
position: absolute;
width: 1px;
background: #666;
pointer-events: none;
}
& .ProseMirror-content ul, & .ProseMirror-content ol {
padding-left: 30px;
cursor: default;
}
& .ProseMirror-content blockquote {
padding-left: 1em;
border-left: 3px solid #eee;
margin-left: 0; margin-right: 0;
}
& .ProseMirror-content pre {
white-space: pre-wrap;
}
& .ProseMirror-content li {
position: relative;
pointer-events: none; /* Don't do weird stuff with marker clicks */
}
& .ProseMirror-content li > * {
pointer-events: auto;
}
& .ProseMirror-nodeselection *::selection { background: transparent; }
& .ProseMirror-nodeselection *::-moz-selection { background: transparent; }
& .ProseMirror-selectednode {
outline: 2px solid #8cf;
}
/* Make sure li selections wrap around markers */
& li.ProseMirror-selectednode {
outline: none;
}
& li.ProseMirror-selectednode:after {
content: "";
position: absolute;
left: -32px;
right: -2px; top: -2px; bottom: -2px;
border: 2px solid #8cf;
pointer-events: none;
}
}

View File

@ -1,326 +0,0 @@
import React, { Component, PropTypes } from 'react';
import { Map } from 'immutable';
import { Schema } from 'prosemirror-model';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import history from 'prosemirror-history';
import {
blockQuoteRule, orderedListRule, bulletListRule, codeBlockRule, headingRule,
inputRules, allInputRules,
} from 'prosemirror-inputrules';
import { keymap } from 'prosemirror-keymap';
import { schema as markdownSchema, defaultMarkdownSerializer } from 'prosemirror-markdown';
import { baseKeymap, setBlockType, toggleMark } from 'prosemirror-commands';
import registry from '../../../../lib/registry';
import { createAssetProxy } from '../../../../valueObjects/AssetProxy';
import { buildKeymap } from './keymap';
import createMarkdownParser from './parser';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../UI/Sticky/Sticky';
import styles from './index.css';
function processUrl(url) {
if (url.match(/^(https?:\/\/|mailto:|\/)/)) {
return url;
}
if (url.match(/^[^/]+\.[^/]+/)) {
return `https://${ url }`;
}
return `/${ url }`;
}
const ruleset = {
blockquote: [blockQuoteRule],
ordered_list: [orderedListRule],
bullet_list: [bulletListRule],
code_block: [codeBlockRule],
heading: [headingRule, 6],
};
function buildInputRules(schema) {
return Map(ruleset)
.filter(rule => schema.nodes[rule])
.map(rule => rule[0].apply(rule[0].slice(1)))
.toArray();
}
function markActive(state, type) {
const { from, to, empty } = state.selection;
if (empty) {
return type.isInSet(state.storedMarks || state.doc.marksAt(from));
}
return state.doc.rangeHasMark(from, to, type);
}
function schemaWithPlugins(schema, plugins) {
let nodeSpec = schema.nodeSpec;
plugins.forEach((plugin) => {
const attrs = {};
plugin.get('fields').forEach((field) => {
attrs[field.get('name')] = { default: null };
});
nodeSpec = nodeSpec.addToEnd(`plugin_${ plugin.get('id') }`, {
attrs,
group: 'block',
parseDOM: [{
tag: 'div[data-plugin]',
getAttrs(dom) {
return JSON.parse(dom.getAttribute('data-plugin'));
},
}],
toDOM(node) {
return ['div', { 'data-plugin': JSON.stringify(node.attrs) }, plugin.get('label')];
},
});
});
return new Schema({
nodes: nodeSpec,
marks: schema.markSpec,
});
}
function createSerializer(schema, plugins) {
const serializer = Object.create(defaultMarkdownSerializer);
plugins.forEach((plugin) => {
serializer.nodes[`plugin_${ plugin.get('id') }`] = (state, node) => {
const toBlock = plugin.get('toBlock');
state.write(`${ toBlock.call(plugin, node.attrs) }\n\n`);
};
});
return serializer;
}
export default class Editor extends Component {
constructor(props) {
super(props);
const plugins = registry.getEditorComponents();
const schema = schemaWithPlugins(markdownSchema, plugins);
this.state = {
plugins,
schema,
parser: createMarkdownParser(schema, plugins),
serializer: createSerializer(schema, plugins),
};
}
componentDidMount() {
this.view = new EditorView(this.ref, {
state: this.createEditorState(),
onAction: this.handleAction,
});
}
createEditorState() {
const { schema, parser } = this.state;
const doc = parser.parse(this.props.value || '');
return EditorState.create({
doc,
schema,
plugins: [
inputRules({
rules: allInputRules.concat(buildInputRules(schema)),
}),
keymap(buildKeymap(schema)),
keymap(baseKeymap),
history.history(),
keymap({
'Mod-z': history.undo,
'Mod-y': history.redo,
}),
],
});
}
componentDidUpdate(prevProps, prevState) {
const editorValue = this.state.serializer.serialize(this.view.state.doc);
// Check that the content of the editor is well synchronized with the props value after rendering.
// Sometimes the editor isn't well updated (eg. after items reordering)
if (editorValue !== this.props.value && editorValue !== prevProps.value) {
// If the content of the editor isn't correct, we update its state with a new one.
this.view.updateState(this.createEditorState());
}
}
handleAction = (action) => {
const { serializer } = this.state;
const newState = this.view.state.applyAction(action);
const md = serializer.serialize(newState.doc);
this.props.onChange(md);
this.view.updateState(newState);
if (newState.selection !== this.state.selection) {
this.handleSelection(newState);
}
this.view.focus();
};
handleSelection = (state) => {
const { schema, selection } = state;
if (selection.from === selection.to) {
const { $from } = selection;
if ($from.parent && $from.parent.type === schema.nodes.paragraph && $from.parent.textContent === '') {
const pos = this.view.coordsAtPos(selection.from);
const editorPos = this.view.content.getBoundingClientRect();
const selectionPosition = { top: pos.top - editorPos.top, left: pos.left - editorPos.left };
this.setState({ selectionPosition });
}
} else {
const pos = this.view.coordsAtPos(selection.from);
const editorPos = this.view.content.getBoundingClientRect();
const selectionPosition = { top: pos.top - editorPos.top, left: pos.left - editorPos.left };
this.setState({ selectionPosition });
}
};
handleRef = (ref) => {
this.ref = ref;
};
handleHeader = level => (
() => {
const { schema } = this.state;
const state = this.view.state;
const { $from, to, node } = state.selection;
let nodeType = schema.nodes.heading;
let attrs = { level };
let inHeader = node && node.hasMarkup(nodeType, attrs);
if (!inHeader) {
inHeader = to <= $from.end() && $from.parent.hasMarkup(nodeType, attrs);
}
if (inHeader) {
nodeType = schema.nodes.paragraph;
attrs = {};
}
const command = setBlockType(nodeType, { level });
command(state, this.handleAction);
}
);
handleBold = () => {
const command = toggleMark(this.state.schema.marks.strong);
command(this.view.state, this.handleAction);
};
handleItalic = () => {
const command = toggleMark(this.state.schema.marks.em);
command(this.view.state, this.handleAction);
};
handleLink = () => {
let url = null;
if (!markActive(this.view.state, this.state.schema.marks.link)) {
url = prompt('Link URL:'); // eslint-disable-line no-alert
}
const command = toggleMark(this.state.schema.marks.link, { href: url ? processUrl(url) : null });
command(this.view.state, this.handleAction);
};
handlePluginSubmit = (plugin, data) => {
const { schema } = this.state;
const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`];
this.view.props.onAction(this.view.state.tr.replaceSelectionWith(nodeType.create(data.toJS())).action());
};
handleDragEnter = (e) => {
e.preventDefault();
this.setState({ dragging: true });
};
handleDragLeave = (e) => {
e.preventDefault();
this.setState({ dragging: false });
};
handleDragOver = (e) => {
e.preventDefault();
};
handleDrop = (e) => {
e.preventDefault();
this.setState({ dragging: false });
const { schema } = this.state;
const nodes = [];
if (e.dataTransfer.files && e.dataTransfer.files.length) {
Array.from(e.dataTransfer.files).forEach((file) => {
createAssetProxy(file.name, file)
.then((assetProxy) => {
this.props.onAddAsset(assetProxy);
if (file.type.split('/')[0] === 'image') {
nodes.push(
schema.nodes.image.create({ src: assetProxy.public_path, alt: file.name })
);
} else {
nodes.push(
schema.marks.link.create({ href: assetProxy.public_path, title: file.name })
);
}
});
});
} else {
nodes.push(schema.nodes.paragraph.create({}, e.dataTransfer.getData('text/plain')));
}
nodes.forEach((node) => {
this.view.props.onAction(this.view.state.tr.replaceSelectionWith(node).action());
});
};
handleToggle = () => {
this.props.onMode('raw');
};
render() {
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
const { plugins, selectionPosition, dragging } = this.state;
const classNames = [styles.editor];
if (dragging) {
classNames.push(styles.dragging);
}
return (<div
className={classNames.join(' ')}
onDragEnter={this.handleDragEnter}
onDragLeave={this.handleDragLeave}
onDragOver={this.handleDragOver}
onDrop={this.handleDrop}
>
<Sticky
className={styles.editorControlBar}
classNameActive={styles.editorControlBarSticky}
fillContainerWidth
>
<Toolbar
selectionPosition={selectionPosition}
onH1={this.handleHeader(1)}
onH2={this.handleHeader(2)}
onBold={this.handleBold}
onItalic={this.handleItalic}
onLink={this.handleLink}
onToggleMode={this.handleToggle}
plugins={plugins}
onSubmit={this.handlePluginSubmit}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
/>
</Sticky>
<div ref={this.handleRef} />
<div className={styles.shim} />
</div>);
}
}
Editor.propTypes = {
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
value: PropTypes.node,
};

View File

@ -1,92 +0,0 @@
const { wrapIn, setBlockType, chainCommands, newlineInCode, toggleMark } = require('prosemirror-commands');
const { selectNextCell, selectPreviousCell } = require('prosemirror-schema-table');
const { wrapInList, splitListItem, liftListItem, sinkListItem } = require('prosemirror-schema-list');
const { undo, redo } = require('prosemirror-history');
const mac = typeof navigator != 'undefined' ? /Mac/.test(navigator.platform) : false;
// :: (Schema, ?Object) → Object
// Inspect the given schema looking for marks and nodes from the
// basic schema, and if found, add key bindings related to them.
// This will add:
//
// * **Mod-b** for toggling [strong](#schema-basic.StrongMark)
// * **Mod-i** for toggling [emphasis](#schema-basic.EmMark)
// * **Mod-`** for toggling [code font](#schema-basic.CodeMark)
// * **Ctrl-Shift-0** for making the current textblock a paragraph
// * **Ctrl-Shift-1** to **Ctrl-Shift-Digit6** for making the current
// textblock a heading of the corresponding level
// * **Ctrl-Shift-Backslash** to make the current textblock a code block
// * **Ctrl-Shift-8** to wrap the selection in an ordered list
// * **Ctrl-Shift-9** to wrap the selection in a bullet list
// * **Ctrl->** to wrap the selection in a block quote
// * **Enter** to split a non-empty textblock in a list item while at
// the same time splitting the list item
// * **Mod-Enter** to insert a hard break
// * **Mod-_** to insert a horizontal rule
//
// You can suppress or map these bindings by passing a `mapKeys`
// argument, which maps key names (say `"Mod-B"` to either `false`, to
// remove the binding, or a new key name string.
function buildKeymap(schema, mapKeys) {
let keys = {}, type;
function bind(key, cmd) {
if (mapKeys) {
const mapped = mapKeys[key];
if (mapped === false) return;
if (mapped) key = mapped;
}
keys[key] = cmd;
}
bind('Mod-z', undo);
bind('Mod-y', redo);
if (type = schema.marks.strong)
bind('Mod-b', toggleMark(type));
if (type = schema.marks.em)
bind('Mod-i', toggleMark(type));
if (type = schema.marks.code)
bind('Mod-`', toggleMark(type));
if (type = schema.nodes.bullet_list)
bind('Shift-Ctrl-8', wrapInList(type));
if (type = schema.nodes.ordered_list)
bind('Shift-Ctrl-9', wrapInList(type));
if (type = schema.nodes.blockquote)
bind('Ctrl->', wrapIn(type));
if (type = schema.nodes.hard_break) {
let br = type, cmd = chainCommands(newlineInCode, (state, onAction) => {
onAction(state.tr.replaceSelection(br.create()).scrollAction());
return true;
});
bind('Mod-Enter', cmd);
bind('Shift-Enter', cmd);
if (mac) bind('Ctrl-Enter', cmd);
}
if (type = schema.nodes.list_item) {
bind('Enter', splitListItem(type));
bind('Mod-[', liftListItem(type));
bind('Mod-]', sinkListItem(type));
}
if (type = schema.nodes.paragraph)
bind('Shift-Ctrl-0', setBlockType(type));
if (type = schema.nodes.code_block)
bind('Shift-Ctrl-\\', setBlockType(type));
if (type = schema.nodes.heading)
for (let i = 1; i <= 6; i++) bind(`Shift-Ctrl-${ i }`, setBlockType(type, { level: i }));
if (type = schema.nodes.horizontal_rule) {
const hr = type;
bind('Mod-_', (state, onAction) => {
onAction(state.tr.replaceSelection(hr.create()).scrollAction());
return true;
});
}
if (schema.nodes.table_row) {
bind('Tab', selectNextCell);
bind('Shift-Tab', selectPreviousCell);
}
return keys;
}
exports.buildKeymap = buildKeymap;

View File

@ -1,257 +0,0 @@
/* eslint-disable */
/*
Based closely on
https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/from_markdown.js
Adds a bit of logic allowing editor plugins to hook into the parsing.
*/
const markdownit = require("markdown-it")
const {Mark} = require("prosemirror-model")
function maybeMerge(a, b) {
if (a.isText && b.isText && Mark.sameSet(a.marks, b.marks))
return a.copy(a.text + b.text)
}
function pluginHandler(schema, plugins) {
return (type, attrs, content) => {
if (type.name === 'paragraph' && content.length === 1 && content[0].type.name === 'text') {
const text = content[0].text;
const plugin = plugins.find(plugin => plugin.get('pattern').test(text));
if (plugin) {
const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`];
const data = plugin.get('fromBlock').call(plugin, text.match(plugin.get('pattern')));
return nodeType.create(data);
}
}
return null;
};
}
// Object used to track the context of a running parse.
class MarkdownParseState {
constructor(schema, plugins, tokenHandlers) {
this.schema = schema
this.stack = [{type: schema.nodes.doc, content: []}]
this.marks = Mark.none
this.tokenHandlers = tokenHandlers
this.pluginHandler = pluginHandler(schema, plugins);
}
top() {
return this.stack[this.stack.length - 1]
}
push(elt) {
if (this.stack.length) this.top().content.push(elt)
}
// : (string)
// Adds the given text to the current position in the document,
// using the current marks as styling.
addText(text) {
if (!text) return
let nodes = this.top().content, last = nodes[nodes.length - 1]
let node = this.schema.text(text, this.marks), merged
if (last && (merged = maybeMerge(last, node))) nodes[nodes.length - 1] = merged
else nodes.push(node)
}
// : (Mark)
// Adds the given mark to the set of active marks.
openMark(mark) {
this.marks = mark.addToSet(this.marks)
}
// : (Mark)
// Removes the given mark from the set of active marks.
closeMark(mark) {
this.marks = mark.removeFromSet(this.marks)
}
parseTokens(toks) {
for (let i = 0; i < toks.length; i++) {
let tok = toks[i]
let handler = this.tokenHandlers[tok.type]
if (!handler)
throw new Error("Token type `" + tok.type + "` not supported by Markdown parser")
handler(this, tok)
}
}
// : (NodeType, ?Object, ?[Node]) → ?Node
// Add a node at the current position.
addNode(type, attrs, content) {
const node = this.pluginHandler(type, attrs, content) || type.createAndFill(attrs, content, this.marks);
if (!node) return null
this.push(node)
return node
}
// : (NodeType, ?Object)
// Wrap subsequent content in a node of the given type.
openNode(type, attrs) {
this.stack.push({type: type, attrs: attrs, content: []})
}
// : () → ?Node
// Close and return the node that is currently on top of the stack.
closeNode() {
if (this.marks.length) this.marks = Mark.none
let info = this.stack.pop()
return this.addNode(info.type, info.attrs, info.content)
}
}
function attrs(given, token) {
return given instanceof Function ? given(token) : given
}
// Code content is represented as a single token with a `content`
// property in Markdown-it.
function noOpenClose(type) {
return type == "code_inline" || type == "code_block" || type == "fence"
}
function withoutTrailingNewline(str) {
return str[str.length - 1] == "\n" ? str.slice(0, str.length - 1) : str
}
function tokenHandlers(schema, tokens) {
let handlers = Object.create(null)
for (let type in tokens) {
let spec = tokens[type]
if (spec.block) {
let nodeType =schema.nodeType(spec.block);
if (noOpenClose(type)) {
handlers[type] = (state, tok) => {
state.openNode(nodeType, attrs(spec.attrs, tok))
state.addText(withoutTrailingNewline(tok.content))
state.closeNode()
}
} else {
handlers[type + "_open"] = (state, tok) => state.openNode(nodeType, attrs(spec.attrs, tok))
handlers[type + "_close"] = state => state.closeNode()
}
} else if (spec.node) {
let nodeType = schema.nodeType(spec.node)
handlers[type] = (state, tok) => state.addNode(nodeType, attrs(spec.attrs, tok))
} else if (spec.mark) {
let markType = schema.marks[spec.mark]
if (noOpenClose(type)) {
handlers[type] = (state, tok) => {
state.openMark(markType.create(attrs(spec.attrs, tok)))
state.addText(withoutTrailingNewline(tok.content))
state.closeMark(markType)
}
} else {
handlers[type + "_open"] = (state, tok) => state.openMark(markType.create(attrs(spec.attrs, tok)))
handlers[type + "_close"] = state => state.closeMark(markType)
}
} else {
throw new RangeError("Unrecognized parsing spec " + JSON.stringify(spec))
}
}
handlers.text = (state, tok) => state.addText(tok.content)
handlers.inline = (state, tok) => state.parseTokens(tok.children)
handlers.softbreak = state => state.addText("\n")
return handlers
}
// ;; A configuration of a Markdown parser. Such a parser uses
// [markdown-it](https://github.com/markdown-it/markdown-it) to
// tokenize a file, and then runs the custom rules it is given over
// the tokens to create a ProseMirror document tree.
class MarkdownParser {
// :: (Schema, MarkdownIt, Object)
// Create a parser with the given configuration. You can configure
// the markdown-it parser to parse the dialect you want, and provide
// a description of the ProseMirror entities those tokens map to in
// the `tokens` object, which maps token names to descriptions of
// what to do with them. Such a description is an object, and may
// have the following properties:
//
// **`node`**`: ?string`
// : This token maps to a single node, whose type can be looked up
// in the schema under the given name. Exactly one of `node`,
// `block`, or `mark` must be set.
//
// **`block`**`: ?string`
// : This token comes in `_open` and `_close` variants (which are
// appended to the base token name provides a the object
// property), and wraps a block of content. The block should be
// wrapped in a node of the type named to by the property's
// value.
//
// **`mark`**`: ?string`
// : This token also comes in `_open` and `_close` variants, but
// should add a mark (named by the value) to its content, rather
// than wrapping it in a node.
//
// **`attrs`**`: ?union<Object, (MarkdownToken) → Object>`
// : If the mark or node to be created needs attributes, they can
// be either given directly, or as a function that takes a
// [markdown-it
// token](https://markdown-it.github.io/markdown-it/#Token) and
// returns an attribute object.
constructor(schema, plugins, tokenizer, tokens) {
// :: Object The value of the `tokens` object used to construct
// this parser. Can be useful to copy and modify to base other
// parsers on.
this.tokens = tokens
this.schema = schema
this.tokenizer = tokenizer
this.plugins = plugins
this.tokenHandlers = tokenHandlers(schema, tokens)
}
// :: (string) → Node
// Parse a string as [CommonMark](http://commonmark.org/) markup,
// and create a ProseMirror document as prescribed by this parser's
// rules.
parse(text) {
let state = new MarkdownParseState(this.schema, this.plugins, this.tokenHandlers), doc
state.parseTokens(this.tokenizer.parse(text, {}))
do { doc = state.closeNode() } while (state.stack.length)
return doc
}
}
// :: MarkdownParser
// A parser parsing unextended [CommonMark](http://commonmark.org/),
// without inline HTML, and producing a document in the basic schema.
export default function createMarkdownParser(schema, plugins) {
const tokens = {
blockquote: {block: "blockquote"},
paragraph: {block: "paragraph"},
list_item: {block: "list_item"},
// Note - we force lists to be tight here, while that's not ProseMirror's default
// The default behavior means list elements always have a `p` inside, and we want
// to avoid tha.
bullet_list: {block: "bullet_list", attrs: tok => ({tight: true})},
ordered_list: {block: "ordered_list", attrs: tok => ({tight: true, order: +tok.attrGet("order") || 1})},
heading: {block: "heading", attrs: tok => ({level: +tok.tag.slice(1)})},
code_block: {block: "code_block"},
fence: {block: "code_block"},
hr: {node: "horizontal_rule"},
image: {node: "image", attrs: tok => ({
src: tok.attrGet("src"),
title: tok.attrGet("title") || null,
alt: tok.children[0] && tok.children[0].content || null
})},
hardbreak: {node: "hard_break"},
em: {mark: "em"},
strong: {mark: "strong"},
link: {mark: "link", attrs: tok => ({
href: tok.attrGet("href"),
title: tok.attrGet("title") || null
})},
code_inline: {mark: "code"}
};
return new MarkdownParser(schema, plugins, markdownit("commonmark", {html: false}), tokens);
}

View File

@ -1,38 +0,0 @@
import React, { PropTypes } from 'react';
import { getSyntaxes } from './richText';
import MarkupItReactRenderer from '../MarkupItReactRenderer/index';
import previewStyle from './defaultPreviewStyle';
const MarkdownPreview = ({ value, getAsset }) => {
if (value == null) {
return null;
}
const schema = {
'mediaproxy': ({ token }) => ( // eslint-disable-line
<img
src={getAsset(token.getIn(['data', 'src']))}
alt={token.getIn(['data', 'alt'])}
/>
),
};
const { markdown } = getSyntaxes();
return (
<div style={previewStyle}>
<MarkupItReactRenderer
value={value}
syntax={markdown}
schema={schema}
getAsset={getAsset}
/>
</div>
);
};
MarkdownPreview.propTypes = {
getAsset: PropTypes.func.isRequired,
value: PropTypes.string,
};
export default MarkdownPreview;

View File

@ -0,0 +1,21 @@
import React from 'react';
class PreviewHOC extends React.Component {
/**
* Only re-render on value change, but always re-render objects and lists.
* Their child widgets will each also be wrapped with this component, and
* will only be updated on value change.
*/
shouldComponentUpdate(nextProps) {
const isWidgetContainer = ['object', 'list'].includes(nextProps.field.get('widget'));
return isWidgetContainer || this.props.value !== nextProps.value;
}
render() {
const { previewComponent, ...props } = this.props;
return React.createElement(previewComponent, props);
}
}
export default PreviewHOC;

View File

@ -1,131 +0,0 @@
/* eslint react/prop-types: 0, react/no-multi-comp: 0 */
import React from 'react';
import { List, Map } from 'immutable';
import MarkupIt from 'markup-it';
import markdownSyntax from 'markup-it/syntaxes/markdown';
import htmlSyntax from 'markup-it/syntaxes/html';
import reInline from 'markup-it/syntaxes/markdown/re/inline';
import { Icon } from '../UI';
/*
* All Rich text widgets (Markdown, for example) should use Slate for text editing and
* MarkupIt to convert between structured formats (Slate JSON, Markdown, HTML, etc.).
* This module Processes and provides Slate nodes and MarkupIt syntaxes augmented with plugins
*/
let processedPlugins = List([]);
const nodes = {};
let augmentedMarkdownSyntax = markdownSyntax;
let augmentedHTMLSyntax = htmlSyntax;
function processEditorPlugins(plugins) {
// Since the plugin list is immutable, a simple comparisson is enough
// to determine whether we need to process again.
if (plugins === processedPlugins) return;
plugins.forEach((plugin) => {
const basicRule = MarkupIt.Rule(plugin.id).regExp(plugin.pattern, (state, match) => (
{ data: plugin.fromBlock(match) }
));
const markdownRule = basicRule.toText((state, token) => (
`${ plugin.toBlock(token.getData().toObject()) }\n\n`
));
const htmlRule = basicRule.toText((state, token) => (
plugin.toPreview(token.getData().toObject())
));
const nodeRenderer = (props) => {
const { node, state } = props;
const isFocused = state.selection.hasEdgeIn(node);
const className = isFocused ? 'plugin active' : 'plugin';
return (
<div {...props.attributes} className={className}>
<div className="plugin_icon" contentEditable={false}><Icon type={plugin.icon} /></div>
<div className="plugin_fields" contentEditable={false}>
{plugin.fields.map(field => `${ field.label }: “${ node.data.get(field.name) }`)}
</div>
</div>
);
};
augmentedMarkdownSyntax = augmentedMarkdownSyntax.addInlineRules(markdownRule);
augmentedHTMLSyntax = augmentedHTMLSyntax.addInlineRules(htmlRule);
nodes[plugin.id] = nodeRenderer;
});
processedPlugins = plugins;
}
function processAssetProxyPlugins(getAsset) {
const assetProxyRule = MarkupIt.Rule('assetproxy').regExp(reInline.link, (state, match) => {
if (match[0].charAt(0) !== '!') {
// Return if this is not an image
return;
}
const imgData = Map({
alt: match[1],
src: match[2],
title: match[3],
}).filter(Boolean);
return {
data: imgData,
};
});
const assetProxyMarkdownRule = assetProxyRule.toText((state, token) => {
const data = token.getData();
const alt = data.get('alt', '');
const src = data.get('src', '');
const title = data.get('title', '');
if (title) {
return `![${ alt }](${ src } "${ title }")`;
} else {
return `![${ alt }](${ src })`;
}
});
const assetProxyHTMLRule = assetProxyRule.toText((state, token) => {
const data = token.getData();
const alt = data.get('alt', '');
const src = data.get('src', '');
return `<img src=${ getAsset(src) } alt=${ alt } />`;
});
nodes.assetproxy = (props) => {
/* eslint react/prop-types: 0 */
const { node, state } = props;
const isFocused = state.selection.hasEdgeIn(node);
const className = isFocused ? 'active' : null;
const src = node.data.get('src');
return (
<img {...props.attributes} src={getAsset(src)} className={className} />
);
};
augmentedMarkdownSyntax = augmentedMarkdownSyntax.addInlineRules(assetProxyMarkdownRule);
augmentedHTMLSyntax = augmentedHTMLSyntax.addInlineRules(assetProxyHTMLRule);
}
function getPlugins() {
return processedPlugins.map(plugin => ({
id: plugin.id,
icon: plugin.icon,
fields: plugin.fields,
})).toArray();
}
function getNodes() {
return nodes;
}
function getSyntaxes(getAsset) {
if (getAsset) {
processAssetProxyPlugins(getAsset);
}
return { markdown: augmentedMarkdownSyntax, html: augmentedHTMLSyntax };
}
export { processEditorPlugins, getNodes, getSyntaxes, getPlugins };

View File

@ -1,40 +0,0 @@
import React from 'react';
import markdownSyntax from 'markup-it/syntaxes/markdown';
import htmlSyntax from 'markup-it/syntaxes/html';
import MarkupItReactRenderer from '../MarkupItReactRenderer';
import { storiesOf } from '@kadira/storybook';
const mdContent = `
# Title
* List 1
* List 2
`;
const htmlContent = `
<h1>Title</h1>
<ol>
<li>List item 1</li>
<li>List item 2</li>
</ol>
`;
function getAsset(path) {
return path;
}
storiesOf('MarkupItReactRenderer', module)
.add('Markdown', () => (
<MarkupItReactRenderer
value={mdContent}
syntax={markdownSyntax}
getAsset={getAsset}
/>
)).add('HTML', () => (
<MarkupItReactRenderer
value={htmlContent}
syntax={htmlSyntax}
getAsset={getAsset}
/>
));

View File

@ -2,5 +2,4 @@ import './Card';
import './Icon';
import './Toast';
import './FindBar';
import './MarkupItReactRenderer';
import './ScrollSync';

View File

@ -13,6 +13,7 @@ import {
deleteEntry,
} from '../actions/entries';
import { closeEntry } from '../actions/editor';
import { deserializeValues } from '../lib/serializeEntryValues';
import { addAsset, removeAsset } from '../actions/media';
import { openSidebar } from '../actions/globalUI';
import { selectEntry, getAsset } from '../reducers';
@ -64,11 +65,19 @@ class EntryPage extends React.Component {
componentWillReceiveProps(nextProps) {
if (this.props.entry === nextProps.entry) return;
const { entry, newEntry, fields, collection } = nextProps;
if (nextProps.entry && !nextProps.entry.get('isFetching') && !nextProps.entry.get('error')) {
this.createDraft(nextProps.entry);
} else if (nextProps.newEntry) {
this.props.createEmptyDraft(nextProps.collection);
if (entry && !entry.get('isFetching') && !entry.get('error')) {
/**
* Deserialize entry values for widgets with registered serializers before
* creating the entry draft.
*/
const values = deserializeValues(entry.get('data'), fields);
const deserializedEntry = entry.set('data', values);
this.createDraft(deserializedEntry);
} else if (newEntry) {
this.props.createEmptyDraft(collection);
}
}

View File

@ -13,7 +13,7 @@ html {
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-family: var(--fontFamily);
height: 100%;
background-color: #fff;
color: #7c8382;
@ -22,7 +22,7 @@ body {
h1, h2, h3, h4, h5, h6, p {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-family: var(--fontFamily);
}
h1 {

View File

@ -37,7 +37,7 @@ const buildtInPlugins = [{
alt: match[1],
},
toBlock: data => `![${ data.alt }](${ data.image })`,
toPreview: data => <img src={data.image} alt={data.alt} />,
toPreview: (data, getAsset) => <img src={getAsset(data.image)} alt={data.alt} />,
pattern: /^!\[([^\]]+)]\(([^)]+)\)$/,
fields: [{
label: 'Image',

View File

@ -1,11 +1,12 @@
import { List } from 'immutable';
import { newEditorPlugin } from '../components/Widgets/MarkdownControlElements/plugins';
import { Map } from 'immutable';
import { newEditorPlugin } from '../components/Widgets/Markdown/MarkdownControl/plugins';
const _registry = {
templates: {},
previewStyles: [],
widgets: {},
editorComponents: List([])
editorComponents: Map(),
widgetValueSerializers: {},
};
export default {
@ -31,9 +32,16 @@ export default {
return _registry.widgets[name];
},
registerEditorComponent(component) {
_registry.editorComponents = _registry.editorComponents.push(newEditorPlugin(component));
const plugin = newEditorPlugin(component);
_registry.editorComponents = _registry.editorComponents.set(plugin.get('id'), plugin);
},
getEditorComponents() {
return _registry.editorComponents;
}
},
registerWidgetValueSerializer(widgetName, serializer) {
_registry.widgetValueSerializers[widgetName] = serializer;
},
getWidgetValueSerializer(widgetName) {
return _registry.widgetValueSerializers[widgetName];
},
};

View File

@ -0,0 +1,67 @@
import { isArray, isObject, isEmpty, isNil } from 'lodash';
import { Map, List } from 'immutable';
import registry from './registry';
/**
* Methods for serializing/deserializing entry field values. Most widgets don't
* require this for their values, and those that do can typically serialize/
* deserialize on every change from within the widget. The serialization
* handlers here are for widgets whose values require heavy serialization that
* would hurt performance if run for every change.
* An example of this is the markdown widget, whose value is stored as a
* markdown string. Instead of stringifying on every change of that field, a
* deserialization method is registered from the widget's control module that
* converts the stored markdown string to an AST, and that AST serves as the
* widget model during editing.
*
* Serialization handlers should be registered for each widget that requires
* them, and the registration method is exposed through the registry. Any
* registered deserialization handlers run on entry load, and serialization
* handlers run on persist.
*/
const runSerializer = (values, fields, method) => {
/**
* Reduce the list of fields to a map where keys are field names and values
* are field values, serializing the values of fields whose widgets have
* registered serializers. If the field is a list or object, call recursively
* for nested fields.
*/
return fields.reduce((acc, field) => {
const fieldName = field.get('name');
const value = values.get(fieldName);
const serializer = registry.getWidgetValueSerializer(field.get('widget'));
const nestedFields = field.get('fields');
// Call recursively for fields within lists
if (nestedFields && List.isList(value)) {
return acc.set(fieldName, value.map(val => runSerializer(val, nestedFields, method)));
}
// Call recursively for fields within objects
if (nestedFields && Map.isMap(value)) {
return acc.set(fieldName, runSerializer(value, nestedFields, method));
}
// Run serialization method on value if not null or undefined
if (serializer && !isNil(value)) {
return acc.set(fieldName, serializer[method](value));
}
// If no serializer is registered for the field's widget, use the field as is
if (!isNil(value)) {
return acc.set(fieldName, value);
}
return acc;
}, Map());
};
export const serializeValues = (values, fields) => {
return runSerializer(values, fields, 'serialize');
};
export const deserializeValues = (values, fields) => {
return runSerializer(values, fields, 'deserialize');
};

View File

@ -53,7 +53,7 @@ module.exports = merge.smart(require('./webpack.base.js'), {
disable: true,
}),
],
devtool: 'cheap-module-source-map',
devtool: 'source-map',
devServer: {
hot: true,
contentBase: 'example/',

2956
yarn.lock

File diff suppressed because it is too large Load Diff