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 '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,
|
||||||
|
|
|
||||||
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 '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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
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);
|
..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;
|
||||||
|
|
|
||||||
|
|
@ -286,25 +286,26 @@ class EditorState {
|
||||||
|
|
||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
|
|
||||||
// broadcast to other users here, before applying the transaction
|
if (isRemote) {
|
||||||
_observer.add((TransactionTime.before, transaction));
|
selection = _applyTransactionFromRemote(transaction);
|
||||||
|
} else {
|
||||||
|
// broadcast to other users here, before applying the 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));
|
||||||
|
|
||||||
_recordRedoOrUndo(options, transaction);
|
_recordRedoOrUndo(options, transaction);
|
||||||
|
|
||||||
if (withUpdateSelection) {
|
if (withUpdateSelection) {
|
||||||
_selectionUpdateReason = SelectionUpdateReason.transaction;
|
_selectionUpdateReason = SelectionUpdateReason.transaction;
|
||||||
if (transaction.selectionExtraInfo != null) {
|
if (transaction.selectionExtraInfo != null) {
|
||||||
selectionExtraInfo = transaction.selectionExtraInfo;
|
selectionExtraInfo = transaction.selectionExtraInfo;
|
||||||
|
}
|
||||||
|
selection = transaction.afterSelection;
|
||||||
}
|
}
|
||||||
selection = transaction.afterSelection;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
completer.complete();
|
completer.complete();
|
||||||
|
|
@ -530,18 +531,66 @@ class EditorState {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyOperation(Operation op) {
|
void _applyTransactionInLocal(Transaction transaction) {
|
||||||
if (op is InsertOperation) {
|
for (final op in transaction.operations) {
|
||||||
document.insert(op.path, op.nodes);
|
Log.editor.debug('apply op (local): ${op.toJson()}');
|
||||||
} else if (op is UpdateOperation) {
|
|
||||||
// ignore the update operation if the attributes are the same.
|
if (op is InsertOperation) {
|
||||||
if (!mapEquals(op.attributes, op.oldAttributes)) {
|
document.insert(op.path, op.nodes);
|
||||||
document.update(op.path, op.attributes);
|
} else if (op is UpdateOperation) {
|
||||||
|
// ignore the update operation if the attributes are the same.
|
||||||
|
if (!mapEquals(op.attributes, op.oldAttributes)) {
|
||||||
|
document.update(op.path, op.attributes);
|
||||||
|
}
|
||||||
|
} else if (op is DeleteOperation) {
|
||||||
|
document.delete(op.path, op.nodes.length);
|
||||||
|
} else if (op is UpdateTextOperation) {
|
||||||
|
document.updateText(op.path, op.delta);
|
||||||
}
|
}
|
||||||
} else if (op is DeleteOperation) {
|
|
||||||
document.delete(op.path, op.nodes.length);
|
|
||||||
} else if (op is UpdateTextOperation) {
|
|
||||||
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';
|
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();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue