From 7d099acfdd8bd77ffe235d33b028258cf4b4ac70 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween Date: Wed, 5 Oct 2022 08:17:32 -0400 Subject: [PATCH] Merge pull request #9150 from overleaf/em-share-ranges-tracker Move RangesTracker to shared lib GitOrigin-RevId: 62da7208f0b453dd7272c06873c7e415ed887817 --- package-lock.json | 235 +++++ .../document-updater/app/js/RangesManager.js | 2 +- .../document-updater/app/js/RangesTracker.js | 849 ------------------ services/document-updater/package.json | 1 + .../js/RangesManager/RangesManagerTests.js | 5 +- .../unit/js/ShareJS/TextTransformTests.js | 2 +- .../web/frontend/js/ide/editor/Document.js | 18 +- .../js/ide/review-panel/RangesTracker.js | 841 ----------------- .../controllers/ReviewPanelController.js | 2 +- services/web/package.json | 1 + 10 files changed, 256 insertions(+), 1700 deletions(-) delete mode 100644 services/document-updater/app/js/RangesTracker.js delete mode 100644 services/web/frontend/js/ide/review-panel/RangesTracker.js diff --git a/package-lock.json b/package-lock.json index e5af073645..95d5a50469 100644 --- a/package-lock.json +++ b/package-lock.json @@ -789,6 +789,133 @@ "mocha": "^5.2.0" } }, + "libraries/ranges-tracker": { + "name": "@overleaf/ranges-tracker", + "devDependencies": { + "mocha": "^10.0.0" + } + }, + "libraries/ranges-tracker/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "libraries/ranges-tracker/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "libraries/ranges-tracker/node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "libraries/ranges-tracker/node_modules/minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "libraries/ranges-tracker/node_modules/mocha": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.0.0.tgz", + "integrity": "sha512-0Wl+elVUD43Y0BqPZBzZt8Tnkw9CMUdNYnUsTfOM1vuhJVZL+kiesFYsqwBkEEuEixaiPe5ZQdqDgX2jddhmoA==", + "dev": true, + "dependencies": { + "@ungap/promise-all-settled": "1.1.2", + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.4", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "5.0.1", + "ms": "2.1.3", + "nanoid": "3.3.3", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "workerpool": "6.2.1", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "libraries/ranges-tracker/node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "dev": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "libraries/ranges-tracker/node_modules/workerpool": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", + "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", + "dev": true + }, + "libraries/ranges-tracker/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, "libraries/redis-wrapper": { "name": "@overleaf/redis-wrapper", "version": "2.1.0", @@ -4344,6 +4471,10 @@ "resolved": "services/project-history", "link": true }, + "node_modules/@overleaf/ranges-tracker": { + "resolved": "libraries/ranges-tracker", + "link": true + }, "node_modules/@overleaf/real-time": { "resolved": "services/real-time", "link": true @@ -32371,6 +32502,7 @@ "@overleaf/logger": "^3.1.0", "@overleaf/metrics": "^4.0.0", "@overleaf/o-error": "^3.4.0", + "@overleaf/ranges-tracker": "*", "@overleaf/redis-wrapper": "^2.0.1", "@overleaf/settings": "^3.0.0", "async": "^3.2.2", @@ -34814,6 +34946,7 @@ "@overleaf/metrics": "^4.0.0", "@overleaf/o-error": "^3.4.0", "@overleaf/object-persistor": "^1.0.1", + "@overleaf/ranges-tracker": "*", "@overleaf/redis-wrapper": "^2.0.0", "@overleaf/settings": "^3.0.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", @@ -40299,6 +40432,7 @@ "@overleaf/logger": "^3.1.0", "@overleaf/metrics": "^4.0.0", "@overleaf/o-error": "^3.4.0", + "@overleaf/ranges-tracker": "*", "@overleaf/redis-wrapper": "^2.0.1", "@overleaf/settings": "^3.0.0", "async": "^3.2.2", @@ -41597,6 +41731,106 @@ } } }, + "@overleaf/ranges-tracker": { + "version": "file:libraries/ranges-tracker", + "requires": { + "mocha": "*" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + }, + "dependencies": { + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "mocha": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.0.0.tgz", + "integrity": "sha512-0Wl+elVUD43Y0BqPZBzZt8Tnkw9CMUdNYnUsTfOM1vuhJVZL+kiesFYsqwBkEEuEixaiPe5ZQdqDgX2jddhmoA==", + "dev": true, + "requires": { + "@ungap/promise-all-settled": "1.1.2", + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.4", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "5.0.1", + "ms": "2.1.3", + "nanoid": "3.3.3", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "workerpool": "6.2.1", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + } + }, + "nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "dev": true + }, + "workerpool": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", + "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", + "dev": true + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + } + } + }, "@overleaf/real-time": { "version": "file:services/real-time", "requires": { @@ -42434,6 +42668,7 @@ "@overleaf/metrics": "^4.0.0", "@overleaf/o-error": "^3.4.0", "@overleaf/object-persistor": "^1.0.1", + "@overleaf/ranges-tracker": "*", "@overleaf/redis-wrapper": "^2.0.0", "@overleaf/settings": "^3.0.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", diff --git a/services/document-updater/app/js/RangesManager.js b/services/document-updater/app/js/RangesManager.js index 581a7426c8..8116333b41 100644 --- a/services/document-updater/app/js/RangesManager.js +++ b/services/document-updater/app/js/RangesManager.js @@ -11,7 +11,7 @@ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ let RangesManager -const RangesTracker = require('./RangesTracker') +const RangesTracker = require('@overleaf/ranges-tracker') const logger = require('@overleaf/logger') const Metrics = require('./Metrics') const _ = require('lodash') diff --git a/services/document-updater/app/js/RangesTracker.js b/services/document-updater/app/js/RangesTracker.js deleted file mode 100644 index 33bb69012d..0000000000 --- a/services/document-updater/app/js/RangesTracker.js +++ /dev/null @@ -1,849 +0,0 @@ -/* eslint-disable - camelcase, - no-return-assign, - no-undef, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS101: Remove unnecessary use of Array.from - * DS102: Remove unnecessary code created because of implicit returns - * DS205: Consider reworking code to avoid use of IIFEs - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -// This file is shared between document-updater and web, so that the server and client share -// an identical track changes implementation. Do not edit it directly in web or document-updater, -// instead edit it at https://github.com/sharelatex/ranges-tracker, where it has a suite of tests -const load = function () { - let RangesTracker - return (RangesTracker = class RangesTracker { - // The purpose of this class is to track a set of inserts and deletes to a document, like - // track changes in Word. We store these as a set of ShareJs style ranges: - // {i: "foo", p: 42} # Insert 'foo' at offset 42 - // {d: "bar", p: 37} # Delete 'bar' at offset 37 - // We only track the inserts and deletes, not the whole document, but by being given all - // updates that are applied to a document, we can update these appropriately. - // - // Note that the set of inserts and deletes we store applies to the document as-is at the moment. - // So inserts correspond to text which is in the document, while deletes correspond to text which - // is no longer there, so their lengths do not affect the position of later offsets. - // E.g. - // this is the current text of the document - // |-----| | - // {i: "current ", p:12} -^ ^- {d: "old ", p: 31} - // - // Track changes rules (should be consistent with Word): - // * When text is inserted at a delete, the text goes to the left of the delete - // I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted - // * Deleting content flagged as 'inserted' does not create a new delete marker, it only - // removes the insert marker. E.g. - // * "abdefghijkl" -> "abfghijkl" when 'de' is deleted. No delete marker added - // |---| <- inserted |-| <- inserted - // * Deletes overlapping regular text and inserted text will insert a delete marker for the - // regular text: - // "abcdefghijkl" -> "abcdejkl" when 'fghi' is deleted - // |----| |--|| - // ^- inserted 'bcdefg' \ ^- deleted 'hi' - // \--inserted 'bcde' - // * Deletes overlapping other deletes are merged. E.g. - // "abcghijkl" -> "ahijkl" when 'bcg is deleted' - // | <- delete 'def' | <- delete 'bcdefg' - // * Deletes by another user will consume deletes by the first user - // * Inserts by another user will not combine with inserts by the first user. If they are in the - // middle of a previous insert by the first user, the original insert will be split into two. - constructor(changes, comments) { - if (changes == null) { - changes = [] - } - this.changes = changes - if (comments == null) { - comments = [] - } - this.comments = comments - this.setIdSeed(RangesTracker.generateIdSeed()) - this.resetDirtyState() - } - - getIdSeed() { - return this.id_seed - } - - setIdSeed(seed) { - this.id_seed = seed - return (this.id_increment = 0) - } - - static generateIdSeed() { - // Generate a the first 18 characters of Mongo ObjectId, leaving 6 for the increment part - // Reference: https://github.com/dreampulse/ObjectId.js/blob/master/src/main/javascript/Objectid.js - const pid = Math.floor(Math.random() * 32767).toString(16) - const machine = Math.floor(Math.random() * 16777216).toString(16) - const timestamp = Math.floor(new Date().valueOf() / 1000).toString(16) - return ( - '00000000'.substr(0, 8 - timestamp.length) + - timestamp + - '000000'.substr(0, 6 - machine.length) + - machine + - '0000'.substr(0, 4 - pid.length) + - pid - ) - } - - static generateId() { - return this.generateIdSeed() + '000001' - } - - newId() { - this.id_increment++ - const increment = this.id_increment.toString(16) - const id = - this.id_seed + '000000'.substr(0, 6 - increment.length) + increment - return id - } - - getComment(comment_id) { - let comment = null - for (const c of Array.from(this.comments)) { - if (c.id === comment_id) { - comment = c - break - } - } - return comment - } - - removeCommentId(comment_id) { - const comment = this.getComment(comment_id) - if (comment == null) { - return - } - this.comments = this.comments.filter(c => c.id !== comment_id) - return this._markAsDirty(comment, 'comment', 'removed') - } - - moveCommentId(comment_id, position, text) { - return (() => { - const result = [] - for (const comment of Array.from(this.comments)) { - if (comment.id === comment_id) { - comment.op.p = position - comment.op.c = text - result.push(this._markAsDirty(comment, 'comment', 'moved')) - } else { - result.push(undefined) - } - } - return result - })() - } - - getChange(change_id) { - let change = null - for (const c of Array.from(this.changes)) { - if (c.id === change_id) { - change = c - break - } - } - return change - } - - getChanges(change_ids) { - const changes_response = [] - const ids_map = {} - - for (const change_id of Array.from(change_ids)) { - ids_map[change_id] = true - } - - for (const change of Array.from(this.changes)) { - if (ids_map[change.id]) { - delete ids_map[change.id] - changes_response.push(change) - } - } - - return changes_response - } - - removeChangeId(change_id) { - const change = this.getChange(change_id) - if (change == null) { - return - } - return this._removeChange(change) - } - - removeChangeIds(change_to_remove_ids) { - if ( - !(change_to_remove_ids != null - ? change_to_remove_ids.length - : undefined) > 0 - ) { - return - } - const i = this.changes.length - const remove_change_id = {} - for (const change_id of Array.from(change_to_remove_ids)) { - remove_change_id[change_id] = true - } - - const remaining_changes = [] - - for (const change of Array.from(this.changes)) { - if (remove_change_id[change.id]) { - delete remove_change_id[change.id] - this._markAsDirty(change, 'change', 'removed') - } else { - remaining_changes.push(change) - } - } - - return (this.changes = remaining_changes) - } - - validate(text) { - let content - for (const change of Array.from(this.changes)) { - if (change.op.i != null) { - content = text.slice(change.op.p, change.op.p + change.op.i.length) - if (content !== change.op.i) { - throw new Error( - `Change (${JSON.stringify( - change - )}) doesn't match text (${JSON.stringify(content)})` - ) - } - } - } - for (const comment of Array.from(this.comments)) { - content = text.slice(comment.op.p, comment.op.p + comment.op.c.length) - if (content !== comment.op.c) { - throw new Error( - `Comment (${JSON.stringify( - comment - )}) doesn't match text (${JSON.stringify(content)})` - ) - } - } - return true - } - - applyOp(op, metadata) { - if (metadata == null) { - metadata = {} - } - if (metadata.ts == null) { - metadata.ts = new Date() - } - // Apply an op that has been applied to the document to our changes to keep them up to date - if (op.i != null) { - this.applyInsertToChanges(op, metadata) - return this.applyInsertToComments(op) - } else if (op.d != null) { - this.applyDeleteToChanges(op, metadata) - return this.applyDeleteToComments(op) - } else if (op.c != null) { - return this.addComment(op, metadata) - } else { - throw new Error('unknown op type') - } - } - - applyOps(ops, metadata) { - if (metadata == null) { - metadata = {} - } - return Array.from(ops).map(op => this.applyOp(op, metadata)) - } - - addComment(op, metadata) { - const existing = this.getComment(op.t) - if (existing != null) { - this.moveCommentId(op.t, op.p, op.c) - return existing - } else { - let comment - this.comments.push( - (comment = { - id: op.t || this.newId(), - op: { - // Copy because we'll modify in place - c: op.c, - p: op.p, - t: op.t, - }, - metadata, - }) - ) - this._markAsDirty(comment, 'comment', 'added') - return comment - } - } - - applyInsertToComments(op) { - return (() => { - const result = [] - for (const comment of Array.from(this.comments)) { - if (op.p <= comment.op.p) { - comment.op.p += op.i.length - result.push(this._markAsDirty(comment, 'comment', 'moved')) - } else if (op.p < comment.op.p + comment.op.c.length) { - const offset = op.p - comment.op.p - comment.op.c = - comment.op.c.slice(0, +(offset - 1) + 1 || undefined) + - op.i + - comment.op.c.slice(offset) - result.push(this._markAsDirty(comment, 'comment', 'moved')) - } else { - result.push(undefined) - } - } - return result - })() - } - - applyDeleteToComments(op) { - const op_start = op.p - const op_length = op.d.length - const op_end = op.p + op_length - return (() => { - const result = [] - for (const comment of Array.from(this.comments)) { - const comment_start = comment.op.p - const comment_end = comment.op.p + comment.op.c.length - const comment_length = comment_end - comment_start - if (op_end <= comment_start) { - // delete is fully before comment - comment.op.p -= op_length - result.push(this._markAsDirty(comment, 'comment', 'moved')) - } else if (op_start >= comment_end) { - // delete is fully after comment, nothing to do - } else { - // delete and comment overlap - let remaining_after, remaining_before - if (op_start <= comment_start) { - remaining_before = '' - } else { - remaining_before = comment.op.c.slice(0, op_start - comment_start) - } - if (op_end >= comment_end) { - remaining_after = '' - } else { - remaining_after = comment.op.c.slice(op_end - comment_start) - } - - // Check deleted content matches delete op - const deleted_comment = comment.op.c.slice( - remaining_before.length, - comment_length - remaining_after.length - ) - const offset = Math.max(0, comment_start - op_start) - const deleted_op_content = op.d - .slice(offset) - .slice(0, deleted_comment.length) - if (deleted_comment !== deleted_op_content) { - throw new Error('deleted content does not match comment content') - } - - comment.op.p = Math.min(comment_start, op_start) - comment.op.c = remaining_before + remaining_after - result.push(this._markAsDirty(comment, 'comment', 'moved')) - } - } - return result - })() - } - - applyInsertToChanges(op, metadata) { - let change - const op_start = op.p - const op_length = op.i.length - const op_end = op.p + op_length - const undoing = !!op.u - - let already_merged = false - let previous_change = null - const moved_changes = [] - const remove_changes = [] - const new_changes = [] - - for (let i = 0; i < this.changes.length; i++) { - change = this.changes[i] - const change_start = change.op.p - - if (change.op.d != null) { - // Shift any deletes after this along by the length of this insert - if (op_start < change_start) { - change.op.p += op_length - moved_changes.push(change) - } else if (op_start === change_start) { - // If we are undoing, then we want to cancel any existing delete ranges if we can. - // Check if the insert matches the start of the delete, and just remove it from the delete instead if so. - if ( - undoing && - change.op.d.length >= op.i.length && - change.op.d.slice(0, op.i.length) === op.i - ) { - change.op.d = change.op.d.slice(op.i.length) - change.op.p += op.i.length - if (change.op.d === '') { - remove_changes.push(change) - } else { - moved_changes.push(change) - } - already_merged = true - } else { - change.op.p += op_length - moved_changes.push(change) - } - } - } else if (change.op.i != null) { - let offset - const change_end = change_start + change.op.i.length - const is_change_overlapping = - op_start >= change_start && op_start <= change_end - - // Only merge inserts if they are from the same user - const is_same_user = metadata.user_id === change.metadata.user_id - - // If we are undoing, then our changes will be removed from any delete ops just after. In that case, if there is also - // an insert op just before, then we shouldn't append it to this insert, but instead only cancel the following delete. - // E.g. - // foo|<--- about to insert 'b' here - // inserted 'foo' --^ ^-- deleted 'bar' - // should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), . - const next_change = this.changes[i + 1] - const is_op_adjacent_to_next_delete = - next_change != null && - next_change.op.d != null && - op.p === change_end && - next_change.op.p === op.p - const will_op_cancel_next_delete = - undoing && - is_op_adjacent_to_next_delete && - next_change.op.d.slice(0, op.i.length) === op.i - - // If there is a delete at the start of the insert, and we're inserting - // at the start, we SHOULDN'T merge since the delete acts as a partition. - // The previous op will be the delete, but it's already been shifted by this insert - // - // I.e. - // Originally: |-- existing insert --| - // | <- existing delete at same offset - // - // Now: |-- existing insert --| <- not shifted yet - // |-- this insert --|| <- existing delete shifted along to end of this op - // - // After: |-- existing insert --| - // |-- this insert --|| <- existing delete - // - // Without the delete, the inserts would be merged. - const is_insert_blocked_by_delete = - previous_change != null && - previous_change.op.d != null && - previous_change.op.p === op_end - - // If the insert is overlapping another insert, either at the beginning in the middle or touching the end, - // then we merge them into one. - if ( - this.track_changes && - is_change_overlapping && - !is_insert_blocked_by_delete && - !already_merged && - !will_op_cancel_next_delete && - is_same_user - ) { - offset = op_start - change_start - change.op.i = - change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset) - change.metadata.ts = metadata.ts - already_merged = true - moved_changes.push(change) - } else if (op_start <= change_start) { - // If we're fully before the other insert we can just shift the other insert by our length. - // If they are touching, and should have been merged, they will have been above. - // If not merged above, then it must be blocked by a delete, and will be after this insert, so we shift it along as well - change.op.p += op_length - moved_changes.push(change) - } else if ( - (!is_same_user || !this.track_changes) && - change_start < op_start && - op_start < change_end - ) { - // This user is inserting inside a change by another user, so we need to split the - // other user's change into one before and after this one. - offset = op_start - change_start - const before_content = change.op.i.slice(0, offset) - const after_content = change.op.i.slice(offset) - - // The existing change can become the 'before' change - change.op.i = before_content - moved_changes.push(change) - - // Create a new op afterwards - const after_change = { - op: { - i: after_content, - p: change_start + offset + op_length, - }, - metadata: {}, - } - for (const key in change.metadata) { - const value = change.metadata[key] - after_change.metadata[key] = value - } - new_changes.push(after_change) - } - } - - previous_change = change - } - - if (this.track_changes && !already_merged) { - this._addOp(op, metadata) - } - for ({ op, metadata } of Array.from(new_changes)) { - this._addOp(op, metadata) - } - - for (change of Array.from(remove_changes)) { - this._removeChange(change) - } - - return (() => { - const result = [] - for (change of Array.from(moved_changes)) { - result.push(this._markAsDirty(change, 'change', 'moved')) - } - return result - })() - } - - applyDeleteToChanges(op, metadata) { - let change - const op_start = op.p - const op_length = op.d.length - const op_end = op.p + op_length - const remove_changes = [] - let moved_changes = [] - - // We might end up modifying our delete op if it merges with existing deletes, or cancels out - // with an existing insert. Since we might do multiple modifications, we record them and do - // all the modifications after looping through the existing changes, so as not to mess up the - // offset indexes as we go. - const op_modifications = [] - for (change of Array.from(this.changes)) { - let change_start - if (change.op.i != null) { - change_start = change.op.p - const change_end = change_start + change.op.i.length - if (op_end <= change_start) { - // Shift ops after us back by our length - change.op.p -= op_length - moved_changes.push(change) - } else if (op_start >= change_end) { - // Delete is after insert, nothing to do - } else { - // When the new delete overlaps an insert, we should remove the part of the insert that - // is now deleted, and also remove the part of the new delete that overlapped. I.e. - // the two cancel out where they overlap. - let delete_remaining_after, - delete_remaining_before, - insert_remaining_after, - insert_remaining_before - if (op_start >= change_start) { - // |-- existing insert --| - // insert_remaining_before -> |.....||-- new delete --| - delete_remaining_before = '' - insert_remaining_before = change.op.i.slice( - 0, - op_start - change_start - ) - } else { - // delete_remaining_before -> |.....||-- existing insert --| - // |-- new delete --| - delete_remaining_before = op.d.slice(0, change_start - op_start) - insert_remaining_before = '' - } - - if (op_end <= change_end) { - // |-- existing insert --| - // |-- new delete --||.....| <- insert_remaining_after - delete_remaining_after = '' - insert_remaining_after = change.op.i.slice(op_end - change_start) - } else { - // |-- existing insert --||.....| <- delete_remaining_after - // |-- new delete --| - delete_remaining_after = op.d.slice(change_end - op_start) - insert_remaining_after = '' - } - - const insert_remaining = - insert_remaining_before + insert_remaining_after - if (insert_remaining.length > 0) { - change.op.i = insert_remaining - change.op.p = Math.min(change_start, op_start) - change.metadata.ts = metadata.ts - moved_changes.push(change) - } else { - remove_changes.push(change) - } - - // We know what we want to preserve of our delete op before (delete_remaining_before) and what we want to preserve - // afterwards (delete_remaining_before). Now we need to turn that into a modification which deletes the - // chunk in the middle not covered by these. - const delete_removed_length = - op.d.length - - delete_remaining_before.length - - delete_remaining_after.length - const delete_removed_start = delete_remaining_before.length - const modification = { - d: op.d.slice( - delete_removed_start, - delete_removed_start + delete_removed_length - ), - p: delete_removed_start, - } - if (modification.d.length > 0) { - op_modifications.push(modification) - } - } - } else if (change.op.d != null) { - change_start = change.op.p - if ( - op_end < change_start || - (!this.track_changes && op_end === change_start) - ) { - // Shift ops after us back by our length. - // If we're tracking changes, it must be strictly before, since we'll merge - // below if they are touching. Otherwise, touching is fine. - change.op.p -= op_length - moved_changes.push(change) - } else if (op_start <= change_start && change_start <= op_end) { - if (this.track_changes) { - // If we overlap a delete, add it in our content, and delete the existing change. - // It's easier to do it this way, rather than modifying the existing delete in case - // we overlap many deletes and we'd need to track that. We have a workaround to - // update the delete in place if possible below. - const offset = change_start - op_start - op_modifications.push({ i: change.op.d, p: offset }) - remove_changes.push(change) - } else { - change.op.p = op_start - moved_changes.push(change) - } - } - } - } - - // Copy rather than modify because we still need to apply it to comments - op = { - p: op.p, - d: this._applyOpModifications(op.d, op_modifications), - } - - for (change of Array.from(remove_changes)) { - // This is a bit of hack to avoid removing one delete and replacing it with another. - // If we don't do this, it causes the UI to flicker - if ( - op.d.length > 0 && - change.op.d != null && - op.p <= change.op.p && - change.op.p <= op.p + op.d.length - ) { - change.op.p = op.p - change.op.d = op.d - change.metadata = metadata - moved_changes.push(change) - op.d = '' // stop it being added - } else { - this._removeChange(change) - } - } - - if (this.track_changes && op.d.length > 0) { - this._addOp(op, metadata) - } else { - // It's possible that we deleted an insert between two other inserts. I.e. - // If we delete 'user_2 insert' in: - // |-- user_1 insert --||-- user_2 insert --||-- user_1 insert --| - // it becomes: - // |-- user_1 insert --||-- user_1 insert --| - // We need to merge these together again - const results = this._scanAndMergeAdjacentUpdates() - moved_changes = moved_changes.concat(results.moved_changes) - for (change of Array.from(results.remove_changes)) { - this._removeChange(change) - moved_changes = moved_changes.filter(c => c !== change) - } - } - - return (() => { - const result = [] - for (change of Array.from(moved_changes)) { - result.push(this._markAsDirty(change, 'change', 'moved')) - } - return result - })() - } - - _addOp(op, metadata) { - const change = { - id: this.newId(), - op: this._clone(op), // Don't take a reference to the existing op since we'll modify this in place with future changes - metadata: this._clone(metadata), - } - this.changes.push(change) - - // Keep ops in order of offset, with deletes before inserts - this.changes.sort(function (c1, c2) { - const result = c1.op.p - c2.op.p - if (result !== 0) { - return result - } else if (c1.op.i != null && c2.op.d != null) { - return 1 - } else if (c1.op.d != null && c2.op.i != null) { - return -1 - } else { - return 0 - } - }) - - return this._markAsDirty(change, 'change', 'added') - } - - _removeChange(change) { - this.changes = this.changes.filter(c => c.id !== change.id) - return this._markAsDirty(change, 'change', 'removed') - } - - _applyOpModifications(content, op_modifications) { - // Put in descending position order, with deleting first if at the same offset - // (Inserting first would modify the content that the delete will delete) - op_modifications.sort(function (a, b) { - const result = b.p - a.p - if (result !== 0) { - return result - } else if (a.i != null && b.d != null) { - return 1 - } else if (a.d != null && b.i != null) { - return -1 - } else { - return 0 - } - }) - - for (const modification of Array.from(op_modifications)) { - if (modification.i != null) { - content = - content.slice(0, modification.p) + - modification.i + - content.slice(modification.p) - } else if (modification.d != null) { - if ( - content.slice( - modification.p, - modification.p + modification.d.length - ) !== modification.d - ) { - throw new Error( - `deleted content does not match. content: ${JSON.stringify( - content - )}; modification: ${JSON.stringify(modification)}` - ) - } - content = - content.slice(0, modification.p) + - content.slice(modification.p + modification.d.length) - } - } - return content - } - - _scanAndMergeAdjacentUpdates() { - // This should only need calling when deleting an update between two - // other updates. There's no other way to get two adjacent updates from the - // same user, since they would be merged on insert. - let previous_change = null - const remove_changes = [] - const moved_changes = [] - for (const change of Array.from(this.changes)) { - if ( - (previous_change != null ? previous_change.op.i : undefined) != - null && - change.op.i != null - ) { - const previous_change_end = - previous_change.op.p + previous_change.op.i.length - const previous_change_user_id = previous_change.metadata.user_id - const change_start = change.op.p - const change_user_id = change.metadata.user_id - if ( - previous_change_end === change_start && - previous_change_user_id === change_user_id - ) { - remove_changes.push(change) - previous_change.op.i += change.op.i - moved_changes.push(previous_change) - } - } else if ( - (previous_change != null ? previous_change.op.d : undefined) != - null && - change.op.d != null && - previous_change.op.p === change.op.p - ) { - // Merge adjacent deletes - previous_change.op.d += change.op.d - remove_changes.push(change) - moved_changes.push(previous_change) - } else { - // Only update to the current change if we haven't removed it. - previous_change = change - } - } - return { moved_changes, remove_changes } - } - - resetDirtyState() { - return (this._dirtyState = { - comment: { - moved: {}, - removed: {}, - added: {}, - }, - change: { - moved: {}, - removed: {}, - added: {}, - }, - }) - } - - getDirtyState() { - return this._dirtyState - } - - _markAsDirty(object, type, action) { - return (this._dirtyState[type][action][object.id] = object) - } - - _clone(object) { - const clone = {} - for (const k in object) { - const v = object[k] - clone[k] = v - } - return clone - } - }) -} - -if (typeof define !== 'undefined' && define !== null) { - define([], load) -} else { - module.exports = load() -} diff --git a/services/document-updater/package.json b/services/document-updater/package.json index b670046d59..c7966f33f8 100644 --- a/services/document-updater/package.json +++ b/services/document-updater/package.json @@ -19,6 +19,7 @@ "@overleaf/logger": "^3.1.0", "@overleaf/metrics": "^4.0.0", "@overleaf/o-error": "^3.4.0", + "@overleaf/ranges-tracker": "*", "@overleaf/redis-wrapper": "^2.0.1", "@overleaf/settings": "^3.0.0", "async": "^3.2.2", diff --git a/services/document-updater/test/unit/js/RangesManager/RangesManagerTests.js b/services/document-updater/test/unit/js/RangesManager/RangesManagerTests.js index 3189728036..9b3dbdfffb 100644 --- a/services/document-updater/test/unit/js/RangesManager/RangesManagerTests.js +++ b/services/document-updater/test/unit/js/RangesManager/RangesManagerTests.js @@ -423,9 +423,8 @@ describe('RangesManager', function () { beforeEach(function () { this.RangesManager = SandboxedModule.require(modulePath, { requires: { - './RangesTracker': (this.RangesTracker = SandboxedModule.require( - '../../../../app/js/RangesTracker.js' - )), + '@overleaf/ranges-tracker': (this.RangesTracker = + SandboxedModule.require('@overleaf/ranges-tracker')), }, }) diff --git a/services/document-updater/test/unit/js/ShareJS/TextTransformTests.js b/services/document-updater/test/unit/js/ShareJS/TextTransformTests.js index 118cf339c2..f4e41573da 100644 --- a/services/document-updater/test/unit/js/ShareJS/TextTransformTests.js +++ b/services/document-updater/test/unit/js/ShareJS/TextTransformTests.js @@ -14,7 +14,7 @@ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ const text = require('../../../../app/js/sharejs/types/text') -const RangesTracker = require('../../../../app/js/RangesTracker') +const RangesTracker = require('@overleaf/ranges-tracker') describe('ShareJS text type', function () { beforeEach(function () { diff --git a/services/web/frontend/js/ide/editor/Document.js b/services/web/frontend/js/ide/editor/Document.js index f583b991a4..01769c5081 100644 --- a/services/web/frontend/js/ide/editor/Document.js +++ b/services/web/frontend/js/ide/editor/Document.js @@ -16,9 +16,9 @@ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ +import RangesTracker from '@overleaf/ranges-tracker' import EventEmitter from '../../utils/EventEmitter' import ShareJsDoc from './ShareJsDoc' -import RangesTracker from '../review-panel/RangesTracker' let Document export default Document = (function () { @@ -760,7 +760,7 @@ export default Document = (function () { ;({ track_changes_as } = this) } this.ranges.track_changes = track_changes_as != null - for (const op of Array.from(ops)) { + for (const op of this._filterOps(ops)) { this.ranges.applyOp(op, { user_id: track_changes_as }) } if (old_id_seed != null) { @@ -788,16 +788,26 @@ export default Document = (function () { this.ranges.changes = changes this.ranges.comments = comments this.ranges.track_changes = this.doc.track_changes - for (const op of Array.from(this.doc.getInflightOp() || [])) { + for (const op of this._filterOps(this.doc.getInflightOp() || [])) { this.ranges.setIdSeed(this.doc.track_changes_id_seeds.inflight) this.ranges.applyOp(op, { user_id: this.track_changes_as }) } - for (const op of Array.from(this.doc.getPendingOp() || [])) { + for (const op of this._filterOps(this.doc.getPendingOp() || [])) { this.ranges.setIdSeed(this.doc.track_changes_id_seeds.pending) this.ranges.applyOp(op, { user_id: this.track_changes_as }) } return this.emit('ranges:redraw') } + + _filterOps(ops) { + // Read-only token users can't see/edit comment, so we filter out comment + // ops to avoid highlighting comment ranges. + if (window.isRestrictedTokenMember) { + return ops.filter(op => op.c == null) + } else { + return ops + } + } } Document.initClass() return Document diff --git a/services/web/frontend/js/ide/review-panel/RangesTracker.js b/services/web/frontend/js/ide/review-panel/RangesTracker.js deleted file mode 100644 index 9bc6e8853f..0000000000 --- a/services/web/frontend/js/ide/review-panel/RangesTracker.js +++ /dev/null @@ -1,841 +0,0 @@ -/* eslint-disable - camelcase, - max-len, - no-return-assign, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS101: Remove unnecessary use of Array.from - * DS102: Remove unnecessary code created because of implicit returns - * DS205: Consider reworking code to avoid use of IIFEs - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -// This file is shared between document-updater and web, so that the server and client share -// an identical track changes implementation. Do not edit it directly in web or document-updater, -// instead edit it at https://github.com/sharelatex/ranges-tracker, where it has a suite of tests -let RangesTracker - -export default RangesTracker = class RangesTracker { - // The purpose of this class is to track a set of inserts and deletes to a document, like - // track changes in Word. We store these as a set of ShareJs style ranges: - // {i: "foo", p: 42} # Insert 'foo' at offset 42 - // {d: "bar", p: 37} # Delete 'bar' at offset 37 - // We only track the inserts and deletes, not the whole document, but by being given all - // updates that are applied to a document, we can update these appropriately. - // - // Note that the set of inserts and deletes we store applies to the document as-is at the moment. - // So inserts correspond to text which is in the document, while deletes correspond to text which - // is no longer there, so their lengths do not affect the position of later offsets. - // E.g. - // this is the current text of the document - // |-----| | - // {i: "current ", p:12} -^ ^- {d: "old ", p: 31} - // - // Track changes rules (should be consistent with Word): - // * When text is inserted at a delete, the text goes to the left of the delete - // I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted - // * Deleting content flagged as 'inserted' does not create a new delete marker, it only - // removes the insert marker. E.g. - // * "abdefghijkl" -> "abfghijkl" when 'de' is deleted. No delete marker added - // |---| <- inserted |-| <- inserted - // * Deletes overlapping regular text and inserted text will insert a delete marker for the - // regular text: - // "abcdefghijkl" -> "abcdejkl" when 'fghi' is deleted - // |----| |--|| - // ^- inserted 'bcdefg' \ ^- deleted 'hi' - // \--inserted 'bcde' - // * Deletes overlapping other deletes are merged. E.g. - // "abcghijkl" -> "ahijkl" when 'bcg is deleted' - // | <- delete 'def' | <- delete 'bcdefg' - // * Deletes by another user will consume deletes by the first user - // * Inserts by another user will not combine with inserts by the first user. If they are in the - // middle of a previous insert by the first user, the original insert will be split into two. - constructor(changes, comments) { - if (changes == null) { - changes = [] - } - this.changes = changes - if (comments == null) { - comments = [] - } - this.comments = comments - this.setIdSeed(RangesTracker.generateIdSeed()) - this.resetDirtyState() - } - - getIdSeed() { - return this.id_seed - } - - setIdSeed(seed) { - this.id_seed = seed - return (this.id_increment = 0) - } - - static generateIdSeed() { - // Generate a the first 18 characters of Mongo ObjectId, leaving 6 for the increment part - // Reference: https://github.com/dreampulse/ObjectId.js/blob/master/src/main/javascript/Objectid.js - const pid = Math.floor(Math.random() * 32767).toString(16) - const machine = Math.floor(Math.random() * 16777216).toString(16) - const timestamp = Math.floor(new Date().valueOf() / 1000).toString(16) - return ( - '00000000'.substr(0, 8 - timestamp.length) + - timestamp + - '000000'.substr(0, 6 - machine.length) + - machine + - '0000'.substr(0, 4 - pid.length) + - pid - ) - } - - static generateId() { - return this.generateIdSeed() + '000001' - } - - newId() { - this.id_increment++ - const increment = this.id_increment.toString(16) - const id = - this.id_seed + '000000'.substr(0, 6 - increment.length) + increment - return id - } - - getComment(comment_id) { - let comment = null - for (const c of Array.from(this.comments)) { - if (c.id === comment_id) { - comment = c - break - } - } - return comment - } - - removeCommentId(comment_id) { - const comment = this.getComment(comment_id) - if (comment == null) { - return - } - this.comments = this.comments.filter(c => c.id !== comment_id) - return this._markAsDirty(comment, 'comment', 'removed') - } - - moveCommentId(comment_id, position, text) { - return (() => { - const result = [] - for (const comment of Array.from(this.comments)) { - if (comment.id === comment_id) { - comment.op.p = position - comment.op.c = text - result.push(this._markAsDirty(comment, 'comment', 'moved')) - } else { - result.push(undefined) - } - } - return result - })() - } - - getChange(change_id) { - let change = null - for (const c of Array.from(this.changes)) { - if (c.id === change_id) { - change = c - break - } - } - return change - } - - getChanges(change_ids) { - const changes_response = [] - const ids_map = {} - - for (const change_id of Array.from(change_ids)) { - ids_map[change_id] = true - } - - for (const change of Array.from(this.changes)) { - if (ids_map[change.id]) { - delete ids_map[change.id] - changes_response.push(change) - } - } - - return changes_response - } - - removeChangeId(change_id) { - const change = this.getChange(change_id) - if (change == null) { - return - } - return this._removeChange(change) - } - - removeChangeIds(change_to_remove_ids) { - if ( - !(change_to_remove_ids != null - ? change_to_remove_ids.length - : undefined) > 0 - ) { - return - } - const i = this.changes.length - const remove_change_id = {} - for (const change_id of Array.from(change_to_remove_ids)) { - remove_change_id[change_id] = true - } - - const remaining_changes = [] - - for (const change of Array.from(this.changes)) { - if (remove_change_id[change.id]) { - delete remove_change_id[change.id] - this._markAsDirty(change, 'change', 'removed') - } else { - remaining_changes.push(change) - } - } - - return (this.changes = remaining_changes) - } - - validate(text) { - let content - for (const change of Array.from(this.changes)) { - if (change.op.i != null) { - content = text.slice(change.op.p, change.op.p + change.op.i.length) - if (content !== change.op.i) { - throw new Error( - `Change (${JSON.stringify( - change - )}) doesn't match text (${JSON.stringify(content)})` - ) - } - } - } - for (const comment of Array.from(this.comments)) { - content = text.slice(comment.op.p, comment.op.p + comment.op.c.length) - if (content !== comment.op.c) { - throw new Error( - `Comment (${JSON.stringify( - comment - )}) doesn't match text (${JSON.stringify(content)})` - ) - } - } - return true - } - - applyOp(op, metadata) { - if (metadata == null) { - metadata = {} - } - if (metadata.ts == null) { - metadata.ts = new Date() - } - // Apply an op that has been applied to the document to our changes to keep them up to date - if (op.i != null) { - this.applyInsertToChanges(op, metadata) - return this.applyInsertToComments(op) - } else if (op.d != null) { - this.applyDeleteToChanges(op, metadata) - return this.applyDeleteToComments(op) - } else if (op.c != null) { - if (!window.isRestrictedTokenMember) { - return this.addComment(op, metadata) - } - } else { - throw new Error('unknown op type') - } - } - - applyOps(ops, metadata) { - if (metadata == null) { - metadata = {} - } - return Array.from(ops).map(op => this.applyOp(op, metadata)) - } - - addComment(op, metadata) { - const existing = this.getComment(op.t) - if (existing != null) { - this.moveCommentId(op.t, op.p, op.c) - return existing - } else { - let comment - this.comments.push( - (comment = { - id: op.t || this.newId(), - op: { - // Copy because we'll modify in place - c: op.c, - p: op.p, - t: op.t, - }, - metadata, - }) - ) - this._markAsDirty(comment, 'comment', 'added') - return comment - } - } - - applyInsertToComments(op) { - return (() => { - const result = [] - for (const comment of Array.from(this.comments)) { - if (op.p <= comment.op.p) { - comment.op.p += op.i.length - result.push(this._markAsDirty(comment, 'comment', 'moved')) - } else if (op.p < comment.op.p + comment.op.c.length) { - const offset = op.p - comment.op.p - comment.op.c = - comment.op.c.slice(0, +(offset - 1) + 1 || undefined) + - op.i + - comment.op.c.slice(offset) - result.push(this._markAsDirty(comment, 'comment', 'moved')) - } else { - result.push(undefined) - } - } - return result - })() - } - - applyDeleteToComments(op) { - const op_start = op.p - const op_length = op.d.length - const op_end = op.p + op_length - return (() => { - const result = [] - for (const comment of Array.from(this.comments)) { - const comment_start = comment.op.p - const comment_end = comment.op.p + comment.op.c.length - const comment_length = comment_end - comment_start - if (op_end <= comment_start) { - // delete is fully before comment - comment.op.p -= op_length - result.push(this._markAsDirty(comment, 'comment', 'moved')) - } else if (op_start >= comment_end) { - // delete is fully after comment, nothing to do - } else { - // delete and comment overlap - let remaining_after, remaining_before - if (op_start <= comment_start) { - remaining_before = '' - } else { - remaining_before = comment.op.c.slice(0, op_start - comment_start) - } - if (op_end >= comment_end) { - remaining_after = '' - } else { - remaining_after = comment.op.c.slice(op_end - comment_start) - } - - // Check deleted content matches delete op - const deleted_comment = comment.op.c.slice( - remaining_before.length, - comment_length - remaining_after.length - ) - const offset = Math.max(0, comment_start - op_start) - const deleted_op_content = op.d - .slice(offset) - .slice(0, deleted_comment.length) - if (deleted_comment !== deleted_op_content) { - throw new Error('deleted content does not match comment content') - } - - comment.op.p = Math.min(comment_start, op_start) - comment.op.c = remaining_before + remaining_after - result.push(this._markAsDirty(comment, 'comment', 'moved')) - } - } - return result - })() - } - - applyInsertToChanges(op, metadata) { - let change - const op_start = op.p - const op_length = op.i.length - const op_end = op.p + op_length - const undoing = !!op.u - - let already_merged = false - let previous_change = null - const moved_changes = [] - const remove_changes = [] - const new_changes = [] - - for (let i = 0; i < this.changes.length; i++) { - change = this.changes[i] - const change_start = change.op.p - - if (change.op.d != null) { - // Shift any deletes after this along by the length of this insert - if (op_start < change_start) { - change.op.p += op_length - moved_changes.push(change) - } else if (op_start === change_start) { - // If we are undoing, then we want to cancel any existing delete ranges if we can. - // Check if the insert matches the start of the delete, and just remove it from the delete instead if so. - if ( - undoing && - change.op.d.length >= op.i.length && - change.op.d.slice(0, op.i.length) === op.i - ) { - change.op.d = change.op.d.slice(op.i.length) - change.op.p += op.i.length - if (change.op.d === '') { - remove_changes.push(change) - } else { - moved_changes.push(change) - } - already_merged = true - } else { - change.op.p += op_length - moved_changes.push(change) - } - } - } else if (change.op.i != null) { - let offset - const change_end = change_start + change.op.i.length - const is_change_overlapping = - op_start >= change_start && op_start <= change_end - - // Only merge inserts if they are from the same user - const is_same_user = metadata.user_id === change.metadata.user_id - - // If we are undoing, then our changes will be removed from any delete ops just after. In that case, if there is also - // an insert op just before, then we shouldn't append it to this insert, but instead only cancel the following delete. - // E.g. - // foo|<--- about to insert 'b' here - // inserted 'foo' --^ ^-- deleted 'bar' - // should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), . - const next_change = this.changes[i + 1] - const is_op_adjacent_to_next_delete = - next_change != null && - next_change.op.d != null && - op.p === change_end && - next_change.op.p === op.p - const will_op_cancel_next_delete = - undoing && - is_op_adjacent_to_next_delete && - next_change.op.d.slice(0, op.i.length) === op.i - - // If there is a delete at the start of the insert, and we're inserting - // at the start, we SHOULDN'T merge since the delete acts as a partition. - // The previous op will be the delete, but it's already been shifted by this insert - // - // I.e. - // Originally: |-- existing insert --| - // | <- existing delete at same offset - // - // Now: |-- existing insert --| <- not shifted yet - // |-- this insert --|| <- existing delete shifted along to end of this op - // - // After: |-- existing insert --| - // |-- this insert --|| <- existing delete - // - // Without the delete, the inserts would be merged. - const is_insert_blocked_by_delete = - previous_change != null && - previous_change.op.d != null && - previous_change.op.p === op_end - - // If the insert is overlapping another insert, either at the beginning in the middle or touching the end, - // then we merge them into one. - if ( - this.track_changes && - is_change_overlapping && - !is_insert_blocked_by_delete && - !already_merged && - !will_op_cancel_next_delete && - is_same_user - ) { - offset = op_start - change_start - change.op.i = - change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset) - change.metadata.ts = metadata.ts - already_merged = true - moved_changes.push(change) - } else if (op_start <= change_start) { - // If we're fully before the other insert we can just shift the other insert by our length. - // If they are touching, and should have been merged, they will have been above. - // If not merged above, then it must be blocked by a delete, and will be after this insert, so we shift it along as well - change.op.p += op_length - moved_changes.push(change) - } else if ( - (!is_same_user || !this.track_changes) && - change_start < op_start && - op_start < change_end - ) { - // This user is inserting inside a change by another user, so we need to split the - // other user's change into one before and after this one. - offset = op_start - change_start - const before_content = change.op.i.slice(0, offset) - const after_content = change.op.i.slice(offset) - - // The existing change can become the 'before' change - change.op.i = before_content - moved_changes.push(change) - - // Create a new op afterwards - const after_change = { - op: { - i: after_content, - p: change_start + offset + op_length, - }, - metadata: {}, - } - for (const key in change.metadata) { - const value = change.metadata[key] - after_change.metadata[key] = value - } - new_changes.push(after_change) - } - } - - previous_change = change - } - - if (this.track_changes && !already_merged) { - this._addOp(op, metadata) - } - for ({ op, metadata } of Array.from(new_changes)) { - this._addOp(op, metadata) - } - - for (change of Array.from(remove_changes)) { - this._removeChange(change) - } - - return (() => { - const result = [] - for (change of Array.from(moved_changes)) { - result.push(this._markAsDirty(change, 'change', 'moved')) - } - return result - })() - } - - applyDeleteToChanges(op, metadata) { - const op_start = op.p - const op_length = op.d.length - const op_end = op.p + op_length - const remove_changes = [] - let moved_changes = [] - - // We might end up modifying our delete op if it merges with existing deletes, or cancels out - // with an existing insert. Since we might do multiple modifications, we record them and do - // all the modifications after looping through the existing changes, so as not to mess up the - // offset indexes as we go. - const op_modifications = [] - for (const change of Array.from(this.changes)) { - let change_start - if (change.op.i != null) { - change_start = change.op.p - const change_end = change_start + change.op.i.length - if (op_end <= change_start) { - // Shift ops after us back by our length - change.op.p -= op_length - moved_changes.push(change) - } else if (op_start >= change_end) { - // Delete is after insert, nothing to do - } else { - // When the new delete overlaps an insert, we should remove the part of the insert that - // is now deleted, and also remove the part of the new delete that overlapped. I.e. - // the two cancel out where they overlap. - let delete_remaining_after, - delete_remaining_before, - insert_remaining_after, - insert_remaining_before - if (op_start >= change_start) { - // |-- existing insert --| - // insert_remaining_before -> |.....||-- new delete --| - delete_remaining_before = '' - insert_remaining_before = change.op.i.slice( - 0, - op_start - change_start - ) - } else { - // delete_remaining_before -> |.....||-- existing insert --| - // |-- new delete --| - delete_remaining_before = op.d.slice(0, change_start - op_start) - insert_remaining_before = '' - } - - if (op_end <= change_end) { - // |-- existing insert --| - // |-- new delete --||.....| <- insert_remaining_after - delete_remaining_after = '' - insert_remaining_after = change.op.i.slice(op_end - change_start) - } else { - // |-- existing insert --||.....| <- delete_remaining_after - // |-- new delete --| - delete_remaining_after = op.d.slice(change_end - op_start) - insert_remaining_after = '' - } - - const insert_remaining = - insert_remaining_before + insert_remaining_after - if (insert_remaining.length > 0) { - change.op.i = insert_remaining - change.op.p = Math.min(change_start, op_start) - change.metadata.ts = metadata.ts - moved_changes.push(change) - } else { - remove_changes.push(change) - } - - // We know what we want to preserve of our delete op before (delete_remaining_before) and what we want to preserve - // afterwards (delete_remaining_before). Now we need to turn that into a modification which deletes the - // chunk in the middle not covered by these. - const delete_removed_length = - op.d.length - - delete_remaining_before.length - - delete_remaining_after.length - const delete_removed_start = delete_remaining_before.length - const modification = { - d: op.d.slice( - delete_removed_start, - delete_removed_start + delete_removed_length - ), - p: delete_removed_start, - } - if (modification.d.length > 0) { - op_modifications.push(modification) - } - } - } else if (change.op.d != null) { - change_start = change.op.p - if ( - op_end < change_start || - (!this.track_changes && op_end === change_start) - ) { - // Shift ops after us back by our length. - // If we're tracking changes, it must be strictly before, since we'll merge - // below if they are touching. Otherwise, touching is fine. - change.op.p -= op_length - moved_changes.push(change) - } else if (op_start <= change_start && change_start <= op_end) { - if (this.track_changes) { - // If we overlap a delete, add it in our content, and delete the existing change. - // It's easier to do it this way, rather than modifying the existing delete in case - // we overlap many deletes and we'd need to track that. We have a workaround to - // update the delete in place if possible below. - const offset = change_start - op_start - op_modifications.push({ i: change.op.d, p: offset }) - remove_changes.push(change) - } else { - change.op.p = op_start - moved_changes.push(change) - } - } - } - } - - // Copy rather than modify because we still need to apply it to comments - op = { - p: op.p, - d: this._applyOpModifications(op.d, op_modifications), - } - - for (const change of Array.from(remove_changes)) { - // This is a bit of hack to avoid removing one delete and replacing it with another. - // If we don't do this, it causes the UI to flicker - if ( - op.d.length > 0 && - change.op.d != null && - op.p <= change.op.p && - change.op.p <= op.p + op.d.length - ) { - change.op.p = op.p - change.op.d = op.d - change.metadata = metadata - moved_changes.push(change) - op.d = '' // stop it being added - } else { - this._removeChange(change) - } - } - - if (this.track_changes && op.d.length > 0) { - this._addOp(op, metadata) - } else { - // It's possible that we deleted an insert between two other inserts. I.e. - // If we delete 'user_2 insert' in: - // |-- user_1 insert --||-- user_2 insert --||-- user_1 insert --| - // it becomes: - // |-- user_1 insert --||-- user_1 insert --| - // We need to merge these together again - const results = this._scanAndMergeAdjacentUpdates() - moved_changes = moved_changes.concat(results.moved_changes) - for (const change of Array.from(results.remove_changes)) { - this._removeChange(change) - moved_changes = moved_changes.filter(c => c !== change) - } - } - - return (() => { - const result = [] - for (const change of Array.from(moved_changes)) { - result.push(this._markAsDirty(change, 'change', 'moved')) - } - return result - })() - } - - _addOp(op, metadata) { - const change = { - id: this.newId(), - op: this._clone(op), // Don't take a reference to the existing op since we'll modify this in place with future changes - metadata: this._clone(metadata), - } - this.changes.push(change) - - // Keep ops in order of offset, with deletes before inserts - this.changes.sort(function (c1, c2) { - const result = c1.op.p - c2.op.p - if (result !== 0) { - return result - } else if (c1.op.i != null && c2.op.d != null) { - return 1 - } else if (c1.op.d != null && c2.op.i != null) { - return -1 - } else { - return 0 - } - }) - - return this._markAsDirty(change, 'change', 'added') - } - - _removeChange(change) { - this.changes = this.changes.filter(c => c.id !== change.id) - return this._markAsDirty(change, 'change', 'removed') - } - - _applyOpModifications(content, op_modifications) { - // Put in descending position order, with deleting first if at the same offset - // (Inserting first would modify the content that the delete will delete) - op_modifications.sort(function (a, b) { - const result = b.p - a.p - if (result !== 0) { - return result - } else if (a.i != null && b.d != null) { - return 1 - } else if (a.d != null && b.i != null) { - return -1 - } else { - return 0 - } - }) - - for (const modification of Array.from(op_modifications)) { - if (modification.i != null) { - content = - content.slice(0, modification.p) + - modification.i + - content.slice(modification.p) - } else if (modification.d != null) { - if ( - content.slice( - modification.p, - modification.p + modification.d.length - ) !== modification.d - ) { - throw new Error( - `deleted content does not match. content: ${JSON.stringify( - content - )}; modification: ${JSON.stringify(modification)}` - ) - } - content = - content.slice(0, modification.p) + - content.slice(modification.p + modification.d.length) - } - } - return content - } - - _scanAndMergeAdjacentUpdates() { - // This should only need calling when deleting an update between two - // other updates. There's no other way to get two adjacent updates from the - // same user, since they would be merged on insert. - let previous_change = null - const remove_changes = [] - const moved_changes = [] - for (const change of Array.from(this.changes)) { - if ( - (previous_change != null ? previous_change.op.i : undefined) != null && - change.op.i != null - ) { - const previous_change_end = - previous_change.op.p + previous_change.op.i.length - const previous_change_user_id = previous_change.metadata.user_id - const change_start = change.op.p - const change_user_id = change.metadata.user_id - if ( - previous_change_end === change_start && - previous_change_user_id === change_user_id - ) { - remove_changes.push(change) - previous_change.op.i += change.op.i - moved_changes.push(previous_change) - } - } else if ( - (previous_change != null ? previous_change.op.d : undefined) != null && - change.op.d != null && - previous_change.op.p === change.op.p - ) { - // Merge adjacent deletes - previous_change.op.d += change.op.d - remove_changes.push(change) - moved_changes.push(previous_change) - } else { - // Only update to the current change if we haven't removed it. - previous_change = change - } - } - return { moved_changes, remove_changes } - } - - resetDirtyState() { - return (this._dirtyState = { - comment: { - moved: {}, - removed: {}, - added: {}, - }, - change: { - moved: {}, - removed: {}, - added: {}, - }, - }) - } - - getDirtyState() { - return this._dirtyState - } - - _markAsDirty(object, type, action) { - return (this._dirtyState[type][action][object.id] = object) - } - - _clone(object) { - const clone = {} - for (const k in object) { - const v = object[k] - clone[k] = v - } - return clone - } -} diff --git a/services/web/frontend/js/ide/review-panel/controllers/ReviewPanelController.js b/services/web/frontend/js/ide/review-panel/controllers/ReviewPanelController.js index beac41cc47..1131ba682c 100644 --- a/services/web/frontend/js/ide/review-panel/controllers/ReviewPanelController.js +++ b/services/web/frontend/js/ide/review-panel/controllers/ReviewPanelController.js @@ -14,10 +14,10 @@ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ +import RangesTracker from '@overleaf/ranges-tracker' import App from '../../../base' import EventEmitter from '../../../utils/EventEmitter' import ColorManager from '../../colors/ColorManager' -import RangesTracker from '../RangesTracker' export default App.controller( 'ReviewPanelController', diff --git a/services/web/package.json b/services/web/package.json index 0ac931bd21..1ecd097e61 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -82,6 +82,7 @@ "@overleaf/metrics": "^4.0.0", "@overleaf/o-error": "^3.4.0", "@overleaf/object-persistor": "^1.0.1", + "@overleaf/ranges-tracker": "*", "@overleaf/redis-wrapper": "^2.0.0", "@overleaf/settings": "^3.0.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",