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

546 lines (474 loc) 13.9 kB
import isEqual from '../util/isEqual' import forEach from '../util/forEach' import uuid from '../util/uuid' import EventEmitter from '../util/EventEmitter' import AnnotationIndex from './AnnotationIndex' import ContainerAnnotationIndex from './ContainerAnnotationIndex' import TypeIndex from './TypeIndex' import RelationshipIndex from './RelationshipIndex' import DocumentChange from './DocumentChange' import IncrementalData from './IncrementalData' import DocumentNodeFactory from './DocumentNodeFactory' import EditingInterface from './EditingInterface' import Selection from './Selection' import NodeSelection from './NodeSelection' import { createSelection } from './selectionHelpers' import JSONConverter from './JSONConverter' import ParentNodeHook from './ParentNodeHook' import { SNIPPET_ID } from './documentHelpers' import { transformDocumentChange } from './operationHelpers' import hasOwnProperty from '../util/hasOwnProperty' const converter = new JSONConverter() /** Basic implementation of a Document. @example ```js import { Document } from 'substance' class MyArticle extends Document { constructor(...args) { super(...args) this.addIndex('foo', FooIndex) } } ``` */ export default class Document extends EventEmitter { /** @param {DocumentSchema} schema The document schema. */ constructor (schema, ...args) { super() this.schema = schema /* istanbul ignore next */ if (!schema) { throw new Error('A document needs a schema for reflection.') } // used internally this._ops = [] this._initialize(...args) } _initialize () { this.__id__ = uuid() this.nodeFactory = new DocumentNodeFactory(this) this.data = new IncrementalData(this.schema, this.nodeFactory) // all by type this.addIndex('type', new TypeIndex('type')) // index for (property-scoped) annotations this.addIndex('annotations', new AnnotationIndex()) // index for (container-scoped) annotations // TODO: enable this only if there is a container annotation in the schema this.addIndex('container-annotations', new ContainerAnnotationIndex()) // a reverse-index for relationship type properties ('one' or 'many') // TODO: enable this only if there is a node with relationship property in the schema this.addIndex('relationships', new RelationshipIndex()) // TODO: maybe we want to have a generalized concept for such low-level hooks ParentNodeHook.register(this) } dispose () { this.off() this.data.off() } get id () { return this.__id__ } /** @returns {model/DocumentSchema} the document's schema. */ getSchema () { return this.schema } /** Check if this storage contains a node with given id. @returns {Boolean} `true` if a node with id exists, `false` otherwise. */ contains (id) { return this.data.contains(id) } /** Get a node or value via path. @param {String|String[]} path node id or path to property. @returns {DocumentNode|any|undefined} a Node instance, a value or undefined if not found. */ get (path, strict) { return this.data.get(path, strict) } resolve (path, strict) { const prop = this.getProperty(path) if (!prop) { if (strict) { throw new Error('Invalid path') } else { return undefined } } const val = this.get(path, strict) if (prop.isReference()) { if (prop.isArray()) { return val.map(id => this.get(id)) } else { return this.get(val) } } else { return val } } /** @return {Object} A hash of {@link model/DocumentNode} instances. */ getNodes () { return this.data.getNodes() } getAnnotations (path) { return this.getIndex('annotations').get(path) } /** * Retrieve the NodeProperty for a given path * * @param {string[]} path */ getProperty (path) { if (path.length !== 2) { throw new Error('path must have length=2') } const [nodeId, propName] = path const node = this.get(nodeId) if (node) { return node.getSchema().getProperty(propName) } else { throw new Error('Invalid path.') } } fromJson (json) { try { // Node: json data may come in inappropriate order // where indexes could be choking this.data._stopIndexing() if (!json.nodes) { throw new Error('Invalid JSON format.') } // the json should just be an array of nodes const nodeEntries = json.nodes nodeEntries.forEach(nodeData => { if (this.data.contains(nodeData.id)) { this.delete(nodeData.id) } this.create(nodeData) }) this.data._startIndexing() } finally { this.data.queue = [] this.data._startIndexing() } return this } /** Create a node from the given data. @param {Object} plain node data. @return {DocumentNode} The created node. @example ```js editorSession.transaction((tx) => { tx.create({ id: 'p1', type: 'paragraph', content: 'Hi I am a Substance paragraph.' }) }) ``` */ create (nodeData) { if (!nodeData.id) { nodeData.id = uuid(nodeData.type) } if (!nodeData.type) { throw new Error('No node type provided') } const op = this._create(nodeData) if (op) { this._ops.push(op) this._emitInternalChange(op) return this.get(nodeData.id) } } createDefaultTextNode (text, dir) { return this.create({ type: this.getSchema().getDefaultTextType(), content: text || '', direction: dir }) } /** Delete the node with given id. @param {String} nodeId @returns {DocumentNode} The deleted node. @example ```js doc.transaction(function(tx) { tx.delete('p1') }) ``` */ delete (nodeId) { const node = this.get(nodeId) const op = this._delete(nodeId) if (op) { this._ops.push(op) this._emitInternalChange(op) } return node } /** Set a property to a new value. @param {String[]} property path @param {any} newValue @returns {DocumentNode} The deleted node. @example ```js doc.transaction(function(tx) { tx.set(['p1', 'content'], "Hello there! I'm a new paragraph.") }) ``` */ set (path, value) { const oldValue = this.get(path) const op = this._set(path, value) if (op) { this._ops.push(op) this._emitInternalChange(op) } return oldValue } /** Update a property incrementally. @param {Array} property path @param {Object} diff @returns {any} The value before applying the update. @example Inserting text into a string property: ``` doc.update(['p1', 'content'], { insert: {offset: 3, value: "fee"} }) ``` would turn "Foobar" into "Foofeebar". Deleting text from a string property: ``` doc.update(['p1', 'content'], { delete: {start: 0, end: 3} }) ``` would turn "Foobar" into "bar". Inserting into an array: ``` doc.update(['p1', 'content'], { insert: {offset: 2, value: 0} }) ``` would turn `[1,2,3,4]` into `[1,2,0,3,4]`. Deleting from an array: ``` doc.update(['body', 'nodes'], { delete: { offset: 2 } }) ``` would turn `[1,2,3,4]` into `[1,2,4]`. */ update (path, diff) { const op = this._update(path, diff) if (op) { this._ops.push(op) this._emitInternalChange(op) } return op } /* Update multiple properties of a node by delegating to Document.set for each changed property. */ updateNode (id, newProps) { const node = this.get(id) forEach(newProps, (value, key) => { if (!isEqual(node.get(key), value)) { this.set([id, key], value) } }) } /** Add a document index. @param {String} name @param {DocumentIndex} index */ addIndex (name, index) { return this.data.addIndex(name, index) } /** @param {String} name @returns {DocumentIndex} the node index with given name. */ getIndex (name) { return this.data.getIndex(name) } createSelection (data) { return createSelection(this, data) } newInstance () { var DocumentClass = this.constructor return new DocumentClass(this.schema) } // useful in combination with paste transformation createSnippet () { var snippet = this.newInstance() var snippetContainer = snippet.create({ type: '@container', id: SNIPPET_ID }) snippet.getContainer = function () { return snippetContainer } return snippet } rebase (change, onto) { if (onto.length > 0) { // ATTENTION: rebase uses mostly the same implementation as transform with some exceptions // FIXME: IMO this is mostly because of wrong design // ATTENTION 2: treating 'onto' as immutable, only updating 'change' transformDocumentChange(onto, change, { rebase: true, immutableLeft: true }) } return change } createFromDocument (doc) { // clear all content, otherwise there would be an inconsistent mixture this.clear() // Note: trying to bring the nodes into a correct order // so that they can be created safely without causing troubles // For example, a list-item should be created before its parent list. // But a paragraph should be created before their annotations // TODO: we should rethink the exception with annotations here // in XML the annotation would be a child of the paragraph // and thus should be created before hand. However our annotation indexes need the annotation target to exist. const nodes = Array.from(doc.getNodes().values()) const levels = {} const visited = new Set() nodes.forEach(n => { if (!visited.has(n)) this._computeDependencyLevel(n, levels, visited) }) // descending order: i.e. nodes with a deeper level get created first nodes.sort((a, b) => { return levels[b.id] - levels[a.id] }) nodes.forEach(n => this.create(n)) return this } _computeDependencyLevel (node, levels, visited) { if (!node) throw new Error('node was nil') if (visited.has(node)) throw new Error('Cyclic node dependency') visited.add(node) // HACK: as of the comment above, annotations are currently treated as overlay // not as children. So we assign level -1 to all annotations, meaning // that they are 'on-top-of' the content, and being created at the very last let level = 0 if (node.isAnnotation() || node.isInlineNode()) { level = -1 } else { const parent = node.getParent() if (parent) { let parentLevel if (hasOwnProperty(levels, parent.id)) { parentLevel = levels[parent.id] } else { parentLevel = this._computeDependencyLevel(parent, levels, visited) } level = parentLevel + 1 } } levels[node.id] = level return level } /** Convert to JSON. @returns {Object} Plain content. */ toJSON () { return converter.exportDocument(this) } clone () { const copy = this.newInstance() copy.createFromDocument(this) return copy } clear () { this.data.clear() this._ops.length = 0 } /* Provides a high-level turtle-graphics style interface to this document */ createEditingInterface () { return new EditingInterface(this) } invert (change) { return change.invert() } _apply (documentChange) { const ops = documentChange.ops for (const op of ops) { this._applyOp(op) } // extract aggregated information, such as which property has been affected etc. documentChange._extractInformation(this) } _applyOp (op) { this.data.apply(op) this.emit('operation:applied', op) } _create (nodeData) { return this.data.create(nodeData) } _delete (nodeId) { return this.data.delete(nodeId) } _set (path, value) { return this.data.set(path, value) } _update (path, diff) { return this.data.update(path, diff) } _createDocumentChange (ops, before, after, info) { return new DocumentChange(ops, before, after, info) } _emitInternalChange (op) { const change = this._createDocumentChange([op], {}, {}) change._extractInformation(this) this.emit('document:changed:internal', change, this) } _notifyChangeListeners (change, info = {}) { this.emit('document:changed', change, info, this) } // NOTE: this is still here because DOMSelection is using it _createSelectionFromRange (range) { if (!range) return Selection.nullSelection const inOneNode = isEqual(range.start.path, range.end.path) if (inOneNode) { if (range.start.isNodeCoordinate()) { // ATTENTION: we only create full NodeSelections // when mapping from the DOM to Model return new NodeSelection(range.containerPath, range.start.getNodeId(), mode, range.reverse, range.surfaceId) return new NodeSelection(range.containerPath, range.start.getNodeId(), 'full', range.reverse, range.surfaceId) } else { return this.createSelection({ type: 'property', path: range.start.path, startOffset: range.start.offset, endOffset: range.end.offset, reverse: range.reverse, containerPath: range.containerPath, surfaceId: range.surfaceId }) } } else { return this.createSelection({ type: 'container', startPath: range.start.path, startOffset: range.start.offset, endPath: range.end.path, endOffset: range.end.offset, reverse: range.reverse, containerPath: range.containerPath, surfaceId: range.surfaceId }) } } get _isDocument () { return true } }