diff --git a/Makefile b/Makefile index caa6f6992..2213e61a9 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,6 @@ dist: $(shell find ./lib) package.json --out-dir \ ./dist \ ./lib - @ touch ./dist # Build the examples. examples: @@ -84,6 +83,14 @@ test-server: --fgrep "$(GREP)" \ ./test/server.js +# Watch the source. +watch-dist: $(shell find ./lib) package.json + @ $(babel) \ + --watch \ + --out-dir \ + ./dist \ + ./lib + # Watch the examples. watch-examples: @ $(watchify) \ diff --git a/examples/plain-text/index.js b/examples/plain-text/index.js index e01371877..a9ec8e82c 100644 --- a/examples/plain-text/index.js +++ b/examples/plain-text/index.js @@ -66,6 +66,7 @@ class PlainText extends React.Component { render = () => { return ( diff --git a/examples/rich-text/index.js b/examples/rich-text/index.js index c6660ace2..2c2b6ce68 100644 --- a/examples/rich-text/index.js +++ b/examples/rich-text/index.js @@ -1,5 +1,5 @@ -import { Editor, Mark, Raw, Utils } from '../..' +import { Editor, Mark, Placeholder, Raw, Utils } from '../..' import React from 'react' import initialState from './state.json' import keycode from 'keycode' @@ -11,13 +11,12 @@ import keycode from 'keycode' */ const NODES = { - 'block-quote': props =>
{props.children}
, - 'bulleted-list': props => , - 'heading-one': props =>

{props.children}

, - 'heading-two': props =>

{props.children}

, - 'list-item': props =>
  • {props.chidlren}
  • , - 'numbered-list': props =>
      {props.children}
    , - 'paragraph': props =>

    {props.children}

    + 'block-quote': props =>
    {props.children}
    , + 'bulleted-list': props => , + 'heading-one': props =>

    {props.children}

    , + 'heading-two': props =>

    {props.children}

    , + 'list-item': props =>
  • {props.chidlren}
  • , + 'numbered-list': props =>
      {props.children}
    } /** @@ -107,6 +106,7 @@ class RichText extends React.Component { return (
    this.renderNode(child)) .toArray() + const attributes = { + 'data-key': node.key + } + const element = ( { const { onChange, plugins, ...editorPlugin } = props + const corePlugin = CorePlugin(props) return [ editorPlugin, ...plugins, diff --git a/lib/components/placeholder.js b/lib/components/placeholder.js new file mode 100644 index 000000000..f532dde44 --- /dev/null +++ b/lib/components/placeholder.js @@ -0,0 +1,108 @@ + +import Portal from 'react-portal' +import React from 'react' +import findDOMNode from '../utils/find-dom-node' + +/** + * Placeholder. + */ + +class Placeholder extends React.Component { + + /** + * Properties. + */ + + static propTypes = { + children: React.PropTypes.any.isRequired, + className: React.PropTypes.string, + node: React.PropTypes.object.isRequired, + parent: React.PropTypes.object.isRequired, + state: React.PropTypes.object.isRequired, + style: React.PropTypes.object + }; + + static defaultProps = { + onlyFirstChild: false, + style: { + opacity: '0.333' + } + }; + + /** + * Should the component update? + * + * @param {Object} props + * @param {Object} state + * @return {Boolean} + */ + + shouldComponentUpdate = (props, state) => { + return ( + props.children != this.props.children || + props.className != this.props.className || + props.parent != this.props.parent || + props.node != this.props.node || + props.style != this.props.style + ) + } + + /** + * Is the placeholder visible? + * + * @return {Boolean} + */ + + isVisible = () => { + const { onlyFirstChild, node, parent } = this.props + if (node.text) return false + if (parent.nodes.size > 1) return false + + const isFirst = parent.nodes.first() === node + if (isFirst) return true + + return false + } + + /** + * On open, update the placeholder element's position. + * + * @param {Element} portal + */ + + onOpen = (portal) => { + const { node } = this.props + const el = portal.firstChild + const nodeEl = findDOMNode(node) + const rect = nodeEl.getBoundingClientRect() + el.style.pointerEvents = 'none' + el.style.position = 'absolute' + el.style.top = `${rect.top}px` + el.style.left = `${rect.left}px` + el.style.width = `${rect.width}px` + el.style.height = `${rect.height}px` + } + + /** + * Render. + * + * @return {Element} element + */ + + render = () => { + const { children, className, style } = this.props + const isOpen = this.isVisible() + return ( + + {children} + + ) + } + +} + +/** + * Export. + */ + +export default Placeholder diff --git a/lib/index.js b/lib/index.js index d14bf71c7..3112f9251 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4,6 +4,7 @@ */ import Editor from './components/editor' +import Placeholder from './components/placeholder' /** * Models. @@ -31,8 +32,12 @@ import Raw from './serializers/raw' */ import Key from './utils/key' +import findDOMNode from './utils/find-dom-node' -const Utils = { Key } +const Utils = { + Key, + findDOMNode +} /** * Export. @@ -47,6 +52,7 @@ export { Html, Inline, Mark, + Placeholder, Raw, Selection, State, @@ -63,6 +69,7 @@ export default { Html, Inline, Mark, + Placeholder, Raw, Selection, State, diff --git a/lib/models/node.js b/lib/models/node.js index 26d13380c..a4e5e3e7f 100644 --- a/lib/models/node.js +++ b/lib/models/node.js @@ -485,7 +485,7 @@ const Node = { if (range.isCollapsed && startOffset == 0) { const text = this.getDescendant(startKey) const previous = this.getPreviousText(startKey) - if (!previous) return marks + if (!previous || !previous.length) return marks const char = previous.characters.get(previous.length - 1) return char.marks } diff --git a/lib/plugins/core.js b/lib/plugins/core.js index e0f8520c9..c5327a63b 100644 --- a/lib/plugins/core.js +++ b/lib/plugins/core.js @@ -1,204 +1,258 @@ import Key from '../utils/key' +import Placeholder from '../components/placeholder' import React from 'react' import keycode from 'keycode' import { IS_WINDOWS, IS_MAC } from '../utils/environment' /** - * Default block renderer. + * The default plugin. * - * @param {Object} props - * @return {Element} element + * @param {Object} options + * @return {Object} */ -function DEFAULT_BLOCK(props) { - return
    {props.children}
    +function Plugin(options = {}) { + const { placeholder } = options + + /** + * Define a default block renderer. + * + * @type {Component} + */ + + class DEFAULT_BLOCK extends React.Component { + + static propTypes = { + attributes: React.PropTypes.object.isRequired, + children: React.PropTypes.any.isRequired, + node: React.PropTypes.object.isRequired, + state: React.PropTypes.object.isRequired + }; + + render = () => { + const { attributes, children } = this.props + return ( +
    + {this.renderPlaceholder()} + {children} +
    + ) + } + + renderPlaceholder = () => { + if (!placeholder) return null + const { node, state } = this.props + + return ( + + {placeholder} + + ) + } + } + + /** + * Define a default inline renderer. + * + * @type {Component} + */ + + class DEFAULT_INLINE extends React.Component { + + static propTypes = { + attributes: React.PropTypes.object.isRequired, + children: React.PropTypes.any.isRequired, + node: React.PropTypes.object.isRequired, + state: React.PropTypes.object.isRequired + }; + + render = () => { + const { attributes, children } = this.props + return {children} + } + + } + + /** + * Define a default mark renderer. + * + * @type {Object} + */ + + const DEFAULT_MARK = {} + + /** + * Return the plugin. + */ + + return { + + /** + * The core `onBeforeInput` handler. + * + * If the current selection is expanded, we have to re-render. + * + * If the next state resolves a new list of decorations for any of its text + * nodes, we have to re-render. + * + * Otherwise, we can allow the default, native text insertion, avoiding a + * re-render for improved performance. + * + * @param {Event} e + * @param {State} state + * @param {Editor} editor + * @return {State or Null} newState + */ + + onBeforeInput(e, state, editor) { + const transform = state.transform().insertText(e.data) + const synthetic = transform.apply() + const resolved = editor.resolveState(synthetic) + + const isSynthenic = ( + state.isExpanded || + !resolved.equals(synthetic) + ) + + if (isSynthenic) e.preventDefault() + + return isSynthenic + ? synthetic + : transform.apply({ isNative: true }) + }, + + /** + * The core `onKeyDown` handler. + * + * @param {Event} e + * @param {State} state + * @param {Editor} editor + * @return {State or Null} newState + */ + + onKeyDown(e, state, editor) { + const key = keycode(e.which) + const transform = state.transform() + + switch (key) { + case 'enter': { + return transform.splitBlock().apply() + } + + case 'backspace': { + return Key.isWord(e) + ? transform.backspaceWord().apply() + : transform.deleteBackward().apply() + } + + case 'delete': { + return Key.isWord(e) + ? transform.deleteWord().apply() + : transform.deleteForward().apply() + } + + case 'up': { + if (state.isExpanded) return + const first = state.blocks.first() + if (!first || !first.isVoid) return + e.preventDefault() + return transform.moveToEndOfPreviousBlock().apply() + } + + case 'down': { + if (state.isExpanded) return + const first = state.blocks.first() + if (!first || !first.isVoid) return + e.preventDefault() + return transform.moveToStartOfNextBlock().apply() + } + + case 'left': { + if (state.isExpanded) return + const node = state.blocks.first() || state.inlines.first() + if (!node || !node.isVoid) return + e.preventDefault() + return transform.moveToEndOfPreviousText().apply() + } + + case 'right': { + if (state.isExpanded) return + const node = state.blocks.first() || state.inlines.first() + if (!node || !node.isVoid) return + e.preventDefault() + return transform.moveToStartOfNextText().apply() + } + + case 'y': { + if (!Key.isWindowsCommand(e)) return + return transform.redo() + } + + case 'z': { + if (!Key.isCommand(e)) return + return IS_MAC && Key.isShift(e) + ? transform.redo() + : transform.undo() + } + } + }, + + /** + * The core `onPaste` handler, which treats everything as plain text. + * + * @param {Event} e + * @param {Object} paste + * @param {State} state + * @param {Editor} editor + * @return {State or Null} newState + */ + + onPaste(e, paste, state, editor) { + if (paste.type == 'files') return + + let transform = state.transform() + + paste.text + .split('\n') + .forEach((line, i) => { + if (i > 0) transform = transform.splitBlock() + transform = transform.insertText(line) + }) + + return transform.apply() + }, + + /** + * The core `node` renderer, which uses plain `
    ` or `` depending on + * what kind of node it is. + * + * @param {Node} node + * @return {Component} component + */ + + renderNode(node) { + return node.kind == 'block' + ? DEFAULT_BLOCK + : DEFAULT_INLINE + }, + + /** + * The core `mark` renderer, with no styles. + * + * @param {Mark} mark + * @return {Object} style + */ + + renderMark(mark) { + return DEFAULT_MARK + } + } } -/** - * Default inline renderer. - * - * @param {Object} props - * @return {Element} element - */ - -function DEFAULT_INLINE(props) { - return {props.children} -} - -/** - * Default mark renderer. - * - * @type {Object} - */ - -const DEFAULT_MARK = {} /** * Export. */ -export default { - - /** - * The core `onBeforeInput` handler. - * - * If the current selection is expanded, we have to re-render. - * - * If the next state resolves a new list of decorations for any of its text - * nodes, we have to re-render. - * - * Otherwise, we can allow the default, native text insertion, avoiding a - * re-render for improved performance. - * - * @param {Event} e - * @param {State} state - * @param {Editor} editor - * @return {State or Null} newState - */ - - onBeforeInput(e, state, editor) { - const transform = state.transform().insertText(e.data) - const synthetic = transform.apply() - const resolved = editor.resolveState(synthetic) - - const isSynthenic = ( - state.isExpanded || - !resolved.equals(synthetic) - ) - - if (isSynthenic) e.preventDefault() - - return isSynthenic - ? synthetic - : transform.apply({ isNative: true }) - }, - - /** - * The core `onKeyDown` handler. - * - * @param {Event} e - * @param {State} state - * @param {Editor} editor - * @return {State or Null} newState - */ - - onKeyDown(e, state, editor) { - const key = keycode(e.which) - const transform = state.transform() - - switch (key) { - case 'enter': { - return transform.splitBlock().apply() - } - - case 'backspace': { - return Key.isWord(e) - ? transform.backspaceWord().apply() - : transform.deleteBackward().apply() - } - - case 'delete': { - return Key.isWord(e) - ? transform.deleteWord().apply() - : transform.deleteForward().apply() - } - - case 'up': { - if (state.isExpanded) return - const first = state.blocks.first() - if (!first || !first.isVoid) return - e.preventDefault() - return transform.moveToEndOfPreviousBlock().apply() - } - - case 'down': { - if (state.isExpanded) return - const first = state.blocks.first() - if (!first || !first.isVoid) return - e.preventDefault() - return transform.moveToStartOfNextBlock().apply() - } - - case 'left': { - if (state.isExpanded) return - const node = state.blocks.first() || state.inlines.first() - if (!node || !node.isVoid) return - e.preventDefault() - return transform.moveToEndOfPreviousText().apply() - } - - case 'right': { - if (state.isExpanded) return - const node = state.blocks.first() || state.inlines.first() - if (!node || !node.isVoid) return - e.preventDefault() - return transform.moveToStartOfNextText().apply() - } - - case 'y': { - if (!Key.isWindowsCommand(e)) return - return transform.redo() - } - - case 'z': { - if (!Key.isCommand(e)) return - return IS_MAC && Key.isShift(e) - ? transform.redo() - : transform.undo() - } - } - }, - - /** - * The core `onPaste` handler, which treats everything as plain text. - * - * @param {Event} e - * @param {Object} paste - * @param {State} state - * @param {Editor} editor - * @return {State or Null} newState - */ - - onPaste(e, paste, state, editor) { - if (paste.type == 'files') return - - let transform = state.transform() - - paste.text - .split('\n') - .forEach((line, i) => { - if (i > 0) transform = transform.splitBlock() - transform = transform.insertText(line) - }) - - return transform.apply() - }, - - /** - * The core `node` renderer, which uses plain `
    ` or `` depending on - * what kind of node it is. - * - * @param {Node} node - * @return {Component} component - */ - - renderNode(node) { - return node.kind == 'block' - ? DEFAULT_BLOCK - : DEFAULT_INLINE - }, - - /** - * The core `mark` renderer, with no styles. - * - * @param {Mark} mark - * @return {Object} style - */ - - renderMark(mark) { - return DEFAULT_MARK - } - -} - +export default Plugin diff --git a/lib/utils/find-dom-node.js b/lib/utils/find-dom-node.js new file mode 100644 index 000000000..2ad55b6d5 --- /dev/null +++ b/lib/utils/find-dom-node.js @@ -0,0 +1,17 @@ + +/** + * Find the DOM node for a `node`. + * + * @param {Node} node + * @return {Element} el + */ + +function findDOMNode(node) { + return window.document.querySelector(`[data-key="${node.key}"]`) +} + +/** + * Export. + */ + +export default findDOMNode