1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-02-25 01:33:37 +01:00
slate/lib/models/state.js

677 lines
15 KiB
JavaScript
Raw Normal View History

2016-06-15 12:07:12 -07:00
2016-06-17 18:52:23 -07:00
import Character from './character'
2016-06-15 19:46:53 -07:00
import Node from './node'
2016-06-17 18:52:23 -07:00
import Selection from './selection'
2016-06-16 16:43:02 -07:00
import Text from './text'
2016-06-15 12:07:12 -07:00
import toCamel from 'to-camel-case'
2016-06-17 16:10:44 -07:00
import { OrderedMap, Record, Stack } from 'immutable'
2016-06-15 12:07:12 -07:00
/**
* Record.
*/
const StateRecord = new Record({
2016-06-15 20:00:41 -07:00
nodes: new OrderedMap(),
2016-06-17 16:10:44 -07:00
selection: new Selection(),
undoStack: new Stack(),
redoStack: new Stack()
2016-06-15 12:07:12 -07:00
})
2016-06-17 00:09:54 -07:00
/**
* 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',
2016-06-17 13:34:29 -07:00
'moveBackward',
'extendForward',
'extendBackward'
2016-06-17 00:09:54 -07:00
]
2016-06-15 12:07:12 -07:00
/**
* State.
*/
class State extends StateRecord {
/**
2016-06-17 18:20:26 -07:00
* Create a new `State` with `properties`.
2016-06-15 12:07:12 -07:00
*
2016-06-17 18:20:26 -07:00
* @param {Objetc} properties
2016-06-15 12:07:12 -07:00
* @return {State} state
*/
2016-06-17 18:20:26 -07:00
static create(properties = {}) {
return new State(properties)
2016-06-15 12:07:12 -07:00
}
/**
2016-06-17 00:52:15 -07:00
* Node-like getters.
*/
2016-06-17 00:09:54 -07:00
get length() {
return this.text.length
}
2016-06-16 16:43:02 -07:00
get text() {
return this.nodes
.map(node => node.text)
.join('')
}
2016-06-17 00:52:15 -07:00
get type() {
return 'state'
}
2016-06-16 16:43:02 -07:00
/**
2016-06-17 00:52:15 -07:00
* Selection-like getters.
*/
2016-06-17 00:52:15 -07:00
get isCollapsed() {
return this.selection.isCollapsed
}
get isExpanded() {
return this.selection.isExpanded
}
get isExtended() {
return this.selection.isExtended
}
2016-06-17 00:09:54 -07:00
get anchorKey() {
return this.selection.anchorKey
2016-06-16 16:43:02 -07:00
}
2016-06-17 00:09:54 -07:00
get anchorOffset() {
return this.selection.anchorOffset
2016-06-16 16:43:02 -07:00
}
2016-06-17 00:09:54 -07:00
get focusKey() {
return this.selection.focusKey
}
2016-06-16 16:43:02 -07:00
2016-06-17 00:09:54 -07:00
get focusOffset() {
return this.selection.focusOffset
2016-06-16 16:43:02 -07:00
}
2016-06-17 00:09:54 -07:00
get startKey() {
return this.selection.startKey
}
2016-06-16 16:43:02 -07:00
2016-06-17 00:09:54 -07:00
get startOffset() {
return this.selection.startOffset
}
2016-06-17 00:09:54 -07:00
get endKey() {
return this.selection.endKey
}
2016-06-17 00:09:54 -07:00
get endOffset() {
return this.selection.endOffset
2016-06-15 19:46:53 -07:00
}
2016-06-16 16:43:02 -07:00
/**
2016-06-17 00:52:15 -07:00
* Get the current anchor node.
2016-06-16 16:43:02 -07:00
*
* @return {Node} node
*/
2016-06-17 00:09:54 -07:00
get anchorNode() {
return this.getNode(this.anchorKey)
2016-06-16 16:43:02 -07:00
}
/**
2016-06-17 00:52:15 -07:00
* Get the current focus node.
2016-06-16 16:43:02 -07:00
*
* @return {Node} node
*/
2016-06-17 00:09:54 -07:00
get focusNode() {
return this.getNode(this.focusKey)
2016-06-16 16:43:02 -07:00
}
/**
2016-06-17 00:52:15 -07:00
* Get the current start node.
*
2016-06-17 00:09:54 -07:00
* @return {Node} node
*/
2016-06-17 00:09:54 -07:00
get startNode() {
return this.getNode(this.startKey)
}
/**
2016-06-17 00:52:15 -07:00
* Get the current end node.
*
2016-06-17 00:09:54 -07:00
* @return {Node} node
*/
2016-06-17 00:09:54 -07:00
get endNode() {
return this.getNode(this.endKey)
}
/**
2016-06-17 00:09:54 -07:00
* Is the selection at the start of `node`?
*
2016-06-17 00:09:54 -07:00
* @param {Node} node
* @return {Boolean} isAtStart
*/
2016-06-17 00:09:54 -07:00
isAtStartOf(node) {
return this.selection.isAtStartOf(node)
}
2016-06-16 12:21:39 -07:00
2016-06-17 00:09:54 -07:00
/**
* Is the selection at the end of `node`?
*
* @param {Node} node
* @return {Boolean} isAtEnd
*/
2016-06-16 12:21:39 -07:00
2016-06-17 00:09:54 -07:00
isAtEndOf(node) {
return this.selection.isAtEndOf(node)
}
/**
2016-06-17 13:34:29 -07:00
* 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`.
*
2016-06-17 13:34:29 -07:00
* @param {Selection} range
* @return {State} state
*/
2016-06-17 13:34:29 -07:00
deleteAtRange(range) {
2016-06-17 00:09:54 -07:00
let state = this
2016-06-16 12:21:39 -07:00
2016-06-17 13:34:29 -07:00
// 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)
2016-06-17 00:09:54 -07:00
return state
}
2016-06-16 12:21:39 -07:00
2016-06-17 13:34:29 -07:00
// 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
*/
2016-06-17 00:09:54 -07:00
2016-06-17 13:34:29 -07:00
deleteBackward(n = 1) {
let state = this
let selection = state.selection
// Determine what the selection should be after deleting.
const startNode = state.startNode
2016-06-17 18:52:23 -07:00
if (state.isExpanded) {
selection = selection.moveToStart()
}
else if (state.isAtStartOf(startNode)) {
2016-06-17 00:09:54 -07:00
const parent = state.getParentNode(startNode)
const previous = state.getPreviousNode(parent).nodes.first()
2016-06-17 13:34:29 -07:00
selection = selection.moveToEndOf(previous)
}
2016-06-17 00:34:27 -07:00
2016-06-17 18:52:23 -07:00
else if (!state.isAtEndOf(state)) {
2016-06-17 13:34:29 -07:00
selection = selection.moveBackward(n)
2016-06-17 00:09:54 -07:00
}
2016-06-17 13:34:29 -07:00
// Delete backward and then update the selection.
state = state.deleteBackwardAtRange(state.selection)
state = state.merge({ selection })
2016-06-17 00:09:54 -07:00
return state
}
2016-06-15 12:07:12 -07:00
/**
2016-06-17 13:34:29 -07:00
* Delete backward `n` characters at a `range`.
2016-06-15 12:07:12 -07:00
*
2016-06-17 13:34:29 -07:00
* @param {Selection} range
* @param {Number} n (optional)
2016-06-15 12:07:12 -07:00
* @return {State} state
*/
2016-06-17 13:34:29 -07:00
deleteBackwardAtRange(range, n = 1) {
2016-06-17 00:09:54 -07:00
let state = this
2016-06-16 16:43:02 -07:00
2016-06-17 13:34:29 -07:00
// 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)
2016-06-17 00:09:54 -07:00
return state
}
2016-06-15 12:07:12 -07:00
2016-06-17 13:34:29 -07:00
// 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
*/
2016-06-15 12:07:12 -07:00
2016-06-17 13:34:29 -07:00
deleteForward(n = 1) {
let state = this
2016-06-17 18:52:23 -07:00
let selection = state.selection
// Determine what the selection should be after deleting.
if (state.isExpanded) {
selection = selection.moveToStart()
}
// Delete forward and then update the selection.
2016-06-17 13:34:29 -07:00
state = state.deleteForwardAtRange(state.selection)
2016-06-17 18:52:23 -07:00
state = state.merge({ selection })
2016-06-17 13:34:29 -07:00
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)) {
2016-06-17 00:52:15 -07:00
const parent = state.getParentNode(startNode)
const next = state.getNextNode(parent).nodes.first()
2016-06-17 13:34:29 -07:00
range = range.extendForwardToStartOf(next)
state = state.deleteAtRange(range)
2016-06-17 00:52:15 -07:00
return state
}
2016-06-17 13:34:29 -07:00
// Otherwise, remove `n` characters ahead of the cursor.
range = range.extendForward(n)
state = state.deleteAtRange(range)
2016-06-17 12:00:15 -07:00
return state
}
/**
* Insert a `text` string at the current cursor position.
*
2016-06-17 13:34:29 -07:00
* @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
2016-06-17 12:00:15 -07:00
* @return {State} state
*/
2016-06-17 13:34:29 -07:00
insertAtRange(range, data) {
2016-06-17 12:00:15 -07:00
let state = this
// When still expanded, remove the current range first.
2016-06-17 13:34:29 -07:00
if (range.isExpanded) {
state = state.deleteAtRange(range)
range = range.moveToStart()
}
// When the data is a string of characters...
if (typeof data == 'string') {
let { startNode, startOffset } = state
let { characters } = startNode
2016-06-17 18:52:23 -07:00
// Create a list of the new characters, with the right marks.
2016-06-17 19:57:37 -07:00
const marks = characters.has(startOffset)
? characters.get(startOffset).marks
: null
2016-06-17 18:52:23 -07:00
const newCharacters = data.split('').reduce((list, char) => {
2016-06-17 19:57:37 -07:00
const obj = { text: char }
if (marks) obj.marks = marks
return list.push(Character.create(obj))
2016-06-17 18:52:23 -07:00
}, Character.createList())
2016-06-17 13:34:29 -07:00
// Splice in the new characters.
2016-06-17 18:52:23 -07:00
const resumeOffset = startOffset + data.length - 1
2016-06-17 13:34:29 -07:00
characters = characters.slice(0, startOffset)
.concat(newCharacters)
2016-06-17 18:52:23 -07:00
.concat(characters.slice(resumeOffset, Infinity))
2016-06-17 13:34:29 -07:00
// Update the existing text node.
startNode = startNode.merge({ characters })
state = state.updateNode(startNode)
return state
2016-06-17 12:00:15 -07:00
}
2016-06-17 00:09:54 -07:00
return state
2016-06-16 16:43:02 -07:00
}
/**
* Normalize all nodes, ensuring that no two text nodes are adjacent.
*
* @return {State} state
*/
normalize() {
// TODO
}
2016-06-16 16:43:02 -07:00
/**
* Split at a `selection`.
*
* @return {State} state
*/
split() {
2016-06-17 00:09:54 -07:00
let state = this
2016-06-17 13:34:29 -07:00
state = state.splitAtRange(state.selection)
2016-06-17 00:34:27 -07:00
2016-06-17 13:34:29 -07:00
const parent = state.getParentNode(state.startNode)
2016-06-17 00:09:54 -07:00
const next = state.getNextNode(parent)
const text = next.nodes.first()
2016-06-17 00:34:27 -07:00
state = state.moveToStartOf(text)
2016-06-17 13:34:29 -07:00
// const next = state.getNextTextNode(state.startNode)
// state = state.moveToStartOf(next)
2016-06-17 00:34:27 -07:00
return state
2016-06-16 16:43:02 -07:00
}
/**
2016-06-17 13:34:29 -07:00
* Split the nodes at a `range`.
2016-06-16 16:43:02 -07:00
*
2016-06-17 13:34:29 -07:00
* @param {Selection} range
2016-06-16 16:43:02 -07:00
* @return {State} state
*/
2016-06-17 13:34:29 -07:00
splitAtRange(range) {
2016-06-16 16:43:02 -07:00
let state = this
2016-06-17 13:34:29 -07:00
// If the range is expanded, remove it first.
if (range.isExpanded) {
state = state.deleteAtRange(range)
range = range.moveToStart()
2016-06-16 16:43:02 -07:00
}
2016-06-17 13:34:29 -07:00
const { startKey, startOffset } = range
const startNode = state.getNode(startKey)
2016-06-17 00:52:15 -07:00
// Split the text node's characters.
2016-06-17 13:34:29 -07:00
const { characters, length } = startNode
2016-06-16 16:43:02 -07:00
const firstCharacters = characters.take(startOffset)
const secondCharacters = characters.takeLast(length - startOffset)
// Create a new first node with only the first set of characters.
2016-06-17 13:34:29 -07:00
const parent = state.getParentNode(startNode)
2016-06-17 00:34:27 -07:00
const firstText = startNode.set('characters', firstCharacters)
2016-06-17 00:09:54 -07:00
const firstNode = parent.updateNode(firstText)
2016-06-16 16:43:02 -07:00
// 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
})
2016-06-17 00:34:27 -07:00
secondText = secondText.set('characters', secondCharacters)
2016-06-16 16:43:02 -07:00
secondNode = secondNode.pushNode(secondText)
// Replace the old parent node in the grandparent with the two new ones.
2016-06-17 00:09:54 -07:00
let grandparent = state.getParentNode(parent)
2016-06-16 16:43:02 -07:00
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)
2016-06-17 13:34:29 -07:00
// If the state is the grandparent, just merge, otherwise deep merge.
2016-06-16 16:43:02 -07:00
if (grandparent == state) {
state = state.merge({ nodes })
} else {
grandparent = grandparent.merge({ nodes })
2016-06-17 00:09:54 -07:00
state = state.updateNode(grandparent)
2016-06-16 16:43:02 -07:00
}
2016-06-15 12:07:12 -07:00
return state
}
2016-06-17 16:10:44 -07:00
/**
* Save the current state into the history.
*
* @return {State} state
*/
save() {
let state = this
let { undoStack, redoStack } = state
undoStack = undoStack.unshift(state)
redoStack = redoStack.clear()
state = state.merge({
undoStack,
redoStack
})
return state
}
/**
* Undo.
*
* @return {State} state
*/
undo() {
let state = this
let { undoStack, redoStack } = state
// If there's no previous state, do nothing.
let previous = undoStack.peek()
if (!previous) return state
// Remove the previous state from the undo stack.
undoStack = undoStack.shift()
// Move the current state into the redo stack.
redoStack = redoStack.unshift(state)
// Return the previous state, with the new history.
return previous.merge({
undoStack,
redoStack
})
}
/**
* Redo.
*
* @return {State} state
*/
redo() {
let state = this
let { undoStack, redoStack } = state
// If there's no next state, do nothing.
let next = redoStack.peek()
if (!next) return state
// Remove the next state from the redo stack.
redoStack = redoStack.shift()
// Move the current state into the undo stack.
undoStack = undoStack.unshift(state)
// Return the next state, with the new history.
return next.merge({
undoStack,
redoStack
})
}
2016-06-15 12:07:12 -07:00
}
2016-06-17 00:09:54 -07:00
/**
* 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 })
}
})
2016-06-15 12:07:12 -07:00
/**
* Export.
*/
export default State