UNPKG

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.

490 lines (444 loc) 12.2 kB
import { isEqual, isNil, isString, cloneDeep, PathObject } from '../util' import TextOperation from './TextOperation' import ArrayOperation from './ArrayOperation' import CoordinateOperation from './CoordinateOperation' import Conflict from './Conflict' const NOP = "NOP" const CREATE = "create" const DELETE = 'delete' const UPDATE = 'update' const SET = 'set' class ObjectOperation { constructor(data) { /* istanbul ignore next */ if (!data) { throw new Error('Data of ObjectOperation is missing.') } /* istanbul ignore next */ if (!data.type) { throw new Error('Invalid data: type is mandatory.') } this.type = data.type if (data.type === NOP) { return } this.path = data.path if (!data.path) { throw new Error('Invalid data: path is mandatory.') } if (this.type === CREATE || this.type === DELETE) { if (!data.val) { throw new Error('Invalid data: value is missing.') } this.val = data.val } else if (this.type === UPDATE) { if (data.diff) { this.diff = data.diff if (data.diff._isTextOperation) { this.propertyType = 'string' } else if (data.diff._isArrayOperation) { this.propertyType = 'array' } else if (data.diff._isCoordinateOperation) { this.propertyType = 'coordinate' } else { throw new Error('Invalid data: diff must be a TextOperation or an ArrayOperation.') } } else { throw new Error("Invalid data: diff is mandatory for update operation.") } } else if (this.type === SET) { this.val = data.val this.original = data.original } else { throw new Error('Invalid type: '+ data.type) } } apply(obj) { if (this.type === NOP) return obj var adapter if (obj._isPathObject) { adapter = obj } else { adapter = new PathObject(obj) } if (this.type === CREATE) { adapter.set(this.path, cloneDeep(this.val)) return obj } if (this.type === DELETE) { adapter.delete(this.path, "strict") } else if (this.type === UPDATE) { var diff = this.diff switch (this.propertyType) { case 'array': { let arr = adapter.get(this.path) diff.apply(arr) break } case 'string': { let str = adapter.get(this.path) if (isNil(str)) str = '' str = diff.apply(str) adapter.set(this.path, str) break } case 'coordinate': { let coor = adapter.get(this.path) if (!coor) throw new Error('No coordinate with path '+this.path) diff.apply(coor) break } default: throw new Error('Invalid state.') } } else if (this.type === SET) { // clone here as the operations value must not be changed adapter.set(this.path, cloneDeep(this.val)) } else { throw new Error('Invalid type.') } return obj } clone() { var data = { type: this.type, path: this.path, } if (this.val) { data.val = cloneDeep(this.val) } if (this.diff) { data.diff = this.diff.clone() } return new ObjectOperation(data) } isNOP() { if (this.type === NOP) return true else if (this.type === UPDATE) return this.diff.isNOP() } isCreate() { return this.type === CREATE } isDelete() { return this.type === DELETE } isUpdate(propertyType) { if (propertyType) { return (this.type === UPDATE && this.propertyType === propertyType) } else { return this.type === UPDATE } } isSet() { return this.type === SET } invert() { if (this.type === NOP) { return new ObjectOperation({ type: NOP }) } var result = new ObjectOperation(this) if (this.type === CREATE) { result.type = DELETE } else if (this.type === DELETE) { result.type = CREATE } else if (this.type === UPDATE) { var invertedDiff if (this.diff._isTextOperation) { invertedDiff = TextOperation.fromJSON(this.diff.toJSON()).invert() } else if (this.diff._isArrayOperation) { invertedDiff = ArrayOperation.fromJSON(this.diff.toJSON()).invert() } else if (this.diff._isCoordinateOperation) { invertedDiff = CoordinateOperation.fromJSON(this.diff.toJSON()).invert() } else { throw new Error('Illegal type') } result.diff = invertedDiff } else /* if (this.type === SET) */ { result.val = this.original result.original = this.val } return result } hasConflict(other) { return ObjectOperation.hasConflict(this, other) } toJSON() { if (this.type === NOP) { return { type: NOP } } var data = { type: this.type, path: this.path, } if (this.type === CREATE || this.type === DELETE) { data.val = this.val } else if (this.type === UPDATE) { if (this.diff._isTextOperation) { data.propertyType = "string" } else if (this.diff._isArrayOperation) { data.propertyType = "array" } else if (this.diff._isCoordinateOperation) { data.propertyType = "coordinate" } else { throw new Error('Invalid property type.') } data.diff = this.diff.toJSON() } else /* if (this.type === SET) */ { data.val = this.val data.original = this.original } return data } getType() { return this.type } getPath() { return this.path } getValue() { return this.val } getOldValue() { return this.original } getValueOp() { return this.diff } /* istanbul ignore next */ toString() { switch (this.type) { case CREATE: return ["(+,", JSON.stringify(this.path), JSON.stringify(this.val), ")"].join('') case DELETE: return ["(-,", JSON.stringify(this.path), JSON.stringify(this.val), ")"].join('') case UPDATE: return ["(>>,", JSON.stringify(this.path), this.propertyType, this.diff.toString(), ")"].join('') case SET: return ["(=,", JSON.stringify(this.path), this.val, this.original, ")"].join('') case NOP: return "NOP" default: throw new Error('Invalid type') } } } ObjectOperation.prototype._isOperation = true ObjectOperation.prototype._isObjectOperation = true /* Low level implementation */ function hasConflict(a, b) { if (a.type === NOP || b.type === NOP) return false return isEqual(a.path, b.path) } function transform_delete_delete(a, b) { // both operations have the same effect. // the transformed operations are turned into NOPs a.type = NOP b.type = NOP } function transform_create_create() { throw new Error("Can not transform two concurring creates of the same property") } function transform_delete_create() { throw new Error('Illegal state: can not create and delete a value at the same time.') } function transform_delete_update(a, b, flipped) { if (a.type !== DELETE) { return transform_delete_update(b, a, true) } var op switch (b.propertyType) { case 'string': op = TextOperation.fromJSON(b.diff) break case 'array': op = ArrayOperation.fromJSON(b.diff) break case 'coordinate': op = CoordinateOperation.fromJSON(b.diff) break default: throw new Error('Illegal type') } // (DELETE, UPDATE) is transformed into (DELETE, CREATE) if (!flipped) { a.type = NOP b.type = CREATE b.val = op.apply(a.val) } // (UPDATE, DELETE): the delete is updated to delete the updated value else { a.val = op.apply(a.val) b.type = NOP } } function transform_create_update() { // it is not possible to reasonably transform this. throw new Error("Can not transform a concurring create and update of the same property") } function transform_update_update(a, b) { // Note: this is a conflict the user should know about var op_a, op_b, t switch(b.propertyType) { case 'string': op_a = TextOperation.fromJSON(a.diff) op_b = TextOperation.fromJSON(b.diff) t = TextOperation.transform(op_a, op_b, {inplace: true}) break case 'array': op_a = ArrayOperation.fromJSON(a.diff) op_b = ArrayOperation.fromJSON(b.diff) t = ArrayOperation.transform(op_a, op_b, {inplace: true}) break case 'coordinate': op_a = CoordinateOperation.fromJSON(a.diff) op_b = CoordinateOperation.fromJSON(b.diff) t = CoordinateOperation.transform(op_a, op_b, {inplace: true}) break default: throw new Error('Illegal type') } a.diff = t[0] b.diff = t[1] } function transform_create_set() { throw new Error('Illegal state: can not create and set a value at the same time.') } function transform_delete_set(a, b, flipped) { if (a.type !== DELETE) return transform_delete_set(b, a, true) if (!flipped) { a.type = NOP b.type = CREATE b.original = undefined } else { a.val = b.val b.type = NOP } } function transform_update_set() { throw new Error("Unresolvable conflict: update + set.") } function transform_set_set(a, b) { a.type = NOP b.original = a.val } const _NOP = 0 const _CREATE = 1 const _DELETE = 2 const _UPDATE = 4 const _SET = 8 const CODE = (() => { const c = {} c[NOP] =_NOP c[CREATE] = _CREATE c[DELETE] = _DELETE c[UPDATE] = _UPDATE c[SET] = _SET return c })() const __transform__ = (() => { /* eslint-disable no-multi-spaces */ const t = {} t[_DELETE | _DELETE] = transform_delete_delete t[_DELETE | _CREATE] = transform_delete_create t[_DELETE | _UPDATE] = transform_delete_update t[_CREATE | _CREATE] = transform_create_create t[_CREATE | _UPDATE] = transform_create_update t[_UPDATE | _UPDATE] = transform_update_update t[_CREATE | _SET ] = transform_create_set t[_DELETE | _SET ] = transform_delete_set t[_UPDATE | _SET ] = transform_update_set t[_SET | _SET ] = transform_set_set /* eslint-enable no-multi-spaces */ return t })() function transform(a, b, options) { options = options || {} if (options['no-conflict'] && hasConflict(a, b)) { throw new Conflict(a, b) } if (!options.inplace) { a = a.clone() b = b.clone() } if (a.isNOP() || b.isNOP()) { return [a, b] } var sameProp = isEqual(a.path, b.path) // without conflict: a' = a, b' = b if (sameProp) { __transform__[CODE[a.type] | CODE[b.type]](a,b) } return [a, b] } ObjectOperation.transform = transform ObjectOperation.hasConflict = hasConflict /* Factories */ ObjectOperation.Create = function(idOrPath, val) { var path if (isString(idOrPath)) { path = [idOrPath] } else { path = idOrPath } return new ObjectOperation({type: CREATE, path: path, val: val}) } ObjectOperation.Delete = function(idOrPath, val) { var path if (isString(idOrPath)) { path = [idOrPath] } else { path = idOrPath } return new ObjectOperation({type: DELETE, path: path, val: val}) } ObjectOperation.Update = function(path, op) { return new ObjectOperation({ type: UPDATE, path: path, diff: op }) } ObjectOperation.Set = function(path, oldVal, newVal) { return new ObjectOperation({ type: SET, path: path, val: cloneDeep(newVal), original: cloneDeep(oldVal) }) } ObjectOperation.fromJSON = function(data) { data = cloneDeep(data) if (data.type === "update") { switch (data.propertyType) { case "string": data.diff = TextOperation.fromJSON(data.diff) break case "array": data.diff = ArrayOperation.fromJSON(data.diff) break case "coordinate": data.diff = CoordinateOperation.fromJSON(data.diff) break default: throw new Error("Unsupported update diff:" + JSON.stringify(data.diff)) } } var op = new ObjectOperation(data) return op } ObjectOperation.NOP = NOP ObjectOperation.CREATE = CREATE ObjectOperation.DELETE = DELETE ObjectOperation.UPDATE = UPDATE ObjectOperation.SET = SET export default ObjectOperation