import * as diff from 'fast-diff'; import cloneDeep = require('lodash.clonedeep'); import isEqual = require('lodash.isequal'); import AttributeMap from './AttributeMap'; import Op from './Op'; import OpIterator from './OpIterator'; const NULL_CHARACTER = String.fromCharCode(0); // Placeholder char for embed in diff() interface EmbedHandler { compose(a: T, b: T, keepNull: boolean): T; invert(a: T, b: T): T; transform(a: T, b: T, priority: boolean): T; } const getEmbedTypeAndData = ( a: Op['insert'] | Op['retain'], b: Op['insert'], ): [string, unknown, unknown] => { if (typeof a !== 'object' || a === null) { throw new Error(`cannot retain a ${typeof a}`); } if (typeof b !== 'object' || b === null) { throw new Error(`cannot retain a ${typeof b}`); } const embedType = Object.keys(a)[0]; if (!embedType || embedType !== Object.keys(b)[0]) { throw new Error( `embed types not matched: ${embedType} != ${Object.keys(b)[0]}`, ); } return [embedType, a[embedType], b[embedType]]; }; class Delta { static Op = Op; static OpIterator = OpIterator; static AttributeMap = AttributeMap; private static handlers: { [embedType: string]: EmbedHandler } = {}; static registerEmbed(embedType: string, handler: EmbedHandler): void { this.handlers[embedType] = handler; } static unregisterEmbed(embedType: string): void { delete this.handlers[embedType]; } private static getHandler(embedType: string): EmbedHandler { const handler = this.handlers[embedType]; if (!handler) { throw new Error(`no handlers for embed type "${embedType}"`); } return handler; } ops: Op[]; constructor(ops?: Op[] | { ops: Op[] }) { // Assume we are given a well formed ops if (Array.isArray(ops)) { this.ops = ops; } else if (ops != null && Array.isArray(ops.ops)) { this.ops = ops.ops; } else { this.ops = []; } } insert( arg: string | Record, attributes?: AttributeMap | null, ): this { const newOp: Op = {}; if (typeof arg === 'string' && arg.length === 0) { return this; } newOp.insert = arg; if ( attributes != null && typeof attributes === 'object' && Object.keys(attributes).length > 0 ) { newOp.attributes = attributes; } return this.push(newOp); } delete(length: number): this { if (length <= 0) { return this; } return this.push({ delete: length }); } retain( length: number | Record, attributes?: AttributeMap | null, ): this { if (typeof length === 'number' && length <= 0) { return this; } const newOp: Op = { retain: length }; if ( attributes != null && typeof attributes === 'object' && Object.keys(attributes).length > 0 ) { newOp.attributes = attributes; } return this.push(newOp); } push(newOp: Op): this { let index = this.ops.length; let lastOp = this.ops[index - 1]; newOp = cloneDeep(newOp); if (typeof lastOp === 'object') { if ( typeof newOp.delete === 'number' && typeof lastOp.delete === 'number' ) { this.ops[index - 1] = { delete: lastOp.delete + newOp.delete }; return this; } // Since it does not matter if we insert before or after deleting at the same index, // always prefer to insert first if (typeof lastOp.delete === 'number' && newOp.insert != null) { index -= 1; lastOp = this.ops[index - 1]; if (typeof lastOp !== 'object') { this.ops.unshift(newOp); return this; } } if (isEqual(newOp.attributes, lastOp.attributes)) { if ( typeof newOp.insert === 'string' && typeof lastOp.insert === 'string' ) { this.ops[index - 1] = { insert: lastOp.insert + newOp.insert }; if (typeof newOp.attributes === 'object') { this.ops[index - 1].attributes = newOp.attributes; } return this; } else if ( typeof newOp.retain === 'number' && typeof lastOp.retain === 'number' ) { this.ops[index - 1] = { retain: lastOp.retain + newOp.retain }; if (typeof newOp.attributes === 'object') { this.ops[index - 1].attributes = newOp.attributes; } return this; } } } if (index === this.ops.length) { this.ops.push(newOp); } else { this.ops.splice(index, 0, newOp); } return this; } chop(): this { const lastOp = this.ops[this.ops.length - 1]; if (lastOp && typeof lastOp.retain === 'number' && !lastOp.attributes) { this.ops.pop(); } return this; } filter(predicate: (op: Op, index: number) => boolean): Op[] { return this.ops.filter(predicate); } forEach(predicate: (op: Op, index: number) => void): void { this.ops.forEach(predicate); } map(predicate: (op: Op, index: number) => T): T[] { return this.ops.map(predicate); } partition(predicate: (op: Op) => boolean): [Op[], Op[]] { const passed: Op[] = []; const failed: Op[] = []; this.forEach((op) => { const target = predicate(op) ? passed : failed; target.push(op); }); return [passed, failed]; } reduce( predicate: (accum: T, curr: Op, index: number) => T, initialValue: T, ): T { return this.ops.reduce(predicate, initialValue); } changeLength(): number { return this.reduce((length, elem) => { if (elem.insert) { return length + Op.length(elem); } else if (elem.delete) { return length - elem.delete; } return length; }, 0); } length(): number { return this.reduce((length, elem) => { return length + Op.length(elem); }, 0); } slice(start = 0, end = Infinity): Delta { const ops = []; const iter = new OpIterator(this.ops); let index = 0; while (index < end && iter.hasNext()) { let nextOp; if (index < start) { nextOp = iter.next(start - index); } else { nextOp = iter.next(end - index); ops.push(nextOp); } index += Op.length(nextOp); } return new Delta(ops); } compose(other: Delta): Delta { const thisIter = new OpIterator(this.ops); const otherIter = new OpIterator(other.ops); const ops = []; const firstOther = otherIter.peek(); if ( firstOther != null && typeof firstOther.retain === 'number' && firstOther.attributes == null ) { let firstLeft = firstOther.retain; while ( thisIter.peekType() === 'insert' && thisIter.peekLength() <= firstLeft ) { firstLeft -= thisIter.peekLength(); ops.push(thisIter.next()); } if (firstOther.retain - firstLeft > 0) { otherIter.next(firstOther.retain - firstLeft); } } const delta = new Delta(ops); while (thisIter.hasNext() || otherIter.hasNext()) { if (otherIter.peekType() === 'insert') { delta.push(otherIter.next()); } else if (thisIter.peekType() === 'delete') { delta.push(thisIter.next()); } else { const length = Math.min(thisIter.peekLength(), otherIter.peekLength()); const thisOp = thisIter.next(length); const otherOp = otherIter.next(length); if (otherOp.retain) { const newOp: Op = {}; if (typeof thisOp.retain === 'number') { newOp.retain = typeof otherOp.retain === 'number' ? length : otherOp.retain; } else { if (typeof otherOp.retain === 'number') { if (thisOp.retain == null) { newOp.insert = thisOp.insert; } else { newOp.retain = thisOp.retain; } } else { const action = thisOp.retain == null ? 'insert' : 'retain'; const [embedType, thisData, otherData] = getEmbedTypeAndData( thisOp[action], otherOp.retain, ); const handler = Delta.getHandler(embedType); newOp[action] = { [embedType]: handler.compose( thisData, otherData, action === 'retain', ), }; } } // Preserve null when composing with a retain, otherwise remove it for inserts const attributes = AttributeMap.compose( thisOp.attributes, otherOp.attributes, typeof thisOp.retain === 'number', ); if (attributes) { newOp.attributes = attributes; } delta.push(newOp); // Optimization if rest of other is just retain if ( !otherIter.hasNext() && isEqual(delta.ops[delta.ops.length - 1], newOp) ) { const rest = new Delta(thisIter.rest()); return delta.concat(rest).chop(); } // Other op should be delete, we could be an insert or retain // Insert + delete cancels out } else if ( typeof otherOp.delete === 'number' && (typeof thisOp.retain === 'number' || (typeof thisOp.retain === 'object' && thisOp.retain !== null)) ) { delta.push(otherOp); } } } return delta.chop(); } concat(other: Delta): Delta { const delta = new Delta(this.ops.slice()); if (other.ops.length > 0) { delta.push(other.ops[0]); delta.ops = delta.ops.concat(other.ops.slice(1)); } return delta; } diff(other: Delta, cursor?: number | diff.CursorInfo): Delta { if (this.ops === other.ops) { return new Delta(); } const strings = [this, other].map((delta) => { return delta .map((op) => { if (op.insert != null) { return typeof op.insert === 'string' ? op.insert : NULL_CHARACTER; } const prep = delta === other ? 'on' : 'with'; throw new Error('diff() called ' + prep + ' non-document'); }) .join(''); }); const retDelta = new Delta(); const diffResult = diff(strings[0], strings[1], cursor, true); const thisIter = new OpIterator(this.ops); const otherIter = new OpIterator(other.ops); diffResult.forEach((component: diff.Diff) => { let length = component[1].length; while (length > 0) { let opLength = 0; switch (component[0]) { case diff.INSERT: opLength = Math.min(otherIter.peekLength(), length); retDelta.push(otherIter.next(opLength)); break; case diff.DELETE: opLength = Math.min(length, thisIter.peekLength()); thisIter.next(opLength); retDelta.delete(opLength); break; case diff.EQUAL: opLength = Math.min( thisIter.peekLength(), otherIter.peekLength(), length, ); const thisOp = thisIter.next(opLength); const otherOp = otherIter.next(opLength); if (isEqual(thisOp.insert, otherOp.insert)) { retDelta.retain( opLength, AttributeMap.diff(thisOp.attributes, otherOp.attributes), ); } else { retDelta.push(otherOp).delete(opLength); } break; } length -= opLength; } }); return retDelta.chop(); } eachLine( predicate: ( line: Delta, attributes: AttributeMap, index: number, ) => boolean | void, newline = '\n', ): void { const iter = new OpIterator(this.ops); let line = new Delta(); let i = 0; while (iter.hasNext()) { if (iter.peekType() !== 'insert') { return; } const thisOp = iter.peek(); const start = Op.length(thisOp) - iter.peekLength(); const index = typeof thisOp.insert === 'string' ? thisOp.insert.indexOf(newline, start) - start : -1; if (index < 0) { line.push(iter.next()); } else if (index > 0) { line.push(iter.next(index)); } else { if (predicate(line, iter.next(1).attributes || {}, i) === false) { return; } i += 1; line = new Delta(); } } if (line.length() > 0) { predicate(line, {}, i); } } invert(base: Delta): Delta { const inverted = new Delta(); this.reduce((baseIndex, op) => { if (op.insert) { inverted.delete(Op.length(op)); } else if (typeof op.retain === 'number' && op.attributes == null) { inverted.retain(op.retain); return baseIndex + op.retain; } else if (op.delete || typeof op.retain === 'number') { const length = (op.delete || op.retain) as number; const slice = base.slice(baseIndex, baseIndex + length); slice.forEach((baseOp) => { if (op.delete) { inverted.push(baseOp); } else if (op.retain && op.attributes) { inverted.retain( Op.length(baseOp), AttributeMap.invert(op.attributes, baseOp.attributes), ); } }); return baseIndex + length; } else if (typeof op.retain === 'object' && op.retain !== null) { const slice = base.slice(baseIndex, baseIndex + 1); const baseOp = new OpIterator(slice.ops).next(); const [embedType, opData, baseOpData] = getEmbedTypeAndData( op.retain, baseOp.insert, ); const handler = Delta.getHandler(embedType); inverted.retain( { [embedType]: handler.invert(opData, baseOpData) }, AttributeMap.invert(op.attributes, baseOp.attributes), ); return baseIndex + 1; } return baseIndex; }, 0); return inverted.chop(); } transform(index: number, priority?: boolean): number; transform(other: Delta, priority?: boolean): Delta; transform(arg: number | Delta, priority = false): typeof arg { priority = !!priority; if (typeof arg === 'number') { return this.transformPosition(arg, priority); } const other: Delta = arg; const thisIter = new OpIterator(this.ops); const otherIter = new OpIterator(other.ops); const delta = new Delta(); while (thisIter.hasNext() || otherIter.hasNext()) { if ( thisIter.peekType() === 'insert' && (priority || otherIter.peekType() !== 'insert') ) { delta.retain(Op.length(thisIter.next())); } else if (otherIter.peekType() === 'insert') { delta.push(otherIter.next()); } else { const length = Math.min(thisIter.peekLength(), otherIter.peekLength()); const thisOp = thisIter.next(length); const otherOp = otherIter.next(length); if (thisOp.delete) { // Our delete either makes their delete redundant or removes their retain continue; } else if (otherOp.delete) { delta.push(otherOp); } else { const thisData = thisOp.retain; const otherData = otherOp.retain; let transformedData: Op['retain'] = typeof otherData === 'object' && otherData !== null ? otherData : length; if ( typeof thisData === 'object' && thisData !== null && typeof otherData === 'object' && otherData !== null ) { const embedType = Object.keys(thisData)[0]; if (embedType === Object.keys(otherData)[0]) { const handler = Delta.getHandler(embedType); if (handler) { transformedData = { [embedType]: handler.transform( thisData[embedType], otherData[embedType], priority, ), }; } } } // We retain either their retain or insert delta.retain( transformedData, AttributeMap.transform( thisOp.attributes, otherOp.attributes, priority, ), ); } } } return delta.chop(); } transformPosition(index: number, priority = false): number { priority = !!priority; const thisIter = new OpIterator(this.ops); let offset = 0; while (thisIter.hasNext() && offset <= index) { const length = thisIter.peekLength(); const nextType = thisIter.peekType(); thisIter.next(); if (nextType === 'delete') { index -= Math.min(length, index - offset); continue; } else if (nextType === 'insert' && (offset < index || !priority)) { index += length; } offset += length; } return index; } } export default Delta; export { Op, OpIterator, AttributeMap }; if (typeof module === 'object') { module.exports = Delta; module.exports.default = Delta; }