text_operation.js 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905
  1. // @ts-check
  2. /**
  3. * The text operation from OT.js with some minor cosmetic changes.
  4. *
  5. * Specifically, this is based on
  6. * https://github.com/Operational-Transformation/ot.js/
  7. * blob/298825f58fb51fefb352e7df5ddbc668f4d5646f/lib/text-operation.js
  8. * from 18 Mar 2013.
  9. */
  10. 'use strict'
  11. const EditOperation = require('./edit_operation')
  12. const {
  13. RetainOp,
  14. InsertOp,
  15. RemoveOp,
  16. isRetain,
  17. isInsert,
  18. isRemove,
  19. } = require('./scan_op')
  20. const {
  21. UnprocessableError,
  22. ApplyError,
  23. InvalidInsertionError,
  24. TooLongError,
  25. } = require('../errors')
  26. const Range = require('../range')
  27. const ClearTrackingProps = require('../file_data/clear_tracking_props')
  28. const TrackingProps = require('../file_data/tracking_props')
  29. /**
  30. * @import StringFileData from '../file_data/string_file_data'
  31. * @import { RawTextOperation, TrackingDirective } from '../types'
  32. * @import { ScanOp } from '../operation/scan_op'
  33. * @import TrackedChangeList from '../file_data/tracked_change_list'
  34. *
  35. * @typedef {{tracking?: TrackingProps, commentIds?: string[]}} InsertOptions
  36. */
  37. /**
  38. * Create an empty text operation.
  39. * @extends EditOperation
  40. */
  41. class TextOperation extends EditOperation {
  42. /**
  43. * Length of the longest file that we'll attempt to edit, in characters.
  44. *
  45. * @type {number}
  46. */
  47. static MAX_STRING_LENGTH = 2 * Math.pow(1024, 2)
  48. static UnprocessableError = UnprocessableError
  49. static ApplyError = ApplyError
  50. static InvalidInsertionError = InvalidInsertionError
  51. static TooLongError = TooLongError
  52. constructor() {
  53. super()
  54. /**
  55. * When an operation is applied to an input string, you can think of this as
  56. * if an imaginary cursor runs over the entire string and skips over some
  57. * parts, removes some parts and inserts characters at some positions. These
  58. * actions (skip/remove/insert) are stored as an array in the "ops" property.
  59. * @type {ScanOp[]}
  60. */
  61. this.ops = []
  62. /**
  63. * An operation's baseLength is the length of every string the operation
  64. * can be applied to.
  65. */
  66. this.baseLength = 0
  67. /**
  68. * The targetLength is the length of every string that results from applying
  69. * the operation on a valid input string.
  70. */
  71. this.targetLength = 0
  72. /**
  73. * The expected content hash after this operation is applied
  74. *
  75. * @type {string | null}
  76. */
  77. this.contentHash = null
  78. }
  79. /**
  80. * @param {TextOperation} other
  81. * @return {boolean}
  82. */
  83. equals(other) {
  84. if (this.baseLength !== other.baseLength) {
  85. return false
  86. }
  87. if (this.targetLength !== other.targetLength) {
  88. return false
  89. }
  90. if (this.ops.length !== other.ops.length) {
  91. return false
  92. }
  93. for (let i = 0; i < this.ops.length; i++) {
  94. if (!this.ops[i].equals(other.ops[i])) {
  95. return false
  96. }
  97. }
  98. return true
  99. }
  100. // After an operation is constructed, the user of the library can specify the
  101. // actions of an operation (skip/insert/remove) with these three builder
  102. // methods. They all return the operation for convenient chaining.
  103. /**
  104. * Skip over a given number of characters.
  105. * @param {number | {r: number}} n
  106. * @param {{tracking?: TrackingDirective}} opts
  107. * @returns {TextOperation}
  108. */
  109. retain(n, opts = {}) {
  110. if (n === 0) {
  111. return this
  112. }
  113. if (!isRetain(n)) {
  114. throw new Error('retain expects an integer or a retain object')
  115. }
  116. const newOp = RetainOp.fromJSON(n)
  117. newOp.tracking = opts.tracking
  118. if (newOp.length === 0) {
  119. return this
  120. }
  121. this.baseLength += newOp.length
  122. this.targetLength += newOp.length
  123. const lastOperation = this.ops[this.ops.length - 1]
  124. if (lastOperation?.canMergeWith(newOp)) {
  125. // The last op is a retain op => we can merge them into one op.
  126. lastOperation.mergeWith(newOp)
  127. } else {
  128. // Create a new op.
  129. this.ops.push(newOp)
  130. }
  131. return this
  132. }
  133. /**
  134. * Insert a string at the current position.
  135. * @param {string | {i: string}} insertValue
  136. * @param {InsertOptions} opts
  137. * @returns {TextOperation}
  138. */
  139. insert(insertValue, opts = {}) {
  140. if (!isInsert(insertValue)) {
  141. throw new Error('insert expects a string or an insert object')
  142. }
  143. const newOp = InsertOp.fromJSON(insertValue)
  144. newOp.tracking = opts.tracking
  145. newOp.commentIds = opts.commentIds
  146. if (newOp.insertion === '') {
  147. return this
  148. }
  149. this.targetLength += newOp.insertion.length
  150. const ops = this.ops
  151. const lastOp = this.ops[this.ops.length - 1]
  152. if (lastOp?.canMergeWith(newOp)) {
  153. // Merge insert op.
  154. lastOp.mergeWith(newOp)
  155. } else if (lastOp instanceof RemoveOp) {
  156. // It doesn't matter when an operation is applied whether the operation
  157. // is remove(3), insert("something") or insert("something"), remove(3).
  158. // Here we enforce that in this case, the insert op always comes first.
  159. // This makes all operations that have the same effect when applied to
  160. // a document of the right length equal in respect to the `equals` method.
  161. const secondToLastOp = ops[ops.length - 2]
  162. if (secondToLastOp?.canMergeWith(newOp)) {
  163. secondToLastOp.mergeWith(newOp)
  164. } else {
  165. ops[ops.length] = ops[ops.length - 1]
  166. ops[ops.length - 2] = newOp
  167. }
  168. } else {
  169. ops.push(newOp)
  170. }
  171. return this
  172. }
  173. /**
  174. * Remove a string at the current position.
  175. * @param {number | string} n
  176. * @returns {TextOperation}
  177. */
  178. remove(n) {
  179. if (typeof n === 'string') {
  180. n = n.length
  181. }
  182. if (typeof n !== 'number') {
  183. throw new Error('remove expects an integer or a string')
  184. }
  185. if (n === 0) {
  186. return this
  187. }
  188. if (n > 0) {
  189. n = -n
  190. }
  191. const newOp = RemoveOp.fromJSON(n)
  192. this.baseLength -= n
  193. const lastOp = this.ops[this.ops.length - 1]
  194. if (lastOp?.canMergeWith(newOp)) {
  195. lastOp.mergeWith(newOp)
  196. } else {
  197. this.ops.push(newOp)
  198. }
  199. return this
  200. }
  201. /**
  202. * Tests whether this operation has no effect.
  203. */
  204. isNoop() {
  205. return (
  206. this.ops.length === 0 ||
  207. (this.ops.length === 1 && this.ops[0] instanceof RetainOp)
  208. )
  209. }
  210. /**
  211. * Pretty printing.
  212. */
  213. toString() {
  214. return this.ops.map(op => op.toString()).join(', ')
  215. }
  216. /**
  217. * @inheritdoc
  218. * @returns {RawTextOperation}
  219. */
  220. toJSON() {
  221. /** @type {RawTextOperation} */
  222. const json = { textOperation: this.ops.map(op => op.toJSON()) }
  223. if (this.contentHash != null) {
  224. json.contentHash = this.contentHash
  225. }
  226. return json
  227. }
  228. /**
  229. * Converts a plain JS object into an operation and validates it.
  230. * @param {RawTextOperation} obj
  231. * @returns {TextOperation}
  232. */
  233. static fromJSON = function ({ textOperation: ops, contentHash }) {
  234. const o = new TextOperation()
  235. for (const op of ops) {
  236. if (isRetain(op)) {
  237. const retain = RetainOp.fromJSON(op)
  238. o.retain(retain.length, { tracking: retain.tracking })
  239. } else if (isInsert(op)) {
  240. const insert = InsertOp.fromJSON(op)
  241. o.insert(insert.insertion, {
  242. commentIds: insert.commentIds,
  243. tracking: insert.tracking,
  244. })
  245. } else if (isRemove(op)) {
  246. const remove = RemoveOp.fromJSON(op)
  247. o.remove(-remove.length)
  248. } else {
  249. throw new UnprocessableError('unknown operation: ' + JSON.stringify(op))
  250. }
  251. }
  252. if (contentHash != null) {
  253. o.contentHash = contentHash
  254. }
  255. return o
  256. }
  257. /**
  258. * Apply an operation to a string, returning a new string. Throws an error if
  259. * there's a mismatch between the input string and the operation.
  260. * @override
  261. * @inheritdoc
  262. * @param {StringFileData} file
  263. */
  264. apply(file) {
  265. const str = file.getContent()
  266. const operation = this
  267. if (str.length !== operation.baseLength) {
  268. throw new TextOperation.ApplyError(
  269. "The operation's base length must be equal to the string's length.",
  270. operation,
  271. str
  272. )
  273. }
  274. const ops = this.ops
  275. let inputCursor = 0
  276. let result = ''
  277. for (const op of ops) {
  278. if (op instanceof RetainOp) {
  279. if (inputCursor + op.length > str.length) {
  280. throw new ApplyError(
  281. "Operation can't retain more chars than are left in the string.",
  282. op.toJSON(),
  283. str
  284. )
  285. }
  286. result += str.slice(inputCursor, inputCursor + op.length)
  287. inputCursor += op.length
  288. } else if (op instanceof InsertOp) {
  289. file.comments.applyInsert(
  290. new Range(result.length, op.insertion.length),
  291. { commentIds: op.commentIds }
  292. )
  293. result += op.insertion
  294. } else if (op instanceof RemoveOp) {
  295. file.comments.applyDelete(new Range(result.length, op.length))
  296. inputCursor += op.length
  297. } else {
  298. throw new UnprocessableError('Unknown ScanOp type during apply')
  299. }
  300. }
  301. if (inputCursor !== str.length) {
  302. throw new TextOperation.ApplyError(
  303. "The operation didn't operate on the whole string.",
  304. operation,
  305. str
  306. )
  307. }
  308. if (result.length > TextOperation.MAX_STRING_LENGTH) {
  309. throw new TextOperation.TooLongError(operation, result.length)
  310. }
  311. file.trackedChanges.applyTextOperation(this)
  312. file.content = result
  313. }
  314. /**
  315. * @inheritdoc
  316. * @param {number} length of the original string; non-negative
  317. * @return {number} length of the new string; non-negative
  318. */
  319. applyToLength(length) {
  320. const operation = this
  321. if (length !== operation.baseLength) {
  322. throw new TextOperation.ApplyError(
  323. "The operation's base length must be equal to the string's length.",
  324. operation,
  325. length
  326. )
  327. }
  328. const { length: newLength, inputCursor } = this.ops.reduce(
  329. (intermediate, op) => op.applyToLength(intermediate),
  330. { length: 0, inputCursor: 0, inputLength: length }
  331. )
  332. if (inputCursor !== length) {
  333. throw new TextOperation.ApplyError(
  334. "The operation didn't operate on the whole string.",
  335. operation,
  336. length
  337. )
  338. }
  339. if (newLength > TextOperation.MAX_STRING_LENGTH) {
  340. throw new TextOperation.TooLongError(operation, newLength)
  341. }
  342. return newLength
  343. }
  344. /**
  345. * @inheritdoc
  346. * @param {StringFileData} previousState
  347. */
  348. invert(previousState) {
  349. const str = previousState.getContent()
  350. let strIndex = 0
  351. const inverse = new TextOperation()
  352. const ops = this.ops
  353. for (let i = 0, l = ops.length; i < l; i++) {
  354. const op = ops[i]
  355. if (op instanceof RetainOp) {
  356. if (op.tracking) {
  357. // Where we need to end up after the retains
  358. const target = strIndex + op.length
  359. // A previous retain could have overriden some tracking info. Now we
  360. // need to restore it.
  361. const previousChanges = previousState.trackedChanges.intersectRange(
  362. new Range(strIndex, op.length)
  363. )
  364. for (const change of previousChanges) {
  365. if (strIndex < change.range.start) {
  366. inverse.retain(change.range.start - strIndex, {
  367. tracking: new ClearTrackingProps(),
  368. })
  369. strIndex = change.range.start
  370. }
  371. inverse.retain(change.range.length, {
  372. tracking: change.tracking,
  373. })
  374. strIndex += change.range.length
  375. }
  376. if (strIndex < target) {
  377. inverse.retain(target - strIndex, {
  378. tracking: new ClearTrackingProps(),
  379. })
  380. strIndex = target
  381. }
  382. } else {
  383. inverse.retain(op.length)
  384. strIndex += op.length
  385. }
  386. } else if (op instanceof InsertOp) {
  387. inverse.remove(op.insertion.length)
  388. } else if (op instanceof RemoveOp) {
  389. const segments = calculateTrackingCommentSegments(
  390. strIndex,
  391. op.length,
  392. previousState.comments,
  393. previousState.trackedChanges
  394. )
  395. for (const segment of segments) {
  396. inverse.insert(str.slice(strIndex, strIndex + segment.length), {
  397. tracking: segment.tracking,
  398. commentIds: segment.commentIds,
  399. })
  400. strIndex += segment.length
  401. }
  402. } else {
  403. throw new UnprocessableError('unknown scanop during inversion')
  404. }
  405. }
  406. return inverse
  407. }
  408. /**
  409. * @inheritdoc
  410. * @param {EditOperation} other
  411. */
  412. canBeComposedWithForUndo(other) {
  413. if (!(other instanceof TextOperation)) {
  414. return false
  415. }
  416. if (this.isNoop() || other.isNoop()) {
  417. return true
  418. }
  419. const startA = getStartIndex(this)
  420. const startB = getStartIndex(other)
  421. const simpleA = getSimpleOp(this)
  422. const simpleB = getSimpleOp(other)
  423. if (!simpleA || !simpleB) {
  424. return false
  425. }
  426. if (simpleA instanceof InsertOp && simpleB instanceof InsertOp) {
  427. return startA + simpleA.insertion.length === startB
  428. }
  429. if (simpleA instanceof RemoveOp && simpleB instanceof RemoveOp) {
  430. // there are two possibilities to delete: with backspace and with the
  431. // delete key.
  432. return startB + simpleB.length === startA || startA === startB
  433. }
  434. return false
  435. }
  436. /**
  437. * @inheritdoc
  438. * @param {EditOperation} other
  439. */
  440. canBeComposedWith(other) {
  441. if (!(other instanceof TextOperation)) {
  442. return false
  443. }
  444. return this.targetLength === other.baseLength
  445. }
  446. /**
  447. * @inheritdoc
  448. * @param {EditOperation} operation2
  449. */
  450. compose(operation2) {
  451. if (!(operation2 instanceof TextOperation)) {
  452. throw new Error(
  453. `Trying to compose TextOperation with ${operation2?.constructor?.name}.`
  454. )
  455. }
  456. const operation1 = this
  457. if (operation1.targetLength !== operation2.baseLength) {
  458. throw new Error(
  459. 'The base length of the second operation has to be the ' +
  460. 'target length of the first operation'
  461. )
  462. }
  463. const operation = new TextOperation() // the combined operation
  464. const ops1 = operation1.ops
  465. const ops2 = operation2.ops // for fast access
  466. let i1 = 0
  467. let i2 = 0 // current index into ops1 respectively ops2
  468. let op1 = ops1[i1++]
  469. let op2 = ops2[i2++] // current ops
  470. for (;;) {
  471. // Dispatch on the type of op1 and op2
  472. if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
  473. // end condition: both ops1 and ops2 have been processed
  474. break
  475. }
  476. if (op1 instanceof RemoveOp) {
  477. operation.remove(-op1.length)
  478. op1 = ops1[i1++]
  479. continue
  480. }
  481. if (op2 instanceof InsertOp) {
  482. operation.insert(op2.insertion, {
  483. tracking: op2.tracking,
  484. commentIds: op2.commentIds,
  485. })
  486. op2 = ops2[i2++]
  487. continue
  488. }
  489. if (typeof op1 === 'undefined') {
  490. throw new Error(
  491. 'Cannot compose operations: first operation is too short.'
  492. )
  493. }
  494. if (typeof op2 === 'undefined') {
  495. throw new Error(
  496. 'Cannot compose operations: first operation is too long.'
  497. )
  498. }
  499. if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
  500. // If both have tracking info, use the latter one. Otherwise use the
  501. // tracking info from the former.
  502. const tracking = op2.tracking ?? op1.tracking
  503. if (op1.length > op2.length) {
  504. operation.retain(op2.length, {
  505. tracking,
  506. })
  507. op1 = new RetainOp(op1.length - op2.length, op1.tracking)
  508. op2 = ops2[i2++]
  509. } else if (op1.length === op2.length) {
  510. operation.retain(op1.length, {
  511. tracking,
  512. })
  513. op1 = ops1[i1++]
  514. op2 = ops2[i2++]
  515. } else {
  516. operation.retain(op1.length, {
  517. tracking,
  518. })
  519. op2 = new RetainOp(op2.length - op1.length, op2.tracking)
  520. op1 = ops1[i1++]
  521. }
  522. } else if (op1 instanceof InsertOp && op2 instanceof RemoveOp) {
  523. if (op1.insertion.length > op2.length) {
  524. op1 = new InsertOp(
  525. op1.insertion.slice(op2.length),
  526. op1.tracking,
  527. op1.commentIds
  528. )
  529. op2 = ops2[i2++]
  530. } else if (op1.insertion.length === op2.length) {
  531. op1 = ops1[i1++]
  532. op2 = ops2[i2++]
  533. } else {
  534. op2 = RemoveOp.fromJSON(op1.insertion.length - op2.length)
  535. op1 = ops1[i1++]
  536. }
  537. } else if (op1 instanceof InsertOp && op2 instanceof RetainOp) {
  538. /** @type InsertOptions */
  539. const opts = {
  540. commentIds: op1.commentIds,
  541. }
  542. if (op2.tracking instanceof TrackingProps) {
  543. // Prefer the tracking info on the second operation
  544. opts.tracking = op2.tracking
  545. } else if (!(op2.tracking instanceof ClearTrackingProps)) {
  546. // The second operation does not cancel the first operation's tracking
  547. opts.tracking = op1.tracking
  548. }
  549. if (op1.insertion.length > op2.length) {
  550. operation.insert(op1.insertion.slice(0, op2.length), opts)
  551. op1 = new InsertOp(
  552. op1.insertion.slice(op2.length),
  553. op1.tracking,
  554. op1.commentIds
  555. )
  556. op2 = ops2[i2++]
  557. } else if (op1.insertion.length === op2.length) {
  558. operation.insert(op1.insertion, opts)
  559. op1 = ops1[i1++]
  560. op2 = ops2[i2++]
  561. } else {
  562. operation.insert(op1.insertion, opts)
  563. op2 = new RetainOp(op2.length - op1.insertion.length, op2.tracking)
  564. op1 = ops1[i1++]
  565. }
  566. } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
  567. if (op1.length > op2.length) {
  568. operation.remove(-op2.length)
  569. op1 = new RetainOp(op1.length - op2.length, op1.tracking)
  570. op2 = ops2[i2++]
  571. } else if (op1.length === op2.length) {
  572. operation.remove(-op2.length)
  573. op1 = ops1[i1++]
  574. op2 = ops2[i2++]
  575. } else {
  576. operation.remove(op1.length)
  577. op2 = RemoveOp.fromJSON(op1.length - op2.length)
  578. op1 = ops1[i1++]
  579. }
  580. } else {
  581. throw new Error(
  582. "This shouldn't happen: op1: " +
  583. JSON.stringify(op1) +
  584. ', op2: ' +
  585. JSON.stringify(op2)
  586. )
  587. }
  588. }
  589. return operation
  590. }
  591. /**
  592. * Transform takes two operations A and B that happened concurrently and
  593. * produces two operations A' and B' (in an array) such that
  594. * `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the
  595. * heart of OT.
  596. * @param {TextOperation} operation1
  597. * @param {TextOperation} operation2
  598. * @returns {[TextOperation, TextOperation]}
  599. */
  600. static transform(operation1, operation2) {
  601. if (operation1.baseLength !== operation2.baseLength) {
  602. throw new Error('Both operations have to have the same base length')
  603. }
  604. const operation1prime = new TextOperation()
  605. const operation2prime = new TextOperation()
  606. const ops1 = operation1.ops
  607. const ops2 = operation2.ops
  608. let i1 = 0
  609. let i2 = 0
  610. let op1 = ops1[i1++]
  611. let op2 = ops2[i2++]
  612. for (;;) {
  613. // At every iteration of the loop, the imaginary cursor that both
  614. // operation1 and operation2 have that operates on the input string must
  615. // have the same position in the input string.
  616. if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
  617. // end condition: both ops1 and ops2 have been processed
  618. break
  619. }
  620. // next two cases: one or both ops are insert ops
  621. // => insert the string in the corresponding prime operation, skip it in
  622. // the other one. If both op1 and op2 are insert ops, prefer op1.
  623. if (op1 instanceof InsertOp) {
  624. operation1prime.insert(op1.insertion, {
  625. tracking: op1.tracking,
  626. commentIds: op1.commentIds,
  627. })
  628. operation2prime.retain(op1.insertion.length)
  629. op1 = ops1[i1++]
  630. continue
  631. }
  632. if (op2 instanceof InsertOp) {
  633. operation1prime.retain(op2.insertion.length)
  634. operation2prime.insert(op2.insertion, {
  635. tracking: op2.tracking,
  636. commentIds: op2.commentIds,
  637. })
  638. op2 = ops2[i2++]
  639. continue
  640. }
  641. if (typeof op1 === 'undefined') {
  642. throw new Error(
  643. 'Cannot compose operations: first operation is too short.'
  644. )
  645. }
  646. if (typeof op2 === 'undefined') {
  647. throw new Error(
  648. 'Cannot compose operations: first operation is too long.'
  649. )
  650. }
  651. let minl
  652. if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
  653. // Simple case: retain/retain
  654. // If both have tracking info, we use the one from op1
  655. /** @type {TrackingProps | ClearTrackingProps | undefined} */
  656. let operation1primeTracking
  657. /** @type {TrackingProps | ClearTrackingProps | undefined} */
  658. let operation2primeTracking
  659. if (op1.tracking) {
  660. operation1primeTracking = op1.tracking
  661. } else {
  662. operation2primeTracking = op2.tracking
  663. }
  664. if (op1.length > op2.length) {
  665. minl = op2.length
  666. op1 = new RetainOp(op1.length - op2.length, op1.tracking)
  667. op2 = ops2[i2++]
  668. } else if (op1.length === op2.length) {
  669. minl = op2.length
  670. op1 = ops1[i1++]
  671. op2 = ops2[i2++]
  672. } else {
  673. minl = op1.length
  674. op2 = new RetainOp(op2.length - op1.length, op2.tracking)
  675. op1 = ops1[i1++]
  676. }
  677. operation1prime.retain(minl, { tracking: operation1primeTracking })
  678. operation2prime.retain(minl, { tracking: operation2primeTracking })
  679. } else if (op1 instanceof RemoveOp && op2 instanceof RemoveOp) {
  680. // Both operations remove the same string at the same position. We don't
  681. // need to produce any operations, we just skip over the remove ops and
  682. // handle the case that one operation removes more than the other.
  683. if (op1.length > op2.length) {
  684. op1 = RemoveOp.fromJSON(op2.length - op1.length)
  685. op2 = ops2[i2++]
  686. } else if (op1.length === op2.length) {
  687. op1 = ops1[i1++]
  688. op2 = ops2[i2++]
  689. } else {
  690. op2 = RemoveOp.fromJSON(op1.length - op2.length)
  691. op1 = ops1[i1++]
  692. }
  693. // next two cases: remove/retain and retain/remove
  694. } else if (op1 instanceof RemoveOp && op2 instanceof RetainOp) {
  695. if (op1.length > op2.length) {
  696. minl = op2.length
  697. op1 = RemoveOp.fromJSON(op2.length - op1.length)
  698. op2 = ops2[i2++]
  699. } else if (op1.length === op2.length) {
  700. minl = op2.length
  701. op1 = ops1[i1++]
  702. op2 = ops2[i2++]
  703. } else {
  704. minl = op1.length
  705. op2 = new RetainOp(op2.length - op1.length, op2.tracking)
  706. op1 = ops1[i1++]
  707. }
  708. operation1prime.remove(minl)
  709. } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
  710. if (op1.length > op2.length) {
  711. minl = op2.length
  712. op1 = new RetainOp(op1.length - op2.length, op1.tracking)
  713. op2 = ops2[i2++]
  714. } else if (op1.length === op2.length) {
  715. minl = op1.length
  716. op1 = ops1[i1++]
  717. op2 = ops2[i2++]
  718. } else {
  719. minl = op1.length
  720. op2 = RemoveOp.fromJSON(op1.length - op2.length)
  721. op1 = ops1[i1++]
  722. }
  723. operation2prime.remove(minl)
  724. } else {
  725. throw new Error("The two operations aren't compatible")
  726. }
  727. }
  728. return [operation1prime, operation2prime]
  729. }
  730. }
  731. // Operation are essentially lists of ops. There are three types of ops:
  732. //
  733. // * Retain ops: Advance the cursor position by a given number of characters.
  734. // Represented by positive ints.
  735. // * Insert ops: Insert a given string at the current cursor position.
  736. // Represented by strings.
  737. // * Remove ops: Remove the next n characters. Represented by negative ints.
  738. /**
  739. *
  740. * @param {TextOperation} operation
  741. * @returns {ScanOp | null}
  742. */
  743. function getSimpleOp(operation) {
  744. const ops = operation.ops
  745. switch (ops.length) {
  746. case 1:
  747. return ops[0]
  748. case 2:
  749. return ops[0] instanceof RetainOp
  750. ? ops[1]
  751. : ops[1] instanceof RetainOp
  752. ? ops[0]
  753. : null
  754. case 3:
  755. if (ops[0] instanceof RetainOp && ops[2] instanceof RetainOp) {
  756. return ops[1]
  757. }
  758. }
  759. return null
  760. }
  761. /**
  762. * @param {TextOperation} operation
  763. * @return {number}
  764. */
  765. function getStartIndex(operation) {
  766. if (operation.ops[0] instanceof RetainOp) {
  767. return operation.ops[0].length
  768. }
  769. return 0
  770. }
  771. /**
  772. * Constructs the segments defined as each overlapping range of tracked
  773. * changes and comments. Each segment can have it's own tracking props and
  774. * attached comment ids.
  775. *
  776. * The quick brown fox jumps over the lazy dog
  777. * Tracked inserts ---------- -----
  778. * Tracked deletes ------
  779. * Comment 1 -------
  780. * Comment 2 ----
  781. * Comment 3 -----------------
  782. *
  783. * Approx. boundaries: | | | || | | | |
  784. *
  785. * @param {number} cursor
  786. * @param {number} length
  787. * @param {import('../file_data/comment_list')} commentsList
  788. * @param {TrackedChangeList} trackedChangeList
  789. * @returns {{length: number, commentIds?: string[], tracking?: TrackingProps}[]}
  790. */
  791. function calculateTrackingCommentSegments(
  792. cursor,
  793. length,
  794. commentsList,
  795. trackedChangeList
  796. ) {
  797. const breaks = new Set()
  798. const opStart = cursor
  799. const opEnd = cursor + length
  800. /**
  801. * Utility function to limit breaks to the boundary set by the operation range
  802. * @param {number} rangeBoundary
  803. */
  804. function addBreak(rangeBoundary) {
  805. if (rangeBoundary < opStart || rangeBoundary > opEnd) {
  806. return
  807. }
  808. breaks.add(rangeBoundary)
  809. }
  810. // Add comment boundaries
  811. for (const comment of commentsList.comments.values()) {
  812. for (const range of comment.ranges) {
  813. addBreak(range.end)
  814. addBreak(range.start)
  815. }
  816. }
  817. // Add tracked change boundaries
  818. for (const trackedChange of trackedChangeList.asSorted()) {
  819. addBreak(trackedChange.range.start)
  820. addBreak(trackedChange.range.end)
  821. }
  822. // Add operation boundaries
  823. addBreak(opStart)
  824. addBreak(opEnd)
  825. // Sort the boundaries so that we can construct ranges between them
  826. const sortedBreaks = Array.from(breaks).sort((a, b) => a - b)
  827. const separateRanges = []
  828. for (let i = 1; i < sortedBreaks.length; i++) {
  829. const start = sortedBreaks[i - 1]
  830. const end = sortedBreaks[i]
  831. const currentRange = new Range(start, end - start)
  832. // The comment ids that cover the current range is part of this sub-range
  833. const commentIds = commentsList.idsCoveringRange(currentRange)
  834. // The tracking info that covers the current range is part of this sub-range
  835. const tracking = trackedChangeList.propsAtRange(currentRange)
  836. separateRanges.push({
  837. length: currentRange.length,
  838. commentIds: commentIds.length > 0 ? commentIds : undefined,
  839. tracking,
  840. })
  841. }
  842. return separateRanges
  843. }
  844. module.exports = TextOperation