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 system. It is developed to power our online editing platform [Substance](http://substance.io).

486 lines (438 loc) 12.4 kB
import isEqual from '../util/isEqual' import isNil from '../util/isNil' import isString from '../util/isString' import cloneDeep from '../util/cloneDeep' import PathObject from '../util/PathObject' 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' export default 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: unsupported operation type for incremental update.') } } 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': { const 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': { const coor = adapter.get(this.path) if (!coor) throw new Error('No coordinate with path ' + this.path) diff.apply(coor) break } default: throw new Error('Unsupported property type for incremental update: ' + this.propertyType) } } 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) { result.diff = this.diff.clone().invert() } 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') } } static transform (a, b, options) { return transform(a, b, options) } static hasConflict (a, b) { return hasConflict(a, b) } // Factories static Create (idOrPath, val) { var path if (isString(idOrPath)) { path = [idOrPath] } else { path = idOrPath } return new ObjectOperation({ type: CREATE, path: path, val: val }) } static Delete (idOrPath, val) { var path if (isString(idOrPath)) { path = [idOrPath] } else { path = idOrPath } return new ObjectOperation({ type: DELETE, path: path, val: val }) } static Update (path, op) { return new ObjectOperation({ type: UPDATE, path: path, diff: op }) } static Set (path, oldVal, newVal) { return new ObjectOperation({ type: SET, path: path, val: cloneDeep(newVal), original: cloneDeep(oldVal) }) } static fromJSON (data) { data = cloneDeep(data) if (data.type === 'update') { data.diff = _deserializeDiffOp(data.propertyType, data.diff) } const op = new ObjectOperation(data) return op } // Symbols // TODO: we should probably just export these symbols static get NOP () { return NOP } static get CREATE () { return CREATE } static get DELETE () { return DELETE } static get UPDATE () { return UPDATE } static get SET () { return SET } // TODO: do we need this anymore? get _isOperation () { return true } get _isObjectOperation () { return true } } /* Low level implementation */ function hasConflict (a, b) { if (a.type === NOP || b.type === NOP) return false return isEqual(a.path, b.path) } function transformDeleteDelete (a, b, options = {}) { // no destructive transformation for rebase if (!options.rebase) { // both operations have the same effect. // the transformed operations are turned into NOPs a.type = NOP b.type = NOP } } function transformCreateCreate (a, b, options = {}) { if (!options.rebase) { throw new Error('Can not transform two concurring creates of the same property') } } function transformDeleteCreate (a, b, options = {}) { if (!options.rebase) { throw new Error('Illegal state: can not create and delete a value at the same time.') } } function _transformDeleteUpdate (a, b, flipped, options = {}) { // no destructive transformation for rebase if (!options.rebase) { if (a.type !== DELETE) { return _transformDeleteUpdate(b, a, true, options) } const op = _deserializeDiffOp(b.propertyType, b.diff) // (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 transformDeleteUpdate (a, b, options = {}) { return _transformDeleteUpdate(a, b, false, options) } function transformCreateUpdate () { // it is not possible to reasonably transform this. throw new Error('Can not transform a concurring create and update of the same property') } function transformUpdateUpdate (a, b, options = {}) { // Note: this is a conflict the user should know about const opA = _deserializeDiffOp(a.propertyType, a.diff) const opB = _deserializeDiffOp(b.propertyType, b.diff) let t switch (b.propertyType) { case 'string': t = TextOperation.transform(opA, opB, options) break case 'array': t = ArrayOperation.transform(opA, opB, options) break case 'coordinate': t = CoordinateOperation.transform(opA, opB, options) break default: throw new Error('Unsupported property type for incremental update') } a.diff = t[0] b.diff = t[1] } function _deserializeDiffOp (propertyType, diff) { if (diff._isOperation) return diff switch (propertyType) { case 'string': return TextOperation.fromJSON(diff) case 'array': return ArrayOperation.fromJSON(diff) case 'coordinate': return CoordinateOperation.fromJSON(diff) default: throw new Error('Unsupported property type for incremental update.') } } function transformCreateSet (a, b, options = {}) { if (!options.rebase) { throw new Error('Illegal state: can not create and set a value at the same time.') } } function _transformDeleteSet (a, b, flipped, options = {}) { if (a.type !== DELETE) return _transformDeleteSet(b, a, true, options) // no destructive transformation for rebase if (!options.rebase) { if (!flipped) { a.type = NOP b.type = CREATE b.original = undefined } else { a.val = b.val b.type = NOP } } } function transformDeleteSet (a, b, options = {}) { return _transformDeleteSet(a, b, false, options) } function transformUpdateSet (a, b, options = {}) { if (!options.rebase) { throw new Error('Unresolvable conflict: update + set.') } } function transformSetSet (a, b, options = {}) { // no destructive transformation for rebase if (!options.rebase) { 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] = transformDeleteDelete t[_DELETE | _CREATE] = transformDeleteCreate t[_DELETE | _UPDATE] = transformDeleteUpdate t[_CREATE | _CREATE] = transformCreateCreate t[_CREATE | _UPDATE] = transformCreateUpdate t[_UPDATE | _UPDATE] = transformUpdateUpdate t[_CREATE | _SET] = transformCreateSet t[_DELETE | _SET] = transformDeleteSet t[_UPDATE | _SET] = transformUpdateSet t[_SET | _SET] = transformSetSet /* eslint-enable no-multi-spaces */ return t })() function transform (a, b, options = {}) { if (options['no-conflict'] && hasConflict(a, b)) { throw new Conflict(a, b) } 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, options) } return [a, b] }