瀏覽代碼

Merge pull request #725 from AppFlowy-IO/feat/operation-transform

Feat: operation transform
Vincent Chan 2 年之前
父節點
當前提交
982cd62fcc

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

@@ -31,7 +31,11 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
     required this.children,
     required this.attributes,
     this.parent,
-  });
+  }) {
+    for (final child in children) {
+      child.parent = this;
+    }
+  }
 
   factory Node.fromJson(Map<String, Object> json) {
     assert(json['type'] is String);

+ 75 - 11
frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart

@@ -1,21 +1,27 @@
-import 'package:flowy_editor/document/path.dart';
-import 'package:flowy_editor/document/node.dart';
-import 'package:flowy_editor/document/text_delta.dart';
 import 'package:flowy_editor/document/attributes.dart';
+import 'package:flowy_editor/flowy_editor.dart';
 
 abstract class Operation {
+  final Path path;
+  Operation({required this.path});
+  Operation copyWithPath(Path path);
   Operation invert();
 }
 
 class InsertOperation extends Operation {
-  final Path path;
   final Node value;
 
   InsertOperation({
-    required this.path,
+    required super.path,
     required this.value,
   });
 
+  InsertOperation copyWith({Path? path, Node? value}) =>
+      InsertOperation(path: path ?? this.path, value: value ?? this.value);
+
+  @override
+  Operation copyWithPath(Path path) => copyWith(path: path);
+
   @override
   Operation invert() {
     return DeleteOperation(
@@ -26,16 +32,25 @@ class InsertOperation extends Operation {
 }
 
 class UpdateOperation extends Operation {
-  final Path path;
   final Attributes attributes;
   final Attributes oldAttributes;
 
   UpdateOperation({
-    required this.path,
+    required super.path,
     required this.attributes,
     required this.oldAttributes,
   });
 
+  UpdateOperation copyWith(
+          {Path? path, Attributes? attributes, Attributes? oldAttributes}) =>
+      UpdateOperation(
+          path: path ?? this.path,
+          attributes: attributes ?? this.attributes,
+          oldAttributes: oldAttributes ?? this.oldAttributes);
+
+  @override
+  Operation copyWithPath(Path path) => copyWith(path: path);
+
   @override
   Operation invert() {
     return UpdateOperation(
@@ -47,14 +62,19 @@ class UpdateOperation extends Operation {
 }
 
 class DeleteOperation extends Operation {
-  final Path path;
   final Node removedValue;
 
   DeleteOperation({
-    required this.path,
+    required super.path,
     required this.removedValue,
   });
 
+  DeleteOperation copyWith({Path? path, Node? removedValue}) => DeleteOperation(
+      path: path ?? this.path, removedValue: removedValue ?? this.removedValue);
+
+  @override
+  Operation copyWithPath(Path path) => copyWith(path: path);
+
   @override
   Operation invert() {
     return InsertOperation(
@@ -65,18 +85,62 @@ class DeleteOperation extends Operation {
 }
 
 class TextEditOperation extends Operation {
-  final Path path;
   final Delta delta;
   final Delta inverted;
 
   TextEditOperation({
-    required this.path,
+    required super.path,
     required this.delta,
     required this.inverted,
   });
 
+  TextEditOperation copyWith({Path? path, Delta? delta, Delta? inverted}) =>
+      TextEditOperation(
+          path: path ?? this.path,
+          delta: delta ?? this.delta,
+          inverted: inverted ?? this.inverted);
+
+  @override
+  Operation copyWithPath(Path path) => copyWith(path: path);
+
   @override
   Operation invert() {
     return TextEditOperation(path: path, delta: inverted, inverted: delta);
   }
 }
+
+Path transformPath(Path preInsertPath, Path b, [int delta = 1]) {
+  if (preInsertPath.length > b.length) {
+    return b;
+  }
+  if (preInsertPath.isEmpty || b.isEmpty) {
+    return b;
+  }
+  // check the prefix
+  for (var i = 0; i < preInsertPath.length - 1; i++) {
+    if (preInsertPath[i] != b[i]) {
+      return b;
+    }
+  }
+  final prefix = preInsertPath.sublist(0, preInsertPath.length - 1);
+  final suffix = b.sublist(preInsertPath.length);
+  final preInsertLast = preInsertPath.last;
+  final bAtIndex = b[preInsertPath.length - 1];
+  if (preInsertLast <= bAtIndex) {
+    prefix.add(bAtIndex + delta);
+  }
+  prefix.addAll(suffix);
+  return prefix;
+}
+
+Operation transformOperation(Operation a, Operation b) {
+  if (a is InsertOperation) {
+    final newPath = transformPath(a.path, b.path);
+    return b.copyWithPath(newPath);
+  } else if (b is DeleteOperation) {
+    final newPath = transformPath(a.path, b.path, -1);
+    return b.copyWithPath(newPath);
+  }
+  // TODO: transform update and textedit
+  return b;
+}

+ 3 - 0
frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart

@@ -90,6 +90,9 @@ class TransactionBuilder {
         return;
       }
     }
+    for (var i = 0; i < operations.length; i++) {
+      op = transformOperation(operations[i], op);
+    }
     operations.add(op);
   }
 

+ 82 - 0
frontend/app_flowy/packages/flowy_editor/test/operation_test.dart

@@ -0,0 +1,82 @@
+import 'dart:collection';
+
+import 'package:flowy_editor/document/node.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flowy_editor/operation/operation.dart';
+import 'package:flowy_editor/operation/transaction_builder.dart';
+import 'package:flowy_editor/editor_state.dart';
+import 'package:flowy_editor/document/state_tree.dart';
+import 'package:flowy_editor/render/render_plugins.dart';
+
+void main() {
+  group('transform path', () {
+    test('transform path changed', () {
+      expect(transformPath([0, 1], [0, 1]), [0, 2]);
+      expect(transformPath([0, 1], [0, 2]), [0, 3]);
+      expect(transformPath([0, 1], [0, 2, 7, 8, 9]), [0, 3, 7, 8, 9]);
+      expect(transformPath([0, 1, 2], [0, 0, 7, 8, 9]), [0, 0, 7, 8, 9]);
+    });
+    test("transform path not changed", () {
+      expect(transformPath([0, 1, 2], [0, 0, 7, 8, 9]), [0, 0, 7, 8, 9]);
+      expect(transformPath([0, 1, 2], [0, 1]), [0, 1]);
+    });
+    test("transform path delta", () {
+      expect(transformPath([0, 1], [0, 1], 5), [0, 6]);
+    });
+  });
+  group('transform operation', () {
+    test('insert + insert', () {
+      final t = transformOperation(
+          InsertOperation(path: [
+            0,
+            1
+          ], value: Node(type: "node", attributes: {}, children: LinkedList())),
+          InsertOperation(
+              path: [0, 1],
+              value:
+                  Node(type: "node", attributes: {}, children: LinkedList())));
+      expect(t.path, [0, 2]);
+    });
+    test('delete + delete', () {
+      final t = transformOperation(
+          DeleteOperation(
+              path: [0, 1],
+              removedValue:
+                  Node(type: "node", attributes: {}, children: LinkedList())),
+          DeleteOperation(
+              path: [0, 2],
+              removedValue:
+                  Node(type: "node", attributes: {}, children: LinkedList())));
+      expect(t.path, [0, 1]);
+    });
+  });
+  test('transform transaction builder', () {
+    final item1 = Node(type: "node", attributes: {}, children: LinkedList());
+    final item2 = Node(type: "node", attributes: {}, children: LinkedList());
+    final item3 = Node(type: "node", attributes: {}, children: LinkedList());
+    final root = Node(
+        type: "root",
+        attributes: {},
+        children: LinkedList()
+          ..addAll([
+            item1,
+            item2,
+            item3,
+          ]));
+    final state = EditorState(
+        document: StateTree(root: root), renderPlugins: RenderPlugins());
+
+    expect(item1.path, [0]);
+    expect(item2.path, [1]);
+    expect(item3.path, [2]);
+
+    final tb = TransactionBuilder(state);
+    tb.deleteNode(item1);
+    tb.deleteNode(item2);
+    tb.deleteNode(item3);
+    final transaction = tb.finish();
+    expect(transaction.operations[0].path, [0]);
+    expect(transaction.operations[1].path, [0]);
+    expect(transaction.operations[2].path, [0]);
+  });
+}