diff --git a/example/example.css b/example/example.css index 3ec853c0..7b683ec3 100644 --- a/example/example.css +++ b/example/example.css @@ -1,15 +1,21 @@ -html, body { - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 14px; +html, +body { color: #444; + font-size: 14px; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; } + body { padding: 20px; } h1 { - font-weight: bold; - color: #666; - font-size: 32px; margin-top: 20px; + color: #666; + font-weight: bold; + font-size: 32px; +} + +img { + max-width: 100%; } diff --git a/package.json b/package.json index fc28d7f4..4b0b45f5 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "@kadira/storybook": "^1.36.0", "autoprefixer": "^6.3.3", "bricks.js": "^1.7.0", + "textarea-caret-position": "^0.1.1", "dateformat": "^1.0.12", "fuzzy": "^0.1.1", "immutability-helper": "^2.0.0", diff --git a/src/components/ControlPanel/ControlPane.css b/src/components/ControlPanel/ControlPane.css index 328ddf27..25973c88 100644 --- a/src/components/ControlPanel/ControlPane.css +++ b/src/components/ControlPanel/ControlPane.css @@ -20,6 +20,7 @@ } } .label { + display: block; color: #AAB0AF; font-size: 12px; margin-bottom: 18px; 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.css b/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.css new file mode 100644 index 00000000..c849baac --- /dev/null +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.css @@ -0,0 +1,16 @@ +.Toolbar { + position: absolute; + z-index: 1000; + display: none; + margin: none; + padding: none; + list-style: none; +} + +.Button { + display: inline-block; +} + +.Visible { + display: block; +} diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.js b/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.js new file mode 100644 index 00000000..69bb4d9b --- /dev/null +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.js @@ -0,0 +1,54 @@ +import React, { Component, PropTypes } from 'react'; +import styles from './Toolbar.css'; + +function button(label, action) { + return (
  • + +
  • ); +} + +export default class Toolbar extends Component { + static propTypes = { + isOpen: PropTypes.bool, + selectionPosition: PropTypes.object, + onBold: PropTypes.func.isRequired, + onItalic: PropTypes.func.isRequired, + onLink: 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, onBold, onItalic, onLink } = this.props; + const classNames = [styles.Toolbar]; + + if (isOpen) { + classNames.push(styles.Visible); + } + + return ( + + ); + } +} diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/index.css b/src/components/Widgets/MarkdownControlElements/RawEditor/index.css index eca2a8d4..6e91c9f0 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/index.css +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/index.css @@ -1,13 +1,3 @@ .root { - font-family: monospace; - display: block; - width: 100%; - padding: 0; - margin: 0; - border: none; - outline: 0; - box-shadow: none; - background: 0 0; - font-size: 18px; - color: #7c8382; + position: relative; } diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js index 25d74f71..7d83939b 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js @@ -1,133 +1,266 @@ import React, { PropTypes } from 'react'; -import { Editor, Plain, Mark } from 'slate'; -import Prism from 'prismjs'; -import PluginDropImages from 'slate-drop-or-paste-images'; +import { fromJS } from 'immutable'; +import CaretPosition from 'textarea-caret-position'; +import registry from '../../../../lib/registry'; import MediaProxy from '../../../../valueObjects/MediaProxy'; -import marks from './prismMarkdown'; +import Toolbar from './Toolbar'; +import BlockMenu from './BlockMenu'; import styles from './index.css'; -Prism.languages.markdown = Prism.languages.extend('markup', {}); -Prism.languages.insertBefore('markdown', 'prolog', marks); -Prism.languages.markdown.bold.inside.url = Prism.util.clone(Prism.languages.markdown.url); -Prism.languages.markdown.italic.inside.url = Prism.util.clone(Prism.languages.markdown.url); -Prism.languages.markdown.bold.inside.italic = Prism.util.clone(Prism.languages.markdown.italic); -Prism.languages.markdown.italic.inside.bold = Prism.util.clone(Prism.languages.markdown.bold); +const HAS_LINE_BREAK = /\n/m; -function renderDecorations(text, block) { - let characters = text.characters.asMutable(); - const string = text.text; - const grammar = Prism.languages.markdown; - const tokens = Prism.tokenize(string, grammar); - let offset = 0; - - for (const token of tokens) { - if (typeof token == 'string') { - offset += token.length; - continue; - } - - const length = offset + token.matchedStr.length; - const name = token.alias || token.type; - const type = `highlight-${ name }`; - - for (let i = offset; i < length; i++) { - let char = characters.get(i); - let { marks } = char; - marks = marks.add(Mark.create({ type })); - char = char.merge({ marks }); - characters = characters.set(i, char); - } - - offset = length; +function processUrl(url) { + if (url.match(/^(https?:\/\/|mailto:|\/)/)) { + return url; } - - return characters.asImmutable(); + if (url.match(/^[^\/]+\.[^\/]+/)) { + return `https://${ url }`; + } + return `/${ url }`; } -const SCHEMA = { - rules: [ - { - match: object => object.kind == 'block', - decorate: renderDecorations, - }, - ], - marks: { - 'highlight-comment': { - opacity: '0.33', - }, - 'highlight-important': { - fontWeight: 'bold', - color: '#006', - }, - 'highlight-keyword': { - fontWeight: 'bold', - color: '#006', - }, - 'highlight-url': { - color: '#006', - }, - 'highlight-punctuation': { - color: '#006', - }, +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 { - - static propTypes = { - onAddMedia: PropTypes.func.isRequired, - getMedia: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - value: PropTypes.string, - }; - constructor(props) { super(props); - const content = props.value ? Plain.deserialize(props.value) : Plain.deserialize(''); - + const plugins = registry.getEditorComponents(); this.state = { - state: content, + plugins: buildtInPlugins.concat(plugins), }; - - this.plugins = [ - PluginDropImages({ - applyTransform: (transform, file) => { - const mediaProxy = new MediaProxy(file.name, file); - const state = Plain.deserialize(`\n\n![${ file.name }](${ mediaProxy.public_path })\n\n`); - props.onAddMedia(mediaProxy); - return transform - .insertFragment(state.get('document')); - }, - }), - ]; + this.shortcuts = { + meta: { + b: this.handleBold, + }, + }; + } + componentDidMount() { + this.updateHeight(); + this.element.addEventListener('dragenter', preventDefault, false); + this.element.addEventListener('dragover', preventDefault, false); + this.element.addEventListener('drop', this.handleDrop, false); } - /** - * 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 dispatched only when the actual - * content changes - */ - handleChange = (state) => { - this.setState({ state }); + componentDidUpdate() { + if (this.newSelection) { + this.element.selectionStart = this.newSelection.start; + this.element.selectionEnd = this.newSelection.end; + this.newSelection = null; + } + } + + componentWillUnmount() { + this.element.removeEventListener('dragenter', preventDefault); + this.element.removeEventListener('dragover', preventDefault); + this.element.removeEventListener('drop', this.handleDrop); + } + + getSelection() { + const start = this.element.selectionStart; + const end = this.element.selectionEnd; + const selected = (this.props.value || '').substr(start, end - start); + return { start, end, selected }; + } + + surroundSelection(chars) { + const selection = this.getSelection(); + const newSelection = Object.assign({}, selection); + const { value } = this.props; + const escapedChars = chars.replace(/\*/g, '\\*'); + const regexp = new RegExp(`^${ escapedChars }.*${ escapedChars }$`); + let changed = chars + selection.selected + chars; + + if (regexp.test(selection.selected)) { + changed = selection.selected.substr(chars.length, selection.selected.length - (chars.length * 2)); + newSelection.end = selection.end - (chars.length * 2); + } else if ( + value.substr(selection.start - chars.length, chars.length) === chars && + value.substr(selection.end, chars.length) === chars + ) { + newSelection.start = selection.start - chars.length; + newSelection.end = selection.end + chars.length; + changed = selection.selected; + } else { + newSelection.end = selection.end + (chars.length * 2); + } + + const beforeSelection = value.substr(0, selection.start); + const afterSelection = value.substr(selection.end); + + this.newSelection = newSelection; + this.props.onChange(beforeSelection + changed + afterSelection); + } + + replaceSelection(chars) { + const { value } = this.props; + const selection = this.getSelection(); + const newSelection = Object.assign({}, selection); + const beforeSelection = value.substr(0, selection.start); + const afterSelection = value.substr(selection.end); + newSelection.end = selection.start + chars.length; + this.newSelection = newSelection; + this.props.onChange(beforeSelection + chars + afterSelection); + } + + updateHeight() { + if (this.element.scrollHeight > this.element.clientHeight) { + this.element.style.height = `${ this.element.scrollHeight }px`; + } + } + + handleRef = (ref) => { + this.element = ref; + if (ref) { + this.caretPosition = new CaretPosition(ref); + } }; - handleDocumentChange = (document, state) => { - const content = Plain.serialize(state, { terse: true }); - this.props.onChange(content); + handleKey = (e) => { + if (e.metaKey) { + const action = this.shortcuts.meta[e.key]; + if (action) { + e.preventDefault(); + action(); + } + } + }; + + handleBold = () => { + this.surroundSelection('**'); + }; + + handleItalic = () => { + this.surroundSelection('*'); + }; + + handleLink = () => { + const url = prompt('URL:'); + const selection = this.getSelection(); + this.replaceSelection(`[${ selection.selected }](${ processUrl(url) })`); + }; + + 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, showBlockMenu: false, selectionPosition: position }); + } catch (e) { + this.setState({ showToolbar: false, showBlockMenu: false }); + } + } else if (selection.start === selection.end) { + const newBlock = + ( + (selection.start === 0 && value.substr(0,1).match(/^\n?$/)) || + 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, showBlockMenu: false }); + } + }; + + handleChange = (e) => { + this.props.onChange(e.target.value); + this.updateHeight(); + }; + + handleBlock = (chars) => { + this.replaceSelection(chars); + this.setState({ showBlockMenu: false }); + }; + + handleDrop = (e) => { + e.preventDefault(); + let data; + + if (e.dataTransfer.files && e.dataTransfer.files.length) { + data = Array.from(e.dataTransfer.files).map((file) => { + const mediaProxy = new MediaProxy(file.name, file); + this.props.onAddMedia(mediaProxy); + const link = `[${ file.name }](${ mediaProxy.public_path })`; + if (file.type.split('/')[0] === 'image') { + return `!${ link }`; + } + return link; + }).join('\n\n'); + } else { + data = e.dataTransfer.getData('text/plain'); + } + this.replaceSelection(data); }; render() { - return ( - + - ); + +