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