Merge pull request #376 from netlify/entry-editor-ui

Move to static rich text editor control bar
This commit is contained in:
Benaiah Mischenko 2017-04-25 16:53:39 -07:00 committed by GitHub
commit 754369d80e
29 changed files with 2211 additions and 1940 deletions

View File

@ -141,7 +141,6 @@
CMS.registerEditorComponent({
id: "youtube",
label: "Youtube",
icon: 'video',
fields: [{name: 'id', label: 'Youtube Video ID'}],
pattern: /^{{<\s?youtube (\S+)\s?>}}/,
fromBlock: function(match) {

View File

@ -94,6 +94,7 @@
"@kadira/storybook": "^1.36.0",
"autoprefixer": "^6.3.3",
"babel-plugin-transform-builtin-extend": "^1.1.0",
"classnames": "^2.2.5",
"dateformat": "^1.0.12",
"deep-equal": "^1.0.1",
"fuzzy": "^0.1.1",
@ -146,6 +147,7 @@
"react-simple-dnd": "^0.1.2",
"react-sortable": "^1.2.0",
"react-split-pane": "^0.1.57",
"react-textarea-autosize-inputref": "^4.1.0",
"react-toolbox": "^1.2.1",
"react-topbar-progress-indicator": "^1.0.0",
"react-waypoint": "^3.1.3",

View File

@ -7,9 +7,14 @@
.previewToggle {
position: absolute;
top: 0;
top: 8px;
right: 40px;
z-index: 1000;
opacity: 0.8;
}
.previewToggleShow {
right: 60px;
}
.controlPane {
@ -17,7 +22,7 @@
overflow: auto;
padding: 20px 20px 0;
border-right: 1px solid var(--defaultColorLight);
background-color: color(#f2f5f4 lightness(90%));
background-color: var(--backgroundTertiaryColorDark);
}
.previewPane {

View File

@ -2,11 +2,14 @@ import React, { Component, PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import SplitPane from 'react-split-pane';
import Button from 'react-toolbox/lib/button';
import classnames from 'classnames';
import { ScrollSync, ScrollSyncPane } from '../ScrollSync';
import ControlPane from '../ControlPanel/ControlPane';
import PreviewPane from '../PreviewPane/PreviewPane';
import Toolbar from './EntryEditorToolbar';
import { StickyContext } from '../UI/Sticky/Sticky';
import styles from './EntryEditor.css';
import stickyStyles from '../UI/Sticky/Sticky.css';
const PREVIEW_VISIBLE = 'cms.preview-visible';
@ -50,17 +53,25 @@ class EntryEditor extends Component {
onCancelEdit,
} = this.props;
const controlClassName = `${ styles.controlPane } ${ this.state.showEventBlocker && styles.blocker }`;
const previewClassName = `${ styles.previewPane } ${ this.state.showEventBlocker && styles.blocker }`;
const { previewVisible, showEventBlocker } = this.state;
const collectionPreviewEnabled = collection.getIn(['editor', 'preview'], true);
const togglePreviewButton = (
<Button className={styles.previewToggle} onClick={this.handleTogglePreview}>Toggle Preview</Button>
<Button
className={classnames(styles.previewToggle, { previewVisible: styles.previewToggleShow })}
onClick={this.handleTogglePreview}
icon={previewVisible ? 'visibility_off' : 'visibility'}
floating
mini
/>
);
const editor = (
<div className={controlClassName}>
<StickyContext
className={classnames(styles.controlPane, { [styles.blocker]: showEventBlocker })}
registerListener={fn => this.updateStickyContext = fn}
>
{ collectionPreviewEnabled ? togglePreviewButton : null }
<ControlPane
collection={collection}
@ -75,7 +86,7 @@ class EntryEditor extends Component {
onRemoveAsset={onRemoveAsset}
ref={c => this.controlPaneRef = c} // eslint-disable-line
/>
</div>
</StickyContext>
);
const editorWithPreview = (
@ -85,9 +96,10 @@ class EntryEditor extends Component {
defaultSize="50%"
onDragStarted={this.handleSplitPaneDragStart}
onDragFinished={this.handleSplitPaneDragFinished}
onChange={this.updateStickyContext}
>
<ScrollSyncPane>{editor}</ScrollSyncPane>
<div className={previewClassName}>
<div className={classnames(styles.previewPane, { [styles.blocker]: showEventBlocker })}>
<PreviewPane
collection={collection}
entry={entry}

View File

@ -0,0 +1,19 @@
.stickyContainer {
position: relative !important;
}
.sticky {
width: 100%;
}
.stickyActive:not(.stickyAtBottom) {
position: fixed !important;
top: 64px !important;
}
.stickyAtBottom {
position: absolute !important;
top: auto !important;
bottom: 30px !important;
}

View File

@ -0,0 +1,219 @@
import React, { Component, PropTypes } from 'react';
import classnames from 'classnames';
import { partial, without } from 'lodash';
import styles from './Sticky.css';
/**
* Sticky is a collection of three components meant to facilitate "sticky" UI
* behavior for nested components. It uses React Context to provide an isolated,
* children-accessible state machine. It was specifically built for the rich
* text editor toolbar to achieve the following:
*
* - work within a scrollable section as if it were the window
* - remain at the top of the scrollable section if the rich text field begins
* to scroll up and out
* - scroll away with the rich text field when it is almost out of view
* - work when multiple rich text fields are present
*
* No available solution was near facilitating this for a React app. Eventually,
* if use continues, it should be improved to be more abstract and potentially
* split off to a separate library unto itself, covering more use cases than
* just the rich text toolbar.
*
* Sticky consists of three components, which are documented right here to
* facilitate a concise, high level overview:
*
* - StickyContext: the scrollable area that essentially serves as the window
* should be wrapped in StickyContext
* - StickyContainer: wraps the secondary container that the sticky element is
* bound within, eg. the rich text field
* - Sticky: wraps the sticky element itself
*/
export class StickyContext extends Component {
static childContextTypes = {
subscribeToStickyContext: PropTypes.func,
unsubscribeToStickyContext: PropTypes.func,
requestUpdate: PropTypes.func,
};
static propTypes = {
className: PropTypes.string,
/**
* registerListener: accepts a function that is called with the `updateStickies` method as an
* arg, so it can be accessed from the component implementation site similar to how refs are
* accessed:
*
* <StickyContext registerListener={fn => this.updateStickyContext = fn}>
*
* This function can then be used from the component implementation site to force update the
* entire Sticky instance, which is sometimes necessary.
*/
registerListener: PropTypes.func,
}
constructor(props) {
super(props);
this.subscriptions = [];
}
subscribeToStickyContext = (fn) => {
this.subscriptions.push(fn);
};
getChildContext() {
return {
subscribeToStickyContext: this.subscribeToStickyContext,
unsubscribeToStickyContext: (fn) => { this.subscriptions = without(this.subscriptions, fn); },
requestUpdate: () => { window.setTimeout(() => { this.updateStickies.call(this, this.ref); }); },
};
}
updateStickies = (ref) => {
const stickyContextTop = ref && ref.getBoundingClientRect().top;
this.subscriptions.forEach((fn) => { fn(stickyContextTop); });
};
componentDidMount() {
this.updateStickies(this.ref);
this.props.registerListener(this.updateStickies.bind(this, this.ref));
}
handleScroll = (event) => {
this.updateStickies(event.target);
};
render() {
return (
<div className={this.props.className} onScroll={this.handleScroll} ref={(ref) => { this.ref = ref; }}>
{this.props.children}
</div>
);
}
}
export class StickyContainer extends Component {
constructor(props) {
super(props);
this.subscriptions = [];
}
static contextTypes = {
subscribeToStickyContext: PropTypes.func,
unsubscribeToStickyContext: PropTypes.func,
requestUpdate: PropTypes.func,
};
static childContextTypes = {
subscribeToStickyContainer: PropTypes.func,
unsubscribeToStickyContainer: PropTypes.func,
requestUpdate: PropTypes.func,
};
getChildContext() {
return {
subscribeToStickyContainer: (fn) => { this.subscriptions.push(fn); },
unsubscribeToStickyContainer: (fn) => { this.subscriptions = without(this.subscriptions, fn); },
requestUpdate: () => { this.context.requestUpdate.call(this); },
};
}
/**
* getPosition: used for updating the sticky element to stick or not stick, and also provides
* width info. Because a sticky element uses fixed positioning, it may not be able to be sized
* relative to a parent, so the StickyContainer width is provided to allow the Sticky to be sized
* accordingly.
*/
getPosition = (contextTop) => {
const rect = this.ref.getBoundingClientRect();
const shouldStick = rect.top < contextTop;
const shouldStickAtBottom = rect.bottom - 60 < contextTop;
this.subscriptions.forEach((fn) => { fn(shouldStick, shouldStickAtBottom, rect.width); });
};
componentDidMount() {
this.context.subscribeToStickyContext(this.getPosition);
}
componentWillUnmount() {
this.context.unsubscribeToStickyContext(this.getPosition);
}
render() {
return (
<div
id={this.context.string}
className={classnames(this.props.className, styles.stickyContainer)}
ref={(ref) => { this.ref = ref }}
>
{this.props.children}
</div>
);
}
}
export class Sticky extends Component {
static contextTypes = {
subscribeToStickyContainer: PropTypes.func,
unsubscribeToStickyContainer: PropTypes.func,
requestUpdate: PropTypes.func,
};
static propTypes = {
className: PropTypes.string,
/**
* fillContainerWidth: allows the sticky width to be dynamically set to the width of it's
* StickyContainer when sticky (fixed positioning).
*/
fillContainerWidth: PropTypes.bool,
}
constructor(props, context) {
super(props, context);
this.state = {};
}
updateSticky = (shouldStick, shouldStickAtBottom, containerWidth) => {
this.setState({ shouldStick, shouldStickAtBottom, containerWidth });
};
componentDidMount() {
this.context.subscribeToStickyContainer(this.updateSticky);
this.context.requestUpdate();
}
componentWillUnmount() {
this.context.unsubscribeToStickyContainer(this.updateSticky);
}
render() {
const { props, state } = this;
const stickyPlaceholderHeight = state.shouldStick ? this.ref.getBoundingClientRect().height : 0;
return (
<div>
<div style={{paddingBottom: stickyPlaceholderHeight}}></div>
<div
className={classnames(
props.className,
styles.sticky,
{
[styles.stickyActive]: state.shouldStick,
[styles.stickyAtBottom]: state.shouldStickAtBottom,
},
)}
style={
props.fillContainerWidth && state.containerWidth && state.shouldStick ?
{ width: state.containerWidth } :
null
}
ref={(ref) => {this.ref = ref}}
>
{props.children}
</div>
</div>
);
}
}

View File

@ -8,16 +8,24 @@
--successColor: #1c7;
--warningColor: #fa0;
--errorColor: #f52;
--textColor: #272e30;
--borderRadius: 2px;
--borderRadiusLarge: 10px;
--dropShadow:
0 2px 2px 0 rgba(0, 0, 0, 0.14),
0 3px 1px -2px rgba(0, 0, 0, 0.2),
0 1px 5px 0 rgba(0, 0, 0, 0.12);
--topmostZindex: 99999;
--foregroundAltColor: #fff;
--backgroundAltColor: #272e30;
--textFieldBorderColor: #e7e7e7;
--highlightFGColor: #fff;
--highlightBGColor: #3ab7a5;
--controlLabelColor: #272e30;
--highlightFGAltColor: #eee;
--controlLabelColor: var(--textColor);
--controlBGColor: #fff;
--backgroundTertiaryColor: #f2f5f4;
--backgroundTertiaryColorDark: color(var(--backgroundTertiaryColor) lightness(90%));
}
.base {
@ -36,3 +44,9 @@
.depth {
box-shadow: var(--shadowColor) 0 1px 6px;
}
.clearfix:after {
content: '';
display: table;
clear: both;
}

View File

@ -3,18 +3,16 @@ import registry from '../../lib/registry';
import RawEditor from './MarkdownControlElements/RawEditor';
import VisualEditor from './MarkdownControlElements/VisualEditor';
import { processEditorPlugins } from './richText';
import { connect } from 'react-redux';
import { switchVisualMode } from '../../actions/editor';
import { StickyContainer } from '../UI/Sticky/Sticky';
const MODE_STORAGE_KEY = 'cms.md-mode';
class MarkdownControl extends React.Component {
export default class MarkdownControl extends React.Component {
static propTypes = {
editor: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
switchVisualMode: PropTypes.func.isRequired,
value: PropTypes.node,
};
@ -35,22 +33,19 @@ class MarkdownControl extends React.Component {
render() {
const { onChange, onAddAsset, onRemoveAsset, getAsset, value } = this.props;
const { mode } = this.state;
if (mode === 'visual') {
return (
<div className="cms-editor-visual">
<VisualEditor
onChange={onChange}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
onMode={this.handleMode}
getAsset={getAsset}
value={value}
/>
</div>
);
}
return (
const visualEditor = (
<div className="cms-editor-visual">
<VisualEditor
onChange={onChange}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
onMode={this.handleMode}
getAsset={getAsset}
value={value}
/>
</div>
);
const rawEditor = (
<div className="cms-editor-raw">
<RawEditor
onChange={onChange}
@ -62,10 +57,6 @@ class MarkdownControl extends React.Component {
/>
</div>
);
return <StickyContainer>{ mode === 'visual' ? visualEditor : rawEditor }</StickyContainer>;
}
}
export default connect(
state => ({ editor: state.editor }),
{ switchVisualMode }
)(MarkdownControl);

View File

@ -1,135 +0,0 @@
.root {
position: absolute;
left: -18px;
display: none;
width: 100%;
z-index: 1000;
}
.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;
}

View File

@ -1,134 +0,0 @@
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,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: 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 (<li key={`plugin-${ plugin.get('id') }`}>
<button onClick={this.handlePlugin(plugin)}>{plugin.get('label')}</button>
</li>);
}
handleSubmit = (e) => {
e.preventDefault();
const { openPlugin, pluginData } = this.state;
this.props.onBlock(openPlugin, pluginData);
this.setState({ openPlugin: null, isExpanded: false });
};
handleCancel = (e) => {
e.preventDefault();
this.setState({ openPlugin: null, isExpanded: false });
};
controlFor(field) {
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
const { pluginData } = this.state;
const widget = resolveWidget(field.get('widget') || 'string');
const value = pluginData.get(field.get('name'));
return (
<div className={styles.control} key={`field-${ field.get('name') }`}>
<label className={styles.label}>{field.get('label')}</label>
{
React.createElement(widget.control, {
field,
value,
onChange: (val) => {
this.setState({
pluginData: pluginData.set(field.get('name'), val),
});
},
onAddAsset,
onRemoveAsset,
getAsset,
})
}
</div>
);
}
pluginForm(plugin) {
return (<form className={styles.pluginForm} onSubmit={this.handleSubmit}>
<h3>Insert {plugin.get('label')}</h3>
{plugin.get('fields').map(field => this.controlFor(field))}
<div className={styles.footer}>
<Button
raised
onClick={this.handleSubmit}
>
Insert
</Button>
{' '}
<Button onClick={this.handleCancel}>
Cancel
</Button>
</div>
</form>);
}
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 (<div className={classNames.join(' ')} ref={this.handleRef}>
<button className={styles.button} onClick={this.handleToggle}>+</button>
<ul className={[styles.menu, isExpanded && !openPlugin ? styles.expanded : styles.collapsed].join(' ')}>
{plugins.map(plugin => this.buttonFor(plugin))}
</ul>
{openPlugin && this.pluginForm(openPlugin)}
</div>);
}
}

View File

@ -2,6 +2,10 @@
position: relative;
}
.editorControlBar {
composes: editorControlBar from "../VisualEditor/index.css";
}
.dragging { }
.shim {

View File

@ -3,10 +3,11 @@ import MarkupIt from 'markup-it';
import markdownSyntax from 'markup-it/syntaxes/markdown';
import htmlSyntax from 'markup-it/syntaxes/html';
import CaretPosition from 'textarea-caret-position';
import TextareaAutosize from 'react-textarea-autosize-inputref';
import registry from '../../../../lib/registry';
import { createAssetProxy } from '../../../../valueObjects/AssetProxy';
import Toolbar from '../Toolbar';
import BlockMenu from '../BlockMenu';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../UI/Sticky/Sticky';
import styles from './index.css';
const HAS_LINE_BREAK = /\n/m;
@ -18,7 +19,7 @@ function processUrl(url) {
if (url.match(/^(https?:\/\/|mailto:|\/)/)) {
return url;
}
if (url.match(/^[^\/]+\.[^\/]+/)) {
if (url.match(/^[^/]+\.[^/]+/)) {
return `https://${ url }`;
}
return `/${ url }`;
@ -44,7 +45,9 @@ function getCleanPaste(e) {
// Handle complex pastes by stealing focus with a contenteditable div
const div = document.createElement('div');
div.contentEditable = true;
div.setAttribute('style', 'opacity: 0; overflow: hidden; width: 1px; height: 1px; position: fixed; top: 50%; left: 0;');
div.setAttribute(
'style', 'opacity: 0; overflow: hidden; width: 1px; height: 1px; position: fixed; top: 50%; left: 0;'
);
document.body.appendChild(div);
div.focus();
setTimeout(() => {
@ -194,7 +197,7 @@ export default class RawEditor extends React.Component {
};
handleLink = () => {
const url = prompt('URL:');
const url = prompt('URL:'); // eslint-disable-line no-alert
const selection = this.getSelection();
this.replaceSelection(`[${ selection.selected }](${ processUrl(url) })`);
};
@ -205,9 +208,9 @@ export default class RawEditor extends React.Component {
if (selection.start !== selection.end && !HAS_LINE_BREAK.test(selection.selected)) {
try {
const selectionPosition = this.caretPosition.get(selection.start, selection.end);
this.setState({ showToolbar: true, showBlockMenu: false, selectionPosition });
this.setState({ selectionPosition });
} catch (e) {
this.setState({ showToolbar: false, showBlockMenu: false });
console.log(e); // eslint-disable-line no-console
}
} else if (selection.start === selection.end) {
const newBlock =
@ -222,12 +225,8 @@ export default class RawEditor extends React.Component {
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 });
this.setState({ selectionPosition: position });
}
} else {
this.setState({ showToolbar: false, showBlockMenu: false });
}
};
@ -236,10 +235,9 @@ export default class RawEditor extends React.Component {
this.updateHeight();
};
handleBlock = (plugin, data) => {
handlePluginSubmit = (plugin, data) => {
const toBlock = plugin.get('toBlock');
this.replaceSelection(toBlock.call(toBlock, data.toJS()));
this.setState({ showBlockMenu: false });
};
handleHeader(header) {
@ -309,7 +307,7 @@ export default class RawEditor extends React.Component {
render() {
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
const { showToolbar, showBlockMenu, plugins, selectionPosition, dragging } = this.state;
const { plugins, selectionPosition, dragging } = this.state;
const classNames = [styles.root];
if (dragging) {
classNames.push(styles.dragging);
@ -322,27 +320,25 @@ export default class RawEditor extends React.Component {
onDragOver={this.handleDragOver}
onDrop={this.handleDrop}
>
<Toolbar
isOpen={showToolbar}
selectionPosition={selectionPosition}
onH1={this.handleHeader('#')}
onH2={this.handleHeader('##')}
onBold={this.handleBold}
onItalic={this.handleItalic}
onLink={this.handleLink}
onToggleMode={this.handleToggle}
/>
<BlockMenu
isOpen={showBlockMenu}
selectionPosition={selectionPosition}
plugins={plugins}
onBlock={this.handleBlock}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
/>
<textarea
ref={this.handleRef}
<Sticky className={styles.editorControlBar} fillContainerWidth>
<Toolbar
selectionPosition={selectionPosition}
onH1={this.handleHeader('#')}
onH2={this.handleHeader('##')}
onBold={this.handleBold}
onItalic={this.handleItalic}
onLink={this.handleLink}
onToggleMode={this.handleToggle}
plugins={plugins}
onSubmit={this.handlePluginSubmit}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
rawMode
/>
</Sticky>
<TextareaAutosize
inputRef={this.handleRef}
value={this.props.value || ''}
onKeyDown={this.handleKey}
onChange={this.handleChange}

View File

@ -1,28 +0,0 @@
.Toolbar {
position: absolute;
z-index: 1000;
display: none;
margin: none;
padding: none;
box-shadow: 1px 1px 5px;
list-style: none;
}
.Button {
display: inline-block;
& button {
padding: 5px;
border: none;
border-right: 1px solid #eee;
background: #fff;
}
}
.Button:last-child button {
border-right: none;
}
.Visible {
display: block;
}

View File

@ -1,63 +0,0 @@
import React, { Component, PropTypes } from 'react';
import { Icon } from '../../UI';
import styles from './Toolbar.css';
function button(label, icon, action) {
return (<li className={styles.Button}>
<button className={styles[label]} onClick={action} title={label}>
<Icon type={icon} />
</button>
</li>);
}
export default class Toolbar extends Component {
static propTypes = {
isOpen: PropTypes.bool,
selectionPosition: PropTypes.object,
onH1: PropTypes.func.isRequired,
onH2: PropTypes.func.isRequired,
onBold: PropTypes.func.isRequired,
onItalic: PropTypes.func.isRequired,
onLink: PropTypes.func.isRequired,
onToggleMode: PropTypes.func.isRequired,
};
componentDidUpdate() {
const { selectionPosition } = this.props;
if (selectionPosition) {
const rect = this.element.getBoundingClientRect();
const parentRect = this.element.parentElement.getBoundingClientRect();
const style = this.element.style;
const pos = {
top: selectionPosition.top - rect.height - 5,
left: Math.min(selectionPosition.left, parentRect.width - rect.width),
};
style.setProperty('top', `${ pos.top }px`);
style.setProperty('left', `${ pos.left }px`);
}
}
handleRef = (ref) => {
this.element = ref;
};
render() {
const { isOpen, onH1, onH2, onBold, onItalic, onLink, onToggleMode } = this.props;
const classNames = [styles.Toolbar];
if (isOpen) {
classNames.push(styles.Visible);
}
return (
<ul className={classNames.join(' ')} ref={this.handleRef}>
{button('Header 1', 'h1', onH1)}
{button('Header 2', 'h2', onH2)}
{button('Bold', 'bold', onBold)}
{button('Italic', 'italic', onItalic)}
{button('Link', 'link', onLink)}
{button('View Code', 'code', onToggleMode)}
</ul>
);
}
}

View File

@ -0,0 +1,13 @@
@import "../../../UI/theme";
.Toolbar {
composes: clearfix;
z-index: 1000;
}
.Toggle {
float: right;
margin-bottom: 0;
padding-top: 4px;
padding-right: 10px;
}

View File

@ -0,0 +1,90 @@
import React, { PropTypes } from 'react';
import { List } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Switch from 'react-toolbox/lib/switch';
import ToolbarButton from './ToolbarButton';
import ToolbarComponentsMenu from './ToolbarComponentsMenu';
import ToolbarPluginForm from './ToolbarPluginForm';
import { Icon } from '../../../UI';
import styles from './Toolbar.css';
export default class Toolbar extends React.Component {
static propTypes = {
selectionPosition: PropTypes.object,
onH1: PropTypes.func.isRequired,
onH2: PropTypes.func.isRequired,
onBold: PropTypes.func.isRequired,
onItalic: PropTypes.func.isRequired,
onLink: PropTypes.func.isRequired,
onToggleMode: PropTypes.func.isRequired,
rawMode: PropTypes.bool,
plugins: ImmutablePropTypes.listOf(ImmutablePropTypes.record),
onSubmit: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
activePlugin: null,
};
}
handlePluginFormDisplay = (plugin) => {
this.setState({ activePlugin: plugin });
}
handlePluginFormSubmit = (plugin, pluginData) => {
this.props.onSubmit(plugin, pluginData);
this.setState({ activePlugin: null });
};
handlePluginFormCancel = (e) => {
this.setState({ activePlugin: null });
};
render() {
const {
onH1,
onH2,
onBold,
onItalic,
onLink,
onToggleMode,
rawMode,
plugins,
onAddAsset,
onRemoveAsset,
getAsset,
} = this.props;
const { activePlugin } = this.state;
return (
<div className={styles.Toolbar}>
<ToolbarButton label="Header 1" icon="h1" action={onH1}/>
<ToolbarButton label="Header 2" icon="h2" action={onH2}/>
<ToolbarButton label="Bold" icon="bold" action={onBold}/>
<ToolbarButton label="Italic" icon="italic" action={onItalic}/>
<ToolbarButton label="Link" icon="link" action={onLink}/>
<ToolbarComponentsMenu
plugins={plugins}
onComponentMenuItemClick={this.handlePluginFormDisplay}
/>
{activePlugin &&
<ToolbarPluginForm
plugin={activePlugin}
onSubmit={this.handlePluginFormSubmit}
onCancel={this.handlePluginFormCancel}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
/>
}
<Switch label="Markdown" onChange={onToggleMode} checked={rawMode} className={styles.Toggle}/>
</div>
);
}
}

View File

@ -0,0 +1,13 @@
@import "../../../UI/theme";
.button {
display: inline-block;
padding: 6px;
border: none;
background-color: transparent;
cursor: pointer;
}
.active {
background-color: var(--highlightFGAltColor);
}

View File

@ -0,0 +1,23 @@
import React, { PropTypes } from 'react';
import classnames from 'classnames';
import { Icon } from '../../../UI';
import styles from './ToolbarButton.css';
const ToolbarButton = ({ label, icon, action, active }) => (
<button
className={classnames(styles.button, { [styles.active]: active })}
onClick={action}
title={label}
>
{ icon ? <Icon type={icon} /> : label }
</button>
);
ToolbarButton.propTypes = {
label: PropTypes.string.isRequired,
icon: PropTypes.string,
action: PropTypes.func.isRequired,
active: PropTypes.bool,
};
export default ToolbarButton;

View File

@ -0,0 +1,14 @@
.root {
display: inline-block;
position: relative;
}
.menuItem {
height: auto;
padding-top: 6px;
padding-bottom: 6px;
& span {
font-size: 14px;
}
}

View File

@ -0,0 +1,52 @@
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Menu, MenuItem } from 'react-toolbox/lib/menu';
import ToolbarButton from './ToolbarButton';
import styles from './ToolbarComponentsMenu.css';
export default class ToolbarComponentsMenu extends React.Component {
static PropTypes = {
plugins: ImmutablePropTypes.list.isRequired,
onComponentMenuItemClick: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
componentsMenuActive: false,
};
}
handleComponentsMenuToggle = () => {
this.setState({ componentsMenuActive: !this.state.componentsMenuActive });
};
handleComponentsMenuHide = () => {
this.setState({ componentsMenuActive: false });
};
render() {
const { plugins, onComponentMenuItemClick } = this.props;
return (
<div className={styles.root}>
<ToolbarButton label="Add Component" icon="plus" action={this.handleComponentsMenuToggle}/>
<Menu
active={this.state.componentsMenuActive}
position="auto"
onHide={this.handleComponentsMenuHide}
ripple={false}
>
{plugins.map(plugin => (
<MenuItem
key={plugin.get('id')}
value={plugin.get('id')}
caption={plugin.get('label')}
onClick={() => onComponentMenuItemClick(plugin)}
className={styles.menuItem}
/>
))}
</Menu>
</div>
);
}
}

View File

@ -0,0 +1,31 @@
@import "../../../UI/theme";
.pluginForm {
position: absolute;
background-color: var(--backgroundTertiaryColorDark);
margin-top: -20px;
margin-bottom: 30px;
margin-left: 20px;
border-radius: var(--borderRadius);
box-shadow: var(--dropShadow);
overflow: hidden;
z-index: 1;
/* Nested to override high specificity React Toolbox styles */
& .header {
padding: 12px;
margin: 0;
color: var(--textColor);
background-color: var(--backgroundColor);
}
}
.body {
padding: 0 12px 16px;
}
.footer {
background-color: var(--backgroundColor);
padding: 10px 20px;
text-align: right;
}

View File

@ -0,0 +1,68 @@
import React, { PropTypes } from 'react';
import { Map } from 'immutable';
import { Button } from 'react-toolbox/lib/button';
import ToolbarPluginFormControl from './ToolbarPluginFormControl';
import styles from './ToolbarPluginForm.css';
export default class ToolbarPluginForm extends React.Component {
static propTypes = {
plugin: PropTypes.object.isRequired,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
data: Map(),
};
}
handleSubmit = (event) => {
const { plugin, onSubmit } = this.props;
onSubmit(plugin, this.state.data);
event.preventDefault();
}
render() {
const {
plugin,
onSubmit,
onCancel,
value,
onAddAsset,
onRemoveAsset,
getAsset,
onChange,
} = this.props;
return (
<form className={styles.pluginForm} onSubmit={this.handleSubmit}>
<h3 className={styles.header}>Insert {plugin.get('label')}</h3>
<div className={styles.body}>
{plugin.get('fields').map((field, index) => (
<ToolbarPluginFormControl
key={index}
field={field}
value={this.state.data.get(field.get('name'))}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
onChange={(val) => {
this.setState({ data: this.state.data.set(field.get('name'), val) });
}}
/>
))}
</div>
<div className={styles.footer}>
<Button raised onClick={this.handleSubmit}>Insert</Button>
{' '}
<Button onClick={onCancel}>Cancel</Button>
</div>
</form>
);
}
}

View File

@ -0,0 +1,7 @@
.control {
composes: control from "../../../ControlPanel/ControlPane.css"
}
.label {
composes: label from "../../../ControlPanel/ControlPane.css";
}

View File

@ -0,0 +1,39 @@
import React, { PropTypes } from 'react';
import { resolveWidget } from '../../../Widgets';
import styles from './ToolbarPluginFormControl.css';
const ToolbarPluginFormControl = ({
field,
value,
pluginData,
onAddAsset,
onRemoveAsset,
getAsset,
onChange,
}) => {
const widget = resolveWidget(field.get('widget') || 'string');
const key = `field-${ field.get('name') }`;
const Control = widget.control;
const controlProps = { field, value, onAddAsset, onRemoveAsset, getAsset, onChange };
return (
<div className={styles.control} key={key}>
<label className={styles.label} htmlFor={key}>{field.get('label')}</label>
<Control {...controlProps}/>
</div>
);
};
ToolbarPluginFormControl.propTypes = {
field: PropTypes.object.isRequired,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
export default ToolbarPluginFormControl;

View File

@ -1,5 +1,12 @@
@import "../../../UI/theme";
.editorControlBar {
background-color: var(--controlBGColor);
border-bottom: 1px solid var(--backgroundTertiaryColorDark);
border-radius: var(--borderRadius) var(--borderRadius) 0 0;
z-index: 1;
}
.editor {
position: relative;
& h1, & h2, & h3 {
@ -61,7 +68,11 @@
position: relative;
background-color: var(--controlBGColor);
padding: 12px;
border-radius: var(--borderRadius);
border-radius: 0 0 var(--borderRadius) var(--borderRadius);
& ul {
padding-left: 20px;
}
}
& .ProseMirror-content {

View File

@ -1,4 +1,5 @@
import React, { Component, PropTypes } from 'react';
import { Map } from 'immutable';
import { Schema } from 'prosemirror-model';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
@ -8,21 +9,21 @@ import {
inputRules, allInputRules,
} from 'prosemirror-inputrules';
import { keymap } from 'prosemirror-keymap';
import { schema, defaultMarkdownSerializer } from 'prosemirror-markdown';
import { schema as markdownSchema, defaultMarkdownSerializer } from 'prosemirror-markdown';
import { baseKeymap, setBlockType, toggleMark } from 'prosemirror-commands';
import registry from '../../../../lib/registry';
import { createAssetProxy } from '../../../../valueObjects/AssetProxy';
import { buildKeymap } from './keymap';
import createMarkdownParser from './parser';
import Toolbar from '../Toolbar';
import BlockMenu from '../BlockMenu';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../UI/Sticky/Sticky';
import styles from './index.css';
function processUrl(url) {
if (url.match(/^(https?:\/\/|mailto:|\/)/)) {
return url;
}
if (url.match(/^[^\/]+\.[^\/]+/)) {
if (url.match(/^[^/]+\.[^/]+/)) {
return `https://${ url }`;
}
return `/${ url }`;
@ -37,15 +38,10 @@ const ruleset = {
};
function buildInputRules(schema) {
const result = [];
for (const rule in ruleset) {
const type = schema.nodes[rule];
if (type) {
const fn = ruleset[rule];
result.push(fn[0].apply(fn.slice(1)));
}
}
return result;
return Map(ruleset)
.filter(rule => schema.nodes[rule])
.map(rule => rule[0].apply(rule[0].slice(1)))
.toArray();
}
function markActive(state, type) {
@ -99,12 +95,12 @@ export default class Editor extends Component {
constructor(props) {
super(props);
const plugins = registry.getEditorComponents();
const s = schemaWithPlugins(schema, plugins);
const schema = schemaWithPlugins(markdownSchema, plugins);
this.state = {
plugins,
schema: s,
parser: createMarkdownParser(s, plugins),
serializer: createSerializer(s, plugins),
schema,
parser: createMarkdownParser(schema, plugins),
serializer: createSerializer(schema, plugins),
};
}
@ -133,7 +129,7 @@ export default class Editor extends Component {
}
handleAction = (action) => {
const { schema, serializer } = this.state;
const { serializer } = this.state;
const newState = this.view.state.applyAction(action);
const md = serializer.serialize(newState.doc);
this.props.onChange(md);
@ -152,15 +148,13 @@ export default class Editor extends Component {
const pos = this.view.coordsAtPos(selection.from);
const editorPos = this.view.content.getBoundingClientRect();
const selectionPosition = { top: pos.top - editorPos.top, left: pos.left - editorPos.left };
this.setState({ showToolbar: false, showBlockMenu: true, selectionPosition });
} else {
this.setState({ showToolbar: false, showBlockMenu: false });
this.setState({ selectionPosition });
}
} else {
const pos = this.view.coordsAtPos(selection.from);
const editorPos = this.view.content.getBoundingClientRect();
const selectionPosition = { top: pos.top - editorPos.top, left: pos.left - editorPos.left };
this.setState({ showToolbar: true, showBlockMenu: false, selectionPosition });
this.setState({ selectionPosition });
}
};
@ -202,13 +196,13 @@ export default class Editor extends Component {
handleLink = () => {
let url = null;
if (!markActive(this.view.state, this.state.schema.marks.link)) {
url = prompt('Link URL:');
url = prompt('Link URL:'); // eslint-disable-line no-alert
}
const command = toggleMark(this.state.schema.marks.link, { href: url ? processUrl(url) : null });
command(this.view.state, this.handleAction);
};
handleBlock = (plugin, data) => {
handlePluginSubmit = (plugin, data) => {
const { schema } = this.state;
const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`];
this.view.props.onAction(this.view.state.tr.replaceSelectionWith(nodeType.create(data.toJS())).action());
@ -268,7 +262,7 @@ export default class Editor extends Component {
render() {
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
const { plugins, showToolbar, showBlockMenu, selectionPosition, dragging } = this.state;
const { plugins, selectionPosition, dragging } = this.state;
const classNames = [styles.editor];
if (dragging) {
classNames.push(styles.dragging);
@ -281,25 +275,22 @@ export default class Editor extends Component {
onDragOver={this.handleDragOver}
onDrop={this.handleDrop}
>
<Toolbar
isOpen={showToolbar}
selectionPosition={selectionPosition}
onH1={this.handleHeader(1)}
onH2={this.handleHeader(2)}
onBold={this.handleBold}
onItalic={this.handleItalic}
onLink={this.handleLink}
onToggleMode={this.handleToggle}
/>
<BlockMenu
isOpen={showBlockMenu}
selectionPosition={selectionPosition}
plugins={plugins}
onBlock={this.handleBlock}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
/>
<Sticky className={styles.editorControlBar} fillContainerWidth>
<Toolbar
selectionPosition={selectionPosition}
onH1={this.handleHeader(1)}
onH2={this.handleHeader(2)}
onBold={this.handleBold}
onItalic={this.handleItalic}
onLink={this.handleLink}
onToggleMode={this.handleToggle}
plugins={plugins}
onSubmit={this.handlePluginSubmit}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
/>
</Sticky>
<div ref={this.handleRef} />
<div className={styles.shim} />
</div>);

View File

@ -1,28 +0,0 @@
export const emptyParagraphBlock = {
nodes: [
{
kind: 'block',
type: 'paragraph',
nodes: [{
kind: 'text',
ranges: [{
text: '',
}],
}],
},
],
};
export const mediaproxyBlock = mediaproxy => ({
kind: 'block',
type: 'paragraph',
nodes: [{
kind: 'inline',
type: 'mediaproxy',
isVoid: true,
data: {
alt: mediaproxy.name,
src: mediaproxy.public_path,
},
}],
});

View File

@ -45,7 +45,7 @@ const buildtInPlugins = [{
},
toBlock: data => `![${ data.alt }](${ data.image })`,
toPreview: data => <img src={data.image} alt={data.alt} />,
pattern: /^!\[([^\]]+)\]\(([^\)]+)\)$/,
pattern: /^!\[([^\]]+)]\(([^)]+)\)$/,
fields: [{
label: 'Image',
name: 'image',

2914
yarn.lock

File diff suppressed because it is too large Load Diff