diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..2e3f183 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,41 @@ +name: 🧪 Run Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + name: Check lints and tests + runs-on: ubuntu-latest + + steps: + - name: 📦 Checkout repository + uses: actions/checkout@v4 + + - name: 🛠️ Set up Dart + uses: dart-lang/setup-dart@v1 + + - name: 🔍 Verify Dart installation + run: dart --version + + - name: 📥 Install dependencies + run: dart pub get + + - name: 🔍 Run Dart analysis + run: dart analyze + + - name: 🧹 Check Dart code formatting + run: dart format --output=none --set-exit-if-changed . + + - name: 🔍 Preview Dart proposed changes + run: dart fix --dry-run + + - name: 📦 Check if package is ready for publishing + run: dart pub publish --dry-run + + - name: 🧪 Run dart tests + run: dart test + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5843e3c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## 10.8.1 + +* Seperate [dart_quill_delta](https://pub.dev/packages/dart_quill_delta) version from [flutter_quill](https://pub.dev/packages/flutter_quill). Discussed in [Flutter Quill #2259](https://github.com/singerdmx/flutter-quill/issues/2259) \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e7ff73e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Flutter Quill project and open source contributors. + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7667568 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# 📜 Dart Quill Delta + +An unofficial Dart port of [quill-js-delta](https://github.com/quilljs/delta/), originally written in TypeScript. + +This package provides a Dart implementation of the [Quill Delta](https://www.npmjs.com/package/quill-delta) format, which is a JSON-based data structure used to describe rich-text documents. For more details, refer to the official [Quill Delta documentation](https://quilljs.com/docs/delta/). + +## 📖 Background + +Previously, this package was part of the [flutter_quill](https://pub.dev/packages/flutter_quill) package, but it has since been separated into its own package. It maintains the same versioning as `flutter_quill`. The [Flutter Quill](http://github.com/singerdmx/flutter-quill) project forked this package from [quill_delta](https://pub.dev/packages/quill_delta). + +## ⚠️ Future Plans + +While this package is currently stable and does not have any significant issues, there have been discussions about introducing a **breaking change** release. This would enhance type safety and enforce the use of standard Quill Delta attributes, unless custom attributes are explicitly included. Currently, we have no plans to implement this change, but if introduced, it may be released as a separate package. + +## 🛠️ Supported Projects + +The following projects support or use this package: + +- [flutter_quill_to_pdf](https://pub.dev/packages/flutter_quill_to_pdf) +- [flutter_quill_delta_from_html](https://pub.dev/packages/flutter_quill_delta_from_html) +- [flutter_quill_delta_easy_parser](https://pub.dev/packages/flutter_quill_delta_easy_parser) +- [flutter_quill](https://pub.dev/packages/flutter_quill) +- [markdown_quill](https://pub.dev/packages/markdown_quill) +- [quill_markdown](https://pub.dev/packages/quill_markdown) +- [delta_markdown](https://pub.dev/packages/delta_markdown) +- [super_editor_quill](https://pub.dev/packages/super_editor_quill) + +## 📚 Documentation + +For detailed usage and API references, refer to the official [Quill Delta documentation](https://quilljs.com/docs/delta/). + +## 📜 Acknowledgments + +* The original package [quill_delta](https://pub.dev/packages/quill_delta) +* [Delta Delta](https://github.com/slab/delta) \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..572dd23 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/example/dart_quill_delta_example.dart b/example/dart_quill_delta_example.dart new file mode 100644 index 0000000..9683989 --- /dev/null +++ b/example/dart_quill_delta_example.dart @@ -0,0 +1,27 @@ +import 'package:dart_quill_delta/dart_quill_delta.dart'; + +void main() { + var doc = Delta()..insert('Hello world', {'h': '1'}); + var change = Delta() + ..retain(6) + ..delete(5) + ..insert('Earth'); + var result = doc.compose(change); + print('Original document:\n$doc\n'); + print('Change:\n$change\n'); + print('Updated document:\n$result\n'); + + /// Prints: + /// + /// Original document: + /// ins⟨Hello world⟩ + {h: 1} + /// + /// Change: + /// ret⟨6⟩ + /// ins⟨Earth⟩ + /// del⟨5⟩ + /// + /// Updated document: + /// ins⟨Hello ⟩ + {h: 1} + /// ins⟨Earth⟩ +} diff --git a/lib/dart_quill_delta.dart b/lib/dart_quill_delta.dart new file mode 100644 index 0000000..d29a906 --- /dev/null +++ b/lib/dart_quill_delta.dart @@ -0,0 +1,5 @@ +library; + +export './src/delta/delta.dart'; +export './src/delta/delta_iterator.dart'; +export './src/operation/operation.dart'; diff --git a/lib/src/delta/delta.dart b/lib/src/delta/delta.dart new file mode 100644 index 0000000..fc0527b --- /dev/null +++ b/lib/src/delta/delta.dart @@ -0,0 +1,563 @@ +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._([]); + + Delta._(this.operations); + + /// Creates new [Delta] from [other]. + factory Delta.from(Delta other) => + Delta._(List.from(other.operations)); + + /// Creates new [Delta] from a List of Operation + factory Delta.fromOperations(List operations) => + Delta._(operations.toList()); + + // Placeholder char for embed in diff() + static final String _kNullCharacter = String.fromCharCode(0); + + /// Transforms two attribute sets. + static Map? transformAttributes( + Map? a, Map? b, bool priority) { + if (a == null) return b; + if (b == null) return null; + + if (!priority) return b; + + final result = b.keys.fold>({}, (attributes, key) { + if (!a.containsKey(key)) attributes[key] = b[key]; + return attributes; + }); + + return result.isEmpty ? null : result; + } + + /// Composes two attribute sets. + static Map? composeAttributes( + Map? a, Map? b, + {bool keepNull = false}) { + a ??= const {}; + b ??= const {}; + + final result = Map.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 invertAttributes( + Map? attr, Map? 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.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? diffAttributes( + Map? a, Map? b) { + a ??= const {}; + b ??= const {}; + + final attributes = {}; + 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 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 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 ==(Object other) { + if (identical(this, other)) return true; + if (other is! Delta) return false; + final typedOther = other; + const comparator = ListEquality(DefaultEquality()); + return comparator.equals(operations, typedOther.operations); + } + + @override + int get hashCode => hashObjects(operations); + + /// Retain [count] of characters from current position. + void retain(int count, [Map? 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? 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 map(T Function(Operation) f) { + return operations.map(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'); +} diff --git a/lib/src/delta/delta_iterator.dart b/lib/src/delta/delta_iterator.dart new file mode 100644 index 0000000..bb8c41e --- /dev/null +++ b/lib/src/delta/delta_iterator.dart @@ -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; + } +} diff --git a/lib/src/operation/operation.dart b/lib/src/operation/operation.dart new file mode 100644 index 0000000..126942a --- /dev/null +++ b/lib/src/operation/operation.dart @@ -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.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? 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? 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 _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? get attributes => + _attributes == null ? null : Map.from(_attributes); + final Map? _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.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 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'; + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..5fa9a64 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,19 @@ +name: dart_quill_delta +description: "A Dart port of quill-js-delta, offering a simple and expressive JSON format for describing rich-text content and its changes." +version: 10.8.1 +homepage: https://github.com/FlutterQuill/dart-quill-delta +repository: https://github.com/FlutterQuill/dart-quill-delta +issue_tracker: https://github.com/FlutterQuill/dart-quill-delta/issues +documentation: https://github.com/FlutterQuill/dart-quill-delta + +environment: + sdk: ^3.2.3 + +dependencies: + collection: ^1.17.0 + diff_match_patch: ^0.4.1 + quiver: ^3.2.1 + +dev_dependencies: + lints: ^4.0.0 + test: ^1.24.0 diff --git a/test/dart_quill_delta_test.dart b/test/dart_quill_delta_test.dart new file mode 100644 index 0000000..a2208c6 --- /dev/null +++ b/test/dart_quill_delta_test.dart @@ -0,0 +1,1452 @@ +import 'dart:convert'; + +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import 'package:test/test.dart'; + +void main() { + group('invertAttributes', () { + test('attr is null', () { + var base = {'bold': true}; + expect(Delta.invertAttributes(null, base), {}); + }); + + test('base is null', () { + var attributes = {'bold': true}; + var expected = {'bold': null}; + expect(Delta.invertAttributes(attributes, null), expected); + }); + + test('both null', () { + expect(Delta.invertAttributes(null, null), {}); + }); + + test('merge', () { + var attributes = {'bold': true}; + var base = {'italic': true}; + var expected = {'bold': null}; + expect(Delta.invertAttributes(attributes, base), expected); + }); + + test('null', () { + var attributes = {'bold': null}; + var base = {'bold': true}; + var expected = {'bold': true}; + expect(Delta.invertAttributes(attributes, base), expected); + }); + + test('replace', () { + var attributes = {'color': 'red'}; + var base = {'color': 'blue'}; + var expected = base; + expect(Delta.invertAttributes(attributes, base), expected); + }); + + test('noop', () { + var attributes = {'color': 'red'}; + var base = {'color': 'red'}; + var expected = {}; + expect(Delta.invertAttributes(attributes, base), expected); + }); + + test('combined', () { + var attributes = { + 'bold': true, + 'italic': null, + 'color': 'red', + 'size': '12px' + }; + var base = { + 'font': 'serif', + 'italic': true, + 'color': 'blue', + 'size': '12px' + }; + var expected = {'bold': null, 'italic': true, 'color': 'blue'}; + expect(Delta.invertAttributes(attributes, base), expected); + }); + }); + + group('composeAttributes', () { + final attributes = const {'b': true, 'color': 'red'}; + + test('left is null', () { + expect(Delta.composeAttributes(null, attributes), attributes); + }); + + test('right is null', () { + expect(Delta.composeAttributes(attributes, null), attributes); + }); + + test('both are null', () { + expect(Delta.composeAttributes(null, null), isNull); + }); + + test('missing', () { + expect(Delta.composeAttributes(attributes, const {'i': true}), + {'b': true, 'color': 'red', 'i': true}); + }); + + test('overwrite', () { + expect( + Delta.composeAttributes( + attributes, const {'b': false, 'color': 'blue'}), + {'b': false, 'color': 'blue'}); + }); + + test('remove', () { + expect(Delta.composeAttributes(attributes, const {'b': null}), + {'color': 'red'}); + }); + + test('remove to null', () { + expect( + Delta.composeAttributes(attributes, const {'b': null, 'color': null}), + isNull); + }); + + test('remove missing', () { + expect( + Delta.composeAttributes(attributes, const {'i': null}), attributes); + }); + }); + + group('transformAttributes', () { + final left = const {'bold': true, 'color': 'red', 'font': null}; + final right = const {'color': 'blue', 'font': 'serif', 'italic': true}; + + test('left is null', () { + expect(Delta.transformAttributes(null, left, false), left); + }); + + test('right is null', () { + expect(Delta.transformAttributes(left, null, false), null); + }); + + test('both are null', () { + expect(Delta.transformAttributes(null, null, false), null); + }); + + test('with priority', () { + expect( + Delta.transformAttributes(left, right, true), const {'italic': true}); + }); + + test('without priority', () { + expect(Delta.transformAttributes(left, right, false), right); + }); + }); + + group('$Operation', () { + test('insert factory', () { + final op = Operation.insert('a', const {'b': true}); + expect(op.isInsert, isTrue); + expect(op.length, 1); + expect(op.attributes, const {'b': true}); + }); + + test('insert (object) factory', () { + final op = Operation.insert({}, const {'b': true}); + expect(op.isInsert, isTrue); + expect(op.length, 1); + expect(op.attributes, const {'b': true}); + }); + + test('delete factory', () { + final op = Operation.delete(5); + expect(op.isDelete, isTrue); + expect(op.length, 5); + expect(op.attributes, isNull); + }); + + test('retain factory', () { + final op = Operation.retain(5, const {'b': true}); + expect(op.isRetain, isTrue); + expect(op.length, 5); + expect(op.attributes, const {'b': true}); + }); + + test('isPlain', () { + final op1 = Operation.retain(1); + final op2 = Operation.retain(1, {}); + final op3 = Operation.retain(1, {'b': true}); + expect(op1.isPlain, isTrue); + expect(op2.isPlain, isTrue); + expect(op3.isPlain, isFalse); + expect(op1.isNotPlain, isFalse); + expect(op2.isNotPlain, isFalse); + expect(op3.isNotPlain, isTrue); + }); + + test('isEmpty', () { + final op1 = Operation.retain(0); + final op2 = Operation.retain(0, {}); + final op3 = Operation.retain(1); + expect(op1.isEmpty, isTrue); + expect(op2.isEmpty, isTrue); + expect(op3.isEmpty, isFalse); + expect(op1.isNotEmpty, isFalse); + expect(op2.isNotEmpty, isFalse); + expect(op3.isNotEmpty, isTrue); + }); + + test('equality', () { + final op1 = Operation.insert('a'); + final op2 = Operation.insert('b', const {'h': '1', 'b': true}); + final op3 = Operation.insert('b', const {'h': true, 'b': '1'}); + final op4 = Operation.insert('a'); + expect(op1, isNot(op2)); + expect(op2, isNot(op3)); + expect(op1, op4); + }); + + test('hashCode', () { + final op1 = Operation.insert('b', const {'h': '1', 'b': true}); + final op2 = Operation.insert('b', const {'h': '1', 'b': true}); + final op3 = Operation.insert('b', const {'h': true, 'b': '1'}); + expect(op2.hashCode, isNot(op3.hashCode)); + expect(op2.hashCode, op1.hashCode); + }); + + test('toString', () { + var op1 = Operation.insert( + 'Hello world!\nAnd fancy line-breaks.\n', {'b': true}); + var op2 = Operation.retain(3, {'b': '1'}); + var op3 = Operation.delete(3); + var op4 = Operation.insert({'a': 1}, {'b': true}); + expect( + '$op1', 'insert⟨ Hello world!⏎And fancy line-breaks.⏎ ⟩ + {b: true}'); + expect('$op2', 'retain⟨ 3 ⟩ + {b: 1}'); + expect('$op3', 'delete⟨ 3 ⟩'); + expect('$op4', 'insert⟨ {a: 1} ⟩ + {b: true}'); + }); + + test('attributes immutable', () { + var op = Operation.insert('\n', {'b': true}); + var attrs = op.attributes!; + attrs['b'] = null; + expect(op.attributes, {'b': true}); + }); + + test('attributes operator== simple', () { + var op1 = Operation.insert('\n', {'b': true}); + var op2 = Operation.insert('\n', {'b': true}); + expect(op1 == op2, isTrue); + }); + + test('attributes operator== complex', () { + var op1 = Operation.insert('\n', { + 'b': {'c': 'd'} + }); + var op2 = Operation.insert('\n', { + 'b': {'c': 'd'} + }); + expect(op1 == op2, isTrue); + }); + }); + + group('Delta', () { + test('isEmpty', () { + final delta = Delta(); + expect(delta, isEmpty); + }); + + test('json', () { + final delta = Delta() + ..insert('abc', {'b': true}) + ..insert('def') + ..insert({'a': 1}); + final result = json.encode(delta); + expect(result, + '[{"insert":"abc","attributes":{"b":true}},{"insert":"def"},{"insert":{"a":1}}]'); + final decoded = Delta.fromJson(json.decode(result)); + expect(decoded, delta); + }); + + test('toString', () { + final delta = Delta() + ..insert('Hello world!', {'b': true}) + ..retain(5); + expect('$delta', 'insert⟨ Hello world! ⟩ + {b: true}\nretain⟨ 5 ⟩'); + }); + + group('invert', () { + test('insert', () { + final delta = Delta() + ..retain(2) + ..insert('A'); + final base = Delta()..insert('123456'); + final expected = Delta() + ..retain(2) + ..delete(1); + final inverted = delta.invert(base); + expect(expected, inverted); + expect(base.compose(delta).compose(inverted), base); + }); + + test('delete', () { + final delta = Delta() + ..retain(2) + ..delete(3); + final base = Delta()..insert('123456'); + final expected = Delta() + ..retain(2) + ..insert('345'); + final inverted = delta.invert(base); + expect(expected, inverted); + expect(base.compose(delta).compose(inverted), base); + }); + + test('retain', () { + final delta = Delta() + ..retain(2) + ..retain(3, {'b': true}); + final base = Delta()..insert('123456'); + final expected = Delta() + ..retain(2) + ..retain(3, {'b': null}); + final inverted = delta.invert(base); + expect(expected, inverted); + expect(base.compose(delta).compose(inverted), base); + }); + + test('retain on a delta with different attributes', () { + final base = Delta() + ..insert('123') + ..insert('4', {'b': true}); + final delta = Delta()..retain(4, {'i': true}); + final expected = Delta()..retain(4, {'i': null}); + final inverted = delta.invert(base); + expect(expected, inverted); + expect(base.compose(delta).compose(inverted), base); + }); + + test('combined', () { + var delta = Delta() + ..retain(2) + ..delete(2) + ..insert('AB', {'italic': true}) + ..retain(2, {'italic': null, 'bold': true}) + ..retain(2, {'color': 'red'}) + ..delete(1); + var base = Delta() + ..insert('123', {'bold': true}) + ..insert('456', {'italic': true}) + ..insert('789', {'color': 'red', 'bold': true}); + var expected = Delta() + ..retain(2) + ..insert('3', {'bold': true}) + ..insert('4', {'italic': true}) + ..delete(2) + ..retain(2, {'italic': true, 'bold': null}) + ..retain(2) + ..insert('9', {'color': 'red', 'bold': true}); + + var inverted = delta.invert(base); + expect(inverted, expected); + expect(base.compose(delta).compose(inverted), base); + }); + }); + + group('push', () { + // ==== insert combinations ==== + + test('insert + insert', () { + final delta = Delta() + ..insert('abc') + ..insert('123'); + expect(delta.first, Operation.insert('abc123')); + }); + + test('insert + insert (object)', () { + const data = {}; + final delta = Delta() + ..insert('abc') + ..insert(data); + expect(delta[0], Operation.insert('abc')); + expect(delta[1], Operation.insert(data)); + }); + + test('insert (object) + insert', () { + const data = {}; + final delta = Delta() + ..insert(data) + ..insert('abc'); + expect(delta[0], Operation.insert(data)); + expect(delta[1], Operation.insert('abc')); + }); + + test('insert + delete', () { + final delta = Delta() + ..insert('abc') + ..delete(3); + expect(delta[0], Operation.insert('abc')); + expect(delta[1], Operation.delete(3)); + }); + + test('insert (object) + delete', () { + final delta = Delta() + ..insert(const {}) + ..delete(3); + expect(delta[0], Operation.insert(const {})); + expect(delta[1], Operation.delete(3)); + }); + + test('insert + retain', () { + final delta = Delta() + ..insert('abc') + ..retain(3); + expect(delta[0], Operation.insert('abc')); + expect(delta[1], Operation.retain(3)); + }); + + test('insert (object) + retain', () { + final delta = Delta() + ..insert(const {}) + ..retain(3); + expect(delta[0], Operation.insert(const {})); + expect(delta[1], Operation.retain(3)); + }); + + // ==== delete combinations ==== + + test('delete + insert', () { + final delta = Delta() + ..delete(2) + ..insert('abc'); + expect(delta[0], Operation.insert('abc')); + expect(delta[1], Operation.delete(2)); + }); + + test('delete + insert (object)', () { + final delta = Delta() + ..delete(2) + ..insert(const {}); + expect(delta[0], Operation.insert(const {})); + expect(delta[1], Operation.delete(2)); + }); + + test('delete + delete', () { + final delta = Delta() + ..delete(2) + ..delete(3); + expect(delta.first, Operation.delete(5)); + }); + + test('delete + retain', () { + final delta = Delta() + ..delete(2) + ..retain(3); + expect(delta[0], Operation.delete(2)); + expect(delta[1], Operation.retain(3)); + }); + + // ==== retain combinations ==== + + test('retain + insert', () { + final delta = Delta() + ..retain(2) + ..insert('abc'); + expect(delta[0], Operation.retain(2)); + expect(delta[1], Operation.insert('abc')); + }); + + test('retain + insert (object)', () { + final delta = Delta() + ..retain(2) + ..insert(const {}); + expect(delta[0], Operation.retain(2)); + expect(delta[1], Operation.insert(const {})); + }); + + test('retain + delete', () { + final delta = Delta() + ..retain(2) + ..delete(3); + expect(delta[0], Operation.retain(2)); + expect(delta[1], Operation.delete(3)); + }); + + test('retain + retain', () { + final delta = Delta() + ..retain(2) + ..retain(3); + expect(delta.first, Operation.retain(5)); + }); + + // ==== edge scenarios ==== + + test('consequent inserts with different attributes do not merge', () { + final delta = Delta() + ..insert('abc', const {'b': true}) + ..insert('123'); + expect(delta.toList(), [ + Operation.insert('abc', const {'b': true}), + Operation.insert('123'), + ]); + }); + + test('consequent inserts (object) do not merge', () { + final delta = Delta() + ..insert(const {}) + ..insert(const {}); + expect(delta.toList(), [ + Operation.insert(const {}), + Operation.insert(const {}), + ]); + }); + + test('consequent inserts (object) with different attributes do not merge', + () { + final delta = Delta() + ..insert(const {}, const {'b': true}) + ..insert(const {}); + expect(delta.toList(), [ + Operation.insert(const {}, const {'b': true}), + Operation.insert(const {}), + ]); + }); + + test('consequent inserts (object) with same attributes do not merge', () { + final delta = Delta() + ..insert(const {}, const {'b': true}) + ..insert(const {}, const {'b': true}); + expect(delta.toList(), [ + Operation.insert(const {}, const {'b': true}), + Operation.insert(const {}, const {'b': true}), + ]); + }); + + test('consequent retain with different attributes do not merge', () { + final delta = Delta() + ..retain(5, const {'b': true}) + ..retain(3); + expect(delta.toList(), [ + Operation.retain(5, const {'b': true}), + Operation.retain(3), + ]); + }); + + test('consequent inserts with same attributes merge', () { + final ul = {'block': 'ul'}; + final doc = Delta() + ..insert('DartConf') + ..insert('\n', ul) + ..insert('Los Angeles') + ..insert('\n', ul); + final change = Delta() + ..retain(8) + ..insert('\n', ul); + final result = doc.compose(change); + final expected = Delta() + ..insert('DartConf') + ..insert('\n\n', ul) + ..insert('Los Angeles') + ..insert('\n', ul); + expect(result, expected); + }); + + test('consequent deletes and inserts', () { + final doc = Delta()..insert('YOLOYOLO'); + final change = Delta() + ..insert('YATA') + ..delete(4) + ..insert('YATA'); + final result = doc.compose(change); + final expected = Delta()..insert('YATAYATAYOLO'); + expect(result, expected); + }); + + test('consequent deletes and inserts (object)', () { + final doc = Delta() + ..insert(const {}) + ..insert(const {}) + ..insert(const {}); + final change = Delta() + ..insert('YATA') + ..delete(2) + ..insert('YATA'); + final result = doc.compose(change); + final expected = Delta() + ..insert('YATAYATA') + ..insert(const {}); + expect(result, expected); + }); + }); + group('compose', () { + // ==== insert combinations ==== + + test('insert + insert', () { + final a = Delta()..insert('A'); + final b = Delta()..insert('B'); + final expected = Delta()..insert('BA'); + expect(a.compose(b), expected); + }); + + test('insert + insert (object)', () { + final a = Delta()..insert('A'); + final b = Delta()..insert(const {}); + final expected = Delta() + ..insert(const {}) + ..insert('A'); + expect(a.compose(b), expected); + }); + + test('insert (object) + insert', () { + final a = Delta()..insert(const {}); + final b = Delta()..insert('B'); + final expected = Delta() + ..insert('B') + ..insert(const {}); + expect(a.compose(b), expected); + }); + + test('insert + delete', () { + final a = Delta()..insert('A'); + final b = Delta()..delete(1); + expect(a.compose(b), isEmpty); + }); + + test('insert (object) + delete', () { + final a = Delta()..insert(const {}); + final b = Delta()..delete(1); + expect(a.compose(b), isEmpty); + }); + + test('insert + retain', () { + final a = Delta()..insert('A'); + final b = Delta()..retain(1, const {'b': true}); + expect(a.compose(b).toList(), [ + Operation.insert('A', const {'b': true}) + ]); + }); + + test('insert (object) + retain', () { + final a = Delta()..insert(const {}); + final b = Delta()..retain(1, const {'b': true}); + expect(a.compose(b).toList(), [ + Operation.insert(const {}, const {'b': true}) + ]); + }); + + // ==== delete combinations ==== + + test('delete + insert', () { + final a = Delta()..delete(1); + final b = Delta()..insert('B'); + final expected = Delta() + ..insert('B') + ..delete(1); + expect(a.compose(b), expected); + }); + + test('delete + insert (object)', () { + final a = Delta()..delete(1); + final b = Delta()..insert(const {}); + final expected = Delta() + ..insert(const {}) + ..delete(1); + expect(a.compose(b), expected); + }); + + test('delete + delete', () { + final a = Delta()..delete(1); + final b = Delta()..delete(1); + final expected = Delta()..delete(2); + expect(a.compose(b), expected); + }); + + test('delete + retain', () { + final a = Delta()..delete(1); + final b = Delta()..retain(1, const {'b': true}); + final expected = Delta() + ..delete(1) + ..retain(1, const {'b': true}); + expect(a.compose(b), expected); + }); + + // ==== retain combinations ==== + + test('retain + insert', () { + final a = Delta()..retain(1, const {'b': true}); + final b = Delta()..insert('B'); + final expected = Delta() + ..insert('B') + ..retain(1, const {'b': true}); + expect(a.compose(b), expected); + }); + + test('retain + insert (object)', () { + final a = Delta()..retain(1, const {'b': true}); + final b = Delta()..insert(const {}); + final expected = Delta() + ..insert(const {}) + ..retain(1, const {'b': true}); + expect(a.compose(b), expected); + }); + + test('retain + delete', () { + final a = Delta()..retain(1, const {'b': true}); + final b = Delta()..delete(1); + final expected = Delta()..delete(1); + expect(a.compose(b), expected); + }); + + test('retain + retain', () { + final a = Delta()..retain(1, const {'color': 'blue'}); + final b = Delta()..retain(1, const {'color': 'red', 'b': true}); + final expected = Delta()..retain(1, const {'color': 'red', 'b': true}); + expect(a.compose(b), expected); + }); + + // ===== other scenarios ===== + + test('insert in middle of text', () { + final a = Delta()..insert('Hello'); + final b = Delta() + ..retain(3) + ..insert('X'); + final expected = Delta()..insert('HelXlo'); + expect(a.compose(b), expected); + }); + + test('insert (object) in middle of text', () { + final a = Delta()..insert('Hello'); + final b = Delta() + ..retain(3) + ..insert(const {}); + final expected = Delta() + ..insert('Hel') + ..insert(const {}) + ..insert('lo'); + expect(a.compose(b), expected); + }); + + test('insert and delete ordering', () { + final a = Delta()..insert('Hello'); + final b = Delta()..insert('Hello'); + final insertFirst = Delta() + ..retain(3) + ..insert('X') + ..delete(1); + final deleteFirst = Delta() + ..retain(3) + ..delete(1) + ..insert('X'); + final expected = Delta()..insert('HelXo'); + expect(a.compose(insertFirst), expected); + expect(b.compose(deleteFirst), expected); + }); + + test('insert (object) and delete ordering', () { + final a = Delta() + ..insert(const [1]) + ..insert(const [2]) + ..insert(const [3]); + final b = Delta() + ..insert(const [1]) + ..insert(const [2]) + ..insert(const [3]); + final insertFirst = Delta() + ..retain(2) + ..insert('X') + ..delete(1); + final deleteFirst = Delta() + ..retain(2) + ..delete(1) + ..insert('X'); + final expected = Delta() + ..insert(const [1]) + ..insert(const [2]) + ..insert('X'); + expect(a.compose(insertFirst), expected); + expect(b.compose(deleteFirst), expected); + }); + + test('delete entire text', () { + final a = Delta() + ..retain(4) + ..insert('Hello'); + final b = Delta()..delete(9); + final expected = Delta()..delete(4); + expect(a.compose(b), expected); + }); + + test('delete object', () { + final a = Delta() + ..retain(4) + ..insert(const {}); + final b = Delta()..delete(5); + final expected = Delta()..delete(4); + expect(a.compose(b), expected); + }); + + test('retain more than length of text', () { + final a = Delta()..insert('Hello'); + final b = Delta()..retain(10); + final expected = Delta()..insert('Hello'); + expect(a.compose(b), expected); + }); + + test('retain more than length of op with object', () { + final a = Delta()..insert(const {}); + final b = Delta()..retain(10); + final expected = Delta()..insert(const {}); + expect(a.compose(b), expected); + }); + + test('remove all attributes', () { + final a = Delta()..insert('A', const {'b': true}); + final b = Delta()..retain(1, const {'b': null}); + final expected = Delta()..insert('A'); + expect(a.compose(b), expected); + }); + + test('remove all attributes in object', () { + final a = Delta()..insert(const {}, const {'b': true}); + final b = Delta()..retain(1, const {'b': null}); + final expected = Delta()..insert(const {}); + expect(a.compose(b), expected); + }); + }); + + group('transform', () { + test('insert + insert', () { + var a1 = Delta()..insert('A'); + var b1 = Delta()..insert('B'); + var a2 = Delta.from(a1); + var b2 = Delta.from(b1); + var expected1 = Delta() + ..retain(1) + ..insert('B'); + var expected2 = Delta()..insert('B'); + expect(a1.transform(b1, true), expected1); + expect(a2.transform(b2, false), expected2); + }); + + test('insert + insert (object)', () { + var a1 = Delta()..insert('A'); + var b1 = Delta()..insert(const {}); + var a2 = Delta.from(a1); + var b2 = Delta.from(b1); + var expected1 = Delta() + ..retain(1) + ..insert(const {}); + var expected2 = Delta()..insert(const {}); + expect(a1.transform(b1, true), expected1); + expect(a2.transform(b2, false), expected2); + }); + + test('insert + retain', () { + var a = Delta()..insert('A'); + var b = Delta()..retain(1, const {'bold': true, 'color': 'red'}); + var expected = Delta() + ..retain(1) + ..retain(1, const {'bold': true, 'color': 'red'}); + expect(a.transform(b, true), expected); + }); + + test('insert (object) + retain', () { + var a = Delta()..insert(const {}); + var b = Delta()..retain(1, const {'bold': true, 'color': 'red'}); + var expected = Delta() + ..retain(1) + ..retain(1, const {'bold': true, 'color': 'red'}); + expect(a.transform(b, true), expected); + }); + + test('insert + delete', () { + var a = Delta()..insert('A'); + var b = Delta()..delete(1); + var expected = Delta() + ..retain(1) + ..delete(1); + expect(a.transform(b, true), expected); + }); + + test('insert (object) + delete', () { + var a = Delta()..insert(const {}); + var b = Delta()..delete(1); + var expected = Delta() + ..retain(1) + ..delete(1); + expect(a.transform(b, true), expected); + }); + + test('delete + insert', () { + var a = Delta()..delete(1); + var b = Delta()..insert('B'); + var expected = Delta()..insert('B'); + expect(a.transform(b, true), expected); + }); + + test('delete + insert (object)', () { + var a = Delta()..delete(1); + var b = Delta()..insert(const {}); + var expected = Delta()..insert(const {}); + expect(a.transform(b, true), expected); + }); + + test('delete + retain', () { + var a = Delta()..delete(1); + var b = Delta()..retain(1, const {'bold': true, 'color': 'red'}); + var expected = Delta(); + expect(a.transform(b, true), expected); + }); + + test('delete + delete', () { + var a = Delta()..delete(1); + var b = Delta()..delete(1); + var expected = Delta(); + expect(a.transform(b, true), expected); + }); + + test('retain + insert', () { + var a = Delta()..retain(1, const {'color': 'blue'}); + var b = Delta()..insert('B'); + var expected = Delta()..insert('B'); + expect(a.transform(b, true), expected); + }); + + test('retain + insert (object)', () { + var a = Delta()..retain(1, const {'color': 'blue'}); + var b = Delta()..insert(const {}); + var expected = Delta()..insert(const {}); + expect(a.transform(b, true), expected); + }); + + test('retain + retain', () { + var a1 = Delta()..retain(1, const {'color': 'blue'}); + var b1 = Delta()..retain(1, const {'bold': true, 'color': 'red'}); + var a2 = Delta()..retain(1, const {'color': 'blue'}); + var b2 = Delta()..retain(1, const {'bold': true, 'color': 'red'}); + var expected1 = Delta()..retain(1, const {'bold': true}); + var expected2 = Delta(); + expect(a1.transform(b1, true), expected1); + expect(b2.transform(a2, true), expected2); + }); + + test('retain + retain without priority', () { + var a1 = Delta()..retain(1, const {'color': 'blue'}); + var b1 = Delta()..retain(1, const {'bold': true, 'color': 'red'}); + var a2 = Delta()..retain(1, const {'color': 'blue'}); + var b2 = Delta()..retain(1, const {'bold': true, 'color': 'red'}); + var expected1 = Delta() + ..retain(1, const {'bold': true, 'color': 'red'}); + var expected2 = Delta()..retain(1, const {'color': 'blue'}); + expect(a1.transform(b1, false), expected1); + expect(b2.transform(a2, false), expected2); + }); + + test('retain + delete', () { + var a = Delta()..retain(1, const {'color': 'blue'}); + var b = Delta()..delete(1); + var expected = Delta()..delete(1); + expect(a.transform(b, true), expected); + }); + + test('alternating edits', () { + var a1 = Delta() + ..retain(2) + ..insert('si') + ..delete(5); + var b1 = Delta() + ..retain(1) + ..insert('e') + ..delete(5) + ..retain(1) + ..insert('ow'); + var a2 = Delta.from(a1); + var b2 = Delta.from(b1); + var expected1 = Delta() + ..retain(1) + ..insert('e') + ..delete(1) + ..retain(2) + ..insert('ow'); + var expected2 = Delta() + ..retain(2) + ..insert('si') + ..delete(1); + expect(a1.transform(b1, false), expected1); + expect(b2.transform(a2, false), expected2); + }); + + test('conflicting appends', () { + var a1 = Delta() + ..retain(3) + ..insert('aa'); + var b1 = Delta() + ..retain(3) + ..insert('bb'); + var a2 = Delta.from(a1); + var b2 = Delta.from(b1); + var expected1 = Delta() + ..retain(5) + ..insert('bb'); + var expected2 = Delta() + ..retain(3) + ..insert('aa'); + expect(a1.transform(b1, true), expected1); + expect(b2.transform(a2, false), expected2); + }); + + test('prepend + append', () { + var a1 = Delta()..insert('aa'); + var b1 = Delta() + ..retain(3) + ..insert('bb'); + var expected1 = Delta() + ..retain(5) + ..insert('bb'); + var a2 = Delta.from(a1); + var b2 = Delta.from(b1); + var expected2 = Delta()..insert('aa'); + expect(a1.transform(b1, false), expected1); + expect(b2.transform(a2, false), expected2); + }); + + test('trailing deletes with differing lengths', () { + var a1 = Delta() + ..retain(2) + ..delete(1); + var b1 = Delta()..delete(3); + var expected1 = Delta()..delete(2); + var a2 = Delta.from(a1); + var b2 = Delta.from(b1); + var expected2 = Delta(); + expect(a1.transform(b1, false), expected1); + expect(b2.transform(a2, false), expected2); + }); + }); + + group('transformPosition', () { + test('insert before position', () { + var delta = Delta()..insert('A'); + expect(delta.transformPosition(2), 3); + }); + + test('insert (object) before position', () { + var delta = Delta()..insert(const {}); + expect(delta.transformPosition(2), 3); + }); + + test('insert after position', () { + var delta = Delta() + ..retain(2) + ..insert('A'); + expect(delta.transformPosition(1), 1); + }); + + test('insert (object) after position', () { + var delta = Delta() + ..retain(2) + ..insert(const {}); + expect(delta.transformPosition(1), 1); + }); + + test('insert at position', () { + var delta = Delta() + ..retain(2) + ..insert('A'); + expect(delta.transformPosition(2, force: false), 2); + expect(delta.transformPosition(2, force: true), 3); + }); + + test('insert (object) at position', () { + var delta = Delta() + ..retain(2) + ..insert(const {}); + expect(delta.transformPosition(2, force: false), 2); + expect(delta.transformPosition(2, force: true), 3); + }); + + test('delete before position', () { + var delta = Delta()..delete(2); + expect(delta.transformPosition(4), 2); + }); + + test('delete after position', () { + var delta = Delta() + ..retain(4) + ..delete(2); + expect(delta.transformPosition(2), 2); + }); + + test('delete across position', () { + var delta = Delta() + ..retain(1) + ..delete(4); + expect(delta.transformPosition(2), 1); + }); + + test('insert and delete before position', () { + var delta = Delta() + ..retain(2) + ..insert('A') + ..delete(2); + expect(delta.transformPosition(4), 3); + }); + + test('insert before and delete across position', () { + var delta = Delta() + ..retain(2) + ..insert('A') + ..delete(4); + expect(delta.transformPosition(4), 3); + }); + + test('delete before and delete across position', () { + var delta = Delta() + ..delete(1) + ..retain(1) + ..delete(4); + expect(delta.transformPosition(4), 1); + }); + }); + + group('slice', () { + test('start', () { + var slice = (Delta() + ..retain(2) + ..insert('A')) + .slice(2); + var expected = Delta()..insert('A'); + expect(slice, expected); + }); + + test('start and end chop', () { + var slice = (Delta()..insert('0123456789')).slice(2, 7); + var expected = Delta()..insert('23456'); + expect(slice, expected); + }); + + test('start and end multiple chop', () { + var slice = (Delta() + ..insert('0123', {'bold': true}) + ..insert('4567')) + .slice(3, 5); + var expected = Delta() + ..insert('3', {'bold': true}) + ..insert('4'); + expect(slice, expected); + }); + + test('start and end', () { + var slice = (Delta() + ..retain(2) + ..insert('A', {'bold': true}) + ..insert('B')) + .slice(2, 3); + var expected = Delta()..insert('A', {'bold': true}); + expect(slice, expected); + }); + + test('start and end objects', () { + var slice = (Delta() + ..insert(const [1]) + ..insert(const [2]) + ..insert(const [3]) + ..insert(const [4]) + ..insert(const [5])) + .slice(2, 3); + var expected = Delta()..insert(const [3]); + expect(slice, expected); + }); + + test('from beginning', () { + var delta = Delta() + ..retain(2) + ..insert('A', {'bold': true}) + ..insert('B'); + var slice = delta.slice(0); + expect(slice, delta); + }); + + test('split ops', () { + var slice = (Delta() + ..insert('AB', {'bold': true}) + ..insert('C')) + .slice(1, 2); + var expected = Delta()..insert('B', {'bold': true}); + expect(slice, expected); + }); + + test('split ops multiple times', () { + var slice = (Delta() + ..insert('ABC', {'bold': true}) + ..insert('D')) + .slice(1, 2); + var expected = Delta()..insert('B', {'bold': true}); + expect(slice, expected); + }); + }); + + group('diff', () { + test('insert', () { + final a = Delta()..insert('A'); + final b = Delta()..insert('AB'); + final expected = Delta() + ..retain(1) + ..insert('B'); + expect(a.diff(b), expected); + }); + + test('delete', () { + final a = Delta()..insert('AB'); + final b = Delta()..insert('A'); + final expected = Delta() + ..retain(1) + ..delete(1); + expect(a.diff(b), expected); + }); + + test('retain', () { + final a = Delta()..insert('A'); + final b = Delta()..insert('A'); + final expected = Delta(); + expect(a.diff(b), expected); + }); + + test('format', () { + final a = Delta()..insert('A'); + final b = Delta()..insert('A', {'b': true}); + final expected = Delta()..retain(1, {'b': true}); + expect(a.diff(b), expected); + }); + + test('object attributes', () { + final a = Delta() + ..insert('A', { + 'font': {'family': 'Helvetica', 'size': '15px'}, + }); + final b = Delta() + ..insert('A', { + 'font': {'family': 'Helvetica', 'size': '15px'}, + }); + var expected = Delta(); + expect(a.diff(b), expected); + }); + + test('embed integer match', () { + final a = Delta()..insert(1); + final b = Delta()..insert(1); + final expected = Delta(); + expect(a.diff(b), expected); + }); + + test('embed integer mismatch', () { + final a = Delta()..insert(1); + final b = Delta()..insert(2); + final expected = Delta() + ..delete(1) + ..insert(2); + expect(a.diff(b), expected); + }); + + test('embed object match', () { + final a = Delta()..insert({'image': 'http://google.com'}); + final b = Delta()..insert({'image': 'http://google.com'}); + final expected = Delta(); + expect(a.diff(b), expected); + }); + + test('embed object mismatch', () { + final a = Delta() + ..insert({ + 'image': 'http://google.com', + 'alt': 'Overwrite', + }); + final b = Delta()..insert({'image': 'http://google.com'}); + var expected = Delta() + ..insert({'image': 'http://google.com'}) + ..delete(1); + expect(a.diff(b), expected); + }); + + test('embed object change', () { + final a = Delta()..insert({'image': 'http://google.com'}); + final b = Delta()..insert({'image': 'http://github.com'}); + final expected = Delta() + ..insert({'image': 'http://github.com'}) + ..delete(1); + expect(a.diff(b), expected); + }); + + test('embed false positive', () { + final a = Delta()..insert(1); + final b = Delta() + ..insert( + String.fromCharCode(0)); // Placeholder char for embed in diff() + final expected = Delta() + ..insert(String.fromCharCode(0)) + ..delete(1); + expect(a.diff(b), expected); + }); + + test('error on non-documents', () { + final a = Delta()..insert('A'); + final b = Delta() + ..retain(1) + ..insert('B'); + expect(() => a.diff(b), throwsArgumentError); + expect(() => b.diff(a), throwsArgumentError); + }); + + test('inconvenient indexes', () { + final a = Delta() + ..insert('12', {'b': true}) + ..insert('34', {'i': true}); + final b = Delta()..insert('123', {'bg': 'red'}); + final expected = Delta() + ..retain(2, {'b': null, 'bg': 'red'}) + ..retain(1, {'i': null, 'bg': 'red'}) + ..delete(1); + expect(a.diff(b), expected); + }); + + test('combination', () { + final a = Delta() + ..insert('Bad', {'bg': 'red'}) + ..insert('cat', {'bg': 'blue'}); + final b = Delta() + ..insert('Good', {'b': true}) + ..insert('dog', {'i': true}); + final expected = Delta() + ..insert('Good', {'b': true}) + ..delete(2) + ..retain(1, {'i': true, 'bg': null}) + ..delete(3) + ..insert('og', {'i': true}); + expect(a.diff(b, cleanupSemantic: false), expected); + }); + + test('cleanup semantic', () { + final a = Delta() + ..insert('Bad', {'bg': 'red'}) + ..insert('cat', {'bg': 'blue'}); + final b = Delta() + ..insert('Good', {'b': true}) + ..insert('dog', {'i': true}); + final expected = Delta() + ..insert('Good', {'b': true}) + ..insert('dog', {'i': true}) + ..delete(6); + expect(a.diff(b), expected); + }); + + test('same document', () { + final a = Delta() + ..insert('A') + ..insert('B', {'b': true}); + final expected = Delta(); + expect(a.diff(a), expected); + }); + + test('non-document', () { + final a = Delta()..insert('Test'); + final b = Delta()..delete(4); + expect(() => a.diff(b), throwsArgumentError); + }); + }); + }); + + group('DeltaIterator', () { + var delta = Delta() + ..insert('Hello', {'b': true}) + ..retain(3) + ..insert(' world', {'i': true}) + ..insert(Embed('hr')) + ..delete(4); + late DeltaIterator iterator; + + setUp(() { + iterator = DeltaIterator(delta); + }); + + test('hasNext', () { + expect(iterator.hasNext, isTrue); + iterator + ..next() + ..next() + ..next() + ..next() + ..next(); + expect(iterator.hasNext, isFalse); + }); + + test('peekLength', () { + expect(iterator.peekLength(), 5); + iterator.next(); + expect(iterator.peekLength(), 3); + iterator.next(); + expect(iterator.peekLength(), 6); + iterator.next(); + expect(iterator.peekLength(), 1); + iterator.next(); + expect(iterator.peekLength(), 4); + iterator.next(); + }); + + test('peekLength with operation split', () { + iterator.next(2); + expect(iterator.peekLength(), 5 - 2); + }); + + test('peekLength after EOF', () { + iterator.skip(19); + expect(iterator.peekLength(), DeltaIterator.maxLength); + }); + + test('peek operation type', () { + expect(iterator.isNextInsert, isTrue); + iterator.next(); + expect(iterator.isNextRetain, isTrue); + iterator.next(); + expect(iterator.isNextInsert, isTrue); + iterator.next(); + expect(iterator.isNextInsert, isTrue); + iterator.next(); + expect(iterator.isNextDelete, isTrue); + iterator.next(); + }); + + test('next', () { + expect(iterator.next(), Operation.insert('Hello', {'b': true})); + expect(iterator.next(), Operation.retain(3)); + expect(iterator.next(), Operation.insert(' world', {'i': true})); + expect(iterator.next(), Operation.insert(Embed('hr'))); + expect(iterator.next(), Operation.delete(4)); + }); + + test('next with operation split', () { + expect(iterator.next(2), Operation.insert('He', {'b': true})); + expect(iterator.next(10), Operation.insert('llo', {'b': true})); + expect(iterator.next(1), Operation.retain(1)); + expect(iterator.next(2), Operation.retain(2)); + }); + + test('next after EOF', () { + iterator.skip(19); + expect(iterator.next(), Operation.retain(DeltaIterator.maxLength)); + }); + }); +} + +class Embed { + final String data; + + Embed(this.data); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! Embed) return false; + final typedOther = other; + return typedOther.data == data; + } + + @override + int get hashCode => data.hashCode; + + Map toJson() => {'data': data}; +}