From c068fae24ede8d9dcf7e954716b8880367b9afdb Mon Sep 17 00:00:00 2001 From: Mathias Biilmann Christensen Date: Sat, 22 Oct 2016 23:12:21 +0300 Subject: [PATCH] Implement block menu with support for plugins --- src/components/Widgets/MarkdownControl.js | 3 +- .../RawEditor/BlockMenu.css | 134 +++++++++++++++++ .../RawEditor/BlockMenu.js | 135 ++++++++++++++++++ .../RawEditor/Toolbar.js | 2 +- .../RawEditor/index.js | 81 +++++++++-- .../MarkdownControlElements/plugins.js | 18 +-- 6 files changed, 351 insertions(+), 22 deletions(-) create mode 100644 src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.css create mode 100644 src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.js diff --git a/src/components/Widgets/MarkdownControl.js b/src/components/Widgets/MarkdownControl.js index 473402ce..a63c98cb 100644 --- a/src/components/Widgets/MarkdownControl.js +++ b/src/components/Widgets/MarkdownControl.js @@ -30,7 +30,7 @@ class MarkdownControl extends React.Component { }; render() { - const { editor, onChange, onAddMedia, getMedia, value } = this.props; + const { editor, onChange, onAddMedia, onRemoveMedia, getMedia, value } = this.props; if (editor.get('useVisualMode')) { return (
@@ -51,6 +51,7 @@ class MarkdownControl extends React.Component { diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.css b/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.css new file mode 100644 index 00000000..5183a9e7 --- /dev/null +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.css @@ -0,0 +1,134 @@ +.root { + position: absolute; + left: -18px; + display: none; + width: 100%; +} + +.visible { + display: block; +} + +.button { + display: block; + width: 15px; + height: 15px; + border: 1px solid #444; + border-radius: 100%; + background: transparent; + line-height: 13px; +} + +.expanded { + display: block; +} + +.collapsed { + display: none; +} + +.pluginForm, +.menu { + margin-top: -20px; + margin-bottom: 30px; + margin-left: 20px; + border-radius: 4px; + background: #fff; + box-shadow: 1px 1px 20px; + + & h3 { + padding: 5px 20px; + border-bottom: 1px solid; + } +} + +.menu { + list-style: none; + + & li button { + display: block; + padding: 5px 20px; + min-width: 30%; + width: 100%; + border: none; + border-bottom: 1px solid; + background: #fff; + -webkit-appearance: none; + } + + & li:last-child button { + border-bottom: none; + } + + & li:hover button { + background: #efefef; + } +} + +.menu.expanded { + display: inline-block; +} + +.control { + position: relative; + padding: 20px 20px; + color: #7c8382; + + & input, + & textarea, + & select { + display: block; + margin: 0; + padding: 0; + width: 100%; + outline: 0; + border: none; + background: 0 0; + box-shadow: none; + color: #7c8382; + font-size: 18px; + font-family: monospace; + } +} + +.label { + display: block; + margin-bottom: 18px; + color: #aab0af; + font-size: 12px; +} + +.widget { + position: relative; + border-bottom: 1px solid #aaa; + + &:after { + position: absolute; + bottom: -7px; + left: 42px; + z-index: 1; + width: 12px; + height: 12px; + border-right: 1px solid #aaa; + border-bottom: 1px solid #aaa; + background-color: #f2f5f4; + content: ''; + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + } + + &:last-child { + border-bottom: none; + } + + &:last-child:after { + display: none; + } +} + +.footer { + padding: 10px 20px; + border-top: 1px solid #eee; + background: #fff; + text-align: right; +} diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.js b/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.js new file mode 100644 index 00000000..0e060a6d --- /dev/null +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.js @@ -0,0 +1,135 @@ +import React, { Component, PropTypes } from 'react'; +import { fromJS } from 'immutable'; +import { Button } from 'react-toolbox/lib/button'; +import { resolveWidget } from '../../../Widgets'; +import styles from './BlockMenu.css'; + +export default class BlockMenu extends Component { + static propTypes = { + isOpen: PropTypes.bool, + selectionPosition: PropTypes.object, + plugins: PropTypes.object.isRequired, + onBlock: PropTypes.func.isRequired, + onAddMedia: PropTypes.func.isRequired, + onRemoveMedia: PropTypes.func.isRequired, + getMedia: PropTypes.func.isRequired, + }; + + constructor(props) { + super(props); + this.state = { + isExpanded: false, + openPlugin: null, + pluginData: fromJS({}), + }; + } + + componentDidUpdate() { + const { selectionPosition } = this.props; + if (selectionPosition) { + const style = this.element.style; + style.setProperty('top', `${ selectionPosition.top }px`); + } + } + + handleToggle = (e) => { + e.preventDefault(); + this.setState({ isExpanded: !this.state.isExpanded }); + }; + + handleRef = (ref) => { + this.element = ref; + }; + + handlePlugin(plugin) { + return (e) => { + e.preventDefault(); + this.setState({ openPlugin: plugin, pluginData: fromJS({}) }); + }; + } + + buttonFor(plugin) { + return (
  • + +
  • ); + } + + handleSubmit = (e) => { + e.preventDefault(); + const { openPlugin, pluginData } = this.state; + const toBlock = openPlugin.get('toBlock'); + this.props.onBlock(toBlock.call(toBlock, pluginData.toJS())); + this.setState({ openPlugin: null, isExpanded: false }); + }; + + handleCancel = (e) => { + e.preventDefault(); + this.setState({ openPlugin: null, isExpanded: false }); + }; + + controlFor(field) { + const { onAddMedia, onRemoveMedia, getMedia } = this.props; + const { pluginData } = this.state; + const widget = resolveWidget(field.get('widget') || 'string'); + const value = pluginData.get(field.get('name')); + + return ( +
    + + { + React.createElement(widget.control, { + field, + value, + onChange: (val) => { + this.setState({ + pluginData: pluginData.set(field.get('name'), val), + }); + }, + onAddMedia, + onRemoveMedia, + getMedia, + }) + } +
    + ); + } + + pluginForm(plugin) { + return (
    +

    Insert {plugin.get('label')}

    + {plugin.get('fields').map(field => this.controlFor(field))} +
    + + {' '} + +
    +
    ); + } + + render() { + const { isOpen, plugins } = this.props; + const { isExpanded, openPlugin } = this.state; + const classNames = [styles.root]; + if (isOpen) { + classNames.push(styles.visible); + } + if (openPlugin) { + classNames.push(styles.openPlugin); + } + + return (
    + +
      + {plugins.map(plugin => this.buttonFor(plugin))} +
    + {openPlugin && this.pluginForm(openPlugin)} +
    ); + } +} diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.js b/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.js index 4f539501..69bb4d9b 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.js +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.js @@ -10,7 +10,7 @@ function button(label, action) { export default class Toolbar extends Component { static propTypes = { isOpen: PropTypes.bool, - selectionPosition: PropTypes.node, + selectionPosition: PropTypes.object, onBold: PropTypes.func.isRequired, onItalic: PropTypes.func.isRequired, onLink: PropTypes.func.isRequired, diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js index aaac11ce..89aa9d20 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js @@ -1,7 +1,10 @@ import React, { PropTypes } from 'react'; +import { fromJS } from 'immutable'; import CaretPosition from 'textarea-caret-position'; -import Toolbar from './Toolbar'; +import registry from '../../../../lib/registry'; import MediaProxy from '../../../../valueObjects/MediaProxy'; +import Toolbar from './Toolbar'; +import BlockMenu from './BlockMenu'; import styles from './index.css'; const HAS_LINE_BREAK = /\n/m; @@ -20,10 +23,36 @@ function preventDefault(e) { e.preventDefault(); } +const buildtInPlugins = fromJS([{ + label: 'Image', + id: 'image', + fromBlock: (data) => { + const m = data.match(/^!\[([^\]]+)\]\(([^\)]+)\)$/); + return m && { + image: m[2], + alt: m[1], + }; + }, + toBlock: data => `![${ data.alt }](${ data.image })`, + toPreview: data => `${ data.alt }`, + pattern: /^!\[([^\]]+)\]\(([^\)]+)\)$/, + fields: [{ + label: 'Image', + name: 'image', + widget: 'image', + }, { + label: 'Alt Text', + name: 'alt', + }], +}]); + export default class RawEditor extends React.Component { constructor(props) { super(props); - this.state = {}; + const plugins = registry.getEditorComponents(); + this.state = { + plugins: buildtInPlugins.concat(plugins), + }; this.shortcuts = { meta: { b: this.handleBold, @@ -111,10 +140,6 @@ export default class RawEditor extends React.Component { } }; - handleToolbarRef = (ref) => { - this.toolbar = ref; - }; - handleKey = (e) => { if (e.metaKey) { const action = this.shortcuts.meta[e.key]; @@ -140,16 +165,34 @@ export default class RawEditor extends React.Component { }; handleSelection = () => { + const { value } = this.props; const selection = this.getSelection(); if (selection.start !== selection.end && !HAS_LINE_BREAK.test(selection.selected)) { try { const position = this.caretPosition.get(selection.start, selection.end); - this.setState({ showToolbar: true, selectionPosition: position }); + this.setState({ showToolbar: true, showBlockMenu: false, selectionPosition: position }); } catch (e) { - this.setState({ showToolbar: false }); + this.setState({ showToolbar: false, showBlockMenu: false }); + } + } else if (selection.start === selection.end) { + const newBlock = + ( + selection.start === 0 || + value.substr(selection.start - 2, 2) === '\n\n') && + ( + selection.end === (value.length - 1) || + value.substr(selection.end, 2) === '\n\n' || + value.substr(selection.end).match(/\n*$/m) + ); + + if (newBlock) { + const position = this.caretPosition.get(selection.start, selection.end); + this.setState({ showToolbar: false, showBlockMenu: true, selectionPosition: position }); + } else { + this.setState({ showToolbar: false, showBlockMenu: false }); } } else { - this.setState({ showToolbar: false }); + this.setState({ showToolbar: false, showBlockMenu: false }); } }; @@ -158,6 +201,11 @@ export default class RawEditor extends React.Component { this.updateHeight(); }; + handleBlock = (chars) => { + this.replaceSelection(chars); + this.setState({ showBlockMenu: false }); + }; + handleDrop = (e) => { e.preventDefault(); let data; @@ -179,16 +227,25 @@ export default class RawEditor extends React.Component { }; render() { - const { showToolbar, selectionPosition } = this.state; + const { onAddMedia, onRemoveMedia, getMedia } = this.props; + const { showToolbar, showBlockMenu, plugins, selectionPosition } = this.state; return (
    +