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).

393 lines (348 loc) 10.4 kB
import cloneDeep from '../util/cloneDeep' import forEach from '../util/forEach' import isArray from '../util/isArray' import isBoolean from '../util/isBoolean' import isNumber from '../util/isNumber' import isObject from '../util/isObject' import isString from '../util/isString' import _isDefined from '../util/_isDefined' import EventEmitter from '../util/EventEmitter' import NodeProperty from './NodeProperty' import NodeSchema from './NodeSchema' import hasOwnProperty from '../util/hasOwnProperty' const VALUE_TYPES = new Set(['id', 'string', 'number', 'boolean', 'enum', 'object', 'array', 'coordinate']) /* Base node implementation. @prop {String} id an id that is unique within this data */ export default class Node extends EventEmitter { /** @param {Object} properties */ constructor (...args) { super() // Note: because the schema is defined lazily // this makes sure that the schema is compiled const NodeClass = this.constructor NodeClass._ensureSchemaIsCompiled() // plain object to store the nodes data this._properties = new Map() // NOTE: this indirection allows us to implement a overridable initializer // For instance, DocumentNode sets the document instance and the props this._initialize(...args) } _initialize (data) { const NodeClass = this.constructor const schema = NodeClass.schema for (const property of schema) { const name = property.name // check integrity of provided data, such as type correctness, // and mandatory properties const propIsGiven = (data[name] !== undefined) const isOptional = property.isOptional() const hasDefault = property.hasDefault() if (!isOptional && !propIsGiven) { throw new Error('Property ' + name + ' is mandatory for node type ' + this.type) } if (propIsGiven) { this._properties.set(name, _checked(property, data[name])) } else if (hasDefault) { this._properties.set(name, cloneDeep(_checked(property, property.getDefault()))) } else { // property is optional } } } dispose () { this._disposed = true } isDisposed () { return Boolean(this._disposed) } /** Check if the node is of a given type. @param {String} typeName @returns {Boolean} true if the node has a parent with given type, false otherwise. */ isInstanceOf (typeName) { return Node.isInstanceOf(this.constructor, typeName) } getSchema () { return this.constructor.schema } get schema () { return this.getSchema() } /** Get a the list of all polymorphic types. @returns {String[]} An array of type names. */ getTypeNames () { const NodeClass = this.constructor const typeNames = this.schema.getSuperTypes() typeNames.unshift(NodeClass.type) return typeNames } /** * Get the type of a property. * * @param {String} propertyName * @returns The property's type. */ getPropertyType (propertyName) { return this.constructor.schema.getProperty(propertyName).type } /** Convert node to JSON. @returns {Object} JSON representation of node. */ toJSON () { var data = { type: this.type } const schema = this.getSchema() for (const prop of schema) { let val = this._properties.get(prop.name) if (prop.isOptional() && val === undefined) continue if (isArray(val) || isObject(val)) { val = cloneDeep(val) } data[prop.name] = val } return data } get type () { return this.constructor.type } /** * This gets called during schema compilation. * * Override this method in sub-classes to provide the accord schema specification. * * > Note: it is not necessary to call super.define() because Node schemas inherit the parent node's schema * > per se */ define () { return { type: '@node', id: 'string' } } _set (propName, value) { this._properties.set(propName, value) } set (propName, value) { this._set(propName, value) } get (propName) { return this._properties.get(propName) } /** Internal implementation of Node.prototype.isInstanceOf. @returns {Boolean} */ static isInstanceOf (NodeClass, typeName) { const schema = NodeClass.schema if (!schema) return false if (NodeClass.type === typeName) return true for (const superType of schema._superTypes) { if (superType === typeName) return true } return false } get _isNode () { return true } static get type () { const NodeClass = this return NodeClass.schema.type } static get schema () { const NodeClass = this NodeClass._ensureSchemaIsCompiled() return NodeClass.compiledSchema } static set schema (spec) { // Note: while the preferred way of defining a schema is via implementing Node.define() // we still leave this here this._compileSchema(spec) } static _ensureSchemaIsCompiled () { const NodeClass = this // If the schema has not been set explicitly, derive it from the parent schema if (!hasOwnProperty(NodeClass, 'compiledSchema')) { NodeClass._compileSchema() } } static _compileSchema (schema) { const NodeClass = this if (!schema) { // Experimental: I'd like to allow schema definition as prototype method // for sake of convenience const define = NodeClass.prototype.define schema = define() } NodeClass.compiledSchema = compileSchema(NodeClass, schema) } } // ### Internal implementation function _assign (maps) { const result = new Map() for (const m of maps) { for (const [key, value] of m) { if (result.has(key)) result.delete(key) result.set(key, value) } } return result } function compileSchema (NodeClass, spec) { const type = spec.type if (!_isDefined(type)) { throw new Error('"type" is required') } const properties = _compileProperties(spec) const allProperties = [properties] let ParentNodeClass = _getParentNodeClass(NodeClass) while (ParentNodeClass) { // ATTENTION: this will actually lead to a recursive compileSchema() call // if the parent class schema has not been compiled yet const parentSchema = ParentNodeClass.schema allProperties.unshift(parentSchema._properties) ParentNodeClass = _getParentNodeClass(ParentNodeClass) } const superTypes = _getSuperTypes(NodeClass) const _schema = new NodeSchema(type, _assign(allProperties), superTypes) // define property getter and setters for (const prop of _schema) { const name = prop.name Object.defineProperty(NodeClass.prototype, name, { get () { return this.get(name) }, set (val) { this.set(name, val) }, enumerable: true, configurable: true }) } return _schema } function _compileProperties (schema) { const properties = new Map() forEach(schema, function (definition, name) { // skip 'type' if (name === 'type') return if (isString(definition) || isArray(definition)) { definition = { type: definition } } else { definition = cloneDeep(definition) } definition = _compileDefintion(definition) definition.name = name properties.set(name, new NodeProperty(name, definition)) }) return properties } function _isValueType (t) { return VALUE_TYPES.has(t) } function _compileDefintion (definition) { let result = Object.assign({}, definition) const type = definition.type if (isArray(type)) { // there are different allowed formats: // 1. canonical: ['array', 'id'], ['array', 'some-node'] // 2. implcit: ['object'] // 3. multi-type: ['p', 'list'] const defs = type const lastIdx = defs.length - 1 const first = defs[0] const last = defs[lastIdx] const isCanonical = first === 'array' if (isCanonical) { result.type = defs.slice() // 'semi'-canonical if (last !== 'id' && !_isValueType(last)) { result.targetTypes = [last] result.type[lastIdx] = 'id' } } else { if (defs.length > 1) { defs.forEach(t => { if (_isValueType(t)) { throw new Error('Multi-types must consist of node types.') } }) result.type = ['array', 'id'] result.targetTypes = defs } else { if (_isValueType(first)) { result.type = ['array', first] } else { result.type = ['array', 'id'] result.targetTypes = defs } } } } else if (type === 'text') { result = { type: 'string', default: '', targetTypes: definition.targetTypes, reflectionType: 'text' } // single reference type } else if (type !== 'id' && !_isValueType(type)) { result.type = 'id' result.targetTypes = [type] } // wrap the array into a Set if (result.targetTypes) { result.targetTypes = new Set(result.targetTypes) } return result } function _checked (prop, value) { let type const name = prop.name if (prop.isArray()) { type = 'array' } else { type = prop.type } if (value === null) { if (prop.isNotNull()) { throw new Error('Value for property ' + name + ' is null.') } else { return value } } if (value === undefined) { throw new Error('Value for property ' + name + ' is undefined.') } if ((type === 'string' && !isString(value)) || (type === 'enum' && !isString(value)) || (type === 'boolean' && !isBoolean(value)) || (type === 'number' && !isNumber(value)) || (type === 'array' && !isArray(value)) || (type === 'id' && !isString(value)) || (type === 'object' && !isObject(value))) { throw new Error('Illegal value type for property ' + name + ': expected ' + type + ', was ' + (typeof value)) } return value } function _getSuperTypes (NodeClass) { var typeNames = [] let ParentNodeClass = _getParentNodeClass(NodeClass) while (ParentNodeClass && ParentNodeClass.type !== '@node') { typeNames.push(ParentNodeClass.type) ParentNodeClass = _getParentNodeClass(ParentNodeClass) } return typeNames } function _getParentNodeClass (Clazz) { var parentProto = Object.getPrototypeOf(Clazz.prototype) if (parentProto && parentProto._isNode) { return parentProto.constructor } }