condense rich text functionality to static toolbar

This commit is contained in:
Shawn Erquhart 2017-03-16 20:45:46 -04:00
parent 96453df346
commit b2fd96c12e
11 changed files with 211 additions and 321 deletions

View File

@ -8,16 +8,23 @@
--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;
--controlLabelColor: var(--textColor);
--controlBGColor: #fff;
--backgroundTertiaryColor: #f2f5f4;
--backgroundTertiaryColorDark: color(var(--backgroundTertiaryColor) lightness(90%));
}
.base {

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

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

View File

@ -6,7 +6,7 @@ import CaretPosition from 'textarea-caret-position';
import registry from '../../../../lib/registry';
import { createAssetProxy } from '../../../../valueObjects/AssetProxy';
import Toolbar from '../Toolbar';
import BlockMenu from '../BlockMenu';
import ToolbarPlugins from '../ToolbarPlugins';
import styles from './index.css';
const HAS_LINE_BREAK = /\n/m;
@ -18,7 +18,7 @@ function processUrl(url) {
if (url.match(/^(https?:\/\/|mailto:|\/)/)) {
return url;
}
if (url.match(/^[^\/]+\.[^\/]+/)) {
if (url.match(/^[^/]+\.[^/]+/)) {
return `https://${ url }`;
}
return `/${ url }`;
@ -44,7 +44,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 +196,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 +207,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 +224,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 +234,9 @@ export default class RawEditor extends React.Component {
this.updateHeight();
};
handleBlock = (plugin, data) => {
handlePlugin = (plugin, data) => {
const toBlock = plugin.get('toBlock');
this.replaceSelection(toBlock.call(toBlock, data.toJS()));
this.setState({ showBlockMenu: false });
};
handleHeader(header) {
@ -309,7 +306,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,25 +319,26 @@ 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}
/>
<div className={styles.editorControlBar}>
<Toolbar
selectionPosition={selectionPosition}
onH1={this.handleHeader('#')}
onH2={this.handleHeader('##')}
onBold={this.handleBold}
onItalic={this.handleItalic}
onLink={this.handleLink}
onToggleMode={this.handleToggle}
/>
<ToolbarPlugins
selectionPosition={selectionPosition}
plugins={plugins}
onPlugin={this.handlePlugin}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
rawMode
/>
</div>
<textarea
ref={this.handleRef}
value={this.props.value || ''}

View File

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

View File

@ -1,4 +1,4 @@
import React, { Component, PropTypes } from 'react';
import React, { PropTypes } from 'react';
import { Icon } from '../../UI';
import styles from './Toolbar.css';
@ -10,54 +10,27 @@ function button(label, icon, action) {
</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>
);
}
function Toolbar(props) {
const { onH1, onH2, onBold, onItalic, onLink, onToggleMode } = props;
return (
<ul className={styles.Toolbar}>
{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>
);
}
Toolbar.propTypes = {
onH1: PropTypes.func.isRequired,
onH2: PropTypes.func.isRequired,
onBold: PropTypes.func.isRequired,
onItalic: PropTypes.func.isRequired,
onLink: PropTypes.func.isRequired,
onToggleMode: PropTypes.func.isRequired,
};
export default Toolbar;

View File

@ -0,0 +1,62 @@
@import "../../UI/theme";
.root {
z-index: 1000;
display: inline-block;
}
.button {
width: 15px;
height: 15px;
border: 1px solid #444;
border-radius: 100%;
background: transparent;
line-height: 13px;
}
.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;
}
.menu {
list-style: none;
}
.menuItem {
display: inline-block;
}
.control {
composes: control from "../../ControlPanel/ControlPane.css"
}
.label {
composes: label from "../../ControlPanel/ControlPane.css";
}
.footer {
background-color: var(--backgroundColor);
padding: 10px 20px;
text-align: right;
}

View File

@ -1,15 +1,15 @@
import React, { Component, PropTypes } from 'react';
import { fromJS } from 'immutable';
import { Button } from 'react-toolbox/lib/button';
import { Icon } from '../../UI';
import { resolveWidget } from '../../Widgets';
import styles from './BlockMenu.css';
import toolbarStyles from './Toolbar.css';
import styles from './ToolbarPlugins.css';
export default class BlockMenu extends Component {
export default class ToolbarPlugins extends Component {
static propTypes = {
isOpen: PropTypes.bool,
selectionPosition: PropTypes.object,
plugins: PropTypes.object.isRequired,
onBlock: PropTypes.func.isRequired,
onPlugin: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
@ -18,29 +18,11 @@ export default class BlockMenu extends Component {
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();
@ -49,21 +31,23 @@ export default class BlockMenu extends Component {
}
buttonFor(plugin) {
return (<li key={`plugin-${ plugin.get('id') }`}>
<button onClick={this.handlePlugin(plugin)}>{plugin.get('label')}</button>
return (<li key={`plugin-${ plugin.get('id') }`} className={toolbarStyles.Button}>
<button className={styles[plugin.get('label')]} onClick={this.handlePlugin(plugin)} title={plugin.get('label')}>
<Icon type={plugin.get('icon')} />
</button>
</li>);
}
handleSubmit = (e) => {
e.preventDefault();
const { openPlugin, pluginData } = this.state;
this.props.onBlock(openPlugin, pluginData);
this.setState({ openPlugin: null, isExpanded: false });
this.props.onPlugin(openPlugin, pluginData);
this.setState({ openPlugin: null });
};
handleCancel = (e) => {
e.preventDefault();
this.setState({ openPlugin: null, isExpanded: false });
this.setState({ openPlugin: null });
};
controlFor(field) {
@ -71,10 +55,11 @@ export default class BlockMenu extends Component {
const { pluginData } = this.state;
const widget = resolveWidget(field.get('widget') || 'string');
const value = pluginData.get(field.get('name'));
const key = `field-${ field.get('name') }`;
return (
<div className={styles.control} key={`field-${ field.get('name') }`}>
<label className={styles.label}>{field.get('label')}</label>
<div className={styles.control} key={key}>
<label className={styles.label} htmlFor={key}>{field.get('label')}</label>
{
React.createElement(widget.control, {
field,
@ -95,8 +80,10 @@ export default class BlockMenu extends Component {
pluginForm(plugin) {
return (<form className={styles.pluginForm} onSubmit={this.handleSubmit}>
<h3>Insert {plugin.get('label')}</h3>
{plugin.get('fields').map(field => this.controlFor(field))}
<h3 className={styles.header}>Insert {plugin.get('label')}</h3>
<div className={styles.body}>
{plugin.get('fields').map(field => this.controlFor(field))}
</div>
<div className={styles.footer}>
<Button
raised
@ -113,19 +100,16 @@ export default class BlockMenu extends Component {
}
render() {
const { isOpen, plugins } = this.props;
const { isExpanded, openPlugin } = this.state;
const { plugins } = this.props;
const { 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(' ')}>
return (<div className={classNames.join(' ')}>
<ul className={styles.menu}>
{plugins.map(plugin => this.buttonFor(plugin))}
</ul>
{openPlugin && this.pluginForm(openPlugin)}

View File

@ -1,5 +1,11 @@
@import "../../../UI/theme";
.editorControlBar {
background-color: var(--controlBGColor);
margin-bottom: 1px;
border-radius: var(--borderRadius) var(--borderRadius) 0 0;
}
.editor {
position: relative;
& h1, & h2, & h3 {
@ -61,7 +67,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 ToolbarPlugins from '../ToolbarPlugins';
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) => {
handlePlugin = (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,25 @@ 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}
/>
<div className={styles.editorControlBar}>
<Toolbar
selectionPosition={selectionPosition}
onH1={this.handleHeader(1)}
onH2={this.handleHeader(2)}
onBold={this.handleBold}
onItalic={this.handleItalic}
onLink={this.handleLink}
onToggleMode={this.handleToggle}
/>
<ToolbarPlugins
selectionPosition={selectionPosition}
plugins={plugins}
onPlugin={this.handlePlugin}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
getAsset={getAsset}
/>
</div>
<div ref={this.handleRef} />
<div className={styles.shim} />
</div>);

View File

@ -39,13 +39,14 @@ if (process.env.NODE_ENV !== 'production' && module.hot) {
const buildtInPlugins = [{
label: 'Image',
id: 'image',
icon: 'picture',
fromBlock: match => match && {
image: match[2],
alt: match[1],
},
toBlock: data => `![${ data.alt }](${ data.image })`,
toPreview: data => <img src={data.image} alt={data.alt} />,
pattern: /^!\[([^\]]+)\]\(([^\)]+)\)$/,
pattern: /^!\[([^\]]+)]\(([^)]+)\)$/,
fields: [{
label: 'Image',
name: 'image',