diff --git a/src/components/Widgets/MarkdownControl.js b/src/components/Widgets/MarkdownControl.js
index dee24487..f27285c0 100644
--- a/src/components/Widgets/MarkdownControl.js
+++ b/src/components/Widgets/MarkdownControl.js
@@ -1,365 +1,16 @@
import React, { PropTypes } from 'react';
-import _ from 'lodash';
-import { Editor, Raw } from 'slate';
-import position from 'selection-position';
-import MarkupIt, { SlateUtils } from 'markup-it';
-import getSyntax from './MarkdownControlElements/syntax';
-import { DEFAULT_NODE, NODES, MARKS } from './MarkdownControlElements/localRenderers';
-import StylesMenu from './MarkdownControlElements/StylesMenu';
-import BlockTypesMenu from './MarkdownControlElements/BlockTypesMenu';
-import styles from './MarkdownControl.css';
+import VisualEditor from './MarkdownControlElements/VisualEditor';
-/**
- * Slate Render Configuration
- */
class MarkdownControl extends React.Component {
- constructor(props) {
- super(props);
-
- this.getMedia = this.getMedia.bind(this);
- const MarkdownSyntax = getSyntax(this.getMedia);
- this.markdown = new MarkupIt(MarkdownSyntax);
-
- this.customImageNodeRenderer = this.customImageNodeRenderer.bind(this);
- NODES['mediaproxy'] = this.customImageNodeRenderer;
-
- this.blockEdit = false;
- this.menuPositions = {
- stylesMenu: {
- top: 0,
- left: 0,
- width: 0,
- height: 0
- },
- blockTypesMenu: {
- top: 0,
- left: 0,
- width: 0,
- height: 0
- }
- };
-
- let rawJson;
- if (props.value !== undefined) {
- // Parse the markdown
- const content = this.markdown.toContent(props.value);
- // Convert the content to JSON
- rawJson = SlateUtils.encode(content);
- } else {
- rawJson = {
- nodes: [
- { kind: 'block',
- type: 'paragraph',
- nodes: [{
- kind: 'text',
- ranges: [{
- text: ''
- }]
- }]
- }
- ]
- };
- }
- this.state = {
- state: Raw.deserialize(rawJson, { terse: true })
- };
-
- this.handleChange = this.handleChange.bind(this);
- this.handleDocumentChange = this.handleDocumentChange.bind(this);
- this.handleMarkStyleClick = this.handleMarkStyleClick.bind(this);
- this.handleBlockStyleClick = this.handleBlockStyleClick.bind(this);
- this.handleInlineClick = this.handleInlineClick.bind(this);
- this.handleBlockTypeClick = this.handleBlockTypeClick.bind(this);
- this.handleImageClick = this.handleImageClick.bind(this);
- this.focusAndAddParagraph = this.focusAndAddParagraph.bind(this);
- this.handleKeyDown = this.handleKeyDown.bind(this);
- this.calculateHoverMenuPosition = _.throttle(this.calculateHoverMenuPosition.bind(this), 100);
- this.calculateBlockMenuPosition = _.throttle(this.calculateBlockMenuPosition.bind(this), 100);
- this.renderBlockTypesMenu = this.renderBlockTypesMenu.bind(this);
- this.renderNode = this.renderNode.bind(this);
- this.renderMark = this.renderMark.bind(this);
- }
-
- getMedia(src) {
- return this.props.getMedia(src);
- }
-
- /**
- * Custom local renderer for image proxy.
- */
- customImageNodeRenderer(editorProps) {
- const { node, state } = editorProps;
- const isFocused = state.selection.hasEdgeIn(node);
- const className = isFocused ? styles.active : null;
- const src = node.data.get('src');
- return (
-
- );
- }
-
- /**
- * Slate keeps track of selections, scroll position etc.
- * So, onChange gets dispatched on every interaction (click, arrows, everything...)
- * It also have an onDocumentChange, that get's dispached only when the actual
- * content changes
- */
- handleChange(state) {
- if (this.blockEdit) {
- this.blockEdit = false;
- } else {
- this.calculateHoverMenuPosition();
- this.setState({ state }, this.calculateBlockMenuPosition);
- }
- }
-
- handleDocumentChange(document, state) {
- const rawJson = Raw.serialize(state, { terse: true });
- const content = SlateUtils.decode(rawJson);
- this.props.onChange(this.markdown.toText(content));
- }
-
- calculateHoverMenuPosition() {
- const rect = position();
- this.menuPositions.stylesMenu = {
- top: rect.top + window.scrollY,
- left: rect.left + window.scrollX,
- width: rect.width,
- height: rect.height
- };
- }
-
- calculateBlockMenuPosition() {
- // Don't bother calculating position if block is not empty
- if (this.state.state.blocks.get(0).isEmpty) {
- const blockElement = document.querySelectorAll(`[data-key='${this.state.state.selection.focusKey}']`);
- if (blockElement.length > 0) {
- const rect = blockElement[0].getBoundingClientRect();
- this.menuPositions.blockTypesMenu = {
- top: rect.top + window.scrollY,
- left: rect.left + window.scrollX
- };
- // Force re-render so the menu is positioned on these new coordinates
- this.forceUpdate();
- }
- }
- }
-
- /**
- * Toggle marks / blocks when button is clicked
- */
- handleMarkStyleClick(type) {
- let { state } = this.state;
-
- state = state
- .transform()
- .toggleMark(type)
- .apply();
-
- this.setState({ state });
- }
-
- handleBlockStyleClick(type, isActive, isList) {
- let { state } = this.state;
- let transform = state.transform();
- const { document } = state;
-
- // Handle everything but list buttons.
- if (type != 'unordered_list' && type != 'ordered_list') {
-
- if (isList) {
- transform = transform
- .setBlock(isActive ? DEFAULT_NODE : type)
- .unwrapBlock('unordered_list')
- .unwrapBlock('ordered_list');
- }
-
- else {
- transform = transform
- .setBlock(isActive ? DEFAULT_NODE : type);
- }
- }
-
- // Handle the extra wrapping required for list buttons.
- else {
- const isType = state.blocks.some((block) => {
- return !!document.getClosest(block, parent => parent.type == type);
- });
-
- if (isList && isType) {
- transform = transform
- .setBlock(DEFAULT_NODE)
- .unwrapBlock('unordered_list');
- } else if (isList) {
- transform = transform
- .unwrapBlock(type == 'unordered_list')
- .wrapBlock(type);
- } else {
- transform = transform
- .setBlock('list_item')
- .wrapBlock(type);
- }
- }
-
- state = transform.apply();
- this.setState({ state });
- }
-
- /**
- * When clicking a link, if the selection has a link in it, remove the link.
- * Otherwise, add a new link with an href and text.
- *
- * @param {Event} e
- */
-
- handleInlineClick(type, isActive) {
- let { state } = this.state;
-
- if (type === 'link') {
- if (!state.isExpanded) return;
-
- if (isActive) {
- state = state
- .transform()
- .unwrapInline('link')
- .apply();
- }
-
- else {
- const href = window.prompt('Enter the URL of the link:', 'http://www.');
- state = state
- .transform()
- .wrapInline({
- type: 'link',
- data: { href }
- })
- .collapseToEnd()
- .apply();
- }
- }
- this.setState({ state });
- }
-
-
- handleBlockTypeClick(type) {
- let { state } = this.state;
-
- state = state
- .transform()
- .insertBlock({
- type: type,
- isVoid: true
- })
- .apply();
-
- this.setState({ state }, this.focusAndAddParagraph);
- }
-
- handleImageClick(mediaProxy) {
- let { state } = this.state;
- this.props.onAddMedia(mediaProxy);
-
- state = state
- .transform()
- .insertInline({
- type: 'mediaproxy',
- isVoid: true,
- data: { src: mediaProxy.path }
- })
- .collapseToEnd()
- .insertBlock(DEFAULT_NODE)
- .focus()
- .apply();
-
- this.setState({ state });
- }
-
- focusAndAddParagraph() {
- const { state } = this.state;
- const blocks = state.document.getBlocks();
- const last = blocks.last();
- const normalized = state
- .transform()
- .focus()
- .collapseToEndOf(last)
- .splitBlock()
- .setBlock(DEFAULT_NODE)
- .apply({
- snapshot: false
- });
- this.setState({ state:normalized });
- }
-
-
- handleKeyDown(evt) {
- if (evt.shiftKey && evt.key === 'Enter') {
- this.blockEdit = true;
- let { state } = this.state;
- state = state
- .transform()
- .insertText(' \n')
- .apply();
-
- this.setState({ state });
- }
- }
-
- /**
- * Return renderers for Slate
- */
- renderNode(node) {
- return NODES[node.type];
- }
- renderMark(mark) {
- return MARKS[mark.type];
- }
-
- renderBlockTypesMenu() {
- const currentBlock = this.state.state.blocks.get(0);
- const isOpen = (currentBlock.isEmpty && currentBlock.type !== 'horizontal-rule');
-
- return (
-
- );
- }
-
- renderStylesMenu() {
- const { state } = this.state;
- const isOpen = !(state.isBlurred || state.isCollapsed);
-
- return (
-
- );
- }
-
render() {
+ const { onChange, onAddMedia, getMedia, value } = this.props;
return (
-
- {this.renderStylesMenu()}
- {this.renderBlockTypesMenu()}
-
-
+
);
}
}
diff --git a/src/components/Widgets/MarkdownControlElements/Block.css b/src/components/Widgets/MarkdownControlElements/VisualEditor/Block.css
similarity index 100%
rename from src/components/Widgets/MarkdownControlElements/Block.css
rename to src/components/Widgets/MarkdownControlElements/VisualEditor/Block.css
diff --git a/src/components/Widgets/MarkdownControlElements/Block.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/Block.js
similarity index 100%
rename from src/components/Widgets/MarkdownControlElements/Block.js
rename to src/components/Widgets/MarkdownControlElements/VisualEditor/Block.js
diff --git a/src/components/Widgets/MarkdownControlElements/BlockTypesMenu.css b/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.css
similarity index 100%
rename from src/components/Widgets/MarkdownControlElements/BlockTypesMenu.css
rename to src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.css
diff --git a/src/components/Widgets/MarkdownControlElements/BlockTypesMenu.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.js
similarity index 97%
rename from src/components/Widgets/MarkdownControlElements/BlockTypesMenu.js
rename to src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.js
index 5337668f..0912ce60 100644
--- a/src/components/Widgets/MarkdownControlElements/BlockTypesMenu.js
+++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.js
@@ -1,7 +1,7 @@
import React, { Component, PropTypes } from 'react';
import Portal from 'react-portal';
-import { Icon } from '../../UI';
-import MediaProxy from '../../../valueObjects/MediaProxy';
+import { Icon } from '../../../UI';
+import MediaProxy from '../../../../valueObjects/MediaProxy';
import styles from './BlockTypesMenu.css';
export default class BlockTypesMenu extends Component {
diff --git a/src/components/Widgets/MarkdownControlElements/StylesMenu.css b/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.css
similarity index 100%
rename from src/components/Widgets/MarkdownControlElements/StylesMenu.css
rename to src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.css
diff --git a/src/components/Widgets/MarkdownControlElements/StylesMenu.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.js
similarity index 99%
rename from src/components/Widgets/MarkdownControlElements/StylesMenu.js
rename to src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.js
index b077a5e5..f2aafc3e 100644
--- a/src/components/Widgets/MarkdownControlElements/StylesMenu.js
+++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.js
@@ -1,6 +1,6 @@
import React, { Component, PropTypes } from 'react';
import Portal from 'react-portal';
-import { Icon } from '../../UI';
+import { Icon } from '../../../UI';
import styles from './StylesMenu.css';
export default class StylesMenu extends Component {
diff --git a/src/components/Widgets/MarkdownControl.css b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.css
similarity index 100%
rename from src/components/Widgets/MarkdownControl.css
rename to src/components/Widgets/MarkdownControlElements/VisualEditor/index.css
diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js
new file mode 100644
index 00000000..33dea48d
--- /dev/null
+++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js
@@ -0,0 +1,361 @@
+import React, { PropTypes } from 'react';
+import _ from 'lodash';
+import { Editor, Raw } from 'slate';
+import position from 'selection-position';
+import MarkupIt, { SlateUtils } from 'markup-it';
+import getSyntax from '../syntax';
+import { emptyParagraphBlock } from '../constants';
+import { DEFAULT_NODE, NODES, MARKS } from './localRenderers';
+import StylesMenu from './StylesMenu';
+import BlockTypesMenu from './BlockTypesMenu';
+import styles from './index.css';
+
+/**
+ * Slate Render Configuration
+ */
+class VisualEditor extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.getMedia = this.getMedia.bind(this);
+ const MarkdownSyntax = getSyntax(this.getMedia);
+ this.markdown = new MarkupIt(MarkdownSyntax);
+
+ this.customImageNodeRenderer = this.customImageNodeRenderer.bind(this);
+ NODES['mediaproxy'] = this.customImageNodeRenderer;
+
+ this.blockEdit = false;
+ this.menuPositions = {
+ stylesMenu: {
+ top: 0,
+ left: 0,
+ width: 0,
+ height: 0
+ },
+ blockTypesMenu: {
+ top: 0,
+ left: 0,
+ width: 0,
+ height: 0
+ }
+ };
+
+ let rawJson;
+ if (props.value !== undefined) {
+ const content = this.markdown.toContent(props.value);
+ rawJson = SlateUtils.encode(content);
+ } else {
+ rawJson = emptyParagraphBlock;
+ }
+ this.state = {
+ state: Raw.deserialize(rawJson, { terse: true })
+ };
+
+ this.handleChange = this.handleChange.bind(this);
+ this.handleDocumentChange = this.handleDocumentChange.bind(this);
+ this.handleMarkStyleClick = this.handleMarkStyleClick.bind(this);
+ this.handleBlockStyleClick = this.handleBlockStyleClick.bind(this);
+ this.handleInlineClick = this.handleInlineClick.bind(this);
+ this.handleBlockTypeClick = this.handleBlockTypeClick.bind(this);
+ this.handleImageClick = this.handleImageClick.bind(this);
+ this.focusAndAddParagraph = this.focusAndAddParagraph.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+ this.calculateHoverMenuPosition = _.throttle(this.calculateHoverMenuPosition.bind(this), 30);
+ this.calculateBlockMenuPosition = _.throttle(this.calculateBlockMenuPosition.bind(this), 100);
+ this.renderBlockTypesMenu = this.renderBlockTypesMenu.bind(this);
+ this.renderNode = this.renderNode.bind(this);
+ this.renderMark = this.renderMark.bind(this);
+ }
+
+ getMedia(src) {
+ return this.props.getMedia(src);
+ }
+
+ /**
+ * Custom local renderer for image proxy.
+ */
+ customImageNodeRenderer(editorProps) {
+ const { node, state } = editorProps;
+ const isFocused = state.selection.hasEdgeIn(node);
+ const className = isFocused ? styles.active : null;
+ const src = node.data.get('src');
+ return (
+
+ );
+ }
+
+ /**
+ * Slate keeps track of selections, scroll position etc.
+ * So, onChange gets dispatched on every interaction (click, arrows, everything...)
+ * It also have an onDocumentChange, that get's dispached only when the actual
+ * content changes
+ */
+ handleChange(state) {
+ if (this.blockEdit) {
+ this.blockEdit = false;
+ } else {
+ this.calculateHoverMenuPosition();
+ this.setState({ state }, this.calculateBlockMenuPosition);
+ }
+ }
+
+ handleDocumentChange(document, state) {
+ const rawJson = Raw.serialize(state, { terse: true });
+ const content = SlateUtils.decode(rawJson);
+ this.props.onChange(this.markdown.toText(content));
+ }
+
+ calculateHoverMenuPosition() {
+ const rect = position();
+ this.menuPositions.stylesMenu = {
+ top: rect.top + window.scrollY,
+ left: rect.left + window.scrollX,
+ width: rect.width,
+ height: rect.height
+ };
+ }
+
+ calculateBlockMenuPosition() {
+ // Don't bother calculating position if block is not empty
+ if (this.state.state.blocks.get(0).isEmpty) {
+ const blockElement = document.querySelectorAll(`[data-key='${this.state.state.selection.focusKey}']`);
+ if (blockElement.length > 0) {
+ const rect = blockElement[0].getBoundingClientRect();
+ this.menuPositions.blockTypesMenu = {
+ top: rect.top + window.scrollY,
+ left: rect.left + window.scrollX
+ };
+ // Force re-render so the menu is positioned on these new coordinates
+ this.forceUpdate();
+ }
+ }
+ }
+
+ /**
+ * Toggle marks / blocks when button is clicked
+ */
+ handleMarkStyleClick(type) {
+ let { state } = this.state;
+
+ state = state
+ .transform()
+ .toggleMark(type)
+ .apply();
+
+ this.setState({ state });
+ }
+
+ handleBlockStyleClick(type, isActive, isList) {
+ let { state } = this.state;
+ let transform = state.transform();
+ const { document } = state;
+
+ // Handle everything but list buttons.
+ if (type != 'unordered_list' && type != 'ordered_list') {
+
+ if (isList) {
+ transform = transform
+ .setBlock(isActive ? DEFAULT_NODE : type)
+ .unwrapBlock('unordered_list')
+ .unwrapBlock('ordered_list');
+ }
+
+ else {
+ transform = transform
+ .setBlock(isActive ? DEFAULT_NODE : type);
+ }
+ }
+
+ // Handle the extra wrapping required for list buttons.
+ else {
+ const isType = state.blocks.some((block) => {
+ return !!document.getClosest(block, parent => parent.type == type);
+ });
+
+ if (isList && isType) {
+ transform = transform
+ .setBlock(DEFAULT_NODE)
+ .unwrapBlock('unordered_list');
+ } else if (isList) {
+ transform = transform
+ .unwrapBlock(type == 'unordered_list')
+ .wrapBlock(type);
+ } else {
+ transform = transform
+ .setBlock('list_item')
+ .wrapBlock(type);
+ }
+ }
+
+ state = transform.apply();
+ this.setState({ state });
+ }
+
+ /**
+ * When clicking a link, if the selection has a link in it, remove the link.
+ * Otherwise, add a new link with an href and text.
+ *
+ * @param {Event} e
+ */
+
+ handleInlineClick(type, isActive) {
+ let { state } = this.state;
+
+ if (type === 'link') {
+ if (!state.isExpanded) return;
+
+ if (isActive) {
+ state = state
+ .transform()
+ .unwrapInline('link')
+ .apply();
+ }
+
+ else {
+ const href = window.prompt('Enter the URL of the link:', 'http://www.');
+ state = state
+ .transform()
+ .wrapInline({
+ type: 'link',
+ data: { href }
+ })
+ .collapseToEnd()
+ .apply();
+ }
+ }
+ this.setState({ state });
+ }
+
+
+ handleBlockTypeClick(type) {
+ let { state } = this.state;
+
+ state = state
+ .transform()
+ .insertBlock({
+ type: type,
+ isVoid: true
+ })
+ .apply();
+
+ this.setState({ state }, this.focusAndAddParagraph);
+ }
+
+ handleImageClick(mediaProxy) {
+ let { state } = this.state;
+ this.props.onAddMedia(mediaProxy);
+
+ state = state
+ .transform()
+ .insertInline({
+ type: 'mediaproxy',
+ isVoid: true,
+ data: { src: mediaProxy.path }
+ })
+ .collapseToEnd()
+ .insertBlock(DEFAULT_NODE)
+ .focus()
+ .apply();
+
+ this.setState({ state });
+ }
+
+ focusAndAddParagraph() {
+ const { state } = this.state;
+ const blocks = state.document.getBlocks();
+ const last = blocks.last();
+ const normalized = state
+ .transform()
+ .focus()
+ .collapseToEndOf(last)
+ .splitBlock()
+ .setBlock(DEFAULT_NODE)
+ .apply({
+ snapshot: false
+ });
+ this.setState({ state:normalized });
+ }
+
+
+ handleKeyDown(evt) {
+ if (evt.shiftKey && evt.key === 'Enter') {
+ this.blockEdit = true;
+ let { state } = this.state;
+ state = state
+ .transform()
+ .insertText(' \n')
+ .apply();
+
+ this.setState({ state });
+ }
+ }
+
+ /**
+ * Return renderers for Slate
+ */
+ renderNode(node) {
+ return NODES[node.type];
+ }
+ renderMark(mark) {
+ return MARKS[mark.type];
+ }
+
+ renderBlockTypesMenu() {
+ const currentBlock = this.state.state.blocks.get(0);
+ const isOpen = (this.props.value !== undefined && currentBlock.isEmpty && currentBlock.type !== 'horizontal-rule');
+
+ return (
+
+ );
+ }
+
+ renderStylesMenu() {
+ const { state } = this.state;
+ const isOpen = !(state.isBlurred || state.isCollapsed);
+
+ return (
+
+ );
+ }
+
+ render() {
+ return (
+
+ {this.renderStylesMenu()}
+ {this.renderBlockTypesMenu()}
+
+
+ );
+ }
+}
+
+export default VisualEditor;
+
+VisualEditor.propTypes = {
+ onChange: PropTypes.func.isRequired,
+ onAddMedia: PropTypes.func.isRequired,
+ getMedia: PropTypes.func.isRequired,
+ value: PropTypes.node,
+};
diff --git a/src/components/Widgets/MarkdownControlElements/localRenderers.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/localRenderers.js
similarity index 97%
rename from src/components/Widgets/MarkdownControlElements/localRenderers.js
rename to src/components/Widgets/MarkdownControlElements/VisualEditor/localRenderers.js
index b23b3d85..2dfac7d2 100644
--- a/src/components/Widgets/MarkdownControlElements/localRenderers.js
+++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/localRenderers.js
@@ -1,6 +1,6 @@
import React from 'react';
import Block from './Block';
-import styles from '../MarkdownControl.css';
+import styles from './index.css';
/* eslint react/prop-types: 0, react/no-multi-comp: 0 */
diff --git a/src/components/Widgets/MarkdownControlElements/constants.js b/src/components/Widgets/MarkdownControlElements/constants.js
new file mode 100644
index 00000000..74779111
--- /dev/null
+++ b/src/components/Widgets/MarkdownControlElements/constants.js
@@ -0,0 +1,13 @@
+export const emptyParagraphBlock = {
+ nodes: [
+ { kind: 'block',
+ type: 'paragraph',
+ nodes: [{
+ kind: 'text',
+ ranges: [{
+ text: ''
+ }]
+ }]
+ }
+ ]
+};