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.

246 lines (223 loc) 6.82 kB
import { isPlainObject, clone, cloneDeep, forEach, map, uuid } from '../util' import OperationSerializer from './OperationSerializer' import ObjectOperation from './ObjectOperation' import { fromJSON as selectionFromJSON } from './selectionHelpers' class DocumentChange { constructor(ops, before, after) { if (arguments.length === 1 && isPlainObject(arguments[0])) { let data = arguments[0] // a unique id for the change this.sha = data.sha // when the change has been applied this.timestamp = data.timestamp // application state before the change was applied this.before = data.before || {} // array of operations this.ops = data.ops this.info = data.info; // custom change info // application state after the change was applied this.after = data.after || {} } else if (arguments.length === 3) { this.sha = uuid() this.info = {} this.timestamp = Date.now() this.ops = ops.slice(0) this.before = before || {} this.after = after || {} } else { throw new Error('Illegal arguments.') } // a hash with all updated properties this.updated = null // a hash with all created nodes this.created = null // a hash with all deleted nodes this.deleted = null } /* Extract aggregated information about which nodes and properties have been affected. This gets called by Document after applying the change. */ _extractInformation(doc) { let ops = this.ops let created = {} let deleted = {} let updated = {} let affectedContainerAnnos = [] // TODO: we will introduce a special operation type for coordinates function _checkAnnotation(op) { switch (op.type) { case "create": case "delete": { let node = op.val if (node.hasOwnProperty('start')) { updated[node.start.path] = true } if (node.hasOwnProperty('end')) { updated[node.end.path] = true } break } case "update": case "set": { // HACK: detecting annotation changes in an opportunistic way let node = doc.get(op.path[0]) if (node) { if (node.isPropertyAnnotation()) { updated[node.start.path] = true } else if (node.isContainerAnnotation()) { affectedContainerAnnos.push(node) } } break } default: /* istanbul ignore next */ throw new Error('Illegal state') } } for (let i = 0; i < ops.length; i++) { let op = ops[i] if (op.type === "create") { created[op.val.id] = op.val delete deleted[op.val.id] } if (op.type === "delete") { delete created[op.val.id] deleted[op.val.id] = op.val } if (op.type === "set" || op.type === "update") { updated[op.path] = true // also mark the node itself as dirty updated[op.path[0]] = true } _checkAnnotation(op) } affectedContainerAnnos.forEach(function(anno) { let container = doc.get(anno.containerId, 'strict') let startPos = container.getPosition(anno.start.path[0]) let endPos = container.getPosition(anno.end.path[0]) for (let pos = startPos; pos <= endPos; pos++) { let node = container.getChildAt(pos) let path if (node.isText()) { path = [node.id, 'content'] } else { path = [node.id] } if (!deleted[node.id]) { updated[path] = true } } }) // remove all deleted nodes from updated if(Object.keys(deleted).length > 0) { forEach(updated, function(_, key) { let nodeId = key.split(',')[0] if (deleted[nodeId]) { delete updated[key] } }) } this.created = created this.deleted = deleted this.updated = updated } invert() { // shallow cloning this let copy = this.toJSON() copy.ops = [] // swapping before and after let tmp = copy.before copy.before = copy.after copy.after = tmp let inverted = DocumentChange.fromJSON(copy) let ops = [] for (let i = this.ops.length - 1; i >= 0; i--) { ops.push(this.ops[i].invert()) } inverted.ops = ops return inverted } /* istanbul ignore start */ isAffected(path) { console.error('DEPRECATED: use change.hasUpdated() instead') return this.hasUpdated(path) } isUpdated(path) { console.error('DEPRECATED: use change.hasUpdated() instead') return this.hasUpdated(path) } /* istanbul ignore end */ hasUpdated(path) { return this.updated[path] } hasDeleted(id) { return this.deleted[id] } serialize() { // TODO serializers and deserializers should allow // for application data in 'after' and 'before' let opSerializer = new OperationSerializer() let data = this.toJSON() data.ops = this.ops.map(function(op) { return opSerializer.serialize(op) }) return JSON.stringify(data) } clone() { return DocumentChange.fromJSON(this.toJSON()) } toJSON() { let data = { // to identify this change sha: this.sha, // before state before: clone(this.before), ops: map(this.ops, function(op) { return op.toJSON() }), info: this.info, // after state after: clone(this.after), } // Just to make sure rich selection objects don't end up // in the JSON result data.after.selection = undefined data.before.selection = undefined let sel = this.before.selection if (sel && sel._isSelection) { data.before.selection = sel.toJSON() } sel = this.after.selection if (sel && sel._isSelection) { data.after.selection = sel.toJSON() } return data } } DocumentChange.deserialize = function(str) { let opSerializer = new OperationSerializer() let data = JSON.parse(str) data.ops = data.ops.map(function(opData) { return opSerializer.deserialize(opData) }) if (data.before.selection) { data.before.selection = selectionFromJSON(data.before.selection) } if (data.after.selection) { data.after.selection = selectionFromJSON(data.after.selection) } return new DocumentChange(data) } DocumentChange.fromJSON = function(data) { // Don't write to original object on deserialization let change = cloneDeep(data) change.ops = data.ops.map(function(opData) { return ObjectOperation.fromJSON(opData) }) change.before.selection = selectionFromJSON(data.before.selection) change.after.selection = selectionFromJSON(data.after.selection) return new DocumentChange(change) } export default DocumentChange