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.

242 lines (211 loc) 5.73 kB
import { isString, isNumber } from '../util' import Conflict from './Conflict' const INSERT = "insert" const DELETE = "delete" class TextOperation { constructor(data) { if (!data || data.type === undefined || data.pos === undefined || data.str === undefined) { throw new Error("Illegal argument: insufficient data.") } // 'insert' or 'delete' this.type = data.type // the position where to apply the operation this.pos = data.pos // the string to delete or insert this.str = data.str // sanity checks if(!this.isInsert() && !this.isDelete()) { throw new Error("Illegal type.") } if (!isString(this.str)) { throw new Error("Illegal argument: expecting string.") } if (!isNumber(this.pos) || this.pos < 0) { throw new Error("Illegal argument: expecting positive number as pos.") } } apply(str) { if (this.isEmpty()) return str if (this.type === INSERT) { if (str.length < this.pos) { throw new Error("Provided string is too short.") } if (str.splice) { return str.splice(this.pos, 0, this.str) } else { return str.slice(0, this.pos).concat(this.str).concat(str.slice(this.pos)) } } else /* if (this.type === DELETE) */ { if (str.length < this.pos + this.str.length) { throw new Error("Provided string is too short.") } if (str.splice) { return str.splice(this.pos, this.str.length) } else { return str.slice(0, this.pos).concat(str.slice(this.pos + this.str.length)) } } } clone() { return new TextOperation(this) } isNOP() { return this.type === "NOP" || this.str.length === 0 } isInsert() { return this.type === INSERT } isDelete() { return this.type === DELETE } getLength() { return this.str.length } invert() { var data = { type: this.isInsert() ? DELETE : INSERT, pos: this.pos, str: this.str } return new TextOperation(data) } hasConflict(other) { return _hasConflict(this, other) } isEmpty() { return this.str.length === 0 } toJSON() { return { type: this.type, pos: this.pos, str: this.str } } toString() { return ["(", (this.isInsert() ? INSERT : DELETE), ",", this.pos, ",'", this.str, "')"].join('') } } TextOperation.prototype._isOperation = true TextOperation.prototype._isTextOperation = true function _hasConflict(a, b) { // Insert vs Insert: // // Insertions are conflicting iff their insert position is the same. if (a.type === INSERT && b.type === INSERT) return (a.pos === b.pos) // Delete vs Delete: // // Deletions are conflicting if their ranges overlap. if (a.type === DELETE && b.type === DELETE) { // to have no conflict, either `a` should be after `b` or `b` after `a`, otherwise. return !(a.pos >= b.pos + b.str.length || b.pos >= a.pos + a.str.length) } // Delete vs Insert: // // A deletion and an insertion are conflicting if the insert position is within the deleted range. var del, ins if (a.type === DELETE) { del = a; ins = b } else { del = b; ins = a } return (ins.pos >= del.pos && ins.pos < del.pos + del.str.length) } // Transforms two Insertions // -------- function transform_insert_insert(a, b) { if (a.pos === b.pos) { b.pos += a.str.length } else if (a.pos < b.pos) { b.pos += a.str.length } else { a.pos += b.str.length } } // Transform two Deletions // -------- // function transform_delete_delete(a, b, first) { // reduce to a normalized case if (a.pos > b.pos) { return transform_delete_delete(b, a, !first) } if (a.pos === b.pos && a.str.length > b.str.length) { return transform_delete_delete(b, a, !first) } // take out overlapping parts if (b.pos < a.pos + a.str.length) { var s = b.pos - a.pos var s1 = a.str.length - s var s2 = s + b.str.length a.str = a.str.slice(0, s) + a.str.slice(s2) b.str = b.str.slice(s1) b.pos -= s } else { b.pos -= a.str.length } } // Transform Insert and Deletion // -------- // function transform_insert_delete(a, b) { if (a.type === DELETE) { return transform_insert_delete(b, a) } // we can assume, that a is an insertion and b is a deletion // a is before b if (a.pos <= b.pos) { b.pos += a.str.length } // a is after b else if (a.pos >= b.pos + b.str.length) { a.pos -= b.str.length } // Note: this is a conflict case the user should be noticed about // If applied still, the deletion takes precedence // a.pos > b.pos && <= b.pos + b.length else { var s = a.pos - b.pos b.str = b.str.slice(0, s) + a.str + b.str.slice(s) a.str = "" } } 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.type === INSERT && b.type === INSERT) { transform_insert_insert(a, b) } else if (a.type === DELETE && b.type === DELETE) { transform_delete_delete(a, b, true) } else { transform_insert_delete(a,b) } return [a, b] } TextOperation.transform = function() { return transform.apply(null, arguments) } /* Factories */ TextOperation.Insert = function(pos, str) { return new TextOperation({ type: INSERT, pos: pos, str: str }) } TextOperation.Delete = function(pos, str) { return new TextOperation({ type: DELETE, pos: pos, str: str }) } TextOperation.INSERT = INSERT TextOperation.DELETE = DELETE TextOperation.fromJSON = function(data) { return new TextOperation(data) } export default TextOperation