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.
387 lines (361 loc) • 11.3 kB
JavaScript
import { filter, flatten, forEach, isArray, isArrayEqual } from '../util'
import DocumentIndex from './DocumentIndex'
import annotationHelpers from './annotationHelpers'
import { isEntirelySelected } from './selectionHelpers'
import ChangeRecorder from './ChangeRecorder'
/**
Some helpers for working with Documents.
@module
@example
```js
import { documentHelpers } from 'substance'
documentHelpers.getPropertyAnnotationsForSelection(doc, sel)
```
*/
export default {
getPropertyAnnotationsForSelection,
getContainerAnnotationsForSelection,
getTextForSelection,
getMarkersForSelection,
getChangeFromDocument,
copyNode,
deleteNode,
deleteTextRange,
deleteListRange,
mergeListItems,
isContainerAnnotation,
getNodes
}
/**
For a given selection get all property annotations
@param {Document} doc
@param {Selection} sel
@return {PropertyAnnotation[]} An array of property annotations.
Returns an empty array when selection is a container selection.
*/
function getPropertyAnnotationsForSelection(doc, sel, options) {
options = options || {}
if (!sel.isPropertySelection()) {
return []
}
let path = sel.getPath()
let annotations = doc.getIndex('annotations').get(path, sel.start.offset, sel.end.offset)
if (options.type) {
annotations = filter(annotations, DocumentIndex.filterByType(options.type))
}
return annotations
}
/**
For a given selection get all container annotations
@param {Document} doc
@param {Selection} sel
@param {String} containerId
@param {String} options.type provides only annotations of that type
@return {Array} An array of container annotations
*/
function getContainerAnnotationsForSelection(doc, sel, containerId, options) {
// ATTENTION: looking for container annotations is not as efficient as property
// selections, as we do not have an index that has notion of the spatial extend
// of an annotation. Opposed to that, common annotations are bound
// to properties which make it easy to lookup.
/* istanbul ignore next */
if (!containerId) {
throw new Error("'containerId' is required.")
}
options = options || {}
let index = doc.getIndex('container-annotations')
let annotations = []
if (index) {
annotations = index.get(containerId, options.type)
annotations = filter(annotations, function(anno) {
return sel.overlaps(anno.getSelection())
})
}
return annotations
}
/**
@param {Document} doc
@param {String} type
@return {Boolean} `true` if given type is a {@link ContainerAnnotation}
*/
function isContainerAnnotation(doc, type) {
let schema = doc.getSchema()
return schema.isInstanceOf(type, 'container-annotation')
}
/**
For a given selection, get the corresponding text string
@param {Document} doc
@param {Selection} sel
@return {string} text enclosed by the annotation
*/
function getTextForSelection(doc, sel) {
if (!sel || sel.isNull()) {
return ""
} else if (sel.isPropertySelection()) {
let text = doc.get(sel.start.path)
return text.substring(sel.start.offset, sel.end.offset)
} else if (sel.isContainerSelection()) {
let result = []
let nodeIds = sel.getNodeIds()
let L = nodeIds.length
for (let i = 0; i < L; i++) {
let id = nodeIds[i]
let node = doc.get(id)
if (node.isText()) {
let text = node.getText()
if (i === L-1) {
text = text.slice(0, sel.end.offset)
}
if (i === 0) {
text = text.slice(sel.start.offset)
}
result.push(text)
}
}
return result.join('\n')
}
}
function getMarkersForSelection(doc, sel) {
// only PropertySelections are supported right now
if (!sel || !sel.isPropertySelection()) return []
const path = sel.getPath()
// markers are stored as one hash for each path, grouped by marker key
let markers = doc.getIndex('markers').get(path)
const filtered = filter(markers, function(m) {
return m.containsSelection(sel)
})
return filtered
}
function getChangeFromDocument(doc) {
let recorder = new ChangeRecorder(doc)
return recorder.generateChange()
}
/*
Deletes a node and its children and attached annotations
and removes it from a given container
*/
function deleteNode(doc, node) {
/* istanbul ignore next */
if (!node) {
console.warn('Invalid arguments')
return
}
// TODO: bring back support for container annotations
if (node.isText()) {
// remove all associated annotations
let annos = doc.getIndex('annotations').get(node.id)
for (let i = 0; i < annos.length; i++) {
doc.delete(annos[i].id)
}
}
// delete recursively
// ATM we do a cascaded delete if the property has type 'id' or ['array', 'id'] and property 'owned' set,
// or if it 'file'
let nodeSchema = node.getSchema()
forEach(nodeSchema, (prop) => {
if ((prop.isReference() && prop.isOwned()) || (prop.type === 'file')) {
if (prop.isArray()) {
let ids = node[prop.name]
ids.forEach((id) => {
deleteNode(doc, doc.get(id))
})
} else {
deleteNode(doc, doc.get(node[prop.name]))
}
}
})
doc.delete(node.id)
}
/*
Creates a 'deep' JSON copy of a node returning an array of JSON objects
that can be used to create the object tree owned by the given root node.
@param {DocumentNode} node
*/
function copyNode(node) {
let nodes = []
// EXPERIMENTAL: using schema reflection to determine whether to do a 'deep' copy or just shallow
let nodeSchema = node.getSchema()
let doc = node.getDocument()
forEach(nodeSchema, (prop) => {
// ATM we do a cascaded copy if the property has type 'id', ['array', 'id'] and is owned by the node,
// or it is of type 'file'
if ((prop.isReference() && prop.isOwned()) || (prop.type === 'file')) {
let val = node[prop.name]
nodes.push(_copyChildren(val))
}
})
nodes.push(node.toJSON())
let annotationIndex = node.getDocument().getIndex('annotations')
let annotations = annotationIndex.get([node.id])
forEach(annotations, function(anno) {
nodes.push(anno.toJSON())
})
let result = flatten(nodes).filter(Boolean)
// console.log('copyNode()', node, result)
return result
function _copyChildren(val) {
if (!val) return null
if (isArray(val)) {
return flatten(val.map(_copyChildren))
} else {
let id = val
if (!id) return null
let child = doc.get(id)
if (!child) return
return copyNode(child)
}
}
}
/*
<-->: anno
|--|: area of change
I: <--> |--| : nothing
II: |--| <--> : move both by total span
III: |-<-->-| : delete anno
IV: |-<-|-> : move start by diff to start, and end by total span
V: <-|->-| : move end by diff to start
VI: <-|--|-> : move end by total span
*/
function deleteTextRange(doc, start, end) {
if (!start) {
start = {
path: end.path,
offset: 0
}
}
let path = start.path
let text = doc.get(path)
if (!end) {
end = {
path: start.path,
offset: text.length
}
}
/* istanbul ignore next */
if (!isArrayEqual(start.path, end.path)) {
throw new Error('start and end must be on one property')
}
let startOffset = start.offset
if (startOffset < 0) throw new Error("start offset must be >= 0")
let endOffset = end.offset
if (endOffset > text.length) throw new Error("end offset must be smaller than the text length")
doc.update(path, { type: 'delete', start: startOffset, end: endOffset })
// update annotations
let annos = doc.getAnnotations(path)
annos.forEach(function(anno) {
let annoStart = anno.start.offset
let annoEnd = anno.end.offset
// I anno is before
if (annoEnd<=startOffset) {
return
}
// II anno is after
else if (annoStart>=endOffset) {
doc.update([anno.id, 'start'], { type: 'shift', value: startOffset-endOffset })
doc.update([anno.id, 'end'], { type: 'shift', value: startOffset-endOffset })
}
// III anno is deleted
else if (annoStart>=startOffset && annoEnd<=endOffset) {
doc.delete(anno.id)
}
// IV anno.start between and anno.end after
else if (annoStart>=startOffset && annoEnd>=endOffset) {
if (annoStart>startOffset) {
doc.update([anno.id, 'start'], { type: 'shift', value: startOffset-annoStart })
}
doc.update([anno.id, 'end'], { type: 'shift', value: startOffset-endOffset })
}
// V anno.start before and anno.end between
else if (annoStart<=startOffset && annoEnd<=endOffset) {
doc.update([anno.id, 'end'], { type: 'shift', value: startOffset-annoEnd })
}
// VI anno.start before and anno.end after
else if (annoStart<startOffset && annoEnd >= endOffset) {
doc.update([anno.id, 'end'], { type: 'shift', value: startOffset-endOffset })
}
else {
console.warn('TODO: handle annotation update case.')
}
})
}
function deleteListRange(doc, list, start, end) {
if (doc !== list.getDocument()) {
list = doc.get(list.id)
}
if (!start) {
start = {
path: list.getItemAt(0).getTextPath(),
offset: 0
}
}
if (!end) {
let item = list.getLastItem()
end = {
path: item.getTextPath(),
offset: item.getLength()
}
}
let startId = start.path[0]
let startPos = list.getItemPosition(startId)
let endId = end.path[0]
let endPos = list.getItemPosition(endId)
// range within the same item
if (startPos === endPos) {
deleteTextRange(doc, start, end)
return
}
// normalize the range if it is 'reverse'
if (startPos > endPos) {
[start, end] = [end, start];
[startPos, endPos] = [endPos, startPos];
[startId, endId] = [endId, startId];
}
let firstItem = doc.get(startId)
let lastItem = doc.get(endId)
let firstEntirelySelected = isEntirelySelected(doc, firstItem, start, null)
let lastEntirelySelected = isEntirelySelected(doc, lastItem, null, end)
// delete or truncate last node
if (lastEntirelySelected) {
list.removeItemAt(endPos)
deleteNode(doc, lastItem)
} else {
deleteTextRange(doc, null, end)
}
// delete inner nodes
for (let i = endPos-1; i > startPos; i--) {
let itemId = list.items[i]
list.removeItemAt(i)
deleteNode(doc, doc.get(itemId))
}
// delete or truncate the first node
if (firstEntirelySelected) {
list.removeItemAt(startPos)
deleteNode(doc, firstItem)
} else {
deleteTextRange(doc, start, null)
}
if (!firstEntirelySelected && !lastEntirelySelected) {
mergeListItems(doc, list.id, startPos)
}
}
function mergeListItems(doc, listId, itemPos) {
// HACK: make sure that the list is really from the doc
let list = doc.get(listId)
let target = list.getItemAt(itemPos)
let targetPath = target.getTextPath()
let targetLength = target.getLength()
let source = list.getItemAt(itemPos+1)
let sourcePath = source.getTextPath()
// hide source
list.removeItemAt(itemPos+1)
// append the text
doc.update(targetPath, { type: 'insert', start: targetLength, text: source.getText() })
// transfer annotations
annotationHelpers.transferAnnotations(doc, sourcePath, 0, targetPath, targetLength)
doc.delete(source.id)
}
function getNodes(doc, ids) {
return ids.map((id) => {
return doc.get(id, 'strict')
})
}