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).
638 lines (594 loc) • 19.2 kB
JavaScript
import annotationHelpers from './annotationHelpers'
import DocumentIndex from './DocumentIndex'
import filter from '../util/filter'
import flatten from '../util/flatten'
import flattenOften from '../util/flattenOften'
import forEach from '../util/forEach'
import isArray from '../util/isArray'
import isArrayEqual from '../util/isArrayEqual'
import isString from '../util/isString'
import isFunction from '../util/isFunction'
import {
isEntirelySelected, getNodeIdsCoveredByContainerSelection
} from './selectionHelpers'
import hasOwnProperty from '../util/hasOwnProperty'
/**
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.
*/
export function getPropertyAnnotationsForSelection (doc, sel, options) {
options = options || {}
if (!sel.isPropertySelection()) {
return []
}
const 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} containerPath
@param {String} options.type provides only annotations of that type
@return {Array} An array of container annotations
*/
export function getContainerAnnotationsForSelection (doc, sel, containerPath, 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 (!containerPath) {
throw new Error("'containerPath' is required.")
}
options = options || {}
const index = doc.getIndex('container-annotations')
let annotations = []
if (index) {
annotations = index.get(containerPath, 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}
*/
export function isContainerAnnotation (doc, type) {
const 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
*/
export function getTextForSelection (doc, sel) {
if (!sel || sel.isNull()) {
return ''
} else if (sel.isPropertySelection()) {
const text = doc.get(sel.start.path)
return text.substring(sel.start.offset, sel.end.offset)
} else if (sel.isContainerSelection()) {
const result = []
const nodeIds = getNodeIdsCoveredByContainerSelection(doc, sel)
const L = nodeIds.length
for (let i = 0; i < L; i++) {
const id = nodeIds[i]
const 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')
}
}
export 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
const markers = doc.getIndex('markers').get(path)
const filtered = filter(markers, function (m) {
return m.containsSelection(sel)
})
return filtered
}
export function deleteNode (doc, node) {
console.error('DEPRECATED: use documentHelpers.deepDeleteNode() instead')
return deepDeleteNode(doc, node)
}
/*
Deletes a node and its children and attached annotations
and removes it from a given container
*/
export function deepDeleteNode (doc, node) {
/* istanbul ignore next */
if (!node) {
console.warn('Invalid arguments')
return
}
if (isString(node)) {
node = doc.get(node)
}
// TODO: bring back support for container annotations
if (node.isText()) {
// remove all associated annotations
const annos = doc.getIndex('annotations').get(node.id)
for (let i = 0; i < annos.length; i++) {
doc.delete(annos[i].id)
}
}
const nodeSchema = node.getSchema()
// remove all references to this node
removeReferences(doc, node)
// remove all children
// Note: correct order of deletion is tricky here.
// 1. annos attached to text properties
// 2. the node itself
// 3. nodes that are referenced via owned properties
for (const prop of nodeSchema) {
if (prop.isText()) {
const annos = doc.getAnnotations([node.id, prop.name])
for (const anno of annos) {
deepDeleteNode(doc, anno)
}
}
}
doc.delete(node.id)
// Recursive deletion of owned nodes
// 1. delete all 'owned' references to child nodes
// 2. delete all annos belonging to text properties
for (const prop of nodeSchema) {
if (prop.isOwned()) {
const value = node.get(prop.name)
if (prop.isArray()) {
let ids = value
if (ids.length > 0) {
// property can be a matrix
if (isArray(ids[0])) ids = flattenOften(ids, 2)
ids.forEach((id) => {
deepDeleteNode(doc, doc.get(id))
})
}
} else {
deepDeleteNode(doc, doc.get(value))
}
}
}
}
export function removeReferences (doc, node) {
const relIndex = doc.getIndex('relationships')
if (!relIndex) {
console.warning('Can not remove references without out relationships index')
return
}
const nodeId = node.id
const refererIds = relIndex.get(nodeId)
for (const id of refererIds) {
const referer = doc.get(id)
const relProps = referer.getSchema().getRelationshipProperties()
for (const prop of relProps) {
const propName = prop.name
if (prop.isArray()) {
const ids = referer.get(propName)
const pos = ids.indexOf(nodeId)
if (pos >= 0) {
doc.update([referer.id, propName], { type: 'delete', pos })
}
} else {
const id = referer.get(propName)
if (id === nodeId) {
doc.set([referer.id, propName], null)
}
}
}
}
}
/*
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
*/
export function copyNode (node) {
const nodes = []
// using schema reflection to determine whether to do a 'deep' copy or just shallow
const doc = node.getDocument()
const nodeSchema = node.getSchema()
for (const prop of nodeSchema) {
// ATM we do a cascaded copy if the property has type 'id', ['array', 'id'] and is owned by the node,
if (prop.isReference() && prop.isOwned()) {
const val = node.get(prop.name)
nodes.push(_copyChildren(val))
}
}
nodes.push(node.toJSON())
const annotationIndex = node.getDocument().getIndex('annotations')
const annotations = annotationIndex.get([node.id])
forEach(annotations, function (anno) {
nodes.push(anno.toJSON())
})
const 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 {
const id = val
if (!id) return null
const 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
*/
export function deleteTextRange (doc, start, end) {
if (!start) {
start = {
path: end.path,
offset: 0
}
}
const path = start.path
const 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')
}
const startOffset = start.offset
if (startOffset < 0) throw new Error('start offset must be >= 0')
const 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
const annos = doc.getAnnotations(path)
annos.forEach(function (anno) {
const annoStart = anno.start.offset
const annoEnd = anno.end.offset
// I anno is before
if (annoEnd <= startOffset) {
// 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.')
}
})
}
export function deleteListRange (doc, list, start, end, options = {}) {
// HACK: resolving the right node
// TODO: we should not do this, instead fix the calling code
if (doc !== list.getDocument()) {
list = doc.get(list.id)
}
let startItem, endItem
if (!start) {
startItem = list.getItemAt(0)
start = {
path: startItem.getPath(),
offset: 0
}
} else {
startItem = doc.get(start.path[0])
}
if (!end) {
endItem = list.getLastItem()
end = {
path: endItem.getPath(),
offset: endItem.getLength()
}
} else {
endItem = doc.get(end.path[0])
}
let startPos = list.getItemPosition(startItem)
let endPos = list.getItemPosition(endItem)
// 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];
[startItem, endItem] = [endItem, startItem]
}
const firstEntirelySelected = isEntirelySelected(doc, startItem, start, null)
const lastEntirelySelected = isEntirelySelected(doc, endItem, null, end)
// delete or truncate last node
if (lastEntirelySelected) {
list.removeItemAt(endPos)
deepDeleteNode(doc, endItem)
} else {
deleteTextRange(doc, null, end)
}
// delete inner nodes
const items = list.getItems()
for (let i = endPos - 1; i > startPos; i--) {
const item = items[i]
list.removeItemAt(i)
deepDeleteNode(doc, item)
}
// delete or truncate the first node
if (firstEntirelySelected) {
// NOTE: this does not work well, because then
// the item where the selection remains would have gone
// But when used by copySelection to truncate head and tail
// we want this.
if (options.deleteEmptyFirstItem) {
list.removeItemAt(startPos)
deepDeleteNode(doc, startItem)
} else {
deleteTextRange(doc, start, null)
}
} else {
deleteTextRange(doc, start, null)
}
if (!firstEntirelySelected && !lastEntirelySelected) {
mergeListItems(doc, list.id, startPos)
}
}
export function setText (doc, textPath, text) {
const oldText = doc.get(textPath)
if (oldText.length > 0) {
deleteTextRange(doc, { path: textPath, offset: 0 })
}
doc.update(textPath, { type: 'insert', start: 0, text })
return this
}
export function mergeListItems (doc, listId, itemPos) {
// HACK: make sure that the list is really from the doc
const list = doc.get(listId)
const targetItem = list.getItemAt(itemPos)
const targetPath = targetItem.getPath()
const targetLength = targetItem.getLength()
const sourceItem = list.getItemAt(itemPos + 1)
const sourcePath = sourceItem.getPath()
// hide source
list.removeItemAt(itemPos + 1)
// append the text
doc.update(targetPath, { type: 'insert', start: targetLength, text: sourceItem.getText() })
// transfer annotations
annotationHelpers.transferAnnotations(doc, sourcePath, 0, targetPath, targetLength)
deepDeleteNode(doc, sourceItem)
}
// used by transforms copy, paste
export const SNIPPET_ID = 'snippet'
export const TEXT_SNIPPET_ID = 'text-snippet'
export function insertAt (doc, containerPath, pos, id) {
doc.update(containerPath, { type: 'insert', pos, value: id })
}
export function append (doc, containerPath, id) {
insertAt(doc, containerPath, doc.get(containerPath).length, id)
}
/**
* Removes an item from a CHILDREN or CONTAINER property.
*
* @param {Document} doc
* @param {string[]} containerPath
* @param {number} pos
* @returns the id of the removed child
*/
export function removeAt (doc, containerPath, pos) {
const op = doc.update(containerPath, { type: 'delete', pos })
if (op && op.diff) {
return op.diff.val
}
}
export function removeFromCollection (doc, containerPath, id) {
const index = doc.get(containerPath).indexOf(id)
if (index >= 0) {
return removeAt(doc, containerPath, index)
}
return false
}
export function getNodesForPath (doc, containerPath) {
const ids = doc.get(containerPath)
return getNodesForIds(doc, ids)
}
export function getNodesForIds (doc, ids) {
return ids.map(id => doc.get(id, 'strict'))
}
export function getNodeAt (doc, containerPath, nodePos) {
const ids = doc.get(containerPath)
return doc.get(ids[nodePos])
}
export function getPreviousNode (doc, containerPath, nodePos) {
if (nodePos > 0) {
return getNodeAt(doc, containerPath, nodePos - 1)
}
}
export function getNextNode (doc, containerPath, nodePos) {
return getNodeAt(doc, containerPath, nodePos + 1)
}
export { default as compareCoordinates } from './_compareCoordinates'
export { default as isCoordinateBefore } from './_isCoordinateBefore'
export { default as getContainerRoot } from './_getContainerRoot'
export { default as getContainerPosition } from './_getContainerPosition'
// TODO: we could optimize this by 'compiling' which properties are 'parent' props
// i.e. TEXT, CHILD, and CHILDREN
export function getChildren (node) {
const doc = node.getDocument()
const id = node.id
const schema = node.getSchema()
let result = []
for (const p of schema) {
const name = p.name
if (p.isText()) {
const annos = doc.getAnnotations([id, name])
forEach(annos, a => result.push(a))
} else if (p.isReference() && p.isOwned()) {
const val = node.get(name)
if (val) {
if (p.isArray()) {
result = result.concat(val.map(id => doc.get(id)))
} else {
result.push(doc.get(val))
}
}
}
}
return result
}
export function getParent (node) {
// TODO: maybe we should implement ParentNodeHook for annotations
if (node._isAnnotation) {
const anno = node
const nodeId = anno.start.path[0]
return anno.getDocument().get(nodeId)
} else {
return node.getParent()
}
}
/**
* Create a node from JSON.
*
* The given JSON allows to initalize children with nested records.
* Every record must have 'type' and all required fields set.
*
* @param {Document} doc
* @param {object} data a JSON object
*
* @example
* ```
* documentHelpers.createNodeFromJson(doc, {
* "type": "journal-article-ref",
* "title": "VivosX, a disulfide crosslinking method to capture site-specific, protein-protein interactions in yeast and human cells",
* "containerTitle": "eLife",
* "volume": "7",
* "doi": "10.7554/eLife.36654",
* "year": "2018",
* "month": "08",
* "day": "09",
* "uri": "https://elifesciences.org/articles/36654",
* "authors": [
* {
* "type": "ref-contrib",
* "name": "Mohan",
* "givenNames": "Chitra"
* }
* ],
* })
* ```
*/
export function createNodeFromJson (doc, data) {
if (!data) throw new Error("'data' is mandatory")
if (!data.type) throw new Error("'data.type' is mandatory")
if (!isFunction(doc.create)) throw new Error('First argument must be document or tx')
const type = data.type
const nodeSchema = doc.getSchema().getNodeSchema(type)
const nodeData = {
type,
id: data.id
}
for (const p of nodeSchema) {
const name = p.name
if (!hasOwnProperty(data, name)) continue
const val = data[name]
if (p.isReference()) {
if (p.isArray()) {
nodeData[name] = val.map(childData => createNodeFromJson(doc, childData).id)
} else {
const child = createNodeFromJson(doc, val)
nodeData[name] = child.id
}
} else {
nodeData[p.name] = val
}
}
return doc.create(nodeData)
}
export function updateProperty (doc, path, newValue) {
const prop = doc.getProperty(path)
const reflectionType = prop.reflectionType
const value = doc.get(path)
switch (reflectionType) {
// primitives and types that can not be changed incrementally
case 'integer':
case 'number':
case 'boolean':
case 'one': {
if (value !== newValue) {
doc.set(path, newValue)
}
break
}
// TODO: we should try to derive an incremental update if possible
case 'string': {
if (value !== newValue) {
doc.set(path, newValue)
}
break
}
// array types
case 'string-array':
case 'many': {
if (!isArrayEqual(value, newValue)) {
doc.set(path, newValue)
}
break
}
// TODO: how should we approach this? I.e. the hierarchical aspect is making this a little unclear
// data for new children would need to be provided, maybe even updates for children
case 'text':
case 'child':
case 'children':
case 'container': {
throw new Error('Not implemented yet.')
}
default:
throw new Error('Unsupported property type: ' + reflectionType)
}
}