Browse Source

Merge pull request #807 from AppFlowy-IO/refactor/text-delta

Fix: emoji position and unicode issues
Vincent Chan 2 years ago
parent
commit
7ac5f822c9

+ 1 - 3
frontend/app_flowy/packages/flowy_editor/lib/src/document/node.dart

@@ -219,7 +219,5 @@ class TextNode extends Node {
         delta: delta ?? this.delta,
       );
 
-  // TODO: It's unneccesry to compute everytime.
-  String toRawString() =>
-      _delta.operations.whereType<TextInsert>().map((op) => op.content).join();
+  String toRawString() => _delta.toRawString();
 }

+ 96 - 54
frontend/app_flowy/packages/flowy_editor/lib/src/document/text_delta.dart

@@ -257,8 +257,10 @@ TextOperation? _textOperationFromJson(Map<String, dynamic> json) {
 }
 
 // basically copy from: https://github.com/quilljs/delta
-class Delta {
-  final List<TextOperation> operations;
+class Delta extends Iterable<TextOperation> {
+  final List<TextOperation> _operations;
+  String? _rawString;
+  List<int>? _runeIndexes;
 
   factory Delta.fromJson(List<dynamic> list) {
     final operations = <TextOperation>[];
@@ -273,51 +275,50 @@ class Delta {
     return Delta(operations);
   }
 
-  Delta([List<TextOperation>? ops]) : operations = ops ?? <TextOperation>[];
+  Delta([List<TextOperation>? ops]) : _operations = ops ?? <TextOperation>[];
 
-  Delta addAll(List<TextOperation> textOps) {
+  void addAll(Iterable<TextOperation> textOps) {
     textOps.forEach(add);
-    return this;
   }
 
-  Delta add(TextOperation textOp) {
+  void add(TextOperation textOp) {
     if (textOp.isEmpty) {
-      return this;
+      return;
     }
+    _rawString = null;
 
-    if (operations.isNotEmpty) {
-      final lastOp = operations.last;
+    if (_operations.isNotEmpty) {
+      final lastOp = _operations.last;
       if (lastOp is TextDelete && textOp is TextDelete) {
         lastOp.length += textOp.length;
-        return this;
+        return;
       }
       if (mapEquals(lastOp.attributes, textOp.attributes)) {
         if (lastOp is TextInsert && textOp is TextInsert) {
           lastOp.content += textOp.content;
-          return this;
+          return;
         }
         // if there is an delete before the insert
         // swap the order
         if (lastOp is TextDelete && textOp is TextInsert) {
-          operations.removeLast();
-          operations.add(textOp);
-          operations.add(lastOp);
-          return this;
+          _operations.removeLast();
+          _operations.add(textOp);
+          _operations.add(lastOp);
+          return;
         }
         if (lastOp is TextRetain && textOp is TextRetain) {
           lastOp.length += textOp.length;
-          return this;
+          return;
         }
       }
     }
 
-    operations.add(textOp);
-    return this;
+    _operations.add(textOp);
   }
 
   Delta slice(int start, [int? end]) {
     final result = Delta();
-    final iterator = _OpIterator(operations);
+    final iterator = _OpIterator(_operations);
     int index = 0;
 
     while ((end == null || index < end) && iterator.hasNext) {
@@ -335,29 +336,22 @@ class Delta {
     return result;
   }
 
-  Delta insert(String content, [Attributes? attributes]) {
-    final op = TextInsert(content, attributes);
-    return add(op);
-  }
+  void insert(String content, [Attributes? attributes]) =>
+      add(TextInsert(content, attributes));
 
-  Delta retain(int length, [Attributes? attributes]) {
-    final op = TextRetain(length, attributes);
-    return add(op);
-  }
+  void retain(int length, [Attributes? attributes]) =>
+      add(TextRetain(length, attributes));
 
-  Delta delete(int length) {
-    final op = TextDelete(length);
-    return add(op);
-  }
+  void delete(int length) => add(TextDelete(length));
 
   int get length {
-    return operations.fold(
+    return _operations.fold(
         0, (previousValue, element) => previousValue + element.length);
   }
 
   Delta compose(Delta other) {
-    final thisIter = _OpIterator(operations);
-    final otherIter = _OpIterator(other.operations);
+    final thisIter = _OpIterator(_operations);
+    final otherIter = _OpIterator(other._operations);
     final ops = <TextOperation>[];
 
     final firstOther = otherIter.peek();
@@ -405,9 +399,9 @@ class Delta {
 
           // Optimization if rest of other is just retain
           if (!otherIter.hasNext &&
-              delta.operations[delta.operations.length - 1] == newOp) {
+              delta._operations[delta._operations.length - 1] == newOp) {
             final rest = Delta(thisIter.rest());
-            return delta.concat(rest).chop();
+            return (delta + rest)..chop();
           }
         } else if (otherOp is TextDelete && (thisOp is TextRetain)) {
           delta.add(otherOp);
@@ -415,27 +409,27 @@ class Delta {
       }
     }
 
-    return delta.chop();
+    return delta..chop();
   }
 
-  Delta concat(Delta other) {
-    var ops = [...operations];
-    if (other.operations.isNotEmpty) {
-      ops.add(other.operations[0]);
-      ops.addAll(other.operations.sublist(1));
+  Delta operator +(Delta other) {
+    var ops = [..._operations];
+    if (other._operations.isNotEmpty) {
+      ops.add(other._operations[0]);
+      ops.addAll(other._operations.sublist(1));
     }
     return Delta(ops);
   }
 
-  Delta chop() {
-    if (operations.isEmpty) {
-      return this;
+  void chop() {
+    if (_operations.isEmpty) {
+      return;
     }
-    final lastOp = operations.last;
+    _rawString = null;
+    final lastOp = _operations.last;
     if (lastOp is TextRetain && (lastOp.attributes?.length ?? 0) == 0) {
-      operations.removeLast();
+      _operations.removeLast();
     }
-    return this;
   }
 
   @override
@@ -443,17 +437,17 @@ class Delta {
     if (other is! Delta) {
       return false;
     }
-    return listEquals(operations, other.operations);
+    return listEquals(_operations, other._operations);
   }
 
   @override
   int get hashCode {
-    return hashList(operations);
+    return hashList(_operations);
   }
 
   Delta invert(Delta base) {
     final inverted = Delta();
-    operations.fold(0, (int previousValue, op) {
+    _operations.fold(0, (int previousValue, op) {
       if (op is TextInsert) {
         inverted.delete(op.length);
       } else if (op is TextRetain && op.attributes == null) {
@@ -462,7 +456,7 @@ class Delta {
       } else if (op is TextDelete || op is TextRetain) {
         final length = op.length;
         final slice = base.slice(previousValue, previousValue + length);
-        for (final baseOp in slice.operations) {
+        for (final baseOp in slice._operations) {
           if (op is TextDelete) {
             inverted.add(baseOp);
           } else if (op is TextRetain && op.attributes != null) {
@@ -474,10 +468,58 @@ class Delta {
       }
       return previousValue;
     });
-    return inverted.chop();
+    return inverted..chop();
   }
 
   List<dynamic> toJson() {
-    return operations.map((e) => e.toJson()).toList();
+    return _operations.map((e) => e.toJson()).toList();
+  }
+
+  int prevRunePosition(int pos) {
+    if (pos == 0) {
+      return pos - 1;
+    }
+    _rawString ??=
+        _operations.whereType<TextInsert>().map((op) => op.content).join();
+    _runeIndexes ??= stringIndexes(_rawString!);
+    return _runeIndexes![pos - 1];
   }
+
+  int nextRunePosition(int pos) {
+    final stringContent = toRawString();
+    if (pos >= stringContent.length - 1) {
+      return stringContent.length;
+    }
+    _runeIndexes ??= stringIndexes(_rawString!);
+
+    for (var i = pos + 1; i < _runeIndexes!.length; i++) {
+      if (_runeIndexes![i] != pos) {
+        return _runeIndexes![i];
+      }
+    }
+
+    return stringContent.length;
+  }
+
+  String toRawString() {
+    _rawString ??=
+        _operations.whereType<TextInsert>().map((op) => op.content).join();
+    return _rawString!;
+  }
+
+  @override
+  Iterator<TextOperation> get iterator => _operations.iterator;
+}
+
+List<int> stringIndexes(String content) {
+  final indexes = List<int>.filled(content.length, 0);
+  final iterator = content.runes.iterator;
+
+  while (iterator.moveNext()) {
+    for (var i = 0; i < iterator.currentSize; i++) {
+      indexes[iterator.rawIndex + i] = iterator.rawIndex;
+    }
+  }
+
+  return indexes;
 }

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/src/extensions/text_node_extensions.dart

@@ -19,7 +19,7 @@ extension TextNodeExtension on TextNode {
       allSatisfyInSelection(StyleKey.strikethrough, selection);
 
   bool allSatisfyInSelection(String styleKey, Selection selection) {
-    final ops = delta.operations.whereType<TextInsert>();
+    final ops = delta.whereType<TextInsert>();
     var start = 0;
     for (final op in ops) {
       if (start >= selection.end.offset) {

+ 3 - 3
frontend/app_flowy/packages/flowy_editor/lib/src/infra/html_converter.dart

@@ -71,7 +71,7 @@ class HTMLToNodesConverter {
         delta.insert(child.text ?? "");
       }
     }
-    if (delta.operations.isNotEmpty) {
+    if (delta.isNotEmpty) {
       result.add(TextNode(type: "text", delta: delta));
     }
     return result;
@@ -101,7 +101,7 @@ class HTMLToNodesConverter {
     } else {
       final delta = Delta();
       delta.insert(element.text);
-      if (delta.operations.isNotEmpty) {
+      if (delta.isNotEmpty) {
         return [TextNode(type: "text", delta: delta)];
       }
     }
@@ -446,7 +446,7 @@ class NodesToHTMLConverter {
       childNodes.add(node);
     }
 
-    for (final op in delta.operations) {
+    for (final op in delta) {
       if (op is TextInsert) {
         final attributes = op.attributes;
         if (attributes != null) {

+ 24 - 14
frontend/app_flowy/packages/flowy_editor/lib/src/operation/transaction_builder.dart

@@ -94,7 +94,7 @@ class TransactionBuilder {
       () => Delta()
         ..retain(firstOffset ?? firstLength)
         ..delete(firstLength - (firstOffset ?? firstLength))
-        ..addAll(secondNode.delta.slice(secondOffset, secondLength).operations),
+        ..addAll(secondNode.delta.slice(secondOffset, secondLength)),
     );
     afterSelection = Selection.collapsed(
       Position(
@@ -108,30 +108,37 @@ class TransactionBuilder {
       [Attributes? attributes]) {
     var newAttributes = attributes;
     if (index != 0 && attributes == null) {
-      newAttributes = node.delta
-          .slice(max(index - 1, 0), index)
-          .operations
-          .first
-          .attributes;
+      newAttributes =
+          node.delta.slice(max(index - 1, 0), index).first.attributes;
     }
     textEdit(
       node,
-      () => Delta().retain(index).insert(
-            content,
-            newAttributes,
-          ),
+      () => Delta()
+        ..retain(index)
+        ..insert(
+          content,
+          newAttributes,
+        ),
     );
     afterSelection = Selection.collapsed(
         Position(path: node.path, offset: index + content.length));
   }
 
   formatText(TextNode node, int index, int length, Attributes attributes) {
-    textEdit(node, () => Delta().retain(index).retain(length, attributes));
+    textEdit(
+        node,
+        () => Delta()
+          ..retain(index)
+          ..retain(length, attributes));
     afterSelection = beforeSelection;
   }
 
   deleteText(TextNode node, int index, int length) {
-    textEdit(node, () => Delta().retain(index).delete(length));
+    textEdit(
+        node,
+        () => Delta()
+          ..retain(index)
+          ..delete(length));
     afterSelection =
         Selection.collapsed(Position(path: node.path, offset: index));
   }
@@ -140,14 +147,17 @@ class TransactionBuilder {
       [Attributes? attributes]) {
     var newAttributes = attributes;
     if (attributes == null) {
-      final ops = node.delta.slice(index, index + length).operations;
+      final ops = node.delta.slice(index, index + length);
       if (ops.isNotEmpty) {
         newAttributes = ops.first.attributes;
       }
     }
     textEdit(
       node,
-      () => Delta().retain(index).delete(length).insert(content, newAttributes),
+      () => Delta()
+        ..retain(index)
+        ..delete(length)
+        ..insert(content, newAttributes),
     );
     afterSelection = Selection.collapsed(
       Position(

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/flowy_rich_text.dart

@@ -198,7 +198,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
   }
 
   TextSpan get _textSpan => TextSpan(
-        children: widget.textNode.delta.operations
+        children: widget.textNode.delta
             .whereType<TextInsert>()
             .map((insert) => RichTextStyle(
                   attributes: insert.attributes ?? {},

+ 11 - 3
frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart

@@ -12,8 +12,8 @@ int _endOffsetOfNode(Node node) {
 
 extension on Position {
   Position? goLeft(EditorState editorState) {
+    final node = editorState.document.nodeAtPath(path)!;
     if (offset == 0) {
-      final node = editorState.document.nodeAtPath(path)!;
       final prevNode = node.previous;
       if (prevNode != null) {
         return Position(
@@ -22,7 +22,11 @@ extension on Position {
       return null;
     }
 
-    return Position(path: path, offset: offset - 1);
+    if (node is TextNode) {
+      return Position(path: path, offset: node.delta.prevRunePosition(offset));
+    } else {
+      return Position(path: path, offset: offset);
+    }
   }
 
   Position? goRight(EditorState editorState) {
@@ -36,7 +40,11 @@ extension on Position {
       return null;
     }
 
-    return Position(path: path, offset: offset + 1);
+    if (node is TextNode) {
+      return Position(path: path, offset: node.delta.nextRunePosition(offset));
+    } else {
+      return Position(path: path, offset: offset);
+    }
   }
 }
 

+ 27 - 16
frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart

@@ -67,7 +67,7 @@ _pasteHTML(EditorState editorState, String html) {
       final textNodeAtPath = nodeAtPath as TextNode;
       final firstTextNode = firstNode as TextNode;
       tb.textEdit(textNodeAtPath,
-          () => Delta().retain(startOffset).concat(firstTextNode.delta));
+          () => (Delta()..retain(startOffset)) + firstTextNode.delta);
       tb.setAfterSelection(Selection.collapsed(Position(
           path: path, offset: startOffset + firstTextNode.delta.length)));
       tb.commit();
@@ -93,17 +93,18 @@ _pasteMultipleLinesInText(
 
     tb.textEdit(
         textNodeAtPath,
-        () => Delta()
-            .retain(offset)
-            .delete(remain.length)
-            .concat(firstTextNode.delta));
+        () =>
+            (Delta()
+              ..retain(offset)
+              ..delete(remain.length)) +
+            firstTextNode.delta);
 
     final tailNodes = nodes.sublist(1);
     path[path.length - 1]++;
     if (tailNodes.isNotEmpty) {
       if (tailNodes.last.type == "text") {
         final tailTextNode = tailNodes.last as TextNode;
-        tailTextNode.delta = tailTextNode.delta.concat(remain);
+        tailTextNode.delta = tailTextNode.delta + remain;
       } else if (remain.length > 0) {
         tailNodes.add(TextNode(type: "text", delta: remain));
       }
@@ -151,7 +152,11 @@ _handlePastePlainText(EditorState editorState, String plainText) {
         editorState.document.nodeAtPath(selection.end.path)! as TextNode;
     final beginOffset = selection.end.offset;
     TransactionBuilder(editorState)
-      ..textEdit(node, () => Delta().retain(beginOffset).insert(lines[0]))
+      ..textEdit(
+          node,
+          () => Delta()
+            ..retain(beginOffset)
+            ..insert(lines[0]))
       ..setAfterSelection(Selection.collapsed(Position(
           path: selection.end.path, offset: beginOffset + lines[0].length)))
       ..commit();
@@ -176,17 +181,19 @@ _handlePastePlainText(EditorState editorState, String plainText) {
       if (index++ == remains.length - 1) {
         return TextNode(
             type: "text",
-            delta: Delta().insert(e).addAll(insertedLineSuffix.operations));
+            delta: Delta()
+              ..insert(e)
+              ..addAll(insertedLineSuffix));
       }
-      return TextNode(type: "text", delta: Delta().insert(e));
+      return TextNode(type: "text", delta: Delta()..insert(e));
     }).toList();
     // insert first line
     tb.textEdit(
         node,
         () => Delta()
-            .retain(beginOffset)
-            .insert(firstLine)
-            .delete(node.delta.length - beginOffset));
+          ..retain(beginOffset)
+          ..insert(firstLine)
+          ..delete(node.delta.length - beginOffset));
     // insert remains
     tb.insertNodes(path, nodes);
     tb.commit();
@@ -227,7 +234,10 @@ _deleteSelectedContent(EditorState editorState) {
     final tb = TransactionBuilder(editorState);
     final len = selection.end.offset - selection.start.offset;
     tb.textEdit(
-        textItem, () => Delta().retain(selection.start.offset).delete(len));
+        textItem,
+        () => Delta()
+          ..retain(selection.start.offset)
+          ..delete(len));
     tb.setAfterSelection(Selection.collapsed(selection.start));
     tb.commit();
     return;
@@ -241,12 +251,13 @@ _deleteSelectedContent(EditorState editorState) {
       final textItem = item as TextNode;
       final deleteLen = textItem.delta.length - selection.start.offset;
       tb.textEdit(textItem, () {
-        final delta = Delta();
-        delta.retain(selection.start.offset).delete(deleteLen);
+        final delta = Delta()
+          ..retain(selection.start.offset)
+          ..delete(deleteLen);
 
         if (endNode is TextNode) {
           final remain = endNode.delta.slice(selection.end.offset);
-          delta.addAll(remain.operations);
+          delta.addAll(remain);
         }
 
         return delta;

+ 3 - 3
frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/delete_text_handler.dart

@@ -25,7 +25,7 @@ FlowyKeyEventHandler deleteTextHandler = (editorState, event) {
   TransactionBuilder transactionBuilder = TransactionBuilder(editorState);
   if (textNodes.length == 1) {
     final textNode = textNodes.first;
-    final index = selection.start.offset - 1;
+    final index = textNode.delta.prevRunePosition(selection.start.offset);
     if (index < 0) {
       // 1. style
       if (textNode.subtype != null) {
@@ -62,8 +62,8 @@ FlowyKeyEventHandler deleteTextHandler = (editorState, event) {
       if (selection.isCollapsed) {
         transactionBuilder.deleteText(
           textNode,
-          selection.start.offset - 1,
-          1,
+          index,
+          selection.start.offset - index,
         );
       } else {
         transactionBuilder.deleteText(

+ 184 - 104
frontend/app_flowy/packages/flowy_editor/test/delta_test.dart

@@ -14,176 +14,218 @@ void main() {
         })
       ]);
 
-      final death = Delta().retain(12).insert("White", {
-        'color': '#fff',
-      }).delete(4);
+      final death = Delta()
+        ..retain(12)
+        ..insert("White", {
+          'color': '#fff',
+        })
+        ..delete(4);
 
       final restores = delta.compose(death);
-      expect(restores.operations, <TextOperation>[
+      expect(restores.toList(), <TextOperation>[
         TextInsert('Gandalf', {'bold': true}),
         TextInsert(' the '),
         TextInsert('White', {'color': '#fff'}),
       ]);
     });
     test('compose()', () {
-      final a = Delta().insert('A');
-      final b = Delta().insert('B');
-      final expected = Delta().insert('B').insert('A');
+      final a = Delta()..insert('A');
+      final b = Delta()..insert('B');
+      final expected = Delta()
+        ..insert('B')
+        ..insert('A');
       expect(a.compose(b), expected);
     });
     test('insert + retain', () {
-      final a = Delta().insert('A');
-      final b = Delta().retain(1, {
-        'bold': true,
-        'color': 'red',
-      });
-      final expected = Delta().insert('A', {
-        'bold': true,
-        'color': 'red',
-      });
+      final a = Delta()..insert('A');
+      final b = Delta()
+        ..retain(1, {
+          'bold': true,
+          'color': 'red',
+        });
+      final expected = Delta()
+        ..insert('A', {
+          'bold': true,
+          'color': 'red',
+        });
       expect(a.compose(b), expected);
     });
     test('insert + delete', () {
-      final a = Delta().insert('A');
-      final b = Delta().delete(1);
+      final a = Delta()..insert('A');
+      final b = Delta()..delete(1);
       final expected = Delta();
       expect(a.compose(b), expected);
     });
     test('delete + insert', () {
-      final a = Delta().delete(1);
-      final b = Delta().insert('B');
-      final expected = Delta().insert('B').delete(1);
+      final a = Delta()..delete(1);
+      final b = Delta()..insert('B');
+      final expected = Delta()
+        ..insert('B')
+        ..delete(1);
       expect(a.compose(b), expected);
     });
     test('delete + retain', () {
-      final a = Delta().delete(1);
-      final b = Delta().retain(1, {
-        'bold': true,
-        'color': 'red',
-      });
-      final expected = Delta().delete(1).retain(1, {
-        'bold': true,
-        'color': 'red',
-      });
+      final a = Delta()..delete(1);
+      final b = Delta()
+        ..retain(1, {
+          'bold': true,
+          'color': 'red',
+        });
+      final expected = Delta()
+        ..delete(1)
+        ..retain(1, {
+          'bold': true,
+          'color': 'red',
+        });
       expect(a.compose(b), expected);
     });
     test('delete + delete', () {
-      final a = Delta().delete(1);
-      final b = Delta().delete(1);
-      final expected = Delta().delete(2);
+      final a = Delta()..delete(1);
+      final b = Delta()..delete(1);
+      final expected = Delta()..delete(2);
       expect(a.compose(b), expected);
     });
     test('retain + insert', () {
-      final a = Delta().retain(1, {'color': 'blue'});
-      final b = Delta().insert('B');
-      final expected = Delta().insert('B').retain(1, {
-        'color': 'blue',
-      });
+      final a = Delta()..retain(1, {'color': 'blue'});
+      final b = Delta()..insert('B');
+      final expected = Delta()
+        ..insert('B')
+        ..retain(1, {
+          'color': 'blue',
+        });
       expect(a.compose(b), expected);
     });
     test('retain + retain', () {
-      final a = Delta().retain(1, {
-        'color': 'blue',
-      });
-      final b = Delta().retain(1, {
-        'bold': true,
-        'color': 'red',
-      });
-      final expected = Delta().retain(1, {
-        'bold': true,
-        'color': 'red',
-      });
+      final a = Delta()
+        ..retain(1, {
+          'color': 'blue',
+        });
+      final b = Delta()
+        ..retain(1, {
+          'bold': true,
+          'color': 'red',
+        });
+      final expected = Delta()
+        ..retain(1, {
+          'bold': true,
+          'color': 'red',
+        });
       expect(a.compose(b), expected);
     });
     test('retain + delete', () {
-      final a = Delta().retain(1, {
-        'color': 'blue',
-      });
-      final b = Delta().delete(1);
-      final expected = Delta().delete(1);
+      final a = Delta()
+        ..retain(1, {
+          'color': 'blue',
+        });
+      final b = Delta()..delete(1);
+      final expected = Delta()..delete(1);
       expect(a.compose(b), expected);
     });
     test('insert in middle of text', () {
-      final a = Delta().insert('Hello');
-      final b = Delta().retain(3).insert('X');
-      final expected = Delta().insert('HelXlo');
+      final a = Delta()..insert('Hello');
+      final b = Delta()
+        ..retain(3)
+        ..insert('X');
+      final expected = Delta()..insert('HelXlo');
       expect(a.compose(b), expected);
     });
     test('insert and delete ordering', () {
-      final a = Delta().insert('Hello');
-      final b = Delta().insert('Hello');
-      final insertFirst = Delta().retain(3).insert('X').delete(1);
-      final deleteFirst = Delta().retain(3).delete(1).insert('X');
-      final expected = Delta().insert('HelXo');
+      final a = Delta()..insert('Hello');
+      final b = Delta()..insert('Hello');
+      final insertFirst = Delta()
+        ..retain(3)
+        ..insert('X')
+        ..delete(1);
+      final deleteFirst = Delta()
+        ..retain(3)
+        ..delete(1)
+        ..insert('X');
+      final expected = Delta()..insert('HelXo');
       expect(a.compose(insertFirst), expected);
       expect(b.compose(deleteFirst), expected);
     });
     test('delete entire text', () {
-      final a = Delta().retain(4).insert('Hello');
-      final b = Delta().delete(9);
-      final expected = Delta().delete(4);
+      final a = Delta()
+        ..retain(4)
+        ..insert('Hello');
+      final b = Delta()..delete(9);
+      final expected = Delta()..delete(4);
       expect(a.compose(b), expected);
     });
     test('retain more than length of text', () {
-      final a = Delta().insert('Hello');
-      final b = Delta().retain(10);
-      final expected = Delta().insert('Hello');
+      final a = Delta()..insert('Hello');
+      final b = Delta()..retain(10);
+      final expected = Delta()..insert('Hello');
       expect(a.compose(b), expected);
     });
     test('retain start optimization', () {
       final a = Delta()
-          .insert('A', {'bold': true})
-          .insert('B')
-          .insert('C', {'bold': true})
-          .delete(1);
-      final b = Delta().retain(3).insert('D');
+        ..insert('A', {'bold': true})
+        ..insert('B')
+        ..insert('C', {'bold': true})
+        ..delete(1);
+      final b = Delta()
+        ..retain(3)
+        ..insert('D');
       final expected = Delta()
-          .insert('A', {'bold': true})
-          .insert('B')
-          .insert('C', {'bold': true})
-          .insert('D')
-          .delete(1);
+        ..insert('A', {'bold': true})
+        ..insert('B')
+        ..insert('C', {'bold': true})
+        ..insert('D')
+        ..delete(1);
       expect(a.compose(b), expected);
     });
     test('retain end optimization', () {
       final a = Delta()
-          .insert('A', {'bold': true})
-          .insert('B')
-          .insert('C', {'bold': true});
-      final b = Delta().delete(1);
-      final expected = Delta().insert('B').insert('C', {'bold': true});
+        ..insert('A', {'bold': true})
+        ..insert('B')
+        ..insert('C', {'bold': true});
+      final b = Delta()..delete(1);
+      final expected = Delta()
+        ..insert('B')
+        ..insert('C', {'bold': true});
       expect(a.compose(b), expected);
     });
     test('retain end optimization join', () {
       final a = Delta()
-          .insert('A', {'bold': true})
-          .insert('B')
-          .insert('C', {'bold': true})
-          .insert('D')
-          .insert('E', {'bold': true})
-          .insert('F');
-      final b = Delta().retain(1).delete(1);
+        ..insert('A', {'bold': true})
+        ..insert('B')
+        ..insert('C', {'bold': true})
+        ..insert('D')
+        ..insert('E', {'bold': true})
+        ..insert('F');
+      final b = Delta()
+        ..retain(1)
+        ..delete(1);
       final expected = Delta()
-          .insert('AC', {'bold': true})
-          .insert('D')
-          .insert('E', {'bold': true})
-          .insert('F');
+        ..insert('AC', {'bold': true})
+        ..insert('D')
+        ..insert('E', {'bold': true})
+        ..insert('F');
       expect(a.compose(b), expected);
     });
   });
   group('invert', () {
     test('insert', () {
-      final delta = Delta().retain(2).insert('A');
-      final base = Delta().insert('12346');
-      final expected = Delta().retain(2).delete(1);
+      final delta = Delta()
+        ..retain(2)
+        ..insert('A');
+      final base = Delta()..insert('12346');
+      final expected = Delta()
+        ..retain(2)
+        ..delete(1);
       final inverted = delta.invert(base);
       expect(expected, inverted);
       expect(base.compose(delta).compose(inverted), base);
     });
     test('delete', () {
-      final delta = Delta().retain(2).delete(3);
-      final base = Delta().insert('123456');
-      final expected = Delta().retain(2).insert('345');
+      final delta = Delta()
+        ..retain(2)
+        ..delete(3);
+      final base = Delta()..insert('123456');
+      final expected = Delta()
+        ..retain(2)
+        ..insert('345');
       final inverted = delta.invert(base);
       expect(expected, inverted);
       expect(base.compose(delta).compose(inverted), base);
@@ -199,7 +241,10 @@ void main() {
   });
   group('json', () {
     test('toJson()', () {
-      final delta = Delta().retain(2).insert('A').delete(3);
+      final delta = Delta()
+        ..retain(2)
+        ..insert('A')
+        ..delete(3);
       expect(delta.toJson(), [
         {'retain': 2},
         {'insert': 'A'},
@@ -207,8 +252,9 @@ void main() {
       ]);
     });
     test('attributes', () {
-      final delta =
-          Delta().retain(2, {'bold': true}).insert('A', {'italic': true});
+      final delta = Delta()
+        ..retain(2, {'bold': true})
+        ..insert('A', {'italic': true});
       expect(delta.toJson(), [
         {
           'retain': 2,
@@ -226,8 +272,42 @@ void main() {
         {'insert': 'A'},
         {'delete': 3},
       ]);
-      final expected = Delta().retain(2).insert('A').delete(3);
+      final expected = Delta()
+        ..retain(2)
+        ..insert('A')
+        ..delete(3);
       expect(delta, expected);
     });
   });
+  group('runes', () {
+    test("stringIndexes", () {
+      final indexes = stringIndexes('😊');
+      expect(indexes[0], 0);
+      expect(indexes[1], 0);
+    });
+    test("next rune 1", () {
+      final delta = Delta()..insert('😊');
+      expect(delta.nextRunePosition(0), 2);
+    });
+    test("next rune 2", () {
+      final delta = Delta()..insert('😊a');
+      expect(delta.nextRunePosition(0), 2);
+    });
+    test("next rune 3", () {
+      final delta = Delta()..insert('😊陈');
+      expect(delta.nextRunePosition(2), 3);
+    });
+    test("prev rune 1", () {
+      final delta = Delta()..insert('😊陈');
+      expect(delta.prevRunePosition(2), 0);
+    });
+    test("prev rune 2", () {
+      final delta = Delta()..insert('😊');
+      expect(delta.prevRunePosition(2), 0);
+    });
+    test("prev rune 3", () {
+      final delta = Delta()..insert('😊');
+      expect(delta.prevRunePosition(0), -1);
+    });
+  });
 }