From 7405ae8f633ca57f966650453635ec7c7b3ff22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Thu, 11 Aug 2016 11:27:09 -0300 Subject: [PATCH] Refactored the markdown visual/block editor into it's own component --- src/components/Widgets/MarkdownControl.js | 365 +----------------- .../{ => VisualEditor}/Block.css | 0 .../{ => VisualEditor}/Block.js | 0 .../{ => VisualEditor}/BlockTypesMenu.css | 0 .../{ => VisualEditor}/BlockTypesMenu.js | 4 +- .../{ => VisualEditor}/StylesMenu.css | 0 .../{ => VisualEditor}/StylesMenu.js | 2 +- .../VisualEditor/index.css} | 0 .../VisualEditor/index.js | 361 +++++++++++++++++ .../{ => VisualEditor}/localRenderers.js | 2 +- .../MarkdownControlElements/constants.js | 13 + 11 files changed, 386 insertions(+), 361 deletions(-) rename src/components/Widgets/MarkdownControlElements/{ => VisualEditor}/Block.css (100%) rename src/components/Widgets/MarkdownControlElements/{ => VisualEditor}/Block.js (100%) rename src/components/Widgets/MarkdownControlElements/{ => VisualEditor}/BlockTypesMenu.css (100%) rename src/components/Widgets/MarkdownControlElements/{ => VisualEditor}/BlockTypesMenu.js (97%) rename src/components/Widgets/MarkdownControlElements/{ => VisualEditor}/StylesMenu.css (100%) rename src/components/Widgets/MarkdownControlElements/{ => VisualEditor}/StylesMenu.js (99%) rename src/components/Widgets/{MarkdownControl.css => MarkdownControlElements/VisualEditor/index.css} (100%) create mode 100644 src/components/Widgets/MarkdownControlElements/VisualEditor/index.js rename src/components/Widgets/MarkdownControlElements/{ => VisualEditor}/localRenderers.js (97%) create mode 100644 src/components/Widgets/MarkdownControlElements/constants.js 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: '' + }] + }] + } + ] +};