Skip to content

Commit b1a9120

Browse files
committed
[delta] fix diffing edge cases
1 parent 6d86815 commit b1a9120

File tree

3 files changed

+160
-22
lines changed

3 files changed

+160
-22
lines changed

delta/delta.js

Lines changed: 142 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,10 +1031,18 @@ export class Delta {
10311031
return this.name === other.name && fun.equalityDeep(this.attrs, other.attrs) && fun.equalityDeep(this.children, other.children) && this.childCnt === other.childCnt
10321032
}
10331033

1034+
1035+
/**
1036+
* @return {DeltaBuilder<NodeName,Attrs,Children,Text,Schema>}
1037+
*/
1038+
clone () {
1039+
return this.slice(0, this.childCnt)
1040+
}
1041+
10341042
/**
10351043
* @param {number} start
10361044
* @param {number} end
1037-
* @return {Delta<NodeName,Attrs,Children,Text,Schema>}
1045+
* @return {DeltaBuilder<NodeName,Attrs,Children,Text,Schema>}
10381046
*/
10391047
slice (start = 0, end = this.childCnt) {
10401048
const cpy = /** @type {DeltaAny} */ (new DeltaBuilder(/** @type {any} */ (this.name), this.$schema))
@@ -1101,7 +1109,7 @@ export class Delta {
11011109
/**
11021110
* @template {DeltaAny} D
11031111
* @param {D} d
1104-
* @return {D extends Delta<infer NodeName,infer Attrs,infer Children,infer Text,infer Schema> ? DeltaBuilder<NodeName,Attrs,Children,Text,Schema> : never}
1112+
* @return {D extends DeltaBuilder<infer NodeName,infer Attrs,infer Children,infer Text,infer Schema> ? DeltaBuilder<NodeName,Attrs,Children,Text,Schema> : never}
11051113
*/
11061114
export const clone = d => /** @type {any} */ (d.slice(0, d.childCnt))
11071115

@@ -1469,26 +1477,50 @@ export class DeltaBuilder extends Delta {
14691477
if (offset === 0) {
14701478
list.insertBetween(this.children, opsI == null ? this.children.end : opsI.prev, opsI, scheduleForMerge(op.clone()))
14711479
} else {
1480+
// @todo inmplement "splitHelper" and "insertHelper" - I'm splitting all the time and
1481+
// forget to update opsI
14721482
if (opsI == null) error.unexpectedCase()
14731483
const cpy = scheduleForMerge(opsI.clone(offset))
14741484
opsI._splice(offset, opsI.length - offset)
14751485
list.insertBetween(this.children, opsI, opsI.next || null, cpy)
14761486
list.insertBetween(this.children, opsI, cpy || null, scheduleForMerge(op.clone()))
1487+
opsI = cpy
14771488
offset = 0
14781489
}
14791490
this.childCnt += op.insert.length
14801491
} else if ($retainOp.check(op)) {
1481-
let skipLen = op.length
1482-
while (opsI != null && opsI.length - offset <= skipLen) {
1483-
skipLen -= opsI.length - offset
1492+
let retainLen = op.length
1493+
1494+
if (offset > 0 && opsI != null && op.format != null && !$deleteOp.check(opsI) && !object.every(op.format, (v,k) => fun.equalityDeep(v, /** @type {InsertOp<any>|RetainOp|ModifyOp} */ (opsI).format?.[k] || null))) {
1495+
// need to split current op
1496+
const cpy = scheduleForMerge(opsI.clone(offset))
1497+
opsI._splice(offset, opsI.length - offset)
1498+
list.insertBetween(this.children, opsI, opsI.next || null, cpy)
1499+
opsI = cpy
1500+
offset = 0
1501+
}
1502+
1503+
while (opsI != null && opsI.length - offset <= retainLen) {
1504+
op.format != null && updateOpFormat(opsI, op.format)
1505+
retainLen -= opsI.length - offset
14841506
opsI = opsI?.next || null
14851507
offset = 0
14861508
}
1509+
14871510
if (opsI != null) {
1488-
offset += skipLen
1489-
} else {
1490-
list.pushEnd(this.children, scheduleForMerge(new RetainOp(skipLen, op.format, op.attribution)))
1491-
this.childCnt += skipLen
1511+
if (op.format != null && retainLen > 0) {
1512+
// split current op and apply format
1513+
const cpy = scheduleForMerge(opsI.clone(retainLen))
1514+
opsI._splice(retainLen, opsI.length - retainLen)
1515+
list.insertBetween(this.children, opsI, opsI.next || null, cpy)
1516+
updateOpFormat(opsI, op.format)
1517+
opsI = cpy
1518+
} else {
1519+
offset += retainLen
1520+
}
1521+
} else if (retainLen > 0) {
1522+
list.pushEnd(this.children, scheduleForMerge(new RetainOp(retainLen, op.format, op.attribution)))
1523+
this.childCnt += retainLen
14921524
}
14931525
} else if ($deleteOp.check(op)) {
14941526
let remainingLen = op.delete
@@ -1745,16 +1777,40 @@ export class DeltaBuilder extends Delta {
17451777
* @return {CastToDelta<OtherDelta> extends Delta<any,any,infer OtherChildren,infer OtherText,any> ? DeltaBuilder<NodeName,Attrs,Children|OtherChildren,Text|OtherText,Schema> : never}
17461778
*/
17471779
append (other) {
1780+
const children = this.children
1781+
let prevLast = children.end
17481782
// @todo Investigate. Above is a typescript issue. It is necessary to cast OtherDelta to a Delta first before
17491783
// inferring type, otherwise Children will contain Text.
17501784
for (const child of other.children) {
1751-
list.pushEnd(this.children, child.clone())
1785+
list.pushEnd(children, child.clone())
17521786
}
1787+
this.childCnt += other.childCnt
1788+
prevLast?.next && tryMergeWithPrev(children, prevLast.next)
17531789
// @ts-ignore
17541790
return this
17551791
}
17561792
}
17571793

1794+
/**
1795+
* @param {ChildrenOpAny} op
1796+
* @param {{[k:string]:any}} formatUpdate
1797+
*/
1798+
const updateOpFormat = (op, formatUpdate) => {
1799+
if (!$deleteOp.check(op)) {
1800+
// apply formatting attributes
1801+
for (const k in formatUpdate) {
1802+
const v = formatUpdate[k]
1803+
if (v != null || $retainOp.check(op)) {
1804+
// never modify formats
1805+
/** @type {any} */ (op).format = object.assign({}, op.format, { [k]: v })
1806+
} else if (op.format != null) {
1807+
const { [k]: _, ...rest } = op.format
1808+
;/** @type {any} */ (op).format = rest
1809+
}
1810+
}
1811+
}
1812+
}
1813+
17581814
/**
17591815
* @template {DeltaAny} D
17601816
* @typedef {D extends DeltaBuilder<infer N,infer Attrs,infer Children,infer Text,infer Schema> ? Delta<N,Attrs,Children,Text,Schema> : D} CastToDelta
@@ -1815,8 +1871,8 @@ export class $Delta extends s.Schema {
18151871
const { $name, $attrs, $children, hasText, $formats } = this.shape
18161872
if (!(o instanceof Delta)) {
18171873
err?.extend(null, 'Delta', o?.constructor.name, 'Constructor match failed')
1818-
} else if (!$name.check(o.name, err)) {
1819-
err?.extend('Delta.name', $name.toString(), o.name, 'Constructor match failed')
1874+
} else if (o.name != null && !$name.check(o.name, err)) {
1875+
err?.extend('Delta.name', $name.toString(), o.name, 'Unexpected node name')
18201876
} else if (list.toArray(o.children).some(c => (!hasText && $textOp.check(c)) || (hasText && $textOp.check(c) && c.format != null && !$formats.check(c.format)) || ($insertOp.check(c) && !c.insert.every(ins => $children.check(ins))))) {
18211877
err?.extend('Delta.children', '', '', 'Children don\'t match the schema')
18221878
} else if (object.some(o.attrs, (op, k) => $insertOp.check(op) && !$attrs.check({ [k]: op.value }, err))) {
@@ -1913,14 +1969,21 @@ export const _$delta = ({ name, attrs, children, text, recursive }) => {
19131969
*/
19141970
export const $deltaAny = /** @type {any} */ (s.$instanceOf(Delta))
19151971

1972+
/**
1973+
* @type {s.Schema<DeltaBuilderAny>}
1974+
*/
1975+
export const $deltaBuilderAny = /** @type {any} */ (s.$instanceOf(DeltaBuilder))
1976+
19161977
/**
19171978
* Helper function to merge attribution and attributes. The latter input "wins".
19181979
*
19191980
* @template {{ [key: string]: any }} T
19201981
* @param {T | null} a
19211982
* @param {T | null} b
19221983
*/
1923-
export const mergeAttrs = (a, b) => object.isEmpty(a) ? b : (object.isEmpty(b) ? a : object.assign({}, a, b))
1984+
export const mergeAttrs = (a, b) => object.isEmpty(a)
1985+
? (object.isEmpty(b) ? null : b)
1986+
: (object.isEmpty(b) ? a : object.assign({}, a, b))
19241987

19251988
/**
19261989
* @template {DeltaAny|null} D
@@ -2102,7 +2165,7 @@ export const map = $schema => /** @type {any} */ (create(/** @type {any} */ ($sc
21022165
* @template {DeltaAny} D
21032166
* @param {D} d1
21042167
* @param {NoInfer<D>} d2
2105-
* @return {D}
2168+
* @return {D extends Delta<infer N,infer Attrs,infer Children,infer Text,any> ? DeltaBuilder<N,Attrs,Children,Text,null> : never}
21062169
*/
21072170
export const diff = (d1, d2) => {
21082171
/**
@@ -2153,11 +2216,14 @@ export const diff = (d1, d2) => {
21532216
d.retain(change.index - lastIndex1)
21542217
// insert minimal diff at curred position in d
21552218
/**
2156-
* @param {DeltaBuilderAny} d
2219+
*
2220+
* @todo it would be better if these would be slices of delta (an actual delta)
2221+
*
21572222
* @param {ChildrenOpAny[]} opsIs
21582223
* @param {ChildrenOpAny[]} opsShould
21592224
*/
2160-
const diffAndApply = (d, opsIs, opsShould) => {
2225+
const diffAndApply = (opsIs, opsShould) => {
2226+
const d = create()
21612227
// @todo unoptimized implementation. Convert content to array and diff that based on
21622228
// generated fingerprints. We probably could do better and cache more information.
21632229
// - benchmark
@@ -2172,6 +2238,7 @@ export const diff = (d1, d2) => {
21722238
const shouldContent = opsShould.flatMap(op => $insertOp.check(op) ? op.insert : ($textOp.check(op) ? op.insert.split('') : error.unexpectedCase()))
21732239
const isContentFingerprinted = isContent.map(c => s.$string.check(c) ? c : fingerprintTrait.fingerprint(c))
21742240
const shouldContentFingerprinted = shouldContent.map(c => s.$string.check(c) ? c : fingerprintTrait.fingerprint(c))
2241+
const hasFormatting = opsIs.some(op => !$deleteOp.check(op) && op.format != null) || opsShould.some(op => !$deleteOp.check(op) && op.format != null)
21752242
/**
21762243
* @type {{ index: number, insert: Array<string|DeltaAny|fingerprintTrait.Fingerprintable>, remove: Array<string|DeltaAny|fingerprintTrait.Fingerprintable> }[]}
21772244
*/
@@ -2193,7 +2260,7 @@ export const diff = (d1, d2) => {
21932260
const a = cd.insert[cdii]
21942261
const b = cd.remove[cdri]
21952262
if ($deltaAny.check(a) && $deltaAny.check(b) && a.name === b.name) {
2196-
d.modify(diff(a, b))
2263+
d.modify(diff(b, a))
21972264
cdii++
21982265
cdri++
21992266
} else if ($deltaAny.check(b)) {
@@ -2210,8 +2277,64 @@ export const diff = (d1, d2) => {
22102277
}
22112278
d.delete(cd.remove.length - cdri)
22122279
}
2280+
// create the diff for formatting
2281+
if (hasFormatting) {
2282+
const formattingDiff = create()
2283+
// update opsIs with content diff. then we can figure out the formatting diff.
2284+
const isUpdated = create()
2285+
// copy opsIs to fresh delta
2286+
opsIs.forEach(op => {
2287+
isUpdated.childCnt += op.length
2288+
list.pushEnd(isUpdated.children, op.clone())
2289+
})
2290+
isUpdated.apply(d)
2291+
let shouldI = 0
2292+
let shouldOffset = 0
2293+
let isOp = isUpdated.children.start
2294+
let isOffset = 0
2295+
while (shouldI < opsShould.length && isOp != null) {
2296+
const shouldOp = opsShould[shouldI]
2297+
if (!$deleteOp.check(shouldOp) && !$deleteOp.check(isOp)) {
2298+
const isFormat = isOp.format
2299+
const minForward = math.min(shouldOp.length - shouldOffset, isOp.length - isOffset)
2300+
shouldOffset += minForward
2301+
isOffset += minForward
2302+
if (fun.equalityDeep(shouldOp.format, isFormat)) {
2303+
formattingDiff.retain(minForward)
2304+
} else {
2305+
/**
2306+
* @type {FormattingAttributes}
2307+
*/
2308+
const fupdate = {}
2309+
shouldOp.format != null && object.forEach(shouldOp.format, (v, k) => {
2310+
if (!fun.equalityDeep(v, isFormat?.[k] || null)) {
2311+
fupdate[k] = v
2312+
}
2313+
})
2314+
isFormat && object.forEach(isFormat, (_, k) => {
2315+
if (shouldOp?.format?.[k] === undefined) {
2316+
fupdate[k] = null
2317+
}
2318+
})
2319+
formattingDiff.retain(minForward, fupdate)
2320+
}
2321+
// update offset and iterators
2322+
if (shouldOffset >= shouldOp.length) {
2323+
shouldI++
2324+
shouldOffset = 0
2325+
}
2326+
if (isOffset >= isOp.length) {
2327+
isOp = isOp.next
2328+
isOffset = 0
2329+
}
2330+
}
2331+
}
2332+
d.apply(formattingDiff)
2333+
}
2334+
return d
22132335
}
2214-
diffAndApply(d, ops1.slice(change.index, change.index + change.remove.length), ops2.slice(change.index + currIndexOffset2, change.index + currIndexOffset2 + change.insert.length))
2336+
const subd = diffAndApply(ops1.slice(change.index, change.index + change.remove.length), ops2.slice(change.index + currIndexOffset2, change.index + currIndexOffset2 + change.insert.length))
2337+
d.append(subd)
22152338
lastIndex1 = change.index + change.remove.length
22162339
currIndexOffset2 += change.insert.length - change.remove.length
22172340
}
@@ -2233,5 +2356,5 @@ export const diff = (d1, d2) => {
22332356
}
22342357
}
22352358
}
2236-
return /** @type {D} */ (d.done(false))
2359+
return /** @type {any} */ (d.done(false))
22372360
}

delta/delta.test.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -687,12 +687,26 @@ export const testRepeatRandomMapDiff = tc => {
687687
}
688688

689689
/**
690-
* @param {t.TestCase} tc
690+
* @param {t.TestCase} _tc
691691
*/
692-
export const testDeltaAppend = tc => {
692+
export const testDeltaAppend = _tc => {
693693
const $d = delta.$delta({ children: s.$number, text: true })
694694
const other = delta.create().insert('b').insert([1, 2])
695695
const _d = delta.create().insert('a')
696696
const d = _d.append(other)
697697
$d.expect(d)
698698
}
699+
700+
export const testDeltaDiffWithFormatting = () => {
701+
const d1 = delta.create().insert('hello world!')
702+
const d2 = delta.create().insert('hello ').insert('world', { bold: true }).insert('!')
703+
const diff = delta.diff(d1, d2)
704+
t.compare(diff, delta.create().retain(6).retain(5, { bold: true }))
705+
}
706+
707+
export const testDeltaDiffWithFormatting2 = () => {
708+
const d1 = delta.create().insert('hello!')
709+
const d2 = delta.create().insert('hello ').insert('world', { bold: true }).insert('!')
710+
const diff = delta.diff(d1, d2)
711+
t.compare(diff, delta.create().retain(5).insert(' ').insert('world', { bold: true }))
712+
}

function.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const equalityDeep = (a, b) => {
7272
if (a === b) {
7373
return true
7474
}
75-
if (a == null || b == null || a.constructor !== b.constructor) {
75+
if (a == null || b == null || (a.constructor !== b.constructor && (a.constructor || Object) !== (b.constructor || Object))) {
7676
return false
7777
}
7878
if (a[equalityTrait.EqualityTraitSymbol] != null) {
@@ -116,6 +116,7 @@ export const equalityDeep = (a, b) => {
116116
}
117117
break
118118
}
119+
case undefined:
119120
case Object:
120121
if (object.size(a) !== object.size(b)) {
121122
return false

0 commit comments

Comments
 (0)