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

Feat: operation transform
This commit is contained in:
Vincent Chan 2022-07-27 14:48:38 +08:00 committed by GitHub
commit 982cd62fcc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 165 additions and 12 deletions

View File

@ -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);

View File

@ -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;
}

View File

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

View File

@ -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]);
});
}