first commit
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// @ts-check
|
||||
const core = require('../../index')
|
||||
const Comment = require('../comment')
|
||||
const Range = require('../range')
|
||||
const EditOperation = require('./edit_operation')
|
||||
|
||||
/**
|
||||
* @import DeleteCommentOperation from './delete_comment_operation'
|
||||
* @import { CommentRawData, RawAddCommentOperation } from '../types'
|
||||
* @import StringFileData from '../file_data/string_file_data'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @extends EditOperation
|
||||
*/
|
||||
class AddCommentOperation extends EditOperation {
|
||||
/**
|
||||
* @param {string} commentId
|
||||
* @param {ReadonlyArray<Range>} ranges
|
||||
* @param {boolean} resolved
|
||||
*/
|
||||
constructor(commentId, ranges, resolved = false) {
|
||||
super()
|
||||
|
||||
for (const range of ranges) {
|
||||
if (range.isEmpty()) {
|
||||
throw new Error("AddCommentOperation can't be built with empty ranges")
|
||||
}
|
||||
}
|
||||
|
||||
/** @readonly */
|
||||
this.commentId = commentId
|
||||
|
||||
/** @readonly */
|
||||
this.ranges = ranges
|
||||
|
||||
/** @readonly */
|
||||
this.resolved = resolved
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {RawAddCommentOperation}
|
||||
*/
|
||||
toJSON() {
|
||||
/** @type RawAddCommentOperation */
|
||||
const raw = {
|
||||
commentId: this.commentId,
|
||||
ranges: this.ranges.map(range => range.toRaw()),
|
||||
}
|
||||
if (this.resolved) {
|
||||
raw.resolved = true
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StringFileData} fileData
|
||||
*/
|
||||
apply(fileData) {
|
||||
fileData.comments.add(
|
||||
new Comment(this.commentId, this.ranges, this.resolved)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {StringFileData} previousState
|
||||
* @returns {EditOperation}
|
||||
*/
|
||||
invert(previousState) {
|
||||
const comment = previousState.comments.getComment(this.commentId)
|
||||
if (!comment) {
|
||||
return new core.DeleteCommentOperation(this.commentId)
|
||||
}
|
||||
|
||||
return new core.AddCommentOperation(
|
||||
comment.id,
|
||||
comment.ranges.slice(),
|
||||
comment.resolved
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {EditOperation} other
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canBeComposedWith(other) {
|
||||
return (
|
||||
(other instanceof AddCommentOperation &&
|
||||
this.commentId === other.commentId) ||
|
||||
(other instanceof core.DeleteCommentOperation &&
|
||||
this.commentId === other.commentId) ||
|
||||
(other instanceof core.SetCommentStateOperation &&
|
||||
this.commentId === other.commentId)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {EditOperation} other
|
||||
* @returns {EditOperation}
|
||||
*/
|
||||
compose(other) {
|
||||
if (
|
||||
other instanceof core.DeleteCommentOperation &&
|
||||
other.commentId === this.commentId
|
||||
) {
|
||||
return other
|
||||
}
|
||||
|
||||
if (
|
||||
other instanceof AddCommentOperation &&
|
||||
other.commentId === this.commentId
|
||||
) {
|
||||
return other
|
||||
}
|
||||
|
||||
if (
|
||||
other instanceof core.SetCommentStateOperation &&
|
||||
other.commentId === this.commentId
|
||||
) {
|
||||
return new AddCommentOperation(
|
||||
this.commentId,
|
||||
this.ranges,
|
||||
other.resolved
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Trying to compose AddCommentOperation with ${other?.constructor?.name}.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {RawAddCommentOperation} raw
|
||||
* @returns {AddCommentOperation}
|
||||
*/
|
||||
static fromJSON(raw) {
|
||||
return new AddCommentOperation(
|
||||
raw.commentId,
|
||||
raw.ranges.map(Range.fromRaw),
|
||||
raw.resolved ?? false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AddCommentOperation
|
@@ -0,0 +1,78 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const File = require('../file')
|
||||
const Operation = require('./')
|
||||
|
||||
/**
|
||||
* Adds a new file to a project.
|
||||
*/
|
||||
class AddFileOperation extends Operation {
|
||||
/**
|
||||
* @param {string} pathname
|
||||
* @param {File} file
|
||||
*/
|
||||
constructor(pathname, file) {
|
||||
super()
|
||||
assert.string(pathname, 'bad pathname')
|
||||
assert.object(file, 'bad file')
|
||||
|
||||
this.pathname = pathname
|
||||
this.file = file
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {String}
|
||||
*/
|
||||
getPathname() {
|
||||
return this.pathname
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
* @param {Object} raw
|
||||
* @return {AddFileOperation}
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new AddFileOperation(raw.pathname, File.fromRaw(raw.file))
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
toRaw() {
|
||||
return { pathname: this.pathname, file: this.file.toRaw() }
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
getFile() {
|
||||
return this.file
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
findBlobHashes(blobHashes) {
|
||||
const hash = this.file.getHash()
|
||||
if (hash) blobHashes.add(hash)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async loadFiles(kind, blobStore) {
|
||||
return await this.file.load(kind, blobStore)
|
||||
}
|
||||
|
||||
async store(blobStore) {
|
||||
const rawFile = await this.file.store(blobStore)
|
||||
return { pathname: this.pathname, file: rawFile }
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
applyTo(snapshot) {
|
||||
snapshot.addFile(this.pathname, this.file.clone())
|
||||
}
|
||||
}
|
||||
module.exports = AddFileOperation
|
@@ -0,0 +1,70 @@
|
||||
// @ts-check
|
||||
const core = require('../../index')
|
||||
const EditNoOperation = require('./edit_no_operation')
|
||||
const EditOperation = require('./edit_operation')
|
||||
|
||||
/**
|
||||
* @import AddCommentOperation from './add_comment_operation'
|
||||
* @import StringFileData from '../file_data/string_file_data'
|
||||
* @import { RawDeleteCommentOperation } from '../types'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @extends EditOperation
|
||||
*/
|
||||
class DeleteCommentOperation extends EditOperation {
|
||||
/**
|
||||
* @param {string} commentId
|
||||
*/
|
||||
constructor(commentId) {
|
||||
super()
|
||||
this.commentId = commentId
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {RawDeleteCommentOperation}
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
deleteComment: this.commentId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {StringFileData} fileData
|
||||
*/
|
||||
apply(fileData) {
|
||||
fileData.comments.delete(this.commentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {StringFileData} previousState
|
||||
* @returns {AddCommentOperation | EditNoOperation}
|
||||
*/
|
||||
invert(previousState) {
|
||||
const comment = previousState.comments.getComment(this.commentId)
|
||||
if (!comment) {
|
||||
return new EditNoOperation()
|
||||
}
|
||||
|
||||
return new core.AddCommentOperation(
|
||||
comment.id,
|
||||
comment.ranges.slice(),
|
||||
comment.resolved
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {RawDeleteCommentOperation} raw
|
||||
* @returns {DeleteCommentOperation}
|
||||
*/
|
||||
static fromJSON(raw) {
|
||||
return new DeleteCommentOperation(raw.deleteComment)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeleteCommentOperation
|
@@ -0,0 +1,105 @@
|
||||
// @ts-check
|
||||
'use strict'
|
||||
/**
|
||||
* @import EditOperation from './edit_operation'
|
||||
* @import { RawEditFileOperation } from '../types'
|
||||
* @import Snapshot from "../snapshot"
|
||||
*/
|
||||
|
||||
const Operation = require('./')
|
||||
const EditOperationBuilder = require('./edit_operation_builder')
|
||||
|
||||
/**
|
||||
* Edit a file in place. It is a wrapper around a single EditOperation.
|
||||
*/
|
||||
class EditFileOperation extends Operation {
|
||||
/**
|
||||
* @param {string} pathname
|
||||
* @param {EditOperation} operation
|
||||
*/
|
||||
constructor(pathname, operation) {
|
||||
super()
|
||||
this.pathname = pathname
|
||||
this.operation = operation
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
toRaw() {
|
||||
return {
|
||||
pathname: this.pathname,
|
||||
...this.operation.toJSON(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize an EditFileOperation.
|
||||
*
|
||||
* @param {RawEditFileOperation} raw
|
||||
* @return {EditFileOperation}
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new EditFileOperation(
|
||||
raw.pathname,
|
||||
EditOperationBuilder.fromJSON(raw)
|
||||
)
|
||||
}
|
||||
|
||||
getPathname() {
|
||||
return this.pathname
|
||||
}
|
||||
|
||||
getOperation() {
|
||||
return this.operation
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {Snapshot} snapshot
|
||||
*/
|
||||
applyTo(snapshot) {
|
||||
// TODO(das7pad): can we teach typescript our polymorphism?
|
||||
// @ts-ignore
|
||||
snapshot.editFile(this.pathname, this.operation)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {Operation} other
|
||||
* @return {boolean}
|
||||
*/
|
||||
canBeComposedWithForUndo(other) {
|
||||
return (
|
||||
this.canBeComposedWith(other) &&
|
||||
this.operation.canBeComposedWithForUndo(other.operation)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {Operation} other
|
||||
* @return {other is EditFileOperation}
|
||||
*/
|
||||
canBeComposedWith(other) {
|
||||
// Ensure that other operation is an edit file operation
|
||||
if (!(other instanceof EditFileOperation)) return false
|
||||
// Ensure that both operations are editing the same file
|
||||
if (this.getPathname() !== other.getPathname()) return false
|
||||
|
||||
return this.operation.canBeComposedWith(other.operation)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {EditFileOperation} other
|
||||
*/
|
||||
compose(other) {
|
||||
return new EditFileOperation(
|
||||
this.pathname,
|
||||
this.operation.compose(other.operation)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EditFileOperation
|
@@ -0,0 +1,29 @@
|
||||
const EditOperation = require('./edit_operation')
|
||||
|
||||
/**
|
||||
* @import { RawEditNoOperation } from '../types'
|
||||
*/
|
||||
|
||||
class EditNoOperation extends EditOperation {
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {StringFileData} fileData
|
||||
*/
|
||||
apply(fileData) {}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {RawEditNoOperation}
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
noOp: true,
|
||||
}
|
||||
}
|
||||
|
||||
static fromJSON() {
|
||||
return new EditNoOperation()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EditNoOperation
|
@@ -0,0 +1,91 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* @import FileData from '../file_data'
|
||||
* @import { RawEditOperation } from '../types'
|
||||
*/
|
||||
|
||||
class EditOperation {
|
||||
constructor() {
|
||||
if (this.constructor === EditOperation) {
|
||||
throw new Error('Cannot instantiate abstract class')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts operation into a JSON value.
|
||||
* @returns {RawEditOperation}
|
||||
*/
|
||||
toJSON() {
|
||||
throw new Error('Abstract method not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @param {FileData} fileData
|
||||
*/
|
||||
apply(fileData) {
|
||||
throw new Error('Abstract method not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the effect of this operation on the length of the text.
|
||||
*
|
||||
* NB: This is an Overleaf addition to the original OT system.
|
||||
*
|
||||
* @param {number} length of the original string; non-negative
|
||||
* @return {number} length of the new string; non-negative
|
||||
*/
|
||||
applyToLength(length) {
|
||||
return length
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the inverse of an operation. The inverse of an operation is the
|
||||
* operation that reverts the effects of the operation, e.g. when you have an
|
||||
* operation 'insert("hello "); skip(6);' then the inverse is 'remove("hello ");
|
||||
* skip(6);'. The inverse should be used for implementing undo.
|
||||
* @param {FileData} previousState
|
||||
* @returns {EditOperation}
|
||||
*/
|
||||
invert(previousState) {
|
||||
throw new Error('Abstract method not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {EditOperation} other
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canBeComposedWith(other) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* When you use ctrl-z to undo your latest changes, you expect the program not
|
||||
* to undo every single keystroke but to undo your last sentence you wrote at
|
||||
* a stretch or the deletion you did by holding the backspace key down. This
|
||||
* This can be implemented by composing operations on the undo stack. This
|
||||
* method can help decide whether two operations should be composed. It
|
||||
* returns true if the operations are consecutive insert operations or both
|
||||
* operations delete text at the same position. You may want to include other
|
||||
* factors like the time since the last change in your decision.
|
||||
* @param {EditOperation} other
|
||||
*/
|
||||
canBeComposedWithForUndo(other) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose merges two consecutive operations into one operation, that
|
||||
* preserves the changes of both. Or, in other words, for each input string S
|
||||
* and a pair of consecutive operations A and B,
|
||||
* apply(apply(S, A), B) = apply(S, compose(A, B)) must hold.
|
||||
* @param {EditOperation} other
|
||||
* @returns {EditOperation}
|
||||
*/
|
||||
compose(other) {
|
||||
throw new Error('Abstract method not implemented')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EditOperation
|
@@ -0,0 +1,93 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @import EditOperation from './edit_operation'
|
||||
* @import { RawTextOperation, RawAddCommentOperation, RawEditOperation } from '../types'
|
||||
* @import { RawDeleteCommentOperation, RawSetCommentStateOperation } from '../types'
|
||||
*/
|
||||
|
||||
const DeleteCommentOperation = require('./delete_comment_operation')
|
||||
const AddCommentOperation = require('./add_comment_operation')
|
||||
const TextOperation = require('./text_operation')
|
||||
const SetCommentStateOperation = require('./set_comment_state_operation')
|
||||
const EditNoOperation = require('./edit_no_operation')
|
||||
|
||||
class EditOperationBuilder {
|
||||
/**
|
||||
*
|
||||
* @param {RawEditOperation} raw
|
||||
* @returns {EditOperation}
|
||||
*/
|
||||
static fromJSON(raw) {
|
||||
if (isTextOperation(raw)) {
|
||||
return TextOperation.fromJSON(raw)
|
||||
}
|
||||
if (isRawAddCommentOperation(raw)) {
|
||||
return AddCommentOperation.fromJSON(raw)
|
||||
}
|
||||
if (isRawDeleteCommentOperation(raw)) {
|
||||
return DeleteCommentOperation.fromJSON(raw)
|
||||
}
|
||||
if (isRawSetCommentStateOperation(raw)) {
|
||||
return SetCommentStateOperation.fromJSON(raw)
|
||||
}
|
||||
if (isRawEditNoOperation(raw)) {
|
||||
return EditNoOperation.fromJSON()
|
||||
}
|
||||
throw new Error('Unsupported operation in EditOperationBuilder.fromJSON')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {raw is RawTextOperation}
|
||||
*/
|
||||
function isTextOperation(raw) {
|
||||
return raw !== null && typeof raw === 'object' && 'textOperation' in raw
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {raw is RawAddCommentOperation}
|
||||
*/
|
||||
function isRawAddCommentOperation(raw) {
|
||||
return (
|
||||
raw !== null &&
|
||||
typeof raw === 'object' &&
|
||||
'commentId' in raw &&
|
||||
'ranges' in raw &&
|
||||
Array.isArray(raw.ranges)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {raw is RawDeleteCommentOperation}
|
||||
*/
|
||||
function isRawDeleteCommentOperation(raw) {
|
||||
return raw !== null && typeof raw === 'object' && 'deleteComment' in raw
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {raw is RawSetCommentStateOperation}
|
||||
*/
|
||||
function isRawSetCommentStateOperation(raw) {
|
||||
return (
|
||||
raw !== null &&
|
||||
typeof raw === 'object' &&
|
||||
'commentId' in raw &&
|
||||
'resolved' in raw &&
|
||||
typeof raw.resolved === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {raw is RawEditNoOperation}
|
||||
*/
|
||||
function isRawEditNoOperation(raw) {
|
||||
return raw !== null && typeof raw === 'object' && 'noOp' in raw
|
||||
}
|
||||
|
||||
module.exports = EditOperationBuilder
|
@@ -0,0 +1,162 @@
|
||||
// @ts-check
|
||||
const core = require('../..')
|
||||
const Comment = require('../comment')
|
||||
const EditNoOperation = require('./edit_no_operation')
|
||||
const TextOperation = require('./text_operation')
|
||||
|
||||
/**
|
||||
* @import EditOperation from './edit_operation'
|
||||
*/
|
||||
|
||||
class EditOperationTransformer {
|
||||
/**
|
||||
* Transform two edit operations against each other.
|
||||
* @param {EditOperation} a
|
||||
* @param {EditOperation} b
|
||||
* @returns {[EditOperation, EditOperation]}
|
||||
*/
|
||||
static transform(a, b) {
|
||||
const {
|
||||
AddCommentOperation,
|
||||
DeleteCommentOperation,
|
||||
SetCommentStateOperation,
|
||||
} = core
|
||||
|
||||
if (a instanceof EditNoOperation || b instanceof EditNoOperation) {
|
||||
return [a, b]
|
||||
}
|
||||
|
||||
const transformers = [
|
||||
createTransformer(TextOperation, TextOperation, TextOperation.transform),
|
||||
createTransformer(TextOperation, DeleteCommentOperation, noConflict),
|
||||
createTransformer(TextOperation, SetCommentStateOperation, noConflict),
|
||||
createTransformer(TextOperation, AddCommentOperation, (a, b) => {
|
||||
// apply the text operation to the comment
|
||||
const originalComment = new Comment(b.commentId, b.ranges, b.resolved)
|
||||
const movedComment = originalComment.applyTextOperation(a, b.commentId)
|
||||
return [
|
||||
a,
|
||||
new AddCommentOperation(
|
||||
movedComment.id,
|
||||
movedComment.ranges,
|
||||
movedComment.resolved
|
||||
),
|
||||
]
|
||||
}),
|
||||
createTransformer(AddCommentOperation, AddCommentOperation, (a, b) => {
|
||||
if (a.commentId === b.commentId) {
|
||||
return [new EditNoOperation(), b]
|
||||
}
|
||||
return [a, b]
|
||||
}),
|
||||
createTransformer(AddCommentOperation, DeleteCommentOperation, (a, b) => {
|
||||
if (a.commentId === b.commentId) {
|
||||
// delete wins
|
||||
return [new EditNoOperation(), b]
|
||||
}
|
||||
return [a, b]
|
||||
}),
|
||||
createTransformer(
|
||||
AddCommentOperation,
|
||||
SetCommentStateOperation,
|
||||
(a, b) => {
|
||||
if (a.commentId === b.commentId) {
|
||||
const newA = new AddCommentOperation(
|
||||
a.commentId,
|
||||
a.ranges,
|
||||
b.resolved
|
||||
)
|
||||
return [newA, b]
|
||||
}
|
||||
return [a, b]
|
||||
}
|
||||
),
|
||||
createTransformer(
|
||||
DeleteCommentOperation,
|
||||
DeleteCommentOperation,
|
||||
(a, b) => {
|
||||
if (a.commentId === b.commentId) {
|
||||
// if both operations delete the same comment, we can ignore both
|
||||
return [new EditNoOperation(), new EditNoOperation()]
|
||||
}
|
||||
return [a, b]
|
||||
}
|
||||
),
|
||||
createTransformer(
|
||||
DeleteCommentOperation,
|
||||
SetCommentStateOperation,
|
||||
(a, b) => {
|
||||
if (a.commentId === b.commentId) {
|
||||
// delete wins
|
||||
return [a, new EditNoOperation()]
|
||||
}
|
||||
return [a, b]
|
||||
}
|
||||
),
|
||||
createTransformer(
|
||||
SetCommentStateOperation,
|
||||
SetCommentStateOperation,
|
||||
(a, b) => {
|
||||
if (a.commentId !== b.commentId) {
|
||||
return [a, b]
|
||||
}
|
||||
|
||||
if (a.resolved === b.resolved) {
|
||||
return [new EditNoOperation(), new EditNoOperation()]
|
||||
}
|
||||
|
||||
const shouldResolve = a.resolved && b.resolved
|
||||
if (a.resolved === shouldResolve) {
|
||||
return [a, new EditNoOperation()]
|
||||
} else {
|
||||
return [new EditNoOperation(), b]
|
||||
}
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
for (const transformer of transformers) {
|
||||
const result = transformer(a, b)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Transform not implemented for ${a.constructor.name}○${b.constructor.name}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {EditOperation} X
|
||||
* @template {EditOperation} Y
|
||||
* @param {new(...args: any[]) => X} ClassA
|
||||
* @param {new(...args: any[]) => Y} ClassB
|
||||
* @param {(a: X, b: Y) => [EditOperation, EditOperation]} transformer
|
||||
* @returns {(a: EditOperation, b: EditOperation) => [EditOperation, EditOperation] | false}
|
||||
*/
|
||||
function createTransformer(ClassA, ClassB, transformer) {
|
||||
return (a, b) => {
|
||||
if (a instanceof ClassA && b instanceof ClassB) {
|
||||
return transformer(a, b)
|
||||
}
|
||||
if (b instanceof ClassA && a instanceof ClassB) {
|
||||
const [bPrime, aPrime] = transformer(b, a)
|
||||
return [aPrime, bPrime]
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {EditOperation} a
|
||||
* @param {EditOperation} b
|
||||
* @returns {[EditOperation, EditOperation]}
|
||||
*/
|
||||
function noConflict(a, b) {
|
||||
return [a, b]
|
||||
}
|
||||
|
||||
module.exports = EditOperationTransformer
|
462
libraries/overleaf-editor-core/lib/operation/index.js
Normal file
462
libraries/overleaf-editor-core/lib/operation/index.js
Normal file
@@ -0,0 +1,462 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const assert = require('check-types').assert
|
||||
const EditOperationTransformer = require('./edit_operation_transformer')
|
||||
|
||||
// Dependencies are loaded at the bottom of the file to mitigate circular
|
||||
// dependency
|
||||
let NoOperation = null
|
||||
let AddFileOperation = null
|
||||
let MoveFileOperation = null
|
||||
let EditFileOperation = null
|
||||
let SetFileMetadataOperation = null
|
||||
|
||||
/**
|
||||
* @import { BlobStore } from "../types"
|
||||
* @import Snapshot from "../snapshot"
|
||||
*/
|
||||
|
||||
/**
|
||||
* An `Operation` changes a `Snapshot` when it is applied. See the
|
||||
* {@tutorial OT} tutorial for background.
|
||||
*/
|
||||
class Operation {
|
||||
/**
|
||||
* Deserialize an Operation.
|
||||
*
|
||||
* @param {Object} raw
|
||||
* @return {Operation} one of the subclasses
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
if ('file' in raw) {
|
||||
return AddFileOperation.fromRaw(raw)
|
||||
}
|
||||
if (
|
||||
'textOperation' in raw ||
|
||||
'commentId' in raw ||
|
||||
'deleteComment' in raw
|
||||
) {
|
||||
return EditFileOperation.fromRaw(raw)
|
||||
}
|
||||
if ('newPathname' in raw) {
|
||||
return new MoveFileOperation(raw.pathname, raw.newPathname)
|
||||
}
|
||||
if ('metadata' in raw) {
|
||||
return new SetFileMetadataOperation(raw.pathname, raw.metadata)
|
||||
}
|
||||
if (_.isEmpty(raw)) {
|
||||
return new NoOperation()
|
||||
}
|
||||
throw new Error('invalid raw operation ' + JSON.stringify(raw))
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an Operation.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
toRaw() {
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this operation does nothing when applied.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
isNoOp() {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* If this Operation references blob hashes, add them to the given Set.
|
||||
*
|
||||
* @param {Set.<String>} blobHashes
|
||||
*/
|
||||
findBlobHashes(blobHashes) {}
|
||||
|
||||
/**
|
||||
* If this operation references any files, load the files.
|
||||
*
|
||||
* @param {string} kind see {File#load}
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async loadFiles(kind, blobStore) {}
|
||||
|
||||
/**
|
||||
* Return a version of this operation that is suitable for long term storage.
|
||||
* In most cases, we just need to convert the operation to raw form, but if
|
||||
* the operation involves File objects, we may need to store their content.
|
||||
*
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise.<Object>}
|
||||
*/
|
||||
async store(blobStore) {
|
||||
return this.toRaw()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply this Operation to a snapshot.
|
||||
*
|
||||
* The snapshot is modified in place.
|
||||
*
|
||||
* @param {Snapshot} snapshot
|
||||
*/
|
||||
applyTo(snapshot) {
|
||||
assert.object(snapshot, 'bad snapshot')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this operation can be composed with another operation to produce a
|
||||
* single operation of the same type as this one, while keeping the composed
|
||||
* operation small and logical enough to be used in the undo stack.
|
||||
*
|
||||
* @param {Operation} other
|
||||
* @return {Boolean}
|
||||
*/
|
||||
canBeComposedWithForUndo(other) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this operation can be composed with another operation to produce a
|
||||
* single operation of the same type as this one.
|
||||
*
|
||||
* TODO Moves can be composed. For example, if you rename a to b and then decide
|
||||
* shortly after that actually you want to call it c, we could compose the two
|
||||
* to get a -> c). Edits can also be composed --- see rules in TextOperation.
|
||||
* We also need to consider the Change --- we will need to consider both time
|
||||
* and author(s) when composing changes. I guess that AddFile can also be
|
||||
* composed in some cases --- if you upload a file and then decide it was the
|
||||
* wrong one and upload a new one, we could drop the one in the middle, but
|
||||
* that seems like a pretty rare case.
|
||||
*
|
||||
* @param {Operation} other
|
||||
* @return {Boolean}
|
||||
*/
|
||||
canBeComposedWith(other) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose this operation with another operation to produce a single operation
|
||||
* of the same type as this one.
|
||||
*
|
||||
* @param {Operation} other
|
||||
* @return {Operation}
|
||||
*/
|
||||
compose(other) {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform takes two operations A and B that happened concurrently and
|
||||
* produces two operations A' and B' (in an array) such that
|
||||
* `apply(apply(S, A), B') = apply(apply(S, B), A')`.
|
||||
*
|
||||
* That is, if one client applies A and then B', they get the same result as
|
||||
* another client who applies B and then A'.
|
||||
*
|
||||
* @param {Operation} a
|
||||
* @param {Operation} b
|
||||
* @return {Operation[]} operations `[a', b']`
|
||||
*/
|
||||
static transform(a, b) {
|
||||
if (a.isNoOp() || b.isNoOp()) return [a, b]
|
||||
|
||||
function transpose(transformer) {
|
||||
return transformer(b, a).reverse()
|
||||
}
|
||||
|
||||
const bIsAddFile = b instanceof AddFileOperation
|
||||
const bIsEditFile = b instanceof EditFileOperation
|
||||
const bIsMoveFile = b instanceof MoveFileOperation
|
||||
const bIsSetFileMetadata = b instanceof SetFileMetadataOperation
|
||||
|
||||
if (a instanceof AddFileOperation) {
|
||||
if (bIsAddFile) return transformAddFileAddFile(a, b)
|
||||
if (bIsMoveFile) return transformAddFileMoveFile(a, b)
|
||||
if (bIsEditFile) return transformAddFileEditFile(a, b)
|
||||
if (bIsSetFileMetadata) return transformAddFileSetFileMetadata(a, b)
|
||||
throw new Error('bad op b')
|
||||
}
|
||||
if (a instanceof MoveFileOperation) {
|
||||
if (bIsAddFile) return transpose(transformAddFileMoveFile)
|
||||
if (bIsMoveFile) return transformMoveFileMoveFile(a, b)
|
||||
if (bIsEditFile) return transformMoveFileEditFile(a, b)
|
||||
if (bIsSetFileMetadata) return transformMoveFileSetFileMetadata(a, b)
|
||||
throw new Error('bad op b')
|
||||
}
|
||||
if (a instanceof EditFileOperation) {
|
||||
if (bIsAddFile) return transpose(transformAddFileEditFile)
|
||||
if (bIsMoveFile) return transpose(transformMoveFileEditFile)
|
||||
if (bIsEditFile) return transformEditFileEditFile(a, b)
|
||||
if (bIsSetFileMetadata) return transformEditFileSetFileMetadata(a, b)
|
||||
throw new Error('bad op b')
|
||||
}
|
||||
if (a instanceof SetFileMetadataOperation) {
|
||||
if (bIsAddFile) return transpose(transformAddFileSetFileMetadata)
|
||||
if (bIsMoveFile) return transpose(transformMoveFileSetFileMetadata)
|
||||
if (bIsEditFile) return transpose(transformEditFileSetFileMetadata)
|
||||
if (bIsSetFileMetadata) return transformSetFileMetadatas(a, b)
|
||||
throw new Error('bad op b')
|
||||
}
|
||||
throw new Error('bad op a')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform each operation in `a` by each operation in `b` and save the primed
|
||||
* operations in place.
|
||||
*
|
||||
* @param {Array.<Operation>} as - modified in place
|
||||
* @param {Array.<Operation>} bs - modified in place
|
||||
*/
|
||||
static transformMultiple(as, bs) {
|
||||
for (let i = 0; i < as.length; ++i) {
|
||||
for (let j = 0; j < bs.length; ++j) {
|
||||
const primes = Operation.transform(as[i], bs[j])
|
||||
as[i] = primes[0]
|
||||
bs[j] = primes[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static addFile(pathname, file) {
|
||||
return new AddFileOperation(pathname, file)
|
||||
}
|
||||
|
||||
static editFile(pathname, editOperation) {
|
||||
return new EditFileOperation(pathname, editOperation)
|
||||
}
|
||||
|
||||
static moveFile(pathname, newPathname) {
|
||||
return new MoveFileOperation(pathname, newPathname)
|
||||
}
|
||||
|
||||
static removeFile(pathname) {
|
||||
return new MoveFileOperation(pathname, '')
|
||||
}
|
||||
|
||||
static setFileMetadata(pathname, metadata) {
|
||||
return new SetFileMetadataOperation(pathname, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Transform
|
||||
//
|
||||
// The way to read these transform functions is that
|
||||
// 1. return_value[0] is the op to be applied after arguments[1], and
|
||||
// 2. return_value[1] is the op to be applied after arguments[0],
|
||||
// in order to arrive at the same project state.
|
||||
//
|
||||
|
||||
function transformAddFileAddFile(add1, add2) {
|
||||
if (add1.getPathname() === add2.getPathname()) {
|
||||
return [Operation.NO_OP, add2] // add2 wins
|
||||
}
|
||||
|
||||
return [add1, add2]
|
||||
}
|
||||
|
||||
function transformAddFileMoveFile(add, move) {
|
||||
function relocateAddFile() {
|
||||
return new AddFileOperation(move.getNewPathname(), add.getFile().clone())
|
||||
}
|
||||
|
||||
if (add.getPathname() === move.getPathname()) {
|
||||
if (move.isRemoveFile()) {
|
||||
return [add, Operation.NO_OP]
|
||||
}
|
||||
return [
|
||||
relocateAddFile(),
|
||||
new MoveFileOperation(add.getPathname(), move.getNewPathname()),
|
||||
]
|
||||
}
|
||||
|
||||
if (add.getPathname() === move.getNewPathname()) {
|
||||
return [relocateAddFile(), new MoveFileOperation(move.getPathname(), '')]
|
||||
}
|
||||
|
||||
return [add, move]
|
||||
}
|
||||
|
||||
function transformAddFileEditFile(add, edit) {
|
||||
if (add.getPathname() === edit.getPathname()) {
|
||||
return [add, Operation.NO_OP] // the add wins
|
||||
}
|
||||
|
||||
return [add, edit]
|
||||
}
|
||||
|
||||
function transformAddFileSetFileMetadata(add, set) {
|
||||
if (add.getPathname() === set.getPathname()) {
|
||||
const newFile = add.getFile().clone()
|
||||
newFile.setMetadata(set.getMetadata())
|
||||
return [new AddFileOperation(add.getPathname(), newFile), set]
|
||||
}
|
||||
|
||||
return [add, set]
|
||||
}
|
||||
|
||||
//
|
||||
// This is one of the trickier ones. There are 15 possible equivalence
|
||||
// relationships between our four variables:
|
||||
//
|
||||
// path1, newPath1, path2, newPath2 --- "same move" (all equal)
|
||||
//
|
||||
// path1, newPath1, path2 | newPath2 --- "no-ops" (1)
|
||||
// path1, newPath1, newPath2 | path2 --- "no-ops" (1)
|
||||
// path1, path2, newPath2 | newPath1 --- "no-ops" (2)
|
||||
// newPath1, path2, newPath2 | path1 --- "no-ops" (2)
|
||||
//
|
||||
// path1, newPath1 | path2, newPath2 --- "no-ops" (1 and 2)
|
||||
// path1, path2 | newPath1, newPath2 --- "same move"
|
||||
// path1, newPath2 | newPath1, path2 --- "opposite moves"
|
||||
//
|
||||
// path1, newPath1 | path2 | newPath2 --- "no-ops" (1)
|
||||
// path1, path2 | newPath1 | newPath2 --- "divergent moves"
|
||||
// path1, newPath2 | newPath1 | path2 --- "transitive move"
|
||||
// newPath1, path2 | path1 | newPath2 --- "transitive move"
|
||||
// newPath1, newPath2 | path1 | path2 --- "convergent move"
|
||||
// path2, newPath2 | path1 | newPath1 --- "no-ops" (2)
|
||||
//
|
||||
// path1 | newPath1 | path2 | newPath2 --- "no conflict"
|
||||
//
|
||||
function transformMoveFileMoveFile(move1, move2) {
|
||||
const path1 = move1.getPathname()
|
||||
const path2 = move2.getPathname()
|
||||
const newPath1 = move1.getNewPathname()
|
||||
const newPath2 = move2.getNewPathname()
|
||||
|
||||
// the same move
|
||||
if (path1 === path2 && newPath1 === newPath2) {
|
||||
return [Operation.NO_OP, Operation.NO_OP]
|
||||
}
|
||||
|
||||
// no-ops
|
||||
if (path1 === newPath1 && path2 === newPath2) {
|
||||
return [Operation.NO_OP, Operation.NO_OP]
|
||||
}
|
||||
if (path1 === newPath1) {
|
||||
return [Operation.NO_OP, move2]
|
||||
}
|
||||
if (path2 === newPath2) {
|
||||
return [move1, Operation.NO_OP]
|
||||
}
|
||||
|
||||
// opposite moves (foo -> bar, bar -> foo)
|
||||
if (path1 === newPath2 && path2 === newPath1) {
|
||||
// We can't handle this very well: if we wanted move2 (say) to win, move2'
|
||||
// would have to be addFile(foo) with the content of bar, but we don't have
|
||||
// the content of bar available here. So, we just destroy both files.
|
||||
return [Operation.removeFile(path1), Operation.removeFile(path2)]
|
||||
}
|
||||
|
||||
// divergent moves (foo -> bar, foo -> baz); convention: move2 wins
|
||||
if (path1 === path2 && newPath1 !== newPath2) {
|
||||
return [Operation.NO_OP, Operation.moveFile(newPath1, newPath2)]
|
||||
}
|
||||
|
||||
// convergent move (foo -> baz, bar -> baz); convention: move2 wins
|
||||
if (newPath1 === newPath2 && path1 !== path2) {
|
||||
return [Operation.removeFile(path1), move2]
|
||||
}
|
||||
|
||||
// transitive move:
|
||||
// 1: foo -> baz, 2: bar -> foo (result: bar -> baz) or
|
||||
// 1: foo -> bar, 2: bar -> baz (result: foo -> baz)
|
||||
if (path1 === newPath2 && newPath1 !== path2) {
|
||||
return [
|
||||
Operation.moveFile(newPath2, newPath1),
|
||||
Operation.moveFile(path2, newPath1),
|
||||
]
|
||||
}
|
||||
if (newPath1 === path2 && path1 !== newPath2) {
|
||||
return [
|
||||
Operation.moveFile(path1, newPath2),
|
||||
Operation.moveFile(newPath1, newPath2),
|
||||
]
|
||||
}
|
||||
|
||||
// no conflict
|
||||
return [move1, move2]
|
||||
}
|
||||
|
||||
function transformMoveFileEditFile(move, edit) {
|
||||
if (move.getPathname() === edit.getPathname()) {
|
||||
if (move.isRemoveFile()) {
|
||||
// let the remove win
|
||||
return [move, Operation.NO_OP]
|
||||
}
|
||||
return [
|
||||
move,
|
||||
Operation.editFile(move.getNewPathname(), edit.getOperation()),
|
||||
]
|
||||
}
|
||||
|
||||
if (move.getNewPathname() === edit.getPathname()) {
|
||||
// let the move win
|
||||
return [move, Operation.NO_OP]
|
||||
}
|
||||
|
||||
return [move, edit]
|
||||
}
|
||||
|
||||
function transformMoveFileSetFileMetadata(move, set) {
|
||||
if (move.getPathname() === set.getPathname()) {
|
||||
return [
|
||||
move,
|
||||
Operation.setFileMetadata(move.getNewPathname(), set.getMetadata()),
|
||||
]
|
||||
}
|
||||
// A: mv foo -> bar
|
||||
// B: set bar.x
|
||||
//
|
||||
// A': mv foo -> bar
|
||||
// B': nothing
|
||||
if (move.getNewPathname() === set.getPathname()) {
|
||||
return [move, Operation.NO_OP] // let the move win
|
||||
}
|
||||
return [move, set]
|
||||
}
|
||||
|
||||
function transformEditFileEditFile(edit1, edit2) {
|
||||
if (edit1.getPathname() === edit2.getPathname()) {
|
||||
const primeOps = EditOperationTransformer.transform(
|
||||
edit1.getOperation(),
|
||||
edit2.getOperation()
|
||||
)
|
||||
return [
|
||||
Operation.editFile(edit1.getPathname(), primeOps[0]),
|
||||
Operation.editFile(edit2.getPathname(), primeOps[1]),
|
||||
]
|
||||
}
|
||||
|
||||
return [edit1, edit2]
|
||||
}
|
||||
|
||||
function transformEditFileSetFileMetadata(edit, set) {
|
||||
// There is no conflict.
|
||||
return [edit, set]
|
||||
}
|
||||
|
||||
function transformSetFileMetadatas(set1, set2) {
|
||||
if (set1.getPathname() === set2.getPathname()) {
|
||||
return [Operation.NO_OP, set2] // set2 wins
|
||||
}
|
||||
return [set1, set2]
|
||||
}
|
||||
|
||||
module.exports = Operation
|
||||
|
||||
// Work around circular import
|
||||
NoOperation = require('./no_operation')
|
||||
AddFileOperation = require('./add_file_operation')
|
||||
MoveFileOperation = require('./move_file_operation')
|
||||
EditFileOperation = require('./edit_file_operation')
|
||||
SetFileMetadataOperation = require('./set_file_metadata_operation')
|
||||
|
||||
Operation.NO_OP = new NoOperation()
|
@@ -0,0 +1,54 @@
|
||||
'use strict'
|
||||
|
||||
const Operation = require('./')
|
||||
|
||||
/**
|
||||
* Moves or removes a file from a project.
|
||||
*/
|
||||
class MoveFileOperation extends Operation {
|
||||
/**
|
||||
* @param {string} pathname
|
||||
* @param {string} newPathname
|
||||
*/
|
||||
constructor(pathname, newPathname) {
|
||||
super()
|
||||
this.pathname = pathname
|
||||
this.newPathname = newPathname
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
toRaw() {
|
||||
return {
|
||||
pathname: this.pathname,
|
||||
newPathname: this.newPathname,
|
||||
}
|
||||
}
|
||||
|
||||
getPathname() {
|
||||
return this.pathname
|
||||
}
|
||||
|
||||
getNewPathname() {
|
||||
return this.newPathname
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this operation is a MoveFile operation that deletes the file.
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isRemoveFile() {
|
||||
return this.getNewPathname() === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
applyTo(snapshot) {
|
||||
snapshot.moveFile(this.getPathname(), this.getNewPathname())
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MoveFileOperation
|
20
libraries/overleaf-editor-core/lib/operation/no_operation.js
Normal file
20
libraries/overleaf-editor-core/lib/operation/no_operation.js
Normal file
@@ -0,0 +1,20 @@
|
||||
'use strict'
|
||||
|
||||
const Operation = require('./')
|
||||
|
||||
/**
|
||||
* An explicit no-operation.
|
||||
*
|
||||
* There are several no-ops, such as moving a file to itself, but it's useful
|
||||
* to have a generic no-op as well.
|
||||
*/
|
||||
class NoOperation extends Operation {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
isNoOp() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NoOperation
|
457
libraries/overleaf-editor-core/lib/operation/scan_op.js
Normal file
457
libraries/overleaf-editor-core/lib/operation/scan_op.js
Normal file
@@ -0,0 +1,457 @@
|
||||
// @ts-check
|
||||
const { containsNonBmpChars } = require('../util')
|
||||
const {
|
||||
ApplyError,
|
||||
InvalidInsertionError,
|
||||
UnprocessableError,
|
||||
} = require('../errors')
|
||||
const ClearTrackingProps = require('../file_data/clear_tracking_props')
|
||||
const TrackingProps = require('../file_data/tracking_props')
|
||||
|
||||
/**
|
||||
* @import { RawScanOp, RawInsertOp, RawRetainOp, RawRemoveOp, TrackingDirective } from '../types'
|
||||
*
|
||||
* @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 length
|
||||
* @param {LengthApplyContext} current
|
||||
* @returns {LengthApplyContext}
|
||||
*/
|
||||
applyToLength(current) {
|
||||
throw new Error('abstract method')
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {RawScanOp}
|
||||
*/
|
||||
toJSON() {
|
||||
throw new Error('abstract method')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RawScanOp} 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 {
|
||||
/**
|
||||
*
|
||||
* @param {string} insertion
|
||||
* @param {TrackingProps | undefined} tracking
|
||||
* @param {string[] | undefined} commentIds
|
||||
*/
|
||||
constructor(insertion, tracking = undefined, commentIds = undefined) {
|
||||
super()
|
||||
if (typeof insertion !== 'string') {
|
||||
throw new InvalidInsertionError('insertion must be a string')
|
||||
}
|
||||
if (containsNonBmpChars(insertion)) {
|
||||
throw new InvalidInsertionError('insertion contains non-BMP characters')
|
||||
}
|
||||
/** @type {string} */
|
||||
this.insertion = insertion
|
||||
/** @type {TrackingProps | undefined} */
|
||||
this.tracking = tracking
|
||||
/** @type {string[] | undefined} */
|
||||
this.commentIds = commentIds
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {RawInsertOp} 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,
|
||||
op.tracking && TrackingProps.fromRaw(op.tracking),
|
||||
op.commentIds
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {LengthApplyContext} current
|
||||
* @returns {LengthApplyContext}
|
||||
*/
|
||||
applyToLength(current) {
|
||||
current.length += this.insertion.length
|
||||
return current
|
||||
}
|
||||
|
||||
/** @inheritdoc
|
||||
* @param {ScanOp} other
|
||||
*/
|
||||
equals(other) {
|
||||
if (!(other instanceof InsertOp)) {
|
||||
return false
|
||||
}
|
||||
if (this.insertion !== other.insertion) {
|
||||
return false
|
||||
}
|
||||
if (this.tracking) {
|
||||
if (!this.tracking.equals(other.tracking)) {
|
||||
return false
|
||||
}
|
||||
} else if (other.tracking) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.commentIds) {
|
||||
return (
|
||||
this.commentIds.length === other.commentIds?.length &&
|
||||
this.commentIds.every(id => other.commentIds?.includes(id))
|
||||
)
|
||||
}
|
||||
return !other.commentIds
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ScanOp} other
|
||||
* @return {other is InsertOp}
|
||||
*/
|
||||
canMergeWith(other) {
|
||||
if (!(other instanceof InsertOp)) {
|
||||
return false
|
||||
}
|
||||
if (this.tracking) {
|
||||
if (!this.tracking.equals(other.tracking)) {
|
||||
return false
|
||||
}
|
||||
} else if (other.tracking) {
|
||||
return false
|
||||
}
|
||||
if (this.commentIds) {
|
||||
return (
|
||||
this.commentIds.length === other.commentIds?.length &&
|
||||
this.commentIds.every(id => other.commentIds?.includes(id))
|
||||
)
|
||||
}
|
||||
return !other.commentIds
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ScanOp} other
|
||||
*/
|
||||
mergeWith(other) {
|
||||
if (!this.canMergeWith(other)) {
|
||||
throw new Error('Cannot merge with incompatible operation')
|
||||
}
|
||||
this.insertion += other.insertion
|
||||
// We already have the same tracking info and commentIds
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {RawInsertOp}
|
||||
*/
|
||||
toJSON() {
|
||||
if (!this.tracking && !this.commentIds) {
|
||||
return this.insertion
|
||||
}
|
||||
/** @type RawInsertOp */
|
||||
const obj = { i: this.insertion }
|
||||
if (this.tracking) {
|
||||
obj.tracking = this.tracking.toRaw()
|
||||
}
|
||||
if (this.commentIds) {
|
||||
obj.commentIds = this.commentIds
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `insert '${this.insertion}'`
|
||||
}
|
||||
}
|
||||
|
||||
class RetainOp extends ScanOp {
|
||||
/**
|
||||
* @param {number} length
|
||||
* @param {TrackingDirective | undefined} tracking
|
||||
*/
|
||||
constructor(length, tracking = undefined) {
|
||||
super()
|
||||
if (length < 0) {
|
||||
throw new Error('length must be non-negative')
|
||||
}
|
||||
/** @type {number} */
|
||||
this.length = length
|
||||
/** @type {TrackingDirective | undefined} */
|
||||
this.tracking = tracking
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {RawRetainOp} op
|
||||
* @returns {RetainOp}
|
||||
*/
|
||||
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')
|
||||
}
|
||||
if (op.tracking) {
|
||||
const tracking =
|
||||
op.tracking.type === 'none'
|
||||
? new ClearTrackingProps()
|
||||
: TrackingProps.fromRaw(op.tracking)
|
||||
return new RetainOp(op.r, tracking)
|
||||
}
|
||||
return new RetainOp(op.r)
|
||||
}
|
||||
|
||||
/** @inheritdoc
|
||||
* @param {ScanOp} other
|
||||
*/
|
||||
equals(other) {
|
||||
if (!(other instanceof RetainOp)) {
|
||||
return false
|
||||
}
|
||||
if (this.length !== other.length) {
|
||||
return false
|
||||
}
|
||||
if (this.tracking) {
|
||||
return this.tracking.equals(other.tracking)
|
||||
}
|
||||
return !other.tracking
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ScanOp} other
|
||||
* @return {other is RetainOp}
|
||||
*/
|
||||
canMergeWith(other) {
|
||||
if (!(other instanceof RetainOp)) {
|
||||
return false
|
||||
}
|
||||
if (this.tracking) {
|
||||
return this.tracking.equals(other.tracking)
|
||||
}
|
||||
return !other.tracking
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ScanOp} other
|
||||
*/
|
||||
mergeWith(other) {
|
||||
if (!this.canMergeWith(other)) {
|
||||
throw new Error('Cannot merge with incompatible operation')
|
||||
}
|
||||
this.length += other.length
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {RawRetainOp}
|
||||
*/
|
||||
toJSON() {
|
||||
if (!this.tracking) {
|
||||
return this.length
|
||||
}
|
||||
return { r: this.length, tracking: this.tracking.toRaw() }
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `retain ${this.length}`
|
||||
}
|
||||
}
|
||||
|
||||
class RemoveOp extends ScanOp {
|
||||
/**
|
||||
* @param {number} length
|
||||
*/
|
||||
constructor(length) {
|
||||
super()
|
||||
if (length < 0) {
|
||||
throw new Error('length must be non-negative')
|
||||
}
|
||||
/** @type {number} */
|
||||
this.length = length
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {LengthApplyContext} current
|
||||
* @returns {LengthApplyContext}
|
||||
*/
|
||||
applyToLength(current) {
|
||||
current.inputCursor += this.length
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {RawRemoveOp} 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
|
||||
* @param {ScanOp} other
|
||||
* @return {boolean}
|
||||
*/
|
||||
equals(other) {
|
||||
if (!(other instanceof RemoveOp)) {
|
||||
return false
|
||||
}
|
||||
return this.length === other.length
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ScanOp} other
|
||||
* @return {other is RemoveOp}
|
||||
*/
|
||||
canMergeWith(other) {
|
||||
return other instanceof RemoveOp
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ScanOp} other
|
||||
*/
|
||||
mergeWith(other) {
|
||||
if (!this.canMergeWith(other)) {
|
||||
throw new Error('Cannot merge with incompatible operation')
|
||||
}
|
||||
this.length += other.length
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {RawRemoveOp}
|
||||
*/
|
||||
toJSON() {
|
||||
return -this.length
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `remove ${this.length}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RawScanOp} op
|
||||
* @returns {op is RawRetainOp}
|
||||
*/
|
||||
function isRetain(op) {
|
||||
return (
|
||||
(typeof op === 'number' && op > 0) ||
|
||||
(typeof op === 'object' &&
|
||||
'r' in op &&
|
||||
typeof op.r === 'number' &&
|
||||
op.r > 0)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RawScanOp} op
|
||||
* @returns {op is RawInsertOp}
|
||||
*/
|
||||
function isInsert(op) {
|
||||
return (
|
||||
typeof op === 'string' ||
|
||||
(typeof op === 'object' && 'i' in op && typeof op.i === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RawScanOp} op
|
||||
* @returns {op is RawRemoveOp}
|
||||
*/
|
||||
function isRemove(op) {
|
||||
return typeof op === 'number' && op < 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ScanOp,
|
||||
InsertOp,
|
||||
RetainOp,
|
||||
RemoveOp,
|
||||
isRetain,
|
||||
isInsert,
|
||||
isRemove,
|
||||
}
|
@@ -0,0 +1,112 @@
|
||||
// @ts-check
|
||||
const core = require('../../index')
|
||||
const Comment = require('../comment')
|
||||
const EditNoOperation = require('./edit_no_operation')
|
||||
const EditOperation = require('./edit_operation')
|
||||
|
||||
/**
|
||||
* @import DeleteCommentOperation from './delete_comment_operation'
|
||||
* @import { CommentRawData } from '../types'
|
||||
* @import { RawSetCommentStateOperation } from '../types'
|
||||
* @import StringFileData from '../file_data/string_file_data'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @extends EditOperation
|
||||
*/
|
||||
class SetCommentStateOperation extends EditOperation {
|
||||
/**
|
||||
* @param {string} commentId
|
||||
* @param {boolean} resolved
|
||||
*/
|
||||
constructor(commentId, resolved) {
|
||||
super()
|
||||
this.commentId = commentId
|
||||
this.resolved = resolved
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {RawSetCommentStateOperation}
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
resolved: this.resolved,
|
||||
commentId: this.commentId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StringFileData} fileData
|
||||
*/
|
||||
apply(fileData) {
|
||||
const comment = fileData.comments.getComment(this.commentId)
|
||||
if (comment) {
|
||||
const newComment = new Comment(comment.id, comment.ranges, this.resolved)
|
||||
fileData.comments.add(newComment)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StringFileData} previousState
|
||||
* @returns {SetCommentStateOperation | EditNoOperation}
|
||||
*/
|
||||
invert(previousState) {
|
||||
const comment = previousState.comments.getComment(this.commentId)
|
||||
if (!comment) {
|
||||
return new EditNoOperation()
|
||||
}
|
||||
|
||||
return new SetCommentStateOperation(this.commentId, comment.resolved)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {EditOperation} other
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canBeComposedWith(other) {
|
||||
return (
|
||||
(other instanceof SetCommentStateOperation &&
|
||||
this.commentId === other.commentId) ||
|
||||
(other instanceof core.DeleteCommentOperation &&
|
||||
this.commentId === other.commentId)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {EditOperation} other
|
||||
* @returns {SetCommentStateOperation | core.DeleteCommentOperation}
|
||||
*/
|
||||
compose(other) {
|
||||
if (
|
||||
other instanceof SetCommentStateOperation &&
|
||||
other.commentId === this.commentId
|
||||
) {
|
||||
return other
|
||||
}
|
||||
|
||||
if (
|
||||
other instanceof core.DeleteCommentOperation &&
|
||||
other.commentId === this.commentId
|
||||
) {
|
||||
return other
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Trying to compose SetCommentStateOperation with ${other?.constructor?.name}.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {RawSetCommentStateOperation} raw
|
||||
* @returns {SetCommentStateOperation}
|
||||
*/
|
||||
static fromJSON(raw) {
|
||||
return new SetCommentStateOperation(raw.commentId, raw.resolved)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SetCommentStateOperation
|
@@ -0,0 +1,53 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const Operation = require('./')
|
||||
|
||||
/**
|
||||
* Moves or removes a file from a project.
|
||||
*/
|
||||
class SetFileMetadataOperation extends Operation {
|
||||
/**
|
||||
* @param {string} pathname
|
||||
* @param {Object} metadata
|
||||
*/
|
||||
constructor(pathname, metadata) {
|
||||
super()
|
||||
assert.string(pathname, 'SetFileMetadataOperation: bad pathname')
|
||||
assert.object(metadata, 'SetFileMetadataOperation: bad metadata')
|
||||
|
||||
this.pathname = pathname
|
||||
this.metadata = metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
toRaw() {
|
||||
return {
|
||||
pathname: this.pathname,
|
||||
metadata: _.cloneDeep(this.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
getPathname() {
|
||||
return this.pathname
|
||||
}
|
||||
|
||||
getMetadata() {
|
||||
return this.metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
applyTo(snapshot) {
|
||||
const file = snapshot.getFile(this.pathname)
|
||||
if (!file) return
|
||||
file.setMetadata(this.metadata)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SetFileMetadataOperation
|
929
libraries/overleaf-editor-core/lib/operation/text_operation.js
Normal file
929
libraries/overleaf-editor-core/lib/operation/text_operation.js
Normal file
@@ -0,0 +1,929 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* The text operation from OT.js with some minor cosmetic changes.
|
||||
*
|
||||
* Specifically, this is based on
|
||||
* https://github.com/Operational-Transformation/ot.js/
|
||||
* blob/298825f58fb51fefb352e7df5ddbc668f4d5646f/lib/text-operation.js
|
||||
* from 18 Mar 2013.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
const containsNonBmpChars = require('../util').containsNonBmpChars
|
||||
const EditOperation = require('./edit_operation')
|
||||
const {
|
||||
RetainOp,
|
||||
InsertOp,
|
||||
RemoveOp,
|
||||
isRetain,
|
||||
isInsert,
|
||||
isRemove,
|
||||
} = require('./scan_op')
|
||||
const {
|
||||
UnprocessableError,
|
||||
ApplyError,
|
||||
InvalidInsertionError,
|
||||
TooLongError,
|
||||
} = require('../errors')
|
||||
const Range = require('../range')
|
||||
const ClearTrackingProps = require('../file_data/clear_tracking_props')
|
||||
const TrackingProps = require('../file_data/tracking_props')
|
||||
|
||||
/**
|
||||
* @import StringFileData from '../file_data/string_file_data'
|
||||
* @import { RawTextOperation, TrackingDirective } from '../types'
|
||||
* @import { ScanOp } from '../operation/scan_op'
|
||||
* @import TrackedChangeList from '../file_data/tracked_change_list'
|
||||
*
|
||||
* @typedef {{tracking?: TrackingProps, commentIds?: string[]}} InsertOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create an empty text operation.
|
||||
* @extends EditOperation
|
||||
*/
|
||||
class TextOperation extends EditOperation {
|
||||
/**
|
||||
* Length of the longest file that we'll attempt to edit, in characters.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
static MAX_STRING_LENGTH = 2 * Math.pow(1024, 2)
|
||||
static UnprocessableError = UnprocessableError
|
||||
static ApplyError = ApplyError
|
||||
static InvalidInsertionError = InvalidInsertionError
|
||||
static TooLongError = TooLongError
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
/**
|
||||
* When an operation is applied to an input string, you can think of this as
|
||||
* 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.
|
||||
*/
|
||||
this.baseLength = 0
|
||||
|
||||
/**
|
||||
* The targetLength is the length of every string that results from applying
|
||||
* the operation on a valid input string.
|
||||
*/
|
||||
this.targetLength = 0
|
||||
|
||||
/**
|
||||
* The expected content hash after this operation is applied
|
||||
*
|
||||
* @type {string | null}
|
||||
*/
|
||||
this.contentHash = null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TextOperation} other
|
||||
* @return {boolean}
|
||||
*/
|
||||
equals(other) {
|
||||
if (this.baseLength !== other.baseLength) {
|
||||
return false
|
||||
}
|
||||
if (this.targetLength !== other.targetLength) {
|
||||
return false
|
||||
}
|
||||
if (this.ops.length !== other.ops.length) {
|
||||
return false
|
||||
}
|
||||
for (let i = 0; i < this.ops.length; i++) {
|
||||
if (!this.ops[i].equals(other.ops[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// After an operation is constructed, the user of the library can specify the
|
||||
// actions of an operation (skip/insert/remove) with these three builder
|
||||
// methods. They all return the operation for convenient chaining.
|
||||
|
||||
/**
|
||||
* Skip over a given number of characters.
|
||||
* @param {number | {r: number}} n
|
||||
* @param {{tracking?: TrackingDirective}} opts
|
||||
* @returns {TextOperation}
|
||||
*/
|
||||
retain(n, opts = {}) {
|
||||
if (n === 0) {
|
||||
return this
|
||||
}
|
||||
|
||||
if (!isRetain(n)) {
|
||||
throw new Error('retain expects an integer or a retain object')
|
||||
}
|
||||
const newOp = RetainOp.fromJSON(n)
|
||||
newOp.tracking = opts.tracking
|
||||
|
||||
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.
|
||||
lastOperation.mergeWith(newOp)
|
||||
} else {
|
||||
// Create a new op.
|
||||
this.ops.push(newOp)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a string at the current position.
|
||||
* @param {string | {i: string}} insertValue
|
||||
* @param {InsertOptions} opts
|
||||
* @returns {TextOperation}
|
||||
*/
|
||||
insert(insertValue, opts = {}) {
|
||||
if (!isInsert(insertValue)) {
|
||||
throw new Error('insert expects a string or an insert object')
|
||||
}
|
||||
const newOp = InsertOp.fromJSON(insertValue)
|
||||
newOp.tracking = opts.tracking
|
||||
newOp.commentIds = opts.commentIds
|
||||
if (newOp.insertion === '') {
|
||||
return this
|
||||
}
|
||||
this.targetLength += newOp.insertion.length
|
||||
const ops = this.ops
|
||||
const lastOp = this.ops[this.ops.length - 1]
|
||||
if (lastOp?.canMergeWith(newOp)) {
|
||||
// Merge insert op.
|
||||
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.
|
||||
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] = newOp
|
||||
}
|
||||
} else {
|
||||
ops.push(newOp)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a string at the current position.
|
||||
* @param {number | string} n
|
||||
* @returns {TextOperation}
|
||||
*/
|
||||
remove(n) {
|
||||
if (typeof n === 'string') {
|
||||
n = n.length
|
||||
}
|
||||
if (typeof n !== 'number') {
|
||||
throw new Error('remove expects an integer or a string')
|
||||
}
|
||||
if (n === 0) {
|
||||
return this
|
||||
}
|
||||
if (n > 0) {
|
||||
n = -n
|
||||
}
|
||||
const newOp = RemoveOp.fromJSON(n)
|
||||
this.baseLength -= n
|
||||
const lastOp = this.ops[this.ops.length - 1]
|
||||
if (lastOp?.canMergeWith(newOp)) {
|
||||
lastOp.mergeWith(newOp)
|
||||
} else {
|
||||
this.ops.push(newOp)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether this operation has no effect.
|
||||
*/
|
||||
isNoop() {
|
||||
return (
|
||||
this.ops.length === 0 ||
|
||||
(this.ops.length === 1 && this.ops[0] instanceof RetainOp)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty printing.
|
||||
*/
|
||||
toString() {
|
||||
return this.ops.map(op => op.toString()).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {RawTextOperation}
|
||||
*/
|
||||
toJSON() {
|
||||
/** @type {RawTextOperation} */
|
||||
const json = { textOperation: this.ops.map(op => op.toJSON()) }
|
||||
if (this.contentHash != null) {
|
||||
json.contentHash = this.contentHash
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a plain JS object into an operation and validates it.
|
||||
* @param {RawTextOperation} obj
|
||||
* @returns {TextOperation}
|
||||
*/
|
||||
static fromJSON = function ({ textOperation: ops, contentHash }) {
|
||||
const o = new TextOperation()
|
||||
for (const op of ops) {
|
||||
if (isRetain(op)) {
|
||||
const retain = RetainOp.fromJSON(op)
|
||||
o.retain(retain.length, { tracking: retain.tracking })
|
||||
} else if (isInsert(op)) {
|
||||
const insert = InsertOp.fromJSON(op)
|
||||
o.insert(insert.insertion, {
|
||||
commentIds: insert.commentIds,
|
||||
tracking: insert.tracking,
|
||||
})
|
||||
} else if (isRemove(op)) {
|
||||
const remove = RemoveOp.fromJSON(op)
|
||||
o.remove(-remove.length)
|
||||
} else {
|
||||
throw new UnprocessableError('unknown operation: ' + JSON.stringify(op))
|
||||
}
|
||||
}
|
||||
if (contentHash != null) {
|
||||
o.contentHash = contentHash
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an operation to a string, returning a new string. Throws an error if
|
||||
* there's a mismatch between the input string and the operation.
|
||||
* @override
|
||||
* @inheritdoc
|
||||
* @param {StringFileData} file
|
||||
*/
|
||||
apply(file) {
|
||||
const str = file.getContent()
|
||||
const operation = this
|
||||
if (containsNonBmpChars(str)) {
|
||||
throw new TextOperation.ApplyError(
|
||||
'The string contains non BMP characters.',
|
||||
operation,
|
||||
str
|
||||
)
|
||||
}
|
||||
if (str.length !== operation.baseLength) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"The operation's base length must be equal to the string's length.",
|
||||
operation,
|
||||
str
|
||||
)
|
||||
}
|
||||
|
||||
const ops = this.ops
|
||||
let inputCursor = 0
|
||||
let result = ''
|
||||
for (const op of ops) {
|
||||
if (op instanceof RetainOp) {
|
||||
if (inputCursor + op.length > str.length) {
|
||||
throw new ApplyError(
|
||||
"Operation can't retain more chars than are left in the string.",
|
||||
op.toJSON(),
|
||||
str
|
||||
)
|
||||
}
|
||||
file.trackedChanges.applyRetain(result.length, op.length, {
|
||||
tracking: op.tracking,
|
||||
})
|
||||
result += str.slice(inputCursor, inputCursor + op.length)
|
||||
inputCursor += op.length
|
||||
} else if (op instanceof InsertOp) {
|
||||
if (containsNonBmpChars(op.insertion)) {
|
||||
throw new InvalidInsertionError(str, op.toJSON())
|
||||
}
|
||||
file.trackedChanges.applyInsert(result.length, op.insertion, {
|
||||
tracking: op.tracking,
|
||||
})
|
||||
file.comments.applyInsert(
|
||||
new Range(result.length, op.insertion.length),
|
||||
{ commentIds: op.commentIds }
|
||||
)
|
||||
result += op.insertion
|
||||
} else if (op instanceof RemoveOp) {
|
||||
file.trackedChanges.applyDelete(result.length, op.length)
|
||||
file.comments.applyDelete(new Range(result.length, op.length))
|
||||
inputCursor += op.length
|
||||
} else {
|
||||
throw new UnprocessableError('Unknown ScanOp type during apply')
|
||||
}
|
||||
}
|
||||
|
||||
if (inputCursor !== str.length) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"The operation didn't operate on the whole string.",
|
||||
operation,
|
||||
str
|
||||
)
|
||||
}
|
||||
|
||||
if (result.length > TextOperation.MAX_STRING_LENGTH) {
|
||||
throw new TextOperation.TooLongError(operation, result.length)
|
||||
}
|
||||
|
||||
file.content = result
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {number} length of the original string; non-negative
|
||||
* @return {number} length of the new string; non-negative
|
||||
*/
|
||||
applyToLength(length) {
|
||||
const operation = this
|
||||
if (length !== operation.baseLength) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"The operation's base length must be equal to the string's length.",
|
||||
operation,
|
||||
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,
|
||||
length
|
||||
)
|
||||
}
|
||||
if (newLength > TextOperation.MAX_STRING_LENGTH) {
|
||||
throw new TextOperation.TooLongError(operation, newLength)
|
||||
}
|
||||
return newLength
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {StringFileData} previousState
|
||||
*/
|
||||
invert(previousState) {
|
||||
const str = previousState.getContent()
|
||||
let strIndex = 0
|
||||
const inverse = new TextOperation()
|
||||
const ops = this.ops
|
||||
for (let i = 0, l = ops.length; i < l; i++) {
|
||||
const op = ops[i]
|
||||
if (op instanceof RetainOp) {
|
||||
// Where we need to end up after the retains
|
||||
const target = strIndex + op.length
|
||||
// A previous retain could have overriden some tracking info. Now we
|
||||
// need to restore it.
|
||||
const previousRanges = previousState.trackedChanges.inRange(
|
||||
new Range(strIndex, op.length)
|
||||
)
|
||||
|
||||
let removeTrackingInfoIfNeeded
|
||||
if (op.tracking) {
|
||||
removeTrackingInfoIfNeeded = new ClearTrackingProps()
|
||||
}
|
||||
|
||||
for (const trackedChange of previousRanges) {
|
||||
if (strIndex < trackedChange.range.start) {
|
||||
inverse.retain(trackedChange.range.start - strIndex, {
|
||||
tracking: removeTrackingInfoIfNeeded,
|
||||
})
|
||||
strIndex = trackedChange.range.start
|
||||
}
|
||||
if (trackedChange.range.end < strIndex + op.length) {
|
||||
inverse.retain(trackedChange.range.length, {
|
||||
tracking: trackedChange.tracking,
|
||||
})
|
||||
strIndex = trackedChange.range.end
|
||||
}
|
||||
if (trackedChange.range.end !== strIndex) {
|
||||
// No need to split the range at the end
|
||||
const [left] = trackedChange.range.splitAt(strIndex)
|
||||
inverse.retain(left.length, { tracking: trackedChange.tracking })
|
||||
strIndex = left.end
|
||||
}
|
||||
}
|
||||
if (strIndex < target) {
|
||||
inverse.retain(target - strIndex, {
|
||||
tracking: removeTrackingInfoIfNeeded,
|
||||
})
|
||||
strIndex = target
|
||||
}
|
||||
} else if (op instanceof InsertOp) {
|
||||
inverse.remove(op.insertion.length)
|
||||
} else if (op instanceof RemoveOp) {
|
||||
const segments = calculateTrackingCommentSegments(
|
||||
strIndex,
|
||||
op.length,
|
||||
previousState.comments,
|
||||
previousState.trackedChanges
|
||||
)
|
||||
for (const segment of segments) {
|
||||
inverse.insert(str.slice(strIndex, strIndex + segment.length), {
|
||||
tracking: segment.tracking,
|
||||
commentIds: segment.commentIds,
|
||||
})
|
||||
strIndex += segment.length
|
||||
}
|
||||
} else {
|
||||
throw new UnprocessableError('unknown scanop during inversion')
|
||||
}
|
||||
}
|
||||
return inverse
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {EditOperation} other
|
||||
*/
|
||||
canBeComposedWithForUndo(other) {
|
||||
if (!(other instanceof TextOperation)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.isNoop() || other.isNoop()) {
|
||||
return true
|
||||
}
|
||||
|
||||
const startA = getStartIndex(this)
|
||||
const startB = getStartIndex(other)
|
||||
const simpleA = getSimpleOp(this)
|
||||
const simpleB = getSimpleOp(other)
|
||||
if (!simpleA || !simpleB) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (simpleA instanceof InsertOp && simpleB instanceof InsertOp) {
|
||||
return startA + simpleA.insertion.length === startB
|
||||
}
|
||||
|
||||
if (simpleA instanceof RemoveOp && simpleB instanceof RemoveOp) {
|
||||
// there are two possibilities to delete: with backspace and with the
|
||||
// delete key.
|
||||
return startB + simpleB.length === startA || startA === startB
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {EditOperation} other
|
||||
*/
|
||||
canBeComposedWith(other) {
|
||||
if (!(other instanceof TextOperation)) {
|
||||
return false
|
||||
}
|
||||
return this.targetLength === other.baseLength
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @param {EditOperation} operation2
|
||||
*/
|
||||
compose(operation2) {
|
||||
if (!(operation2 instanceof TextOperation)) {
|
||||
throw new Error(
|
||||
`Trying to compose TextOperation with ${operation2?.constructor?.name}.`
|
||||
)
|
||||
}
|
||||
const operation1 = this
|
||||
if (operation1.targetLength !== operation2.baseLength) {
|
||||
throw new Error(
|
||||
'The base length of the second operation has to be the ' +
|
||||
'target length of the first operation'
|
||||
)
|
||||
}
|
||||
|
||||
const operation = new TextOperation() // the combined operation
|
||||
const ops1 = operation1.ops
|
||||
const ops2 = operation2.ops // for fast access
|
||||
let i1 = 0
|
||||
let i2 = 0 // current index into ops1 respectively ops2
|
||||
let op1 = ops1[i1++]
|
||||
let op2 = ops2[i2++] // current ops
|
||||
for (;;) {
|
||||
// Dispatch on the type of op1 and op2
|
||||
if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
|
||||
// end condition: both ops1 and ops2 have been processed
|
||||
break
|
||||
}
|
||||
|
||||
if (op1 instanceof RemoveOp) {
|
||||
operation.remove(-op1.length)
|
||||
op1 = ops1[i1++]
|
||||
continue
|
||||
}
|
||||
|
||||
if (op2 instanceof InsertOp) {
|
||||
operation.insert(op2.insertion, {
|
||||
tracking: op2.tracking,
|
||||
commentIds: op2.commentIds,
|
||||
})
|
||||
op2 = ops2[i2++]
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof op1 === 'undefined') {
|
||||
throw new Error(
|
||||
'Cannot compose operations: first operation is too short.'
|
||||
)
|
||||
}
|
||||
if (typeof op2 === 'undefined') {
|
||||
throw new Error(
|
||||
'Cannot compose operations: first operation is too long.'
|
||||
)
|
||||
}
|
||||
|
||||
if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
|
||||
// If both have tracking info, use the latter one. Otherwise use the
|
||||
// tracking info from the former.
|
||||
const tracking = op2.tracking ?? op1.tracking
|
||||
if (op1.length > op2.length) {
|
||||
operation.retain(op2.length, {
|
||||
tracking,
|
||||
})
|
||||
op1 = new RetainOp(op1.length - op2.length, op1.tracking)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1.length === op2.length) {
|
||||
operation.retain(op1.length, {
|
||||
tracking,
|
||||
})
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
operation.retain(op1.length, {
|
||||
tracking,
|
||||
})
|
||||
op2 = new RetainOp(op2.length - op1.length, op2.tracking)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
} else if (op1 instanceof InsertOp && op2 instanceof RemoveOp) {
|
||||
if (op1.insertion.length > op2.length) {
|
||||
op1 = new InsertOp(
|
||||
op1.insertion.slice(op2.length),
|
||||
op1.tracking,
|
||||
op1.commentIds
|
||||
)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1.insertion.length === op2.length) {
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
op2 = RemoveOp.fromJSON(op1.insertion.length - op2.length)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
} else if (op1 instanceof InsertOp && op2 instanceof RetainOp) {
|
||||
/** @type InsertOptions */
|
||||
const opts = {
|
||||
commentIds: op1.commentIds,
|
||||
}
|
||||
if (op2.tracking instanceof TrackingProps) {
|
||||
// Prefer the tracking info on the second operation
|
||||
opts.tracking = op2.tracking
|
||||
} else if (!(op2.tracking instanceof ClearTrackingProps)) {
|
||||
// The second operation does not cancel the first operation's tracking
|
||||
opts.tracking = op1.tracking
|
||||
}
|
||||
if (op1.insertion.length > op2.length) {
|
||||
operation.insert(op1.insertion.slice(0, op2.length), opts)
|
||||
op1 = new InsertOp(
|
||||
op1.insertion.slice(op2.length),
|
||||
op1.tracking,
|
||||
op1.commentIds
|
||||
)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1.insertion.length === op2.length) {
|
||||
operation.insert(op1.insertion, opts)
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
operation.insert(op1.insertion, opts)
|
||||
op2 = new RetainOp(op2.length - op1.insertion.length, op2.tracking)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
} else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
|
||||
if (op1.length > op2.length) {
|
||||
operation.remove(-op2.length)
|
||||
op1 = new RetainOp(op1.length - op2.length, op1.tracking)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1.length === op2.length) {
|
||||
operation.remove(-op2.length)
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
operation.remove(op1.length)
|
||||
op2 = RemoveOp.fromJSON(op1.length - op2.length)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"This shouldn't happen: op1: " +
|
||||
JSON.stringify(op1) +
|
||||
', op2: ' +
|
||||
JSON.stringify(op2)
|
||||
)
|
||||
}
|
||||
}
|
||||
return operation
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform takes two operations A and B that happened concurrently and
|
||||
* produces two operations A' and B' (in an array) such that
|
||||
* `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the
|
||||
* heart of OT.
|
||||
* @param {TextOperation} operation1
|
||||
* @param {TextOperation} operation2
|
||||
* @returns {[TextOperation, TextOperation]}
|
||||
*/
|
||||
static transform(operation1, operation2) {
|
||||
if (operation1.baseLength !== operation2.baseLength) {
|
||||
throw new Error('Both operations have to have the same base length')
|
||||
}
|
||||
|
||||
const operation1prime = new TextOperation()
|
||||
const operation2prime = new TextOperation()
|
||||
const ops1 = operation1.ops
|
||||
const ops2 = operation2.ops
|
||||
let i1 = 0
|
||||
let i2 = 0
|
||||
let op1 = ops1[i1++]
|
||||
let op2 = ops2[i2++]
|
||||
for (;;) {
|
||||
// At every iteration of the loop, the imaginary cursor that both
|
||||
// operation1 and operation2 have that operates on the input string must
|
||||
// have the same position in the input string.
|
||||
|
||||
if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
|
||||
// end condition: both ops1 and ops2 have been processed
|
||||
break
|
||||
}
|
||||
|
||||
// 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 (op1 instanceof InsertOp) {
|
||||
operation1prime.insert(op1.insertion, {
|
||||
tracking: op1.tracking,
|
||||
commentIds: op1.commentIds,
|
||||
})
|
||||
operation2prime.retain(op1.insertion.length)
|
||||
op1 = ops1[i1++]
|
||||
continue
|
||||
}
|
||||
if (op2 instanceof InsertOp) {
|
||||
operation1prime.retain(op2.insertion.length)
|
||||
operation2prime.insert(op2.insertion, {
|
||||
tracking: op2.tracking,
|
||||
commentIds: op2.commentIds,
|
||||
})
|
||||
op2 = ops2[i2++]
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof op1 === 'undefined') {
|
||||
throw new Error(
|
||||
'Cannot compose operations: first operation is too short.'
|
||||
)
|
||||
}
|
||||
if (typeof op2 === 'undefined') {
|
||||
throw new Error(
|
||||
'Cannot compose operations: first operation is too long.'
|
||||
)
|
||||
}
|
||||
|
||||
let minl
|
||||
if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
|
||||
// Simple case: retain/retain
|
||||
|
||||
// If both have tracking info, we use the one from op1
|
||||
/** @type {TrackingProps | ClearTrackingProps | undefined} */
|
||||
let operation1primeTracking
|
||||
/** @type {TrackingProps | ClearTrackingProps | undefined} */
|
||||
let operation2primeTracking
|
||||
if (op1.tracking) {
|
||||
operation1primeTracking = op1.tracking
|
||||
} else {
|
||||
operation2primeTracking = op2.tracking
|
||||
}
|
||||
|
||||
if (op1.length > op2.length) {
|
||||
minl = op2.length
|
||||
op1 = new RetainOp(op1.length - op2.length, op1.tracking)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1.length === op2.length) {
|
||||
minl = op2.length
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
minl = op1.length
|
||||
op2 = new RetainOp(op2.length - op1.length, op2.tracking)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
operation1prime.retain(minl, { tracking: operation1primeTracking })
|
||||
operation2prime.retain(minl, { tracking: operation2primeTracking })
|
||||
} 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.length > op2.length) {
|
||||
op1 = RemoveOp.fromJSON(op2.length - op1.length)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1.length === op2.length) {
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
op2 = RemoveOp.fromJSON(op1.length - op2.length)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
// next two cases: remove/retain and retain/remove
|
||||
} else if (op1 instanceof RemoveOp && op2 instanceof RetainOp) {
|
||||
if (op1.length > op2.length) {
|
||||
minl = op2.length
|
||||
op1 = RemoveOp.fromJSON(op2.length - op1.length)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1.length === op2.length) {
|
||||
minl = op2.length
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
minl = op1.length
|
||||
op2 = new RetainOp(op2.length - op1.length, op2.tracking)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
operation1prime.remove(minl)
|
||||
} else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
|
||||
if (op1.length > op2.length) {
|
||||
minl = op2.length
|
||||
op1 = new RetainOp(op1.length - op2.length, op1.tracking)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1.length === op2.length) {
|
||||
minl = op1.length
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
minl = op1.length
|
||||
op2 = RemoveOp.fromJSON(op1.length - op2.length)
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
operation2prime.remove(minl)
|
||||
} else {
|
||||
throw new Error("The two operations aren't compatible")
|
||||
}
|
||||
}
|
||||
|
||||
return [operation1prime, operation2prime]
|
||||
}
|
||||
}
|
||||
|
||||
// Operation are essentially lists of ops. There are three types of ops:
|
||||
//
|
||||
// * Retain ops: Advance the cursor position by a given number of characters.
|
||||
// Represented by positive ints.
|
||||
// * Insert ops: Insert a given string at the current cursor position.
|
||||
// Represented by strings.
|
||||
// * Remove ops: Remove the next n characters. Represented by negative ints.
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {TextOperation} operation
|
||||
* @returns {ScanOp | null}
|
||||
*/
|
||||
function getSimpleOp(operation) {
|
||||
const ops = operation.ops
|
||||
switch (ops.length) {
|
||||
case 1:
|
||||
return ops[0]
|
||||
case 2:
|
||||
return ops[0] instanceof RetainOp
|
||||
? ops[1]
|
||||
: ops[1] instanceof RetainOp
|
||||
? ops[0]
|
||||
: null
|
||||
case 3:
|
||||
if (ops[0] instanceof RetainOp && ops[2] instanceof RetainOp) {
|
||||
return ops[1]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TextOperation} operation
|
||||
* @return {number}
|
||||
*/
|
||||
function getStartIndex(operation) {
|
||||
if (operation.ops[0] instanceof RetainOp) {
|
||||
return operation.ops[0].length
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the segments defined as each overlapping range of tracked
|
||||
* changes and comments. Each segment can have it's own tracking props and
|
||||
* attached comment ids.
|
||||
*
|
||||
* The quick brown fox jumps over the lazy dog
|
||||
* Tracked inserts ---------- -----
|
||||
* Tracked deletes ------
|
||||
* Comment 1 -------
|
||||
* Comment 2 ----
|
||||
* Comment 3 -----------------
|
||||
*
|
||||
* Approx. boundaries: | | | || | | | |
|
||||
*
|
||||
* @param {number} cursor
|
||||
* @param {number} length
|
||||
* @param {import('../file_data/comment_list')} commentsList
|
||||
* @param {TrackedChangeList} trackedChangeList
|
||||
* @returns {{length: number, commentIds?: string[], tracking?: TrackingProps}[]}
|
||||
*/
|
||||
function calculateTrackingCommentSegments(
|
||||
cursor,
|
||||
length,
|
||||
commentsList,
|
||||
trackedChangeList
|
||||
) {
|
||||
const breaks = new Set()
|
||||
const opStart = cursor
|
||||
const opEnd = cursor + length
|
||||
/**
|
||||
* Utility function to limit breaks to the boundary set by the operation range
|
||||
* @param {number} rangeBoundary
|
||||
*/
|
||||
function addBreak(rangeBoundary) {
|
||||
if (rangeBoundary < opStart || rangeBoundary > opEnd) {
|
||||
return
|
||||
}
|
||||
breaks.add(rangeBoundary)
|
||||
}
|
||||
// Add comment boundaries
|
||||
for (const comment of commentsList.comments.values()) {
|
||||
for (const range of comment.ranges) {
|
||||
addBreak(range.end)
|
||||
addBreak(range.start)
|
||||
}
|
||||
}
|
||||
// Add tracked change boundaries
|
||||
for (const trackedChange of trackedChangeList.asSorted()) {
|
||||
addBreak(trackedChange.range.start)
|
||||
addBreak(trackedChange.range.end)
|
||||
}
|
||||
// Add operation boundaries
|
||||
addBreak(opStart)
|
||||
addBreak(opEnd)
|
||||
|
||||
// Sort the boundaries so that we can construct ranges between them
|
||||
const sortedBreaks = Array.from(breaks).sort((a, b) => a - b)
|
||||
|
||||
const separateRanges = []
|
||||
for (let i = 1; i < sortedBreaks.length; i++) {
|
||||
const start = sortedBreaks[i - 1]
|
||||
const end = sortedBreaks[i]
|
||||
const currentRange = new Range(start, end - start)
|
||||
// The comment ids that cover the current range is part of this sub-range
|
||||
const commentIds = commentsList.idsCoveringRange(currentRange)
|
||||
// The tracking info that covers the current range is part of this sub-range
|
||||
const tracking = trackedChangeList.propsAtRange(currentRange)
|
||||
separateRanges.push({
|
||||
length: currentRange.length,
|
||||
commentIds: commentIds.length > 0 ? commentIds : undefined,
|
||||
tracking,
|
||||
})
|
||||
}
|
||||
return separateRanges
|
||||
}
|
||||
|
||||
module.exports = TextOperation
|
Reference in New Issue
Block a user