Repository navigation
✨ Add diff to compute an op between two docs
#18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e503823
f9a0ed3
df03cf6
61d00d0
15e3b76
31c4857
157307f
23a1a11
9773328
0cbb209
347957f
f6598e0
5bdad1b
32b4045
853e6fa
6601e55
4594e75
e3d18c5
c6ed1ac
73b0043
e9f5773
b42b4a9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,14 @@ | ||
| import * as sharedb from 'sharedb'; | ||
| type Type = (typeof sharedb)['types']['map'][string]; | ||
| export const type: Type; | ||
|
|
||
| // Contract a registered subtype implements so json0's diff can delegate to it | ||
| // for embedded values of that subtype (eg rich-text). isOfType lets diff recognise | ||
| // such a value by shape, since a raw value carries no type tag. | ||
| export interface DiffableSubtype { | ||
| diff(before: any, after: any): any; | ||
| isOfType(value: any): boolean; | ||
| } | ||
|
|
||
| export const type: Type & { | ||
| diff(before: any, after: any): sharedb.Op[]; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| var deepEqual = require('fast-deep-equal'); | ||
| var arrayDiff = require('arraydiff'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any reason we used that package that hasn't been updated for last 11 years, there is some other one updated 3 years ago https://www.npmjs.com/package/fast-array-diff
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea there is actually an explanation of this (and more additional approaches considered for diffing arrays) in the PR description. Basically the one you linked is lacking an equivalent of
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For further context, that library is also the one that's used in |
||
|
|
||
| /* | ||
| This is the implementation of the JSON OT type. | ||
|
|
@@ -395,6 +396,149 @@ json.compose = function(op1,op2) { | |
| return newOp; | ||
| }; | ||
|
|
||
| // Produce an op that transforms `before` into `after`. | ||
| json.diff = function(before, after) { | ||
|
dodoarg marked this conversation as resolved.
Resolved
|
||
| return diffValue([], before, after); | ||
| }; | ||
|
|
||
| function diffValue(path, before, after) { | ||
| if (deepEqual(before, after)) return []; | ||
|
|
||
| for (var name in subtypes) { | ||
| if (isTypeDiffable(subtypes[name], before, after)) { | ||
| var op = subtypes[name].diff(before, after); | ||
| return op && op.length ? [{p: path, t: name, o: op}] : [] | ||
| } | ||
| } | ||
|
|
||
| if (isArray(before) && isArray(after)) { | ||
| return diffArray(path, before, after); | ||
| } | ||
|
|
||
| if (isObject(before) && isObject(after)) { | ||
| return diffObject(path, before, after); | ||
| } | ||
|
|
||
| return [{p: path, od: before, oi: after}]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I wonder if we should utilise them, but maybe it is safer to just do what you did which is
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting point. About
While the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That are very good points! |
||
| } | ||
|
|
||
| // A subtype can diff two values only if it can both diff and recognise them as its own documents. | ||
| function isTypeDiffable(subtype, before, after) { | ||
| return typeof subtype.diff === 'function' && | ||
| typeof subtype.isOfType === 'function' && | ||
| subtype.isOfType(before) && | ||
| subtype.isOfType(after); | ||
| } | ||
|
|
||
| // Map arrayDiff's batched diffs to json0 list ops by replaying them against a | ||
| // working copy: json0's ld/li/lm mirror those splices, so the result round-trips. | ||
| // A block move expands to one lm per element, keeping each element's identity. | ||
| function diffArray(path, before, after) { | ||
| var work = before.slice(); | ||
| var op = []; | ||
|
|
||
| detectSubstitutions(arrayDiff(before, after, deepEqual)).forEach(function(diff) { | ||
| switch (diff.type) { | ||
| case 'substitute': return diffSubstitution(op, work, path, diff); | ||
| case 'remove': return diffRemoval(op, work, path, diff); | ||
| case 'insert': return diffInsertion(op, work, path, diff); | ||
| case 'move': return diffMove(op, work, path, diff); | ||
| } | ||
| }); | ||
|
|
||
| return op; | ||
| } | ||
|
|
||
| function diffSubstitution(op, work, path, diff) { | ||
| var current = work[diff.index]; | ||
| // Recurse only when both elements are the same kind of container, so diffValue descends deeper into | ||
| // the path. Anything else (a primitive, or an object-to-array switch) has no shared shape to diff, so | ||
| // it stays a whole-element ld + li - a bare od/oi at a list index is invalid anyway. | ||
| if (sameContainerKind(current, diff.value)) { | ||
| op.push(...diffValue(path.concat(diff.index), current, diff.value)); | ||
| } else { | ||
| op.push({p: path.concat(diff.index), ld: current}); | ||
| op.push({p: path.concat(diff.index), li: diff.value}); | ||
| } | ||
| work[diff.index] = diff.value; | ||
| } | ||
|
|
||
| function diffRemoval(op, work, path, diff) { | ||
| for (let i = 0; i < diff.howMany; i++) { | ||
| op.push({p: path.concat(diff.index), ld: work[diff.index]}); | ||
| work.splice(diff.index, 1); | ||
| } | ||
| } | ||
|
|
||
| function diffInsertion(op, work, path, diff) { | ||
| for (let i = 0; i < diff.values.length; i++) { | ||
| op.push({p: path.concat(diff.index + i), li: diff.values[i]}); | ||
| work.splice(diff.index + i, 0, diff.values[i]); | ||
| } | ||
| } | ||
|
|
||
| function diffMove(op, work, path, diff) { | ||
| // A move relocates a run of `howMany` elements from base index `from` to `to`. json0 has no block | ||
| // move, so we emit one `lm` per element - but the same reshape can be expressed two ways: slide the | ||
| // run to its destination (`howMany` lms), or slide the `from - to` displaced elements the other way | ||
| // past it (that many lms). Pick whichever needs fewer. | ||
| if (diff.howMany <= diff.from - diff.to) { | ||
| // Move the run: element-by-element from `from` to `to`. | ||
| for (let i = 0; i < diff.howMany; i++) | ||
| op.push({p: path.concat(diff.from + i), lm: diff.to + i}); | ||
| } else { | ||
| // Move the displaced elements instead: each is lifted from `to` and dropped past the run. | ||
| for (let i = 0; i < diff.from - diff.to; i++) | ||
| op.push({p: path.concat(diff.to), lm: diff.from + diff.howMany - 1}); | ||
| } | ||
| var block = work.splice(diff.from, diff.howMany); | ||
| work.splice(diff.to, 0, ...block); | ||
| } | ||
|
|
||
| function sameContainerKind(a, b) { | ||
| return (isArray(a) && isArray(b)) || (isObject(a) && isObject(b)); | ||
| } | ||
|
|
||
| // arrayDiff only speaks remove/insert/move, so an element changed in place comes back as a | ||
| // remove immediately followed by an insert at the same index. Fuse that one unambiguous shape - | ||
| // remove of a single element, then insert of a single element at the same index - into a | ||
| // `substitute`, so diffArray can recurse into it. Anything else (batch replaces, differing counts) | ||
| // is left untouched and keeps its literal ld/li. A move is never a fusion candidate: arrayDiff | ||
| // matches equal elements, so a remove+insert pair always has differing values. | ||
| function detectSubstitutions(diffs) { | ||
| var diffsWithSubs = []; | ||
| for (var i = 0; i < diffs.length; i++) { | ||
| var current = diffs[i]; | ||
| var next = diffs[i + 1]; | ||
| if ( | ||
| current.type === 'remove' && current.howMany === 1 && | ||
| next && next.type === 'insert' && next.values.length === 1 && | ||
| next.index === current.index | ||
| ) { | ||
| diffsWithSubs.push({type: 'substitute', index: current.index, value: next.values[0]}); | ||
| i++; | ||
| } else { | ||
| diffsWithSubs.push(current); | ||
| } | ||
| } | ||
| return diffsWithSubs; | ||
| } | ||
|
|
||
| function diffObject(path, before, after) { | ||
| var op = []; | ||
| Object.keys(before).forEach(function(key) { | ||
| if (!hasOwn(after, key)) op.push({p: path.concat(key), od: before[key]}); | ||
|
dodoarg marked this conversation as resolved.
Resolved
|
||
| }); | ||
|
dodoarg marked this conversation as resolved.
Resolved
|
||
| Object.keys(after).forEach(function(key) { | ||
| if (!hasOwn(before, key)) { | ||
| op.push({p: path.concat(key), oi: after[key]}); | ||
| } else { | ||
| op.push(...diffValue(path.concat(key), before[key], after[key])); | ||
| } | ||
| }); | ||
| return op; | ||
| } | ||
|
|
||
| json.normalize = function(op) { | ||
| var newOp = []; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,8 @@ | |
| // is equivalent to this op: | ||
| // [{i:'a', p:0}, {i:'b', p:1}, {i:'c', p:2}] | ||
|
|
||
| var fastDiff = require('fast-diff'); | ||
|
|
||
| var text = module.exports = { | ||
| name: 'text0', | ||
| uri: 'http://sharejs.org/types/textv0', | ||
|
|
@@ -117,6 +119,38 @@ text.compose = function(op1, op2) { | |
| return newOp; | ||
| }; | ||
|
|
||
|
|
||
| // Compute the op that transforms `before` into `after`, delegating to fast-diff (the same algorithm | ||
| // quill-delta uses for rich-text) and mapping its equal/insert/delete segments to text0 ops. | ||
| // fast-diff is O(n*d) in the edit distance: fine when the two strings share most content (a small edit in | ||
| // a huge doc strips to a tiny residual), but degrades to ~O(n^2) for large, substantially-different pairs. | ||
| // See <editor issue> | ||
| text.diff = function(before, after) { | ||
| var op = []; | ||
| var p = 0; | ||
| fastDiff(before, after).forEach(function(segment) { | ||
| var kind = segment[0]; | ||
| var value = segment[1]; | ||
| switch (kind) { | ||
| case fastDiff.EQUAL: | ||
| p += value.length; | ||
| break; | ||
| case fastDiff.DELETE: | ||
| op.push({p: p, d: value}); | ||
| break; | ||
| case fastDiff.INSERT: | ||
| op.push({p: p, i: value}); | ||
| p += value.length; | ||
| break; | ||
| } | ||
| }); | ||
| return op; | ||
|
Comment on lines
+131
to
+147
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we have the same diff for strings in json0?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So this is the |
||
| }; | ||
|
|
||
| text.isOfType = function(snapshot) { | ||
| return typeof snapshot === 'string'; | ||
| }; | ||
|
|
||
| /** Clean up an op */ | ||
| text.normalize = function(op) { | ||
| var newOp = []; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.