Rich text editor for Flutter
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

563 lines
18 KiB

import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:diff_match_patch/diff_match_patch.dart' as dmp;
import 'package:quiver/core.dart';
import '../operation/operation.dart';
import 'delta_iterator.dart';
/// Delta represents a document or a modification of a document as a sequence of
/// insert, delete and retain operations.
///
/// Delta consisting of only "insert" operations is usually referred to as
/// "document delta". When delta includes also "retain" or "delete" operations
/// it is a "change delta".
class Delta {
/// Creates new empty [Delta].
factory Delta() => Delta._(<Operation>[]);
Delta._(this.operations);
/// Creates new [Delta] from [other].
factory Delta.from(Delta other) =>
Delta._(List<Operation>.from(other.operations));
/// Creates new [Delta] from a List of Operation
factory Delta.fromOperations(List<Operation> operations) =>
Delta._(operations.toList());
// Placeholder char for embed in diff()
static final String _kNullCharacter = String.fromCharCode(0);
/// Transforms two attribute sets.
static Map<String, dynamic>? transformAttributes(
Map<String, dynamic>? a, Map<String, dynamic>? b, bool priority) {
if (a == null) return b;
if (b == null) return null;
if (!priority) return b;
final result = b.keys.fold<Map<String, dynamic>>({}, (attributes, key) {
if (!a.containsKey(key)) attributes[key] = b[key];
return attributes;
});
return result.isEmpty ? null : result;
}
/// Composes two attribute sets.
static Map<String, dynamic>? composeAttributes(
Map<String, dynamic>? a, Map<String, dynamic>? b,
{bool keepNull = false}) {
a ??= const {};
b ??= const {};
final result = Map<String, dynamic>.from(a)..addAll(b);
final keys = result.keys.toList(growable: false);
if (!keepNull) {
for (final key in keys) {
if (result[key] == null) result.remove(key);
}
}
return result.isEmpty ? null : result;
}
///get anti-attr result base on base
static Map<String, dynamic> invertAttributes(
Map<String, dynamic>? attr, Map<String, dynamic>? base) {
attr ??= const {};
base ??= const {};
final baseInverted = base.keys.fold({}, (dynamic memo, key) {
if (base![key] != attr![key] && attr.containsKey(key)) {
memo[key] = base[key];
}
return memo;
});
final inverted =
Map<String, dynamic>.from(attr.keys.fold(baseInverted, (memo, key) {
if (base![key] != attr![key] && !base.containsKey(key)) {
memo[key] = null;
}
return memo;
}));
return inverted;
}
/// Returns diff between two attribute sets
static Map<String, dynamic>? diffAttributes(
Map<String, dynamic>? a, Map<String, dynamic>? b) {
a ??= const {};
b ??= const {};
final attributes = <String, dynamic>{};
for (final key in (a.keys.toList()..addAll(b.keys))) {
if (a[key] != b[key]) {
attributes[key] = b.containsKey(key) ? b[key] : null;
}
}
return attributes.keys.isNotEmpty ? attributes : null;
}
final List<Operation> operations;
int modificationCount = 0;
/// Creates [Delta] from de-serialized JSON representation.
///
/// If `dataDecoder` parameter is not null then it is used to additionally
/// decode the operation's data object. Only applied to insert operations.
static Delta fromJson(List data, {DataDecoder? dataDecoder}) {
return Delta._(data
.map((op) => Operation.fromJson(op, dataDecoder: dataDecoder))
.toList());
}
/// Returns list of operations in this delta.
List<Operation> toList() => List.from(operations);
/// Returns JSON-serializable version of this delta.
List<Map<String, dynamic>> toJson() =>
toList().map((operation) => operation.toJson()).toList();
/// Returns `true` if this delta is empty.
bool get isEmpty => operations.isEmpty;
/// Returns `true` if this delta is not empty.
bool get isNotEmpty => operations.isNotEmpty;
/// Returns number of operations in this delta.
int get length => operations.length;
/// Returns [Operation] at specified [index] in this delta.
Operation operator [](int index) => operations[index];
/// Returns [Operation] at specified [index] in this delta.
Operation elementAt(int index) => operations.elementAt(index);
/// Returns the first [Operation] in this delta.
Operation get first => operations.first;
/// Returns the last [Operation] in this delta.
Operation get last => operations.last;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! Delta) return false;
final typedOther = other;
const comparator = ListEquality<Operation>(DefaultEquality<Operation>());
return comparator.equals(operations, typedOther.operations);
}
@override
int get hashCode => hashObjects(operations);
/// Retain [count] of characters from current position.
void retain(int count, [Map<String, dynamic>? attributes]) {
assert(count >= 0);
if (count == 0) return; // no-op
push(Operation.retain(count, attributes));
}
/// Insert [data] at current position.
void insert(dynamic data, [Map<String, dynamic>? attributes]) {
if (data is String && data.isEmpty) return; // no-op
push(Operation.insert(data, attributes));
}
/// Delete [count] characters from current position.
void delete(int count) {
assert(count >= 0);
if (count == 0) return;
push(Operation.delete(count));
}
void _mergeWithTail(Operation operation) {
assert(isNotEmpty);
assert(last.key == operation.key);
assert(operation.data is String && last.data is String);
final length = operation.length! + last.length!;
final lastText = last.data as String;
final opText = operation.data as String;
final resultText = lastText + opText;
final index = operations.length;
operations.replaceRange(index - 1, index, [
Operation(operation.key, length, resultText, operation.attributes),
]);
}
/// Pushes new operation into this delta.
///
/// Performs compaction by composing [operation] with current tail operation
/// of this delta, when possible. For instance, if current tail is
/// `insert('abc')` and pushed operation is `insert('123')` then existing
/// tail is replaced with `insert('abc123')` - a compound result of the two
/// operations.
void push(Operation operation) {
if (operation.isEmpty) return;
var index = operations.length;
final lastOp = operations.isNotEmpty ? operations.last : null;
if (lastOp != null) {
if (lastOp.isDelete && operation.isDelete) {
_mergeWithTail(operation);
return;
}
if (lastOp.isDelete && operation.isInsert) {
index -= 1; // Always insert before deleting
final nLastOp = (index > 0) ? operations.elementAt(index - 1) : null;
if (nLastOp == null) {
operations.insert(0, operation);
return;
}
}
if (lastOp.isInsert && operation.isInsert) {
if (lastOp.hasSameAttributes(operation) &&
operation.data is String &&
lastOp.data is String) {
_mergeWithTail(operation);
return;
}
}
if (lastOp.isRetain && operation.isRetain) {
if (lastOp.hasSameAttributes(operation)) {
_mergeWithTail(operation);
return;
}
}
}
if (index == operations.length) {
operations.add(operation);
} else {
final opAtIndex = operations.elementAt(index);
operations.replaceRange(index, index + 1, [operation, opAtIndex]);
}
modificationCount++;
}
/// Composes next operation from [thisIter] and [otherIter].
///
/// Returns new operation or `null` if operations from [thisIter] and
/// [otherIter] nullify each other. For instance, for the pair `insert('abc')`
/// and `delete(3)` composition result would be empty string.
Operation? _composeOperation(
DeltaIterator thisIter, DeltaIterator otherIter) {
if (otherIter.isNextInsert) return otherIter.next();
if (thisIter.isNextDelete) return thisIter.next();
final length = math.min(thisIter.peekLength(), otherIter.peekLength());
final thisOp = thisIter.next(length);
final otherOp = otherIter.next(length);
assert(thisOp.length == otherOp.length);
if (otherOp.isRetain) {
final attributes = composeAttributes(
thisOp.attributes,
otherOp.attributes,
keepNull: thisOp.isRetain,
);
if (thisOp.isRetain) {
return Operation.retain(thisOp.length, attributes);
} else if (thisOp.isInsert) {
return Operation.insert(thisOp.data, attributes);
} else {
throw StateError('Unreachable');
}
} else {
// otherOp == delete && thisOp in [retain, insert]
assert(otherOp.isDelete);
if (thisOp.isRetain) return otherOp;
assert(thisOp.isInsert);
// otherOp(delete) + thisOp(insert) => null
}
return null;
}
/// Composes this delta with [other] and returns new [Delta].
///
/// It is not required for this and [other] delta to represent a document
/// delta (consisting only of insert operations).
Delta compose(Delta other) {
final result = Delta();
final thisIter = DeltaIterator(this);
final otherIter = DeltaIterator(other);
while (thisIter.hasNext || otherIter.hasNext) {
final newOp = _composeOperation(thisIter, otherIter);
if (newOp != null) result.push(newOp);
}
return result..trim();
}
/// Returns a new lazy Iterable with elements that are created by calling
/// f on each element of this Iterable in iteration order.
///
/// Convenience method
Iterable<T> map<T>(T Function(Operation) f) {
return operations.map<T>(f);
}
/// Returns a [Delta] containing differences between 2 [Delta]s.
/// If [cleanupSemantic] is `true` (default), applies the following:
///
/// The diff of "mouse" and "sofas" is
/// [delete(1), insert("s"), retain(1),
/// delete("u"), insert("fa"), retain(1), delete(1)].
/// While this is the optimum diff, it is difficult for humans to understand.
/// Semantic cleanup rewrites the diff,
/// expanding it into a more intelligible format.
/// The above example would become: [(-1, "mouse"), (1, "sofas")].
/// (source: https://github.com/google/diff-match-patch/wiki/API)
///
/// Useful when one wishes to display difference between 2 documents
Delta diff(Delta other, {bool cleanupSemantic = true}) {
if (operations.equals(other.operations)) {
return Delta();
}
final stringThis = map((op) {
if (op.isInsert) {
return op.data is String ? op.data : _kNullCharacter;
}
final prep = this == other ? 'on' : 'with';
throw ArgumentError('diff() call $prep non-document');
}).join();
final stringOther = other.map((op) {
if (op.isInsert) {
return op.data is String ? op.data : _kNullCharacter;
}
final prep = this == other ? 'on' : 'with';
throw ArgumentError('diff() call $prep non-document');
}).join();
final retDelta = Delta();
final diffResult = dmp.diff(stringThis, stringOther);
if (cleanupSemantic) {
dmp.DiffMatchPatch().diffCleanupSemantic(diffResult);
}
final thisIter = DeltaIterator(this);
final otherIter = DeltaIterator(other);
for (final component in diffResult) {
var length = component.text.length;
while (length > 0) {
var opLength = 0;
switch (component.operation) {
case dmp.DIFF_INSERT:
opLength = math.min(otherIter.peekLength(), length);
retDelta.push(otherIter.next(opLength));
break;
case dmp.DIFF_DELETE:
opLength = math.min(length, thisIter.peekLength());
thisIter.next(opLength);
retDelta.delete(opLength);
break;
case dmp.DIFF_EQUAL:
opLength = math.min(
math.min(thisIter.peekLength(), otherIter.peekLength()),
length,
);
final thisOp = thisIter.next(opLength);
final otherOp = otherIter.next(opLength);
if (thisOp.data == otherOp.data) {
retDelta.retain(
opLength,
diffAttributes(thisOp.attributes, otherOp.attributes),
);
} else {
retDelta
..push(otherOp)
..delete(opLength);
}
break;
}
length -= opLength;
}
}
return retDelta..trim();
}
/// Transforms next operation from [otherIter] against next operation in
/// [thisIter].
///
/// Returns `null` if both operations nullify each other.
Operation? _transformOperation(
DeltaIterator thisIter, DeltaIterator otherIter, bool priority) {
if (thisIter.isNextInsert && (priority || !otherIter.isNextInsert)) {
return Operation.retain(thisIter.next().length);
} else if (otherIter.isNextInsert) {
return otherIter.next();
}
final length = math.min(thisIter.peekLength(), otherIter.peekLength());
final thisOp = thisIter.next(length);
final otherOp = otherIter.next(length);
assert(thisOp.length == otherOp.length);
// At this point only delete and retain operations are possible.
if (thisOp.isDelete) {
// otherOp is either delete or retain, so they nullify each other.
return null;
} else if (otherOp.isDelete) {
return otherOp;
} else {
// Retain otherOp which is either retain or insert.
return Operation.retain(
length,
transformAttributes(thisOp.attributes, otherOp.attributes, priority),
);
}
}
/// Transforms [other] delta against operations in this delta.
Delta transform(Delta other, bool priority) {
final result = Delta();
final thisIter = DeltaIterator(this);
final otherIter = DeltaIterator(other);
while (thisIter.hasNext || otherIter.hasNext) {
final newOp = _transformOperation(thisIter, otherIter, priority);
if (newOp != null) result.push(newOp);
}
return result..trim();
}
/// Removes trailing retain operation with empty attributes, if present.
void trim() {
if (isNotEmpty) {
final last = operations.last;
if (last.isRetain && last.isPlain) operations.removeLast();
}
}
/// Removes trailing '\n'
void _trimNewLine() {
if (isNotEmpty) {
final lastOp = operations.last;
final lastOpData = lastOp.data;
if (lastOpData is String && lastOpData.endsWith('\n')) {
operations.removeLast();
if (lastOpData.length > 1) {
insert(lastOpData.substring(0, lastOpData.length - 1),
lastOp.attributes);
}
}
}
}
/// Concatenates [other] with this delta and returns the result.
Delta concat(Delta other, {bool trimNewLine = false}) {
final result = Delta.from(this);
if (trimNewLine) {
result._trimNewLine();
}
if (other.isNotEmpty) {
// In case first operation of other can be merged with last operation in
// our list.
result.push(other.operations.first);
result.operations.addAll(other.operations.sublist(1));
}
return result;
}
/// Inverts this delta against [base].
///
/// Returns new delta which negates effect of this delta when applied to
/// [base]. This is an equivalent of "undo" operation on deltas.
Delta invert(Delta base) {
final inverted = Delta();
if (base.isEmpty) return inverted;
var baseIndex = 0;
for (final op in operations) {
if (op.isInsert) {
inverted.delete(op.length!);
} else if (op.isRetain && op.isPlain) {
inverted.retain(op.length!);
baseIndex += op.length!;
} else if (op.isDelete || (op.isRetain && op.isNotPlain)) {
final length = op.length!;
final sliceDelta = base.slice(baseIndex, baseIndex + length);
sliceDelta.toList().forEach((baseOp) {
if (op.isDelete) {
inverted.push(baseOp);
} else if (op.isRetain && op.isNotPlain) {
final invertAttr =
invertAttributes(op.attributes, baseOp.attributes);
inverted.retain(
baseOp.length!, invertAttr.isEmpty ? null : invertAttr);
}
});
baseIndex += length;
} else {
throw StateError('Unreachable');
}
}
inverted.trim();
return inverted;
}
/// Returns slice of this delta from [start] index (inclusive) to [end]
/// (exclusive).
Delta slice(int start, [int? end]) {
final delta = Delta();
var index = 0;
final opIterator = DeltaIterator(this);
final actualEnd = end ?? DeltaIterator.maxLength;
while (index < actualEnd && opIterator.hasNext) {
Operation op;
if (index < start) {
op = opIterator.next(start - index);
} else {
op = opIterator.next(actualEnd - index);
delta.push(op);
}
index += op.length!;
}
return delta;
}
/// Transforms [index] against this delta.
///
/// Any "delete" operation before specified [index] shifts it backward, as
/// well as any "insert" operation shifts it forward.
///
/// The [force] argument is used to resolve scenarios when there is an
/// insert operation at the same position as [index]. If [force] is set to
/// `true` (default) then position is forced to shift forward, otherwise
/// position stays at the same index. In other words setting [force] to
/// `false` gives higher priority to the transformed position.
///
/// Useful to adjust caret or selection positions.
int transformPosition(int index, {bool force = true}) {
final iter = DeltaIterator(this);
var offset = 0;
while (iter.hasNext && offset <= index) {
final op = iter.next();
if (op.isDelete) {
index -= math.min(op.length!, index - offset);
continue;
} else if (op.isInsert && (offset < index || force)) {
index += op.length!;
}
offset += op.length!;
}
return index;
}
@override
String toString() => operations.join('\n');
}