feat: add diff document/nodes function (#748)

* feat: transform operations

* feat: add document diff function
This commit is contained in:
Lucas.Xu 2024-03-19 16:38:14 +07:00 • committed by GitHub
commit cbb33469cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 330 additions and 29 deletions

View file

@ -4,6 +4,7 @@ import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:appflowy_editor/appflowy_editor.dart'; 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/customize_theme_for_editor.dart';
import 'package:example/pages/editor.dart'; import 'package:example/pages/editor.dart';
import 'package:example/pages/editor_list.dart'; import 'package:example/pages/editor_list.dart';
@ -159,6 +160,14 @@ class _HomePageState extends State<HomePage> {
// Theme Demo // Theme Demo
_buildSeparator(context, 'Showcases'), _buildSeparator(context, 'Showcases'),
_buildListTile(context, 'Collab Editor', () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CollabEditor(),
),
);
}),
_buildListTile(context, 'Custom Theme', () { _buildListTile(context, 'Custom Theme', () {
Navigator.push( Navigator.push(
context, context,

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

View file

@ -1,11 +1,9 @@
import 'dart:convert'; 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/desktop_editor.dart';
import 'package:example/pages/mobile_editor.dart'; import 'package:example/pages/mobile_editor.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
class Editor extends StatefulWidget { class Editor extends StatefulWidget {
const Editor({ const Editor({
@ -105,6 +103,8 @@ class _EditorState extends State<Editor> {
} }
}); });
widget.onEditorStateChange(editorState);
this.editorState = editorState; this.editorState = editorState;
registerWordCounter(); registerWordCounter();
} }

View file

@ -1,6 +1,7 @@
export 'document/attributes.dart'; export 'document/attributes.dart';
export 'document/deprecated/document.dart'; export 'document/deprecated/document.dart';
export 'document/deprecated/node.dart'; export 'document/deprecated/node.dart';
export 'document/diff.dart';
export 'document/document.dart'; export 'document/document.dart';
export 'document/node.dart'; export 'document/node.dart';
export 'document/node_iterator.dart'; export 'document/node_iterator.dart';

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

View file

@ -71,6 +71,17 @@ extension PathExtensions on Path {
..add(last + 1); ..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 get previous {
Path previousPath = Path.from(this, growable: true); Path previousPath = Path.from(this, growable: true);
if (isEmpty) { if (isEmpty) {
@ -82,6 +93,17 @@ extension PathExtensions on Path {
..add(max(0, last - 1)); ..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 { Path get parent {
if (isEmpty) { if (isEmpty) {
return this; return this;

View file

@ -286,13 +286,13 @@ class EditorState {
final completer = Completer<void>(); final completer = Completer<void>();
if (isRemote) {
selection = _applyTransactionFromRemote(transaction);
} else {
// broadcast to other users here, before applying the transaction // broadcast to other users here, before applying the transaction
_observer.add((TransactionTime.before, transaction)); _observer.add((TransactionTime.before, transaction));
for (final operation in transaction.operations) { _applyTransactionInLocal(transaction);
Log.editor.debug('apply op: ${operation.toJson()}');
_applyOperation(operation);
}
// broadcast to other users here, after applying the transaction // broadcast to other users here, after applying the transaction
_observer.add((TransactionTime.after, transaction)); _observer.add((TransactionTime.after, transaction));
@ -306,6 +306,7 @@ class EditorState {
} }
selection = transaction.afterSelection; selection = transaction.afterSelection;
} }
}
completer.complete(); 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) { if (op is InsertOperation) {
document.insert(op.path, op.nodes); document.insert(op.path, op.nodes);
} else if (op is UpdateOperation) { } else if (op is UpdateOperation) {
@ -545,3 +549,48 @@ class EditorState {
} }
} }
} }
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;
}
}

View 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,
);
});
});
}

View file

@ -2,7 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() async { void main() async {
group('documemnt.dart', () { group('document.dart', () {
test('insert', () { test('insert', () {
final document = Document.blank(); final document = Document.blank();