mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-02-24 01:02:31 +01:00
605 lines
14 KiB
JavaScript
605 lines
14 KiB
JavaScript
|
|
import Selection from './selection'
|
|
import Node from './node'
|
|
import Text from './text'
|
|
import convertRangesToCharacters from '../utils/convert-ranges-to-characters'
|
|
import toCamel from 'to-camel-case'
|
|
import { OrderedMap, Record } from 'immutable'
|
|
|
|
/**
|
|
* Record.
|
|
*/
|
|
|
|
const StateRecord = new Record({
|
|
nodes: new OrderedMap(),
|
|
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',
|
|
'extendForward',
|
|
'extendBackward'
|
|
]
|
|
|
|
/**
|
|
* State.
|
|
*/
|
|
|
|
class State extends StateRecord {
|
|
|
|
/**
|
|
* Create a new `State` from `attrs`.
|
|
*
|
|
* @return {State} state
|
|
*/
|
|
|
|
static create(attrs) {
|
|
return new State({
|
|
nodes: Node.createMap(attrs.nodes),
|
|
selection: Selection.create(attrs.selection)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Node-like getters.
|
|
*/
|
|
|
|
get length() {
|
|
return this.text.length
|
|
}
|
|
|
|
get text() {
|
|
return this.nodes
|
|
.map(node => node.text)
|
|
.join('')
|
|
}
|
|
|
|
get type() {
|
|
return 'state'
|
|
}
|
|
|
|
/**
|
|
* Selection-like getters.
|
|
*/
|
|
|
|
get isCollapsed() {
|
|
return this.selection.isCollapsed
|
|
}
|
|
|
|
get isExpanded() {
|
|
return this.selection.isExpanded
|
|
}
|
|
|
|
get isExtended() {
|
|
return this.selection.isExtended
|
|
}
|
|
|
|
get anchorKey() {
|
|
return this.selection.anchorKey
|
|
}
|
|
|
|
get anchorOffset() {
|
|
return this.selection.anchorOffset
|
|
}
|
|
|
|
get focusKey() {
|
|
return this.selection.focusKey
|
|
}
|
|
|
|
get focusOffset() {
|
|
return this.selection.focusOffset
|
|
}
|
|
|
|
get startKey() {
|
|
return this.selection.startKey
|
|
}
|
|
|
|
get startOffset() {
|
|
return this.selection.startOffset
|
|
}
|
|
|
|
get endKey() {
|
|
return this.selection.endKey
|
|
}
|
|
|
|
get endOffset() {
|
|
return this.selection.endOffset
|
|
}
|
|
|
|
/**
|
|
* Get the current anchor node.
|
|
*
|
|
* @return {Node} node
|
|
*/
|
|
|
|
get anchorNode() {
|
|
return this.getNode(this.anchorKey)
|
|
}
|
|
|
|
/**
|
|
* Get the current focus node.
|
|
*
|
|
* @return {Node} node
|
|
*/
|
|
|
|
get focusNode() {
|
|
return this.getNode(this.focusKey)
|
|
}
|
|
|
|
/**
|
|
* Get the current start node.
|
|
*
|
|
* @return {Node} node
|
|
*/
|
|
|
|
get startNode() {
|
|
return this.getNode(this.startKey)
|
|
}
|
|
|
|
/**
|
|
* Get the current 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)
|
|
}
|
|
|
|
/**
|
|
* Delete a single character.
|
|
*
|
|
* @return {State} state
|
|
*/
|
|
|
|
delete() {
|
|
let state = this
|
|
|
|
// When collapsed, there's nothing to do.
|
|
if (state.isCollapsed) return state
|
|
|
|
// Otherwise, delete and update the selection.
|
|
state = state.deleteAtRange(state.selection)
|
|
state = state.moveToStart()
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Delete everything in a `range`.
|
|
*
|
|
* @param {Selection} range
|
|
* @return {State} state
|
|
*/
|
|
|
|
deleteAtRange(range) {
|
|
let state = this
|
|
|
|
// If the range is collapsed, there's nothing to do.
|
|
if (range.isCollapsed) return state
|
|
|
|
const { startKey, startOffset, endKey, endOffset } = range
|
|
let startNode = state.getNode(startKey)
|
|
|
|
// If the start and end nodes are the same, remove the matching characters.
|
|
if (startKey == endKey) {
|
|
let { characters } = startNode
|
|
|
|
characters = characters.filterNot((char, i) => {
|
|
return startOffset <= i && i < endOffset
|
|
})
|
|
|
|
startNode = startNode.merge({ characters })
|
|
state = state.updateNode(startNode)
|
|
return state
|
|
}
|
|
|
|
// Otherwise, remove the text from the first and last nodes...
|
|
const startRange = Selection.create({
|
|
anchorKey: startKey,
|
|
anchorOffset: startOffset,
|
|
focusKey: startKey,
|
|
focusOffset: startNode.length
|
|
})
|
|
|
|
const endRange = Selection.create({
|
|
anchorKey: endKey,
|
|
anchorOffset: 0,
|
|
focusKey: endKey,
|
|
focusOffset: endOffset
|
|
})
|
|
|
|
state = state.deleteAtRange(startRange)
|
|
state = state.deleteAtRange(endRange)
|
|
|
|
// Then remove any nodes in between the top-most start and end nodes...
|
|
let startParent = state.getParentNode(startKey)
|
|
let endParent = state.getParentNode(endKey)
|
|
|
|
const startGrandestParent = state.nodes.find((node) => {
|
|
return node == startParent || node.hasNode(startParent)
|
|
})
|
|
|
|
const endGrandestParent = state.nodes.find((node) => {
|
|
return node == endParent || node.hasNode(endParent)
|
|
})
|
|
|
|
const nodes = state.nodes
|
|
.takeUntil(node => node == startGrandestParent)
|
|
.set(startGrandestParent.key, startGrandestParent)
|
|
.concat(state.nodes.skipUntil(node => node == endGrandestParent))
|
|
|
|
state = state.merge({ nodes })
|
|
|
|
// Then bring the end text node into the start node.
|
|
let endText = state.getNode(endKey)
|
|
startParent = startParent.pushNode(endText)
|
|
endParent = endParent.removeNode(endText)
|
|
state = state.updateNode(startParent)
|
|
state = state.updateNode(endParent)
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Delete backward `n` characters at the current selection.
|
|
*
|
|
* @param {Number} n (optional)
|
|
* @return {State} state
|
|
*/
|
|
|
|
deleteBackward(n = 1) {
|
|
let state = this
|
|
let selection = state.selection
|
|
|
|
// Determine what the selection should be after deleting.
|
|
const startNode = state.startNode
|
|
|
|
if (state.isCollapsed && state.isAtStartOf(startNode)) {
|
|
const parent = state.getParentNode(startNode)
|
|
const previous = state.getPreviousNode(parent).nodes.first()
|
|
selection = selection.moveToEndOf(previous)
|
|
}
|
|
|
|
else if (state.isCollapsed && !state.isAtEndOf(state)) {
|
|
selection = selection.moveBackward(n)
|
|
}
|
|
|
|
// Delete backward and then update the selection.
|
|
state = state.deleteBackwardAtRange(state.selection)
|
|
state = state.merge({ selection })
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Delete backward `n` characters at a `range`.
|
|
*
|
|
* @param {Selection} range
|
|
* @param {Number} n (optional)
|
|
* @return {State} state
|
|
*/
|
|
|
|
deleteBackwardAtRange(range, n = 1) {
|
|
let state = this
|
|
|
|
// When collapsed at the end of the document, there's nothing to do.
|
|
if (range.isCollapsed && range.isAtEndOf(state)) return state
|
|
|
|
// When the range is still expanded, just do a regular delete.
|
|
if (range.isExpanded) return state.deleteAtRange(range)
|
|
|
|
// When at start of a text node, merge forwards into the next text node.
|
|
const { startKey } = range
|
|
const startNode = state.getNode(startKey)
|
|
|
|
if (range.isAtStartOf(startNode)) {
|
|
const parent = state.getParentNode(startNode)
|
|
const previous = state.getPreviousNode(parent).nodes.first()
|
|
range = range.extendBackwardToEndOf(previous)
|
|
state = state.deleteAtRange(range)
|
|
return state
|
|
}
|
|
|
|
// Otherwise, remove `n` characters behind of the cursor.
|
|
range = range.extendBackward(n)
|
|
state = state.deleteAtRange(range)
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Delete forward `n` characters at the current selection.
|
|
*
|
|
* @param {Number} n (optional)
|
|
* @return {State} state
|
|
*/
|
|
|
|
deleteForward(n = 1) {
|
|
let state = this
|
|
state = state.deleteForwardAtRange(state.selection)
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Delete forward `n` characters at a `range`.
|
|
*
|
|
* @param {Selection} range
|
|
* @param {Number} n (optional)
|
|
* @return {State} state
|
|
*/
|
|
|
|
deleteForwardAtRange(range, n = 1) {
|
|
let state = this
|
|
|
|
// When collapsed at the end of the document, there's nothing to do.
|
|
if (range.isCollapsed && range.isAtEndOf(state)) return state
|
|
|
|
// When the range is still expanded, just do a regular delete.
|
|
if (range.isExpanded) return state.deleteAtRange(range)
|
|
|
|
// When at end of a text node, merge forwards into the next text node.
|
|
const { startKey } = range
|
|
const startNode = state.getNode(startKey)
|
|
|
|
if (range.isAtEndOf(startNode)) {
|
|
const parent = state.getParentNode(startNode)
|
|
const next = state.getNextNode(parent).nodes.first()
|
|
range = range.extendForwardToStartOf(next)
|
|
state = state.deleteAtRange(range)
|
|
return state
|
|
}
|
|
|
|
// Otherwise, remove `n` characters ahead of the cursor.
|
|
range = range.extendForward(n)
|
|
state = state.deleteAtRange(range)
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Insert a `text` string at the current cursor position.
|
|
*
|
|
* @param {String or Node or OrderedMap} data
|
|
* @return {State} state
|
|
*/
|
|
|
|
insert(data) {
|
|
let state = this
|
|
state = state.insertAtRange(state.selection, data)
|
|
|
|
// When the data is a string of characters...
|
|
if (typeof data == 'string') {
|
|
state = state.moveForward(data.length)
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Insert `data` at a `range`.
|
|
*
|
|
* @param {Selection} range
|
|
* @param {String or Node or OrderedMap} data
|
|
* @return {State} state
|
|
*/
|
|
|
|
insertAtRange(range, data) {
|
|
let state = this
|
|
|
|
// When still expanded, remove the current range first.
|
|
if (range.isExpanded) {
|
|
state = state.deleteAtRange(range)
|
|
range = range.moveToStart()
|
|
}
|
|
|
|
// When the data is a string of characters...
|
|
if (typeof data == 'string') {
|
|
|
|
// Insert text at the current cursor.
|
|
const ranges = [{ text: data }]
|
|
let { startNode, startOffset } = state
|
|
let { characters } = startNode
|
|
let newCharacters = convertRangesToCharacters(ranges)
|
|
const { size } = newCharacters
|
|
|
|
// Splice in the new characters.
|
|
characters = characters.slice(0, startOffset)
|
|
.concat(newCharacters)
|
|
.concat(characters.slice(startOffset + size - 1, Infinity))
|
|
|
|
// Update the existing text node.
|
|
startNode = startNode.merge({ characters })
|
|
state = state.updateNode(startNode)
|
|
return state
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Normalize all nodes, ensuring that no two text nodes are adjacent.
|
|
*
|
|
* @return {State} state
|
|
*/
|
|
|
|
normalize() {
|
|
// TODO
|
|
}
|
|
|
|
/**
|
|
* Remove characters from a node by `key` between offsets.
|
|
*
|
|
* @param {String} key
|
|
* @param {Number} startOffset
|
|
* @param {Number} endOffset
|
|
* @return {State} state
|
|
*/
|
|
|
|
removeCharacters(key, startOffset, endOffset) {
|
|
let state = this
|
|
let node = state.getNode(key)
|
|
|
|
const characters = node.characters.filterNot((char, i) => {
|
|
return startOffset <= i && i < endOffset
|
|
})
|
|
|
|
node = node.merge({ characters })
|
|
state = state.updateNode(node)
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Split at a `selection`.
|
|
*
|
|
* @return {State} state
|
|
*/
|
|
|
|
split() {
|
|
let state = this
|
|
state = state.splitAtRange(state.selection)
|
|
|
|
const parent = state.getParentNode(state.startNode)
|
|
const next = state.getNextNode(parent)
|
|
const text = next.nodes.first()
|
|
state = state.moveToStartOf(text)
|
|
|
|
// const next = state.getNextTextNode(state.startNode)
|
|
// state = state.moveToStartOf(next)
|
|
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Split the nodes at a `range`.
|
|
*
|
|
* @param {Selection} range
|
|
* @return {State} state
|
|
*/
|
|
|
|
splitAtRange(range) {
|
|
let state = this
|
|
|
|
// If the range is expanded, remove it first.
|
|
if (range.isExpanded) {
|
|
state = state.deleteAtRange(range)
|
|
range = range.moveToStart()
|
|
}
|
|
|
|
const { startKey, startOffset } = range
|
|
const startNode = state.getNode(startKey)
|
|
|
|
// Split the text node's characters.
|
|
const { characters, length } = startNode
|
|
const firstCharacters = characters.take(startOffset)
|
|
const secondCharacters = characters.takeLast(length - startOffset)
|
|
|
|
// Create a new first node with only the first set of characters.
|
|
const parent = state.getParentNode(startNode)
|
|
const firstText = startNode.set('characters', firstCharacters)
|
|
const firstNode = parent.updateNode(firstText)
|
|
|
|
// Create a brand new second node with the second set of characters.
|
|
let secondText = Text.create({})
|
|
let secondNode = Node.create({
|
|
type: firstNode.type,
|
|
data: firstNode.data
|
|
})
|
|
|
|
secondText = secondText.set('characters', secondCharacters)
|
|
secondNode = secondNode.pushNode(secondText)
|
|
|
|
// Replace the old parent node in the grandparent with the two new ones.
|
|
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
|
|
.set(firstNode.key, firstNode)
|
|
.set(secondNode.key, secondNode)
|
|
.concat(afters)
|
|
|
|
// If the state is the grandparent, just merge, otherwise deep merge.
|
|
if (grandparent == state) {
|
|
state = state.merge({ nodes })
|
|
} else {
|
|
grandparent = grandparent.merge({ nodes })
|
|
state = state.updateNode(grandparent)
|
|
}
|
|
|
|
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.
|
|
*/
|
|
|
|
export default State
|