Merge pull request #254 from KyleAMathews/cerealize
Migrate rich text editor to Slate backed by Unified
This commit is contained in:
49
package.json
49
package.json
@ -51,38 +51,38 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"babel": "^6.5.2",
|
"babel": "^6.5.2",
|
||||||
"babel-cli": "^6.18.0",
|
"babel-cli": "^6.18.0",
|
||||||
"babel-core": "^6.5.1",
|
"babel-core": "^6.23.1",
|
||||||
"babel-jest": "^20.0.3",
|
"babel-jest": "^20.0.3",
|
||||||
"babel-loader": "^7.0.0",
|
"babel-loader": "^7.0.0",
|
||||||
"babel-plugin-lodash": "^3.2.0",
|
"babel-plugin-lodash": "^3.2.0",
|
||||||
"babel-preset-es2015": "^6.5.0",
|
"babel-preset-es2015": "^6.22.0",
|
||||||
"babel-preset-react": "^6.5.0",
|
"babel-preset-react": "^6.23.0",
|
||||||
"babel-preset-stage-1": "^6.16.0",
|
"babel-preset-stage-1": "^6.22.0",
|
||||||
"babel-runtime": "^6.5.0",
|
"babel-runtime": "^6.23.0",
|
||||||
"cross-env": "^5.0.2",
|
"cross-env": "^5.0.2",
|
||||||
"css-loader": "^0.28.4",
|
"css-loader": "^0.28.4",
|
||||||
"enzyme": "^2.4.1",
|
"enzyme": "^2.4.1",
|
||||||
"eslint": "^3.7.1",
|
"eslint": "^3.7.1",
|
||||||
"eslint-config-netlify": "github:netlify/eslint-config-netlify",
|
"eslint-config-netlify": "github:netlify/eslint-config-netlify",
|
||||||
"eslint-import-resolver-webpack": "^0.8.3",
|
"eslint-import-resolver-webpack": "^0.8.3",
|
||||||
"exports-loader": "^0.6.3",
|
"exports-loader": "^0.6.4",
|
||||||
"extract-text-webpack-plugin": "^2.1.2",
|
"extract-text-webpack-plugin": "^2.1.2",
|
||||||
"file-loader": "^0.11.2",
|
"file-loader": "^0.11.2",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"imports-loader": "^0.7.1",
|
"imports-loader": "^0.7.1",
|
||||||
"jest": "^20.0.4",
|
"jest": "^20.0.4",
|
||||||
"jest-cli": "^20.0.4",
|
"jest-cli": "^20.0.4",
|
||||||
"lint-staged": "^3.1.0",
|
"lint-staged": "^3.3.1",
|
||||||
"node-sass": "^3.10.0",
|
"node-sass": "^3.10.0",
|
||||||
"npm-check": "^5.2.3",
|
"npm-check": "^5.2.3",
|
||||||
"postcss-cssnext": "^2.7.0",
|
"postcss-cssnext": "^2.7.0",
|
||||||
"postcss-import": "^10.0.0",
|
"postcss-import": "^10.0.0",
|
||||||
"postcss-loader": "^2.0.5",
|
"postcss-loader": "^2.0.5",
|
||||||
"react-addons-test-utils": "^15.3.2",
|
"react-addons-test-utils": "^15.4.2",
|
||||||
"sass-loader": "^6.0.5",
|
"sass-loader": "^6.0.5",
|
||||||
"style-loader": "^0.18.2",
|
"style-loader": "^0.18.2",
|
||||||
"stylefmt": "^4.3.1",
|
"stylefmt": "^4.3.1",
|
||||||
"stylelint": "^7.3.1",
|
"stylelint": "^7.9.0",
|
||||||
"stylelint-config-css-modules": "^0.1.0",
|
"stylelint-config-css-modules": "^0.1.0",
|
||||||
"stylelint-config-standard": "^13.0.2",
|
"stylelint-config-standard": "^13.0.2",
|
||||||
"stylelint-declaration-block-order": "^0.1.0",
|
"stylelint-declaration-block-order": "^0.1.0",
|
||||||
@ -110,8 +110,9 @@
|
|||||||
"jwt-decode": "^2.1.0",
|
"jwt-decode": "^2.1.0",
|
||||||
"localforage": "^1.4.2",
|
"localforage": "^1.4.2",
|
||||||
"lodash": "^4.13.1",
|
"lodash": "^4.13.1",
|
||||||
"markup-it": "^2.0.0",
|
|
||||||
"material-design-icons": "^3.0.1",
|
"material-design-icons": "^3.0.1",
|
||||||
|
"mdast-util-definitions": "^1.2.2",
|
||||||
|
"mdast-util-to-string": "^1.0.4",
|
||||||
"moment": "^2.11.2",
|
"moment": "^2.11.2",
|
||||||
"netlify-auth-js": "^0.5.5",
|
"netlify-auth-js": "^0.5.5",
|
||||||
"normalize.css": "^4.2.0",
|
"normalize.css": "^4.2.0",
|
||||||
@ -119,18 +120,6 @@
|
|||||||
"preliminaries-parser-toml": "1.1.0",
|
"preliminaries-parser-toml": "1.1.0",
|
||||||
"preliminaries-parser-yaml": "1.1.0",
|
"preliminaries-parser-yaml": "1.1.0",
|
||||||
"prismjs": "^1.5.1",
|
"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": "^15.1.0",
|
||||||
"react-addons-css-transition-group": "^15.3.1",
|
"react-addons-css-transition-group": "^15.3.1",
|
||||||
"react-autosuggest": "^7.0.1",
|
"react-autosuggest": "^7.0.1",
|
||||||
@ -157,12 +146,20 @@
|
|||||||
"redux-notifications": "^2.1.1",
|
"redux-notifications": "^2.1.1",
|
||||||
"redux-optimist": "^0.0.2",
|
"redux-optimist": "^0.0.2",
|
||||||
"redux-thunk": "^1.0.3",
|
"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",
|
"semaphore": "^1.0.5",
|
||||||
"slate": "^0.14.14",
|
"slate": "^0.21.0",
|
||||||
"slate-drop-or-paste-images": "^0.2.0",
|
"slate-edit-list": "^0.7.1",
|
||||||
|
"slate-edit-table": "^0.10.1",
|
||||||
"slug": "^0.9.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",
|
"uuid": "^2.0.3",
|
||||||
"whatwg-fetch": "^1.0.0"
|
"whatwg-fetch": "^1.0.0"
|
||||||
},
|
},
|
||||||
|
@ -228,20 +228,28 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
|
|||||||
if (!entryDraft.get('fieldsErrors').isEmpty()) return Promise.resolve();
|
if (!entryDraft.get('fieldsErrors').isEmpty()) return Promise.resolve();
|
||||||
|
|
||||||
const backend = currentBackend(state.config);
|
const backend = currentBackend(state.config);
|
||||||
|
const transactionID = uuid.v4();
|
||||||
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
|
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
|
||||||
const entry = entryDraft.get('entry');
|
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;
|
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(() => {
|
.then(() => {
|
||||||
dispatch(notifSend({
|
dispatch(notifSend({
|
||||||
message: 'Entry saved',
|
message: 'Entry saved',
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
dismissAfter: 4000,
|
dismissAfter: 4000,
|
||||||
}));
|
}));
|
||||||
return dispatch(unpublishedEntryPersisted(collection, entry, transactionID));
|
return dispatch(unpublishedEntryPersisted(collection, serializedEntry, transactionID));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
dispatch(notifSend({
|
dispatch(notifSend({
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { List } from 'immutable';
|
import { List } from 'immutable';
|
||||||
import { actions as notifActions } from 'redux-notifications';
|
import { actions as notifActions } from 'redux-notifications';
|
||||||
|
import { serializeValues } from '../lib/serializeEntryValues';
|
||||||
import { closeEntry } from './editor';
|
import { closeEntry } from './editor';
|
||||||
import { currentBackend } from '../backends/backend';
|
import { currentBackend } from '../backends/backend';
|
||||||
import { getIntegrationProvider } from '../integrations';
|
import { getIntegrationProvider } from '../integrations';
|
||||||
@ -216,10 +217,11 @@ export function loadEntry(collection, slug) {
|
|||||||
const backend = currentBackend(state.config);
|
const backend = currentBackend(state.config);
|
||||||
dispatch(entryLoading(collection, slug));
|
dispatch(entryLoading(collection, slug));
|
||||||
return backend.getEntry(collection, slug)
|
return backend.getEntry(collection, slug)
|
||||||
.then(loadedEntry => (
|
.then(loadedEntry => {
|
||||||
dispatch(entryLoaded(collection, loadedEntry))
|
return dispatch(entryLoaded(collection, loadedEntry))
|
||||||
))
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
dispatch(notifSend({
|
dispatch(notifSend({
|
||||||
message: `Failed to load entry: ${ error.message }`,
|
message: `Failed to load entry: ${ error.message }`,
|
||||||
kind: 'danger',
|
kind: 'danger',
|
||||||
@ -265,28 +267,37 @@ export function persistEntry(collection) {
|
|||||||
|
|
||||||
// Early return if draft contains validation errors
|
// Early return if draft contains validation errors
|
||||||
if (!entryDraft.get('fieldsErrors').isEmpty()) return Promise.reject();
|
if (!entryDraft.get('fieldsErrors').isEmpty()) return Promise.reject();
|
||||||
|
|
||||||
const backend = currentBackend(state.config);
|
const backend = currentBackend(state.config);
|
||||||
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
|
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
|
||||||
const entry = entryDraft.get('entry');
|
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
|
return backend
|
||||||
.persistEntry(state.config, collection, entryDraft, assetProxies.toJS())
|
.persistEntry(state.config, collection, serializedEntryDraft, assetProxies.toJS())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
dispatch(notifSend({
|
dispatch(notifSend({
|
||||||
message: 'Entry saved',
|
message: 'Entry saved',
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
dismissAfter: 4000,
|
dismissAfter: 4000,
|
||||||
}));
|
}));
|
||||||
return dispatch(entryPersisted(collection, entry));
|
return dispatch(entryPersisted(collection, serializedEntry));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
dispatch(notifSend({
|
dispatch(notifSend({
|
||||||
message: `Failed to persist entry: ${ error }`,
|
message: `Failed to persist entry: ${ error }`,
|
||||||
kind: 'danger',
|
kind: 'danger',
|
||||||
dismissAfter: 8000,
|
dismissAfter: 8000,
|
||||||
}));
|
}));
|
||||||
return dispatch(entryPersistFail(collection, entry, error));
|
return dispatch(entryPersistFail(collection, serializedEntry, error));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
& input,
|
& input,
|
||||||
& textarea,
|
& textarea,
|
||||||
& select {
|
& select,
|
||||||
font-family: 'SFMono-Regular', Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
& div[contenteditable=true] {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@ -28,6 +28,12 @@
|
|||||||
border-color: var(--primaryColor);
|
border-color: var(--primaryColor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& input,
|
||||||
|
& textarea,
|
||||||
|
& select {
|
||||||
|
font-family: var(--fontFamilyMono);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.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
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
###### 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
|
|
||||||
|
|
||||||

|
|
||||||
`;
|
|
||||||
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',
|
fontFamily: 'Roboto, "Helvetica Neue", HelveticaNeue, Helvetica, Arial, sans-serif',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Preview({ collection, fields, widgetFor }) {
|
/**
|
||||||
if (!collection || !fields) {
|
* Use a stateful component so that child components can effectively utilize
|
||||||
return null;
|
* `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 = {
|
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 { List, Map } from 'immutable';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Frame from 'react-frame-component';
|
import Frame from 'react-frame-component';
|
||||||
import { ScrollSyncPane } from '../ScrollSync';
|
|
||||||
import registry from '../../lib/registry';
|
import registry from '../../lib/registry';
|
||||||
import { resolveWidget } from '../Widgets';
|
import { resolveWidget } from '../Widgets';
|
||||||
import { selectTemplateName, selectInferedField } from '../../reducers/collections';
|
import { selectTemplateName, selectInferedField } from '../../reducers/collections';
|
||||||
import { INFERABLE_FIELDS } from '../../constants/fieldInference';
|
import { INFERABLE_FIELDS } from '../../constants/fieldInference';
|
||||||
|
import PreviewContent from './PreviewContent.js';
|
||||||
|
import PreviewHOC from '../Widgets/PreviewHOC';
|
||||||
import Preview from './Preview';
|
import Preview from './Preview';
|
||||||
import styles from './PreviewPane.css';
|
import styles from './PreviewPane.css';
|
||||||
|
|
||||||
@ -16,15 +17,21 @@ export default class PreviewPane extends React.Component {
|
|||||||
const { fieldsMetaData, getAsset, entry } = props;
|
const { fieldsMetaData, getAsset, entry } = props;
|
||||||
const widget = resolveWidget(field.get('widget'));
|
const widget = resolveWidget(field.get('widget'));
|
||||||
|
|
||||||
return !widget.preview ? null : React.createElement(widget.preview, {
|
/**
|
||||||
field,
|
* Use an HOC to provide conditional updates for all previews.
|
||||||
key: field.get('name'),
|
*/
|
||||||
value: value && Map.isMap(value) ? value.get(field.get('name')) : value,
|
return !widget.preview ? null : (
|
||||||
metadata: fieldsMetaData && fieldsMetaData.get(field.get('name')),
|
<PreviewHOC
|
||||||
getAsset,
|
previewComponent={widget.preview}
|
||||||
entry,
|
key={field.get('name')}
|
||||||
fieldsMetaData,
|
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 = {};
|
inferedFields = {};
|
||||||
@ -118,7 +125,9 @@ export default class PreviewPane extends React.Component {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const component = registry.getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) || Preview;
|
const previewComponent =
|
||||||
|
registry.getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) ||
|
||||||
|
Preview;
|
||||||
|
|
||||||
this.inferFields();
|
this.inferFields();
|
||||||
|
|
||||||
@ -135,18 +144,6 @@ export default class PreviewPane extends React.Component {
|
|||||||
return <Frame className={styles.frame} head={styleEls} />;
|
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
|
return (<Frame
|
||||||
className={styles.frame}
|
className={styles.frame}
|
||||||
head={styleEls}
|
head={styleEls}
|
||||||
@ -156,7 +153,7 @@ export default class PreviewPane extends React.Component {
|
|||||||
<head><base target="_blank"/></head>
|
<head><base target="_blank"/></head>
|
||||||
<body><div></div></body>
|
<body><div></div></body>
|
||||||
</html>`}
|
</html>`}
|
||||||
><PreviewContent /></Frame>);
|
><PreviewContent {...{ previewComponent, previewProps }}/></Frame>);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
:root {
|
: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;
|
--defaultColor: #333;
|
||||||
--defaultColorLight: #fff;
|
--defaultColorLight: #fff;
|
||||||
--backgroundColor: #fff;
|
--backgroundColor: #fff;
|
||||||
|
@ -9,8 +9,8 @@ import ListControl from './Widgets/ListControl';
|
|||||||
import ListPreview from './Widgets/ListPreview';
|
import ListPreview from './Widgets/ListPreview';
|
||||||
import TextControl from './Widgets/TextControl';
|
import TextControl from './Widgets/TextControl';
|
||||||
import TextPreview from './Widgets/TextPreview';
|
import TextPreview from './Widgets/TextPreview';
|
||||||
import MarkdownControl from './Widgets/MarkdownControl';
|
import MarkdownControl from './Widgets/Markdown/MarkdownControl';
|
||||||
import MarkdownPreview from './Widgets/MarkdownPreview';
|
import MarkdownPreview from './Widgets/Markdown/MarkdownPreview';
|
||||||
import ImageControl from './Widgets/ImageControl';
|
import ImageControl from './Widgets/ImageControl';
|
||||||
import ImagePreview from './Widgets/ImagePreview';
|
import ImagePreview from './Widgets/ImagePreview';
|
||||||
import FileControl from './Widgets/FileControl';
|
import FileControl from './Widgets/FileControl';
|
||||||
|
@ -22,6 +22,10 @@ class ControlHOC extends Component {
|
|||||||
getAsset: PropTypes.func.isRequired,
|
getAsset: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
shouldComponentUpdate(nextProps) {
|
||||||
|
return this.props.value !== nextProps.value;
|
||||||
|
}
|
||||||
|
|
||||||
processInnerControlRef = (wrappedControl) => {
|
processInnerControlRef = (wrappedControl) => {
|
||||||
if (!wrappedControl) return;
|
if (!wrappedControl) return;
|
||||||
this.wrappedControlValid = wrappedControl.isValid || truthy;
|
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 {
|
.Toolbar {
|
||||||
composes: clearfix;
|
composes: clearfix;
|
@ -5,24 +5,20 @@ import Switch from 'react-toolbox/lib/switch';
|
|||||||
import ToolbarButton from './ToolbarButton';
|
import ToolbarButton from './ToolbarButton';
|
||||||
import ToolbarComponentsMenu from './ToolbarComponentsMenu';
|
import ToolbarComponentsMenu from './ToolbarComponentsMenu';
|
||||||
import ToolbarPluginForm from './ToolbarPluginForm';
|
import ToolbarPluginForm from './ToolbarPluginForm';
|
||||||
import { Icon } from '../../../UI';
|
import { Icon } from '../../../../UI';
|
||||||
import styles from './Toolbar.css';
|
import styles from './Toolbar.css';
|
||||||
|
|
||||||
export default class Toolbar extends React.Component {
|
export default class Toolbar extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
selectionPosition: PropTypes.object,
|
buttons: PropTypes.object,
|
||||||
onH1: PropTypes.func.isRequired,
|
|
||||||
onH2: PropTypes.func.isRequired,
|
|
||||||
onBold: PropTypes.func.isRequired,
|
|
||||||
onItalic: PropTypes.func.isRequired,
|
|
||||||
onLink: PropTypes.func.isRequired,
|
|
||||||
onToggleMode: PropTypes.func.isRequired,
|
onToggleMode: PropTypes.func.isRequired,
|
||||||
rawMode: PropTypes.bool,
|
rawMode: PropTypes.bool,
|
||||||
plugins: ImmutablePropTypes.listOf(ImmutablePropTypes.record),
|
plugins: ImmutablePropTypes.map,
|
||||||
onSubmit: PropTypes.func.isRequired,
|
onSubmit: PropTypes.func,
|
||||||
onAddAsset: PropTypes.func.isRequired,
|
onAddAsset: PropTypes.func,
|
||||||
onRemoveAsset: PropTypes.func.isRequired,
|
onRemoveAsset: PropTypes.func,
|
||||||
getAsset: PropTypes.func.isRequired,
|
getAsset: PropTypes.func,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@ -47,31 +43,47 @@ export default class Toolbar extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
onH1,
|
|
||||||
onH2,
|
|
||||||
onBold,
|
|
||||||
onItalic,
|
|
||||||
onLink,
|
|
||||||
onToggleMode,
|
onToggleMode,
|
||||||
rawMode,
|
rawMode,
|
||||||
plugins,
|
plugins,
|
||||||
onAddAsset,
|
onAddAsset,
|
||||||
onRemoveAsset,
|
onRemoveAsset,
|
||||||
getAsset,
|
getAsset,
|
||||||
|
disabled,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
const buttons = this.props.buttons || {};
|
||||||
|
|
||||||
const { activePlugin } = this.state;
|
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 (
|
return (
|
||||||
<div className={styles.Toolbar}>
|
<div className={styles.Toolbar}>
|
||||||
<ToolbarButton label="Header 1" icon="h1" action={onH1}/>
|
{ buttonsConfig.map((btn, i) => (
|
||||||
<ToolbarButton label="Header 2" icon="h2" action={onH2}/>
|
<ToolbarButton
|
||||||
<ToolbarButton label="Bold" icon="bold" action={onBold}/>
|
key={i}
|
||||||
<ToolbarButton label="Italic" icon="italic" action={onItalic}/>
|
action={btn.state && btn.state.onAction || (() => {})}
|
||||||
<ToolbarButton label="Link" icon="link" action={onLink}/>
|
active={btn.state && btn.state.active}
|
||||||
|
disabled={disabled}
|
||||||
|
{...btn}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<ToolbarComponentsMenu
|
<ToolbarComponentsMenu
|
||||||
plugins={plugins}
|
plugins={plugins}
|
||||||
onComponentMenuItemClick={this.handlePluginFormDisplay}
|
onComponentMenuItemClick={this.handlePluginFormDisplay}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
{activePlugin &&
|
{activePlugin &&
|
||||||
<ToolbarPluginForm
|
<ToolbarPluginForm
|
@ -1,11 +1,14 @@
|
|||||||
@import "../../../UI/theme";
|
@import "../../../../UI/theme";
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: none;
|
border: none;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
cursor: pointer;
|
|
||||||
|
&:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
@ -1,13 +1,14 @@
|
|||||||
import React, { PropTypes } from 'react';
|
import React, { PropTypes } from 'react';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { Icon } from '../../../UI';
|
import { Icon } from '../../../../UI';
|
||||||
import styles from './ToolbarButton.css';
|
import styles from './ToolbarButton.css';
|
||||||
|
|
||||||
const ToolbarButton = ({ label, icon, action, active }) => (
|
const ToolbarButton = ({ label, icon, action, active, disabled }) => (
|
||||||
<button
|
<button
|
||||||
className={classnames(styles.button, { [styles.active]: active })}
|
className={classnames(styles.button, { [styles.active]: active })}
|
||||||
onClick={action}
|
onClick={action}
|
||||||
title={label}
|
title={label}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{ icon ? <Icon type={icon} /> : label }
|
{ icon ? <Icon type={icon} /> : label }
|
||||||
</button>
|
</button>
|
@ -6,7 +6,7 @@ import styles from './ToolbarComponentsMenu.css';
|
|||||||
|
|
||||||
export default class ToolbarComponentsMenu extends React.Component {
|
export default class ToolbarComponentsMenu extends React.Component {
|
||||||
static PropTypes = {
|
static PropTypes = {
|
||||||
plugins: ImmutablePropTypes.list.isRequired,
|
plugins: ImmutablePropTypes.map,
|
||||||
onComponentMenuItemClick: PropTypes.func.isRequired,
|
onComponentMenuItemClick: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -26,17 +26,22 @@ export default class ToolbarComponentsMenu extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { plugins, onComponentMenuItemClick } = this.props;
|
const { plugins, onComponentMenuItemClick, disabled } = this.props;
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<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
|
<Menu
|
||||||
active={this.state.componentsMenuActive}
|
active={this.state.componentsMenuActive}
|
||||||
position="auto"
|
position="auto"
|
||||||
onHide={this.handleComponentsMenuHide}
|
onHide={this.handleComponentsMenuHide}
|
||||||
ripple={false}
|
ripple={false}
|
||||||
>
|
>
|
||||||
{plugins.map(plugin => (
|
{plugins && plugins.map(plugin => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={plugin.get('id')}
|
key={plugin.get('id')}
|
||||||
value={plugin.get('id')}
|
value={plugin.get('id')}
|
@ -1,4 +1,4 @@
|
|||||||
@import "../../../UI/theme";
|
@import "../../../../UI/theme";
|
||||||
|
|
||||||
.pluginForm {
|
.pluginForm {
|
||||||
position: absolute;
|
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 React, { PropTypes } from 'react';
|
||||||
import { resolveWidget } from '../../../Widgets';
|
import { resolveWidget } from '../../../../Widgets';
|
||||||
import styles from './ToolbarPluginFormControl.css';
|
import styles from './ToolbarPluginFormControl.css';
|
||||||
|
|
||||||
const ToolbarPluginFormControl = ({
|
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 => ``,
|
||||||
|
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 = `
|
||||||
|

|
||||||
|
`;
|
||||||
|
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 >}}
|
||||||
|
`;
|
||||||
|
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 React, { PropTypes } from 'react';
|
||||||
import registry from '../../lib/registry';
|
import registry from '../../../../lib/registry';
|
||||||
import RawEditor from './MarkdownControlElements/RawEditor';
|
import { markdownToRemark, remarkToMarkdown } from '../serializers'
|
||||||
import VisualEditor from './MarkdownControlElements/VisualEditor';
|
import RawEditor from './RawEditor';
|
||||||
import { processEditorPlugins } from './richText';
|
import VisualEditor from './VisualEditor';
|
||||||
import { StickyContainer } from '../UI/Sticky/Sticky';
|
import { StickyContainer } from '../../../UI/Sticky/Sticky';
|
||||||
|
|
||||||
const MODE_STORAGE_KEY = 'cms.md-mode';
|
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 {
|
export default class MarkdownControl extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
onAddAsset: PropTypes.func.isRequired,
|
onAddAsset: PropTypes.func.isRequired,
|
||||||
onRemoveAsset: PropTypes.func.isRequired,
|
onRemoveAsset: PropTypes.func.isRequired,
|
||||||
getAsset: PropTypes.func.isRequired,
|
getAsset: PropTypes.func.isRequired,
|
||||||
value: PropTypes.node,
|
value: PropTypes.object,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@ -21,10 +32,6 @@ export default class MarkdownControl extends React.Component {
|
|||||||
this.state = { mode: localStorage.getItem(MODE_STORAGE_KEY) || 'visual' };
|
this.state = { mode: localStorage.getItem(MODE_STORAGE_KEY) || 'visual' };
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillMount() {
|
|
||||||
processEditorPlugins(registry.getEditorComponents());
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMode = (mode) => {
|
handleMode = (mode) => {
|
||||||
this.setState({ mode });
|
this.setState({ mode });
|
||||||
localStorage.setItem(MODE_STORAGE_KEY, 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></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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
###### 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: `` };
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* 
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
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 ``;
|
|
||||||
} else {
|
|
||||||
return ``;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
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 './Icon';
|
||||||
import './Toast';
|
import './Toast';
|
||||||
import './FindBar';
|
import './FindBar';
|
||||||
import './MarkupItReactRenderer';
|
|
||||||
import './ScrollSync';
|
import './ScrollSync';
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
deleteEntry,
|
deleteEntry,
|
||||||
} from '../actions/entries';
|
} from '../actions/entries';
|
||||||
import { closeEntry } from '../actions/editor';
|
import { closeEntry } from '../actions/editor';
|
||||||
|
import { deserializeValues } from '../lib/serializeEntryValues';
|
||||||
import { addAsset, removeAsset } from '../actions/media';
|
import { addAsset, removeAsset } from '../actions/media';
|
||||||
import { openSidebar } from '../actions/globalUI';
|
import { openSidebar } from '../actions/globalUI';
|
||||||
import { selectEntry, getAsset } from '../reducers';
|
import { selectEntry, getAsset } from '../reducers';
|
||||||
@ -64,11 +65,19 @@ class EntryPage extends React.Component {
|
|||||||
|
|
||||||
componentWillReceiveProps(nextProps) {
|
componentWillReceiveProps(nextProps) {
|
||||||
if (this.props.entry === nextProps.entry) return;
|
if (this.props.entry === nextProps.entry) return;
|
||||||
|
const { entry, newEntry, fields, collection } = nextProps;
|
||||||
|
|
||||||
if (nextProps.entry && !nextProps.entry.get('isFetching') && !nextProps.entry.get('error')) {
|
if (entry && !entry.get('isFetching') && !entry.get('error')) {
|
||||||
this.createDraft(nextProps.entry);
|
|
||||||
} else if (nextProps.newEntry) {
|
/**
|
||||||
this.props.createEmptyDraft(nextProps.collection);
|
* 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 {
|
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%;
|
height: 100%;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
color: #7c8382;
|
color: #7c8382;
|
||||||
@ -22,7 +22,7 @@ body {
|
|||||||
|
|
||||||
h1, h2, h3, h4, h5, h6, p {
|
h1, h2, h3, h4, h5, h6, p {
|
||||||
margin: 0;
|
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 {
|
h1 {
|
||||||
|
@ -37,7 +37,7 @@ const buildtInPlugins = [{
|
|||||||
alt: match[1],
|
alt: match[1],
|
||||||
},
|
},
|
||||||
toBlock: data => ``,
|
toBlock: data => ``,
|
||||||
toPreview: data => <img src={data.image} alt={data.alt} />,
|
toPreview: (data, getAsset) => <img src={getAsset(data.image)} alt={data.alt} />,
|
||||||
pattern: /^!\[([^\]]+)]\(([^)]+)\)$/,
|
pattern: /^!\[([^\]]+)]\(([^)]+)\)$/,
|
||||||
fields: [{
|
fields: [{
|
||||||
label: 'Image',
|
label: 'Image',
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { List } from 'immutable';
|
import { Map } from 'immutable';
|
||||||
import { newEditorPlugin } from '../components/Widgets/MarkdownControlElements/plugins';
|
import { newEditorPlugin } from '../components/Widgets/Markdown/MarkdownControl/plugins';
|
||||||
|
|
||||||
const _registry = {
|
const _registry = {
|
||||||
templates: {},
|
templates: {},
|
||||||
previewStyles: [],
|
previewStyles: [],
|
||||||
widgets: {},
|
widgets: {},
|
||||||
editorComponents: List([])
|
editorComponents: Map(),
|
||||||
|
widgetValueSerializers: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -31,9 +32,16 @@ export default {
|
|||||||
return _registry.widgets[name];
|
return _registry.widgets[name];
|
||||||
},
|
},
|
||||||
registerEditorComponent(component) {
|
registerEditorComponent(component) {
|
||||||
_registry.editorComponents = _registry.editorComponents.push(newEditorPlugin(component));
|
const plugin = newEditorPlugin(component);
|
||||||
|
_registry.editorComponents = _registry.editorComponents.set(plugin.get('id'), plugin);
|
||||||
},
|
},
|
||||||
getEditorComponents() {
|
getEditorComponents() {
|
||||||
return _registry.editorComponents;
|
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,
|
disable: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
devtool: 'cheap-module-source-map',
|
devtool: 'source-map',
|
||||||
devServer: {
|
devServer: {
|
||||||
hot: true,
|
hot: true,
|
||||||
contentBase: 'example/',
|
contentBase: 'example/',
|
||||||
|
Reference in New Issue
Block a user