From 4df6b26bec49cfbd7f76d04916609207bc0f5f9d Mon Sep 17 00:00:00 2001 From: Ellet Date: Sat, 9 Dec 2023 00:56:10 +0300 Subject: [PATCH] Split quill delta into seperate package --- .github/workflows/main.yml | 2 +- .github/workflows/publish.yml | 2 +- .gitignore | 2 - README.md | 2 +- dart_quill_delta/.gitignore | 7 + dart_quill_delta/CHANGELOG.md | 3 + .../LICENSE | 0 dart_quill_delta/README.md | 2 + dart_quill_delta/analysis_options.yaml | 30 + .../example/dart_quill_delta_example.dart | 1 + dart_quill_delta/lib/dart_quill_delta.dart | 5 + dart_quill_delta/lib/src/delta/delta.dart | 562 ++++++ .../lib/src/delta/delta_iterator.dart | 100 + .../lib/src/operation/operation.dart | 171 ++ dart_quill_delta/pubspec.yaml | 20 + .../test/dart_quill_delta_test.dart | 11 + example/pubspec.yaml | 2 +- lib/src/models/quill_delta.dart | 1656 +++++++++-------- packages/README.md | 14 - .../pubspec_overrides.yaml.disabled | 3 - pubspec.yaml | 3 +- pubspec_overrides.yaml.disabled | 4 +- .../.gitignore | 0 .../.metadata | 0 .../CHANGELOG.md | 0 quill_html_converter/LICENSE | 21 + .../README.md | 0 .../analysis_options.yaml | 0 .../lib/quill_html_converter.dart | 2 +- .../pubspec.yaml | 11 +- .../pubspec_overrides.yaml.disabled | 3 + .../test/quill_html_converter.dart | 0 scripts/disable_local_dev.sh | 2 +- scripts/enable_local_dev.sh | 2 +- scripts/pub_get.sh | 2 +- scripts/regenerate_versions.dart | 2 +- 36 files changed, 1783 insertions(+), 864 deletions(-) create mode 100644 dart_quill_delta/.gitignore create mode 100644 dart_quill_delta/CHANGELOG.md rename {packages/quill_html_converter => dart_quill_delta}/LICENSE (100%) create mode 100644 dart_quill_delta/README.md create mode 100644 dart_quill_delta/analysis_options.yaml create mode 100644 dart_quill_delta/example/dart_quill_delta_example.dart create mode 100644 dart_quill_delta/lib/dart_quill_delta.dart create mode 100644 dart_quill_delta/lib/src/delta/delta.dart create mode 100644 dart_quill_delta/lib/src/delta/delta_iterator.dart create mode 100644 dart_quill_delta/lib/src/operation/operation.dart create mode 100644 dart_quill_delta/pubspec.yaml create mode 100644 dart_quill_delta/test/dart_quill_delta_test.dart delete mode 100644 packages/README.md delete mode 100644 packages/quill_html_converter/pubspec_overrides.yaml.disabled rename {packages/quill_html_converter => quill_html_converter}/.gitignore (100%) rename {packages/quill_html_converter => quill_html_converter}/.metadata (100%) rename {packages/quill_html_converter => quill_html_converter}/CHANGELOG.md (100%) create mode 100644 quill_html_converter/LICENSE rename {packages/quill_html_converter => quill_html_converter}/README.md (100%) rename {packages/quill_html_converter => quill_html_converter}/analysis_options.yaml (100%) rename {packages/quill_html_converter => quill_html_converter}/lib/quill_html_converter.dart (94%) rename {packages/quill_html_converter => quill_html_converter}/pubspec.yaml (77%) create mode 100644 quill_html_converter/pubspec_overrides.yaml.disabled rename {packages/quill_html_converter => quill_html_converter}/test/quill_html_converter.dart (100%) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5a709a98..c4528f8d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,7 +33,7 @@ jobs: run: flutter pub get -C flutter_quill_test - name: Install quill_html_converter dependencies - run: flutter pub get -C packages/quill_html_converter + run: flutter pub get -C quill_html_converter - name: Run flutter analysis run: flutter analyze diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 45970dc5..af93476e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -51,4 +51,4 @@ jobs: - name: Publish quill_html_converter run: flutter pub publish --force - working-directory: ./packages/quill_html_converter/ + working-directory: ./quill_html_converter/ diff --git a/.gitignore b/.gitignore index fc03c276..9290cda2 100644 --- a/.gitignore +++ b/.gitignore @@ -80,7 +80,5 @@ pubspec.lock # For local development pubspec_overrides.yaml -old_example - # A directory where you put all of your local things that you don't want to push .flutter-quill \ No newline at end of file diff --git a/README.md b/README.md index ecd67fb8..0bc82533 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ it to other formats such as HTML to publish it, or send an email. You have two options: -1. Using [quill_html_converter](./packages/quill_html_converter/) to convert to HTML, the package can convert the Quill delta to HTML well +1. Using [quill_html_converter](./quill_html_converter/) to convert to HTML, the package can convert the Quill delta to HTML well (it uses [vsc_quill_delta_to_html](https://pub.dev/packages/vsc_quill_delta_to_html)), it just a handy extension to do it more quickly 1. Another option is to use [vsc_quill_delta_to_html](https://pub.dev/packages/vsc_quill_delta_to_html) to convert your document diff --git a/dart_quill_delta/.gitignore b/dart_quill_delta/.gitignore new file mode 100644 index 00000000..3cceda55 --- /dev/null +++ b/dart_quill_delta/.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/dart_quill_delta/CHANGELOG.md b/dart_quill_delta/CHANGELOG.md new file mode 100644 index 00000000..59f8a732 --- /dev/null +++ b/dart_quill_delta/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* Initial version. diff --git a/packages/quill_html_converter/LICENSE b/dart_quill_delta/LICENSE similarity index 100% rename from packages/quill_html_converter/LICENSE rename to dart_quill_delta/LICENSE diff --git a/dart_quill_delta/README.md b/dart_quill_delta/README.md new file mode 100644 index 00000000..7d7aad35 --- /dev/null +++ b/dart_quill_delta/README.md @@ -0,0 +1,2 @@ +# Dart Quill Delta +A port of [quill-js-delta](https://github.com/quilljs/delta/) from typescript to dart \ No newline at end of file diff --git a/dart_quill_delta/analysis_options.yaml b/dart_quill_delta/analysis_options.yaml new file mode 100644 index 00000000..dee8927a --- /dev/null +++ b/dart_quill_delta/analysis_options.yaml @@ -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 diff --git a/dart_quill_delta/example/dart_quill_delta_example.dart b/dart_quill_delta/example/dart_quill_delta_example.dart new file mode 100644 index 00000000..ab73b3a2 --- /dev/null +++ b/dart_quill_delta/example/dart_quill_delta_example.dart @@ -0,0 +1 @@ +void main() {} diff --git a/dart_quill_delta/lib/dart_quill_delta.dart b/dart_quill_delta/lib/dart_quill_delta.dart new file mode 100644 index 00000000..d29a9063 --- /dev/null +++ b/dart_quill_delta/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/dart_quill_delta/lib/src/delta/delta.dart b/dart_quill_delta/lib/src/delta/delta.dart new file mode 100644 index 00000000..cb3a60be --- /dev/null +++ b/dart_quill_delta/lib/src/delta/delta.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._([]); + + 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 ==(dynamic 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/dart_quill_delta/lib/src/delta/delta_iterator.dart b/dart_quill_delta/lib/src/delta/delta_iterator.dart new file mode 100644 index 00000000..bb8c41e0 --- /dev/null +++ b/dart_quill_delta/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/dart_quill_delta/lib/src/operation/operation.dart b/dart_quill_delta/lib/src/operation/operation.dart new file mode 100644 index 00000000..126942ae --- /dev/null +++ b/dart_quill_delta/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/dart_quill_delta/pubspec.yaml b/dart_quill_delta/pubspec.yaml new file mode 100644 index 00000000..6f853b27 --- /dev/null +++ b/dart_quill_delta/pubspec.yaml @@ -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 diff --git a/dart_quill_delta/test/dart_quill_delta_test.dart b/dart_quill_delta/test/dart_quill_delta_test.dart new file mode 100644 index 00000000..7de0162a --- /dev/null +++ b/dart_quill_delta/test/dart_quill_delta_test.dart @@ -0,0 +1,11 @@ +import 'package:test/test.dart'; + +void main() { + group('A group of tests', () { + setUp(() { + // Additional setup goes here. + }); + + test('First Test', () {}); + }); +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index b3d2e15c..2fa4358a 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -54,7 +54,7 @@ dependency_overrides: flutter_quill_test: path: ../flutter_quill_test quill_html_converter: - path: ../packages/quill_html_converter + path: ../quill_html_converter dev_dependencies: diff --git a/lib/src/models/quill_delta.dart b/lib/src/models/quill_delta.dart index 196651e9..8b31045e 100644 --- a/lib/src/models/quill_delta.dart +++ b/lib/src/models/quill_delta.dart @@ -1,827 +1,829 @@ -/// Implementation of Quill Delta format in Dart. -library; - -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'; - -const _attributeEquality = DeepCollectionEquality(); -const _valueEquality = DeepCollectionEquality(); - -/// 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; - -/// 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'; - } -} - -/// 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._(List operations) : _operations = 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 ==(dynamic 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'); -} - -/// 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; - } -} +export 'package:dart_quill_delta/dart_quill_delta.dart'; + +// /// Implementation of Quill Delta format in Dart. +// library; + +// 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'; + +// const _attributeEquality = DeepCollectionEquality(); +// const _valueEquality = DeepCollectionEquality(); + +// /// 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; + +// /// 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'; +// } +// } + +// /// 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._(List operations) : _operations = 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 ==(dynamic 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'); +// } + +// /// 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/packages/README.md b/packages/README.md deleted file mode 100644 index 0f8fce77..00000000 --- a/packages/README.md +++ /dev/null @@ -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/) \ No newline at end of file diff --git a/packages/quill_html_converter/pubspec_overrides.yaml.disabled b/packages/quill_html_converter/pubspec_overrides.yaml.disabled deleted file mode 100644 index 844dcdea..00000000 --- a/packages/quill_html_converter/pubspec_overrides.yaml.disabled +++ /dev/null @@ -1,3 +0,0 @@ -dependency_overrides: - flutter_quill: - path: ../../ \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 7c506343..2d9c3572 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,8 +47,6 @@ dependencies: collection: ^1.17.0 flutter_colorpicker: ^1.0.3 quiver: ^3.2.1 - characters: ^1.3.0 - diff_match_patch: ^0.4.1 equatable: ^2.0.5 meta: ^1.9.1 @@ -62,6 +60,7 @@ dependencies: flutter_keyboard_visibility: ^5.4.1 device_info_plus: ^9.1.0 super_clipboard: ^0.7.3 + dart_quill_delta: ^0.0.1 dev_dependencies: flutter_lints: ^3.0.1 diff --git a/pubspec_overrides.yaml.disabled b/pubspec_overrides.yaml.disabled index 0c0f849c..94d344b0 100644 --- a/pubspec_overrides.yaml.disabled +++ b/pubspec_overrides.yaml.disabled @@ -1,3 +1,5 @@ dependency_overrides: flutter_quill_test: - path: ./flutter_quill_test \ No newline at end of file + path: ./flutter_quill_test + dart_quill_delta: + path: ./dart_quill_delta \ No newline at end of file diff --git a/packages/quill_html_converter/.gitignore b/quill_html_converter/.gitignore similarity index 100% rename from packages/quill_html_converter/.gitignore rename to quill_html_converter/.gitignore diff --git a/packages/quill_html_converter/.metadata b/quill_html_converter/.metadata similarity index 100% rename from packages/quill_html_converter/.metadata rename to quill_html_converter/.metadata diff --git a/packages/quill_html_converter/CHANGELOG.md b/quill_html_converter/CHANGELOG.md similarity index 100% rename from packages/quill_html_converter/CHANGELOG.md rename to quill_html_converter/CHANGELOG.md diff --git a/quill_html_converter/LICENSE b/quill_html_converter/LICENSE new file mode 100644 index 00000000..e82b91ed --- /dev/null +++ b/quill_html_converter/LICENSE @@ -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. diff --git a/packages/quill_html_converter/README.md b/quill_html_converter/README.md similarity index 100% rename from packages/quill_html_converter/README.md rename to quill_html_converter/README.md diff --git a/packages/quill_html_converter/analysis_options.yaml b/quill_html_converter/analysis_options.yaml similarity index 100% rename from packages/quill_html_converter/analysis_options.yaml rename to quill_html_converter/analysis_options.yaml diff --git a/packages/quill_html_converter/lib/quill_html_converter.dart b/quill_html_converter/lib/quill_html_converter.dart similarity index 94% rename from packages/quill_html_converter/lib/quill_html_converter.dart rename to quill_html_converter/lib/quill_html_converter.dart index 2e8d080f..7ed4fe0b 100644 --- a/packages/quill_html_converter/lib/quill_html_converter.dart +++ b/quill_html_converter/lib/quill_html_converter.dart @@ -1,6 +1,6 @@ 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' as conventer show ConverterOptions, QuillDeltaToHtmlConverter; diff --git a/packages/quill_html_converter/pubspec.yaml b/quill_html_converter/pubspec.yaml similarity index 77% rename from packages/quill_html_converter/pubspec.yaml rename to quill_html_converter/pubspec.yaml index 47f5796b..d0918020 100644 --- a/packages/quill_html_converter/pubspec.yaml +++ b/quill_html_converter/pubspec.yaml @@ -1,10 +1,10 @@ name: quill_html_converter description: A extension for flutter_quill package to add support for dealing with conversion to/from html version: 9.0.0-dev-11 -homepage: https://github.com/singerdmx/flutter-quill/tree/master/packages/quill_html_converter/ -repository: https://github.com/singerdmx/flutter-quill/tree/master/packages/quill_html_converter/ +homepage: https://github.com/singerdmx/flutter-quill/tree/master/quill_html_converter/ +repository: https://github.com/singerdmx/flutter-quill/tree/master/quill_html_converter/ issue_tracker: https://github.com/singerdmx/flutter-quill/issues/ -documentation: https://github.com/singerdmx/flutter-quill/tree/master/packages/quill_html_converter/ +documentation: https://github.com/singerdmx/flutter-quill/tree/master/quill_html_converter/ topics: - ui @@ -20,14 +20,13 @@ environment: dependencies: flutter: sdk: flutter - flutter_quill: ^9.0.0-dev-6 vsc_quill_delta_to_html: ^1.0.3 html2md: ^1.3.1 - # markdown: ^7.1.1 - # delta_markdown_converter: ^0.0.3-dev markdown: ^7.1.1 charcode: ^1.3.1 collection: ^1.18.0 + dart_quill_delta: ^0.0.1 + dev_dependencies: flutter_test: diff --git a/quill_html_converter/pubspec_overrides.yaml.disabled b/quill_html_converter/pubspec_overrides.yaml.disabled new file mode 100644 index 00000000..aae8e5dd --- /dev/null +++ b/quill_html_converter/pubspec_overrides.yaml.disabled @@ -0,0 +1,3 @@ +dependency_overrides: + dart_quill_delta: + path: ../dart_quill_delta \ No newline at end of file diff --git a/packages/quill_html_converter/test/quill_html_converter.dart b/quill_html_converter/test/quill_html_converter.dart similarity index 100% rename from packages/quill_html_converter/test/quill_html_converter.dart rename to quill_html_converter/test/quill_html_converter.dart diff --git a/scripts/disable_local_dev.sh b/scripts/disable_local_dev.sh index a64ba486..ca52b8c2 100755 --- a/scripts/disable_local_dev.sh +++ b/scripts/disable_local_dev.sh @@ -20,7 +20,7 @@ rm flutter_quill_test/pubspec_overrides.yaml echo "" echo "Disable local development for all the other packages..." -rm packages/quill_html_converter/pubspec_overrides.yaml +rm quill_html_converter/pubspec_overrides.yaml echo "" diff --git a/scripts/enable_local_dev.sh b/scripts/enable_local_dev.sh index 603465bc..c37bc895 100755 --- a/scripts/enable_local_dev.sh +++ b/scripts/enable_local_dev.sh @@ -20,7 +20,7 @@ cp flutter_quill_test/pubspec_overrides.yaml.disabled flutter_quill_test/pubspec echo "" echo "Enable local development for all the other packages..." -cp packages/quill_html_converter/pubspec_overrides.yaml.disabled packages/quill_html_converter/pubspec_overrides.yaml +cp quill_html_converter/pubspec_overrides.yaml.disabled quill_html_converter/pubspec_overrides.yaml echo "" diff --git a/scripts/pub_get.sh b/scripts/pub_get.sh index 6bd082c8..a836d79f 100644 --- a/scripts/pub_get.sh +++ b/scripts/pub_get.sh @@ -2,4 +2,4 @@ flutter pub get (cd flutter_quill_extensions && flutter pub get) (cd flutter_quill_test && flutter pub get) -(cd packages/quill_html_converter && flutter pub get) +(cd quill_html_converter && flutter pub get) diff --git a/scripts/regenerate_versions.dart b/scripts/regenerate_versions.dart index ed73e523..27989012 100644 --- a/scripts/regenerate_versions.dart +++ b/scripts/regenerate_versions.dart @@ -13,7 +13,7 @@ final packages = [ './', './flutter_quill_extensions', './flutter_quill_test', - './packages/quill_html_converter' + './quill_html_converter' ]; Future main(List args) async {