Merge pull request #198 from netlify/prosemirror-polishing
An assortment of Markdown Editor fixes
This commit is contained in:
@ -10,6 +10,10 @@ import htmlSyntax from 'markup-it/syntaxes/html';
|
||||
import reInline from 'markup-it/syntaxes/markdown/re/inline';
|
||||
import MarkupItReactRenderer from '../';
|
||||
|
||||
function getMedia(path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
describe('MarkitupReactRenderer', () => {
|
||||
describe('basics', () => {
|
||||
it('should re-render properly after a value and syntax update', () => {
|
||||
@ -17,6 +21,7 @@ describe('MarkitupReactRenderer', () => {
|
||||
<MarkupItReactRenderer
|
||||
value="# Title"
|
||||
syntax={markdownSyntax}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
);
|
||||
const tree1 = component.html();
|
||||
@ -33,6 +38,7 @@ describe('MarkitupReactRenderer', () => {
|
||||
<MarkupItReactRenderer
|
||||
value="# Title"
|
||||
syntax={markdownSyntax}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
);
|
||||
const syntax1 = component.instance().props.syntax;
|
||||
@ -77,6 +83,7 @@ Text with **bold** & _em_ elements
|
||||
<MarkupItReactRenderer
|
||||
value={value}
|
||||
syntax={markdownSyntax}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
@ -91,6 +98,7 @@ Text with **bold** & _em_ elements
|
||||
<MarkupItReactRenderer
|
||||
value={value}
|
||||
syntax={markdownSyntax}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
@ -115,6 +123,7 @@ Text with **bold** & _em_ elements
|
||||
<MarkupItReactRenderer
|
||||
value={value}
|
||||
syntax={markdownSyntax}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
@ -134,6 +143,7 @@ I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3]
|
||||
<MarkupItReactRenderer
|
||||
value={value}
|
||||
syntax={markdownSyntax}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
@ -147,6 +157,7 @@ I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3]
|
||||
<MarkupItReactRenderer
|
||||
value={value}
|
||||
syntax={markdownSyntax}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
@ -158,6 +169,7 @@ I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3]
|
||||
<MarkupItReactRenderer
|
||||
value={value}
|
||||
syntax={markdownSyntax}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
@ -172,7 +184,7 @@ I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3]
|
||||
<form action="test">
|
||||
<label for="input">
|
||||
<input type="checkbox" checked="checked" id="input"/> My label
|
||||
</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>
|
||||
@ -185,6 +197,7 @@ I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3]
|
||||
<MarkupItReactRenderer
|
||||
value={value}
|
||||
syntax={markdownSyntax}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
@ -228,6 +241,7 @@ I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3]
|
||||
value={value}
|
||||
syntax={myMarkdownSyntax}
|
||||
schema={myCustomSchema}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
@ -241,6 +255,7 @@ I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3]
|
||||
<MarkupItReactRenderer
|
||||
value={value}
|
||||
syntax={htmlSyntax}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
|
@ -10,7 +10,7 @@ exports[`MarkitupReactRenderer Markdown rendering HTML should render HTML as is
|
||||
"<article><h1>Title</h1><div><form action=\"test\">
|
||||
<label for=\"input\">
|
||||
<input type=\"checkbox\" checked=\"checked\" id=\"input\"/> My label
|
||||
</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>
|
||||
|
@ -42,11 +42,7 @@ const defaultSchema = {
|
||||
[ENTITIES.HARD_BREAK]: 'br',
|
||||
};
|
||||
|
||||
const notAllowedAttributes = ['loose'];
|
||||
|
||||
function sanitizeProps(props) {
|
||||
return omit(props, notAllowedAttributes);
|
||||
}
|
||||
const notAllowedAttributes = ['loose', 'image'];
|
||||
|
||||
export default class MarkupItReactRenderer extends React.Component {
|
||||
|
||||
@ -66,6 +62,17 @@ export default class MarkupItReactRenderer extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
sanitizeProps(props) {
|
||||
const { getMedia } = this.props;
|
||||
|
||||
if (props.image) {
|
||||
props = Object.assign({}, props, { src: getMedia(props.image).toString() });
|
||||
}
|
||||
|
||||
return omit(props, notAllowedAttributes);
|
||||
}
|
||||
|
||||
|
||||
renderToken(schema, token, index = 0, key = '0') {
|
||||
const type = token.get('type');
|
||||
const data = token.get('data');
|
||||
@ -85,7 +92,7 @@ export default class MarkupItReactRenderer extends React.Component {
|
||||
if (nodeType !== null) {
|
||||
let props = { key, token };
|
||||
if (typeof nodeType !== 'function') {
|
||||
props = { key, ...sanitizeProps(data.toJS()) };
|
||||
props = { key, ...this.sanitizeProps(data.toJS()) };
|
||||
}
|
||||
// If this is a react element
|
||||
return React.createElement(nodeType, props, children);
|
||||
@ -108,7 +115,7 @@ export default class MarkupItReactRenderer extends React.Component {
|
||||
|
||||
|
||||
render() {
|
||||
const { value, schema } = this.props;
|
||||
const { value, schema, getMedia } = this.props;
|
||||
const content = this.parser.toContent(value);
|
||||
return this.renderToken({ ...defaultSchema, ...schema }, content.get('token'));
|
||||
}
|
||||
@ -121,4 +128,5 @@ MarkupItReactRenderer.propTypes = {
|
||||
PropTypes.string,
|
||||
PropTypes.func,
|
||||
])),
|
||||
getMedia: PropTypes.func.isRequired,
|
||||
};
|
||||
|
@ -6,6 +6,8 @@ import { processEditorPlugins } from './richText';
|
||||
import { connect } from 'react-redux';
|
||||
import { switchVisualMode } from '../../actions/editor';
|
||||
|
||||
const MODE_STORAGE_KEY = 'cms.md-mode';
|
||||
|
||||
class MarkdownControl extends React.Component {
|
||||
static propTypes = {
|
||||
editor: PropTypes.object.isRequired,
|
||||
@ -18,7 +20,7 @@ class MarkdownControl extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { mode: 'visual' };
|
||||
this.state = { mode: localStorage.getItem(MODE_STORAGE_KEY) || 'visual' };
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
@ -27,6 +29,7 @@ class MarkdownControl extends React.Component {
|
||||
|
||||
handleMode = (mode) => {
|
||||
this.setState({ mode });
|
||||
localStorage.setItem(MODE_STORAGE_KEY, mode);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -1,3 +1,22 @@
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
@ -24,10 +24,6 @@ function processUrl(url) {
|
||||
return `/${ url }`;
|
||||
}
|
||||
|
||||
function preventDefault(e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function cleanupPaste(paste) {
|
||||
const content = html.toContent(paste);
|
||||
return markdown.toText(content);
|
||||
@ -76,11 +72,9 @@ export default class RawEditor extends React.Component {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateHeight();
|
||||
this.element.addEventListener('dragenter', preventDefault, false);
|
||||
this.element.addEventListener('dragover', preventDefault, false);
|
||||
this.element.addEventListener('drop', this.handleDrop, false);
|
||||
this.element.addEventListener('paste', this.handlePaste, false);
|
||||
}
|
||||
|
||||
@ -93,9 +87,7 @@ export default class RawEditor extends React.Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.element.removeEventListener('dragenter', preventDefault);
|
||||
this.element.removeEventListener('dragover', preventDefault);
|
||||
this.element.removeEventListener('drop', this.handleDrop);
|
||||
this.element.removeEventListener('paste', this.handlePaste);
|
||||
}
|
||||
|
||||
getSelection() {
|
||||
@ -256,8 +248,25 @@ export default class RawEditor extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -296,8 +305,19 @@ export default class RawEditor extends React.Component {
|
||||
|
||||
render() {
|
||||
const { onAddMedia, onRemoveMedia, getMedia } = this.props;
|
||||
const { showToolbar, showBlockMenu, plugins, selectionPosition } = this.state;
|
||||
return (<div className={styles.root}>
|
||||
const { showToolbar, showBlockMenu, 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}
|
||||
>
|
||||
<Toolbar
|
||||
isOpen={showToolbar}
|
||||
selectionPosition={selectionPosition}
|
||||
@ -324,6 +344,7 @@ export default class RawEditor extends React.Component {
|
||||
onChange={this.handleChange}
|
||||
onSelect={this.handleSelection}
|
||||
/>
|
||||
<div className={styles.shim}/>
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,13 @@
|
||||
& p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
& hr {
|
||||
border: 1px solid;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
& li > p {
|
||||
margin: 0;
|
||||
}
|
||||
& div[data-plugin] {
|
||||
background: #fff;
|
||||
border: 1px solid #aaa;
|
||||
@ -28,6 +35,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
@ -11,6 +11,7 @@ import { keymap } from 'prosemirror-keymap';
|
||||
import { schema, defaultMarkdownSerializer } from 'prosemirror-markdown';
|
||||
import { baseKeymap, setBlockType, toggleMark } from 'prosemirror-commands';
|
||||
import registry from '../../../../lib/registry';
|
||||
import MediaProxy from '../../../../valueObjects/MediaProxy';
|
||||
import { buildKeymap } from './keymap';
|
||||
import createMarkdownParser from './parser';
|
||||
import Toolbar from '../Toolbar';
|
||||
@ -88,7 +89,7 @@ function createSerializer(schema, plugins) {
|
||||
plugins.forEach((plugin) => {
|
||||
serializer.nodes[`plugin_${ plugin.get('id') }`] = (state, node) => {
|
||||
const toBlock = plugin.get('toBlock');
|
||||
state.write(toBlock.call(plugin, node.attrs));
|
||||
state.write(toBlock.call(plugin, node.attrs) + '\n\n');
|
||||
};
|
||||
});
|
||||
return serializer;
|
||||
@ -210,7 +211,53 @@ export default class Editor extends Component {
|
||||
handleBlock = (plugin, data) => {
|
||||
const { schema } = this.state;
|
||||
const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`];
|
||||
this.view.props.onAction(this.view.state.tr.replaceSelection(nodeType.create(data.toJS())).action());
|
||||
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) => {
|
||||
const mediaProxy = new MediaProxy(file.name, file);
|
||||
this.props.onAddMedia(mediaProxy);
|
||||
if (file.type.split('/')[0] === 'image') {
|
||||
nodes.push(
|
||||
schema.nodes.image.create({ src: mediaProxy.public_path, alt: file.name })
|
||||
);
|
||||
} else {
|
||||
nodes.push(
|
||||
schema.marks.link.create({ href: mediaProxy.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 = () => {
|
||||
@ -219,9 +266,19 @@ export default class Editor extends Component {
|
||||
|
||||
render() {
|
||||
const { onAddMedia, onRemoveMedia, getMedia } = this.props;
|
||||
const { plugins, showToolbar, showBlockMenu, selectionPosition } = this.state;
|
||||
const { plugins, showToolbar, showBlockMenu, selectionPosition, dragging } = this.state;
|
||||
const classNames = [styles.editor];
|
||||
if (dragging) {
|
||||
classNames.push(styles.dragging);
|
||||
}
|
||||
|
||||
return (<div className={styles.editor}>
|
||||
return (<div
|
||||
className={classNames.join(' ')}
|
||||
onDragEnter={this.handleDragEnter}
|
||||
onDragLeave={this.handleDragLeave}
|
||||
onDragOver={this.handleDragOver}
|
||||
onDrop={this.handleDrop}
|
||||
>
|
||||
<Toolbar
|
||||
isOpen={showToolbar}
|
||||
selectionPosition={selectionPosition}
|
||||
@ -242,6 +299,7 @@ export default class Editor extends Component {
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
<div ref={this.handleRef} />
|
||||
<div className={styles.shim} />
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
@ -228,8 +228,11 @@ export default function createMarkdownParser(schema, plugins) {
|
||||
blockquote: {block: "blockquote"},
|
||||
paragraph: {block: "paragraph"},
|
||||
list_item: {block: "list_item"},
|
||||
bullet_list: {block: "bullet_list"},
|
||||
ordered_list: {block: "ordered_list", attrs: tok => ({order: +tok.attrGet("order") || 1})},
|
||||
// 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"},
|
||||
|
@ -24,6 +24,7 @@ const MarkdownPreview = ({ value, getMedia }) => {
|
||||
value={value}
|
||||
syntax={markdown}
|
||||
schema={schema}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -19,16 +19,22 @@ const htmlContent = `
|
||||
</ol>
|
||||
`;
|
||||
|
||||
function getMedia(path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
storiesOf('MarkupItReactRenderer', module)
|
||||
.add('Markdown', () => (
|
||||
<MarkupItReactRenderer
|
||||
value={mdContent}
|
||||
syntax={markdownSyntax}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
|
||||
)).add('HTML', () => (
|
||||
<MarkupItReactRenderer
|
||||
value={htmlContent}
|
||||
syntax={htmlSyntax}
|
||||
getMedia={getMedia}
|
||||
/>
|
||||
));
|
||||
|
Reference in New Issue
Block a user