substance
Version:
Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing systems.
1,085 lines (1,044 loc) • 37.5 kB
JavaScript
import { isArrayEqual, isString, last, uuid } from '../util'
import annotationHelpers from './annotationHelpers'
import documentHelpers from './documentHelpers'
import { setCursor, isEntirelySelected, selectNode } from './selectionHelpers'
import paste from './paste'
/**
Core editing implementation, that controls meta behavior
such as deleting a selection, merging nodes, etc.
Some of the implementation are then delegated to specific editing behaviors,
such as manipulating content of a text-property, merging or breaking text nodes
Note: this is pretty much the same what we did with transforms before.
We decided to move this here, to switch to a stateful editor implementation (aka turtle-graphics-style)
*/
class Editing {
// create an annotation for the current selection using the given data
annotate(tx, annotation) {
let sel = tx.selection
let schema = tx.getSchema()
let AnnotationClass = schema.getNodeClass(annotation.type)
if (!AnnotationClass) throw new Error('Unknown annotation type', annotation)
let start = sel.start
let end = sel.end
let containerId = sel.containerId
let nodeData = { start, end, containerId }
// TODO: we need to generalize how node category can be derived statically
/* istanbul ignore else */
if (sel.isPropertySelection()) {
if (!AnnotationClass.prototype._isAnnotation) {
throw new Error('Annotation can not be created for a selection.')
}
} else if (sel.isContainerSelection()) {
if (AnnotationClass.prototype._isPropertyAnnotation) {
console.warn('NOT SUPPORTED YET: creating property annotations for a non collapsed container selection.')
}
}
Object.assign(nodeData, annotation)
return tx.create(nodeData)
}
break(tx) {
let sel = tx.selection
if (sel.isNodeSelection()) {
let containerId = sel.containerId
let container = tx.get(containerId)
let nodeId = sel.getNodeId()
let nodePos = container.getPosition(nodeId, 'strict')
let textNode = tx.createDefaultTextNode()
if (sel.isBefore()) {
container.showAt(nodePos, textNode.id)
// leave selection as is
} else {
container.showAt(nodePos+1, textNode.id)
setCursor(tx, textNode, containerId, 'before')
}
}
else if (sel.isCustomSelection()) {
// TODO: what to do with custom selections?
}
else if (sel.isCollapsed() || sel.isPropertySelection()) {
let containerId = sel.containerId
if (!sel.isCollapsed()) {
// delete the selection
this._deletePropertySelection(tx, sel)
tx.setSelection(sel.collapse('left'))
}
// then break the node
if (containerId) {
let container = tx.get(containerId)
let nodeId = sel.start.path[0]
let node = tx.get(nodeId)
this._breakNode(tx, node, sel.start, container)
}
}
else if (sel.isContainerSelection()) {
let start = sel.start
let containerId = sel.containerId
let container = tx.get(containerId)
let startNodeId = start.path[0]
let nodePos = container.getPosition(startNodeId, 'strict')
this._deleteContainerSelection(tx, sel, {noMerge: true })
setCursor(tx, container.getNodeAt(nodePos+1), containerId, 'before')
}
}
delete(tx, direction) {
let sel = tx.selection
// special implementation for node selections:
// either delete the node (replacing with an empty text node)
// or just move the cursor
/* istanbul ignore else */
if (sel.isNodeSelection()) {
this._deleteNodeSelection(tx, sel, direction)
}
// TODO: what to do with custom selections?
else if (sel.isCustomSelection()) {}
// if the selection is collapsed this is the classical one-character deletion
// either backwards (backspace) or forward (delete)
else if (sel.isCollapsed()) {
// Deletion of a single character leads to a merge
// if cursor is at a text boundary (TextNode, ListItem)
// and direction is towards that boundary
let path = sel.start.path
let node = tx.get(path[0])
let text = tx.get(path)
let offset = sel.start.offset
let needsMerge = (sel.containerId && (
(offset === 0 && direction === 'left') ||
(offset === text.length && direction === 'right')
))
if (needsMerge) {
// ATTENTION: deviation from standard implementation
// for list items: Word and GDoc toggle a list item
// when doing a BACKSPACE at the first position
let root = node.getContainerRoot()
if (root.isList() && offset === 0 && direction === 'left') {
return this.toggleList(tx)
} else {
let container = tx.get(sel.containerId)
this._merge(tx, root, sel.start, direction, container)
}
} else {
// if we are not in a merge scenario, we stop at the boundaries
if ((offset === 0 && direction === 'left') ||
(offset === text.length && direction === 'right')) {
return
}
let startOffset = (direction === 'left') ? offset-1 : offset
let endOffset = startOffset+1
let start = { path: path, offset: startOffset }
let end = { path: path, offset: endOffset }
documentHelpers.deleteTextRange(tx, start, end)
tx.setSelection({
type: 'property',
path: path,
startOffset: startOffset,
containerId: sel.containerId
})
}
}
// deleting a range of characters within a text property
else if (sel.isPropertySelection()) {
documentHelpers.deleteTextRange(tx, sel.start, sel.end)
tx.setSelection(sel.collapse('left'))
}
// deleting a range within a container (across multiple nodes)
else if (sel.isContainerSelection()) {
this._deleteContainerSelection(tx, sel)
}
else {
console.warn('Unsupported case: tx.delete(%)', direction, sel)
}
}
_deleteNodeSelection(tx, sel, direction) {
let nodeId = sel.getNodeId()
let container = tx.get(sel.containerId)
let nodePos = container.getPosition(nodeId, 'strict')
if (sel.isFull() ||
sel.isBefore() && direction === 'right' ||
sel.isAfter() && direction === 'left' ) {
// replace the node with default text node
container.hideAt(nodePos)
documentHelpers.deleteNode(tx, tx.get(nodeId))
let newNode = tx.createDefaultTextNode()
container.showAt(nodePos, newNode.id)
tx.setSelection({
type: 'property',
path: newNode.getTextPath(),
startOffset: 0,
containerId: container.id,
})
} else {
/* istanbul ignore else */
if (sel.isBefore() && direction === 'left') {
if (nodePos > 0) {
let previous = container.getNodeAt(nodePos-1)
if (previous.isText()) {
tx.setSelection({
type: 'property',
path: previous.getTextPath(),
startOffset: previous.getLength()
})
this.delete(tx, direction)
} else {
tx.setSelection({
type: 'node',
nodeId: previous.id,
containerId: container.id
})
}
} else {
// nothing to do
}
} else if (sel.isAfter() && direction === 'right') {
if (nodePos < container.getLength()-1) {
let next = container.getNodeAt(nodePos+1)
if (next.isText()) {
tx.setSelection({
type: 'property',
path: next.getTextPath(),
startOffset: 0
})
this.delete(tx, direction)
} else {
tx.setSelection({
type: 'node',
nodeId: next.id,
containerId: container.id
})
}
} else {
// nothing to do
}
} else {
console.warn('Unsupported case: delete(%s)', direction, sel)
}
}
}
_deletePropertySelection(tx, sel) {
let path = sel.start.path
let start = sel.start.offset
let end = sel.end.offset
tx.update(path, { type: 'delete', start: start, end: end })
annotationHelpers.deletedText(tx, path, start, end)
}
// deletes all inner nodes and 'truncates' start and end node
_deleteContainerSelection(tx, sel, options = {}) {
let containerId = sel.containerId
let container = tx.get(containerId)
let start = sel.start
let end = sel.end
let startId = start.getNodeId()
let endId = end.getNodeId()
let startPos = container.getPosition(startId, 'strict')
let endPos = container.getPosition(endId, 'strict')
// special case: selection within one node
if (startPos === endPos) {
// ATTENTION: we need the root node here e.g. the list, not the list-item
let node = tx.get(startId).getContainerRoot()
/* istanbul ignore else */
if (node.isText()) {
documentHelpers.deleteTextRange(tx, start, end)
} else if (node.isList()) {
documentHelpers.deleteListRange(tx, node, start, end)
} else {
throw new Error('Not supported yet.')
}
tx.setSelection(sel.collapse('left'))
return
}
// TODO: document the algorithm
let firstNode = tx.get(start.getNodeId())
let lastNode = tx.get(end.getNodeId())
let firstEntirelySelected = isEntirelySelected(tx, firstNode, start, null)
let lastEntirelySelected = isEntirelySelected(tx, lastNode, null, end)
// delete or truncate last node
if (lastEntirelySelected) {
container.hideAt(endPos)
documentHelpers.deleteNode(tx, lastNode)
} else {
// ATTENTION: we need the root node here e.g. the list, not the list-item
let node = lastNode.getContainerRoot()
/* istanbul ignore else */
if (node.isText()) {
documentHelpers.deleteTextRange(tx, null, end)
} else if (node.isList()) {
documentHelpers.deleteListRange(tx, node, null, end)
} else {
// IsolatedNodes can not be selected partially
}
}
// delete inner nodes
for (let i = endPos-1; i > startPos; i--) {
let nodeId = container.getNodeIdAt(i)
container.hideAt(i)
documentHelpers.deleteNode(tx, tx.get(nodeId))
}
// delete or truncate the first node
if (firstEntirelySelected) {
container.hideAt(startPos)
documentHelpers.deleteNode(tx, firstNode)
} else {
// ATTENTION: we need the root node here e.g. the list, not the list-item
let node = firstNode.getContainerRoot()
/* istanbul ignore else */
if (node.isText()) {
documentHelpers.deleteTextRange(tx, start, null)
} else if (node.isList()) {
documentHelpers.deleteListRange(tx, node, start, null)
} else {
// IsolatedNodes can not be selected partially
}
}
// insert a new TextNode if all has been deleted
if (firstEntirelySelected && lastEntirelySelected) {
// insert a new paragraph
let textNode = tx.createDefaultTextNode()
container.showAt(startPos, textNode.id)
tx.setSelection({
type: 'property',
path: textNode.getTextPath(),
startOffset: 0,
containerId: containerId
})
} else if (!firstEntirelySelected && !lastEntirelySelected) {
if (!options.noMerge) {
this._merge(tx, firstNode, sel.start, 'right', container)
}
tx.setSelection(sel.collapse('left'))
} else if (firstEntirelySelected) {
setCursor(tx, lastNode, container.id, 'before')
} else {
setCursor(tx, firstNode, container.id, 'after')
}
}
insertInlineNode(tx, nodeData) {
let sel = tx.selection
let text = "\uFEFF"
this.insertText(tx, text)
sel = tx.selection
let endOffset = tx.selection.end.offset
let startOffset = endOffset - text.length
nodeData = Object.assign({}, nodeData, {
start: {
path: sel.path,
offset: startOffset
},
end: {
path: sel.path,
offset: endOffset
}
})
return tx.create(nodeData)
}
insertBlockNode(tx, nodeData) {
let sel = tx.selection
// don't create the node if it already exists
let blockNode
if (!nodeData._isNode || !tx.get(nodeData.id)) {
blockNode = tx.create(nodeData)
} else {
blockNode = tx.get(nodeData.id)
}
/* istanbul ignore else */
if (sel.isNodeSelection()) {
let containerId = sel.containerId
let container = tx.get(containerId)
let nodeId = sel.getNodeId()
let nodePos = container.getPosition(nodeId, 'strict')
// insert before
if (sel.isBefore()) {
container.showAt(nodePos, blockNode.id)
}
// insert after
else if (sel.isAfter()) {
container.showAt(nodePos+1, blockNode.id)
tx.setSelection({
type: 'node',
containerId: containerId,
nodeId: blockNode.id,
mode: 'after'
})
} else {
container.hideAt(nodePos)
documentHelpers.deleteNode(tx, tx.get(nodeId))
container.showAt(nodePos, blockNode.id)
tx.setSelection({
type: 'node',
containerId: containerId,
nodeId: blockNode.id,
mode: 'after'
})
}
} else if (sel.isPropertySelection()) {
/* istanbul ignore next */
if (!sel.containerId) throw new Error('insertBlockNode can only be used within a container.')
let container = tx.get(sel.containerId)
if (!sel.isCollapsed()) {
this._deletePropertySelection(tx, sel)
tx.setSelection(sel.collapse('left'))
}
let node = tx.get(sel.path[0])
/* istanbul ignore next */
if (!node) throw new Error('Invalid selection.')
let nodePos = container.getPosition(node.id, 'strict')
/* istanbul ignore else */
if (node.isText()) {
let text = node.getText()
// replace node
if (text.length === 0) {
container.hideAt(nodePos)
documentHelpers.deleteNode(tx, node)
container.showAt(nodePos, blockNode.id)
setCursor(tx, blockNode, container.id, 'after')
}
// insert before
else if (sel.start.offset === 0) {
container.showAt(nodePos, blockNode.id)
}
// insert after
else if (sel.start.offset === text.length) {
container.showAt(nodePos+1, blockNode.id)
setCursor(tx, blockNode, container.id, 'before')
}
// break
else {
this.break(tx)
container.showAt(nodePos+1, blockNode.id)
setCursor(tx, blockNode, container.id, 'after')
}
} else {
console.error('Not supported: insertBlockNode() on a custom node')
}
} else if (sel.isContainerSelection()) {
if (sel.isCollapsed()) {
let start = sel.start
/* istanbul ignore else */
if (start.isPropertyCoordinate()) {
tx.setSelection({
type: 'property',
path: start.path,
startOffset: start.offset,
containerId: sel.containerId,
})
} else if (start.isNodeCoordinate()) {
tx.setSelection({
type: 'node',
containerId: sel.containerId,
nodeId: start.path[0],
mode: start.offset === 0 ? 'before' : 'after',
})
} else {
throw new Error('Unsupported selection for insertBlockNode')
}
return this.insertBlockNode(tx, blockNode)
} else {
this.break(tx)
return this.insertBlockNode(tx, blockNode)
}
}
return blockNode
}
insertText(tx, text) {
let sel = tx.selection
// type over a selected node or insert a paragraph before
// or after
/* istanbul ignore else */
if (sel.isNodeSelection()) {
let containerId = sel.containerId
let container = tx.get(containerId)
let nodeId = sel.getNodeId()
let nodePos = container.getPosition(nodeId, 'strict')
let textNode = tx.createDefaultTextNode(text)
if (sel.isBefore()) {
container.showAt(nodePos, textNode)
} else if (sel.isAfter()) {
container.showAt(nodePos+1, textNode)
} else {
container.hide(nodeId)
documentHelpers.deleteNode(tx, tx.get(nodeId))
container.showAt(nodePos, textNode)
}
setCursor(tx, textNode, sel.containerId, 'after')
} else if (sel.isCustomSelection()) {
// TODO: what to do with custom selections?
} else if (sel.isCollapsed() || sel.isPropertySelection()) {
// console.log('#### before', sel.toString())
this._insertText(tx, sel, text)
// console.log('### setting selection after typing: ', tx.selection.toString())
} else if (sel.isContainerSelection()) {
this._deleteContainerSelection(tx, sel)
this.insertText(tx, text)
}
}
paste(tx, content) {
if (!content) return
/* istanbul ignore else */
if (isString(content)) {
paste(tx, {text: content})
} else if (content._isDocument) {
paste(tx, {doc: content})
} else {
throw new Error('Illegal content for paste.')
}
}
/**
Switch text type for a given node. E.g. from `paragraph` to `heading`.
@param {Object} args object with `selection`, `containerId` and `data` with new node data
@return {Object} object with updated `selection`
@example
```js
switchTextType(tx, {
selection: bodyEditor.getSelection(),
containerId: bodyEditor.getContainerId(),
data: {
type: 'heading',
level: 2
}
})
```
*/
switchTextType(tx, data) {
let sel = tx.selection
/* istanbul ignore next */
if (!sel.isPropertySelection()) {
throw new Error("Selection must be a PropertySelection.")
}
let containerId = sel.containerId
/* istanbul ignore next */
if (!containerId) {
throw new Error("Selection must be within a container.")
}
let path = sel.path
let nodeId = path[0]
let node = tx.get(nodeId)
/* istanbul ignore next */
if (!(node.isInstanceOf('text'))) {
throw new Error('Trying to use switchTextType on a non text node.')
}
// create a new node and transfer annotations
let newNode = Object.assign({
id: uuid(data.type),
type: data.type,
content: node.content,
direction: node.direction
}, data)
let newPath = [newNode.id, 'content']
newNode = tx.create(newNode)
annotationHelpers.transferAnnotations(tx, path, 0, newPath, 0)
// hide and delete the old one, show the new node
let container = tx.get(sel.containerId)
let pos = container.getPosition(nodeId, 'strict')
container.hide(nodeId)
documentHelpers.deleteNode(tx, node)
container.showAt(pos, newNode.id)
tx.setSelection({
type: 'property',
path: newPath,
startOffset: sel.start.offset,
endOffset: sel.end.offset,
containerId: containerId
})
return newNode
}
toggleList(tx, params) {
let sel = tx.selection
let container = tx.get(sel.containerId)
/* istanbul ignore next */
if (!container) {
throw new Error("Selection must be within a container.")
}
if (sel.isPropertySelection()) {
let nodeId = sel.start.path[0]
// ATTENTION: we need the root node here e.g. the list, not the list-item
let node = tx.get(nodeId).getContainerRoot()
let nodePos = container.getPosition(node.id, 'strict')
/* istanbul ignore else */
if (node.isText()) {
container.hideAt(nodePos)
// TODO: what if this should create a different list-item type?
let newItem = tx.create({
type: 'list-item',
content: node.getText(),
})
annotationHelpers.transferAnnotations(tx, node.getTextPath(), 0, newItem.getTextPath(), 0)
let newList = tx.create(Object.assign({
type: 'list',
items: [newItem.id]
}, params))
documentHelpers.deleteNode(tx, node)
container.showAt(nodePos, newList.id)
tx.setSelection({
type: 'property',
path: newItem.getTextPath(),
startOffset: sel.start.offset,
containerId: sel.containerId
})
} else if (node.isList()) {
let itemId = sel.start.path[0]
let itemPos = node.getItemPosition(itemId)
let item = node.getItemAt(itemPos)
let newTextNode = tx.createDefaultTextNode(item.getText())
annotationHelpers.transferAnnotations(tx, item.getTextPath(), 0, newTextNode.getTextPath(), 0)
// take the item out of the list
node.removeItemAt(itemPos)
if (node.isEmpty()) {
container.hideAt(nodePos)
documentHelpers.deleteNode(tx, node)
container.showAt(nodePos, newTextNode.id)
} else if (itemPos === 0) {
container.showAt(nodePos, newTextNode.id)
} else if (node.getLength() <= itemPos){
container.showAt(nodePos+1, newTextNode.id)
} else {
//split the
let tail = []
const items = node.items.slice()
const L = items.length
for (let i = L-1; i >= itemPos; i--) {
tail.unshift(items[i])
node.removeItemAt(i)
}
let newList = tx.create({
type: 'list',
items: tail,
ordered: node.ordered
})
container.showAt(nodePos+1, newTextNode.id)
container.showAt(nodePos+2, newList.id)
}
tx.setSelection({
type: 'property',
path: newTextNode.getTextPath(),
startOffset: sel.start.offset,
containerId: sel.containerId
})
} else {
// unsupported node type
}
} else if (sel.isContainerSelection()) {
console.error('TODO: support toggleList with ContainerSelection')
}
}
indent(tx) {
let sel = tx.selection
if (sel.isPropertySelection()) {
let nodeId = sel.start.getNodeId()
// ATTENTION: we need the root node here, e.g. the list, not the list items
let node = tx.get(nodeId).getContainerRoot()
if (node.isList()) {
let itemId = sel.start.path[0]
let item = tx.get(itemId)
// Note: allowing only 3 levels
if (item && item.level<3) {
tx.set([itemId, 'level'], item.level+1)
}
}
} else if (sel.isContainerSelection()) {
console.error('TODO: support toggleList with ContainerSelection')
}
}
dedent(tx) {
let sel = tx.selection
if (sel.isPropertySelection()) {
let nodeId = sel.start.getNodeId()
// ATTENTION: we need the root node here, e.g. the list, not the list items
let node = tx.get(nodeId).getContainerRoot()
if (node.isList()) {
let itemId = sel.start.path[0]
let item = tx.get(itemId)
if (item && item.level>1) {
tx.set([itemId, 'level'], item.level-1)
}
}
} else if (sel.isContainerSelection()) {
console.error('TODO: support toggleList with ContainerSelection')
}
}
/*
<-->: anno
|--|: area of change
I: <--> |--| : nothing
II: |--| <--> : move both by total span+L
III: |-<-->-| : delete anno
IV: |-<-|-> : move start by diff to start+L, and end by total span+L
V: <-|->-| : move end by diff to start+L
VI: <-->|--| : noting if !anno.autoExpandRight
VII: <-|--|-> : move end by total span+L
*/
_insertText(tx, sel, text) {
let start = sel.start
let end = sel.end
/* istanbul ignore next */
if (!isArrayEqual(start.path, end.path)) {
throw new Error('Unsupported state: range should be on one property')
}
let path = start.path
let startOffset = start.offset
let endOffset = end.offset
let typeover = !sel.isCollapsed()
let L = text.length
// delete selected text
if (typeover) {
tx.update(path, { type: 'delete', start: startOffset, end: endOffset })
}
// insert new text
tx.update(path, { type: 'insert', start: startOffset, text: text })
// update annotations
let annos = tx.getAnnotations(path)
annos.forEach(function(anno) {
let annoStart = anno.start.offset
let annoEnd = anno.end.offset
/* istanbul ignore else */
// I anno is before
if (annoEnd<startOffset) {
return
}
// II anno is after
else if (annoStart>=endOffset) {
tx.update([anno.id, 'start'], { type: 'shift', value: startOffset-endOffset+L })
tx.update([anno.id, 'end'], { type: 'shift', value: startOffset-endOffset+L })
}
// III anno is deleted
// NOTE: InlineNodes only have a length of one character
// so they are always 'covered', and as they can not expand
// they are deleted
else if (
(annoStart>=startOffset && annoEnd<endOffset) ||
(anno._isInlineNode && annoStart>=startOffset && annoEnd<=endOffset)
) {
tx.delete(anno.id)
}
// IV anno.start between and anno.end after
else if (annoStart>=startOffset && annoEnd>=endOffset) {
// do not move start if typing over
if (annoStart>startOffset || !typeover) {
tx.update([anno.id, 'start'], { type: 'shift', value: startOffset-annoStart+L })
}
tx.update([anno.id, 'end'], { type: 'shift', value: startOffset-endOffset+L })
}
// V anno.start before and anno.end between
else if (annoStart<startOffset && annoEnd<endOffset) {
// NOTE: here the anno gets expanded (that's the common way)
tx.update([anno.id, 'end'], { type: 'shift', value: startOffset-annoEnd+L })
}
// VI
else if (annoEnd === startOffset && !anno.constructor.autoExpandRight) {
// skip
}
// VII anno.start before and anno.end after
else if (annoStart<startOffset && annoEnd>=endOffset) {
if (anno._isInlineNode) {
// skip
} else {
tx.update([anno.id, 'end'], { type: 'shift', value: startOffset-endOffset+L })
}
}
else {
console.warn('TODO: handle annotation update case.')
}
})
let offset = startOffset + text.length
tx.setSelection({
type: 'property',
path: start.path,
startOffset: offset,
containerId: sel.containerId,
surfaceId: sel.surfaceId
})
}
_breakNode(tx, node, coor, container) {
// ATTENTION: we need the root here, e.g. a list, not the list-item
node = node.getContainerRoot()
/* istanbul ignore else */
if (node.isText()) {
this._breakTextNode(tx, node, coor, container)
} else if (node.isList()) {
this._breakListNode(tx, node, coor, container)
} else {
throw new Error('Not supported')
}
}
_breakTextNode(tx, node, coor, container) {
let path = coor.path
let offset = coor.offset
let nodePos = container.getPosition(node.id, 'strict')
let text = node.getText()
// when breaking at the first position, a new node of the same
// type will be inserted.
if (offset === 0) {
let newNode = tx.create({
type: node.type,
content: ""
})
// show the new node
container.showAt(nodePos, newNode.id)
tx.setSelection({
type: 'property',
path: path,
startOffset: 0,
containerId: container.id
})
}
// otherwise split the text property and create a new paragraph node with trailing text and annotations transferred
else {
let newNode = node.toJSON()
delete newNode.id
newNode.content = text.substring(offset)
// if at the end insert a default text node no matter in which text node we are
if (offset === text.length) {
newNode.type = tx.getSchema().getDefaultTextType()
}
newNode = tx.create(newNode)
// Now we need to transfer annotations
if (offset < text.length) {
// transfer annotations which are after offset to the new node
annotationHelpers.transferAnnotations(tx, path, offset, newNode.getTextPath(), 0)
// truncate the original property
tx.update(path, { type: 'delete', start: offset, end: text.length })
}
// show the new node
container.showAt(nodePos+1, newNode.id)
// update the selection
tx.setSelection({
type: 'property',
path: newNode.getTextPath(),
startOffset: 0,
containerId: container.id
})
}
}
_breakListNode(tx, node, coor, container) {
let path = coor.path
let offset = coor.offset
let listItem = tx.get(path[0])
let L = node.length
let itemPos = node.getItemPosition(listItem.id)
let text = listItem.getText()
let newItem = listItem.toJSON()
delete newItem.id
if (offset === 0) {
// if breaking at an empty list item, then the list is split into two
if (!text) {
// if it is the first or last item, a default text node is inserted before or after, and the item is removed
// if the list has only one element, it is removed
let nodePos = container.getPosition(node.id, 'strict')
let newTextNode = tx.createDefaultTextNode()
// if the list is empty, replace it with a paragraph
if (L < 2) {
container.hide(node.id)
documentHelpers.deleteNode(tx, node)
container.showAt(nodePos, newTextNode.id)
}
// if at the first list item, remove the item
else if (itemPos === 0) {
node.remove(listItem.id)
documentHelpers.deleteNode(tx, listItem)
container.showAt(nodePos, newTextNode.id)
}
// if at the last list item, remove the item and append the paragraph
else if (itemPos >= L-1) {
node.remove(listItem.id)
documentHelpers.deleteNode(tx, listItem)
container.showAt(nodePos+1, newTextNode.id)
}
// otherwise create a new list
else {
let tail = []
const items = node.items.slice()
for (let i = L-1; i > itemPos; i--) {
tail.unshift(items[i])
node.remove(items[i])
}
node.remove(items[itemPos])
let newList = tx.create({
type: 'list',
items: tail,
ordered: node.ordered
})
container.showAt(nodePos+1, newTextNode.id)
container.showAt(nodePos+2, newList.id)
}
tx.setSelection({
type: 'property',
path: newTextNode.getTextPath(),
startOffset: 0
})
}
// insert a new paragraph above the current one
else {
newItem.content = ""
newItem = tx.create(newItem)
node.insertItemAt(itemPos, newItem.id)
tx.setSelection({
type: 'property',
path: listItem.getTextPath(),
startOffset: 0
})
}
}
// otherwise split the text property and create a new paragraph node with trailing text and annotations transferred
else {
newItem.content = text.substring(offset)
newItem = tx.create(newItem)
// Now we need to transfer annotations
if (offset < text.length) {
// transfer annotations which are after offset to the new node
annotationHelpers.transferAnnotations(tx, path, offset, [newItem.id,'content'], 0)
// truncate the original property
tx.update(path, { type: 'delete', start: offset, end: text.length })
}
node.insertItemAt(itemPos+1, newItem.id)
tx.setSelection({
type: 'property',
path: newItem.getTextPath(),
startOffset: 0
})
}
}
_merge(tx, node, coor, direction, container) {
// detect cases where list items get merged
// within a single list node
if (node.isList()) {
let list = node
let itemId = coor.path[0]
let itemPos = list.getItemPosition(itemId)
let withinListNode = (
(direction === 'left' && itemPos > 0) ||
(direction === 'right' && itemPos<list.items.length-1)
)
if (withinListNode) {
itemPos = (direction === 'left') ? itemPos-1 : itemPos
let target = list.getItemAt(itemPos)
let targetLength = target.getLength()
documentHelpers.mergeListItems(tx, list.id, itemPos)
tx.setSelection({
type: 'property',
path: target.getTextPath(),
startOffset: targetLength,
containerId: container.id
})
return
}
}
// in all other cases merge is done across node boundaries
let nodePos = container.getPosition(node, 'strict')
if (direction === 'left' && nodePos > 0) {
this._mergeNodes(tx, container, nodePos-1, direction)
} else if (direction === 'right' && nodePos<container.getLength()-1) {
this._mergeNodes(tx, container, nodePos, direction)
}
}
_mergeNodes(tx, container, pos, direction) {
let first = container.getChildAt(pos)
let second = container.getChildAt(pos+1)
if (first.isText()) {
// Simplification for empty nodes
if (first.isEmpty()) {
container.hide(first.id)
documentHelpers.deleteNode(tx, first)
// TODO: need to clear where to handle
// selections ... probably better not to do it here
setCursor(tx, second, container.id, 'before')
return
}
let target = first
let targetPath = target.getTextPath()
let targetLength = target.getLength()
if (second.isText()) {
let source = second
let sourcePath = source.getTextPath()
container.hide(source.id)
// append the text
tx.update(targetPath, { type: 'insert', start: targetLength, text: source.getText() })
// transfer annotations
annotationHelpers.transferAnnotations(tx, sourcePath, 0, targetPath, targetLength)
documentHelpers.deleteNode(tx, source)
tx.setSelection({
type: 'property',
path: targetPath,
startOffset: targetLength,
containerId: container.id
})
} else if (second.isList()) {
let list = second
if (!second.isEmpty()) {
let source = list.getFirstItem()
let sourcePath = source.getTextPath()
// remove merged item from list
list.removeItemAt(0)
// append the text
tx.update(targetPath, { type: 'insert', start: targetLength, text: source.getText() })
// transfer annotations
annotationHelpers.transferAnnotations(tx, sourcePath, 0, targetPath, targetLength)
// delete item and prune empty list
documentHelpers.deleteNode(tx, source)
}
if (list.isEmpty()) {
container.hide(list.id)
documentHelpers.deleteNode(tx, list)
}
tx.setSelection({
type: 'property',
path: targetPath,
startOffset: targetLength,
containerId: container.id
})
} else {
selectNode(tx, direction === 'left' ? first.id : second.id, container.id)
}
} else if (first.isList()) {
if (second.isText()) {
let source = second
let sourcePath = source.getTextPath()
let target = first.getLastItem()
let targetPath = target.getTextPath()
let targetLength = target.getLength()
// hide source
container.hide(source.id)
// append the text
tx.update(targetPath, { type: 'insert', start: targetLength, text: source.getText() })
// transfer annotations
annotationHelpers.transferAnnotations(tx, sourcePath, 0, targetPath, targetLength)
documentHelpers.deleteNode(tx, source)
tx.setSelection({
type: 'property',
path: target.getTextPath(),
startOffset: targetLength,
containerId: container.id
})
} else if (second.isList()) {
/* istanbul ignore next */
if (direction !== 'right') {
// ATTENTION: merging two lists by using BACKSPACE is not possible,
// as BACKSPACE will first turn the list into a paragraph
throw new Error('Illegal state')
}
container.hide(second.id)
let firstItems = first.items.slice()
let secondItems = second.items.slice()
for (let i=0; i<secondItems.length;i++) {
second.removeItemAt(0)
first.appendItem(secondItems[i])
}
documentHelpers.deleteNode(tx, second)
let item = tx.get(last(firstItems))
tx.setSelection({
type: 'property',
path: item.getTextPath(),
startOffset: item.getLength(),
containerId: container.id
})
} else {
selectNode(tx, direction === 'left' ? first.id : second.id, container.id)
}
} else {
if (second.isText() && second.isEmpty()) {
container.hide(second.id)
documentHelpers.deleteNode(tx, second)
setCursor(tx, first, container.id, 'after')
} else {
selectNode(tx, direction === 'left' ? first.id : second.id, container.id)
}
}
}
}
export default Editing