Merge pull request #254 from KyleAMathews/cerealize
Migrate rich text editor to Slate backed by Unified
This commit is contained in:
commit
79c30b9048
49
package.json
49
package.json
@ -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"
|
||||
},
|
||||
|
@ -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({
|
||||
|
@ -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));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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> & <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>"`;
|
@ -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,
|
||||
};
|
@ -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 = {
|
||||
|
24
src/components/PreviewPane/PreviewContent.js
Normal file
24
src/components/PreviewPane/PreviewContent.js
Normal 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;
|
@ -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>);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
@ -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,
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
@import "../../../UI/theme";
|
||||
@import "../../../../UI/theme";
|
||||
|
||||
.Toolbar {
|
||||
composes: clearfix;
|
@ -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
|
@ -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 {
|
@ -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>
|
@ -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')}
|
@ -1,4 +1,4 @@
|
||||
@import "../../../UI/theme";
|
||||
@import "../../../../UI/theme";
|
||||
|
||||
.pluginForm {
|
||||
position: absolute;
|
@ -0,0 +1,7 @@
|
||||
.control {
|
||||
composes: control from "../../../../ControlPanel/ControlPane.css"
|
||||
}
|
||||
|
||||
.label {
|
||||
composes: label from "../../../../ControlPanel/ControlPane.css";
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { resolveWidget } from '../../../Widgets';
|
||||
import { resolveWidget } from '../../../../Widgets';
|
||||
import styles from './ToolbarPluginFormControl.css';
|
||||
|
||||
const ToolbarPluginFormControl = ({
|
File diff suppressed because it is too large
Load Diff
@ -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();
|
||||
});
|
||||
});
|
@ -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>;
|
||||
},
|
||||
};
|
@ -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);
|
||||
}
|
@ -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,
|
||||
};
|
@ -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();
|
||||
}
|
||||
}
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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);
|
@ -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> & <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>"
|
||||
`;
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
18
src/components/Widgets/Markdown/MarkdownPreview/index.js
Normal file
18
src/components/Widgets/Markdown/MarkdownPreview/index.js
Normal 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;
|
@ -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);
|
||||
});
|
||||
});
|
@ -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) '));
|
||||
});
|
||||
});
|
244
src/components/Widgets/Markdown/serializers/index.js
Normal file
244
src/components/Widgets/Markdown/serializers/index.js
Normal 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;
|
||||
};
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 };
|
||||
}
|
||||
}
|
120
src/components/Widgets/Markdown/serializers/remarkPaddedLinks.js
Normal file
120
src/components/Widgets/Markdown/serializers/remarkPaddedLinks.js
Normal 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;
|
||||
}
|
@ -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 };
|
||||
}
|
||||
}
|
@ -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]);
|
||||
}
|
||||
}
|
293
src/components/Widgets/Markdown/serializers/remarkSlate.js
Normal file
293
src/components/Widgets/Markdown/serializers/remarkSlate.js
Normal 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;
|
||||
}
|
@ -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 };
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
330
src/components/Widgets/Markdown/serializers/slateRemark.js
Normal file
330
src/components/Widgets/Markdown/serializers/slateRemark.js
Normal 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;
|
||||
}
|
@ -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);
|
||||
}
|
@ -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,
|
||||
};
|
@ -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;
|
@ -1,7 +0,0 @@
|
||||
.control {
|
||||
composes: control from "../../../ControlPanel/ControlPane.css"
|
||||
}
|
||||
|
||||
.label {
|
||||
composes: label from "../../../ControlPanel/ControlPane.css";
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
@ -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;
|
@ -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);
|
||||
}
|
@ -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;
|
21
src/components/Widgets/PreviewHOC.js
Normal file
21
src/components/Widgets/PreviewHOC.js
Normal 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;
|
@ -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 };
|
@ -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}
|
||||
/>
|
||||
));
|
@ -2,5 +2,4 @@ import './Card';
|
||||
import './Icon';
|
||||
import './Toast';
|
||||
import './FindBar';
|
||||
import './MarkupItReactRenderer';
|
||||
import './ScrollSync';
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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',
|
||||
|
@ -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];
|
||||
},
|
||||
};
|
||||
|
67
src/lib/serializeEntryValues.js
Normal file
67
src/lib/serializeEntryValues.js
Normal 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');
|
||||
};
|
@ -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/',
|
||||
|
Loading…
x
Reference in New Issue
Block a user