parent
36851c5199
commit
1d581858ac
57 changed files with 2095 additions and 1051 deletions
@ -0,0 +1,7 @@ |
|||||||
|
# https://dart.dev/guides/libraries/private-files |
||||||
|
# Created by `dart pub` |
||||||
|
.dart_tool/ |
||||||
|
|
||||||
|
# Avoid committing pubspec.lock for library packages; see |
||||||
|
# https://dart.dev/guides/libraries/private-files#pubspeclock. |
||||||
|
pubspec.lock |
@ -0,0 +1,3 @@ |
|||||||
|
## 0.0.1 |
||||||
|
|
||||||
|
* Initial version. |
@ -0,0 +1,2 @@ |
|||||||
|
# Dart Quill Delta |
||||||
|
A port of [quill-js-delta](https://github.com/quilljs/delta/) from typescript to dart |
@ -0,0 +1,30 @@ |
|||||||
|
# This file configures the static analysis results for your project (errors, |
||||||
|
# warnings, and lints). |
||||||
|
# |
||||||
|
# This enables the 'recommended' set of lints from `package:lints`. |
||||||
|
# This set helps identify many issues that may lead to problems when running |
||||||
|
# or consuming Dart code, and enforces writing Dart using a single, idiomatic |
||||||
|
# style and format. |
||||||
|
# |
||||||
|
# If you want a smaller set of lints you can change this to specify |
||||||
|
# 'package:lints/core.yaml'. These are just the most critical lints |
||||||
|
# (the recommended set includes the core lints). |
||||||
|
# The core lints are also what is used by pub.dev for scoring packages. |
||||||
|
|
||||||
|
include: package:lints/recommended.yaml |
||||||
|
|
||||||
|
# Uncomment the following section to specify additional rules. |
||||||
|
|
||||||
|
# linter: |
||||||
|
# rules: |
||||||
|
# - camel_case_types |
||||||
|
|
||||||
|
# analyzer: |
||||||
|
# exclude: |
||||||
|
# - path/to/excluded/files/** |
||||||
|
|
||||||
|
# For more information about the core and recommended set of lints, see |
||||||
|
# https://dart.dev/go/core-lints |
||||||
|
|
||||||
|
# For additional information about configuring this file, see |
||||||
|
# https://dart.dev/guides/language/analysis-options |
@ -0,0 +1 @@ |
|||||||
|
void main() {} |
@ -0,0 +1,5 @@ |
|||||||
|
library; |
||||||
|
|
||||||
|
export './src/delta/delta.dart'; |
||||||
|
export './src/delta/delta_iterator.dart'; |
||||||
|
export './src/operation/operation.dart'; |
@ -0,0 +1,562 @@ |
|||||||
|
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 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 ==(dynamic 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'); |
||||||
|
} |
@ -0,0 +1,100 @@ |
|||||||
|
import 'dart:math' as math; |
||||||
|
|
||||||
|
import '../operation/operation.dart'; |
||||||
|
import 'delta.dart'; |
||||||
|
|
||||||
|
/// Specialized iterator for [Delta]s. |
||||||
|
class DeltaIterator { |
||||||
|
DeltaIterator(this.delta) : _modificationCount = delta.modificationCount; |
||||||
|
|
||||||
|
static const int maxLength = 1073741824; |
||||||
|
|
||||||
|
final Delta delta; |
||||||
|
final int _modificationCount; |
||||||
|
int _index = 0; |
||||||
|
int _offset = 0; |
||||||
|
|
||||||
|
bool get isNextInsert => nextOperationKey == Operation.insertKey; |
||||||
|
|
||||||
|
bool get isNextDelete => nextOperationKey == Operation.deleteKey; |
||||||
|
|
||||||
|
bool get isNextRetain => nextOperationKey == Operation.retainKey; |
||||||
|
|
||||||
|
String? get nextOperationKey { |
||||||
|
if (_index < delta.length) { |
||||||
|
return delta.elementAt(_index).key; |
||||||
|
} else { |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
bool get hasNext => peekLength() < maxLength; |
||||||
|
|
||||||
|
/// Returns length of next operation without consuming it. |
||||||
|
/// |
||||||
|
/// Returns [maxLength] if there is no more operations left to iterate. |
||||||
|
int peekLength() { |
||||||
|
if (_index < delta.length) { |
||||||
|
final operation = delta.operations[_index]; |
||||||
|
return operation.length! - _offset; |
||||||
|
} |
||||||
|
return maxLength; |
||||||
|
} |
||||||
|
|
||||||
|
/// Consumes and returns next operation. |
||||||
|
/// |
||||||
|
/// Optional [length] specifies maximum length of operation to return. Note |
||||||
|
/// that actual length of returned operation may be less than specified value. |
||||||
|
/// |
||||||
|
/// If this iterator reached the end of the Delta then returns a retain |
||||||
|
/// operation with its length set to [maxLength]. |
||||||
|
// TODO: Note that we used double.infinity as the default value |
||||||
|
// for length here |
||||||
|
// but this can now cause a type error since operation length is |
||||||
|
// expected to be an int. Changing default length to [maxLength] is |
||||||
|
// a workaround to avoid breaking changes. |
||||||
|
Operation next([int length = maxLength]) { |
||||||
|
if (_modificationCount != delta.modificationCount) { |
||||||
|
throw ConcurrentModificationError(delta); |
||||||
|
} |
||||||
|
|
||||||
|
if (_index < delta.length) { |
||||||
|
final op = delta.elementAt(_index); |
||||||
|
final opKey = op.key; |
||||||
|
final opAttributes = op.attributes; |
||||||
|
final currentOffset = _offset; |
||||||
|
final actualLength = math.min(op.length! - currentOffset, length); |
||||||
|
if (actualLength == op.length! - currentOffset) { |
||||||
|
_index++; |
||||||
|
_offset = 0; |
||||||
|
} else { |
||||||
|
_offset += actualLength; |
||||||
|
} |
||||||
|
final opData = op.isInsert && op.data is String |
||||||
|
? (op.data as String) |
||||||
|
.substring(currentOffset, currentOffset + actualLength) |
||||||
|
: op.data; |
||||||
|
final opIsNotEmpty = |
||||||
|
opData is String ? opData.isNotEmpty : true; // embeds are never empty |
||||||
|
final opLength = opData is String ? opData.length : 1; |
||||||
|
final opActualLength = opIsNotEmpty ? opLength : actualLength; |
||||||
|
return Operation(opKey, opActualLength, opData, opAttributes); |
||||||
|
} |
||||||
|
return Operation.retain(length); |
||||||
|
} |
||||||
|
|
||||||
|
/// Skips [length] characters in source delta. |
||||||
|
/// |
||||||
|
/// Returns last skipped operation, or `null` if there was nothing to skip. |
||||||
|
Operation? skip(int length) { |
||||||
|
var skipped = 0; |
||||||
|
Operation? op; |
||||||
|
while (skipped < length && hasNext) { |
||||||
|
final opLength = peekLength(); |
||||||
|
final skip = math.min(length - skipped, opLength); |
||||||
|
op = next(skip); |
||||||
|
skipped += op.length!; |
||||||
|
} |
||||||
|
return op; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,171 @@ |
|||||||
|
import 'package:collection/collection.dart'; |
||||||
|
import 'package:quiver/core.dart'; |
||||||
|
|
||||||
|
/// Decoder function to convert raw `data` object into a user-defined data type. |
||||||
|
/// |
||||||
|
/// Useful with embedded content. |
||||||
|
typedef DataDecoder = Object? Function(Object data); |
||||||
|
|
||||||
|
/// Default data decoder which simply passes through the original value. |
||||||
|
Object? _passThroughDataDecoder(Object? data) => data; |
||||||
|
|
||||||
|
const _attributeEquality = DeepCollectionEquality(); |
||||||
|
const _valueEquality = DeepCollectionEquality(); |
||||||
|
|
||||||
|
/// Operation performed on a rich-text document. |
||||||
|
class Operation { |
||||||
|
Operation(this.key, this.length, this.data, Map? attributes) |
||||||
|
: assert(_validKeys.contains(key), 'Invalid operation key "$key".'), |
||||||
|
assert(() { |
||||||
|
if (key != Operation.insertKey) return true; |
||||||
|
return data is String ? data.length == length : length == 1; |
||||||
|
}(), 'Length of insert operation must be equal to the data length.'), |
||||||
|
_attributes = |
||||||
|
attributes != null ? Map<String, dynamic>.from(attributes) : null; |
||||||
|
|
||||||
|
/// Creates operation which deletes [length] of characters. |
||||||
|
factory Operation.delete(int length) => |
||||||
|
Operation(Operation.deleteKey, length, '', null); |
||||||
|
|
||||||
|
/// Creates operation which inserts [text] with optional [attributes]. |
||||||
|
factory Operation.insert(dynamic data, [Map<String, dynamic>? attributes]) => |
||||||
|
Operation(Operation.insertKey, data is String ? data.length : 1, data, |
||||||
|
attributes); |
||||||
|
|
||||||
|
/// Creates operation which retains [length] of characters and optionally |
||||||
|
/// applies attributes. |
||||||
|
factory Operation.retain(int? length, [Map<String, dynamic>? attributes]) => |
||||||
|
Operation(Operation.retainKey, length, '', attributes); |
||||||
|
|
||||||
|
/// Key of insert operations. |
||||||
|
static const String insertKey = 'insert'; |
||||||
|
|
||||||
|
/// Key of delete operations. |
||||||
|
static const String deleteKey = 'delete'; |
||||||
|
|
||||||
|
/// Key of retain operations. |
||||||
|
static const String retainKey = 'retain'; |
||||||
|
|
||||||
|
/// Key of attributes collection. |
||||||
|
static const String attributesKey = 'attributes'; |
||||||
|
|
||||||
|
static const List<String> _validKeys = [insertKey, deleteKey, retainKey]; |
||||||
|
|
||||||
|
/// Key of this operation, can be "insert", "delete" or "retain". |
||||||
|
final String key; |
||||||
|
|
||||||
|
/// Length of this operation. |
||||||
|
final int? length; |
||||||
|
|
||||||
|
/// Payload of "insert" operation, for other types is set to empty string. |
||||||
|
final Object? data; |
||||||
|
|
||||||
|
/// Rich-text attributes set by this operation, can be `null`. |
||||||
|
Map<String, dynamic>? get attributes => |
||||||
|
_attributes == null ? null : Map<String, dynamic>.from(_attributes); |
||||||
|
final Map<String, dynamic>? _attributes; |
||||||
|
|
||||||
|
/// Creates new [Operation] from JSON payload. |
||||||
|
/// |
||||||
|
/// If `dataDecoder` parameter is not null then it is used to additionally |
||||||
|
/// decode the operation's data object. Only applied to insert operations. |
||||||
|
static Operation fromJson(Map data, {DataDecoder? dataDecoder}) { |
||||||
|
dataDecoder ??= _passThroughDataDecoder; |
||||||
|
final map = Map<String, dynamic>.from(data); |
||||||
|
if (map.containsKey(Operation.insertKey)) { |
||||||
|
final data = dataDecoder(map[Operation.insertKey]); |
||||||
|
final dataLength = data is String ? data.length : 1; |
||||||
|
return Operation( |
||||||
|
Operation.insertKey, dataLength, data, map[Operation.attributesKey]); |
||||||
|
} else if (map.containsKey(Operation.deleteKey)) { |
||||||
|
final int? length = map[Operation.deleteKey]; |
||||||
|
return Operation(Operation.deleteKey, length, '', null); |
||||||
|
} else if (map.containsKey(Operation.retainKey)) { |
||||||
|
final int? length = map[Operation.retainKey]; |
||||||
|
return Operation( |
||||||
|
Operation.retainKey, length, '', map[Operation.attributesKey]); |
||||||
|
} |
||||||
|
throw ArgumentError.value(data, 'Invalid data for Delta operation.'); |
||||||
|
} |
||||||
|
|
||||||
|
/// Returns JSON-serializable representation of this operation. |
||||||
|
Map<String, dynamic> toJson() { |
||||||
|
final json = {key: value}; |
||||||
|
if (_attributes != null) json[Operation.attributesKey] = attributes; |
||||||
|
return json; |
||||||
|
} |
||||||
|
|
||||||
|
/// Returns value of this operation. |
||||||
|
/// |
||||||
|
/// For insert operations this returns text, for delete and retain - length. |
||||||
|
dynamic get value => (key == Operation.insertKey) ? data : length; |
||||||
|
|
||||||
|
/// Returns `true` if this is a delete operation. |
||||||
|
bool get isDelete => key == Operation.deleteKey; |
||||||
|
|
||||||
|
/// Returns `true` if this is an insert operation. |
||||||
|
bool get isInsert => key == Operation.insertKey; |
||||||
|
|
||||||
|
/// Returns `true` if this is a retain operation. |
||||||
|
bool get isRetain => key == Operation.retainKey; |
||||||
|
|
||||||
|
/// Returns `true` if this operation has no attributes, e.g. is plain text. |
||||||
|
bool get isPlain => _attributes == null || _attributes.isEmpty; |
||||||
|
|
||||||
|
/// Returns `true` if this operation sets at least one attribute. |
||||||
|
bool get isNotPlain => !isPlain; |
||||||
|
|
||||||
|
/// Returns `true` is this operation is empty. |
||||||
|
/// |
||||||
|
/// An operation is considered empty if its [length] is equal to `0`. |
||||||
|
bool get isEmpty => length == 0; |
||||||
|
|
||||||
|
/// Returns `true` is this operation is not empty. |
||||||
|
bool get isNotEmpty => length! > 0; |
||||||
|
|
||||||
|
@override |
||||||
|
bool operator ==(other) { |
||||||
|
if (identical(this, other)) return true; |
||||||
|
if (other is! Operation) return false; |
||||||
|
final typedOther = other; |
||||||
|
return key == typedOther.key && |
||||||
|
length == typedOther.length && |
||||||
|
_valueEquality.equals(data, typedOther.data) && |
||||||
|
hasSameAttributes(typedOther); |
||||||
|
} |
||||||
|
|
||||||
|
/// Returns `true` if this operation has attribute specified by [name]. |
||||||
|
bool hasAttribute(String name) => |
||||||
|
isNotPlain && _attributes!.containsKey(name); |
||||||
|
|
||||||
|
/// Returns `true` if [other] operation has the same attributes as this one. |
||||||
|
bool hasSameAttributes(Operation other) { |
||||||
|
// treat null and empty equal |
||||||
|
if ((_attributes?.isEmpty ?? true) && |
||||||
|
(other._attributes?.isEmpty ?? true)) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
return _attributeEquality.equals(_attributes, other._attributes); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
int get hashCode { |
||||||
|
if (_attributes != null && _attributes.isNotEmpty) { |
||||||
|
final attrsHash = |
||||||
|
hashObjects(_attributes.entries.map((e) => hash2(e.key, e.value))); |
||||||
|
return hash3(key, value, attrsHash); |
||||||
|
} |
||||||
|
return hash2(key, value); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
String toString() { |
||||||
|
final attr = attributes == null ? '' : ' + $attributes'; |
||||||
|
final text = isInsert |
||||||
|
? (data is String |
||||||
|
? (data as String).replaceAll('\n', '⏎') |
||||||
|
: data.toString()) |
||||||
|
: '$length'; |
||||||
|
return '$key⟨ $text ⟩$attr'; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
name: dart_quill_delta |
||||||
|
description: A port of quill-js-delta from typescript to dart |
||||||
|
version: 0.0.1 |
||||||
|
homepage: https://github.com/singerdmx/flutter-quill/tree/master/dart_quill_delta/ |
||||||
|
repository: https://github.com/singerdmx/flutter-quill/tree/master/dart_quill_delta/ |
||||||
|
issue_tracker: https://github.com/singerdmx/flutter-quill/issues/ |
||||||
|
documentation: https://github.com/singerdmx/flutter-quill/tree/master/dart_quill_delta/ |
||||||
|
|
||||||
|
environment: |
||||||
|
sdk: ^3.2.3 |
||||||
|
|
||||||
|
# Add regular dependencies here. |
||||||
|
dependencies: |
||||||
|
collection: ^1.17.0 |
||||||
|
diff_match_patch: ^0.4.1 |
||||||
|
quiver: ^3.2.1 |
||||||
|
|
||||||
|
dev_dependencies: |
||||||
|
lints: ^3.0.0 |
||||||
|
test: ^1.24.0 |
@ -0,0 +1,11 @@ |
|||||||
|
import 'package:test/test.dart'; |
||||||
|
|
||||||
|
void main() { |
||||||
|
group('A group of tests', () { |
||||||
|
setUp(() { |
||||||
|
// Additional setup goes here. |
||||||
|
}); |
||||||
|
|
||||||
|
test('First Test', () {}); |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1 @@ |
|||||||
|
extensions: |
File diff suppressed because it is too large
Load Diff
@ -1,14 +0,0 @@ |
|||||||
# Flutter Quill Packages |
|
||||||
|
|
||||||
This folder contains packages that add more features to the [FlutterQuill](../README.md) |
|
||||||
that might be outside of the packages main purpose |
|
||||||
|
|
||||||
Pub: [quill_html_converter](https://pub.dev/packages/quill_html_converter) |
|
||||||
|
|
||||||
## Table of contents |
|
||||||
- [Flutter Quill Packages](#flutter-quill-packages) |
|
||||||
- [Table of contents](#table-of-contents) |
|
||||||
- [Packages](#packages) |
|
||||||
|
|
||||||
## Packages |
|
||||||
- [quill_html_converter](./quill_html_converter/) |
|
@ -1,3 +0,0 @@ |
|||||||
dependency_overrides: |
|
||||||
flutter_quill: |
|
||||||
path: ../../ |
|
@ -1,3 +1,5 @@ |
|||||||
dependency_overrides: |
dependency_overrides: |
||||||
flutter_quill_test: |
flutter_quill_test: |
||||||
path: ./flutter_quill_test |
path: ./flutter_quill_test |
||||||
|
dart_quill_delta: |
||||||
|
path: ./dart_quill_delta |
@ -0,0 +1,21 @@ |
|||||||
|
MIT License |
||||||
|
|
||||||
|
Copyright (c) 2023 Flutter Quill Team |
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||||
|
of this software and associated documentation files (the "Software"), to deal |
||||||
|
in the Software without restriction, including without limitation the rights |
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||||
|
copies of the Software, and to permit persons to whom the Software is |
||||||
|
furnished to do so, subject to the following conditions: |
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all |
||||||
|
copies or substantial portions of the Software. |
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||||
|
SOFTWARE. |
@ -1,6 +1,6 @@ |
|||||||
library quill_html_converter; |
library quill_html_converter; |
||||||
|
|
||||||
import 'package:flutter_quill/flutter_quill.dart' show Delta; |
import 'package:dart_quill_delta/dart_quill_delta.dart'; |
||||||
import 'package:vsc_quill_delta_to_html/vsc_quill_delta_to_html.dart' |
import 'package:vsc_quill_delta_to_html/vsc_quill_delta_to_html.dart' |
||||||
as conventer show ConverterOptions, QuillDeltaToHtmlConverter; |
as conventer show ConverterOptions, QuillDeltaToHtmlConverter; |
||||||
|
|
@ -0,0 +1,3 @@ |
|||||||
|
dependency_overrides: |
||||||
|
dart_quill_delta: |
||||||
|
path: ../dart_quill_delta |
@ -1 +1 @@ |
|||||||
const version = '9.0.0-dev-8'; |
const version = '9.0.0-dev-11'; |
||||||
|
Loading…
Reference in new issue