mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
[overleaf-editor-core] Introduce ScanOp and subclasses (#16695)
* [overleaf-editor-core] Introduce ScanOp and subclasses * [overleaf-editor-core] Remove unused methods * [overleaf-editor-core] Add tests for ScanOps * [overleaf-editor-core] Simplify merge * [overleaf-editor-core] Make ApplyContexts mutable * [overleaf-editor-core] Remove unnecessary containsNonBmpChars check * [overleaf-editor-core] Revert to using reduce * [overleaf-editor-core] Modify inputCursor after using it * [overleaf-editor-core] Rename DeleteOp to RemoveOp * [overleaf-editor-core] Remove useless constructor * [overleaf-editor-core] Mutate in mergeWith * [overleaf-editor-core] Add check for out-of-bounds retain * [overleaf-editor-core] Move error import GitOrigin-RevId: d07bd58177579638551257d56f087e8967e5b52f
This commit is contained in:
parent
b87dad77d9
commit
63ff16843c
7 changed files with 880 additions and 241 deletions
37
libraries/overleaf-editor-core/lib/errors.js
Normal file
37
libraries/overleaf-editor-core/lib/errors.js
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
const OError = require('@overleaf/o-error')
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
UnprocessableError,
|
||||||
|
ApplyError,
|
||||||
|
InvalidInsertionError,
|
||||||
|
TooLongError,
|
||||||
|
}
|
364
libraries/overleaf-editor-core/lib/operation/scan_op.js
Normal file
364
libraries/overleaf-editor-core/lib/operation/scan_op.js
Normal file
|
@ -0,0 +1,364 @@
|
||||||
|
// @ts-check
|
||||||
|
const { containsNonBmpChars } = require('../util')
|
||||||
|
const {
|
||||||
|
ApplyError,
|
||||||
|
InvalidInsertionError,
|
||||||
|
UnprocessableError,
|
||||||
|
} = require('../errors')
|
||||||
|
|
||||||
|
/** @typedef {{ result: string, inputCursor: number}} ApplyContext */
|
||||||
|
/** @typedef {{ length: number, inputCursor: number, readonly inputLength: number}} LengthApplyContext */
|
||||||
|
|
||||||
|
class ScanOp {
|
||||||
|
constructor() {
|
||||||
|
if (this.constructor === ScanOp) {
|
||||||
|
throw new Error('Cannot instantiate abstract class')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies an operation to a string
|
||||||
|
* @param {string} input
|
||||||
|
* @param {ApplyContext} current
|
||||||
|
* @returns {ApplyContext}
|
||||||
|
*/
|
||||||
|
apply(input, current) {
|
||||||
|
throw new Error('abstract method')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies an operation to a length
|
||||||
|
* @param {LengthApplyContext} current
|
||||||
|
* @returns {LengthApplyContext}
|
||||||
|
*/
|
||||||
|
applyToLength(current) {
|
||||||
|
throw new Error('abstract method')
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
throw new Error('abstract method')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} raw
|
||||||
|
* @returns {ScanOp}
|
||||||
|
*/
|
||||||
|
static fromJSON(raw) {
|
||||||
|
if (isRetain(raw)) {
|
||||||
|
return RetainOp.fromJSON(raw)
|
||||||
|
} else if (isInsert(raw)) {
|
||||||
|
return InsertOp.fromJSON(raw)
|
||||||
|
} else if (isRemove(raw)) {
|
||||||
|
return RemoveOp.fromJSON(raw)
|
||||||
|
}
|
||||||
|
throw new UnprocessableError(`Invalid ScanOp ${JSON.stringify(raw)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests whether two ScanOps are equal
|
||||||
|
* @param {ScanOp} _other
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
equals(_other) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests whether two ScanOps can be merged into a single operation
|
||||||
|
* @param {ScanOp} other
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
canMergeWith(other) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge two ScanOps into a single operation
|
||||||
|
* @param {ScanOp} _other
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
mergeWith(_other) {
|
||||||
|
throw new Error('abstract method')
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
'ScanOp'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InsertOp extends ScanOp {
|
||||||
|
constructor(insertion) {
|
||||||
|
super()
|
||||||
|
if (typeof insertion !== 'string') {
|
||||||
|
throw new InvalidInsertionError('insertion must be a string')
|
||||||
|
}
|
||||||
|
if (containsNonBmpChars(insertion)) {
|
||||||
|
throw new InvalidInsertionError('insertion contains non-BMP characters')
|
||||||
|
}
|
||||||
|
this.insertion = insertion
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {{i: string} | string} op
|
||||||
|
* @returns {InsertOp}
|
||||||
|
*/
|
||||||
|
static fromJSON(op) {
|
||||||
|
if (typeof op === 'string') {
|
||||||
|
return new InsertOp(op)
|
||||||
|
}
|
||||||
|
// It must be an object with an 'i' property.
|
||||||
|
if (typeof op.i !== 'string') {
|
||||||
|
throw new InvalidInsertionError(
|
||||||
|
'insert operation must have a string property'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return new InsertOp(op.i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
* @param {string} input
|
||||||
|
* @param {ApplyContext} current
|
||||||
|
* @returns {ApplyContext}
|
||||||
|
* */
|
||||||
|
apply(input, current) {
|
||||||
|
if (containsNonBmpChars(this.insertion)) {
|
||||||
|
throw new InvalidInsertionError(input, this.toJSON())
|
||||||
|
}
|
||||||
|
current.result += this.insertion
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
* @param {LengthApplyContext} current
|
||||||
|
* @returns {LengthApplyContext}
|
||||||
|
*/
|
||||||
|
applyToLength(current) {
|
||||||
|
current.length += this.insertion.length
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
equals(other) {
|
||||||
|
if (!(other instanceof InsertOp)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return this.insertion === other.insertion
|
||||||
|
}
|
||||||
|
|
||||||
|
canMergeWith(other) {
|
||||||
|
return other instanceof InsertOp
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeWith(other) {
|
||||||
|
if (!(other instanceof InsertOp)) {
|
||||||
|
throw new Error('Cannot merge with incompatible operation')
|
||||||
|
}
|
||||||
|
this.insertion += other.insertion
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
// TODO: Once we add metadata to the operation, generate an object rather
|
||||||
|
// than the compact representation.
|
||||||
|
return this.insertion
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return `insert '${this.insertion}'`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RetainOp extends ScanOp {
|
||||||
|
constructor(length) {
|
||||||
|
super()
|
||||||
|
if (length < 0) {
|
||||||
|
throw new Error('length must be non-negative')
|
||||||
|
}
|
||||||
|
this.length = length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
* @param {string} input
|
||||||
|
* @param {ApplyContext} current
|
||||||
|
* @returns {ApplyContext}
|
||||||
|
* */
|
||||||
|
apply(input, current) {
|
||||||
|
if (current.inputCursor + this.length > input.length) {
|
||||||
|
throw new ApplyError(
|
||||||
|
"Operation can't retain more chars than are left in the string.",
|
||||||
|
this.toJSON(),
|
||||||
|
input
|
||||||
|
)
|
||||||
|
}
|
||||||
|
current.result += input.slice(
|
||||||
|
current.inputCursor,
|
||||||
|
current.inputCursor + this.length
|
||||||
|
)
|
||||||
|
current.inputCursor += this.length
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
* @param {LengthApplyContext} current
|
||||||
|
* @returns {LengthApplyContext}
|
||||||
|
*/
|
||||||
|
applyToLength(current) {
|
||||||
|
if (current.inputCursor + this.length > current.inputLength) {
|
||||||
|
throw new ApplyError(
|
||||||
|
"Operation can't retain more chars than are left in the string.",
|
||||||
|
this.toJSON(),
|
||||||
|
current.inputLength
|
||||||
|
)
|
||||||
|
}
|
||||||
|
current.length += this.length
|
||||||
|
current.inputCursor += this.length
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number | {r: number}} op
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
static fromJSON(op) {
|
||||||
|
if (typeof op === 'number') {
|
||||||
|
return new RetainOp(op)
|
||||||
|
}
|
||||||
|
// It must be an object with a 'r' property.
|
||||||
|
if (typeof op.r !== 'number') {
|
||||||
|
throw new Error('retain operation must have a number property')
|
||||||
|
}
|
||||||
|
return new RetainOp(op.r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
equals(other) {
|
||||||
|
if (!(other instanceof RetainOp)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return this.length === other.length
|
||||||
|
}
|
||||||
|
|
||||||
|
canMergeWith(other) {
|
||||||
|
return other instanceof RetainOp
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeWith(other) {
|
||||||
|
if (!(other instanceof RetainOp)) {
|
||||||
|
throw new Error('Cannot merge with incompatible operation')
|
||||||
|
}
|
||||||
|
this.length += other.length
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
// TODO: Once we add metadata to the operation, generate an object rather
|
||||||
|
// than the compact representation.
|
||||||
|
return this.length
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return `retain ${this.length}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoveOp extends ScanOp {
|
||||||
|
constructor(length) {
|
||||||
|
super()
|
||||||
|
if (length < 0) {
|
||||||
|
throw new Error('length must be non-negative')
|
||||||
|
}
|
||||||
|
this.length = length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
* @param {string} _input
|
||||||
|
* @param {ApplyContext} current
|
||||||
|
* @returns {ApplyContext}
|
||||||
|
*/
|
||||||
|
apply(_input, current) {
|
||||||
|
current.inputCursor += this.length
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
* @param {LengthApplyContext} current
|
||||||
|
* @returns {LengthApplyContext}
|
||||||
|
*/
|
||||||
|
applyToLength(current) {
|
||||||
|
current.inputCursor += this.length
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} op
|
||||||
|
* @returns {RemoveOp}
|
||||||
|
*/
|
||||||
|
static fromJSON(op) {
|
||||||
|
if (typeof op !== 'number' || op > 0) {
|
||||||
|
throw new Error('delete operation must be a negative number')
|
||||||
|
}
|
||||||
|
return new RemoveOp(-op)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
equals(other) {
|
||||||
|
if (!(other instanceof RemoveOp)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return this.length === other.length
|
||||||
|
}
|
||||||
|
|
||||||
|
canMergeWith(other) {
|
||||||
|
return other instanceof RemoveOp
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeWith(other) {
|
||||||
|
if (!(other instanceof RemoveOp)) {
|
||||||
|
throw new Error('Cannot merge with incompatible operation')
|
||||||
|
}
|
||||||
|
this.length += other.length
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return -this.length
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return `remove ${this.length}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetain(op) {
|
||||||
|
return (
|
||||||
|
(typeof op === 'number' && op > 0) ||
|
||||||
|
(typeof op === 'object' && typeof op.r === 'number' && op.r > 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsert(op) {
|
||||||
|
return (
|
||||||
|
typeof op === 'string' ||
|
||||||
|
(typeof op === 'object' && typeof op.i === 'string')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRemove(op) {
|
||||||
|
return typeof op === 'number' && op < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ScanOp,
|
||||||
|
InsertOp,
|
||||||
|
RetainOp,
|
||||||
|
RemoveOp,
|
||||||
|
isRetain,
|
||||||
|
isInsert,
|
||||||
|
isRemove,
|
||||||
|
}
|
|
@ -10,39 +10,24 @@
|
||||||
|
|
||||||
'use strict'
|
'use strict'
|
||||||
const containsNonBmpChars = require('../util').containsNonBmpChars
|
const containsNonBmpChars = require('../util').containsNonBmpChars
|
||||||
const OError = require('@overleaf/o-error')
|
|
||||||
const EditOperation = require('./edit_operation')
|
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 */
|
/** @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.
|
* Create an empty text operation.
|
||||||
* @extends EditOperation
|
* @extends EditOperation
|
||||||
|
@ -58,9 +43,6 @@ class TextOperation extends EditOperation {
|
||||||
static ApplyError = ApplyError
|
static ApplyError = ApplyError
|
||||||
static InvalidInsertionError = InvalidInsertionError
|
static InvalidInsertionError = InvalidInsertionError
|
||||||
static TooLongError = TooLongError
|
static TooLongError = TooLongError
|
||||||
static isRetain = isRetain
|
|
||||||
static isInsert = isInsert
|
|
||||||
static isRemove = isRemove
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
@ -68,6 +50,7 @@ class TextOperation extends EditOperation {
|
||||||
// if an imaginary cursor runs over the entire string and skips over some
|
// if an imaginary cursor runs over the entire string and skips over some
|
||||||
// parts, removes some parts and inserts characters at some positions. These
|
// parts, removes some parts and inserts characters at some positions. These
|
||||||
// actions (skip/remove/insert) are stored as an array in the "ops" property.
|
// actions (skip/remove/insert) are stored as an array in the "ops" property.
|
||||||
|
/** @type {ScanOp[]} */
|
||||||
this.ops = []
|
this.ops = []
|
||||||
// An operation's baseLength is the length of every string the operation
|
// An operation's baseLength is the length of every string the operation
|
||||||
// can be applied to.
|
// can be applied to.
|
||||||
|
@ -88,7 +71,7 @@ class TextOperation extends EditOperation {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for (let i = 0; i < this.ops.length; i++) {
|
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
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -101,64 +84,76 @@ class TextOperation extends EditOperation {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Skip over a given number of characters.
|
* Skip over a given number of characters.
|
||||||
|
* @param {number | {r: number}} n
|
||||||
*/
|
*/
|
||||||
retain(n) {
|
retain(n) {
|
||||||
if (typeof n !== 'number') {
|
|
||||||
throw new Error('retain expects an integer')
|
|
||||||
}
|
|
||||||
if (n === 0) {
|
if (n === 0) {
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
this.baseLength += n
|
|
||||||
this.targetLength += n
|
if (!isRetain(n)) {
|
||||||
if (isRetain(this.ops[this.ops.length - 1])) {
|
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.
|
// 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 {
|
} else {
|
||||||
// Create a new op.
|
// Create a new op.
|
||||||
this.ops.push(n)
|
this.ops.push(newOp)
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a string at the current position.
|
* Insert a string at the current position.
|
||||||
|
* @param {string | {i: string}} insertValue
|
||||||
*/
|
*/
|
||||||
insert(str) {
|
insert(insertValue) {
|
||||||
if (typeof str !== 'string') {
|
if (!isInsert(insertValue)) {
|
||||||
throw new Error('insert expects a string')
|
throw new Error('insert expects a string or an insert object')
|
||||||
}
|
}
|
||||||
if (containsNonBmpChars(str)) {
|
const newOp = InsertOp.fromJSON(insertValue)
|
||||||
throw new TextOperation.InvalidInsertionError(str)
|
if (newOp.insertion === '') {
|
||||||
}
|
|
||||||
if (str === '') {
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
this.targetLength += str.length
|
this.targetLength += newOp.insertion.length
|
||||||
const ops = this.ops
|
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.
|
// Merge insert op.
|
||||||
ops[ops.length - 1] += str
|
lastOp.mergeWith(newOp)
|
||||||
} else if (isRemove(ops[ops.length - 1])) {
|
} else if (lastOp instanceof RemoveOp) {
|
||||||
// It doesn't matter when an operation is applied whether the operation
|
// It doesn't matter when an operation is applied whether the operation
|
||||||
// is remove(3), insert("something") or insert("something"), remove(3).
|
// is remove(3), insert("something") or insert("something"), remove(3).
|
||||||
// Here we enforce that in this case, the insert op always comes first.
|
// 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
|
// 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.
|
// a document of the right length equal in respect to the `equals` method.
|
||||||
if (isInsert(ops[ops.length - 2])) {
|
const secondToLastOp = ops[ops.length - 2]
|
||||||
ops[ops.length - 2] += str
|
if (secondToLastOp?.canMergeWith(newOp)) {
|
||||||
|
secondToLastOp.mergeWith(newOp)
|
||||||
} else {
|
} else {
|
||||||
ops[ops.length] = ops[ops.length - 1]
|
ops[ops.length] = ops[ops.length - 1]
|
||||||
ops[ops.length - 2] = str
|
ops[ops.length - 2] = newOp
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ops.push(str)
|
ops.push(newOp)
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a string at the current position.
|
* Remove a string at the current position.
|
||||||
|
* @param {number | string} n
|
||||||
*/
|
*/
|
||||||
remove(n) {
|
remove(n) {
|
||||||
if (typeof n === 'string') {
|
if (typeof n === 'string') {
|
||||||
|
@ -173,11 +168,13 @@ class TextOperation extends EditOperation {
|
||||||
if (n > 0) {
|
if (n > 0) {
|
||||||
n = -n
|
n = -n
|
||||||
}
|
}
|
||||||
|
const newOp = RemoveOp.fromJSON(n)
|
||||||
this.baseLength -= n
|
this.baseLength -= n
|
||||||
if (isRemove(this.ops[this.ops.length - 1])) {
|
const lastOp = this.ops[this.ops.length - 1]
|
||||||
this.ops[this.ops.length - 1] += n
|
if (lastOp?.canMergeWith(newOp)) {
|
||||||
|
lastOp.mergeWith(newOp)
|
||||||
} else {
|
} else {
|
||||||
this.ops.push(n)
|
this.ops.push(newOp)
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
@ -187,7 +184,8 @@ class TextOperation extends EditOperation {
|
||||||
*/
|
*/
|
||||||
isNoop() {
|
isNoop() {
|
||||||
return (
|
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.
|
* Pretty printing.
|
||||||
*/
|
*/
|
||||||
toString() {
|
toString() {
|
||||||
return this.ops
|
return this.ops.map(op => op.toString()).join(', ')
|
||||||
.map(op => {
|
|
||||||
if (isRetain(op)) {
|
|
||||||
return 'retain ' + op
|
|
||||||
} else if (isInsert(op)) {
|
|
||||||
return "insert '" + op + "'"
|
|
||||||
} else {
|
|
||||||
return 'remove ' + -op
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.join(', ')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
toJSON() {
|
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 }) {
|
static fromJSON = function ({ textOperation: ops }) {
|
||||||
const o = new TextOperation()
|
const o = new TextOperation()
|
||||||
for (let i = 0, l = ops.length; i < l; i++) {
|
for (const op of ops) {
|
||||||
const op = ops[i]
|
|
||||||
if (isRetain(op)) {
|
if (isRetain(op)) {
|
||||||
o.retain(op)
|
o.retain(op)
|
||||||
} else if (isInsert(op)) {
|
} else if (isInsert(op)) {
|
||||||
|
@ -229,12 +216,7 @@ class TextOperation extends EditOperation {
|
||||||
} else if (isRemove(op)) {
|
} else if (isRemove(op)) {
|
||||||
o.remove(op)
|
o.remove(op)
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new UnprocessableError('unknown operation: ' + JSON.stringify(op))
|
||||||
'unknown operation: ' +
|
|
||||||
JSON.stringify(op) +
|
|
||||||
' in ' +
|
|
||||||
JSON.stringify(ops)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return o
|
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
|
const ops = this.ops
|
||||||
for (let i = 0, l = ops.length; i < l; i++) {
|
const { inputCursor, result } = ops.reduce(
|
||||||
const op = ops[i]
|
(intermediate, op) => op.apply(str, intermediate),
|
||||||
if (isRetain(op)) {
|
{ result: '', inputCursor: 0 }
|
||||||
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.
|
if (inputCursor !== str.length) {
|
||||||
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) {
|
|
||||||
throw new TextOperation.ApplyError(
|
throw new TextOperation.ApplyError(
|
||||||
"The operation didn't operate on the whole string.",
|
"The operation didn't operate on the whole string.",
|
||||||
operation,
|
operation,
|
||||||
|
@ -321,31 +280,13 @@ class TextOperation extends EditOperation {
|
||||||
length
|
length
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
let newLength = 0
|
|
||||||
let strIndex = 0
|
const { length: newLength, inputCursor } = this.ops.reduce(
|
||||||
const ops = this.ops
|
(intermediate, op) => op.applyToLength(intermediate),
|
||||||
for (let i = 0, l = ops.length; i < l; i++) {
|
{ length: 0, inputCursor: 0, inputLength: length }
|
||||||
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.
|
if (inputCursor !== length) {
|
||||||
newLength += op
|
|
||||||
strIndex += op
|
|
||||||
} else if (isInsert(op)) {
|
|
||||||
// Insert string.
|
|
||||||
newLength += op.length
|
|
||||||
} else {
|
|
||||||
// remove op
|
|
||||||
strIndex -= op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (strIndex !== length) {
|
|
||||||
throw new TextOperation.ApplyError(
|
throw new TextOperation.ApplyError(
|
||||||
"The operation didn't operate on the whole string.",
|
"The operation didn't operate on the whole string.",
|
||||||
operation,
|
operation,
|
||||||
|
@ -369,15 +310,17 @@ class TextOperation extends EditOperation {
|
||||||
const ops = this.ops
|
const ops = this.ops
|
||||||
for (let i = 0, l = ops.length; i < l; i++) {
|
for (let i = 0, l = ops.length; i < l; i++) {
|
||||||
const op = ops[i]
|
const op = ops[i]
|
||||||
if (isRetain(op)) {
|
if (op instanceof RetainOp) {
|
||||||
inverse.retain(op)
|
inverse.retain(op.length)
|
||||||
strIndex += op
|
strIndex += op.length
|
||||||
} else if (isInsert(op)) {
|
} else if (op instanceof InsertOp) {
|
||||||
inverse.remove(op.length)
|
inverse.remove(op.insertion.length)
|
||||||
} else {
|
} else if (op instanceof RemoveOp) {
|
||||||
// remove op
|
// remove op
|
||||||
inverse.insert(str.slice(strIndex, strIndex - op))
|
inverse.insert(str.slice(strIndex, strIndex + op.length))
|
||||||
strIndex -= op
|
strIndex += op.length
|
||||||
|
} else {
|
||||||
|
throw new UnprocessableError('unknown scanop during inversion')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return inverse
|
return inverse
|
||||||
|
@ -404,14 +347,14 @@ class TextOperation extends EditOperation {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInsert(simpleA) && isInsert(simpleB)) {
|
if (simpleA instanceof InsertOp && simpleB instanceof InsertOp) {
|
||||||
return startA + simpleA.length === startB
|
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
|
// there are two possibilities to delete: with backspace and with the
|
||||||
// delete key.
|
// delete key.
|
||||||
return startB - simpleB === startA || startA === startB
|
return startB + simpleB.length === startA || startA === startB
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
|
@ -460,13 +403,14 @@ class TextOperation extends EditOperation {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRemove(op1)) {
|
if (op1 instanceof RemoveOp) {
|
||||||
operation.remove(op1)
|
operation.remove(-op1.length)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (isInsert(op2)) {
|
|
||||||
operation.insert(op2)
|
if (op2 instanceof InsertOp) {
|
||||||
|
operation.insert(op2.insertion)
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -482,57 +426,57 @@ class TextOperation extends EditOperation {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRetain(op1) && isRetain(op2)) {
|
if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
|
||||||
if (op1 > op2) {
|
if (op1.length > op2.length) {
|
||||||
operation.retain(op2)
|
operation.retain(op2.length)
|
||||||
op1 = op1 - op2
|
op1 = ScanOp.fromJSON(op1.length - op2.length)
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else if (op1 === op2) {
|
} else if (op1.length === op2.length) {
|
||||||
operation.retain(op1)
|
operation.retain(op1.length)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else {
|
} else {
|
||||||
operation.retain(op1)
|
operation.retain(op1.length)
|
||||||
op2 = op2 - op1
|
op2 = ScanOp.fromJSON(op2.length - op1.length)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
}
|
}
|
||||||
} else if (isInsert(op1) && isRemove(op2)) {
|
} else if (op1 instanceof InsertOp && op2 instanceof RemoveOp) {
|
||||||
if (op1.length > -op2) {
|
if (op1.insertion.length > op2.length) {
|
||||||
op1 = op1.slice(-op2)
|
op1 = ScanOp.fromJSON(op1.insertion.slice(op2.length))
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else if (op1.length === -op2) {
|
} else if (op1.insertion.length === op2.length) {
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else {
|
} else {
|
||||||
op2 = op2 + op1.length
|
op2 = ScanOp.fromJSON(-op2.length + op1.insertion.length)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
}
|
}
|
||||||
} else if (isInsert(op1) && isRetain(op2)) {
|
} else if (op1 instanceof InsertOp && op2 instanceof RetainOp) {
|
||||||
if (op1.length > op2) {
|
if (op1.insertion.length > op2.length) {
|
||||||
operation.insert(op1.slice(0, op2))
|
operation.insert(op1.insertion.slice(0, op2.length))
|
||||||
op1 = op1.slice(op2)
|
op1 = ScanOp.fromJSON(op1.insertion.slice(op2.length))
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else if (op1.length === op2) {
|
} else if (op1.insertion.length === op2.length) {
|
||||||
operation.insert(op1)
|
operation.insert(op1.insertion)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else {
|
} else {
|
||||||
operation.insert(op1)
|
operation.insert(op1.insertion)
|
||||||
op2 = op2 - op1.length
|
op2 = ScanOp.fromJSON(op2.length - op1.insertion.length)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
}
|
}
|
||||||
} else if (isRetain(op1) && isRemove(op2)) {
|
} else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
|
||||||
if (op1 > -op2) {
|
if (op1.length > op2.length) {
|
||||||
operation.remove(op2)
|
operation.remove(-op2.length)
|
||||||
op1 = op1 + op2
|
op1 = ScanOp.fromJSON(op1.length - op2.length)
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else if (op1 === -op2) {
|
} else if (op1.length === op2.length) {
|
||||||
operation.remove(op2)
|
operation.remove(-op2.length)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else {
|
} else {
|
||||||
operation.remove(op1)
|
operation.remove(op1.length)
|
||||||
op2 = op2 + op1
|
op2 = ScanOp.fromJSON(-op2.length + op1.length)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -581,15 +525,15 @@ class TextOperation extends EditOperation {
|
||||||
// next two cases: one or both ops are insert ops
|
// next two cases: one or both ops are insert ops
|
||||||
// => insert the string in the corresponding prime operation, skip it in
|
// => insert the string in the corresponding prime operation, skip it in
|
||||||
// the other one. If both op1 and op2 are insert ops, prefer op1.
|
// the other one. If both op1 and op2 are insert ops, prefer op1.
|
||||||
if (isInsert(op1)) {
|
if (op1 instanceof InsertOp) {
|
||||||
operation1prime.insert(op1)
|
operation1prime.insert(op1.insertion)
|
||||||
operation2prime.retain(op1.length)
|
operation2prime.retain(op1.insertion.length)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (isInsert(op2)) {
|
if (op2 instanceof InsertOp) {
|
||||||
operation1prime.retain(op2.length)
|
operation1prime.retain(op2.insertion.length)
|
||||||
operation2prime.insert(op2)
|
operation2prime.insert(op2.insertion)
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -606,65 +550,65 @@ class TextOperation extends EditOperation {
|
||||||
}
|
}
|
||||||
|
|
||||||
let minl
|
let minl
|
||||||
if (isRetain(op1) && isRetain(op2)) {
|
if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
|
||||||
// Simple case: retain/retain
|
// Simple case: retain/retain
|
||||||
if (op1 > op2) {
|
if (op1.length > op2.length) {
|
||||||
minl = op2
|
minl = op2.length
|
||||||
op1 = op1 - op2
|
op1 = ScanOp.fromJSON(op1.length - op2.length)
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else if (op1 === op2) {
|
} else if (op1.length === op2.length) {
|
||||||
minl = op2
|
minl = op2.length
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else {
|
} else {
|
||||||
minl = op1
|
minl = op1.length
|
||||||
op2 = op2 - op1
|
op2 = ScanOp.fromJSON(op2.length - op1.length)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
}
|
}
|
||||||
operation1prime.retain(minl)
|
operation1prime.retain(minl)
|
||||||
operation2prime.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
|
// 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
|
// need to produce any operations, we just skip over the remove ops and
|
||||||
// handle the case that one operation removes more than the other.
|
// handle the case that one operation removes more than the other.
|
||||||
if (-op1 > -op2) {
|
if (op1.length > op2.length) {
|
||||||
op1 = op1 - op2
|
op1 = ScanOp.fromJSON(-op1.length - -op2.length)
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else if (op1 === op2) {
|
} else if (op1.length === op2.length) {
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else {
|
} else {
|
||||||
op2 = op2 - op1
|
op2 = ScanOp.fromJSON(-op2.length - -op1.length)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
}
|
}
|
||||||
// next two cases: remove/retain and retain/remove
|
// next two cases: remove/retain and retain/remove
|
||||||
} else if (isRemove(op1) && isRetain(op2)) {
|
} else if (op1 instanceof RemoveOp && op2 instanceof RetainOp) {
|
||||||
if (-op1 > op2) {
|
if (op1.length > op2.length) {
|
||||||
minl = op2
|
minl = op2.length
|
||||||
op1 = op1 + op2
|
op1 = ScanOp.fromJSON(-op1.length + op2.length)
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else if (-op1 === op2) {
|
} else if (op1.length === op2.length) {
|
||||||
minl = op2
|
minl = op2.length
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else {
|
} else {
|
||||||
minl = -op1
|
minl = op1.length
|
||||||
op2 = op2 + op1
|
op2 = ScanOp.fromJSON(op2.length + -op1.length)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
}
|
}
|
||||||
operation1prime.remove(minl)
|
operation1prime.remove(minl)
|
||||||
} else if (isRetain(op1) && isRemove(op2)) {
|
} else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
|
||||||
if (op1 > -op2) {
|
if (op1.length > op2.length) {
|
||||||
minl = -op2
|
minl = op2.length
|
||||||
op1 = op1 + op2
|
op1 = ScanOp.fromJSON(op1.length + -op2.length)
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else if (op1 === -op2) {
|
} else if (op1.length === op2.length) {
|
||||||
minl = op1
|
minl = op1.length
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
op2 = ops2[i2++]
|
op2 = ops2[i2++]
|
||||||
} else {
|
} else {
|
||||||
minl = op1
|
minl = op1.length
|
||||||
op2 = op2 + op1
|
op2 = ScanOp.fromJSON(-op2.length + op1.length)
|
||||||
op1 = ops1[i1++]
|
op1 = ops1[i1++]
|
||||||
}
|
}
|
||||||
operation2prime.remove(minl)
|
operation2prime.remove(minl)
|
||||||
|
@ -685,27 +629,24 @@ class TextOperation extends EditOperation {
|
||||||
// Represented by strings.
|
// Represented by strings.
|
||||||
// * Remove ops: Remove the next n characters. Represented by negative ints.
|
// * Remove ops: Remove the next n characters. Represented by negative ints.
|
||||||
|
|
||||||
function isRetain(op) {
|
/**
|
||||||
return typeof op === 'number' && op > 0
|
*
|
||||||
}
|
* @param {TextOperation} operation
|
||||||
|
* @returns {ScanOp | null}
|
||||||
function isInsert(op) {
|
*/
|
||||||
return typeof op === 'string'
|
function getSimpleOp(operation) {
|
||||||
}
|
|
||||||
|
|
||||||
function isRemove(op) {
|
|
||||||
return typeof op === 'number' && op < 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSimpleOp(operation, fn) {
|
|
||||||
const ops = operation.ops
|
const ops = operation.ops
|
||||||
switch (ops.length) {
|
switch (ops.length) {
|
||||||
case 1:
|
case 1:
|
||||||
return ops[0]
|
return ops[0]
|
||||||
case 2:
|
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:
|
case 3:
|
||||||
if (isRetain(ops[0]) && isRetain(ops[2])) {
|
if (ops[0] instanceof RetainOp && ops[2] instanceof RetainOp) {
|
||||||
return ops[1]
|
return ops[1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -713,8 +654,8 @@ function getSimpleOp(operation, fn) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStartIndex(operation) {
|
function getStartIndex(operation) {
|
||||||
if (isRetain(operation.ops[0])) {
|
if (operation.ops[0] instanceof RetainOp) {
|
||||||
return operation.ops[0]
|
return operation.ops[0].length
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,9 @@
|
||||||
|
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
/*
|
/**
|
||||||
* return true/false if the given string contains non-BMP chars
|
* @param {string} str
|
||||||
|
* @returns {boolean} true if the given string contains non-BMP chars otherwise false
|
||||||
*/
|
*/
|
||||||
exports.containsNonBmpChars = function utilContainsNonBmpChars(str) {
|
exports.containsNonBmpChars = function utilContainsNonBmpChars(str) {
|
||||||
// check for first (high) surrogate in a non-BMP character
|
// check for first (high) surrogate in a non-BMP character
|
||||||
|
|
|
@ -31,7 +31,7 @@ describe('EditOperationBuilder', function () {
|
||||||
}
|
}
|
||||||
const op = EditOperationBuilder.fromJSON(raw)
|
const op = EditOperationBuilder.fromJSON(raw)
|
||||||
expect(op).to.be.an.instanceof(TextOperation)
|
expect(op).to.be.an.instanceof(TextOperation)
|
||||||
expect(op.ops).to.deep.equal([1, 'foo', 3])
|
expect(op.toJSON()).to.deep.equal(raw)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Throws error for unsupported operation', function () {
|
it('Throws error for unsupported operation', function () {
|
||||||
|
|
295
libraries/overleaf-editor-core/test/scan_op.test.js
Normal file
295
libraries/overleaf-editor-core/test/scan_op.test.js
Normal file
|
@ -0,0 +1,295 @@
|
||||||
|
// @ts-check
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const {
|
||||||
|
RetainOp,
|
||||||
|
ScanOp,
|
||||||
|
InsertOp,
|
||||||
|
RemoveOp,
|
||||||
|
} = require('../lib/operation/scan_op')
|
||||||
|
const { UnprocessableError, ApplyError } = require('../lib/errors')
|
||||||
|
|
||||||
|
describe('ScanOp', function () {
|
||||||
|
describe('fromJSON', function () {
|
||||||
|
it('constructs a RetainOp from object', function () {
|
||||||
|
const op = ScanOp.fromJSON({ r: 1 })
|
||||||
|
expect(op).to.be.instanceOf(RetainOp)
|
||||||
|
expect(/** @type {RetainOp} */ (op).length).to.equal(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('constructs a RetainOp from number', function () {
|
||||||
|
const op = ScanOp.fromJSON(2)
|
||||||
|
expect(op).to.be.instanceOf(RetainOp)
|
||||||
|
expect(/** @type {RetainOp} */ (op).length).to.equal(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('constructs an InsertOp from string', function () {
|
||||||
|
const op = ScanOp.fromJSON('abc')
|
||||||
|
expect(op).to.be.instanceOf(InsertOp)
|
||||||
|
expect(/** @type {InsertOp} */ (op).insertion).to.equal('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('constructs an InsertOp from object', function () {
|
||||||
|
const op = ScanOp.fromJSON({ i: 'abc' })
|
||||||
|
expect(op).to.be.instanceOf(InsertOp)
|
||||||
|
expect(/** @type {InsertOp} */ (op).insertion).to.equal('abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('constructs a RemoveOp from number', function () {
|
||||||
|
const op = ScanOp.fromJSON(-2)
|
||||||
|
expect(op).to.be.instanceOf(RemoveOp)
|
||||||
|
expect(/** @type {RemoveOp} */ (op).length).to.equal(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws an error for invalid input', function () {
|
||||||
|
expect(() => ScanOp.fromJSON({})).to.throw(UnprocessableError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws an error for zero', function () {
|
||||||
|
expect(() => ScanOp.fromJSON(0)).to.throw(UnprocessableError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RetainOp', function () {
|
||||||
|
it('is equal to another RetainOp with the same length', function () {
|
||||||
|
const op1 = new RetainOp(1)
|
||||||
|
const op2 = new RetainOp(1)
|
||||||
|
expect(op1.equals(op2)).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not equal to another RetainOp with a different length', function () {
|
||||||
|
const op1 = new RetainOp(1)
|
||||||
|
const op2 = new RetainOp(2)
|
||||||
|
expect(op1.equals(op2)).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not equal to an InsertOp', function () {
|
||||||
|
const op1 = new RetainOp(1)
|
||||||
|
const op2 = new InsertOp('a')
|
||||||
|
expect(op1.equals(op2)).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not equal to a RemoveOp', function () {
|
||||||
|
const op1 = new RetainOp(1)
|
||||||
|
const op2 = new RemoveOp(1)
|
||||||
|
expect(op1.equals(op2)).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can merge with another RetainOp', function () {
|
||||||
|
const op1 = new RetainOp(1)
|
||||||
|
const op2 = new RetainOp(2)
|
||||||
|
expect(op1.canMergeWith(op2)).to.be.true
|
||||||
|
op1.mergeWith(op2)
|
||||||
|
expect(op1.equals(new RetainOp(3))).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cannot merge with an InsertOp', function () {
|
||||||
|
const op1 = new RetainOp(1)
|
||||||
|
const op2 = new InsertOp('a')
|
||||||
|
expect(op1.canMergeWith(op2)).to.be.false
|
||||||
|
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cannot merge with a RemoveOp', function () {
|
||||||
|
const op1 = new RetainOp(1)
|
||||||
|
const op2 = new RemoveOp(1)
|
||||||
|
expect(op1.canMergeWith(op2)).to.be.false
|
||||||
|
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be converted to JSON', function () {
|
||||||
|
const op = new RetainOp(3)
|
||||||
|
expect(op.toJSON()).to.equal(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds to the length and cursor when applied to length', function () {
|
||||||
|
const op = new RetainOp(3)
|
||||||
|
const { length, inputCursor } = op.applyToLength({
|
||||||
|
length: 10,
|
||||||
|
inputCursor: 10,
|
||||||
|
inputLength: 30,
|
||||||
|
})
|
||||||
|
expect(length).to.equal(13)
|
||||||
|
expect(inputCursor).to.equal(13)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds from the input to the result when applied', function () {
|
||||||
|
const op = new RetainOp(3)
|
||||||
|
const { result, inputCursor } = op.apply('abcdefghi', {
|
||||||
|
result: 'xyz',
|
||||||
|
inputCursor: 3,
|
||||||
|
})
|
||||||
|
expect(result).to.equal('xyzdef')
|
||||||
|
expect(inputCursor).to.equal(6)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('InsertOp', function () {
|
||||||
|
it('is equal to another InsertOp with the same insertion', function () {
|
||||||
|
const op1 = new InsertOp('a')
|
||||||
|
const op2 = new InsertOp('a')
|
||||||
|
expect(op1.equals(op2)).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not equal to another InsertOp with a different insertion', function () {
|
||||||
|
const op1 = new InsertOp('a')
|
||||||
|
const op2 = new InsertOp('b')
|
||||||
|
expect(op1.equals(op2)).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not equal to a RetainOp', function () {
|
||||||
|
const op1 = new InsertOp('a')
|
||||||
|
const op2 = new RetainOp(1)
|
||||||
|
expect(op1.equals(op2)).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not equal to a RemoveOp', function () {
|
||||||
|
const op1 = new InsertOp('a')
|
||||||
|
const op2 = new RemoveOp(1)
|
||||||
|
expect(op1.equals(op2)).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can merge with another InsertOp', function () {
|
||||||
|
const op1 = new InsertOp('a')
|
||||||
|
const op2 = new InsertOp('b')
|
||||||
|
expect(op1.canMergeWith(op2)).to.be.true
|
||||||
|
op1.mergeWith(op2)
|
||||||
|
expect(op1.equals(new InsertOp('ab'))).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cannot merge with a RetainOp', function () {
|
||||||
|
const op1 = new InsertOp('a')
|
||||||
|
const op2 = new RetainOp(1)
|
||||||
|
expect(op1.canMergeWith(op2)).to.be.false
|
||||||
|
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cannot merge with a RemoveOp', function () {
|
||||||
|
const op1 = new InsertOp('a')
|
||||||
|
const op2 = new RemoveOp(1)
|
||||||
|
expect(op1.canMergeWith(op2)).to.be.false
|
||||||
|
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be converted to JSON', function () {
|
||||||
|
const op = new InsertOp('a')
|
||||||
|
expect(op.toJSON()).to.equal('a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds to the length when applied to length', function () {
|
||||||
|
const op = new InsertOp('abc')
|
||||||
|
const { length, inputCursor } = op.applyToLength({
|
||||||
|
length: 10,
|
||||||
|
inputCursor: 20,
|
||||||
|
inputLength: 40,
|
||||||
|
})
|
||||||
|
expect(length).to.equal(13)
|
||||||
|
expect(inputCursor).to.equal(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds from the insertion to the result when applied', function () {
|
||||||
|
const op = new InsertOp('ghi')
|
||||||
|
const { result, inputCursor } = op.apply('abcdef', {
|
||||||
|
result: 'xyz',
|
||||||
|
inputCursor: 3,
|
||||||
|
})
|
||||||
|
expect(result).to.equal('xyzghi')
|
||||||
|
expect(inputCursor).to.equal(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can apply a retain of the rest of the input', function () {
|
||||||
|
const op = new RetainOp(10)
|
||||||
|
const { length, inputCursor } = op.applyToLength({
|
||||||
|
length: 10,
|
||||||
|
inputCursor: 5,
|
||||||
|
inputLength: 15,
|
||||||
|
})
|
||||||
|
expect(length).to.equal(20)
|
||||||
|
expect(inputCursor).to.equal(15)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cannot apply to length if the input cursor is at the end', function () {
|
||||||
|
const op = new RetainOp(10)
|
||||||
|
expect(() =>
|
||||||
|
op.applyToLength({
|
||||||
|
length: 10,
|
||||||
|
inputCursor: 10,
|
||||||
|
inputLength: 10,
|
||||||
|
})
|
||||||
|
).to.throw(ApplyError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RemoveOp', function () {
|
||||||
|
it('is equal to another RemoveOp with the same length', function () {
|
||||||
|
const op1 = new RemoveOp(1)
|
||||||
|
const op2 = new RemoveOp(1)
|
||||||
|
expect(op1.equals(op2)).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not equal to another RemoveOp with a different length', function () {
|
||||||
|
const op1 = new RemoveOp(1)
|
||||||
|
const op2 = new RemoveOp(2)
|
||||||
|
expect(op1.equals(op2)).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not equal to a RetainOp', function () {
|
||||||
|
const op1 = new RemoveOp(1)
|
||||||
|
const op2 = new RetainOp(1)
|
||||||
|
expect(op1.equals(op2)).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not equal to an InsertOp', function () {
|
||||||
|
const op1 = new RemoveOp(1)
|
||||||
|
const op2 = new InsertOp('a')
|
||||||
|
expect(op1.equals(op2)).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can merge with another RemoveOp', function () {
|
||||||
|
const op1 = new RemoveOp(1)
|
||||||
|
const op2 = new RemoveOp(2)
|
||||||
|
expect(op1.canMergeWith(op2)).to.be.true
|
||||||
|
op1.mergeWith(op2)
|
||||||
|
expect(op1.equals(new RemoveOp(3))).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cannot merge with a RetainOp', function () {
|
||||||
|
const op1 = new RemoveOp(1)
|
||||||
|
const op2 = new RetainOp(1)
|
||||||
|
expect(op1.canMergeWith(op2)).to.be.false
|
||||||
|
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cannot merge with an InsertOp', function () {
|
||||||
|
const op1 = new RemoveOp(1)
|
||||||
|
const op2 = new InsertOp('a')
|
||||||
|
expect(op1.canMergeWith(op2)).to.be.false
|
||||||
|
expect(() => op1.mergeWith(op2)).to.throw(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be converted to JSON', function () {
|
||||||
|
const op = new RemoveOp(3)
|
||||||
|
expect(op.toJSON()).to.equal(-3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds to the input cursor when applied to length', function () {
|
||||||
|
const op = new RemoveOp(3)
|
||||||
|
const { length, inputCursor } = op.applyToLength({
|
||||||
|
length: 10,
|
||||||
|
inputCursor: 10,
|
||||||
|
inputLength: 30,
|
||||||
|
})
|
||||||
|
expect(length).to.equal(10)
|
||||||
|
expect(inputCursor).to.equal(13)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not change the result and adds to the cursor when applied', function () {
|
||||||
|
const op = new RemoveOp(3)
|
||||||
|
const { result, inputCursor } = op.apply('abcdefghi', {
|
||||||
|
result: 'xyz',
|
||||||
|
inputCursor: 3,
|
||||||
|
})
|
||||||
|
expect(result).to.equal('xyz')
|
||||||
|
expect(inputCursor).to.equal(6)
|
||||||
|
})
|
||||||
|
})
|
|
@ -13,6 +13,7 @@ const randomOperation = require('./support/random_text_operation')
|
||||||
const ot = require('..')
|
const ot = require('..')
|
||||||
const TextOperation = ot.TextOperation
|
const TextOperation = ot.TextOperation
|
||||||
const StringFileData = require('../lib/file_data/string_file_data')
|
const StringFileData = require('../lib/file_data/string_file_data')
|
||||||
|
const { RetainOp, InsertOp, RemoveOp } = require('../lib/operation/scan_op')
|
||||||
|
|
||||||
describe('TextOperation', function () {
|
describe('TextOperation', function () {
|
||||||
const numTrials = 500
|
const numTrials = 500
|
||||||
|
@ -73,22 +74,22 @@ describe('TextOperation', function () {
|
||||||
expect(o.ops.length).to.equal(0)
|
expect(o.ops.length).to.equal(0)
|
||||||
o.retain(2)
|
o.retain(2)
|
||||||
expect(o.ops.length).to.equal(1)
|
expect(o.ops.length).to.equal(1)
|
||||||
expect(last(o.ops)).to.equal(2)
|
expect(last(o.ops).equals(new RetainOp(2))).to.be.true
|
||||||
o.retain(3)
|
o.retain(3)
|
||||||
expect(o.ops.length).to.equal(1)
|
expect(o.ops.length).to.equal(1)
|
||||||
expect(last(o.ops)).to.equal(5)
|
expect(last(o.ops).equals(new RetainOp(5))).to.be.true
|
||||||
o.insert('abc')
|
o.insert('abc')
|
||||||
expect(o.ops.length).to.equal(2)
|
expect(o.ops.length).to.equal(2)
|
||||||
expect(last(o.ops)).to.equal('abc')
|
expect(last(o.ops).equals(new InsertOp('abc'))).to.be.true
|
||||||
o.insert('xyz')
|
o.insert('xyz')
|
||||||
expect(o.ops.length).to.equal(2)
|
expect(o.ops.length).to.equal(2)
|
||||||
expect(last(o.ops)).to.equal('abcxyz')
|
expect(last(o.ops).equals(new InsertOp('abcxyz'))).to.be.true
|
||||||
o.remove('d')
|
o.remove('d')
|
||||||
expect(o.ops.length).to.equal(3)
|
expect(o.ops.length).to.equal(3)
|
||||||
expect(last(o.ops)).to.equal(-1)
|
expect(last(o.ops).equals(new RemoveOp(1))).to.be.true
|
||||||
o.remove('d')
|
o.remove('d')
|
||||||
expect(o.ops.length).to.equal(3)
|
expect(o.ops.length).to.equal(3)
|
||||||
expect(last(o.ops)).to.equal(-2)
|
expect(last(o.ops).equals(new RemoveOp(2))).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('checks for no-ops', function () {
|
it('checks for no-ops', function () {
|
||||||
|
|
Loading…
Reference in a new issue