diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart index 8b80fd0b51..9871bf24ee 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart @@ -31,7 +31,11 @@ class Node extends ChangeNotifier with LinkedListEntry { required this.children, required this.attributes, this.parent, - }); + }) { + for (final child in children) { + child.parent = this; + } + } factory Node.fromJson(Map json) { assert(json['type'] is String); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart index e3710ddb3c..487844af14 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart +++ b/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; +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart index 319002bd45..fb042fe566 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart +++ b/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); } diff --git a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart b/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart new file mode 100644 index 0000000000..683b6df58e --- /dev/null +++ b/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]); + }); +}