Merge pull request #574 from netlify/markdown-editor-improvements
Markdown editor improvements
This commit is contained in:
commit
86b094b1cf
@ -1,5 +1,6 @@
|
||||
import uuid from 'uuid';
|
||||
import { actions as notifActions } from 'redux-notifications';
|
||||
import { serializeValues } from '../lib/serializeEntryValues';
|
||||
import { closeEntry } from './editor';
|
||||
import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
|
||||
import { currentBackend } from '../backends/backend';
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { Editor as Slate, Plain } from 'slate';
|
||||
import { markdownToRemark, remarkToMarkdown } from '../../serializers';
|
||||
import { debounce } from 'lodash';
|
||||
import Toolbar from '../Toolbar/Toolbar';
|
||||
import { Sticky } from '../../../../UI/Sticky/Sticky';
|
||||
import styles from './index.css';
|
||||
@ -8,14 +8,8 @@ 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 || ''),
|
||||
editorState: Plain.deserialize(this.props.value || ''),
|
||||
};
|
||||
}
|
||||
|
||||
@ -27,15 +21,15 @@ export default class RawEditor extends React.Component {
|
||||
this.setState({ editorState });
|
||||
}
|
||||
|
||||
onChange = debounce(this.props.onChange, 250);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* text (which is Markdown) and pass that up as the new value.
|
||||
*/
|
||||
handleDocumentChange = (doc, editorState) => {
|
||||
const value = Plain.serialize(editorState);
|
||||
const mdast = markdownToRemark(value);
|
||||
this.props.onChange(mdast);
|
||||
this.onChange(value);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -79,5 +73,5 @@ export default class RawEditor extends React.Component {
|
||||
RawEditor.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onMode: PropTypes.func.isRequired,
|
||||
value: PropTypes.object,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { fromJS } from 'immutable';
|
||||
import { markdownToRemark, remarkToSlate } from '../../../serializers';
|
||||
import { markdownToSlate } from '../../../serializers';
|
||||
|
||||
// Temporary plugins test, uses preloaded plugins from ../parser
|
||||
// TODO: make the parser more testable
|
||||
const parser = markdownToSlate;
|
||||
|
||||
// Temporary plugins test
|
||||
const testPlugins = fromJS([
|
||||
{
|
||||
label: 'Image',
|
||||
@ -44,8 +45,6 @@ const testPlugins = fromJS([
|
||||
},
|
||||
]);
|
||||
|
||||
const parser = markdown => remarkToSlate(markdownToRemark(markdown));
|
||||
|
||||
describe("Compile markdown to Slate Raw AST", () => {
|
||||
it("should compile simple markdown", () => {
|
||||
const value = `
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { get, isEmpty } from 'lodash';
|
||||
import { get, isEmpty, debounce } from 'lodash';
|
||||
import { Editor as Slate, Raw, Block, Text } from 'slate';
|
||||
import { slateToRemark, remarkToSlate, htmlToSlate } from '../../serializers';
|
||||
import { slateToMarkdown, markdownToSlate, htmlToSlate } from '../../serializers';
|
||||
import registry from '../../../../../lib/registry';
|
||||
import Toolbar from '../Toolbar/Toolbar';
|
||||
import { Sticky } from '../../../../UI/Sticky/Sticky';
|
||||
@ -15,10 +15,10 @@ 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 });
|
||||
const emptyRawDoc = { nodes: [emptyBlock] };
|
||||
const rawDoc = this.props.value && markdownToSlate(this.props.value);
|
||||
const rawDocHasNodes = !isEmpty(get(rawDoc, 'nodes'))
|
||||
const editorState = Raw.deserialize(rawDocHasNodes ? rawDoc : emptyRawDoc, { terse: true });
|
||||
this.state = {
|
||||
editorState,
|
||||
schema: {
|
||||
@ -43,11 +43,13 @@ export default class Editor extends Component {
|
||||
return state.transform().insertFragment(doc).apply();
|
||||
}
|
||||
|
||||
onChange = debounce(this.props.onChange, 250);
|
||||
|
||||
handleDocumentChange = (doc, editorState) => {
|
||||
const raw = Raw.serialize(editorState, { terse: true });
|
||||
const plugins = this.state.shortcodePlugins;
|
||||
const mdast = slateToRemark(raw, plugins);
|
||||
this.props.onChange(mdast);
|
||||
const markdown = slateToMarkdown(raw, plugins);
|
||||
this.onChange(markdown);
|
||||
};
|
||||
|
||||
hasMark = type => this.state.editorState.marks.some(mark => mark.type === type);
|
||||
@ -211,5 +213,5 @@ Editor.propTypes = {
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onMode: PropTypes.func.isRequired,
|
||||
value: PropTypes.object,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
@ -34,6 +34,21 @@ export const SoftBreakConfigured = SoftBreak(SoftBreakOpts);
|
||||
|
||||
export const ParagraphSoftBreakConfigured = SlateSoftBreak({ onlyIn: ['paragraph'], shift: true });
|
||||
|
||||
const BreakToDefaultBlock = ({ onlyIn = [], defaultBlock = 'paragraph' }) => ({
|
||||
onKeyDown(e, data, state) {
|
||||
if (data.key != 'enter' || e.shiftKey == true || state.isExpanded) return;
|
||||
if (onlyIn.includes(state.startBlock.type)) {
|
||||
return state.transform().insertBlock(defaultBlock).apply();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const BreakToDefaultBlockOpts = {
|
||||
onlyIn: ['heading-one', 'heading-two', 'heading-three', 'heading-four', 'heading-five', 'heading-six'],
|
||||
};
|
||||
|
||||
export const BreakToDefaultBlockConfigured = BreakToDefaultBlock(BreakToDefaultBlockOpts);
|
||||
|
||||
const BackspaceCloseBlock = (options = {}) => ({
|
||||
onKeyDown(e, data, state) {
|
||||
if (data.key != 'backspace') return;
|
||||
@ -87,6 +102,7 @@ const plugins = [
|
||||
SoftBreakConfigured,
|
||||
ParagraphSoftBreakConfigured,
|
||||
BackspaceCloseBlockConfigured,
|
||||
BreakToDefaultBlockConfigured,
|
||||
EditListConfigured,
|
||||
EditTableConfigured,
|
||||
];
|
||||
|
@ -7,24 +7,13 @@ import { StickyContainer } from '../../../UI/Sticky/Sticky';
|
||||
|
||||
const MODE_STORAGE_KEY = 'cms.md-mode';
|
||||
|
||||
/**
|
||||
* The markdown field value is persisted as a markdown string, but stringifying
|
||||
* on every keystroke is a big perf hit, so we'll register functions to perform
|
||||
* those actions only when necessary, such as after loading and before
|
||||
* persisting.
|
||||
*/
|
||||
registry.registerWidgetValueSerializer('markdown', {
|
||||
serialize: remarkToMarkdown,
|
||||
deserialize: markdownToRemark,
|
||||
});
|
||||
|
||||
export default class MarkdownControl extends React.Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
value: PropTypes.object,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
@ -4,7 +4,9 @@ import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { padStart } from 'lodash';
|
||||
import MarkdownPreview from '../index';
|
||||
import { markdownToRemark } from '../../serializers';
|
||||
import { markdownToHtml } from '../../serializers';
|
||||
|
||||
const parser = markdownToHtml;
|
||||
|
||||
describe('Markdown Preview renderer', () => {
|
||||
describe('Markdown rendering', () => {
|
||||
@ -36,7 +38,7 @@ Text with **bold** & _em_ elements
|
||||
|
||||
###### H6
|
||||
`;
|
||||
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@ -45,7 +47,7 @@ Text with **bold** & _em_ elements
|
||||
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)} />);
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
}
|
||||
@ -64,7 +66,7 @@ Text with **bold** & _em_ elements
|
||||
1. Sub-Sublist 3
|
||||
1. ol item 3
|
||||
`;
|
||||
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@ -78,7 +80,7 @@ I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3]
|
||||
[2]: http://search.yahoo.com/ "Yahoo Search"
|
||||
[3]: http://search.msn.com/ "MSN Search"
|
||||
`;
|
||||
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@ -86,13 +88,13 @@ I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3]
|
||||
describe('Code', () => {
|
||||
it('should render code', () => {
|
||||
const value = 'Use the `printf()` function.';
|
||||
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(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)} />);
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@ -114,7 +116,7 @@ I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3]
|
||||
|
||||
<h1 style="display: block; border: 10px solid #f00; width: 100%">Test</h1>
|
||||
`;
|
||||
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@ -123,7 +125,7 @@ I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3]
|
||||
describe('HTML rendering', () => {
|
||||
it('should render HTML', () => {
|
||||
const value = '<p>Paragraph with <em>inline</em> element</p>';
|
||||
const component = shallow(<MarkdownPreview value={markdownToRemark(value)} />);
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
@ -1,18 +1,18 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { remarkToHtml } from '../serializers';
|
||||
import { markdownToHtml } from '../serializers';
|
||||
import previewStyle from '../../defaultPreviewStyle';
|
||||
|
||||
const MarkdownPreview = ({ value, getAsset }) => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
const html = remarkToHtml(value, getAsset);
|
||||
const html = markdownToHtml(value, getAsset);
|
||||
return <div style={previewStyle} dangerouslySetInnerHTML={{__html: html}}></div>;
|
||||
};
|
||||
|
||||
MarkdownPreview.propTypes = {
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
value: PropTypes.object,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
export default MarkdownPreview;
|
||||
|
@ -12,12 +12,12 @@ import rehypePaperEmoji from './rehypePaperEmoji';
|
||||
import remarkAssertParents from './remarkAssertParents';
|
||||
import remarkPaddedLinks from './remarkPaddedLinks';
|
||||
import remarkWrapHtml from './remarkWrapHtml';
|
||||
import remarkToSlatePlugin from './remarkSlate';
|
||||
import remarkToSlate from './remarkSlate';
|
||||
import remarkSquashReferences from './remarkSquashReferences';
|
||||
import remarkImagesToText from './remarkImagesToText';
|
||||
import remarkShortcodes from './remarkShortcodes';
|
||||
import remarkEscapeMarkdownEntities from './remarkEscapeMarkdownEntities'
|
||||
import slateToRemarkParser from './slateRemark';
|
||||
import slateToRemark from './slateRemark';
|
||||
import registry from '../../../../lib/registry';
|
||||
|
||||
/**
|
||||
@ -35,11 +35,9 @@ import registry from '../../../../lib/registry';
|
||||
* - 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.
|
||||
* document. Underneath, it's a Unist tree with a Markdown-specific schema.
|
||||
* 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
|
||||
@ -54,55 +52,6 @@ import registry from '../../../../lib/registry';
|
||||
* 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.
|
||||
*/
|
||||
|
||||
|
||||
@ -180,9 +129,11 @@ export const remarkToMarkdown = obj => {
|
||||
|
||||
|
||||
/**
|
||||
* Convert an MDAST to an HTML string.
|
||||
* Convert Markdown to HTML.
|
||||
*/
|
||||
export const remarkToHtml = (mdast, getAsset) => {
|
||||
export const markdownToHtml = (markdown, getAsset) => {
|
||||
const mdast = markdownToRemark(markdown);
|
||||
|
||||
const hast = unified()
|
||||
.use(remarkToRehypeShortcodes, { plugins: registry.getEditorComponents(), getAsset })
|
||||
.use(remarkToRehype, { allowDangerousHTML: true })
|
||||
@ -216,7 +167,7 @@ export const htmlToSlate = html => {
|
||||
.use(remarkImagesToText)
|
||||
.use(remarkShortcodes, { plugins: registry.getEditorComponents() })
|
||||
.use(remarkWrapHtml)
|
||||
.use(remarkToSlatePlugin)
|
||||
.use(remarkToSlate)
|
||||
.runSync(mdast);
|
||||
|
||||
return slateRaw;
|
||||
@ -224,19 +175,22 @@ export const htmlToSlate = html => {
|
||||
|
||||
|
||||
/**
|
||||
* Convert an MDAST to Slate's Raw AST.
|
||||
* Convert Markdown to Slate's Raw AST.
|
||||
*/
|
||||
export const remarkToSlate = mdast => {
|
||||
const result = unified()
|
||||
export const markdownToSlate = markdown => {
|
||||
const mdast = markdownToRemark(markdown);
|
||||
|
||||
const slateRaw = unified()
|
||||
.use(remarkWrapHtml)
|
||||
.use(remarkToSlatePlugin)
|
||||
.use(remarkToSlate)
|
||||
.runSync(mdast);
|
||||
return result;
|
||||
|
||||
return slateRaw;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Convert a Slate Raw AST to MDAST.
|
||||
* Convert a Slate Raw AST to Markdown.
|
||||
*
|
||||
* Requires shortcode plugins to parse shortcode nodes back to text.
|
||||
*
|
||||
@ -244,7 +198,8 @@ export const remarkToSlate = mdast => {
|
||||
* 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;
|
||||
export const slateToMarkdown = raw => {
|
||||
const mdast = slateToRemark(raw, { shortcodePlugins: registry.getEditorComponents() });
|
||||
const markdown = remarkToMarkdown(mdast);
|
||||
return markdown;
|
||||
};
|
||||
|
@ -281,7 +281,7 @@ function convertNode(node, nodes) {
|
||||
* 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() {
|
||||
export default function remarkToSlate() {
|
||||
function transform(node) {
|
||||
|
||||
/**
|
||||
|
Loading…
x
Reference in New Issue
Block a user