diff --git a/examples/richtext/index.css b/examples/richtext/index.css
index e38de6985..0c9a20b69 100644
--- a/examples/richtext/index.css
+++ b/examples/richtext/index.css
@@ -15,7 +15,15 @@ p {
margin: 0;
}
-.editor p + p {
+blockquote {
+ border-left: 2px solid #ddd;
+ margin-left: 0;
+ padding-left: 10px;
+ color: #aaa;
+ font-style: italic;
+}
+
+.editor > * > * + * {
margin-top: 1em;
}
diff --git a/examples/richtext/index.js b/examples/richtext/index.js
index dd5b6267b..a97bbd999 100644
--- a/examples/richtext/index.js
+++ b/examples/richtext/index.js
@@ -68,24 +68,39 @@ class App extends React.Component {
state: Raw.deserialize(state)
};
- isMarkActive(type) {
+ hasMark(type) {
const { state } = this.state
- const { document, selection } = state
- const marks = document.getMarksAtRange(selection)
- return marks.some(mark => mark.type == type)
+ const { currentMarks } = state
+ return currentMarks.some(mark => mark.type == type)
+ }
+
+ hasBlock(type) {
+ const { state } = this.state
+ const { currentWrappingNodes } = state
+ return currentWrappingNodes.some(node => node.type == type)
}
onClickMark(e, type) {
e.preventDefault()
-
+ const isActive = this.hasMark(type)
let { state } = this.state
- const { marks } = state
- const isActive = this.isMarkActive(type)
- const mark = Mark.create({ type })
state = state
.transform()
- [isActive ? 'unmark' : 'mark'](mark)
+ [isActive ? 'unmark' : 'mark'](type)
+ .apply()
+
+ this.setState({ state })
+ }
+
+ onClickBlock(e, type) {
+ e.preventDefault()
+ const isActive = this.hasBlock(type)
+ let { state } = this.state
+
+ state = state
+ .transform()
+ .setType(isActive ? 'paragraph' : type)
.apply()
this.setState({ state })
@@ -101,25 +116,44 @@ class App extends React.Component {
}
renderToolbar() {
- const isBold = this.isMarkActive('bold')
- const isItalic = this.isMarkActive('italic')
- const isCode = this.isMarkActive('code')
+ const isBold = this.hasMark('bold')
+ const isCode = this.hasMark('code')
+ const isItalic = this.hasMark('italic')
+ const isUnderlined = this.hasMark('underlined')
return (
- this.onClickMark(e, 'bold')} data-active={isBold}>
- format_bold
-
- this.onClickMark(e, 'italic')} data-active={isItalic}>
- format_italic
-
- this.onClickMark(e, 'code')} data-active={isCode}>
- code
-
+ {this.renderMarkButton('bold', 'format_bold')}
+ {this.renderMarkButton('italic', 'format_italic')}
+ {this.renderMarkButton('underlined', 'format_underlined')}
+ {this.renderMarkButton('code', 'code')}
+ {this.renderBlockButton('heading-one', 'looks_one')}
+ {this.renderBlockButton('heading-two', 'looks_two')}
+ {this.renderBlockButton('block-quote', 'format_quote')}
+ {this.renderBlockButton('numbered-list', 'format_list_numbered')}
+ {this.renderBlockButton('bulleted-list', 'format_list_bulleted')}
)
}
+ renderMarkButton(type, icon) {
+ const isActive = this.hasMark(type)
+ return (
+ this.onClickMark(e, type)} data-active={isActive}>
+ {icon}
+
+ )
+ }
+
+ renderBlockButton(type, icon) {
+ const isActive = this.hasBlock(type)
+ return (
+ this.onClickBlock(e, type)} data-active={isActive}>
+ {icon}
+
+ )
+ }
+
renderEditor() {
return (
@@ -142,10 +176,26 @@ class App extends React.Component {
renderNode(node) {
switch (node.type) {
+ case 'block-quote': {
+ return (props) =>
{props.children}
+ }
+ case 'bulleted-list': {
+ return (props) =>
+ }
+ case 'heading-one': {
+ return (props) =>
{props.children}
+ }
+ case 'heading-two': {
+ return (props) =>
{props.children}
+ }
+ case 'list-item': {
+ return (props) =>
{props.chidlren}
+ }
+ case 'numbered-list': {
+ return (props) =>
{props.children}
+ }
case 'paragraph': {
- return (props) => {
- return
{props.children}
- }
+ return (props) =>
{props.children}
}
}
}
@@ -157,11 +207,6 @@ class App extends React.Component {
fontWeight: 'bold'
}
}
- case 'italic': {
- return {
- fontStyle: 'italic'
- }
- }
case 'code': {
return {
fontFamily: 'monospace',
@@ -170,6 +215,16 @@ class App extends React.Component {
borderRadius: '4px'
}
}
+ case 'italic': {
+ return {
+ fontStyle: 'italic'
+ }
+ }
+ case 'underlined': {
+ return {
+ textDecoration: 'underline'
+ }
+ }
}
}
diff --git a/lib/components/leaf.js b/lib/components/leaf.js
index cd5ec8c11..669644d3e 100644
--- a/lib/components/leaf.js
+++ b/lib/components/leaf.js
@@ -110,9 +110,8 @@ class Leaf extends React.Component {
return (
{text ||
}
diff --git a/lib/components/text.js b/lib/components/text.js
index ad009821c..20a35c0a4 100644
--- a/lib/components/text.js
+++ b/lib/components/text.js
@@ -20,11 +20,7 @@ class Text extends React.Component {
render() {
const { node } = this.props
return (
-
+
{this.renderLeaves()}
)
diff --git a/lib/models/node.js b/lib/models/node.js
index 1cfe18fed..4c7107166 100644
--- a/lib/models/node.js
+++ b/lib/models/node.js
@@ -4,7 +4,7 @@ import Element from './element'
import Mark from './mark'
import Selection from './selection'
import Text from './text'
-import { List, OrderedMap, Set } from 'immutable'
+import { List, OrderedMap, OrderedSet, Set } from 'immutable'
/**
* Node.
@@ -260,8 +260,8 @@ const Node = {
*/
getLastTextNode() {
- const texts = this.findNode(node => node.type == 'text')
- return texts.size ? texts.get(texts.size - 1) : null
+ const texts = this.filterNodes(node => node.type == 'text')
+ return texts.last() || null
},
/**
@@ -319,8 +319,8 @@ const Node = {
/**
* Get the child text node at an `offset`.
*
- * @param {String} offset
- * @return {Node or Null}
+ * @param {String or Node} key
+ * @return {Number} offset
*/
getNodeOffset(key) {
@@ -338,7 +338,9 @@ const Node = {
const befores = this.nodes.takeUntil(node => node.key == child.key)
// Calculate the offset of the nodes before the matching child.
- const offset = befores.map(child => child.length)
+ const offset = befores.reduce((offset, child) => {
+ return offset + child.length
+ }, 0)
// If the child's parent is this node, return the offset of all of the nodes
// before it, otherwise recurse.
@@ -413,8 +415,8 @@ const Node = {
focusOffset: 0
})
- const texts = this.getTextNodesAtRange()
- const previous = texts.get(text.size - 2)
+ const texts = this.getTextNodesAtRange(range)
+ const previous = texts.get(texts.size - 2)
return previous
},
@@ -460,54 +462,6 @@ const Node = {
return match
},
- /**
- * Get the child text nodes after an `offset`.
- *
- * @param {String} offset
- * @return {OrderedMap} matches
- */
-
- getTextNodesAfterOffset(offset) {
- let matches = new OrderedMap()
- let i
-
- this.nodes.forEach((child) => {
- if (child.length <= offset + i) return
-
- matches = child.type == 'text'
- ? matches.set(child.key, child)
- : matches.concat(child.getTextNodesAfterOffset(offset - i))
-
- i += child.length
- })
-
- return matches
- },
-
- /**
- * Get the child text nodes before an `offset`.
- *
- * @param {String} offset
- * @return {OrderedMap} matches
- */
-
- getTextNodesBeforeOffset(offset) {
- let matches = new OrderedMap()
- let i
-
- this.nodes.forEach((child) => {
- if (child.length > offset + i) return
-
- matches = child.type == 'text'
- ? matches.set(child.key, child)
- : matches.concat(child.getTextNodesBeforeOffset(offset - i))
-
- i += child.length
- })
-
- return matches
- },
-
/**
* Get all of the text nodes in a `range`.
*
@@ -522,17 +476,30 @@ const Node = {
this.assertHasNode(startKey)
this.assertHasNode(endKey)
- // Convert the start and end nodes to offsets.
- const startNode = this.getNode(startKey)
- const endNode = this.getNode(endKey)
- const startOffset = this.getNodeOffset(startNode)
- const endOffset = this.getNodeOffset(endNode)
-
// Return the text nodes after the start offset and before the end offset.
- const afterStart = this.getTextNodesAfterOffset(startOffset)
- const beforeEnd = this.getTextNodesBeforeOffset(endOffset)
- const between = afterStart.filter(node => beforeEnd.includes(node))
- return between
+ const endNode = this.getNode(endKey)
+ const texts = this.filterNodes(node => node.type == 'text')
+ const afterStart = texts.skipUntil(node => node.key == startKey)
+ const upToEnd = afterStart.takeUntil(node => node.key == endKey)
+ let matches = upToEnd.set(endNode.key, endNode)
+ return matches
+ },
+
+ /**
+ * Get all of the wrapping nodes in a `range`.
+ *
+ * @param {Selection} range
+ * @return {OrderedMap} nodes
+ */
+
+ getWrappingNodesAtRange(range) {
+ const node = this
+ const texts = node.getTextNodesAtRange(range)
+ const parents = texts.map((text) => {
+ return node.nodes.includes(text) ? node : node.getParentNode(text)
+ })
+
+ return parents
},
/**
@@ -718,6 +685,37 @@ const Node = {
return this.merge({ nodes })
},
+ /**
+ * Set the direct parent of text nodes in a range to `type`.
+ *
+ * @param {Selection} range
+ * @return {Node} node
+ */
+
+ setTypeAtRange(range, type) {
+ let node = this
+ const texts = node.getTextNodesAtRange(range)
+ let parents = new OrderedSet()
+
+ // Find the direct parent of each text node.
+ texts.forEach((text) => {
+ const parent = node.has(text.key) ? node : node.getParentNode(text)
+ parents = parents.add(parent)
+ })
+
+ // Set the new type for each parent.
+ parents = parents.forEach((parent) => {
+ if (parent == node) {
+ node = node.merge({ type })
+ } else {
+ parent = parent.merge({ type })
+ node = node.updateNode(parent)
+ }
+ })
+
+ return node
+ },
+
/**
* Split the nodes at a `range`.
*
@@ -844,8 +842,39 @@ const Node = {
})
return this.merge({ nodes })
+ },
+
+ /**
+ * Wrap all of the nodes in a `range` in a new `parent` node.
+ *
+ * @param {Selection} range
+ * @param {Node or String} parent
+ * @return {Node} node
+ */
+
+ wrapAtRange(range, parent) {
+
+ // Allow for the parent to by just a type.
+ if (typeof parent == 'string') {
+ parent = Element.create({ type: parent })
+ }
+
+ // Add the child to the parent's nodes.
+ const child = this.findNode(key)
+ parent = node.nodes.set(child.key, child)
+
+ // Remove the child from this node.
+ node = node.removeNode(child)
+
+ // Add the parent to this node.
+
+ return node
}
+ /**
+ * Unwrap the node
+ */
+
}
/**
diff --git a/lib/models/state.js b/lib/models/state.js
index 39d0ced1c..747d72aab 100644
--- a/lib/models/state.js
+++ b/lib/models/state.js
@@ -59,7 +59,7 @@ class State extends Record(DEFAULTS) {
* @return {List} characters
*/
- get characters() {
+ get currentCharacters() {
const { document, selection } = this
return document.getCharactersAtRange(selection)
}
@@ -70,18 +70,29 @@ class State extends Record(DEFAULTS) {
* @return {Set} marks
*/
- get marks() {
+ get currentMarks() {
const { document, selection } = this
return document.getMarksAtRange(selection)
}
+ /**
+ * Get the wrapping nodes in the current selection.
+ *
+ * @return {OrderedMap} nodes
+ */
+
+ get currentWrappingNodes() {
+ const { document, selection, textNodes } = this
+ return document.getWrappingNodesAtRange(selection)
+ }
+
/**
* Get the text nodes in the current selection.
*
* @return {OrderedMap} nodes
*/
- get textNodes() {
+ get currentTextNodes() {
const { document, selection } = this
return document.getTextNodesAtRange(selection)
}
@@ -147,7 +158,7 @@ class State extends Record(DEFAULTS) {
after = selection.moveToEndOf(previous)
}
- else if (!selection.isAtEndOf(document)) {
+ else {
after = selection.moveBackward(n)
}
@@ -215,6 +226,21 @@ class State extends Record(DEFAULTS) {
return state
}
+ /**
+ * Set the nodes in the current selection to `type`.
+ *
+ * @param {String} type
+ * @return {State} state
+ */
+
+ setType(type) {
+ let state = this
+ let { document, selection } = state
+ document = document.setTypeAtRange(selection, type)
+ state = state.merge({ document })
+ return state
+ }
+
/**
* Split the node at the current selection.
*
diff --git a/lib/models/transform.js b/lib/models/transform.js
index 5b8d2336a..906984a68 100644
--- a/lib/models/transform.js
+++ b/lib/models/transform.js
@@ -46,6 +46,8 @@ const TRANSFORM_TYPES = [
'insertTextAtRange',
'mark',
'markAtRange',
+ 'setType',
+ 'setTypeAtRange',
'split',
'splitAtRange',
'unmark',