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 +}