|
|
@@ -10,39 +10,24 @@
|
|
|
|
|
|
'use strict'
|
|
|
const containsNonBmpChars = require('../util').containsNonBmpChars
|
|
|
-const OError = require('@overleaf/o-error')
|
|
|
const EditOperation = require('./edit_operation')
|
|
|
+const {
|
|
|
+ ScanOp,
|
|
|
+ RetainOp,
|
|
|
+ InsertOp,
|
|
|
+ RemoveOp,
|
|
|
+ isRetain,
|
|
|
+ isInsert,
|
|
|
+ isRemove,
|
|
|
+} = require('./scan_op')
|
|
|
+const {
|
|
|
+ UnprocessableError,
|
|
|
+ ApplyError,
|
|
|
+ InvalidInsertionError,
|
|
|
+ TooLongError,
|
|
|
+} = require('../errors')
|
|
|
/** @typedef {import('../file_data/string_file_data')} StringFileData */
|
|
|
|
|
|
-class UnprocessableError extends OError {}
|
|
|
-
|
|
|
-class ApplyError extends UnprocessableError {
|
|
|
- constructor(message, operation, operand) {
|
|
|
- super(message, { operation, operand })
|
|
|
- this.operation = operation
|
|
|
- this.operand = operand
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-class InvalidInsertionError extends UnprocessableError {
|
|
|
- constructor(str, operation) {
|
|
|
- super('inserted text contains non BMP characters', { str, operation })
|
|
|
- this.str = str
|
|
|
- this.operation = operation
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-class TooLongError extends UnprocessableError {
|
|
|
- constructor(operation, resultLength) {
|
|
|
- super(`resulting string would be too long: ${resultLength}`, {
|
|
|
- operation,
|
|
|
- resultLength,
|
|
|
- })
|
|
|
- this.operation = operation
|
|
|
- this.resultLength = resultLength
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
/**
|
|
|
* Create an empty text operation.
|
|
|
* @extends EditOperation
|
|
|
@@ -58,9 +43,6 @@ class TextOperation extends EditOperation {
|
|
|
static ApplyError = ApplyError
|
|
|
static InvalidInsertionError = InvalidInsertionError
|
|
|
static TooLongError = TooLongError
|
|
|
- static isRetain = isRetain
|
|
|
- static isInsert = isInsert
|
|
|
- static isRemove = isRemove
|
|
|
|
|
|
constructor() {
|
|
|
super()
|
|
|
@@ -68,6 +50,7 @@ class TextOperation extends EditOperation {
|
|
|
// if an imaginary cursor runs over the entire string and skips over some
|
|
|
// parts, removes some parts and inserts characters at some positions. These
|
|
|
// actions (skip/remove/insert) are stored as an array in the "ops" property.
|
|
|
+ /** @type {ScanOp[]} */
|
|
|
this.ops = []
|
|
|
// An operation's baseLength is the length of every string the operation
|
|
|
// can be applied to.
|
|
|
@@ -88,7 +71,7 @@ class TextOperation extends EditOperation {
|
|
|
return false
|
|
|
}
|
|
|
for (let i = 0; i < this.ops.length; i++) {
|
|
|
- if (this.ops[i] !== other.ops[i]) {
|
|
|
+ if (!this.ops[i].equals(other.ops[i])) {
|
|
|
return false
|
|
|
}
|
|
|
}
|
|
|
@@ -101,64 +84,76 @@ class TextOperation extends EditOperation {
|
|
|
|
|
|
/**
|
|
|
* Skip over a given number of characters.
|
|
|
+ * @param {number | {r: number}} n
|
|
|
*/
|
|
|
retain(n) {
|
|
|
- if (typeof n !== 'number') {
|
|
|
- throw new Error('retain expects an integer')
|
|
|
- }
|
|
|
if (n === 0) {
|
|
|
return this
|
|
|
}
|
|
|
- this.baseLength += n
|
|
|
- this.targetLength += n
|
|
|
- if (isRetain(this.ops[this.ops.length - 1])) {
|
|
|
+
|
|
|
+ if (!isRetain(n)) {
|
|
|
+ throw new Error('retain expects an integer or a retain object')
|
|
|
+ }
|
|
|
+ const newOp = RetainOp.fromJSON(n)
|
|
|
+
|
|
|
+ if (newOp.length === 0) {
|
|
|
+ return this
|
|
|
+ }
|
|
|
+
|
|
|
+ this.baseLength += newOp.length
|
|
|
+ this.targetLength += newOp.length
|
|
|
+
|
|
|
+ const lastOperation = this.ops[this.ops.length - 1]
|
|
|
+ if (lastOperation?.canMergeWith(newOp)) {
|
|
|
// The last op is a retain op => we can merge them into one op.
|
|
|
- this.ops[this.ops.length - 1] += n
|
|
|
+ lastOperation.mergeWith(newOp)
|
|
|
} else {
|
|
|
// Create a new op.
|
|
|
- this.ops.push(n)
|
|
|
+ this.ops.push(newOp)
|
|
|
}
|
|
|
return this
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Insert a string at the current position.
|
|
|
+ * @param {string | {i: string}} insertValue
|
|
|
*/
|
|
|
- insert(str) {
|
|
|
- if (typeof str !== 'string') {
|
|
|
- throw new Error('insert expects a string')
|
|
|
+ insert(insertValue) {
|
|
|
+ if (!isInsert(insertValue)) {
|
|
|
+ throw new Error('insert expects a string or an insert object')
|
|
|
}
|
|
|
- if (containsNonBmpChars(str)) {
|
|
|
- throw new TextOperation.InvalidInsertionError(str)
|
|
|
- }
|
|
|
- if (str === '') {
|
|
|
+ const newOp = InsertOp.fromJSON(insertValue)
|
|
|
+ if (newOp.insertion === '') {
|
|
|
return this
|
|
|
}
|
|
|
- this.targetLength += str.length
|
|
|
+ this.targetLength += newOp.insertion.length
|
|
|
const ops = this.ops
|
|
|
- if (isInsert(ops[ops.length - 1])) {
|
|
|
+ const lastOp = this.ops[this.ops.length - 1]
|
|
|
+ if (lastOp?.canMergeWith(newOp)) {
|
|
|
// Merge insert op.
|
|
|
- ops[ops.length - 1] += str
|
|
|
- } else if (isRemove(ops[ops.length - 1])) {
|
|
|
+ lastOp.mergeWith(newOp)
|
|
|
+ } else if (lastOp instanceof RemoveOp) {
|
|
|
// It doesn't matter when an operation is applied whether the operation
|
|
|
// is remove(3), insert("something") or insert("something"), remove(3).
|
|
|
// Here we enforce that in this case, the insert op always comes first.
|
|
|
// This makes all operations that have the same effect when applied to
|
|
|
// a document of the right length equal in respect to the `equals` method.
|
|
|
- if (isInsert(ops[ops.length - 2])) {
|
|
|
- ops[ops.length - 2] += str
|
|
|
+ const secondToLastOp = ops[ops.length - 2]
|
|
|
+ if (secondToLastOp?.canMergeWith(newOp)) {
|
|
|
+ secondToLastOp.mergeWith(newOp)
|
|
|
} else {
|
|
|
ops[ops.length] = ops[ops.length - 1]
|
|
|
- ops[ops.length - 2] = str
|
|
|
+ ops[ops.length - 2] = newOp
|
|
|
}
|
|
|
} else {
|
|
|
- ops.push(str)
|
|
|
+ ops.push(newOp)
|
|
|
}
|
|
|
return this
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Remove a string at the current position.
|
|
|
+ * @param {number | string} n
|
|
|
*/
|
|
|
remove(n) {
|
|
|
if (typeof n === 'string') {
|
|
|
@@ -173,11 +168,13 @@ class TextOperation extends EditOperation {
|
|
|
if (n > 0) {
|
|
|
n = -n
|
|
|
}
|
|
|
+ const newOp = RemoveOp.fromJSON(n)
|
|
|
this.baseLength -= n
|
|
|
- if (isRemove(this.ops[this.ops.length - 1])) {
|
|
|
- this.ops[this.ops.length - 1] += n
|
|
|
+ const lastOp = this.ops[this.ops.length - 1]
|
|
|
+ if (lastOp?.canMergeWith(newOp)) {
|
|
|
+ lastOp.mergeWith(newOp)
|
|
|
} else {
|
|
|
- this.ops.push(n)
|
|
|
+ this.ops.push(newOp)
|
|
|
}
|
|
|
return this
|
|
|
}
|
|
|
@@ -187,7 +184,8 @@ class TextOperation extends EditOperation {
|
|
|
*/
|
|
|
isNoop() {
|
|
|
return (
|
|
|
- this.ops.length === 0 || (this.ops.length === 1 && isRetain(this.ops[0]))
|
|
|
+ this.ops.length === 0 ||
|
|
|
+ (this.ops.length === 1 && this.ops[0] instanceof RetainOp)
|
|
|
)
|
|
|
}
|
|
|
|
|
|
@@ -195,24 +193,14 @@ class TextOperation extends EditOperation {
|
|
|
* Pretty printing.
|
|
|
*/
|
|
|
toString() {
|
|
|
- return this.ops
|
|
|
- .map(op => {
|
|
|
- if (isRetain(op)) {
|
|
|
- return 'retain ' + op
|
|
|
- } else if (isInsert(op)) {
|
|
|
- return "insert '" + op + "'"
|
|
|
- } else {
|
|
|
- return 'remove ' + -op
|
|
|
- }
|
|
|
- })
|
|
|
- .join(', ')
|
|
|
+ return this.ops.map(op => op.toString()).join(', ')
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @inheritdoc
|
|
|
*/
|
|
|
toJSON() {
|
|
|
- return { textOperation: this.ops }
|
|
|
+ return { textOperation: this.ops.map(op => op.toJSON()) }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -220,8 +208,7 @@ class TextOperation extends EditOperation {
|
|
|
*/
|
|
|
static fromJSON = function ({ textOperation: ops }) {
|
|
|
const o = new TextOperation()
|
|
|
- for (let i = 0, l = ops.length; i < l; i++) {
|
|
|
- const op = ops[i]
|
|
|
+ for (const op of ops) {
|
|
|
if (isRetain(op)) {
|
|
|
o.retain(op)
|
|
|
} else if (isInsert(op)) {
|
|
|
@@ -229,12 +216,7 @@ class TextOperation extends EditOperation {
|
|
|
} else if (isRemove(op)) {
|
|
|
o.remove(op)
|
|
|
} else {
|
|
|
- throw new Error(
|
|
|
- 'unknown operation: ' +
|
|
|
- JSON.stringify(op) +
|
|
|
- ' in ' +
|
|
|
- JSON.stringify(ops)
|
|
|
- )
|
|
|
+ throw new UnprocessableError('unknown operation: ' + JSON.stringify(op))
|
|
|
}
|
|
|
}
|
|
|
return o
|
|
|
@@ -265,36 +247,13 @@ class TextOperation extends EditOperation {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- // Build up the result string directly by concatenation (which is actually
|
|
|
- // faster than joining arrays because it is optimised in v8).
|
|
|
- let result = ''
|
|
|
- let strIndex = 0
|
|
|
const ops = this.ops
|
|
|
- for (let i = 0, l = ops.length; i < l; i++) {
|
|
|
- const op = ops[i]
|
|
|
- if (isRetain(op)) {
|
|
|
- if (strIndex + op > str.length) {
|
|
|
- throw new TextOperation.ApplyError(
|
|
|
- "Operation can't retain more chars than are left in the string.",
|
|
|
- operation,
|
|
|
- str
|
|
|
- )
|
|
|
- }
|
|
|
- // Copy skipped part of the old string.
|
|
|
- result += str.slice(strIndex, strIndex + op)
|
|
|
- strIndex += op
|
|
|
- } else if (isInsert(op)) {
|
|
|
- if (containsNonBmpChars(op)) {
|
|
|
- throw new TextOperation.InvalidInsertionError(str, operation)
|
|
|
- }
|
|
|
- // Insert string.
|
|
|
- result += op
|
|
|
- } else {
|
|
|
- // remove op
|
|
|
- strIndex -= op
|
|
|
- }
|
|
|
- }
|
|
|
- if (strIndex !== str.length) {
|
|
|
+ const { inputCursor, result } = ops.reduce(
|
|
|
+ (intermediate, op) => op.apply(str, intermediate),
|
|
|
+ { result: '', inputCursor: 0 }
|
|
|
+ )
|
|
|
+
|
|
|
+ if (inputCursor !== str.length) {
|
|
|
throw new TextOperation.ApplyError(
|
|
|
"The operation didn't operate on the whole string.",
|
|
|
operation,
|
|
|
@@ -321,31 +280,13 @@ class TextOperation extends EditOperation {
|
|
|
length
|
|
|
)
|
|
|
}
|
|
|
- let newLength = 0
|
|
|
- let strIndex = 0
|
|
|
- const ops = this.ops
|
|
|
- for (let i = 0, l = ops.length; i < l; i++) {
|
|
|
- const op = ops[i]
|
|
|
- if (isRetain(op)) {
|
|
|
- if (strIndex + op > length) {
|
|
|
- throw new TextOperation.ApplyError(
|
|
|
- "Operation can't retain more chars than are left in the string.",
|
|
|
- operation,
|
|
|
- length
|
|
|
- )
|
|
|
- }
|
|
|
- // Copy skipped part of the old string.
|
|
|
- newLength += op
|
|
|
- strIndex += op
|
|
|
- } else if (isInsert(op)) {
|
|
|
- // Insert string.
|
|
|
- newLength += op.length
|
|
|
- } else {
|
|
|
- // remove op
|
|
|
- strIndex -= op
|
|
|
- }
|
|
|
- }
|
|
|
- if (strIndex !== length) {
|
|
|
+
|
|
|
+ const { length: newLength, inputCursor } = this.ops.reduce(
|
|
|
+ (intermediate, op) => op.applyToLength(intermediate),
|
|
|
+ { length: 0, inputCursor: 0, inputLength: length }
|
|
|
+ )
|
|
|
+
|
|
|
+ if (inputCursor !== length) {
|
|
|
throw new TextOperation.ApplyError(
|
|
|
"The operation didn't operate on the whole string.",
|
|
|
operation,
|
|
|
@@ -369,15 +310,17 @@ class TextOperation extends EditOperation {
|
|
|
const ops = this.ops
|
|
|
for (let i = 0, l = ops.length; i < l; i++) {
|
|
|
const op = ops[i]
|
|
|
- if (isRetain(op)) {
|
|
|
- inverse.retain(op)
|
|
|
- strIndex += op
|
|
|
- } else if (isInsert(op)) {
|
|
|
- inverse.remove(op.length)
|
|
|
- } else {
|
|
|
+ if (op instanceof RetainOp) {
|
|
|
+ inverse.retain(op.length)
|
|
|
+ strIndex += op.length
|
|
|
+ } else if (op instanceof InsertOp) {
|
|
|
+ inverse.remove(op.insertion.length)
|
|
|
+ } else if (op instanceof RemoveOp) {
|
|
|
// remove op
|
|
|
- inverse.insert(str.slice(strIndex, strIndex - op))
|
|
|
- strIndex -= op
|
|
|
+ inverse.insert(str.slice(strIndex, strIndex + op.length))
|
|
|
+ strIndex += op.length
|
|
|
+ } else {
|
|
|
+ throw new UnprocessableError('unknown scanop during inversion')
|
|
|
}
|
|
|
}
|
|
|
return inverse
|
|
|
@@ -404,14 +347,14 @@ class TextOperation extends EditOperation {
|
|
|
return false
|
|
|
}
|
|
|
|
|
|
- if (isInsert(simpleA) && isInsert(simpleB)) {
|
|
|
- return startA + simpleA.length === startB
|
|
|
+ if (simpleA instanceof InsertOp && simpleB instanceof InsertOp) {
|
|
|
+ return startA + simpleA.insertion.length === startB
|
|
|
}
|
|
|
|
|
|
- if (isRemove(simpleA) && isRemove(simpleB)) {
|
|
|
+ if (simpleA instanceof RemoveOp && simpleB instanceof RemoveOp) {
|
|
|
// there are two possibilities to delete: with backspace and with the
|
|
|
// delete key.
|
|
|
- return startB - simpleB === startA || startA === startB
|
|
|
+ return startB + simpleB.length === startA || startA === startB
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
@@ -460,13 +403,14 @@ class TextOperation extends EditOperation {
|
|
|
break
|
|
|
}
|
|
|
|
|
|
- if (isRemove(op1)) {
|
|
|
- operation.remove(op1)
|
|
|
+ if (op1 instanceof RemoveOp) {
|
|
|
+ operation.remove(-op1.length)
|
|
|
op1 = ops1[i1++]
|
|
|
continue
|
|
|
}
|
|
|
- if (isInsert(op2)) {
|
|
|
- operation.insert(op2)
|
|
|
+
|
|
|
+ if (op2 instanceof InsertOp) {
|
|
|
+ operation.insert(op2.insertion)
|
|
|
op2 = ops2[i2++]
|
|
|
continue
|
|
|
}
|
|
|
@@ -482,57 +426,57 @@ class TextOperation extends EditOperation {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- if (isRetain(op1) && isRetain(op2)) {
|
|
|
- if (op1 > op2) {
|
|
|
- operation.retain(op2)
|
|
|
- op1 = op1 - op2
|
|
|
+ if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
|
|
|
+ if (op1.length > op2.length) {
|
|
|
+ operation.retain(op2.length)
|
|
|
+ op1 = ScanOp.fromJSON(op1.length - op2.length)
|
|
|
op2 = ops2[i2++]
|
|
|
- } else if (op1 === op2) {
|
|
|
- operation.retain(op1)
|
|
|
+ } else if (op1.length === op2.length) {
|
|
|
+ operation.retain(op1.length)
|
|
|
op1 = ops1[i1++]
|
|
|
op2 = ops2[i2++]
|
|
|
} else {
|
|
|
- operation.retain(op1)
|
|
|
- op2 = op2 - op1
|
|
|
+ operation.retain(op1.length)
|
|
|
+ op2 = ScanOp.fromJSON(op2.length - op1.length)
|
|
|
op1 = ops1[i1++]
|
|
|
}
|
|
|
- } else if (isInsert(op1) && isRemove(op2)) {
|
|
|
- if (op1.length > -op2) {
|
|
|
- op1 = op1.slice(-op2)
|
|
|
+ } else if (op1 instanceof InsertOp && op2 instanceof RemoveOp) {
|
|
|
+ if (op1.insertion.length > op2.length) {
|
|
|
+ op1 = ScanOp.fromJSON(op1.insertion.slice(op2.length))
|
|
|
op2 = ops2[i2++]
|
|
|
- } else if (op1.length === -op2) {
|
|
|
+ } else if (op1.insertion.length === op2.length) {
|
|
|
op1 = ops1[i1++]
|
|
|
op2 = ops2[i2++]
|
|
|
} else {
|
|
|
- op2 = op2 + op1.length
|
|
|
+ op2 = ScanOp.fromJSON(-op2.length + op1.insertion.length)
|
|
|
op1 = ops1[i1++]
|
|
|
}
|
|
|
- } else if (isInsert(op1) && isRetain(op2)) {
|
|
|
- if (op1.length > op2) {
|
|
|
- operation.insert(op1.slice(0, op2))
|
|
|
- op1 = op1.slice(op2)
|
|
|
+ } else if (op1 instanceof InsertOp && op2 instanceof RetainOp) {
|
|
|
+ if (op1.insertion.length > op2.length) {
|
|
|
+ operation.insert(op1.insertion.slice(0, op2.length))
|
|
|
+ op1 = ScanOp.fromJSON(op1.insertion.slice(op2.length))
|
|
|
op2 = ops2[i2++]
|
|
|
- } else if (op1.length === op2) {
|
|
|
- operation.insert(op1)
|
|
|
+ } else if (op1.insertion.length === op2.length) {
|
|
|
+ operation.insert(op1.insertion)
|
|
|
op1 = ops1[i1++]
|
|
|
op2 = ops2[i2++]
|
|
|
} else {
|
|
|
- operation.insert(op1)
|
|
|
- op2 = op2 - op1.length
|
|
|
+ operation.insert(op1.insertion)
|
|
|
+ op2 = ScanOp.fromJSON(op2.length - op1.insertion.length)
|
|
|
op1 = ops1[i1++]
|
|
|
}
|
|
|
- } else if (isRetain(op1) && isRemove(op2)) {
|
|
|
- if (op1 > -op2) {
|
|
|
- operation.remove(op2)
|
|
|
- op1 = op1 + op2
|
|
|
+ } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
|
|
|
+ if (op1.length > op2.length) {
|
|
|
+ operation.remove(-op2.length)
|
|
|
+ op1 = ScanOp.fromJSON(op1.length - op2.length)
|
|
|
op2 = ops2[i2++]
|
|
|
- } else if (op1 === -op2) {
|
|
|
- operation.remove(op2)
|
|
|
+ } else if (op1.length === op2.length) {
|
|
|
+ operation.remove(-op2.length)
|
|
|
op1 = ops1[i1++]
|
|
|
op2 = ops2[i2++]
|
|
|
} else {
|
|
|
- operation.remove(op1)
|
|
|
- op2 = op2 + op1
|
|
|
+ operation.remove(op1.length)
|
|
|
+ op2 = ScanOp.fromJSON(-op2.length + op1.length)
|
|
|
op1 = ops1[i1++]
|
|
|
}
|
|
|
} else {
|
|
|
@@ -581,15 +525,15 @@ class TextOperation extends EditOperation {
|
|
|
// next two cases: one or both ops are insert ops
|
|
|
// => insert the string in the corresponding prime operation, skip it in
|
|
|
// the other one. If both op1 and op2 are insert ops, prefer op1.
|
|
|
- if (isInsert(op1)) {
|
|
|
- operation1prime.insert(op1)
|
|
|
- operation2prime.retain(op1.length)
|
|
|
+ if (op1 instanceof InsertOp) {
|
|
|
+ operation1prime.insert(op1.insertion)
|
|
|
+ operation2prime.retain(op1.insertion.length)
|
|
|
op1 = ops1[i1++]
|
|
|
continue
|
|
|
}
|
|
|
- if (isInsert(op2)) {
|
|
|
- operation1prime.retain(op2.length)
|
|
|
- operation2prime.insert(op2)
|
|
|
+ if (op2 instanceof InsertOp) {
|
|
|
+ operation1prime.retain(op2.insertion.length)
|
|
|
+ operation2prime.insert(op2.insertion)
|
|
|
op2 = ops2[i2++]
|
|
|
continue
|
|
|
}
|
|
|
@@ -606,65 +550,65 @@ class TextOperation extends EditOperation {
|
|
|
}
|
|
|
|
|
|
let minl
|
|
|
- if (isRetain(op1) && isRetain(op2)) {
|
|
|
+ if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
|
|
|
// Simple case: retain/retain
|
|
|
- if (op1 > op2) {
|
|
|
- minl = op2
|
|
|
- op1 = op1 - op2
|
|
|
+ if (op1.length > op2.length) {
|
|
|
+ minl = op2.length
|
|
|
+ op1 = ScanOp.fromJSON(op1.length - op2.length)
|
|
|
op2 = ops2[i2++]
|
|
|
- } else if (op1 === op2) {
|
|
|
- minl = op2
|
|
|
+ } else if (op1.length === op2.length) {
|
|
|
+ minl = op2.length
|
|
|
op1 = ops1[i1++]
|
|
|
op2 = ops2[i2++]
|
|
|
} else {
|
|
|
- minl = op1
|
|
|
- op2 = op2 - op1
|
|
|
+ minl = op1.length
|
|
|
+ op2 = ScanOp.fromJSON(op2.length - op1.length)
|
|
|
op1 = ops1[i1++]
|
|
|
}
|
|
|
operation1prime.retain(minl)
|
|
|
operation2prime.retain(minl)
|
|
|
- } else if (isRemove(op1) && isRemove(op2)) {
|
|
|
+ } else if (op1 instanceof RemoveOp && op2 instanceof RemoveOp) {
|
|
|
// Both operations remove the same string at the same position. We don't
|
|
|
// need to produce any operations, we just skip over the remove ops and
|
|
|
// handle the case that one operation removes more than the other.
|
|
|
- if (-op1 > -op2) {
|
|
|
- op1 = op1 - op2
|
|
|
+ if (op1.length > op2.length) {
|
|
|
+ op1 = ScanOp.fromJSON(-op1.length - -op2.length)
|
|
|
op2 = ops2[i2++]
|
|
|
- } else if (op1 === op2) {
|
|
|
+ } else if (op1.length === op2.length) {
|
|
|
op1 = ops1[i1++]
|
|
|
op2 = ops2[i2++]
|
|
|
} else {
|
|
|
- op2 = op2 - op1
|
|
|
+ op2 = ScanOp.fromJSON(-op2.length - -op1.length)
|
|
|
op1 = ops1[i1++]
|
|
|
}
|
|
|
// next two cases: remove/retain and retain/remove
|
|
|
- } else if (isRemove(op1) && isRetain(op2)) {
|
|
|
- if (-op1 > op2) {
|
|
|
- minl = op2
|
|
|
- op1 = op1 + op2
|
|
|
+ } else if (op1 instanceof RemoveOp && op2 instanceof RetainOp) {
|
|
|
+ if (op1.length > op2.length) {
|
|
|
+ minl = op2.length
|
|
|
+ op1 = ScanOp.fromJSON(-op1.length + op2.length)
|
|
|
op2 = ops2[i2++]
|
|
|
- } else if (-op1 === op2) {
|
|
|
- minl = op2
|
|
|
+ } else if (op1.length === op2.length) {
|
|
|
+ minl = op2.length
|
|
|
op1 = ops1[i1++]
|
|
|
op2 = ops2[i2++]
|
|
|
} else {
|
|
|
- minl = -op1
|
|
|
- op2 = op2 + op1
|
|
|
+ minl = op1.length
|
|
|
+ op2 = ScanOp.fromJSON(op2.length + -op1.length)
|
|
|
op1 = ops1[i1++]
|
|
|
}
|
|
|
operation1prime.remove(minl)
|
|
|
- } else if (isRetain(op1) && isRemove(op2)) {
|
|
|
- if (op1 > -op2) {
|
|
|
- minl = -op2
|
|
|
- op1 = op1 + op2
|
|
|
+ } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
|
|
|
+ if (op1.length > op2.length) {
|
|
|
+ minl = op2.length
|
|
|
+ op1 = ScanOp.fromJSON(op1.length + -op2.length)
|
|
|
op2 = ops2[i2++]
|
|
|
- } else if (op1 === -op2) {
|
|
|
- minl = op1
|
|
|
+ } else if (op1.length === op2.length) {
|
|
|
+ minl = op1.length
|
|
|
op1 = ops1[i1++]
|
|
|
op2 = ops2[i2++]
|
|
|
} else {
|
|
|
- minl = op1
|
|
|
- op2 = op2 + op1
|
|
|
+ minl = op1.length
|
|
|
+ op2 = ScanOp.fromJSON(-op2.length + op1.length)
|
|
|
op1 = ops1[i1++]
|
|
|
}
|
|
|
operation2prime.remove(minl)
|
|
|
@@ -685,27 +629,24 @@ class TextOperation extends EditOperation {
|
|
|
// Represented by strings.
|
|
|
// * Remove ops: Remove the next n characters. Represented by negative ints.
|
|
|
|
|
|
-function isRetain(op) {
|
|
|
- return typeof op === 'number' && op > 0
|
|
|
-}
|
|
|
-
|
|
|
-function isInsert(op) {
|
|
|
- return typeof op === 'string'
|
|
|
-}
|
|
|
-
|
|
|
-function isRemove(op) {
|
|
|
- return typeof op === 'number' && op < 0
|
|
|
-}
|
|
|
-
|
|
|
-function getSimpleOp(operation, fn) {
|
|
|
+/**
|
|
|
+ *
|
|
|
+ * @param {TextOperation} operation
|
|
|
+ * @returns {ScanOp | null}
|
|
|
+ */
|
|
|
+function getSimpleOp(operation) {
|
|
|
const ops = operation.ops
|
|
|
switch (ops.length) {
|
|
|
case 1:
|
|
|
return ops[0]
|
|
|
case 2:
|
|
|
- return isRetain(ops[0]) ? ops[1] : isRetain(ops[1]) ? ops[0] : null
|
|
|
+ return ops[0] instanceof RetainOp
|
|
|
+ ? ops[1]
|
|
|
+ : ops[1] instanceof RetainOp
|
|
|
+ ? ops[0]
|
|
|
+ : null
|
|
|
case 3:
|
|
|
- if (isRetain(ops[0]) && isRetain(ops[2])) {
|
|
|
+ if (ops[0] instanceof RetainOp && ops[2] instanceof RetainOp) {
|
|
|
return ops[1]
|
|
|
}
|
|
|
}
|
|
|
@@ -713,8 +654,8 @@ function getSimpleOp(operation, fn) {
|
|
|
}
|
|
|
|
|
|
function getStartIndex(operation) {
|
|
|
- if (isRetain(operation.ops[0])) {
|
|
|
- return operation.ops[0]
|
|
|
+ if (operation.ops[0] instanceof RetainOp) {
|
|
|
+ return operation.ops[0].length
|
|
|
}
|
|
|
return 0
|
|
|
}
|