reject-changes.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. import { EditorState } from '@codemirror/state'
  2. import { Change, EditOperation } from '../../../../../../types/change'
  3. import { isDeleteOperation, isInsertOperation } from '@/utils/operations'
  4. import { DocumentContainer } from '@/features/ide-react/editor/document-container'
  5. import { trackChangesAnnotation } from '@/features/source-editor/extensions/realtime'
  6. /**
  7. * Remove tracked changes from the range tracker when they're rejected,
  8. * and restore the original content
  9. */
  10. export const rejectChanges = (
  11. state: EditorState,
  12. ranges: DocumentContainer['ranges'],
  13. changeIds: string[]
  14. ) => {
  15. const changes = ranges!.getChanges(changeIds) as Change<EditOperation>[]
  16. if (changes.length === 0) {
  17. return {}
  18. }
  19. // When doing bulk rejections, adjacent changes might interact with each other.
  20. // Consider an insertion with an adjacent deletion (which is a common use-case, replacing words):
  21. //
  22. // "foo bar baz" -> "foo quux baz"
  23. //
  24. // The change above will be modeled with two ops, with the insertion going first:
  25. //
  26. // foo quux baz
  27. // |--| -> insertion of "quux", op 1, at position 4
  28. // | -> deletion of "bar", op 2, pushed forward by "quux" to position 8
  29. //
  30. // When rejecting these changes at once, if the insertion is rejected first, we get unexpected
  31. // results. What happens is:
  32. //
  33. // 1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
  34. // starting from position 4;
  35. //
  36. // "foo quux baz" -> "foo baz"
  37. // |--| -> 4 characters to be removed
  38. //
  39. // 2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if
  40. // the word "quuux" was still present).
  41. //
  42. // "foo baz" -> "foo bazbar"
  43. // | -> deletion of "bar" is reverted by reinserting "bar" at position 8
  44. //
  45. // While the intended result would be "foo bar baz", what we get is:
  46. //
  47. // "foo bazbar" (note "bar" readded at position 8)
  48. //
  49. // The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted
  50. // from position 4. This includes the position where the deletion exists; when that position is
  51. // cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it.
  52. // As we still hold a reference to it, the code tries to revert it by readding the deleted text, but
  53. // does so at the outdated position (position 8, which was valid when "quux" was present).
  54. //
  55. // To avoid this kind of problem, we need to make sure that reverting operations doesn't affect
  56. // subsequent operations that come after. Reverse sorting the operations based on position will
  57. // achieve it; in the case above, it makes sure that the the deletion is reverted first:
  58. //
  59. // 1) Rejecting the deletion adds the deleted word "bar" at position 8
  60. //
  61. // "foo quux baz" -> "foo quuxbar baz"
  62. // | -> deletion of "bar" is reverted by
  63. // reinserting "bar" at position 8
  64. //
  65. // 2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
  66. // starting from position 4 and achieves the expected result:
  67. //
  68. // "foo quuxbar baz" -> "foo bar baz"
  69. // |--| -> 4 characters to be removed
  70. changes.sort((a, b) => b.op.p - a.op.p)
  71. const changesToDispatch = changes.map(change => {
  72. const { op } = change
  73. if (isInsertOperation(op)) {
  74. const from = op.p
  75. const content = op.i
  76. const to = from + content.length
  77. const text = state.doc.sliceString(from, to)
  78. if (text !== content) {
  79. throw new Error(`Op to be removed does not match editor text`)
  80. }
  81. return { from, to, insert: '' }
  82. } else if (isDeleteOperation(op)) {
  83. return {
  84. from: op.p,
  85. to: op.p,
  86. insert: op.d,
  87. }
  88. } else {
  89. throw new Error(`unknown change type: ${JSON.stringify(change)}`)
  90. }
  91. })
  92. return {
  93. changes: changesToDispatch,
  94. annotations: [trackChangesAnnotation.of('reject')],
  95. }
  96. }