Merge pull request #574 from netlify/markdown-editor-improvements

Markdown editor improvements
This commit is contained in:
Shawn Erquhart 2017-09-01 15:17:28 -04:00 committed by GitHub
commit 86b094b1cf
10 changed files with 78 additions and 120 deletions

View File

@ -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';

View File

@ -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,
};

View File

@ -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 = `

View File

@ -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,
};

View File

@ -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,
];

View File

@ -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) {

View File

@ -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();
});
});

View File

@ -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;

View File

@ -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;
};

View File

@ -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) {
/**