diff --git a/frontend/.vscode/launch.json b/frontend/.vscode/launch.json index 4c4f7aa8e6..d4ff85a2dd 100644 --- a/frontend/.vscode/launch.json +++ b/frontend/.vscode/launch.json @@ -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]", + }, + ] } diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index 46b13d240d..e4e87805cd 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -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 diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart index f34c9e0d6d..e3d79a5868 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -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 { ..level = AppFlowyEditorLogLevel.all ..handler = (log) { if (enableDocumentInternalLog) { - Log.info(log); + // Log.info(log); } }; } @@ -363,6 +365,9 @@ class DocumentBloc extends Bloc { } 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 { 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, + ); + } } } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_collab_adapter.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_collab_adapter.dart index a3e62d569c..afedc03ef9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_collab_adapter.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_collab_adapter.dart @@ -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 forceReload() async { diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_diff.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_diff.dart new file mode 100644 index 0000000000..e174d6671e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_diff.dart @@ -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 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 diffNode(Node oldNode, Node newNode) { + final operations = []; + + // 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.fromEntries( + oldNode.children.map((child) => MapEntry(child.id, child)), + ); + final newChildrenById = Map.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 _optimizeOperations(List 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 mergeInsertOperations(List operations) { + if (enableDebugLog) { + _logOperations('mergeInsertOperations[before]', operations); + } + + final copy = [...operations]; + final insertOperations = operations + .whereType() + .sorted(_descendingCompareTo) + .toList(); + + _mergeConsecutiveOperations( + 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 mergeDeleteOperations(List operations) { + if (enableDebugLog) { + _logOperations('mergeDeleteOperations[before]', operations); + } + + final copy = [...operations]; + final deleteOperations = operations + .whereType() + .sorted(_descendingCompareTo) + .toList(); + + _mergeConsecutiveOperations( + 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( + List 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 operations) { + debugPrint('$prefix: ${operations.map((op) => op.toJson()).toList()}'); + } + + int _descendingCompareTo(Operation a, Operation b) { + return a.path > b.path ? 1 : -1; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart index 24e05c5077..8d911ebfb5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -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; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart index 78c9fe9894..99275b9a8e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart @@ -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(), diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index 347343dad8..8cba474667 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -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 diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 7e6aec892f..c46ab526ec 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -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" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 385d2f453c..124984adaf 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -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: diff --git a/frontend/appflowy_flutter/test/unit_test/document/document_diff/document_diff_test.dart b/frontend/appflowy_flutter/test/unit_test/document/document_diff/document_diff_test.dart new file mode 100644 index 0000000000..7124eb93fc --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/document_diff/document_diff_test.dart @@ -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 applyOperationAndVerifyDocument( + Document before, + Document after, + List 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()); + + 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()); + expect(operations[1], isA()); + + 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()); + + 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()); + + 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); + }); + }); +} diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index c71fe6268f..c78fb2c9cb 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -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",