├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src ├── README.md ├── change.ts ├── changeset.ts ├── diff.ts └── simplify.ts └── test ├── test-changed-range.ts ├── test-changes.ts ├── test-diff.ts ├── test-merge.ts └── test-simplify.ts /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .tern-port 3 | /dist 4 | /test/*.js -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.3.1 (2025-05-28) 2 | 3 | ### Bug fixes 4 | 5 | Improve diffing to not treat closing tokens of different node types as the same token. 6 | 7 | ## 2.3.0 (2025-05-05) 8 | 9 | ### New features 10 | 11 | Change sets can now be passed a custom token encoder that controls the way changed content is diffed. 12 | 13 | ## 2.2.1 (2023-05-17) 14 | 15 | ### Bug fixes 16 | 17 | Include CommonJS type declarations in the package to please new TypeScript resolution settings. 18 | 19 | ## 2.2.0 (2022-05-30) 20 | 21 | ### New features 22 | 23 | Include TypeScript type declarations. 24 | 25 | ## 2.1.2 (2019-11-20) 26 | 27 | ### Bug fixes 28 | 29 | Rename ES module files to use a .js extension, since Webpack gets confused by .mjs 30 | 31 | ## 2.1.1 (2019-11-19) 32 | 33 | ### Bug fixes 34 | 35 | The file referred to in the package's `module` field now is compiled down to ES5. 36 | 37 | ## 2.1.0 (2019-11-08) 38 | 39 | ### New features 40 | 41 | Add a `module` field to package json file. 42 | 43 | ## 2.0.4 (2019-03-12) 44 | 45 | ### Bug fixes 46 | 47 | Fixes an issue where steps that cause multiple changed ranges (such as `ReplaceAroundStep`) would cause invalid change sets. 48 | 49 | Fix a bug in incremental change set updates that would cause incorrect results in a number of cases. 50 | 51 | ## 2.0.3 (2019-01-09) 52 | 53 | ### Bug fixes 54 | 55 | Make `simplifyChanges` merge adjacent simplified changes (which can occur when a word boundary is inserted). 56 | 57 | ## 2.0.2 (2019-01-08) 58 | 59 | ### Bug fixes 60 | 61 | Fix a bug in simplifyChanges that only occurred when the changes weren't at the start of the document. 62 | 63 | ## 2.0.1 (2019-01-07) 64 | 65 | ### Bug fixes 66 | 67 | Fixes issue in `simplifyChanges` where it sometimes returned nonsense when the inspected text wasn't at the start of the document. 68 | 69 | ## 2.0.0 (2019-01-04) 70 | 71 | ### Bug fixes 72 | 73 | Solves various cases where complicated edits could corrupt the set of changes due to the mapped positions of deletions not agreeing with the mapped positions of insertions. 74 | 75 | ### New features 76 | 77 | Moves to a more efficient diffing algorithm (Meyers), so that large replacements can be accurately diffed using reasonable time and memory. 78 | 79 | You can now find the original document given to a `ChangeSet` with its `startDoc` accessor. 80 | 81 | ### Breaking changes 82 | 83 | The way change data is stored in `ChangeSet` objects works differently in this version. Instead of keeping deletions and insertions in separate arrays, the object holds an array of changes, which cover all the changed regions between the old and new document. Each change has start and end positions in both the old and the new document, and contains arrays of insertions and deletions within it. 84 | 85 | This representation avoids a bunch of consistency problems that existed in the old approach, where keeping positions coherent in the deletion and insertion arrays was hard. 86 | 87 | This means the `deletions` and `insertions` members in `ChangeSet` are gone, and instead there is a `changes` property which holds an array of `Change` objects. Each of these has `fromA` and `toA` properties indicating its extent in the old document, and `fromB` and `toB` properties pointing into the new document. Actual insertions and deletions are stored in `inserted` and `deleted` arrays in `Change` objects, which hold `{data, length}` objects, where the total length of deletions adds up to `toA - fromA`, and the total length of insertions to `toB - fromB`. 88 | 89 | When creating a `ChangeSet` object, you no longer need to pass separate compare and combine callbacks. Instead, these are now represented using a single function that returns a combined data value or `null` when the values are not compatible. 90 | 91 | ## 1.2.1 (2018-11-15) 92 | 93 | ### Bug fixes 94 | 95 | Properly apply the heuristics for ignoring short matches when diffing, and adjust those heuristics to more agressively weed out tiny matches in large changes. 96 | 97 | ## 1.2.0 (2018-11-08) 98 | 99 | ### New features 100 | 101 | The new `changedRange` method can be used to compare two change sets and find out which range has changed. 102 | 103 | ## 1.1.0 (2018-11-07) 104 | 105 | ### New features 106 | 107 | Add a new method, `ChangeSet.map` to update the data associated with changed ranges. 108 | 109 | ## 1.0.5 (2018-09-25) 110 | 111 | ### Bug fixes 112 | 113 | Fix another issue where overlapping changes that can't be merged could produce a corrupt change set. 114 | 115 | ## 1.0.4 (2018-09-24) 116 | 117 | ### Bug fixes 118 | 119 | Fixes an issue where `addSteps` could produce invalid change sets when a new step's deleted range overlapped with an incompatible previous deletion. 120 | 121 | ## 1.0.3 (2017-11-10) 122 | 123 | ### Bug fixes 124 | 125 | Fix issue where deleting, inserting, and deleting the same content would lead to an inconsistent change set. 126 | 127 | ## 1.0.2 (2017-10-19) 128 | 129 | ### Bug fixes 130 | 131 | Fix a bug that caused `addSteps` to break when merging two insertions into a single deletion. 132 | 133 | ## 1.0.1 (2017-10-18) 134 | 135 | ### Bug fixes 136 | 137 | Fix crash in `ChangeSet.addSteps`. 138 | 139 | ## 1.0.0 (2017-10-13) 140 | 141 | First stable release. 142 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2017 by Marijn Haverbeke and others 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prosemirror-changeset 2 | 3 | This is a helper module that can turn a sequence of document changes 4 | into a set of insertions and deletions, for example to display them in 5 | a change-tracking interface. Such a set can be built up incrementally, 6 | in order to do such change tracking in a halfway performant way during 7 | live editing. 8 | 9 | This code is licensed under an [MIT 10 | licence](https://github.com/ProseMirror/prosemirror-changeset/blob/master/LICENSE). 11 | 12 | ## Programming interface 13 | 14 | Insertions and deletions are represented as ‘spans’—ranges in the 15 | document. The deleted spans refer to the original document, whereas 16 | the inserted ones point into the current document. 17 | 18 | It is possible to associate arbitrary data values with such spans, for 19 | example to track the user that made the change, the timestamp at which 20 | it was made, or the step data necessary to invert it again. 21 | 22 | ### class Change`` 23 | 24 | A replaced range with metadata associated with it. 25 | 26 | * **`fromA`**`: number`\ 27 | The start of the range deleted/replaced in the old document. 28 | 29 | * **`toA`**`: number`\ 30 | The end of the range in the old document. 31 | 32 | * **`fromB`**`: number`\ 33 | The start of the range inserted in the new document. 34 | 35 | * **`toB`**`: number`\ 36 | The end of the range in the new document. 37 | 38 | * **`deleted`**`: readonly Span[]`\ 39 | Data associated with the deleted content. The length of these 40 | spans adds up to `this.toA - this.fromA`. 41 | 42 | * **`inserted`**`: readonly Span[]`\ 43 | Data associated with the inserted content. Length adds up to 44 | `this.toB - this.fromB`. 45 | 46 | * `static `**`merge`**`(x: readonly Change[], y: readonly Change[], combine: fn(dataA: Data, dataB: Data) → Data) → readonly Change[]`\ 47 | This merges two changesets (the end document of x should be the 48 | start document of y) into a single one spanning the start of x to 49 | the end of y. 50 | 51 | 52 | ### class Span`` 53 | 54 | Stores metadata for a part of a change. 55 | 56 | * **`length`**`: number`\ 57 | The length of this span. 58 | 59 | * **`data`**`: Data`\ 60 | The data associated with this span. 61 | 62 | 63 | ### class ChangeSet`` 64 | 65 | A change set tracks the changes to a document from a given point 66 | in the past. It condenses a number of step maps down to a flat 67 | sequence of replacements, and simplifies replacments that 68 | partially undo themselves by comparing their content. 69 | 70 | * **`changes`**`: readonly Change[]`\ 71 | Replaced regions. 72 | 73 | * **`addSteps`**`(newDoc: Node, maps: readonly StepMap[], data: Data | readonly Data[]) → ChangeSet`\ 74 | Computes a new changeset by adding the given step maps and 75 | metadata (either as an array, per-map, or as a single value to be 76 | associated with all maps) to the current set. Will not mutate the 77 | old set. 78 | 79 | Note that due to simplification that happens after each add, 80 | incrementally adding steps might create a different final set 81 | than adding all those changes at once, since different document 82 | tokens might be matched during simplification depending on the 83 | boundaries of the current changed ranges. 84 | 85 | * **`startDoc`**`: Node`\ 86 | The starting document of the change set. 87 | 88 | * **`map`**`(f: fn(range: Span) → Data) → ChangeSet`\ 89 | Map the span's data values in the given set through a function 90 | and construct a new set with the resulting data. 91 | 92 | * **`changedRange`**`(b: ChangeSet, maps?: readonly StepMap[]) → {from: number, to: number}`\ 93 | Compare two changesets and return the range in which they are 94 | changed, if any. If the document changed between the maps, pass 95 | the maps for the steps that changed it as second argument, and 96 | make sure the method is called on the old set and passed the new 97 | set. The returned positions will be in new document coordinates. 98 | 99 | * `static `**`create`**`(doc: Node, combine?: fn(dataA: Data, dataB: Data) → Data = (a, b) => a === b ? a : null as any, tokenEncoder?: TokenEncoder = DefaultEncoder) → ChangeSet`\ 100 | Create a changeset with the given base object and configuration. 101 | 102 | The `combine` function is used to compare and combine metadata—it 103 | should return null when metadata isn't compatible, and a combined 104 | version for a merged range when it is. 105 | 106 | When given, a token encoder determines how document tokens are 107 | serialized and compared when diffing the content produced by 108 | changes. The default is to just compare nodes by name and text 109 | by character, ignoring marks and attributes. 110 | 111 | 112 | * **`simplifyChanges`**`(changes: readonly Change[], doc: Node) → Change[]`\ 113 | Simplifies a set of changes for presentation. This makes the 114 | assumption that having both insertions and deletions within a word 115 | is confusing, and, when such changes occur without a word boundary 116 | between them, they should be expanded to cover the entire set of 117 | words (in the new document) they touch. An exception is made for 118 | single-character replacements. 119 | 120 | 121 | ### interface TokenEncoder`` 122 | 123 | A token encoder can be passed when creating a `ChangeSet` in order 124 | to influence the way the library runs its diffing algorithm. The 125 | encoder determines how document tokens (such as nodes and 126 | characters) are encoded and compared. 127 | 128 | Note that both the encoding and the comparison may run a lot, and 129 | doing non-trivial work in these functions could impact 130 | performance. 131 | 132 | * **`encodeCharacter`**`(char: number, marks: readonly Mark[]) → T`\ 133 | Encode a given character, with the given marks applied. 134 | 135 | * **`encodeNodeStart`**`(node: Node) → T`\ 136 | Encode the start of a node or, if this is a leaf node, the 137 | entire node. 138 | 139 | * **`encodeNodeEnd`**`(node: Node) → T`\ 140 | Encode the end token for the given node. It is valid to encode 141 | every end token in the same way. 142 | 143 | * **`compareTokens`**`(a: T, b: T) → boolean`\ 144 | Compare the given tokens. Should return true when they count as 145 | equal. 146 | 147 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-changeset", 3 | "version": "2.3.1", 4 | "description": "Distills a series of editing steps into deleted and added ranges", 5 | "type": "module", 6 | "main": "dist/index.cjs", 7 | "module": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "exports": { 10 | "import": "./dist/index.js", 11 | "require": "./dist/index.cjs" 12 | }, 13 | "sideEffects": false, 14 | "license": "MIT", 15 | "maintainers": [ 16 | { 17 | "name": "Marijn Haverbeke", 18 | "email": "marijn@haverbeke.berlin", 19 | "web": "http://marijnhaverbeke.nl" 20 | } 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git://github.com/prosemirror/prosemirror-changeset.git" 25 | }, 26 | "dependencies": { 27 | "prosemirror-transform": "^1.0.0" 28 | }, 29 | "devDependencies": { 30 | "@prosemirror/buildhelper": "^0.1.5", 31 | "prosemirror-model": "^1.0.0", 32 | "prosemirror-test-builder": "^1.0.0", 33 | "builddocs": "^1.0.8" 34 | }, 35 | "scripts": { 36 | "test": "pm-runtests", 37 | "prepare": "pm-buildhelper src/changeset.ts", 38 | "build-readme": "builddocs --format markdown --main src/README.md src/changeset.ts > README.md" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # prosemirror-changeset 2 | 3 | This is a helper module that can turn a sequence of document changes 4 | into a set of insertions and deletions, for example to display them in 5 | a change-tracking interface. Such a set can be built up incrementally, 6 | in order to do such change tracking in a halfway performant way during 7 | live editing. 8 | 9 | This code is licensed under an [MIT 10 | licence](https://github.com/ProseMirror/prosemirror-changeset/blob/master/LICENSE). 11 | 12 | ## Programming interface 13 | 14 | Insertions and deletions are represented as ‘spans’—ranges in the 15 | document. The deleted spans refer to the original document, whereas 16 | the inserted ones point into the current document. 17 | 18 | It is possible to associate arbitrary data values with such spans, for 19 | example to track the user that made the change, the timestamp at which 20 | it was made, or the step data necessary to invert it again. 21 | 22 | @Change 23 | 24 | @Span 25 | 26 | @ChangeSet 27 | 28 | @simplifyChanges 29 | 30 | @TokenEncoder -------------------------------------------------------------------------------- /src/change.ts: -------------------------------------------------------------------------------- 1 | /// Stores metadata for a part of a change. 2 | export class Span { 3 | /// @internal 4 | constructor( 5 | /// The length of this span. 6 | readonly length: number, 7 | /// The data associated with this span. 8 | readonly data: Data 9 | ) {} 10 | 11 | /// @internal 12 | cut(length: number) { 13 | return length == this.length ? this : new Span(length, this.data) 14 | } 15 | 16 | /// @internal 17 | static slice(spans: readonly Span[], from: number, to: number) { 18 | if (from == to) return Span.none 19 | if (from == 0 && to == Span.len(spans)) return spans 20 | let result = [] 21 | for (let i = 0, off = 0; off < to; i++) { 22 | let span = spans[i], end = off + span.length 23 | let overlap = Math.min(to, end) - Math.max(from, off) 24 | if (overlap > 0) result.push(span.cut(overlap)) 25 | off = end 26 | } 27 | return result 28 | } 29 | 30 | /// @internal 31 | static join(a: readonly Span[], b: readonly Span[], combine: (dataA: Data, dataB: Data) => Data) { 32 | if (a.length == 0) return b 33 | if (b.length == 0) return a 34 | let combined = combine(a[a.length - 1].data, b[0].data) 35 | if (combined == null) return a.concat(b) 36 | let result = a.slice(0, a.length - 1) 37 | result.push(new Span(a[a.length - 1].length + b[0].length, combined)) 38 | for (let i = 1; i < b.length; i++) result.push(b[i]) 39 | return result 40 | } 41 | 42 | /// @internal 43 | static len(spans: readonly Span[]) { 44 | let len = 0 45 | for (let i = 0; i < spans.length; i++) len += spans[i].length 46 | return len 47 | } 48 | 49 | /// @internal 50 | static none: readonly Span[] = [] 51 | } 52 | 53 | /// A replaced range with metadata associated with it. 54 | export class Change { 55 | /// @internal 56 | constructor( 57 | /// The start of the range deleted/replaced in the old document. 58 | readonly fromA: number, 59 | /// The end of the range in the old document. 60 | readonly toA: number, 61 | /// The start of the range inserted in the new document. 62 | readonly fromB: number, 63 | /// The end of the range in the new document. 64 | readonly toB: number, 65 | /// Data associated with the deleted content. The length of these 66 | /// spans adds up to `this.toA - this.fromA`. 67 | readonly deleted: readonly Span[], 68 | /// Data associated with the inserted content. Length adds up to 69 | /// `this.toB - this.fromB`. 70 | readonly inserted: readonly Span[] 71 | ) {} 72 | 73 | /// @internal 74 | get lenA() { return this.toA - this.fromA } 75 | /// @internal 76 | get lenB() { return this.toB - this.fromB } 77 | 78 | /// @internal 79 | slice(startA: number, endA: number, startB: number, endB: number): Change { 80 | if (startA == 0 && startB == 0 && endA == this.toA - this.fromA && 81 | endB == this.toB - this.fromB) return this 82 | return new Change(this.fromA + startA, this.fromA + endA, 83 | this.fromB + startB, this.fromB + endB, 84 | Span.slice(this.deleted, startA, endA), 85 | Span.slice(this.inserted, startB, endB)) 86 | } 87 | 88 | /// This merges two changesets (the end document of x should be the 89 | /// start document of y) into a single one spanning the start of x to 90 | /// the end of y. 91 | static merge(x: readonly Change[], 92 | y: readonly Change[], 93 | combine: (dataA: Data, dataB: Data) => Data): readonly Change[] { 94 | if (x.length == 0) return y 95 | if (y.length == 0) return x 96 | 97 | let result = [] 98 | // Iterate over both sets in parallel, using the middle coordinate 99 | // system (B in x, A in y) to synchronize. 100 | for (let iX = 0, iY = 0, curX: Change | null = x[0], curY: Change | null = y[0];;) { 101 | if (!curX && !curY) { 102 | return result 103 | } else if (curX && (!curY || curX.toB < curY.fromA)) { // curX entirely in front of curY 104 | let off = iY ? y[iY - 1].toB - y[iY - 1].toA : 0 105 | result.push(off == 0 ? curX : 106 | new Change(curX.fromA, curX.toA, curX.fromB + off, curX.toB + off, 107 | curX.deleted, curX.inserted)) 108 | curX = iX++ == x.length ? null : x[iX] 109 | } else if (curY && (!curX || curY.toA < curX.fromB)) { // curY entirely in front of curX 110 | let off = iX ? x[iX - 1].toB - x[iX - 1].toA : 0 111 | result.push(off == 0 ? curY : 112 | new Change(curY.fromA - off, curY.toA - off, curY.fromB, curY.toB, 113 | curY.deleted, curY.inserted)) 114 | curY = iY++ == y.length ? null : y[iY] 115 | } else { // Touch, need to merge 116 | // The rules for merging ranges are that deletions from the 117 | // old set and insertions from the new are kept. Areas of the 118 | // middle document covered by a but not by b are insertions 119 | // from a that need to be added, and areas covered by b but 120 | // not a are deletions from b that need to be added. 121 | let pos = Math.min(curX!.fromB, curY!.fromA) 122 | let fromA = Math.min(curX!.fromA, curY!.fromA - (iX ? x[iX - 1].toB - x[iX - 1].toA : 0)), toA = fromA 123 | let fromB = Math.min(curY!.fromB, curX!.fromB + (iY ? y[iY - 1].toB - y[iY - 1].toA : 0)), toB = fromB 124 | let deleted = Span.none, inserted = Span.none 125 | 126 | // Used to prevent appending ins/del range for the same Change twice 127 | let enteredX = false, enteredY = false 128 | 129 | // Need to have an inner loop since any number of further 130 | // ranges might be touching this group 131 | for (;;) { 132 | let nextX = !curX ? 2e8 : pos >= curX.fromB ? curX.toB : curX.fromB 133 | let nextY = !curY ? 2e8 : pos >= curY.fromA ? curY.toA : curY.fromA 134 | let next = Math.min(nextX, nextY) 135 | let inX = curX && pos >= curX.fromB, inY = curY && pos >= curY.fromA 136 | if (!inX && !inY) break 137 | if (inX && pos == curX!.fromB && !enteredX) { 138 | deleted = Span.join(deleted, curX!.deleted, combine) 139 | toA += curX!.lenA 140 | enteredX = true 141 | } 142 | if (inX && !inY) { 143 | inserted = Span.join(inserted, Span.slice(curX!.inserted, pos - curX!.fromB, next - curX!.fromB), combine) 144 | toB += next - pos 145 | } 146 | if (inY && pos == curY!.fromA && !enteredY) { 147 | inserted = Span.join(inserted, curY!.inserted, combine) 148 | toB += curY!.lenB 149 | enteredY = true 150 | } 151 | if (inY && !inX) { 152 | deleted = Span.join(deleted, Span.slice(curY!.deleted, pos - curY!.fromA, next - curY!.fromA), combine) 153 | toA += next - pos 154 | } 155 | 156 | if (inX && next == curX!.toB) { 157 | curX = iX++ == x.length ? null : x[iX] 158 | enteredX = false 159 | } 160 | if (inY && next == curY!.toA) { 161 | curY = iY++ == y.length ? null : y[iY] 162 | enteredY = false 163 | } 164 | pos = next 165 | } 166 | if (fromA < toA || fromB < toB) 167 | result.push(new Change(fromA, toA, fromB, toB, deleted, inserted)) 168 | } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/changeset.ts: -------------------------------------------------------------------------------- 1 | import {Node} from "prosemirror-model" 2 | import {StepMap} from "prosemirror-transform" 3 | import {computeDiff, TokenEncoder, DefaultEncoder} from "./diff" 4 | import {Change, Span} from "./change" 5 | export {Change, Span} 6 | export {simplifyChanges} from "./simplify" 7 | export {TokenEncoder} 8 | 9 | /// A change set tracks the changes to a document from a given point 10 | /// in the past. It condenses a number of step maps down to a flat 11 | /// sequence of replacements, and simplifies replacments that 12 | /// partially undo themselves by comparing their content. 13 | export class ChangeSet { 14 | /// @internal 15 | constructor( 16 | /// @internal 17 | readonly config: { 18 | doc: Node, 19 | combine: (dataA: Data, dataB: Data) => Data, 20 | encoder: TokenEncoder 21 | }, 22 | /// Replaced regions. 23 | readonly changes: readonly Change[] 24 | ) {} 25 | 26 | /// Computes a new changeset by adding the given step maps and 27 | /// metadata (either as an array, per-map, or as a single value to be 28 | /// associated with all maps) to the current set. Will not mutate the 29 | /// old set. 30 | /// 31 | /// Note that due to simplification that happens after each add, 32 | /// incrementally adding steps might create a different final set 33 | /// than adding all those changes at once, since different document 34 | /// tokens might be matched during simplification depending on the 35 | /// boundaries of the current changed ranges. 36 | addSteps(newDoc: Node, maps: readonly StepMap[], data: Data | readonly Data[]): ChangeSet { 37 | // This works by inspecting the position maps for the changes, 38 | // which indicate what parts of the document were replaced by new 39 | // content, and the size of that new content. It uses these to 40 | // build up Change objects. 41 | // 42 | // These change objects are put in sets and merged together using 43 | // Change.merge, giving us the changes created by the new steps. 44 | // Those changes can then be merged with the existing set of 45 | // changes. 46 | // 47 | // For each change that was touched by the new steps, we recompute 48 | // a diff to try to minimize the change by dropping matching 49 | // pieces of the old and new document from the change. 50 | 51 | let stepChanges: Change[] = [] 52 | // Add spans for new steps. 53 | for (let i = 0; i < maps.length; i++) { 54 | let d = Array.isArray(data) ? data[i] : data 55 | let off = 0 56 | maps[i].forEach((fromA, toA, fromB, toB) => { 57 | 58 | stepChanges.push(new Change(fromA + off, toA + off, fromB, toB, 59 | fromA == toA ? Span.none : [new Span(toA - fromA, d)], 60 | fromB == toB ? Span.none : [new Span(toB - fromB, d)])) 61 | 62 | off = (toB - fromB) - (toA - fromA) 63 | }) 64 | } 65 | if (stepChanges.length == 0) return this 66 | 67 | let newChanges = mergeAll(stepChanges, this.config.combine) 68 | let changes = Change.merge(this.changes, newChanges, this.config.combine) 69 | let updated: Change[] = changes as Change[] 70 | 71 | // Minimize changes when possible 72 | for (let i = 0; i < updated.length; i++) { 73 | let change = updated[i] 74 | if (change.fromA == change.toA || change.fromB == change.toB || 75 | // Only look at changes that touch newly added changed ranges 76 | !newChanges.some(r => r.toB > change.fromB && r.fromB < change.toB)) continue 77 | let diff = computeDiff(this.config.doc.content, newDoc.content, change, this.config.encoder) 78 | 79 | // Fast path: If they are completely different, don't do anything 80 | if (diff.length == 1 && diff[0].fromB == 0 && diff[0].toB == change.toB - change.fromB) 81 | continue 82 | 83 | if (updated == changes) updated = changes.slice() 84 | if (diff.length == 1) { 85 | updated[i] = diff[0] 86 | } else { 87 | updated.splice(i, 1, ...diff) 88 | i += diff.length - 1 89 | } 90 | } 91 | 92 | return new ChangeSet(this.config, updated) 93 | } 94 | 95 | /// The starting document of the change set. 96 | get startDoc(): Node { return this.config.doc } 97 | 98 | /// Map the span's data values in the given set through a function 99 | /// and construct a new set with the resulting data. 100 | map(f: (range: Span) => Data): ChangeSet { 101 | let mapSpan = (span: Span) => { 102 | let newData = f(span) 103 | return newData === span.data ? span : new Span(span.length, newData) 104 | } 105 | return new ChangeSet(this.config, this.changes.map((ch: Change) => { 106 | return new Change(ch.fromA, ch.toA, ch.fromB, ch.toB, ch.deleted.map(mapSpan), ch.inserted.map(mapSpan)) 107 | })) 108 | } 109 | 110 | /// Compare two changesets and return the range in which they are 111 | /// changed, if any. If the document changed between the maps, pass 112 | /// the maps for the steps that changed it as second argument, and 113 | /// make sure the method is called on the old set and passed the new 114 | /// set. The returned positions will be in new document coordinates. 115 | changedRange(b: ChangeSet, maps?: readonly StepMap[]): {from: number, to: number} | null { 116 | if (b == this) return null 117 | let touched = maps && touchedRange(maps) 118 | let moved = touched ? (touched.toB - touched.fromB) - (touched.toA - touched.fromA) : 0 119 | function map(p: number) { 120 | return !touched || p <= touched.fromA ? p : p + moved 121 | } 122 | 123 | let from = touched ? touched.fromB : 2e8, to = touched ? touched.toB : -2e8 124 | function add(start: number, end = start) { 125 | from = Math.min(start, from); to = Math.max(end, to) 126 | } 127 | 128 | let rA = this.changes, rB = b.changes 129 | for (let iA = 0, iB = 0; iA < rA.length && iB < rB.length;) { 130 | let rangeA = rA[iA], rangeB = rB[iB] 131 | if (rangeA && rangeB && sameRanges(rangeA, rangeB, map)) { iA++; iB++ } 132 | else if (rangeB && (!rangeA || map(rangeA.fromB) >= rangeB.fromB)) { add(rangeB.fromB, rangeB.toB); iB++ } 133 | else { add(map(rangeA.fromB), map(rangeA.toB)); iA++ } 134 | } 135 | 136 | return from <= to ? {from, to} : null 137 | } 138 | 139 | /// Create a changeset with the given base object and configuration. 140 | /// 141 | /// The `combine` function is used to compare and combine metadata—it 142 | /// should return null when metadata isn't compatible, and a combined 143 | /// version for a merged range when it is. 144 | /// 145 | /// When given, a token encoder determines how document tokens are 146 | /// serialized and compared when diffing the content produced by 147 | /// changes. The default is to just compare nodes by name and text 148 | /// by character, ignoring marks and attributes. 149 | static create( 150 | doc: Node, 151 | combine: (dataA: Data, dataB: Data) => Data = (a, b) => a === b ? a : null as any, 152 | tokenEncoder: TokenEncoder = DefaultEncoder 153 | ) { 154 | return new ChangeSet({combine, doc, encoder: tokenEncoder}, []) 155 | } 156 | 157 | /// Exported for testing @internal 158 | static computeDiff = computeDiff 159 | } 160 | 161 | // Divide-and-conquer approach to merging a series of ranges. 162 | function mergeAll( 163 | ranges: readonly Change[], 164 | combine: (dA: Data, dB: Data) => Data, 165 | start = 0, end = ranges.length 166 | ): readonly Change[] { 167 | if (end == start + 1) return [ranges[start]] 168 | let mid = (start + end) >> 1 169 | return Change.merge(mergeAll(ranges, combine, start, mid), 170 | mergeAll(ranges, combine, mid, end), combine) 171 | } 172 | 173 | function endRange(maps: readonly StepMap[]) { 174 | let from = 2e8, to = -2e8 175 | for (let i = 0; i < maps.length; i++) { 176 | let map = maps[i] 177 | if (from != 2e8) { 178 | from = map.map(from, -1) 179 | to = map.map(to, 1) 180 | } 181 | map.forEach((_s, _e, start, end) => { 182 | from = Math.min(from, start) 183 | to = Math.max(to, end) 184 | }) 185 | } 186 | return from == 2e8 ? null : {from, to} 187 | } 188 | 189 | function touchedRange(maps: readonly StepMap[]) { 190 | let b = endRange(maps) 191 | if (!b) return null 192 | let a = endRange(maps.map(m => m.invert()).reverse())! 193 | return {fromA: a.from, toA: a.to, fromB: b.from, toB: b.to} 194 | } 195 | 196 | function sameRanges(a: Change, b: Change, map: (pos: number) => number) { 197 | return map(a.fromB) == b.fromB && map(a.toB) == b.toB && 198 | sameSpans(a.deleted, b.deleted) && sameSpans(a.inserted, b.inserted) 199 | } 200 | 201 | function sameSpans(a: readonly Span[], b: readonly Span[]) { 202 | if (a.length != b.length) return false 203 | for (let i = 0; i < a.length; i++) 204 | if (a[i].length != b[i].length || a[i].data !== b[i].data) return false 205 | return true 206 | } 207 | -------------------------------------------------------------------------------- /src/diff.ts: -------------------------------------------------------------------------------- 1 | import {Fragment, Node, NodeType, Mark} from "prosemirror-model" 2 | import {Change} from "./change" 3 | 4 | /// A token encoder can be passed when creating a `ChangeSet` in order 5 | /// to influence the way the library runs its diffing algorithm. The 6 | /// encoder determines how document tokens (such as nodes and 7 | /// characters) are encoded and compared. 8 | /// 9 | /// Note that both the encoding and the comparison may run a lot, and 10 | /// doing non-trivial work in these functions could impact 11 | /// performance. 12 | export interface TokenEncoder { 13 | /// Encode a given character, with the given marks applied. 14 | encodeCharacter(char: number, marks: readonly Mark[]): T 15 | /// Encode the start of a node or, if this is a leaf node, the 16 | /// entire node. 17 | encodeNodeStart(node: Node): T 18 | /// Encode the end token for the given node. It is valid to encode 19 | /// every end token in the same way. 20 | encodeNodeEnd(node: Node): T 21 | /// Compare the given tokens. Should return true when they count as 22 | /// equal. 23 | compareTokens(a: T, b: T): boolean 24 | } 25 | 26 | function typeID(type: NodeType) { 27 | let cache: Record = type.schema.cached.changeSetIDs || (type.schema.cached.changeSetIDs = Object.create(null)) 28 | let id = cache[type.name] 29 | if (id == null) cache[type.name] = id = Object.keys(type.schema.nodes).indexOf(type.name) + 1 30 | return id 31 | } 32 | 33 | // The default token encoder, which encodes node open tokens are 34 | // encoded as strings holding the node name, characters as their 35 | // character code, and node close tokens as negative numbers. 36 | export const DefaultEncoder: TokenEncoder = { 37 | encodeCharacter: char => char, 38 | encodeNodeStart: node => node.type.name, 39 | encodeNodeEnd: node => -typeID(node.type), 40 | compareTokens: (a, b) => a === b 41 | } 42 | 43 | // Convert the given range of a fragment to tokens. 44 | function tokens(frag: Fragment, encoder: TokenEncoder, start: number, end: number, target: T[]) { 45 | for (let i = 0, off = 0; i < frag.childCount; i++) { 46 | let child = frag.child(i), endOff = off + child.nodeSize 47 | let from = Math.max(off, start), to = Math.min(endOff, end) 48 | if (from < to) { 49 | if (child.isText) { 50 | for (let j = from; j < to; j++) target.push(encoder.encodeCharacter(child.text!.charCodeAt(j - off), child.marks)) 51 | } else if (child.isLeaf) { 52 | target.push(encoder.encodeNodeStart(child)) 53 | } else { 54 | if (from == off) target.push(encoder.encodeNodeStart(child)) 55 | tokens(child.content, encoder, Math.max(off + 1, from) - off - 1, Math.min(endOff - 1, to) - off - 1, target) 56 | if (to == endOff) target.push(encoder.encodeNodeEnd(child)) 57 | } 58 | } 59 | off = endOff 60 | } 61 | return target 62 | } 63 | 64 | // The code below will refuse to compute a diff with more than 5000 65 | // insertions or deletions, which takes about 300ms to reach on my 66 | // machine. This is a safeguard against runaway computations. 67 | const MAX_DIFF_SIZE = 5000 68 | 69 | // This obscure mess of constants computes the minimum length of an 70 | // unchanged range (not at the start/end of the compared content). The 71 | // idea is to make it higher in bigger replacements, so that you don't 72 | // get a diff soup of coincidentally identical letters when replacing 73 | // a paragraph. 74 | function minUnchanged(sizeA: number, sizeB: number) { 75 | return Math.min(15, Math.max(2, Math.floor(Math.max(sizeA, sizeB) / 10))) 76 | } 77 | 78 | export function computeDiff(fragA: Fragment, fragB: Fragment, range: Change, encoder: TokenEncoder = DefaultEncoder) { 79 | let tokA = tokens(fragA, encoder, range.fromA, range.toA, []) 80 | let tokB = tokens(fragB, encoder, range.fromB, range.toB, []) 81 | 82 | // Scan from both sides to cheaply eliminate work 83 | let start = 0, endA = tokA.length, endB = tokB.length 84 | let cmp = encoder.compareTokens 85 | while (start < tokA.length && start < tokB.length && cmp(tokA[start], tokB[start])) start++ 86 | if (start == tokA.length && start == tokB.length) return [] 87 | while (endA > start && endB > start && cmp(tokA[endA - 1], tokB[endB - 1])) endA--, endB-- 88 | // If the result is simple _or_ too big to cheaply compute, return 89 | // the remaining region as the diff 90 | if (endA == start || endB == start || (endA == endB && endA == start + 1)) 91 | return [range.slice(start, endA, start, endB)] 92 | 93 | // This is an implementation of Myers' diff algorithm 94 | // See https://neil.fraser.name/writing/diff/myers.pdf and 95 | // https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/ 96 | 97 | let lenA = endA - start, lenB = endB - start 98 | let max = Math.min(MAX_DIFF_SIZE, lenA + lenB), off = max + 1 99 | let history: number[][] = [] 100 | let frontier: number[] = [] 101 | for (let len = off * 2, i = 0; i < len; i++) frontier[i] = -1 102 | 103 | for (let size = 0; size <= max; size++) { 104 | for (let diag = -size; diag <= size; diag += 2) { 105 | let next = frontier[diag + 1 + max], prev = frontier[diag - 1 + max] 106 | let x = next < prev ? prev : next + 1, y = x + diag 107 | while (x < lenA && y < lenB && cmp(tokA[start + x], tokB[start + y])) x++, y++ 108 | frontier[diag + max] = x 109 | // Found a match 110 | if (x >= lenA && y >= lenB) { 111 | // Trace back through the history to build up a set of changed ranges. 112 | let diff = [], minSpan = minUnchanged(endA - start, endB - start) 113 | // Used to add steps to a diff one at a time, back to front, merging 114 | // ones that are less than minSpan tokens apart 115 | let fromA = -1, toA = -1, fromB = -1, toB = -1 116 | let add = (fA: number, tA: number, fB: number, tB: number) => { 117 | if (fromA > -1 && fromA < tA + minSpan) { 118 | fromA = fA; fromB = fB 119 | } else { 120 | if (fromA > -1) 121 | diff.push(range.slice(fromA, toA, fromB, toB)) 122 | fromA = fA; toA = tA 123 | fromB = fB; toB = tB 124 | } 125 | } 126 | 127 | for (let i = size - 1; i >= 0; i--) { 128 | let next = frontier[diag + 1 + max], prev = frontier[diag - 1 + max] 129 | if (next < prev) { // Deletion 130 | diag-- 131 | x = prev + start; y = x + diag 132 | add(x, x, y, y + 1) 133 | } else { // Insertion 134 | diag++ 135 | x = next + start; y = x + diag 136 | add(x, x + 1, y, y) 137 | } 138 | frontier = history[i >> 1] 139 | } 140 | if (fromA > -1) diff.push(range.slice(fromA, toA, fromB, toB)) 141 | return diff.reverse() 142 | } 143 | } 144 | // Since only either odd or even diagonals are read from each 145 | // frontier, we only copy them every other iteration. 146 | if (size % 2 == 0) history.push(frontier.slice()) 147 | } 148 | // The loop exited, meaning the maximum amount of work was done. 149 | // Just return a change spanning the entire range. 150 | return [range.slice(start, endA, start, endB)] 151 | } 152 | -------------------------------------------------------------------------------- /src/simplify.ts: -------------------------------------------------------------------------------- 1 | import {Fragment, Node} from "prosemirror-model" 2 | import {Span, Change} from "./change" 3 | 4 | let letter: RegExp | undefined 5 | // If the runtime support unicode properties in regexps, that's a good 6 | // source of info on whether something is a letter. 7 | try { letter = new RegExp("[\\p{Alphabetic}_]", "u") } catch(_) {} 8 | 9 | // Otherwise, we see if the character changes when upper/lowercased, 10 | // or if it is part of these common single-case scripts. 11 | const nonASCIISingleCaseWordChar = /[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/ 12 | 13 | function isLetter(code: number) { 14 | if (code < 128) 15 | return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 79 && code <= 122 16 | let ch = String.fromCharCode(code) 17 | if (letter) return letter.test(ch) 18 | return ch.toUpperCase() != ch.toLowerCase() || nonASCIISingleCaseWordChar.test(ch) 19 | } 20 | 21 | // Convert a range of document into a string, so that we can easily 22 | // access characters at a given position. Treat non-text tokens as 23 | // spaces so that they aren't considered part of a word. 24 | function getText(frag: Fragment, start: number, end: number) { 25 | let out = "" 26 | function convert(frag: Fragment, start: number, end: number) { 27 | for (let i = 0, off = 0; i < frag.childCount; i++) { 28 | let child = frag.child(i), endOff = off + child.nodeSize 29 | let from = Math.max(off, start), to = Math.min(endOff, end) 30 | if (from < to) { 31 | if (child.isText) { 32 | out += child.text!.slice(Math.max(0, start - off), Math.min(child.text!.length, end - off)) 33 | } else if (child.isLeaf) { 34 | out += " " 35 | } else { 36 | if (from == off) out += " " 37 | convert(child.content, Math.max(0, from - off - 1), Math.min(child.content.size, end - off)) 38 | if (to == endOff) out += " " 39 | } 40 | } 41 | off = endOff 42 | } 43 | } 44 | convert(frag, start, end) 45 | return out 46 | } 47 | 48 | // The distance changes have to be apart for us to not consider them 49 | // candidates for merging. 50 | const MAX_SIMPLIFY_DISTANCE = 30 51 | 52 | /// Simplifies a set of changes for presentation. This makes the 53 | /// assumption that having both insertions and deletions within a word 54 | /// is confusing, and, when such changes occur without a word boundary 55 | /// between them, they should be expanded to cover the entire set of 56 | /// words (in the new document) they touch. An exception is made for 57 | /// single-character replacements. 58 | export function simplifyChanges(changes: readonly Change[], doc: Node) { 59 | let result: Change[] = [] 60 | for (let i = 0; i < changes.length; i++) { 61 | let end = changes[i].toB, start = i 62 | while (i < changes.length - 1 && changes[i + 1].fromB <= end + MAX_SIMPLIFY_DISTANCE) 63 | end = changes[++i].toB 64 | simplifyAdjacentChanges(changes, start, i + 1, doc, result) 65 | } 66 | return result 67 | } 68 | 69 | function simplifyAdjacentChanges(changes: readonly Change[], from: number, to: number, doc: Node, target: Change[]) { 70 | let start = Math.max(0, changes[from].fromB - MAX_SIMPLIFY_DISTANCE) 71 | let end = Math.min(doc.content.size, changes[to - 1].toB + MAX_SIMPLIFY_DISTANCE) 72 | let text = getText(doc.content, start, end) 73 | 74 | for (let i = from; i < to; i++) { 75 | let startI = i, last = changes[i], deleted = last.lenA, inserted = last.lenB 76 | while (i < to - 1) { 77 | let next = changes[i + 1], boundary = false 78 | let prevLetter = last.toB == end ? false : isLetter(text.charCodeAt(last.toB - 1 - start)) 79 | for (let pos = last.toB; !boundary && pos < next.fromB; pos++) { 80 | let nextLetter = pos == end ? false : isLetter(text.charCodeAt(pos - start)) 81 | if ((!prevLetter || !nextLetter) && pos != changes[startI].fromB) boundary = true 82 | prevLetter = nextLetter 83 | } 84 | if (boundary) break 85 | deleted += next.lenA; inserted += next.lenB 86 | last = next 87 | i++ 88 | } 89 | 90 | if (inserted > 0 && deleted > 0 && !(inserted == 1 && deleted == 1)) { 91 | let from = changes[startI].fromB, to = changes[i].toB 92 | if (from < end && isLetter(text.charCodeAt(from - start))) 93 | while (from > start && isLetter(text.charCodeAt(from - 1 - start))) from-- 94 | if (to > start && isLetter(text.charCodeAt(to - 1 - start))) 95 | while (to < end && isLetter(text.charCodeAt(to - start))) to++ 96 | let joined = fillChange(changes.slice(startI, i + 1), from, to) 97 | let last = target.length ? target[target.length - 1] : null 98 | if (last && last.toA == joined.fromA) 99 | target[target.length - 1] = new Change(last.fromA, joined.toA, last.fromB, joined.toB, 100 | last.deleted.concat(joined.deleted), last.inserted.concat(joined.inserted)) 101 | else 102 | target.push(joined) 103 | } else { 104 | for (let j = startI; j <= i; j++) target.push(changes[j]) 105 | } 106 | } 107 | return changes 108 | } 109 | 110 | function combine(a: T, b: T): T { return a === b ? a : null as any } 111 | 112 | function fillChange(changes: readonly Change[], fromB: number, toB: number) { 113 | let fromA = changes[0].fromA - (changes[0].fromB - fromB) 114 | let last = changes[changes.length - 1] 115 | let toA = last.toA + (toB - last.toB) 116 | let deleted = Span.none, inserted = Span.none 117 | let delData = (changes[0].deleted.length ? changes[0].deleted : changes[0].inserted)[0].data 118 | let insData = (changes[0].inserted.length ? changes[0].inserted : changes[0].deleted)[0].data 119 | for (let posA = fromA, posB = fromB, i = 0;; i++) { 120 | let next = i == changes.length ? null : changes[i] 121 | let endA = next ? next.fromA : toA, endB = next ? next.fromB : toB 122 | if (endA > posA) deleted = Span.join(deleted, [new Span(endA - posA, delData)], combine) 123 | if (endB > posB) inserted = Span.join(inserted, [new Span(endB - posB, insData)], combine) 124 | if (!next) break 125 | deleted = Span.join(deleted, next.deleted, combine) 126 | inserted = Span.join(inserted, next.inserted, combine) 127 | if (deleted.length) delData = deleted[deleted.length - 1].data 128 | if (inserted.length) insData = inserted[inserted.length - 1].data 129 | posA = next.toA; posB = next.toB 130 | } 131 | return new Change(fromA, toA, fromB, toB, deleted, inserted) 132 | } 133 | -------------------------------------------------------------------------------- /test/test-changed-range.ts: -------------------------------------------------------------------------------- 1 | import ist from "ist" 2 | import {schema, doc, p} from "prosemirror-test-builder" 3 | import {Transform} from "prosemirror-transform" 4 | import {Node} from "prosemirror-model" 5 | 6 | import {ChangeSet} from "prosemirror-changeset" 7 | 8 | function mk(doc: Node, change: (tr: Transform) => Transform): {doc0: Node, tr: Transform, data: any[], set0: ChangeSet, set: ChangeSet} { 9 | let tr = change(new Transform(doc)) 10 | let data = new Array(tr.steps.length).fill("a") 11 | let set0 = ChangeSet.create(doc) 12 | return {doc0: doc, tr, data, set0, 13 | set: set0.addSteps(tr.doc, tr.mapping.maps, data)} 14 | } 15 | 16 | function same(a: any, b: any) { 17 | ist(JSON.stringify(a), JSON.stringify(b)) 18 | } 19 | 20 | describe("ChangeSet.changedRange", () => { 21 | it("returns null for identical sets", () => { 22 | let {set, doc0, tr, data} = mk(doc(p("foo")), tr => tr 23 | .replaceWith(2, 3, schema.text("aaaa")) 24 | .replaceWith(1, 1, schema.text("xx")) 25 | .delete(5, 7)) 26 | ist(set.changedRange(set), null) 27 | ist(set.changedRange(ChangeSet.create(doc0).addSteps(tr.doc, tr.mapping.maps, data)), null) 28 | }) 29 | 30 | it("returns only the changed range in simple cases", () => { 31 | let {set0, set, tr} = mk(doc(p("abcd")), tr => tr.replaceWith(2, 4, schema.text("u"))) 32 | same(set0.changedRange(set, tr.mapping.maps), {from: 2, to: 3}) 33 | }) 34 | 35 | it("expands to cover updated spans", () => { 36 | let {doc0, set0, set, tr} = mk(doc(p("abcd")), tr => tr 37 | .replaceWith(2, 2, schema.text("c")) 38 | .delete(3, 5)) 39 | let set1 = ChangeSet.create(doc0).addSteps(tr.docs[1], [tr.mapping.maps[0]], ["a"]) 40 | same(set0.changedRange(set1, [tr.mapping.maps[0]]), {from: 2, to: 3}) 41 | same(set1.changedRange(set, [tr.mapping.maps[1]]), {from: 2, to: 3}) 42 | }) 43 | 44 | it("detects changes in deletions", () => { 45 | let {set} = mk(doc(p("abc")), tr => tr.delete(2, 3)) 46 | same(set.changedRange(set.map(() => "b")), {from: 2, to: 2}) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /test/test-changes.ts: -------------------------------------------------------------------------------- 1 | import ist from "ist" 2 | import {schema, doc, p, blockquote, h1} from "prosemirror-test-builder" 3 | import {Transform} from "prosemirror-transform" 4 | import {Node} from "prosemirror-model" 5 | 6 | import {ChangeSet} from "prosemirror-changeset" 7 | 8 | describe("ChangeSet", () => { 9 | it("finds a single insertion", 10 | find(doc(p("hello")), tr => tr.insert(3, t("XY")), [[3, 3, 3, 5]])) 11 | 12 | it("finds a single deletion", 13 | find(doc(p("hello")), tr => tr.delete(3, 5), [[3, 5, 3, 3]])) 14 | 15 | it("identifies a replacement", 16 | find(doc(p("hello")), tr => tr.replaceWith(3, 5, t("juj")), 17 | [[3, 5, 3, 6]])) 18 | 19 | it("merges adjacent canceling edits", 20 | find(doc(p("hello")), 21 | tr => tr.delete(3, 5).insert(3, t("ll")), 22 | [])) 23 | 24 | it("doesn't crash when cancelling edits are followed by others", 25 | find(doc(p("hello")), 26 | tr => tr.delete(2, 3).insert(2, t("e")).delete(5, 6), 27 | [[5, 6, 5, 5]])) 28 | 29 | it("stops handling an inserted span after collapsing it", 30 | find(doc(p("abcba")), tr => tr.insert(2, t("b")).insert(6, t("b")).delete(3, 6), 31 | [[3, 4, 3, 3]])) 32 | 33 | it("partially merges insert at start", 34 | find(doc(p("helLo")), tr => tr.delete(3, 5).insert(3, t("l")), 35 | [[4, 5, 4, 4]])) 36 | 37 | it("partially merges insert at end", 38 | find(doc(p("helLo")), tr => tr.delete(3, 5).insert(3, t("L")), 39 | [[3, 4, 3, 3]])) 40 | 41 | it("partially merges delete at start", 42 | find(doc(p("abc")), tr => tr.insert(3, t("xyz")).delete(3, 4), 43 | [[3, 3, 3, 5]])) 44 | 45 | it("partially merges delete at end", 46 | find(doc(p("abc")), tr => tr.insert(3, t("xyz")).delete(5, 6), 47 | [[3, 3, 3, 5]])) 48 | 49 | it("finds multiple insertions", 50 | find(doc(p("abc")), tr => tr.insert(1, t("x")).insert(5, t("y")), 51 | [[1, 1, 1, 2], [4, 4, 5, 6]])) 52 | 53 | it("finds multiple deletions", 54 | find(doc(p("xyz")), tr => tr.delete(1, 2).delete(2, 3), 55 | [[1, 2, 1, 1], [3, 4, 2, 2]])) 56 | 57 | it("identifies a deletion between insertions", 58 | find(doc(p("zyz")), tr => tr.insert(2, t("A")).insert(4, t("B")).delete(3, 4), 59 | [[2, 3, 2, 4]])) 60 | 61 | it("can add a deletion in a new addStep call", find(doc(p("hello")), [ 62 | tr => tr.delete(1, 2), 63 | tr => tr.delete(2, 3) 64 | ], [[1, 2, 1, 1], [3, 4, 2, 2]])) 65 | 66 | it("merges delete/insert from different addStep calls", find(doc(p("hello")), [ 67 | tr => tr.delete(3, 5), 68 | tr => tr.insert(3, t("ll")) 69 | ], [])) 70 | 71 | it("revert a deletion by inserting the character again", find(doc(p("bar")), [ 72 | tr => tr.delete(2, 3), // br 73 | tr => tr.insert(2, t("x")), // bxr 74 | tr => tr.insert(2, t("a")) // baxr 75 | ], [[3, 3, 3, 4]])) 76 | 77 | it("insert character before changed character", find(doc(p("bar")), [ 78 | tr => tr.delete(2, 3), // br 79 | tr => tr.insert(2, t("x")), // bxr 80 | tr => tr.insert(2, t("x")) // bxxr 81 | ], [[2, 3, 2, 4]])) 82 | 83 | it("partially merges delete/insert from different addStep calls", find(doc(p("heljo")), [ 84 | tr => tr.delete(3, 5), 85 | tr => tr.insert(3, t("ll")) 86 | ], [[4, 5, 4, 5]])) 87 | 88 | it("merges insert/delete from different addStep calls", find(doc(p("ok")), [ 89 | tr => tr.insert(2, t("--")), 90 | tr => tr.delete(2, 4) 91 | ], [])) 92 | 93 | it("partially merges insert/delete from different addStep calls", find(doc(p("ok")), [ 94 | tr => tr.insert(2, t("--")), 95 | tr => tr.delete(2, 3) 96 | ], [[2, 2, 2, 3]])) 97 | 98 | it("maps deletions forward", find(doc(p("foobar")), [ 99 | tr => tr.delete(5, 6), 100 | tr => tr.insert(1, t("OKAY")) 101 | ], [[1, 1, 1, 5], [5, 6, 9, 9]])) 102 | 103 | it("can incrementally undo then redo", find(doc(p("bar")), [ 104 | tr => tr.delete(2, 3), 105 | tr => tr.insert(2, t("a")), 106 | tr => tr.delete(2, 3) 107 | ], [[2, 3, 2, 2]])) 108 | 109 | it("can map through complicated changesets", find(doc(p("12345678901234")), [ 110 | tr => tr.delete(9, 12).insert(6, t("xyz")).replaceWith(2, 3, t("uv")), 111 | tr => tr.delete(14, 15).insert(13, t("90")).delete(8, 9) 112 | ], [[2, 3, 2, 4], [6, 6, 7, 9], [11, 12, 14, 14], [13, 14, 15, 15]])) 113 | 114 | it("computes a proper diff of the changes", 115 | find(doc(p("abcd"), p("efgh")), tr => tr.delete(2, 10).insert(2, t("cdef")), 116 | [[2, 3, 2, 2], [5, 7, 4, 4], [9, 10, 6, 6]])) 117 | 118 | it("handles re-adding content step by step", find(doc(p("one two three")), [ 119 | tr => tr.delete(1, 14), 120 | tr => tr.insert(1, t("two")), 121 | tr => tr.insert(4, t(" ")), 122 | tr => tr.insert(5, t("three")) 123 | ], [[1, 5, 1, 1]])) 124 | 125 | it("doesn't get confused by split deletions", find(doc(blockquote(h1("one"), p("two four"))), [ 126 | tr => tr.delete(7, 11), 127 | tr => tr.replaceWith(0, 13, blockquote(h1("one"), p("four"))) 128 | ], [[7, 11, 7, 7, [[4, 0]], []]], true)) 129 | 130 | it("doesn't get confused by multiply split deletions", find(doc(blockquote(h1("one"), p("two three"))), [ 131 | tr => tr.delete(14, 16), 132 | tr => tr.delete(7, 11), 133 | tr => tr.delete(3, 5), 134 | tr => tr.replaceWith(0, 10, blockquote(h1("o"), p("thr"))) 135 | ], [[3, 5, 3, 3, [[2, 2]], []], [8, 12, 6, 6, [[3, 1], [1, 3]], []], 136 | [14, 16, 8, 8, [[2, 0]], []]], true)) 137 | 138 | it("won't lose the order of overlapping changes", find(doc(p("12345")), [ 139 | tr => tr.delete(4, 5), 140 | tr => tr.replaceWith(2, 2, t("a")), 141 | tr => tr.delete(1, 6), 142 | tr => tr.replaceWith(1, 1, t("1a235")) 143 | ], [[2, 2, 2, 3, [], [[1, 1]]], [4, 5, 5, 5, [[1, 0]], []]], [0, 0, 1, 1])) 144 | 145 | it("properly maps deleted positions", find(doc(p("jTKqvPrzApX")), [ 146 | tr => tr.delete(8, 11), 147 | tr => tr.replaceWith(1, 1, t("MPu")), 148 | tr => tr.delete(2, 12), 149 | tr => tr.replaceWith(2, 2, t("PujTKqvPrX")) 150 | ], [[1, 1, 1, 4, [], [[3, 2]]], [8, 11, 11, 11, [[3, 1]], []]], [1, 2, 2, 2])) 151 | 152 | it("fuzz issue 1", find(doc(p("hzwiKqBPzn")), [ 153 | tr => tr.delete(3, 7), 154 | tr => tr.replaceWith(5, 5, t("LH")), 155 | tr => tr.replaceWith(6, 6, t("uE")), 156 | tr => tr.delete(1, 6), 157 | tr => tr.delete(3, 6) 158 | ], [[1, 11, 1, 3, [[2, 1], [4, 0], [2, 1], [2, 0]], [[2, 0]]]], [0, 1, 0, 1, 0])) 159 | 160 | it("fuzz issue 2", find(doc(p("eAMISWgauf")), [ 161 | tr => tr.delete(5, 10), 162 | tr => tr.replaceWith(5, 5, t("KkM")), 163 | tr => tr.replaceWith(3, 3, t("UDO")), 164 | tr => tr.delete(1, 12), 165 | tr => tr.replaceWith(1, 1, t("eAUDOMIKkMf")), 166 | tr => tr.delete(5, 8), 167 | tr => tr.replaceWith(3, 3, t("qX")) 168 | ], [[3, 10, 3, 10, [[2, 0], [5, 2]], [[7, 0]]]], [2, 0, 0, 0, 0, 0, 0])) 169 | 170 | it("fuzz issue 3", find(doc(p("hfxjahnOuH")), [ 171 | tr => tr.delete(1, 5), 172 | tr => tr.replaceWith(3, 3, t("X")), 173 | tr => tr.delete(1, 8), 174 | tr => tr.replaceWith(1, 1, t("ahXnOuH")), 175 | tr => tr.delete(2, 4), 176 | tr => tr.replaceWith(2, 2, t("tn")), 177 | tr => tr.delete(5, 7), 178 | tr => tr.delete(1, 6), 179 | tr => tr.replaceWith(1, 1, t("atnnH")), 180 | tr => tr.delete(2, 6) 181 | ], [[1, 11, 1, 2, [[4, 1], [1, 0], [1, 1], [1, 0], [2, 1], [1, 0]], [[1, 0]]]], [1, 0, 1, 1, 1, 1, 1, 0, 0, 0])) 182 | 183 | it("correctly handles steps with multiple map entries", find(doc(p()), [ 184 | tr => tr.replaceWith(1, 1, t("ab")), 185 | tr => tr.wrap(tr.doc.resolve(1).blockRange()!, [{type: schema.nodes.blockquote}]) 186 | ], [[0, 0, 0, 1], [1, 1, 2, 4], [2, 2, 5, 6]])) 187 | }) 188 | 189 | function find(doc: Node, build: ((tr: Transform) => void) | ((tr: Transform) => void)[], 190 | changes: any[], sep?: number[] | boolean) { 191 | return () => { 192 | let set = ChangeSet.create(doc), curDoc = doc 193 | if (!Array.isArray(build)) build = [build] 194 | build.forEach((build, i) => { 195 | let tr = new Transform(curDoc) 196 | build(tr) 197 | set = set.addSteps(tr.doc, tr.mapping.maps, !sep ? 0 : Array.isArray(sep) ? sep[i] : i) 198 | curDoc = tr.doc 199 | }) 200 | 201 | let owner = sep && changes.length && changes[0].length > 4 202 | ist(JSON.stringify(set.changes.map(ch => { 203 | let range: any[] = [ch.fromA, ch.toA, ch.fromB, ch.toB] 204 | if (owner) range.push(ch.deleted.map(d => [d.length, d.data]), 205 | ch.inserted.map(d => [d.length, d.data])) 206 | return range 207 | })), JSON.stringify(changes)) 208 | } 209 | } 210 | 211 | function t(str: string) { return schema.text(str) } 212 | -------------------------------------------------------------------------------- /test/test-diff.ts: -------------------------------------------------------------------------------- 1 | import ist from "ist" 2 | import {doc, p, em, strong, h1, h2} from "prosemirror-test-builder" 3 | import {Node} from "prosemirror-model" 4 | import {Span, Change, ChangeSet} from "prosemirror-changeset" 5 | const {computeDiff} = ChangeSet 6 | 7 | describe("computeDiff", () => { 8 | function test(doc1: Node, doc2: Node, ...ranges: number[][]) { 9 | let diff = computeDiff(doc1.content, doc2.content, 10 | new Change(0, doc1.content.size, 0, doc2.content.size, 11 | [new Span(doc1.content.size, 0)], 12 | [new Span(doc2.content.size, 0)])) 13 | ist(JSON.stringify(diff.map(r => [r.fromA, r.toA, r.fromB, r.toB])), JSON.stringify(ranges)) 14 | } 15 | 16 | it("returns an empty diff for identical documents", () => 17 | test(doc(p("foo"), p("bar")), doc(p("foo"), p("bar")))) 18 | 19 | it("finds single-letter changes", () => 20 | test(doc(p("foo"), p("bar")), doc(p("foa"), p("bar")), 21 | [3, 4, 3, 4])) 22 | 23 | it("finds simple structure changes", () => 24 | test(doc(p("foo"), p("bar")), doc(p("foobar")), 25 | [4, 6, 4, 4])) 26 | 27 | it("finds multiple changes", () => 28 | test(doc(p("foo"), p("---bar")), doc(p("fgo"), p("---bur")), 29 | [2, 4, 2, 4], [10, 11, 10, 11])) 30 | 31 | it("ignores single-letter unchanged parts", () => 32 | test(doc(p("abcdef")), doc(p("axydzf")), [2, 6, 2, 6])) 33 | 34 | it("ignores matching substrings in longer diffs", () => 35 | test(doc(p("One two three")), doc(p("One"), p("And another long paragraph that has wo and ee in it")), 36 | [4, 14, 4, 57])) 37 | 38 | it("finds deletions", () => 39 | test(doc(p("abc"), p("def")), doc(p("ac"), p("d")), 40 | [2, 3, 2, 2], [7, 9, 6, 6])) 41 | 42 | it("ignores marks", () => 43 | test(doc(p("abc")), doc(p(em("a"), strong("bc"))))) 44 | 45 | it("ignores marks in diffing", () => 46 | test(doc(p("abcdefghi")), doc(p(em("x"), strong("bc"), "defgh", em("y"))), 47 | [1, 2, 1, 2], [9, 10, 9, 10])) 48 | 49 | it("ignores attributes", () => 50 | test(doc(h1("x")), doc(h2("x")))) 51 | 52 | it("finds huge deletions", () => { 53 | let xs = "x".repeat(200), bs = "b".repeat(20) 54 | test(doc(p("a" + bs + "c")), doc(p("a" + xs + bs + xs + "c")), 55 | [2, 2, 2, 202], [22, 22, 222, 422]) 56 | }) 57 | 58 | it("finds huge insertions", () => { 59 | let xs = "x".repeat(200), bs = "b".repeat(20) 60 | test(doc(p("a" + xs + bs + xs + "c")), doc(p("a" + bs + "c")), 61 | [2, 202, 2, 2], [222, 422, 22, 22]) 62 | }) 63 | 64 | it("can handle ambiguous diffs", () => 65 | test(doc(p("abcbcd")), doc(p("abcd")), [4, 6, 4, 4])) 66 | 67 | it("sees the difference between different closing tokens", () => 68 | test(doc(p("a")), doc(h1("oo")), [0, 3, 0, 4])) 69 | }) 70 | -------------------------------------------------------------------------------- /test/test-merge.ts: -------------------------------------------------------------------------------- 1 | import ist from "ist" 2 | import {Change, Span} from "prosemirror-changeset" 3 | 4 | describe("mergeChanges", () => { 5 | it("can merge simple insertions", () => test( 6 | [[1, 1, 1, 2]], [[1, 1, 1, 2]], [[1, 1, 1, 3]] 7 | )) 8 | 9 | it("can merge simple deletions", () => test( 10 | [[1, 2, 1, 1]], [[1, 2, 1, 1]], [[1, 3, 1, 1]] 11 | )) 12 | 13 | it("can merge insertion before deletion", () => test( 14 | [[2, 3, 2, 2]], [[1, 1, 1, 2]], [[1, 1, 1, 2], [2, 3, 3, 3]] 15 | )) 16 | 17 | it("can merge insertion after deletion", () => test( 18 | [[2, 3, 2, 2]], [[2, 2, 2, 3]], [[2, 3, 2, 3]] 19 | )) 20 | 21 | it("can merge deletion before insertion", () => test( 22 | [[2, 2, 2, 3]], [[1, 2, 1, 1]], [[1, 2, 1, 2]] 23 | )) 24 | 25 | it("can merge deletion after insertion", () => test( 26 | [[2, 2, 2, 3]], [[3, 4, 3, 3]], [[2, 3, 2, 3]] 27 | )) 28 | 29 | it("can merge deletion of insertion", () => test( 30 | [[2, 2, 2, 3]], [[2, 3, 2, 2]], [] 31 | )) 32 | 33 | it("can merge insertion after replace", () => test( 34 | [[2, 3, 2, 3]], [[3, 3, 3, 4]], [[2, 3, 2, 4]] 35 | )) 36 | 37 | it("can merge insertion before replace", () => test( 38 | [[2, 3, 2, 3]], [[2, 2, 2, 3]], [[2, 3, 2, 4]] 39 | )) 40 | 41 | it("can merge replace after insert", () => test( 42 | [[2, 2, 2, 3]], [[2, 3, 2, 3]], [[2, 2, 2, 3]] 43 | )) 44 | }) 45 | 46 | function range(array: number[], author = 0) { 47 | let [fromA, toA] = array 48 | let [fromB, toB] = array.length > 2 ? array.slice(2) : array 49 | return new Change(fromA, toA, fromB, toB, [new Span(toA - fromA, author)], [new Span(toB - fromB, author)]) 50 | } 51 | 52 | function test(changeA: number[][], changeB: number[][], expected: number[][]) { 53 | const result = Change.merge(changeA.map(range), changeB.map(range), a => a) 54 | .map(r => [r.fromA, r.toA, r.fromB, r.toB]) 55 | ist(JSON.stringify(result), JSON.stringify(expected)) 56 | } 57 | -------------------------------------------------------------------------------- /test/test-simplify.ts: -------------------------------------------------------------------------------- 1 | import ist from "ist" 2 | import {doc, p, img} from "prosemirror-test-builder" 3 | import {Node} from "prosemirror-model" 4 | import {simplifyChanges, Change, Span} from "prosemirror-changeset" 5 | 6 | describe("simplifyChanges", () => { 7 | it("doesn't change insertion-only changes", () => test( 8 | [[1, 1, 1, 2], [2, 2, 3, 4]], doc(p("hello")), [[1, 1, 1, 2], [2, 2, 3, 4]])) 9 | 10 | it("doesn't change deletion-only changes", () => test( 11 | [[1, 2, 1, 1], [3, 4, 2, 2]], doc(p("hello")), [[1, 2, 1, 1], [3, 4, 2, 2]])) 12 | 13 | it("doesn't change single-letter-replacements", () => test( 14 | [[1, 2, 1, 2]], doc(p("hello")), [[1, 2, 1, 2]])) 15 | 16 | it("does expand multiple-letter replacements", () => test( 17 | [[2, 4, 2, 4]], doc(p("hello")), [[1, 6, 1, 6]])) 18 | 19 | it("does combine changes within the same word", () => test( 20 | [[1, 3, 1, 1], [5, 5, 3, 4]], doc(p("hello")), [[1, 7, 1, 6]])) 21 | 22 | it("expands changes to cover full words", () => test( 23 | [[7, 10]], doc(p("one two three four")), [[5, 14]])) 24 | 25 | it("doesn't expand across non-word text", () => test( 26 | [[7, 10]], doc(p("one two ----- four")), [[5, 10]])) 27 | 28 | it("treats leaf nodes as non-words", () => test( 29 | [[2, 3], [6, 7]], doc(p("one", img(), "two")), [[2, 3], [6, 7]])) 30 | 31 | it("treats node boundaries as non-words", () => test( 32 | [[2, 3], [7, 8]], doc(p("one"), p("two")), [[2, 3], [7, 8]])) 33 | 34 | it("can merge stretches of changes", () => test( 35 | [[2, 3], [4, 6], [8, 10], [15, 16]], doc(p("foo bar baz bug ugh")), [[1, 12], [15, 16]])) 36 | 37 | it("handles realistic word updates", () => test( 38 | [[8, 8, 8, 11], [10, 15, 13, 17]], doc(p("chonic condition")), [[8, 15, 8, 17]])) 39 | 40 | it("works when after significant content", () => test( 41 | [[63, 80, 63, 83]], doc(p("one long paragraph -----"), p("two long paragraphs ------"), p("a vote against the government")), 42 | [[62, 81, 62, 84]])) 43 | 44 | it("joins changes that grow together when simplifying", () => test( 45 | [[1, 5, 1, 5], [7, 13, 7, 9], [20, 21, 16, 16]], doc(p('and his co-star')), 46 | [[1, 13, 1, 9], [20, 21, 16, 16]])) 47 | 48 | it("properly fills in metadata", () => { 49 | let simple = simplifyChanges([range([2, 3], 0), range([4, 6], 1), range([8, 9, 8, 8], 2)], 50 | doc(p("1234567890"))) 51 | ist(simple.length, 1) 52 | ist(JSON.stringify(simple[0].deleted.map(s => [s.length, s.data])), 53 | JSON.stringify([[3, 0], [4, 1], [4, 2]])) 54 | ist(JSON.stringify(simple[0].inserted.map(s => [s.length, s.data])), 55 | JSON.stringify([[3, 0], [4, 1], [3, 2]])) 56 | }) 57 | }) 58 | 59 | function range(array: number[], author = 0) { 60 | let [fromA, toA] = array 61 | let [fromB, toB] = array.length > 2 ? array.slice(2) : array 62 | return new Change(fromA, toA, fromB, toB, [new Span(toA - fromA, author)], [new Span(toB - fromB, author)]) 63 | } 64 | 65 | function test(changes: number[][], doc: Node, result: number[][]) { 66 | let ranges = changes.map(range) 67 | ist(JSON.stringify(simplifyChanges(ranges, doc).map((r, i) => { 68 | if (result[i] && result[i].length > 2) return [r.fromA, r.toA, r.fromB, r.toB] 69 | else return [r.fromB, r.toB] 70 | })), JSON.stringify(result)) 71 | } 72 | --------------------------------------------------------------------------------