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).
467 lines (418 loc) • 11.6 kB
JavaScript
import isArray from '../util/isArray'
import isString from '../util/isString'
import _isDefined from '../util/_isDefined'
import EventEmitter from '../util/EventEmitter'
import isPlainObject from '../util/isPlainObject'
import cloneDeep from '../util/cloneDeep'
/*
A data storage implemention that supports data defined via a {@link Schema},
and incremental updates which are backed by a OT library.
It forms the underlying implementation for {@link Document}.
*/
export default class Data extends EventEmitter {
/**
@param {Schema} schema
@param {Object} [options]
*/
constructor (schema, nodeFactory) {
super()
/* istanbul ignore start */
if (!schema) {
throw new Error('schema is mandatory')
}
if (!nodeFactory) {
throw new Error('nodeFactory is mandatory')
}
/* istanbul ignore end */
this.schema = schema
this.nodeFactory = nodeFactory
this.nodes = new Map()
this.indexes = new Map()
// Sometimes necessary to resolve issues with updating indexes in presence
// of cyclic dependencies
this.__QUEUE_INDEXING__ = false
this.queue = []
}
/**
Check if this storage contains a node with given id.
@returns {bool} `true` if a node with id exists, `false` otherwise.
*/
contains (id) {
return Boolean(this.nodes.has(id))
}
/**
Get a node or value via path.
@param {String|String[]} path node id or path to property.
@returns {Node|Object|Primitive|undefined} a Node instance, a value or undefined if not found.
*/
get (path, strict) {
const result = this._get(path)
if (strict && result === undefined) {
if (isString(path)) {
throw new Error("Could not find node with id '" + path + "'.")
} else if (!this.contains(path[0])) {
throw new Error("Could not find node with id '" + path[0] + "'.")
} else {
throw new Error("Property for path '" + path + "' us undefined.")
}
}
return result
}
_get (path) {
if (!path) return undefined
let result
if (isString(path)) {
const id = path
result = this.nodes.get(id)
} else if (path.length === 1) {
const id = path[0]
result = this.nodes.get(id)
} else if (path.length > 1) {
const id = path[0]
const node = this.nodes.get(id)
let val = node.get(path[1])
for (let i = 2; i < path.length; i++) {
if (!val) return undefined
val = val[path[i]]
}
result = val
}
return result
}
/**
Get the internal storage for nodes.
@return The internal node storage.
*/
getNodes () {
return this.nodes
}
/**
Create a node from the given data.
@return {Node} The created node.
*/
create (nodeData) {
var node = this.nodeFactory.create(nodeData.type, nodeData)
if (!node) {
throw new Error('Illegal argument: could not create node for data:', nodeData)
}
if (this.contains(node.id)) {
throw new Error('Node already exists: ' + node.id)
}
if (!node.id || !node.type) {
throw new Error('Node id and type are mandatory.')
}
this.nodes.set(node.id, node)
const change = {
type: 'create',
node
}
if (this.__QUEUE_INDEXING__) {
this.queue.push(change)
} else {
this._updateIndexes(change)
}
return node
}
/**
Delete the node with given id.
@param {String} nodeId
@returns {Node} The deleted node.
*/
delete (nodeId) {
const node = this.nodes.get(nodeId)
if (!node) return
node.dispose()
this.nodes.delete(nodeId)
const change = {
type: 'delete',
node: node
}
if (this.__QUEUE_INDEXING__) {
this.queue.push(change)
} else {
this._updateIndexes(change)
}
return node
}
/**
Set a property to a new value.
@param {Array} property path
@param {Object} newValue
@returns {Node} The deleted node.
*/
set (path, newValue) {
const node = this.get(path[0])
const oldValue = this._set(path, newValue)
var change = {
type: 'set',
node: node,
path: path,
newValue: newValue,
oldValue: oldValue
}
if (this.__QUEUE_INDEXING__) {
this.queue.push(change)
} else {
this._updateIndexes(change)
}
return oldValue
}
_set (path, newValue) {
const oldValue = _setValue(this.nodes, path, newValue)
return oldValue
}
/**
Update a property incrementally.
@param {Array} property path
@param {Object} diff
@returns {any} The value before applying the update.
*/
update (path, diff) {
const node = this.get(path[0])
let oldValue = this._get(path)
let newValue
if (diff._isOperation) {
// ATTENTION: array operations are done inplace
if (diff._isArrayOperation) {
const tmp = oldValue
oldValue = Array.from(oldValue)
newValue = diff.apply(tmp)
// ATTENTION: coordinate operations are done inplace
} else if (diff._isCoordinateOperation) {
const tmp = oldValue
oldValue = oldValue.clone()
newValue = diff.apply(tmp)
} else {
newValue = diff.apply(oldValue)
}
} else {
diff = this._normalizeDiff(oldValue, diff)
if (isString(oldValue)) {
switch (diff.type) {
case 'delete': {
newValue = oldValue.split('').splice(diff.start, diff.end - diff.start).join('')
break
}
case 'insert': {
newValue = [oldValue.substring(0, diff.start), diff.text, oldValue.substring(diff.start)].join('')
break
}
default:
throw new Error('Unknown diff type')
}
} else if (isArray(oldValue)) {
newValue = oldValue.slice(0)
switch (diff.type) {
case 'delete': {
newValue.splice(diff.pos, 1)
break
}
case 'insert': {
newValue.splice(diff.pos, 0, diff.value)
break
}
default:
throw new Error('Unknown diff type')
}
} else if (oldValue._isCoordinate) {
switch (diff.type) {
case 'shift': {
// ATTENTION: in this case we do not want to create a new value
oldValue = { path: oldValue.path, offset: oldValue.offset }
newValue = oldValue
newValue.offset += diff.value
break
}
default:
throw new Error('Unknown diff type')
}
} else {
throw new Error('Diff is not supported:', JSON.stringify(diff))
}
}
this._set(path, newValue)
var change = {
type: 'update',
node: node,
path: path,
newValue: newValue,
oldValue: oldValue
}
if (this.__QUEUE_INDEXING__) {
this.queue.push(change)
} else {
this._updateIndexes(change)
}
return oldValue
}
// normalize to support legacy formats
_normalizeDiff (value, diff) {
if (isString(value)) {
// legacy
if (diff.delete) {
console.warn('DEPRECATED: use doc.update(path, {type:"delete", start:s, end: e}) instead')
diff = {
type: 'delete',
start: diff.delete.start,
end: diff.delete.end
}
} else if (diff.insert) {
console.warn('DEPRECATED: use doc.update(path, {type:"insert", start:s, text: t}) instead')
diff = {
type: 'insert',
start: diff.insert.offset,
text: diff.insert.value
}
}
} else if (isArray(value)) {
// legacy
if (diff.delete) {
console.warn('DEPRECATED: use doc.update(path, {type:"delete", pos:1}) instead')
diff = {
type: 'delete',
pos: diff.delete.offset
}
} else if (diff.insert) {
console.warn('DEPRECATED: use doc.update(path, {type:"insert", pos:1, value: "foo"}) instead')
diff = {
type: 'insert',
pos: diff.insert.offset,
value: diff.insert.value
}
}
} else if (value._isCoordinate) {
if (_isDefined(diff.shift)) {
console.warn('DEPRECATED: use doc.update(path, {type:"shift", value:2}) instead')
diff = {
type: 'shift',
value: diff.shift
}
}
}
return diff
}
/*
DEPRECATED: We moved away from having JSON as first-class exchange format.
We will remove this soon.
@internal
@deprecated
*/
toJSON () {
const nodes = {}
for (const node of this.nodes.values()) {
nodes[node.id] = node.toJSON()
}
return {
schema: [this.schema.id, this.schema.version],
nodes
}
}
reset () {
this.clear()
}
/**
Clear nodes.
@internal
*/
clear () {
this.nodes = new Map()
for (const index of this.indexes.values()) {
index.clear()
}
}
/**
Add a node index.
@param {String} name
@param {NodeIndex} index
*/
addIndex (name, index) {
if (this.indexes[name]) {
console.error('Index with name %s already exists.', name)
}
index.reset(this)
this.indexes.set(name, index)
return index
}
/**
Get the node index with given name.
@param {String} name
@returns {NodeIndex} The node index.
*/
getIndex (name) {
return this.indexes.get(name)
}
/**
Update a node index by providing of change object.
@param {Object} change
*/
_updateIndexes (change) {
if (!change || this.__QUEUE_INDEXING__) return
for (const index of this.indexes.values()) {
if (index.select(change.node)) {
switch (change.type) {
case 'create':
index.create(change.node)
break
case 'delete':
index.delete(change.node)
break
case 'set':
index.set(change.node, change.path, change.newValue, change.oldValue)
break
case 'update':
index.update(change.node, change.path, change.newValue, change.oldValue)
break
default:
throw new Error('Illegal state.')
}
}
}
}
/**
Stops indexing process, all changes will be collected in indexing queue.
@private
*/
_stopIndexing () {
this.__QUEUE_INDEXING__ = true
}
/**
Update all index changes from indexing queue.
@private
*/
_startIndexing () {
this.__QUEUE_INDEXING__ = false
while (this.queue.length > 0) {
var change = this.queue.shift()
this._updateIndexes(change)
}
}
}
function _setValue (nodes, path, newValue) {
// HACK: cloning the value so that we get independent copies
if (isArray(newValue)) newValue = newValue.slice()
else if (isPlainObject(newValue)) newValue = cloneDeep(newValue)
if (!path || path.length < 2) {
throw new Error('Illegal value path.')
}
const nodeId = path[0]
const propName = path[1]
const node = nodes.get(nodeId)
if (!node) throw new Error(`Unknown node: ${nodeId}`)
let oldValue = node.get(propName)
const L = path.length
if (L > 2) {
if (!oldValue) throw new Error('Can not set value.')
let ctx = oldValue
for (let i = 2; i < L - 1; i++) {
ctx = ctx[path[i]]
if (!ctx) throw new Error('Can not set value.')
}
const valName = path[path.length - 1]
oldValue = ctx[valName]
ctx[valName] = newValue
} else {
// _set() does not trigger an operation
node._set(propName, newValue)
}
return oldValue
}