diff --git a/examples/basic/index.js b/examples/basic/index.js
index 3acf6320e..a9a802611 100644
--- a/examples/basic/index.js
+++ b/examples/basic/index.js
@@ -10,40 +10,33 @@ import ReactDOM from 'react-dom'
const state = {
nodes: [
{
- kind: 'node',
type: 'code',
- data: {},
nodes: [
{
type: 'text',
ranges: [
{
- text: 'A\nfew\nlines\nof\ncode.',
- marks: []
+ text: 'A\nfew\nlines\nof\ncode.'
}
]
}
]
},
{
- kind: 'node',
type: 'paragraph',
- data: {},
nodes: [
{
type: 'text',
ranges: [
{
- text: 'A ',
- marks: []
+ text: 'A '
},
{
text: 'simple',
marks: ['bold']
},
{
- text: ' paragraph of text.',
- marks: []
+ text: ' paragraph of text.'
}
]
}
diff --git a/lib/components/content.js b/lib/components/content.js
index 8bd47d5e6..cbc4229a4 100644
--- a/lib/components/content.js
+++ b/lib/components/content.js
@@ -1,9 +1,9 @@
+import OffsetKey from '../utils/offset-key'
import React from 'react'
import ReactDOM from 'react-dom'
import Text from './text'
import TextModel from '../models/text'
-import findSelection from '../utils/find-selection'
import keycode from 'keycode'
/**
@@ -66,37 +66,28 @@ class Content extends React.Component {
const el = ReactDOM.findDOMNode(this)
const { anchorNode, anchorOffset, focusNode, focusOffset } = native
- const anchorIsText = anchorNode.nodeType == 3
- const focusIsText = focusNode.nodeType == 3
+ const anchor = OffsetKey.findPoint(anchorNode, anchorOffset)
+ const focus = OffsetKey.findPoint(focusNode, focusOffset)
+ const edges = state.filterNodes((node) => {
+ return node.key == anchor.key || node.key == focus.key
+ })
- // If both are text nodes, find their parents and create the selection.
- if (anchorIsText && focusIsText) {
- const anchor = findSelection(anchorNode, anchorOffset)
- const focus = findSelection(focusNode, focusOffset)
- const { nodes } = state
+ const isBackward = (
+ (edges.size == 2 && edges.first().key == focus.key) ||
+ (edges.size == 1 && anchor.offset > focus.offset)
+ )
- const startAndEnd = state.filterNodes((node) => {
- return node.key == anchor.key || node.key == focus.key
- })
+ selection = selection.merge({
+ anchorKey: anchor.key,
+ anchorOffset: anchor.offset,
+ focusKey: focus.key,
+ focusOffset: focus.offset,
+ isBackward: isBackward,
+ isFocused: true
+ })
- const isBackward = (
- (startAndEnd.size == 2 && startAndEnd.first().key == focus.key) ||
- (startAndEnd.size == 1 && anchor.offset > focus.offset)
- )
-
- selection = selection.merge({
- anchorKey: anchor.key,
- anchorOffset: anchor.offset,
- focusKey: focus.key,
- focusOffset: focus.offset,
- isBackward: isBackward,
- isFocused: true
- })
-
- state = state.set('selection', selection)
- this.onChange(state)
- return
- }
+ state = state.set('selection', selection)
+ this.onChange(state)
}
/**
diff --git a/lib/components/leaf.js b/lib/components/leaf.js
index 5dfd94550..8bd6d76a0 100644
--- a/lib/components/leaf.js
+++ b/lib/components/leaf.js
@@ -1,4 +1,5 @@
+import OffsetKey from '../utils/offset-key'
import React from 'react'
import ReactDOM from 'react-dom'
import createOffsetKey from '../utils/create-offset-key'
@@ -96,26 +97,24 @@ class Leaf extends React.Component {
render() {
const { node, range } = this.props
const { text } = range
- const offsetKey = createOffsetKey(node, range)
const styles = this.renderStyles()
+ const offsetKey = OffsetKey.stringify({
+ key: node.key,
+ start: range.offset,
+ end: range.offset + range.text.length
+ })
return (
- {text}
+ {text == '' ?
: text}
)
}
- renderOffsetKey() {
- const { node, offset, text } = this.props
- const key = `${node.key}.${offset}-${offset + text.length}`
- return key
- }
-
renderStyles() {
const { range, renderMark } = this.props
const { marks } = range
diff --git a/lib/components/text.js b/lib/components/text.js
index 9d1e94cec..3817baa50 100644
--- a/lib/components/text.js
+++ b/lib/components/text.js
@@ -20,7 +20,9 @@ class Text extends React.Component {
const { node } = this.props
const { characters } = node
const ranges = convertCharactersToRanges(characters)
- const leaves = ranges.map(range => this.renderLeaf(range))
+ const leaves = ranges.length
+ ? ranges.map(range => this.renderLeaf(range))
+ : this.renderSpacerLeaf()
return (
node.key == key)
.rest()
@@ -141,11 +145,61 @@ class Node extends NodeRecord {
if (shallow != null) return shallow
return this.nodes
- .map(node => node instanceof Node ? node.getNodeAfter(key) : null)
+ .map(node => node instanceof Node ? node.getNextNode(key) : null)
.filter(node => node)
.first()
}
+ /**
+ * Get the child node before the one by `key`.
+ *
+ * @param {String or Node} key
+ * @return {Node or Null}
+ */
+
+ getPreviousNode(key) {
+ if (typeof key != 'string') {
+ key = key.key
+ }
+
+ const matches = this.nodes.get(key)
+
+ if (matches) {
+ return this.nodes
+ .takeUntil(node => node.key == key)
+ .last()
+ }
+
+ return this.nodes
+ .map(node => node instanceof Node ? node.getPreviousNode(key) : null)
+ .filter(node => node)
+ .first()
+ }
+
+ /**
+ * Get the parent of a child node by `key`.
+ *
+ * @param {String or Node} key
+ * @return {Node or Null}
+ */
+
+ getParentNode(key) {
+ if (typeof key != 'string') {
+ key = key.key
+ }
+
+ if (this.nodes.get(key)) return this
+ let node = null
+
+ this.nodes.forEach((child) => {
+ if (!(child instanceof Node)) return
+ const match = child.getParentNode(key)
+ if (match) node = match
+ })
+
+ return node
+ }
+
/**
* Get the child text node at `offset`.
*
@@ -169,46 +223,76 @@ class Node extends NodeRecord {
}
/**
- * Get the parent of a child node by `key`.
+ * Recursively check if a child node exists by `key`.
*
- * @param {String} key
- * @return {Node or Null}
+ * @param {String or Node} key
+ * @return {Boolean} true
*/
- getParentOfNode(key) {
- if (this.nodes.get(key)) return this
- let node = null
+ hasNode(key) {
+ if (typeof key != 'string') {
+ key = key.key
+ }
- this.nodes.forEach((child) => {
- if (!(child instanceof Node)) return
- const match = child.getParentOfNode(key)
- if (match) node = match
- })
+ const shallow = this.nodes.has(key)
+ if (shallow) return true
- return node
+ const deep = this.nodes
+ .map(node => node instanceof Node ? node.hasNode(key) : false)
+ .some(has => has)
+ if (deep) return true
+
+ return false
}
/**
* Push a new `node` onto the map of nodes.
*
+ * @param {String or Node} key
* @param {Node} node
* @return {Node} node
*/
- pushNode(node) {
- let nodes = this.nodes.set(node.key, node)
+ pushNode(key, node) {
+ if (typeof key != 'string') {
+ node = key
+ key = node.key
+ }
+
+ let nodes = this.nodes.set(key, node)
+ return this.merge({ nodes })
+ }
+
+ /**
+ * Remove a `node` from the children node map.
+ *
+ * @param {String or Node} key
+ * @return {Node} node
+ */
+
+ removeNode(key) {
+ if (typeof key != 'string') {
+ key = key.key
+ }
+
+ let nodes = this.nodes.remove(key)
return this.merge({ nodes })
}
/**
* Set a new value for a child node by `key`.
*
- * @param {String} key
+ * @param {String or Node} key
* @param {Node} node
* @return {Node} node
*/
- setNode(key, node) {
+ updateNode(key, node) {
+ if (typeof key != 'string') {
+ node = key
+ key = node.key
+ }
+
if (this.nodes.get(key)) {
const nodes = this.nodes.set(key, node)
return this.set('nodes', nodes)
@@ -216,7 +300,7 @@ class Node extends NodeRecord {
const nodes = this.nodes.map((child) => {
return child instanceof Node
- ? child.setNode(key, node)
+ ? child.updateNode(key, node)
: child
})
diff --git a/lib/models/selection.js b/lib/models/selection.js
index 22db0b4db..2faa8ffe7 100644
--- a/lib/models/selection.js
+++ b/lib/models/selection.js
@@ -60,31 +60,190 @@ class Selection extends SelectionRecord {
}
/**
- * Check whether the selection is at the start of a `state`.
+ * Check whether the selection is at the start of a `node`.
*
- * @param {State} state
+ * @param {Node} node
* @return {Boolean} isAtStart
*/
- isAtStartOf(state) {
- const { nodes } = state
- const { startKey } = this
- const first = nodes.first()
- return startKey == first.key
+ isAtStartOf(node) {
+ const { startKey, startOffset } = this
+ const first = node.type == 'text' ? node : node.nodes.first()
+ return startKey == first.key && startOffset == 0
}
/**
- * Check whether the selection is at the end of a `state`.
+ * Check whether the selection is at the end of a `node`.
*
- * @param {State} state
+ * @param {Node} node
* @return {Boolean} isAtEnd
*/
- isAtEndOf(state) {
- const { nodes } = state
- const { endKey } = this
- const last = nodes.last()
- return endKey == last.key
+ isAtEndOf(node) {
+ const { endKey, endOffset } = this
+ const last = node.type == 'text' ? node : node.nodes.last()
+ return endKey == last.key && endOffset == last.length
+ }
+
+ /**
+ * Move the selection to a set of `properties`.
+ *
+ * @param {Object} properties
+ * @return {State} state
+ */
+
+ moveTo(properties) {
+ return this.merge(properties)
+ }
+
+ /**
+ * Move the focus point to the anchor point.
+ *
+ * @return {Selection} selection
+ */
+
+ moveToAnchor() {
+ return this.merge({
+ focusKey: this.anchorKey,
+ focusOffset: this.anchorOffset
+ })
+ }
+
+ /**
+ * Move the anchor point to the focus point.
+ *
+ * @return {Selection} selection
+ */
+
+ moveToFocus() {
+ return this.merge({
+ anchorKey: this.focusKey,
+ anchorOffset: this.focusOffset
+ })
+ }
+
+ /**
+ * Move the end point to the start point.
+ *
+ * @return {Selection} selection
+ */
+
+ moveToStart() {
+ return this.isBackward
+ ? this.merge({
+ anchorKey: this.focusKey,
+ anchorOffset: this.focusOffset,
+ isBackward: false
+ })
+ : this.merge({
+ focusKey: this.anchorKey,
+ focusOffset: this.anchorOffset,
+ isBackward: false
+ })
+ }
+
+ /**
+ * Move the start point to the end point.
+ *
+ * @return {Selection} selection
+ */
+
+ moveToEnd() {
+ return this.isBackward
+ ? this.merge({
+ focusKey: this.anchorKey,
+ focusOffset: this.anchorOffset,
+ isBackward: false
+ })
+ : this.merge({
+ anchorKey: this.focusKey,
+ anchorOffset: this.focusOffset,
+ isBackward: false
+ })
+ }
+
+ /**
+ * Move to the start of a `node`.
+ *
+ * @return {Selection} selection
+ */
+
+ moveToStartOf(node) {
+ return this.merge({
+ anchorKey: node.key,
+ anchorOffset: 0,
+ focusKey: node.key,
+ focusOffset: 0,
+ isBackward: false
+ })
+ }
+
+ /**
+ * Move to the end of a `node`.
+ *
+ * @return {Selection} selection
+ */
+
+ moveToEndOf(node) {
+ return this.merge({
+ anchorKey: node.key,
+ anchorOffset: node.length,
+ focusKey: node.key,
+ focusOffset: node.length,
+ isBackward: false
+ })
+ }
+
+ /**
+ * Move to the entire range of a `node`.
+ *
+ * @return {Selection} selection
+ */
+
+ moveToRangeOf(node) {
+ return this.merge({
+ anchorKey: node.key,
+ anchorOffset: 0,
+ focusKey: node.key,
+ focusOffset: node.length,
+ isBackward: false
+ })
+ }
+
+ /**
+ * Move the selection forward `n` characters.
+ *
+ * @param {Number} n
+ * @return {Selection} selection
+ */
+
+ moveForward(n = 1) {
+ if (!this.isCollapsed) {
+ throw new Error('The selection must be collapsed to move forward.')
+ }
+
+ return this.merge({
+ anchorOffset: this.anchorOffset + n,
+ focusOffset: this.focusOffset + n
+ })
+ }
+
+ /**
+ * Move the selection backward `n` characters.
+ *
+ * @param {Number} n
+ * @return {Selection} selection
+ */
+
+ moveBackward(n = 1) {
+ if (!this.isCollapsed) {
+ throw new Error('The selection must be collapsed to move backward.')
+ }
+
+ return this.merge({
+ anchorOffset: this.anchorOffset - n,
+ focusOffset: this.focusOffset - n
+ })
}
}
diff --git a/lib/models/state.js b/lib/models/state.js
index 89cd2e1bf..35bb682a5 100644
--- a/lib/models/state.js
+++ b/lib/models/state.js
@@ -14,6 +14,40 @@ const StateRecord = new Record({
selection: new Selection()
})
+/**
+ * Node-like methods, that should be mixed into the `State` prototype.
+ */
+
+const NODE_LIKE_METHODS = [
+ 'filterNodes',
+ 'findNode',
+ 'getNextNode',
+ 'getNode',
+ 'getParentNode',
+ 'getPreviousNode',
+ 'hasNode',
+ 'pushNode',
+ 'removeNode',
+ 'updateNode'
+]
+
+/**
+ * Selection-like methods, that should be mixed into the `State` prototype.
+ */
+
+const SELECTION_LIKE_METHODS = [
+ 'moveTo',
+ 'moveToAnchor',
+ 'moveToEnd',
+ 'moveToFocus',
+ 'moveToStart',
+ 'moveToStartOf',
+ 'moveToEndOf',
+ 'moveToRangeOf',
+ 'moveForward',
+ 'moveBackward'
+]
+
/**
* State.
*/
@@ -34,15 +68,25 @@ class State extends StateRecord {
}
/**
+ * Get whether the selection is collapsed.
*
- * NODES HELPERS.
- * ==============
- *
- * These are all nodes-like helper functions that help with actions related to
- * the recursively-nested node tree.
- *
+ * @return {Boolean} isCollapsed
*/
+ get isCollapsed() {
+ return this.selection.isCollapsed
+ }
+
+ /**
+ * Get the length of the concatenated text of all nodes.
+ *
+ * @return {Number} length
+ */
+
+ get length() {
+ return this.text.length
+ }
+
/**
* Get the concatenated text of all nodes.
*
@@ -56,226 +100,182 @@ class State extends StateRecord {
}
/**
- * Get a node by `key`.
+ * Get the anchor key.
*
- * @param {String} key
- * @return {Node or Null}
+ * @return {String} anchorKey
*/
- getNode(key) {
- return this.findNode(node => node.key == key) || null
+ get anchorKey() {
+ return this.selection.anchorKey
}
/**
- * Get the child node after the one by `key`.
+ * Get the anchor offset.
*
- * @param {String} key
- * @return {Node or Null}
+ * @return {String} anchorOffset
*/
- getNodeAfter(key) {
- const shallow = this.nodes
- .skipUntil(node => node.key == key)
- .rest()
- .first()
-
- if (shallow != null) return shallow
-
- return this.nodes
- .map(node => node instanceof Node ? node.getNodeAfter(key) : null)
- .filter(node => node)
- .first()
+ get anchorOffset() {
+ return this.selection.anchorOffset
}
/**
- * Get the child text node at `offset`.
+ * Get the focus key.
*
- * @param {String} offset
- * @return {Node or Null}
+ * @return {String} focusKey
*/
- getNodeAtOffset(offset) {
- let node = null
- let i
-
- this.nodes.forEach((child) => {
- const match = child.text.length > offset + i
- if (!match) return
- node = match.type == 'text'
- ? match
- : match.getNodeAtOffset(offset - i)
- })
-
- return node
+ get focusKey() {
+ return this.selection.focusKey
}
/**
- * Get the parent of a child node by `key`.
+ * Get the focus offset.
*
- * @param {String} key
- * @return {Node or Null}
+ * @return {String} focusOffset
*/
- getParentOfNode(key) {
- if (this.nodes.get(key)) return this
- let node = null
-
- this.nodes.forEach((child) => {
- if (!(child instanceof Node)) return
- const match = child.getParentOfNode(key)
- if (match) node = match
- })
-
- return node
+ get focusOffset() {
+ return this.selection.focusOffset
}
/**
- * Recursively find children nodes by `iterator`.
+ * Get the start key.
*
- * @param {Function} iterator
- * @return {OrderedMap} matches
+ * @return {String} startKey
*/
- findNode(iterator) {
- const shallow = this.nodes.find(iterator)
- if (shallow != null) return shallow
-
- const deep = this.nodes
- .map(node => node instanceof Node ? node.findNode(iterator) : null)
- .filter(node => node)
- .first()
- return deep
+ get startKey() {
+ return this.selection.startKey
}
/**
- * Recursively filter children nodes with `iterator`.
+ * Get the start offset.
*
- * @param {Function} iterator
- * @return {OrderedMap} matches
+ * @return {String} startOffset
*/
- filterNodes(iterator) {
- const shallow = this.nodes.filter(iterator)
- const deep = this.nodes
- .map(node => node instanceof Node ? node.filterNodes(iterator) : null)
- .filter(node => node)
- .reduce((all, map) => {
- return all.concat(map)
- }, shallow)
-
- return deep
+ get startOffset() {
+ return this.selection.startOffset
}
/**
- * Push a new `node` onto the map of nodes.
+ * Get the end key.
+ *
+ * @return {String} endKey
+ */
+
+ get endKey() {
+ return this.selection.endKey
+ }
+
+ /**
+ * Get the end offset.
+ *
+ * @return {String} endOffset
+ */
+
+ get endOffset() {
+ return this.selection.endOffset
+ }
+
+ /**
+ * Get the anchor node.
*
- * @param {Node} node
* @return {Node} node
*/
- pushNode(node) {
- let notes = this.notes.set(node.key, node)
- return this.merge({ notes })
+ get anchorNode() {
+ return this.getNode(this.anchorKey)
}
/**
- * Set a new value for a child node by `key`.
+ * Get the focus node.
*
- * @param {String} key
- * @param {Node} node
* @return {Node} node
*/
- setNode(key, node) {
- if (this.nodes.get(key)) {
- const nodes = this.nodes.set(key, node)
- return this.merge({ nodes })
- }
-
- const nodes = this.nodes.map((child) => {
- return child instanceof Node
- ? child.setNode(key, node)
- : child
- })
-
- return this.merge({ nodes })
+ get focusNode() {
+ return this.getNode(this.focusKey)
}
/**
+ * Get the start node.
*
- * TRANSFORMS.
- * -----------
- *
- * These are all transform helper functions that map to a specific transform
- * type that you can apply to a state.
- *
+ * @return {Node} node
*/
+ get startNode() {
+ return this.getNode(this.startKey)
+ }
+
/**
- * Backspace a single character.
+ * Get the end node.
+ *
+ * @return {Node} node
+ */
+
+ get endNode() {
+ return this.getNode(this.endKey)
+ }
+
+ /**
+ * Is the selection at the start of `node`?
+ *
+ * @param {Node} node
+ * @return {Boolean} isAtStart
+ */
+
+ isAtStartOf(node) {
+ return this.selection.isAtStartOf(node)
+ }
+
+ /**
+ * Is the selection at the end of `node`?
+ *
+ * @param {Node} node
+ * @return {Boolean} isAtEnd
+ */
+
+ isAtEndOf(node) {
+ return this.selection.isAtEndOf(node)
+ }
+
+ /**
+ * Backspace.
*
* @return {State} state
*/
backspace() {
- const { selection } = this
+ let state = this
- // when not collapsed, remove the entire selection
- if (!selection.isCollapsed) {
- return this
- .removeSelection(selection)
- .collapseBackward()
+ // When not collapsed, remove the entire selection.
+ if (!state.isCollapsed) {
+ state = state.removeRange()
+ state = state.moveToStart()
+ return state
}
- // when already at the start of the content, there's nothing to do
- if (selection.isAtStartOf(this)) return this
+ // When already at the start of the content, there's nothing to do.
+ if (state.isAtStartOf(state)) return state
- // otherwise, remove one character behind of the cursor
- const { startKey, endOffset } = selection
- const node = this.getNode(startKey)
+ // When at start of a node, merge backwards into the previous node.
+ const { startNode } = state
+ if (state.isAtStartOf(startNode)) {
+ const parent = state.getParentNode(startNode)
+ const previous = state.getPreviousNode(parent).nodes.first()
+ const range = selection.moveToEndOf(previous)
+ state = state.removeRange(range)
+ return state
+ }
+
+ // Otherwise, remove one character behind of the cursor.
+ const { endOffset } = state
const startOffset = endOffset - 1
- return this
- .removeCharacters(node, startOffset, endOffset)
- .moveTo({
- anchorOffset: startOffset,
- focusOffset: startOffset
- })
- }
-
- /**
- * Collapse the current selection backward, towards it's anchor point.
- *
- * @return {State} state
- */
-
- collapseBackward() {
- let { selection } = this
- let { anchorKey, anchorOffset } = selection
-
- selection = selection.merge({
- focusKey: anchorKey,
- focusOffset: anchorOffset
- })
-
- return this.merge({ selection })
- }
-
- /**
- * Collapse the current selection forward, towards it's focus point.
- *
- * @return {State} state
- */
-
- collapseForward() {
- let { selection } = this
- const { focusKey, focusOffset } = selection
-
- selection = selection.merge({
- anchorKey: focusKey,
- anchorOffset: focusOffset
- })
-
- return this.merge({ selection })
+ state = state.removeCharacters(startNode.key, startOffset, endOffset)
+ state = state.moveBackward()
+ return state
}
/**
@@ -285,39 +285,23 @@ class State extends StateRecord {
*/
delete() {
- const { selection } = this
+ let state = this
- // when not collapsed, remove the entire selection
- if (!selection.isCollapsed) {
- return this
- .removeSelection(selection)
- .collapseBackward()
+ // When not collapsed, remove the entire selection range.
+ if (!state.isCollapsed) {
+ state = state.removeRange()
+ state = state.moveToStart()
+ return state
}
- // when already at the end of the content, there's nothing to do
- if (selection.isAtEndOf(this)) return this
+ // When already at the end of the content, there's nothing to do.
+ if (state.isAtEndOf(state)) return state
- // otherwise, remove one character ahead of the cursor
- const { startKey, startOffset } = selection
- const node = this.getNode(startKey)
+ // Otherwise, remove one character ahead of the cursor.
+ const { startOffset, startNode } = state
const endOffset = startOffset + 1
- return this.removeCharacters(node, startOffset, endOffset)
- }
-
- /**
- * Move the selection to a specific anchor and focus.
- *
- * @param {Object} properties
- * @property {String} anchorKey (optional)
- * @property {Number} anchorOffset (optional)
- * @property {String} focusKey (optional)
- * @property {String} focusOffset (optional)
- * @return {State} state
- */
-
- moveTo(properties) {
- const selection = this.selection.merge(properties)
- return this.merge({ selection })
+ state = state.removeCharacters(startNode.key, startOffset, endOffset)
+ return state
}
/**
@@ -370,15 +354,15 @@ class State extends StateRecord {
*/
removeCharacters(key, startOffset, endOffset) {
- let node = this.getNode(key)
- let { characters } = node
-
- characters = node.characters.filterNot((char, i) => {
+ let state = this
+ let node = state.getNode(key)
+ const characters = node.characters.filterNot((char, i) => {
return startOffset <= i && i < endOffset
})
node = node.merge({ characters })
- return this.setNode(key, node)
+ state = state.updateNode(node)
+ return state
}
/**
@@ -388,12 +372,13 @@ class State extends StateRecord {
*/
split() {
- let { selection } = this
- let state = this.splitSelection(selection)
- let { anchorKey } = state.selection
- let parent = state.getParentOfNode(anchorKey)
- let node = state.getNodeAfter(parent.key)
- let text = node.nodes.first()
+ let state = this
+ const { selection } = state
+ state = state.splitRange(selection)
+ const { anchorKey } = state.selection
+ const parent = state.getParentNode(anchorKey)
+ const next = state.getNextNode(parent)
+ const text = next.nodes.first()
return state.moveTo({
anchorKey: text.key,
anchorOffset: 0,
@@ -409,12 +394,12 @@ class State extends StateRecord {
* @return {State} state
*/
- splitSelection(selection) {
+ splitRange(selection) {
let state = this
// if there's an existing selection, remove it first
if (!selection.isCollapsed) {
- state = state.removeSelection(selection)
+ state = state.removeRange(selection)
selection = selection.merge({
focusKey: selection.anchorKey,
focusOffset: selection.anchorOffset
@@ -424,7 +409,7 @@ class State extends StateRecord {
// then split the node at the selection
const { startKey, startOffset } = selection
const text = state.getNode(startKey)
- const parent = state.getParentOfNode(text.key)
+ const parent = state.getParentNode(text)
// split the characters
const { characters , length } = text
@@ -433,7 +418,7 @@ class State extends StateRecord {
// Create a new first node with only the first set of characters.
const firstText = text.set('characters', firstCharacters)
- const firstNode = parent.setNode(firstText.key, firstText)
+ const firstNode = parent.updateNode(firstText)
// Create a brand new second node with the second set of characters.
let secondText = Text.create({})
@@ -446,7 +431,7 @@ class State extends StateRecord {
secondNode = secondNode.pushNode(secondText)
// Replace the old parent node in the grandparent with the two new ones.
- let grandparent = state.getParentOfNode(parent.key)
+ let grandparent = state.getParentNode(parent)
const befores = grandparent.nodes.takeUntil(node => node.key == parent.key)
const afters = grandparent.nodes.skipUntil(node => node.key == parent.key).rest()
const nodes = befores
@@ -458,14 +443,79 @@ class State extends StateRecord {
state = state.merge({ nodes })
} else {
grandparent = grandparent.merge({ nodes })
- state = state.setNode(grandparent.key, grandparent)
+ state = state.updateNode(grandparent)
}
return state
}
+ /**
+ * Merge the nodes between `selection`.
+ *
+ * @param {Selection} selection (optional)
+ * @return {State} state
+ */
+
+ removeRange(selection = this.selection) {
+ let state = this
+
+ // If the selection is collapsed, there's nothing to do.
+ if (selection.isCollapsed) return state
+
+ // If the start and end nodes are the same, just remove the matching text.
+ const { startKey, startOffset, endKey, endOffset } = selection
+ if (startKey == endKey) {
+ return state.removeCharacters(startKey, startOffset, endOffset)
+ }
+
+ // Otherwise, remove the text from the first and last nodes...
+ let startText = state.getNode(startKey)
+ state = state.removeCharacters(startKey, startOffset, startText.length)
+ state = state.removeCharacters(endKey, 0, endOffset)
+
+ // Then remove any nodes in between the top-most start and end nodes...
+ let startNode = state.getParentNode(startKey)
+ let endNode = state.getParentNode(endKey)
+ const startParent = state.nodes.find(node => node == startNode || node.hasNode(startNode))
+ const endParent = state.nodes.find(node => node == endNode || node.hasNode(endNode))
+
+ const nodes = state.nodes
+ .takeUntil(node => node == startParent)
+ .set(startParent.key, startParent)
+ .concat(state.nodes.skipUntil(node => node == endParent))
+
+ state = state.merge({ nodes })
+
+ // Then bring the end text node into the start node.
+ let endText = state.getNode(endKey)
+ startNode = startNode.pushNode(endText)
+ endNode = endNode.removeNode(endText)
+ state = state.updateNode(startNode)
+ state = state.updateNode(endNode)
+ return state
+ }
+
}
+/**
+ * Mix in node-like methods.
+ */
+
+NODE_LIKE_METHODS.forEach((method) => {
+ State.prototype[method] = Node.prototype[method]
+})
+
+/**
+ * Mix in selection-like methods.
+ */
+
+SELECTION_LIKE_METHODS.forEach((method) => {
+ State.prototype[method] = function (...args) {
+ let selection = this.selection[method](...args)
+ return this.merge({ selection })
+ }
+})
+
/**
* Export.
*/
diff --git a/lib/models/text.js b/lib/models/text.js
index d958a8b0a..d90cae625 100644
--- a/lib/models/text.js
+++ b/lib/models/text.js
@@ -55,6 +55,16 @@ class Text extends TextRecord {
.join('')
}
+ /**
+ * Immutable type to match other nodes.
+ *
+ * @return {String} type
+ */
+
+ get type() {
+ return 'text'
+ }
+
}
/**
diff --git a/lib/utils/find-offset-key.js b/lib/utils/find-offset-key.js
deleted file mode 100644
index dc2cd7d42..000000000
--- a/lib/utils/find-offset-key.js
+++ /dev/null
@@ -1,24 +0,0 @@
-
-/**
- * Find the nearest parent of a `node` and return their offset key.
- *
- * @param {Node} node
- * @return {String} key
- */
-
-export default function findOffsetKey(node) {
- let match = node
-
- while (match && match != document.documentElement) {
- if (
- match instanceof Element &&
- match.hasAttribute('data-key')
- ) {
- return match.getAttribute('data-key')
- }
-
- match = match.parentNode
- }
-
- return null
-}
diff --git a/lib/utils/find-selection.js b/lib/utils/find-selection.js
deleted file mode 100644
index 01e899853..000000000
--- a/lib/utils/find-selection.js
+++ /dev/null
@@ -1,35 +0,0 @@
-
-import findOffsetKey from './find-offset-key'
-
-/**
- * Offset key splitter.
- */
-
-const SPLITTER = /^(\w+)(?:\.(\d+)-(\d+))?$/
-
-/**
- * Find the selection anchor properties from a `node`.
- *
- * @param {Node} node
- * @param {Number} nodeOffset
- * @return {Object} anchor
- * @property {String} anchorKey
- * @property {Number} anchorOffset
- */
-
-export default function findSelection(node, nodeOffset) {
- const offsetKey = findOffsetKey(node)
- if (!offsetKey) return null
-
- const matches = SPLITTER.exec(offsetKey)
- if (!matches) throw new Error(`Unknown offset key "${offsetKey}".`)
-
- let [ all, key, offsetStart, offsetEnd ] = matches
- offsetStart = parseInt(offsetStart, 10)
- offsetEnd = parseInt(offsetEnd, 10)
-
- return {
- key: key,
- offset: offsetStart + nodeOffset
- }
-}
diff --git a/lib/utils/offset-key.js b/lib/utils/offset-key.js
new file mode 100644
index 000000000..0a97c7c9a
--- /dev/null
+++ b/lib/utils/offset-key.js
@@ -0,0 +1,90 @@
+
+/**
+ * Offset key parser regex.
+ */
+
+const PARSER = /^(\w+)(?::(\d+)-(\d+))?$/
+
+/**
+ * Offset key attribute name.
+ */
+
+const ATTRIBUTE = 'data-offset-key'
+
+/**
+ * From a `node`, find the closest parent's offset key.
+ *
+ * @param {Node} node
+ * @return {String} key
+ */
+
+function findKey(node) {
+ if (node.nodeType == 3) node = node.parentNode
+ const parent = node.closest(`[${ATTRIBUTE}]`)
+ if (!parent) return null
+ return parent.getAttribute(ATTRIBUTE)
+}
+
+/**
+ * From a `node` and `offset`, find the closest parent's point.
+ *
+ * @param {Node} node
+ * @param {Offset} offset
+ * @return {String} key
+ */
+
+function findPoint(node, offset) {
+ const key = findKey(node)
+ const parsed = parse(key)
+ return {
+ key: parsed.key,
+ offset: parsed.start + offset
+ }
+}
+
+/**
+ * Parse an offset key `string`.
+ *
+ * @param {String} string
+ * @return {Object} parsed
+ */
+
+function parse(string) {
+ const matches = PARSER.exec(string)
+ if (!matches) throw new Error(`Invalid offset key string "${string}".`)
+
+ let [ original, key, start, end ] = matches
+ start = parseInt(start, 10)
+ end = parseInt(end, 10)
+
+ return {
+ key,
+ start,
+ end
+ }
+}
+
+/**
+ * Stringify an offset key `object`.
+ *
+ * @param {Object} object
+ * @property {String} key
+ * @property {Number} start
+ * @property {Number} end
+ * @return {String} key
+ */
+
+function stringify(object) {
+ return `${object.key}:${object.start}-${object.end}`
+}
+
+/**
+ * Export.
+ */
+
+export default {
+ findKey,
+ findPoint,
+ parse,
+ stringify
+}