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 system. It is developed to power our online editing platform [Substance](http://substance.io).
1,137 lines (1,093 loc) • 41.3 kB
JavaScript
import isArrayEqual from '../util/isArrayEqual'
import isString from '../util/isString'
import uuid from '../util/uuid'
import annotationHelpers from './annotationHelpers'
import {
deleteTextRange, deepDeleteNode, deleteListRange, mergeListItems,
insertAt, removeAt, getContainerRoot, getContainerPosition, getNextNode, getPreviousNode
} from './documentHelpers'
import { setCursor, isEntirelySelected, selectNode } from './selectionHelpers'
import paste from './paste'
function _isLowSurrogate (charCode) {
return charCode >= 55296 && charCode <= 56319
}
function _isHighSurrogate (charCode) {
return charCode >= 56320 && charCode <= 57343
}
/**
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)
*/
export default class Editing {
// create an annotation for the current selection using the given data
annotate (tx, annotation) {
const sel = tx.selection
const schema = tx.getSchema()
const AnnotationClass = schema.getNodeClass(annotation.type)
if (!AnnotationClass) throw new Error('Unknown annotation type', annotation)
const start = sel.start
const end = sel.end
const containerPath = sel.containerPath
const nodeData = { start, end, containerPath }
// TODO: we need to generalize how node category can be derived statically
/* istanbul ignore else */
if (sel.isPropertySelection()) {
if (!AnnotationClass.isAnnotation()) {
throw new Error('Annotation can not be created for a selection.')
}
} else if (sel.isContainerSelection()) {
if (AnnotationClass.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) {
const sel = tx.selection
if (sel.isNodeSelection()) {
const containerPath = sel.containerPath
const nodeId = sel.getNodeId()
const nodePos = getContainerPosition(tx, containerPath, nodeId)
const textNode = this.createTextNode(tx, containerPath)
if (sel.isBefore()) {
insertAt(tx, containerPath, nodePos, textNode.id)
// leave selection as is
} else {
insertAt(tx, containerPath, nodePos + 1, textNode.id)
setCursor(tx, textNode, containerPath, 'before')
}
} else if (sel.isCustomSelection()) {
// TODO: what to do with custom selections?
} else if (sel.isCollapsed() || sel.isPropertySelection()) {
const containerPath = sel.containerPath
if (!sel.isCollapsed()) {
// delete the selection
this._deletePropertySelection(tx, sel)
tx.setSelection(sel.collapse('left'))
}
// then break the node
if (containerPath) {
const nodeId = sel.start.path[0]
const node = tx.get(nodeId)
this._breakNode(tx, node, sel.start, containerPath)
}
} else if (sel.isContainerSelection()) {
const start = sel.start
const containerPath = sel.containerPath
const startNodeId = start.path[0]
const nodePos = getContainerPosition(tx, containerPath, startNodeId)
this._deleteContainerSelection(tx, sel, { noMerge: true })
setCursor(tx, getNextNode(tx, containerPath, nodePos), containerPath, 'before')
}
}
createTextNode (tx, containerPath, text) { // eslint-disable-line no-unused-vars
const prop = tx.getProperty(containerPath)
if (!prop.defaultTextType) {
throw new Error('Container properties must have a "defaultTextType" defined in the schema')
}
return tx.create({
type: prop.defaultTextType,
content: text
})
}
createListNode (tx, containerPath, params = {}) { // eslint-disable-line no-unused-vars
// Note: override this create a different node type
// according to the context
return tx.create({ type: 'list', items: [], listType: params.listType || 'bullet' })
}
delete (tx, direction) {
const 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
const path = sel.start.path
const nodeId = path[0]
const containerPath = sel.containerPath
const text = tx.get(path)
const offset = sel.start.offset
const needsMerge = (containerPath && (
(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
// IMO this is not 'consistent' because it is not the
// inverse of 'break'
// We will 'toggle' only if the cursor is on the first position
// of the first item
const root = getContainerRoot(tx, containerPath, nodeId)
if (root.isList() && offset === 0 && direction === 'left') {
return this.toggleList(tx)
} else {
this._merge(tx, root, sel.start, direction, containerPath)
}
} 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
}
const startOffset = (direction === 'left') ? offset - 1 : offset
const endOffset = startOffset + 1
const start = { path: path, offset: startOffset }
const end = { path: path, offset: endOffset }
// ATTENTION: be careful not to corrupt suggorate pairs
// i.e. if deleting to the left and we see a low-suggorate character
// then we have to delete two chars
// and if deleting to the right and we see a hight-suggorate character
// we should also delete the lower one
const charCode = text.charCodeAt(startOffset)
// is character a low-suggorate?
if (_isLowSurrogate(charCode)) {
const nextCharCode = text.charCodeAt(endOffset)
if (_isHighSurrogate(nextCharCode)) {
end.offset++
}
} else if (_isHighSurrogate(charCode)) {
start.offset--
}
deleteTextRange(tx, start, end)
tx.setSelection({
type: 'property',
path: path,
startOffset: start.offset,
containerPath: sel.containerPath
})
}
// deleting a range of characters within a text property
} else if (sel.isPropertySelection()) {
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) {
const nodeId = sel.getNodeId()
const containerPath = sel.containerPath
const nodePos = getContainerPosition(tx, containerPath, nodeId)
if (sel.isFull() ||
(sel.isBefore() && direction === 'right') ||
(sel.isAfter() && direction === 'left')) {
// replace the node with default text node
removeAt(tx, containerPath, nodePos)
deepDeleteNode(tx, tx.get(nodeId))
const newNode = this.createTextNode(tx, sel.containerPath)
insertAt(tx, containerPath, nodePos, newNode.id)
tx.setSelection({
type: 'property',
path: newNode.getPath(),
startOffset: 0,
containerPath
})
} else {
/* istanbul ignore else */
if (sel.isBefore() && direction === 'left') {
if (nodePos > 0) {
const previous = getPreviousNode(tx, containerPath, nodePos)
if (previous.isText()) {
tx.setSelection({
type: 'property',
path: previous.getPath(),
startOffset: previous.getLength()
})
this.delete(tx, direction)
} else {
tx.setSelection({
type: 'node',
nodeId: previous.id,
containerPath
})
}
} else {
// nothing to do
}
} else if (sel.isAfter() && direction === 'right') {
const nodeIds = tx.get(containerPath)
if (nodePos < nodeIds.length - 1) {
const next = getNextNode(tx, containerPath, nodePos)
if (next.isText()) {
tx.setSelection({
type: 'property',
path: next.getPath(),
startOffset: 0
})
this.delete(tx, direction)
} else {
tx.setSelection({
type: 'node',
nodeId: next.id,
containerPath
})
}
} else {
// nothing to do
}
} else {
console.warn('Unsupported case: delete(%s)', direction, sel)
}
}
}
_deletePropertySelection (tx, sel) {
const path = sel.start.path
const start = sel.start.offset
const 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 = {}) {
const containerPath = sel.containerPath
const start = sel.start
const end = sel.end
const startId = start.getNodeId()
const endId = end.getNodeId()
const startPos = getContainerPosition(tx, containerPath, startId)
const endPos = getContainerPosition(tx, containerPath, endId)
// special case: selection within one node
if (startPos === endPos) {
// ATTENTION: we need the root node here e.g. the list, not the list-item
// OUCH: how we will we do it
const node = getContainerRoot(tx, containerPath, startId)
/* istanbul ignore else */
if (node.isText()) {
deleteTextRange(tx, start, end)
} else if (node.isList()) {
deleteListRange(tx, node, start, end)
} else {
throw new Error('Not supported yet.')
}
tx.setSelection(sel.collapse('left'))
return
}
// TODO: document the algorithm
const firstNodeId = start.getNodeId()
const lastNodeId = end.getNodeId()
const firstNode = tx.get(start.getNodeId())
const lastNode = tx.get(end.getNodeId())
const firstEntirelySelected = isEntirelySelected(tx, firstNode, start, null)
const lastEntirelySelected = isEntirelySelected(tx, lastNode, null, end)
// delete or truncate last node
if (lastEntirelySelected) {
removeAt(tx, containerPath, endPos)
deepDeleteNode(tx, lastNode)
} else {
// ATTENTION: we need the root node here e.g. the list, not the list-item
const node = getContainerRoot(tx, containerPath, lastNodeId)
/* istanbul ignore else */
if (node.isText()) {
deleteTextRange(tx, null, end)
} else if (node.isList()) {
deleteListRange(tx, node, null, end)
} else {
// IsolatedNodes can not be selected partially
}
}
// delete inner nodes
for (let i = endPos - 1; i > startPos; i--) {
const nodeId = removeAt(tx, containerPath, i)
deepDeleteNode(tx, tx.get(nodeId))
}
// delete or truncate the first node
if (firstEntirelySelected) {
removeAt(tx, containerPath, startPos)
deepDeleteNode(tx, firstNode)
} else {
// ATTENTION: we need the root node here e.g. the list, not the list-item
const node = getContainerRoot(tx, containerPath, firstNodeId)
/* istanbul ignore else */
if (node.isText()) {
deleteTextRange(tx, start, null)
} else if (node.isList()) {
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
const textNode = this.createTextNode(tx, containerPath)
insertAt(tx, containerPath, startPos, textNode.id)
tx.setSelection({
type: 'property',
path: textNode.getPath(),
startOffset: 0,
containerPath: containerPath
})
} else if (!firstEntirelySelected && !lastEntirelySelected) {
if (!options.noMerge) {
const firstNodeRoot = getContainerRoot(tx, containerPath, firstNode.id)
this._merge(tx, firstNodeRoot, sel.start, 'right', containerPath)
}
tx.setSelection(sel.collapse('left'))
} else if (firstEntirelySelected) {
setCursor(tx, lastNode, containerPath, 'before')
} else {
setCursor(tx, firstNode, containerPath, 'after')
}
}
insertInlineNode (tx, nodeData) {
let sel = tx.selection
const text = '\uFEFF'
this.insertText(tx, text)
sel = tx.selection
const endOffset = tx.selection.end.offset
const 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) {
const sel = tx.selection
const containerPath = sel.containerPath
// 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()) {
const nodeId = sel.getNodeId()
const nodePos = getContainerPosition(tx, containerPath, nodeId)
// insert before
if (sel.isBefore()) {
insertAt(tx, containerPath, nodePos, blockNode.id)
// insert after
} else if (sel.isAfter()) {
insertAt(tx, containerPath, nodePos + 1, blockNode.id)
tx.setSelection({
type: 'node',
containerPath,
nodeId: blockNode.id,
mode: 'after'
})
} else {
removeAt(tx, containerPath, nodePos)
deepDeleteNode(tx, tx.get(nodeId))
insertAt(tx, containerPath, nodePos, blockNode.id)
tx.setSelection({
type: 'node',
containerPath,
nodeId: blockNode.id,
mode: 'after'
})
}
} else if (sel.isPropertySelection()) {
/* istanbul ignore next */
if (!containerPath) throw new Error('insertBlockNode can only be used within a container.')
if (!sel.isCollapsed()) {
this._deletePropertySelection(tx, sel)
tx.setSelection(sel.collapse('left'))
}
const node = tx.get(sel.path[0])
/* istanbul ignore next */
if (!node) throw new Error('Invalid selection.')
const nodePos = getContainerPosition(tx, containerPath, node.id)
/* istanbul ignore else */
if (node.isText()) {
const text = node.getText()
// replace node
if (text.length === 0) {
removeAt(tx, containerPath, nodePos)
deepDeleteNode(tx, node)
insertAt(tx, containerPath, nodePos, blockNode.id)
setCursor(tx, blockNode, containerPath, 'after')
// insert before
} else if (sel.start.offset === 0) {
insertAt(tx, containerPath, nodePos, blockNode.id)
// insert after
} else if (sel.start.offset === text.length) {
insertAt(tx, containerPath, nodePos + 1, blockNode.id)
setCursor(tx, blockNode, containerPath, 'before')
// break
} else {
this.break(tx)
insertAt(tx, containerPath, nodePos + 1, blockNode.id)
setCursor(tx, blockNode, containerPath, 'after')
}
} else {
console.error('Not supported: insertBlockNode() on a custom node')
}
} else if (sel.isContainerSelection()) {
if (sel.isCollapsed()) {
const start = sel.start
/* istanbul ignore else */
if (start.isPropertyCoordinate()) {
tx.setSelection({
type: 'property',
path: start.path,
startOffset: start.offset,
containerPath
})
} else if (start.isNodeCoordinate()) {
tx.setSelection({
type: 'node',
containerPath,
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) {
const sel = tx.selection
// type over a selected node or insert a paragraph before
// or after
/* istanbul ignore else */
if (sel.isNodeSelection()) {
const containerPath = sel.containerPath
const nodeId = sel.getNodeId()
const nodePos = getContainerPosition(tx, containerPath, nodeId)
const textNode = this.createTextNode(tx, containerPath, text)
if (sel.isBefore()) {
insertAt(tx, containerPath, nodePos, textNode.id)
} else if (sel.isAfter()) {
insertAt(tx, containerPath, nodePos + 1, textNode.id)
} else {
removeAt(tx, containerPath, nodePos)
deepDeleteNode(tx, tx.get(nodeId))
insertAt(tx, containerPath, nodePos, textNode.id)
}
setCursor(tx, textNode, containerPath, '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`, `containerPath` and `data` with new node data
* @return {Object} object with updated `selection`
*
* @example
*
* ```js
* switchTextType(tx, {
* selection: bodyEditor.getSelection(),
* containerPath: bodyEditor.getContainerPath(),
* data: {
* type: 'heading',
* level: 2
* }
* })
* ```
*/
switchTextType (tx, data) {
const sel = tx.selection
/* istanbul ignore next */
if (!sel.isPropertySelection()) {
throw new Error('Selection must be a PropertySelection.')
}
const containerPath = sel.containerPath
/* istanbul ignore next */
if (!containerPath) {
throw new Error('Selection must be within a container.')
}
const path = sel.path
const nodeId = path[0]
const node = tx.get(nodeId)
/* istanbul ignore next */
if (!(node.isText())) {
throw new Error('Trying to use switchTextType on a non text node.')
}
const newId = uuid(data.type)
// Note: a TextNode is allowed to have its own way to store the plain-text
const oldPath = node.getPath()
console.assert(oldPath.length === 2, 'Currently we assume that TextNodes store the plain-text on the first level')
const textProp = oldPath[1]
const newPath = [newId, textProp]
// create a new node and transfer annotations
const newNodeData = Object.assign({
id: newId,
type: data.type,
direction: node.direction
}, data)
newNodeData[textProp] = node.getText()
const newNode = tx.create(newNodeData)
annotationHelpers.transferAnnotations(tx, path, 0, newPath, 0)
// hide and delete the old one, show the new node
const pos = getContainerPosition(tx, containerPath, nodeId)
removeAt(tx, containerPath, pos)
deepDeleteNode(tx, node)
insertAt(tx, containerPath, pos, newNode.id)
tx.setSelection({
type: 'property',
path: newPath,
startOffset: sel.start.offset,
endOffset: sel.end.offset,
containerPath
})
return newNode
}
toggleList (tx, params) {
const sel = tx.selection
const containerPath = sel.containerPath
/* istanbul ignore next */
if (!containerPath) {
throw new Error('Selection must be within a container.')
}
if (sel.isPropertySelection()) {
const nodeId = sel.start.path[0]
// ATTENTION: we need the root node here e.g. the list, not the list-item
const node = getContainerRoot(tx, containerPath, nodeId)
const nodePos = node.getPosition()
/* istanbul ignore else */
if (node.isText()) {
removeAt(tx, containerPath, nodePos)
const newList = this.createListNode(tx, containerPath, params)
const newItem = newList.createListItem(node.getText())
annotationHelpers.transferAnnotations(tx, node.getPath(), 0, newItem.getPath(), 0)
newList.appendItem(newItem)
deepDeleteNode(tx, node)
insertAt(tx, containerPath, nodePos, newList.id)
tx.setSelection({
type: 'property',
path: newItem.getPath(),
startOffset: sel.start.offset,
containerPath
})
} else if (node.isList()) {
const itemId = sel.start.path[0]
const item = tx.get(itemId)
const itemPos = node.getItemPosition(item)
const newTextNode = this.createTextNode(tx, containerPath, item.getText())
annotationHelpers.transferAnnotations(tx, item.getPath(), 0, newTextNode.getPath(), 0)
// take the item out of the list
node.removeItemAt(itemPos)
if (node.isEmpty()) {
removeAt(tx, containerPath, nodePos)
deepDeleteNode(tx, node)
insertAt(tx, containerPath, nodePos, newTextNode.id)
} else if (itemPos === 0) {
insertAt(tx, containerPath, nodePos, newTextNode.id)
} else if (node.getLength() <= itemPos) {
insertAt(tx, containerPath, nodePos + 1, newTextNode.id)
} else {
// split the
const tail = []
const items = node.getItems()
const L = items.length
for (let i = L - 1; i >= itemPos; i--) {
tail.unshift(items[i])
node.removeItemAt(i)
}
const newList = this.createListNode(tx, containerPath, node)
for (let i = 0; i < tail.length; i++) {
newList.appendItem(tail[i])
}
insertAt(tx, containerPath, nodePos + 1, newTextNode.id)
insertAt(tx, containerPath, nodePos + 2, newList.id)
}
tx.setSelection({
type: 'property',
path: newTextNode.getPath(),
startOffset: sel.start.offset,
containerPath
})
} else {
// unsupported node type
}
} else if (sel.isContainerSelection()) {
console.error('TODO: support toggleList with ContainerSelection')
}
}
indent (tx) {
const sel = tx.selection
const containerPath = sel.containerPath
if (sel.isPropertySelection()) {
const nodeId = sel.start.getNodeId()
// ATTENTION: we need the root node here, e.g. the list, not the list items
const node = getContainerRoot(tx, containerPath, nodeId)
if (node.isList()) {
const itemId = sel.start.path[0]
const item = tx.get(itemId)
const level = item.getLevel()
// Note: allowing only 3 levels
if (item && level < 3) {
item.setLevel(item.level + 1)
// a pseudo change to let the list know that something has changed
tx.set([node.id, '_itemsChanged'], true)
}
}
} else if (sel.isContainerSelection()) {
console.error('TODO: support toggleList with ContainerSelection')
}
}
dedent (tx) {
const sel = tx.selection
const containerPath = sel.containerPath
if (sel.isPropertySelection()) {
const nodeId = sel.start.getNodeId()
// ATTENTION: we need the root node here, e.g. the list, not the list items
const node = getContainerRoot(tx, containerPath, nodeId)
if (node.isList()) {
const itemId = sel.start.path[0]
const item = tx.get(itemId)
const level = item.getLevel()
if (item) {
if (level > 1) {
item.setLevel(item.level - 1)
// a pseudo change to let the list know that something has changed
tx.set([node.id, '_itemsChanged'], true)
}
// TODO: we could toggle the list item to paragraph
// if dedenting on the first level
// else {
// return this.toggleList(tx)
// }
}
}
} 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) {
const start = sel.start
const end = sel.end
/* istanbul ignore next */
if (!isArrayEqual(start.path, end.path)) {
throw new Error('Unsupported state: range should be on one property')
}
const path = start.path
const startOffset = start.offset
const endOffset = end.offset
const typeover = !sel.isCollapsed()
const 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
const annos = tx.getAnnotations(path)
annos.forEach(function (anno) {
const annoStart = anno.start.offset
const annoEnd = anno.end.offset
/* istanbul ignore else */
// I anno is before
if (annoEnd < startOffset) {
// 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.')
}
})
const offset = startOffset + text.length
tx.setSelection({
type: 'property',
path: start.path,
startOffset: offset,
containerPath: sel.containerPath,
surfaceId: sel.surfaceId
})
}
_breakNode (tx, node, coor, containerPath) {
// ATTENTION: we need the root here, e.g. a list, not the list-item
node = getContainerRoot(tx, containerPath, node.id)
/* istanbul ignore else */
if (node.isText()) {
this._breakTextNode(tx, node, coor, containerPath)
} else if (node.isList()) {
this._breakListNode(tx, node, coor, containerPath)
} else {
console.error('FIXME: _breakNode() not supported for type', node.type)
}
}
_breakTextNode (tx, node, coor, containerPath) {
const path = coor.path
const offset = coor.offset
const nodePos = node.getPosition()
const text = node.getText()
// when breaking at the first position, a new node of the same
// type will be inserted.
if (offset === 0) {
const newNode = tx.create({
type: node.type,
content: ''
})
// show the new node
insertAt(tx, containerPath, nodePos, newNode.id)
tx.setSelection({
type: 'property',
path: path,
startOffset: 0,
containerPath
})
// otherwise split the text property and create a new paragraph node with trailing text and annotations transferred
} else {
const textPath = node.getPath()
const textProp = textPath[1]
const newId = uuid(node.type)
const newNodeData = node.toJSON()
newNodeData.id = newId
newNodeData[textProp] = text.substring(offset)
// if at the end insert a default text node no matter in which text node we are
if (offset === text.length) {
newNodeData.type = tx.getSchema().getDefaultTextType()
}
const newNode = tx.create(newNodeData)
// 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.getPath(), 0)
// truncate the original property
tx.update(path, { type: 'delete', start: offset, end: text.length })
}
// show the new node
insertAt(tx, containerPath, nodePos + 1, newNode.id)
// update the selection
tx.setSelection({
type: 'property',
path: newNode.getPath(),
startOffset: 0,
containerPath
})
}
}
_breakListNode (tx, node, coor, containerPath) {
const path = coor.path
const offset = coor.offset
const listItem = tx.get(path[0])
const L = node.length
const itemPos = node.getItemPosition(listItem)
const text = listItem.getText()
const textProp = listItem.getPath()[1]
const newItemData = listItem.toJSON()
delete newItemData.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
const nodePos = node.getPosition()
const newTextNode = this.createTextNode(tx, containerPath)
// if the list is empty, replace it with a paragraph
if (L < 2) {
removeAt(tx, containerPath, nodePos)
deepDeleteNode(tx, node)
insertAt(tx, containerPath, nodePos, newTextNode.id)
// if at the first list item, remove the item
} else if (itemPos === 0) {
node.removeItem(listItem)
deepDeleteNode(tx, listItem)
insertAt(tx, containerPath, nodePos, newTextNode.id)
// if at the last list item, remove the item and append the paragraph
} else if (itemPos >= L - 1) {
node.removeItem(listItem)
deepDeleteNode(tx, listItem)
insertAt(tx, containerPath, nodePos + 1, newTextNode.id)
// otherwise create a new list
} else {
const tail = []
const items = node.getItems().slice()
for (let i = L - 1; i > itemPos; i--) {
tail.unshift(items[i])
node.removeItem(items[i])
}
node.removeItem(items[itemPos])
const newList = this.createListNode(tx, containerPath, node)
for (let i = 0; i < tail.length; i++) {
newList.appendItem(tail[i])
}
insertAt(tx, containerPath, nodePos + 1, newTextNode.id)
insertAt(tx, containerPath, nodePos + 2, newList.id)
}
tx.setSelection({
type: 'property',
path: newTextNode.getPath(),
startOffset: 0
})
// insert a new paragraph above the current one
} else {
newItemData[textProp] = ''
const newItem = tx.create(newItemData)
node.insertItemAt(itemPos, newItem)
tx.setSelection({
type: 'property',
path: listItem.getPath(),
startOffset: 0
})
}
// otherwise split the text property and create a new paragraph node with trailing text and annotations transferred
} else {
newItemData[textProp] = text.substring(offset)
const newItem = tx.create(newItemData)
// 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.getPath(), 0)
// truncate the original property
tx.update(path, { type: 'delete', start: offset, end: text.length })
}
node.insertItemAt(itemPos + 1, newItem)
tx.setSelection({
type: 'property',
path: newItem.getPath(),
startOffset: 0
})
}
}
_merge (tx, node, coor, direction, containerPath) {
// detect cases where list items get merged
// within a single list node
if (node.isList()) {
const list = node
const itemId = coor.path[0]
const item = tx.get(itemId)
let itemPos = list.getItemPosition(item)
const withinListNode = (
(direction === 'left' && itemPos > 0) ||
(direction === 'right' && itemPos < list.items.length - 1)
)
if (withinListNode) {
itemPos = (direction === 'left') ? itemPos - 1 : itemPos
const target = list.getItemAt(itemPos)
const targetLength = target.getLength()
mergeListItems(tx, list.id, itemPos)
tx.setSelection({
type: 'property',
path: target.getPath(),
startOffset: targetLength,
containerPath
})
return
}
}
// in all other cases merge is done across node boundaries
const nodeIds = tx.get(containerPath)
const nodePos = node.getPosition()
if (direction === 'left' && nodePos > 0) {
this._mergeNodes(tx, containerPath, nodePos - 1, direction)
} else if (direction === 'right' && nodePos < nodeIds.length - 1) {
this._mergeNodes(tx, containerPath, nodePos, direction)
}
}
_mergeNodes (tx, containerPath, pos, direction) {
const nodeIds = tx.get(containerPath)
const first = tx.get(nodeIds[pos])
let secondPos = pos + 1
const second = tx.get(nodeIds[secondPos])
if (first.isText()) {
// Simplification for empty nodes
if (first.isEmpty()) {
removeAt(tx, containerPath, pos)
secondPos--
deepDeleteNode(tx, first)
// TODO: need to clear where to handle
// selections ... probably better not to do it here
setCursor(tx, second, containerPath, 'before')
return
}
const target = first
const targetPath = target.getPath()
const targetLength = target.getLength()
if (second.isText()) {
const source = second
const sourcePath = source.getPath()
removeAt(tx, containerPath, secondPos)
// append the text
tx.update(targetPath, { type: 'insert', start: targetLength, text: source.getText() })
// transfer annotations
annotationHelpers.transferAnnotations(tx, sourcePath, 0, targetPath, targetLength)
deepDeleteNode(tx, source)
tx.setSelection({
type: 'property',
path: targetPath,
startOffset: targetLength,
containerPath
})
} else if (second.isList()) {
const list = second
if (!second.isEmpty()) {
const source = list.getFirstItem()
const sourcePath = source.getPath()
// 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
deepDeleteNode(tx, source)
}
if (list.isEmpty()) {
removeAt(tx, containerPath, secondPos)
deepDeleteNode(tx, list)
}
tx.setSelection({
type: 'property',
path: targetPath,
startOffset: targetLength,
containerPath
})
} else {
selectNode(tx, direction === 'left' ? first.id : second.id, containerPath)
}
} else if (first.isList()) {
if (second.isText()) {
const target = first.getLastItem()
const targetPath = target.getPath()
const targetLength = target.getLength()
const third = (nodeIds.length > pos + 2) ? tx.get(nodeIds[pos + 2]) : null
if (second.getLength() === 0) {
removeAt(tx, containerPath, secondPos)
deepDeleteNode(tx, second)
} else {
const source = second
const sourcePath = source.getPath()
removeAt(tx, containerPath, secondPos)
tx.update(targetPath, { type: 'insert', start: targetLength, text: source.getText() })
annotationHelpers.transferAnnotations(tx, sourcePath, 0, targetPath, targetLength)
deepDeleteNode(tx, source)
}
// merge to lists if they were split by a paragraph
if (third && third.type === first.type) {
this._mergeTwoLists(tx, containerPath, first, third)
}
tx.setSelection({
type: 'property',
path: target.getPath(),
startOffset: targetLength,
containerPath
})
} 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')
}
const item = first.getLastItem()
this._mergeTwoLists(tx, containerPath, first, second)
tx.setSelection({
type: 'property',
path: item.getPath(),
startOffset: item.getLength(),
containerPath
})
} else {
selectNode(tx, direction === 'left' ? first.id : second.id, containerPath)
}
} else {
if (second.isText() && second.isEmpty()) {
removeAt(tx, containerPath, secondPos)
deepDeleteNode(tx, second)
setCursor(tx, first, containerPath, 'after')
} else {
selectNode(tx, direction === 'left' ? first.id : second.id, containerPath)
}
}
}
_mergeTwoLists (tx, containerPath, first, second) {
const secondPos = second.getPosition()
removeAt(tx, containerPath, secondPos)
const secondItems = second.getItems().slice()
for (let i = 0; i < secondItems.length; i++) {
second.removeItemAt(0)
first.appendItem(secondItems[i])
}
deepDeleteNode(tx, second)
}
}