text_operation.js 27 KB

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