feat: add diff document/nodes function (#748)
* feat: transform operations * feat: add document diff function
This commit is contained in:
parent
2493d9b920
commit
cbb33469cd
9 changed files with 330 additions and 29 deletions
|
|
@ -4,6 +4,7 @@ import 'dart:io';
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:example/pages/collab_editor.dart';
|
||||
import 'package:example/pages/customize_theme_for_editor.dart';
|
||||
import 'package:example/pages/editor.dart';
|
||||
import 'package:example/pages/editor_list.dart';
|
||||
|
|
@ -159,6 +160,14 @@ class _HomePageState extends State<HomePage> {
|
|||
|
||||
// Theme Demo
|
||||
_buildSeparator(context, 'Showcases'),
|
||||
_buildListTile(context, 'Collab Editor', () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CollabEditor(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
_buildListTile(context, 'Custom Theme', () {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
|
|
|||
66
example/lib/pages/collab_editor.dart
Normal file
66
example/lib/pages/collab_editor.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// Not completed yet
|
||||
class CollabEditor extends StatefulWidget {
|
||||
const CollabEditor({super.key});
|
||||
|
||||
@override
|
||||
State<CollabEditor> createState() => _CollabEditorState();
|
||||
}
|
||||
|
||||
class _CollabEditorState extends State<CollabEditor> {
|
||||
final EditorState editorStateA =
|
||||
EditorState(document: Document.blank(withInitialText: true));
|
||||
final EditorState editorStateB =
|
||||
EditorState(document: Document.blank(withInitialText: true));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
editorStateA.transactionStream.listen((event) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
if (event.$1 == TransactionTime.before) {
|
||||
editorStateB.apply(event.$2, isRemote: true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
editorStateB.transactionStream.listen((event) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
if (event.$1 == TransactionTime.before) {
|
||||
editorStateA.apply(event.$2, isRemote: true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
editorStateA.dispose();
|
||||
editorStateB.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppFlowyEditor(
|
||||
editorState: editorStateA,
|
||||
),
|
||||
),
|
||||
const VerticalDivider(),
|
||||
Expanded(
|
||||
child: AppFlowyEditor(
|
||||
editorState: editorStateB,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:example/pages/desktop_editor.dart';
|
||||
import 'package:example/pages/mobile_editor.dart';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Editor extends StatefulWidget {
|
||||
const Editor({
|
||||
|
|
@ -105,6 +103,8 @@ class _EditorState extends State<Editor> {
|
|||
}
|
||||
});
|
||||
|
||||
widget.onEditorStateChange(editorState);
|
||||
|
||||
this.editorState = editorState;
|
||||
registerWordCounter();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export 'document/attributes.dart';
|
||||
export 'document/deprecated/document.dart';
|
||||
export 'document/deprecated/node.dart';
|
||||
export 'document/diff.dart';
|
||||
export 'document/document.dart';
|
||||
export 'document/node.dart';
|
||||
export 'document/node_iterator.dart';
|
||||
|
|
|
|||
47
lib/src/core/document/diff.dart
Normal file
47
lib/src/core/document/diff.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
const _equality = DeepCollectionEquality();
|
||||
|
||||
List<Operation> diffDocuments(Document oldDocument, Document newDocument) {
|
||||
return diffNodes(oldDocument.root, newDocument.root);
|
||||
}
|
||||
|
||||
List<Operation> diffNodes(Node oldNode, Node newNode) {
|
||||
List<Operation> operations = [];
|
||||
|
||||
if (!_equality.equals(oldNode.attributes, newNode.attributes)) {
|
||||
operations.add(
|
||||
UpdateOperation(oldNode.path, newNode.attributes, oldNode.attributes),
|
||||
);
|
||||
}
|
||||
|
||||
final oldChildrenById = {
|
||||
for (final child in oldNode.children) child.id: child,
|
||||
};
|
||||
final newChildrenById = {
|
||||
for (final child in newNode.children) child.id: child,
|
||||
};
|
||||
|
||||
// Identify insertions and updates
|
||||
for (final newChild in newNode.children) {
|
||||
final oldChild = oldChildrenById[newChild.id];
|
||||
if (oldChild == null) {
|
||||
// Insert operation
|
||||
operations.add(InsertOperation(newChild.path, [newChild]));
|
||||
} else {
|
||||
// Recursive diff for updates
|
||||
operations.addAll(diffNodes(oldChild, newChild));
|
||||
}
|
||||
}
|
||||
|
||||
// Identify deletions
|
||||
oldChildrenById.keys
|
||||
.where((id) => !newChildrenById.containsKey(id))
|
||||
.forEach((id) {
|
||||
final oldChild = oldChildrenById[id]!;
|
||||
operations.add(DeleteOperation(oldChild.path, [oldChild]));
|
||||
});
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
|
@ -71,6 +71,17 @@ extension PathExtensions on Path {
|
|||
..add(last + 1);
|
||||
}
|
||||
|
||||
Path nextNPath(int n) {
|
||||
Path nextPath = Path.from(this, growable: true);
|
||||
if (isEmpty) {
|
||||
return nextPath;
|
||||
}
|
||||
final last = nextPath.last;
|
||||
return nextPath
|
||||
..removeLast()
|
||||
..add(last + n);
|
||||
}
|
||||
|
||||
Path get previous {
|
||||
Path previousPath = Path.from(this, growable: true);
|
||||
if (isEmpty) {
|
||||
|
|
@ -82,6 +93,17 @@ extension PathExtensions on Path {
|
|||
..add(max(0, last - 1));
|
||||
}
|
||||
|
||||
Path previousNPath(int n) {
|
||||
Path previousPath = Path.from(this, growable: true);
|
||||
if (isEmpty) {
|
||||
return previousPath;
|
||||
}
|
||||
final last = previousPath.last;
|
||||
return previousPath
|
||||
..removeLast()
|
||||
..add(max(0, last - n));
|
||||
}
|
||||
|
||||
Path get parent {
|
||||
if (isEmpty) {
|
||||
return this;
|
||||
|
|
|
|||
|
|
@ -286,13 +286,13 @@ class EditorState {
|
|||
|
||||
final completer = Completer<void>();
|
||||
|
||||
if (isRemote) {
|
||||
selection = _applyTransactionFromRemote(transaction);
|
||||
} else {
|
||||
// broadcast to other users here, before applying the transaction
|
||||
_observer.add((TransactionTime.before, transaction));
|
||||
|
||||
for (final operation in transaction.operations) {
|
||||
Log.editor.debug('apply op: ${operation.toJson()}');
|
||||
_applyOperation(operation);
|
||||
}
|
||||
_applyTransactionInLocal(transaction);
|
||||
|
||||
// broadcast to other users here, after applying the transaction
|
||||
_observer.add((TransactionTime.after, transaction));
|
||||
|
|
@ -306,6 +306,7 @@ class EditorState {
|
|||
}
|
||||
selection = transaction.afterSelection;
|
||||
}
|
||||
}
|
||||
|
||||
completer.complete();
|
||||
|
||||
|
|
@ -530,7 +531,10 @@ class EditorState {
|
|||
});
|
||||
}
|
||||
|
||||
void _applyOperation(Operation op) {
|
||||
void _applyTransactionInLocal(Transaction transaction) {
|
||||
for (final op in transaction.operations) {
|
||||
Log.editor.debug('apply op (local): ${op.toJson()}');
|
||||
|
||||
if (op is InsertOperation) {
|
||||
document.insert(op.path, op.nodes);
|
||||
} else if (op is UpdateOperation) {
|
||||
|
|
@ -544,4 +548,49 @@ class EditorState {
|
|||
document.updateText(op.path, op.delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Selection? _applyTransactionFromRemote(Transaction transaction) {
|
||||
var selection = this.selection;
|
||||
|
||||
for (final op in transaction.operations) {
|
||||
Log.editor.debug('apply op (remote): ${op.toJson()}');
|
||||
|
||||
if (op is InsertOperation) {
|
||||
document.insert(op.path, op.nodes);
|
||||
if (selection != null) {
|
||||
if (op.path <= selection.start.path) {
|
||||
selection = Selection(
|
||||
start: selection.start.copyWith(
|
||||
path: selection.start.path.nextNPath(op.nodes.length),
|
||||
),
|
||||
end: selection.end.copyWith(
|
||||
path: selection.end.path.nextNPath(op.nodes.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (op is UpdateOperation) {
|
||||
document.update(op.path, op.attributes);
|
||||
} else if (op is DeleteOperation) {
|
||||
document.delete(op.path, op.nodes.length);
|
||||
if (selection != null) {
|
||||
if (op.path <= selection.start.path) {
|
||||
selection = Selection(
|
||||
start: selection.start.copyWith(
|
||||
path: selection.start.path.previous,
|
||||
),
|
||||
end: selection.end.copyWith(
|
||||
path: selection.end.path.previous,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (op is UpdateTextOperation) {
|
||||
document.updateText(op.path, op.delta);
|
||||
}
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
test/core/document/diff_test.dart
Normal file
107
test/core/document/diff_test.dart
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nanoid/non_secure.dart';
|
||||
|
||||
void main() async {
|
||||
group('diff.dart', () {
|
||||
Node buildNodeWithId(String id, String text) {
|
||||
return Node(
|
||||
type: ParagraphBlockKeys.type,
|
||||
attributes: {
|
||||
ParagraphBlockKeys.delta: (Delta()..insert(text)).toJson(),
|
||||
},
|
||||
id: id,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Document> apply(Document document, List<Operation> ops) async {
|
||||
final editorState = EditorState(document: document);
|
||||
final transaction = editorState.transaction;
|
||||
for (final op in ops) {
|
||||
transaction.add(op);
|
||||
}
|
||||
await editorState.apply(transaction, isRemote: true);
|
||||
return editorState.document;
|
||||
}
|
||||
|
||||
test('text changes', () async {
|
||||
final id = nanoid(6);
|
||||
final documentA = Document.blank()
|
||||
..insert([0], [buildNodeWithId(id, 'Hello World')]);
|
||||
final documentB = Document.blank()
|
||||
..insert([0], [buildNodeWithId(id, 'Hello AppFlowy!')]);
|
||||
|
||||
final ops = diffDocuments(documentA, documentB);
|
||||
expect(ops.length, 1);
|
||||
final op = ops.first;
|
||||
expect(op, isA<UpdateOperation>());
|
||||
expect((op as UpdateOperation).path, [0]);
|
||||
|
||||
final expectation = jsonEncode(documentB.toJson());
|
||||
expect(
|
||||
jsonEncode((await apply(documentA, ops)).toJson()),
|
||||
expectation,
|
||||
);
|
||||
});
|
||||
|
||||
test('insert', () async {
|
||||
final id1 = nanoid(6);
|
||||
final id2 = nanoid(6);
|
||||
final documentA = Document.blank()
|
||||
..insert(
|
||||
[0],
|
||||
[buildNodeWithId(id1, 'Hello AppFlowy!')],
|
||||
);
|
||||
final documentB = Document.blank()
|
||||
..insert([
|
||||
0,
|
||||
], [
|
||||
buildNodeWithId(id1, 'Hello AppFlowy!'),
|
||||
buildNodeWithId(id2, 'Hello World!'),
|
||||
]);
|
||||
|
||||
final ops = diffDocuments(documentA, documentB);
|
||||
expect(ops.length, 1);
|
||||
final op = ops.first;
|
||||
expect(op, isA<InsertOperation>());
|
||||
expect((op as InsertOperation).path, [1]);
|
||||
|
||||
final expectation = jsonEncode(documentB.toJson());
|
||||
expect(
|
||||
jsonEncode((await apply(documentA, ops)).toJson()),
|
||||
expectation,
|
||||
);
|
||||
});
|
||||
|
||||
test('delete', () async {
|
||||
final id1 = nanoid(6);
|
||||
final id2 = nanoid(6);
|
||||
final documentA = Document.blank()
|
||||
..insert(
|
||||
[0],
|
||||
[buildNodeWithId(id1, 'Hello AppFlowy!')],
|
||||
);
|
||||
final documentB = Document.blank()
|
||||
..insert([
|
||||
0,
|
||||
], [
|
||||
buildNodeWithId(id1, 'Hello AppFlowy!'),
|
||||
buildNodeWithId(id2, 'Hello World!'),
|
||||
]);
|
||||
|
||||
final ops = diffDocuments(documentB, documentA);
|
||||
expect(ops.length, 1);
|
||||
final op = ops.first;
|
||||
expect(op, isA<DeleteOperation>());
|
||||
expect((op as DeleteOperation).path, [1]);
|
||||
|
||||
final expectation = jsonEncode(documentA.toJson());
|
||||
expect(
|
||||
jsonEncode((await apply(documentB, ops)).toJson()),
|
||||
expectation,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() async {
|
||||
group('documemnt.dart', () {
|
||||
group('document.dart', () {
|
||||
test('insert', () {
|
||||
final document = Document.blank();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue