fix: improve the document diff function to prevent partial ordering issues (#7217)

* fix: improve the document diff function to prevent partial ordering issues

* fix: improve the document diff function to prevent partial ordering issues

* fix: nested block padding issues

* fix: improve the document diff function to prevent partial ordering issues

* chore: update editor version

* test: add no diff test and update text diff test

* test: delete and insert text diff with different id

* test: insert single text / delete single text tests

* test: multiple delete and update diff

* test: multiple insert and update diff

* chore: revert cargo changes

* chore: remove unused code

* chore: optimize the code logic
This commit is contained in:
Lucas 2025-01-16 21:01:23 +08:00 committed by GitHub
parent eead2d20f5
commit f35dfaf525
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 800 additions and 198 deletions

View File

@ -1,125 +1,125 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// This task only builds the Dart code of AppFlowy.
// It supports both the desktop and mobile version.
"name": "AF: Build Dart Only",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"env": {
"RUST_LOG": "debug",
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core",
"env": {
"RUST_LOG": "trace",
"RUST_BACKTRACE": "1"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds will:
// - call the clean task,
// - rebuild all the generated Files (including freeze and language files)
// - rebuild the the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS Simulator",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For Android",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (Android)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
},
]
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// This task only builds the Dart code of AppFlowy.
// It supports both the desktop and mobile version.
"name": "AF: Build Dart Only",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"env": {
"RUST_LOG": "debug",
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core",
"env": {
"RUST_LOG": "trace",
"RUST_BACKTRACE": "1"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds will:
// - call the clean task,
// - rebuild all the generated Files (including freeze and language files)
// - rebuild the the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS Simulator",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For Android",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (Android)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
},
]
}

View File

@ -175,36 +175,36 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
SPEC CHECKSUMS:
app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795
appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
app_links: c5161ac5ab5383ad046884568b4b91cb52df5d91
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
integration_test: d5929033778cc4991a187e4e1a85396fa4f59b3a
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite: c35dad70033b8862124f8337cc994a809fcd9fa3
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
webview_flutter_wkwebview: 45a041c7831641076618876de3ba75c712860c6b
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca

View File

@ -11,6 +11,7 @@ import 'package:appflowy/plugins/document/application/editor_transaction_adapter
import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/app_widget.dart';
import 'package:appflowy/startup/tasks/device_info_task.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/util/color_generator/color_generator.dart';
@ -18,6 +19,7 @@ import 'package:appflowy/util/color_to_hex_string.dart';
import 'package:appflowy/util/debounce.dart';
import 'package:appflowy/util/throttle.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
@ -25,11 +27,11 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
show
EditorState,
AppFlowyEditorLogLevel,
TransactionTime,
Selection,
EditorState,
Position,
Selection,
TransactionTime,
paragraphNode;
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/foundation.dart';
@ -304,7 +306,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
..level = AppFlowyEditorLogLevel.all
..handler = (log) {
if (enableDocumentInternalLog) {
Log.info(log);
// Log.info(log);
}
};
}
@ -363,6 +365,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
}
void _throttleSyncDoc(DocEventPB docEvent) {
if (enableDocumentInternalLog) {
Log.info('[DocumentBloc] throttle sync doc: ${docEvent.toProto3Json()}');
}
_syncThrottle.call(() {
_onDocumentStateUpdate(docEvent);
});
@ -448,9 +453,17 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
if (!deepEqual) {
Log.error('document integrity check failed');
// Enable it to debug the document integrity check failed
// Log.error('cloud doc: $cloudJson');
// Log.error('local doc: $localJson');
assert(false, 'document integrity check failed');
Log.error('cloud doc: $cloudJson');
Log.error('local doc: $localJson');
final context = AppGlobals.rootNavKey.currentContext;
if (context != null && context.mounted) {
showToastNotification(
context,
message: 'document integrity check failed',
type: ToastificationType.error,
);
}
}
}
}

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/document_diff.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/shared/list_extension.dart';
import 'package:appflowy/startup/tasks/device_info_task.dart';
@ -16,10 +17,14 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class DocumentCollabAdapter {
DocumentCollabAdapter(this.editorState, this.docId);
DocumentCollabAdapter(
this.editorState,
this.docId,
);
final EditorState editorState;
final String docId;
final DocumentDiff diff = const DocumentDiff();
final _service = DocumentService();
@ -75,13 +80,13 @@ class DocumentCollabAdapter {
return;
}
final ops = diffNodes(editorState.document.root, document.root);
final ops = diff.diffDocument(editorState.document, document);
if (ops.isEmpty) {
return;
}
// Use for debugging, DO NOT REMOVE
// prettyPrintJson(ops.map((op) => op.toJson()).toList());
prettyPrintJson(ops.map((op) => op.toJson()).toList());
final transaction = editorState.transaction;
for (final op in ops) {
@ -90,17 +95,17 @@ class DocumentCollabAdapter {
await editorState.apply(transaction, isRemote: true);
// Use for debugging, DO NOT REMOVE
// assert(() {
// final local = editorState.document.root.toJson();
// final remote = document.root.toJson();
// if (!const DeepCollectionEquality().equals(local, remote)) {
// Log.error('Invalid diff status');
// Log.error('Local: $local');
// Log.error('Remote: $remote');
// return false;
// }
// return true;
// }());
assert(() {
final local = editorState.document.root.toJson();
final remote = document.root.toJson();
if (!const DeepCollectionEquality().equals(local, remote)) {
Log.error('Invalid diff status');
Log.error('Local: $local');
Log.error('Remote: $remote');
return false;
}
return true;
}());
}
Future<void> forceReload() async {

View File

@ -0,0 +1,172 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
/// DocumentDiff compares two document states and generates operations needed
/// to transform one state into another.
class DocumentDiff {
const DocumentDiff({
this.enableDebugLog = false,
});
final bool enableDebugLog;
// Using DeepCollectionEquality for deep comparison of collections
static const _equality = DeepCollectionEquality();
/// Generates operations needed to transform oldDocument into newDocument.
/// Returns a list of operations (Insert, Delete, Update) that can be applied sequentially.
List<Operation> diffDocument(Document oldDocument, Document newDocument) {
return diffNode(oldDocument.root, newDocument.root);
}
/// Compares two nodes and their children recursively to generate transformation operations.
/// Returns a list of operations that will transform oldNode into newNode.
List<Operation> diffNode(Node oldNode, Node newNode) {
final operations = <Operation>[];
// Compare and update node attributes if they're different.
// Using DeepCollectionEquality instead of == for deep comparison of collections
if (!_equality.equals(oldNode.attributes, newNode.attributes)) {
operations.add(
UpdateOperation(oldNode.path, newNode.attributes, oldNode.attributes),
);
}
final oldChildrenById = Map<String, Node>.fromEntries(
oldNode.children.map((child) => MapEntry(child.id, child)),
);
final newChildrenById = Map<String, Node>.fromEntries(
newNode.children.map((child) => MapEntry(child.id, child)),
);
// Insertion or Update
for (final newChild in newNode.children) {
final oldChild = oldChildrenById[newChild.id];
if (oldChild == null) {
// If the node doesn't exist in the old document, it's a new node.
operations.add(InsertOperation(newChild.path, [newChild]));
} else {
// If the node exists in the old document, recursively compare its children
operations.addAll(diffNode(oldChild, newChild));
}
}
// Deletion
for (final id in oldChildrenById.keys) {
// If the node doesn't exist in the new document, it's a deletion.
if (!newChildrenById.containsKey(id)) {
final oldChild = oldChildrenById[id]!;
operations.add(DeleteOperation(oldChild.path, [oldChild]));
}
}
// Optimize operations by merging consecutive inserts and deletes
return _optimizeOperations(operations);
}
/// Optimizes the list of operations by merging consecutive operations where possible.
/// This reduces the total number of operations that need to be applied.
List<Operation> _optimizeOperations(List<Operation> operations) {
// Optimize the insert operations first, then the delete operations
final optimizedOps = mergeDeleteOperations(
mergeInsertOperations(
operations,
),
);
return optimizedOps;
}
/// Merges consecutive insert operations to reduce the number of operations.
/// Operations are merged if they target consecutive paths in the document.
List<Operation> mergeInsertOperations(List<Operation> operations) {
if (enableDebugLog) {
_logOperations('mergeInsertOperations[before]', operations);
}
final copy = [...operations];
final insertOperations = operations
.whereType<InsertOperation>()
.sorted(_descendingCompareTo)
.toList();
_mergeConsecutiveOperations<InsertOperation>(
insertOperations,
(prev, current) => InsertOperation(
prev.path,
[...prev.nodes, ...current.nodes],
),
);
if (insertOperations.isNotEmpty) {
copy
..removeWhere((op) => op is InsertOperation)
..insertAll(0, insertOperations); // Insert ops must be at the start
}
if (enableDebugLog) {
_logOperations('mergeInsertOperations[after]', copy);
}
return copy;
}
/// Merges consecutive delete operations to reduce the number of operations.
/// Operations are merged if they target consecutive paths in the document.
List<Operation> mergeDeleteOperations(List<Operation> operations) {
if (enableDebugLog) {
_logOperations('mergeDeleteOperations[before]', operations);
}
final copy = [...operations];
final deleteOperations = operations
.whereType<DeleteOperation>()
.sorted(_descendingCompareTo)
.toList();
_mergeConsecutiveOperations<DeleteOperation>(
deleteOperations,
(prev, current) => DeleteOperation(
prev.path,
[...prev.nodes, ...current.nodes],
),
);
if (deleteOperations.isNotEmpty) {
copy
..removeWhere((op) => op is DeleteOperation)
..addAll(deleteOperations); // Delete ops must be at the end
}
if (enableDebugLog) {
_logOperations('mergeDeleteOperations[after]', copy);
}
return copy;
}
/// Merge consecutive operations of the same type
void _mergeConsecutiveOperations<T extends Operation>(
List<T> operations,
T Function(T prev, T current) merge,
) {
for (var i = operations.length - 1; i > 0; i--) {
final op = operations[i];
final previousOp = operations[i - 1];
if (op.path.equals(previousOp.path.next)) {
operations
..removeAt(i)
..[i - 1] = merge(previousOp, op);
}
}
}
void _logOperations(String prefix, List<Operation> operations) {
debugPrint('$prefix: ${operations.map((op) => op.toJson()).toList()}');
}
int _descendingCompareTo(Operation a, Operation b) {
return a.path > b.path ? 1 : -1;
}
}

View File

@ -105,9 +105,16 @@ BlockComponentConfiguration _buildDefaultConfiguration(BuildContext context) {
return const EdgeInsets.symmetric(vertical: 5.0);
},
indentPadding: (node, textDirection) => textDirection == TextDirection.ltr
? const EdgeInsets.only(left: 26.0)
: const EdgeInsets.only(right: 26.0),
indentPadding: (node, textDirection) {
double padding = 26.0;
// only add indent padding for the top level node to align the children
if (UniversalPlatform.isMobile && node.path.length == 1) {
padding += EditorStyleCustomizer.nodeHorizontalPadding;
}
return textDirection == TextDirection.ltr
? EdgeInsets.only(left: padding)
: EdgeInsets.only(right: padding);
},
);
return configuration;
}

View File

@ -287,7 +287,9 @@ class _ToggleListBlockComponentWidgetState
}
return Padding(
padding: indentPadding,
padding: UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 26.0)
: indentPadding,
child: FlowyButton(
text: FlowyText(
buildPlaceholderText(),

View File

@ -130,31 +130,31 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a
appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9
bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00
connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38
app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468
appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7
bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9
connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041
file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31
flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
HotKey: e96d8a2ddbf4591131e2bb3f54e69554d90cdca6
hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c
irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
ReachabilitySwift: 7f151ff156cea1481a8411701195ac6a984f4979
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
screen_retriever: 4f97c103641aab8ce183fa5af3b87029df167936
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite: c35dad70033b8862124f8337cc994a809fcd9fa3
super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823

View File

@ -61,8 +61,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "5352bb4"
resolved-ref: "5352bb4a2483039b97073f40c32a93f8fc5e7242"
ref: "7b33754"
resolved-ref: "7b33754d63841868ef4e391d1385063ef2835a61"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "4.0.0"

View File

@ -174,7 +174,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "5352bb4"
ref: "7b33754"
appflowy_editor_plugins:
git:

View File

@ -0,0 +1,403 @@
import 'package:appflowy/plugins/document/application/document_diff.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('document diff:', () {
setUpAll(() {
Log.shared.disableLog = true;
});
tearDownAll(() {
Log.shared.disableLog = false;
});
const diff = DocumentDiff();
Node createNodeWithId({required String id, required String text}) {
return Node(
id: id,
type: ParagraphBlockKeys.type,
attributes: {
ParagraphBlockKeys.delta: (Delta()..insert(text)).toJson(),
},
);
}
Future<void> applyOperationAndVerifyDocument(
Document before,
Document after,
List<Operation> operations,
) async {
final expected = after.toJson();
final editorState = EditorState(document: before);
final transaction = editorState.transaction;
for (final operation in operations) {
transaction.add(operation);
}
await editorState.apply(transaction);
expect(editorState.document.toJson(), expected);
}
test('no diff when the document is the same', () async {
// create two nodes with the same id and texts
final node1 = createNodeWithId(id: '1', text: 'Hello AppFlowy');
final node2 = createNodeWithId(id: '1', text: 'Hello AppFlowy');
final previous = Document.blank()..insert([0], [node1]);
final next = Document.blank()..insert([0], [node2]);
final operations = diff.diffDocument(previous, next);
expect(operations, isEmpty);
await applyOperationAndVerifyDocument(previous, next, operations);
});
test('update text diff with the same id', () async {
final node1 = createNodeWithId(id: '1', text: 'Hello AppFlowy');
final node2 = createNodeWithId(id: '1', text: 'Hello AppFlowy 2');
final previous = Document.blank()..insert([0], [node1]);
final next = Document.blank()..insert([0], [node2]);
final operations = diff.diffDocument(previous, next);
expect(operations.length, 1);
expect(operations[0], isA<UpdateOperation>());
await applyOperationAndVerifyDocument(previous, next, operations);
});
test('delete and insert text diff with different id', () async {
final node1 = createNodeWithId(id: '1', text: 'Hello AppFlowy');
final node2 = createNodeWithId(id: '2', text: 'Hello AppFlowy 2');
final previous = Document.blank()..insert([0], [node1]);
final next = Document.blank()..insert([0], [node2]);
final operations = diff.diffDocument(previous, next);
expect(operations.length, 2);
expect(operations[0], isA<InsertOperation>());
expect(operations[1], isA<DeleteOperation>());
await applyOperationAndVerifyDocument(previous, next, operations);
});
test('insert single text diff', () async {
final node1 = createNodeWithId(
id: '1',
text: 'Hello AppFlowy - First line',
);
final node21 = createNodeWithId(
id: '1',
text: 'Hello AppFlowy - First line',
);
final node22 = createNodeWithId(
id: '2',
text: 'Hello AppFlowy - Second line',
);
final previous = Document.blank()..insert([0], [node1]);
final next = Document.blank()..insert([0], [node21, node22]);
final operations = diff.diffDocument(previous, next);
expect(operations.length, 1);
expect(operations[0], isA<InsertOperation>());
await applyOperationAndVerifyDocument(previous, next, operations);
});
test('delete single text diff', () async {
final node11 = createNodeWithId(
id: '1',
text: 'Hello AppFlowy - First line',
);
final node12 = createNodeWithId(
id: '2',
text: 'Hello AppFlowy - Second line',
);
final node21 = createNodeWithId(
id: '1',
text: 'Hello AppFlowy - First line',
);
final previous = Document.blank()..insert([0], [node11, node12]);
final next = Document.blank()..insert([0], [node21]);
final operations = diff.diffDocument(previous, next);
expect(operations.length, 1);
expect(operations[0], isA<DeleteOperation>());
await applyOperationAndVerifyDocument(previous, next, operations);
});
test('insert multiple texts diff', () async {
final node11 = createNodeWithId(
id: '1',
text: 'Hello AppFlowy - First line',
);
final node15 = createNodeWithId(
id: '5',
text: 'Hello AppFlowy - Fifth line',
);
final node21 = createNodeWithId(
id: '1',
text: 'Hello AppFlowy - First line',
);
final node22 = createNodeWithId(
id: '2',
text: 'Hello AppFlowy - Second line',
);
final node23 = createNodeWithId(
id: '3',
text: 'Hello AppFlowy - Third line',
);
final node24 = createNodeWithId(
id: '4',
text: 'Hello AppFlowy - Fourth line',
);
final node25 = createNodeWithId(
id: '5',
text: 'Hello AppFlowy - Fifth line',
);
final previous = Document.blank()
..insert(
[0],
[
node11,
node15,
],
);
final next = Document.blank()
..insert(
[0],
[
node21,
node22,
node23,
node24,
node25,
],
);
final operations = diff.diffDocument(previous, next);
expect(operations.length, 1);
final op = operations[0] as InsertOperation;
expect(op.path, [1]);
expect(op.nodes, [node22, node23, node24]);
await applyOperationAndVerifyDocument(previous, next, operations);
});
test('delete multiple texts diff', () async {
final node11 = createNodeWithId(
id: '1',
text: 'Hello AppFlowy - First line',
);
final node12 = createNodeWithId(
id: '2',
text: 'Hello AppFlowy - Second line',
);
final node13 = createNodeWithId(
id: '3',
text: 'Hello AppFlowy - Third line',
);
final node14 = createNodeWithId(
id: '4',
text: 'Hello AppFlowy - Fourth line',
);
final node15 = createNodeWithId(
id: '5',
text: 'Hello AppFlowy - Fifth line',
);
final node21 = createNodeWithId(
id: '1',
text: 'Hello AppFlowy - First line',
);
final node25 = createNodeWithId(
id: '5',
text: 'Hello AppFlowy - Fifth line',
);
final previous = Document.blank()
..insert(
[0],
[
node11,
node12,
node13,
node14,
node15,
],
);
final next = Document.blank()
..insert(
[0],
[
node21,
node25,
],
);
final operations = diff.diffDocument(previous, next);
expect(operations.length, 1);
final op = operations[0] as DeleteOperation;
expect(op.path, [1]);
expect(op.nodes, [node12, node13, node14]);
await applyOperationAndVerifyDocument(previous, next, operations);
});
test('multiple delete and update diff', () async {
final node11 = createNodeWithId(
id: '1',
text: 'Hello AppFlowy - First line',
);
final node12 = createNodeWithId(
id: '2',
text: 'Hello AppFlowy - Second line',
);
final node13 = createNodeWithId(
id: '3',
text: 'Hello AppFlowy - Third line',
);
final node14 = createNodeWithId(
id: '4',
text: 'Hello AppFlowy - Fourth line',
);
final node15 = createNodeWithId(
id: '5',
text: 'Hello AppFlowy - Fifth line',
);
final node21 = createNodeWithId(
id: '1',
text: 'Hello AppFlowy - First line',
);
final node22 = createNodeWithId(
id: '2',
text: '',
);
final node25 = createNodeWithId(
id: '5',
text: 'Hello AppFlowy - Fifth line',
);
final previous = Document.blank()
..insert(
[0],
[
node11,
node12,
node13,
node14,
node15,
],
);
final next = Document.blank()
..insert(
[0],
[
node21,
node22,
node25,
],
);
final operations = diff.diffDocument(previous, next);
expect(operations.length, 2);
final op1 = operations[0] as UpdateOperation;
expect(op1.path, [1]);
expect(op1.attributes, node22.attributes);
final op2 = operations[1] as DeleteOperation;
expect(op2.path, [2]);
expect(op2.nodes, [node13, node14]);
await applyOperationAndVerifyDocument(previous, next, operations);
});
test('multiple insert and update diff', () async {
final node11 = createNodeWithId(
id: '1',
text: 'Hello AppFlowy - First line',
);
final node12 = createNodeWithId(
id: '2',
text: 'Hello AppFlowy - Second line',
);
final node13 = createNodeWithId(
id: '3',
text: 'Hello AppFlowy - Third line',
);
final node21 = createNodeWithId(
id: '1',
text: 'Hello AppFlowy - First line',
);
final node22 = createNodeWithId(
id: '2',
text: 'Hello AppFlowy - Second line - Updated',
);
final node23 = createNodeWithId(
id: '3',
text: 'Hello AppFlowy - Third line - Updated',
);
final node24 = createNodeWithId(
id: '4',
text: 'Hello AppFlowy - Fourth line - Updated',
);
final node25 = createNodeWithId(
id: '5',
text: 'Hello AppFlowy - Fifth line - Updated',
);
final previous = Document.blank()
..insert(
[0],
[
node11,
node12,
node13,
],
);
final next = Document.blank()
..insert(
[0],
[
node21,
node22,
node23,
node24,
node25,
],
);
final operations = diff.diffDocument(previous, next);
expect(operations.length, 3);
final op1 = operations[0] as InsertOperation;
expect(op1.path, [3]);
expect(op1.nodes, [node24, node25]);
final op2 = operations[1] as UpdateOperation;
expect(op2.path, [1]);
expect(op2.attributes, node22.attributes);
final op3 = operations[2] as UpdateOperation;
expect(op3.path, [2]);
expect(op3.attributes, node23.attributes);
await applyOperationAndVerifyDocument(previous, next, operations);
});
});
}

View File

@ -267,9 +267,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.17"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cb8f1d480b0ea3783ab015936d2a55c87e219676f0c0b7dec61494043f21857"
checksum = "df895a515f70646414f4b45c0b79082783b80552b373a68283012928df56f522"
dependencies = [
"bzip2",
"deflate64",
@ -2781,9 +2781,9 @@ checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
[[package]]
name = "futures-lite"
version = "2.3.0"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5"
checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532"
dependencies = [
"fastrand",
"futures-core",