diff --git a/frontend/appflowy_flutter/.gitignore b/frontend/appflowy_flutter/.gitignore index 8bf1c9abfe..504d82329b 100644 --- a/frontend/appflowy_flutter/.gitignore +++ b/frontend/appflowy_flutter/.gitignore @@ -72,3 +72,5 @@ windows/flutter/dart_ffi/ *.env coverage/ + +**/failures/*.png diff --git a/frontend/appflowy_flutter/integration_test/cover_image_test.dart b/frontend/appflowy_flutter/integration_test/document/cover_image_test.dart similarity index 96% rename from frontend/appflowy_flutter/integration_test/cover_image_test.dart rename to frontend/appflowy_flutter/integration_test/document/cover_image_test.dart index fcb8a12ae7..0d3708d1f2 100644 --- a/frontend/appflowy_flutter/integration_test/cover_image_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/cover_image_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'util/util.dart'; +import '../util/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document_test.dart b/frontend/appflowy_flutter/integration_test/document/document_test.dart similarity index 98% rename from frontend/appflowy_flutter/integration_test/document_test.dart rename to frontend/appflowy_flutter/integration_test/document/document_test.dart index 93e753a9a1..3efbbee43b 100644 --- a/frontend/appflowy_flutter/integration_test/document_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/document_test.dart @@ -4,7 +4,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'util/util.dart'; +import '../util/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document_with_database_test.dart b/frontend/appflowy_flutter/integration_test/document/document_with_database_test.dart similarity index 99% rename from frontend/appflowy_flutter/integration_test/document_with_database_test.dart rename to frontend/appflowy_flutter/integration_test/document/document_with_database_test.dart index 73fdf4dd51..5f6cea1d18 100644 --- a/frontend/appflowy_flutter/integration_test/document_with_database_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/document_with_database_test.dart @@ -7,7 +7,7 @@ import 'package:flowy_infra/uuid.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'util/util.dart'; +import '../util/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart b/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart new file mode 100644 index 0000000000..10dd472362 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/document/edit_document_test.dart @@ -0,0 +1,141 @@ +import 'dart:io'; + +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../util/ime.dart'; +import '../util/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('edit document', () { + const location = 'appflowy'; + + setUp(() async { + await TestFolder.cleanTestLocation(location); + await TestFolder.setTestLocation(location); + }); + + tearDown(() async { + await TestFolder.cleanTestLocation(null); + }); + + testWidgets('redo & undo', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + // create a new document called Sample + const pageName = 'Sample'; + await tester.createNewPageWithName(ViewLayoutPB.Document, pageName); + + // focus on the editor + await tester.editor.tapLineOfEditorAt(0); + + // insert 1. to trigger it to be a numbered list + await tester.ime.insertText('1. '); + expect(find.text('1.', findRichText: true), findsOneWidget); + expect( + tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type, + NumberedListBlockKeys.type, + ); + + // undo + // numbered list will be reverted to paragraph + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: Platform.isWindows || Platform.isLinux, + isMetaPressed: Platform.isMacOS, + ); + expect( + tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type, + ParagraphBlockKeys.type, + ); + + // redo + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: Platform.isWindows || Platform.isLinux, + isMetaPressed: Platform.isMacOS, + isShiftPressed: true, + ); + expect( + tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type, + NumberedListBlockKeys.type, + ); + + // switch to other page and switch back + await tester.openPage(readme); + await tester.openPage(pageName); + + // the numbered list should be kept + expect( + tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type, + NumberedListBlockKeys.type, + ); + }); + + testWidgets('write a readme document', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + // create a new document called Sample + const pageName = 'Sample'; + await tester.createNewPageWithName(ViewLayoutPB.Document, pageName); + + // focus on the editor + await tester.editor.tapLineOfEditorAt(0); + + // mock inputting the sample + final lines = _sample.split('\n'); + for (final line in lines) { + await tester.ime.insertText(line); + await tester.ime.insertCharacter('\n'); + } + + // switch to other page and switch back + await tester.openPage(readme); + await tester.openPage(pageName); + + // this screenshots are different on different platform, so comment it out temporarily. + // check the document + // await expectLater( + // find.byType(AppFlowyEditor), + // matchesGoldenFile('document/edit_document_test.png'), + // ); + }); + }); +} + +// TODO(Lucas.Xu): there're no shorctcuts for underline, format code yet. +const _sample = r''' +# Heading 1 +## Heading 2 +### Heading 3 +--- +[] Highlight any text, and use the editing menu to _style_ **your** writing `however` you ~~like.~~ + +[] Type / followed by /bullet or /num to create a list. + +[x] Click `+ New Page` button at the bottom of your sidebar to add a new page. + +[] Click `+` next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`. +--- +* bulleted list 1 + +* bulleted list 2 + +* bulleted list 3 +bulleted list 4 +--- +1. numbered list 1 + +2. numbered list 2 + +3. numbered list 3 +numbered list 4 +--- +" quote'''; diff --git a/frontend/appflowy_flutter/integration_test/document/edit_document_test.png b/frontend/appflowy_flutter/integration_test/document/edit_document_test.png new file mode 100644 index 0000000000..6490cb4de2 Binary files /dev/null and b/frontend/appflowy_flutter/integration_test/document/edit_document_test.png differ diff --git a/frontend/appflowy_flutter/integration_test/runner.dart b/frontend/appflowy_flutter/integration_test/runner.dart index 054dccfbf1..8dc301ddbd 100644 --- a/frontend/appflowy_flutter/integration_test/runner.dart +++ b/frontend/appflowy_flutter/integration_test/runner.dart @@ -1,11 +1,13 @@ import 'package:integration_test/integration_test.dart'; import 'switch_folder_test.dart' as switch_folder_test; -import 'document_test.dart' as document_test; -import 'cover_image_test.dart' as cover_image_test; +import 'document/document_test.dart' as document_test; +import 'document/cover_image_test.dart' as cover_image_test; import 'share_markdown_test.dart' as share_markdown_test; import 'import_files_test.dart' as import_files_test; -import 'document_with_database_test.dart' as document_with_database_test; +import 'document/document_with_database_test.dart' + as document_with_database_test; +import 'document/edit_document_test.dart' as edit_document_test; import 'database_cell_test.dart' as database_cell_test; import 'database_field_test.dart' as database_field_test; import 'database_share_test.dart' as database_share_test; @@ -27,11 +29,14 @@ import 'database_sort_test.dart' as database_sort_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); switch_folder_test.main(); - cover_image_test.main(); - document_test.main(); share_markdown_test.main(); import_files_test.main(); + + // Document integration tests + cover_image_test.main(); + document_test.main(); document_with_database_test.main(); + edit_document_test.main(); // Database integration tests database_cell_test.main(); diff --git a/frontend/appflowy_flutter/integration_test/util/common_operations.dart b/frontend/appflowy_flutter/integration_test/util/common_operations.dart index 7a86acd9f8..1523f7467a 100644 --- a/frontend/appflowy_flutter/integration_test/util/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/common_operations.dart @@ -11,6 +11,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'util.dart'; @@ -244,6 +245,42 @@ extension CommonOperations on WidgetTester { ); await pumpAndSettle(); } + + Future simulateKeyEvent( + LogicalKeyboardKey key, { + bool isControlPressed = false, + bool isShiftPressed = false, + bool isAltPressed = false, + bool isMetaPressed = false, + }) async { + if (isControlPressed) { + await simulateKeyDownEvent(LogicalKeyboardKey.control); + } + if (isShiftPressed) { + await simulateKeyDownEvent(LogicalKeyboardKey.shift); + } + if (isAltPressed) { + await simulateKeyDownEvent(LogicalKeyboardKey.alt); + } + if (isMetaPressed) { + await simulateKeyDownEvent(LogicalKeyboardKey.meta); + } + await simulateKeyDownEvent(key); + await simulateKeyUpEvent(key); + if (isControlPressed) { + await simulateKeyUpEvent(LogicalKeyboardKey.control); + } + if (isShiftPressed) { + await simulateKeyUpEvent(LogicalKeyboardKey.shift); + } + if (isAltPressed) { + await simulateKeyUpEvent(LogicalKeyboardKey.alt); + } + if (isMetaPressed) { + await simulateKeyUpEvent(LogicalKeyboardKey.meta); + } + await pumpAndSettle(); + } } extension ViewLayoutPBTest on ViewLayoutPB { diff --git a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart b/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart index 70b089370c..31187e1fbc 100644 --- a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart @@ -13,6 +13,12 @@ class EditorOperations { final WidgetTester tester; + EditorState getCurrentEditorState() { + return tester + .widget(find.byType(AppFlowyEditor)) + .editorState; + } + /// Tap the line of editor at [index] Future tapLineOfEditorAt(int index) async { final textBlocks = find.byType(TextBlockComponentWidget); diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart index 8c82db14bb..98498781ef 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart @@ -9,7 +9,7 @@ import 'package:appflowy/plugins/document/application/doc_service.dart'; import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart'; import 'package:appflowy_editor/appflowy_editor.dart' - show EditorState, LogLevel; + show EditorState, LogLevel, TransactionTime; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:flutter/foundation.dart'; @@ -149,8 +149,12 @@ class DocumentBloc extends Bloc { this.editorState = editorState; // subscribe to the document change from the editor - _subscription = editorState.transactionStream.listen((transaction) async { - await _transactionAdapter.apply(transaction, editorState); + _subscription = editorState.transactionStream.listen((event) async { + final time = event.$1; + if (time != TransactionTime.before) { + return; + } + await _transactionAdapter.apply(event.$2, editorState); }); // output the log from the editor when debug mode diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart index 36b852b11b..569d81fd69 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart @@ -117,12 +117,13 @@ extension NodeToBlock on Node { BlockPB toBlock({ String? parentId, String? childrenId, + Attributes? attributes, }) { assert(id.isNotEmpty); final block = BlockPB.create() ..id = id ..ty = type - ..data = _dataAdapter(type, attributes); + ..data = _dataAdapter(type, attributes ?? this.attributes); if (childrenId != null && childrenId.isNotEmpty) { block.childrenId = childrenId; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart b/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart index eccbcf31f2..79eb3aa7be 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart @@ -10,7 +10,8 @@ import 'package:appflowy_editor/appflowy_editor.dart' UpdateOperation, DeleteOperation, PathExtensions, - Node; + Node, + composeAttributes; import 'package:collection/collection.dart'; import 'dart:async'; @@ -34,7 +35,8 @@ class TransactionAdapter { final actions = transaction.operations .map((op) => op.toBlockAction(editorState)) .whereNotNull() - .expand((element) => element); + .expand((element) => element) + .toList(growable: false); // avoid lazy evaluation // Log.debug('actions => $actions'); await documentService.applyAction( documentId: documentId, @@ -114,7 +116,10 @@ extension on UpdateOperation { node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? ''; assert(parentId.isNotEmpty); final payload = BlockActionPayloadPB() - ..block = node.toBlock() + ..block = node.toBlock( + parentId: parentId, + attributes: composeAttributes(oldAttributes, attributes), + ) ..parentId = parentId; actions.add( BlockActionPB() @@ -132,7 +137,9 @@ extension on DeleteOperation { final parentId = node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? ''; final payload = BlockActionPayloadPB() - ..block = node.toBlock() + ..block = node.toBlock( + parentId: parentId, + ) ..parentId = parentId; assert(parentId.isNotEmpty); actions.add( diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart index 3ef00ff3ab..6062cae35f 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart @@ -50,7 +50,6 @@ class AppFlowyPopover extends StatelessWidget { offset: offset, popupBuilder: (context) { final child = popupBuilder(context); - debugPrint("Show popover: $child"); return _PopoverContainer( constraints: constraints, margin: margin, diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 5ef5d52db6..6e4e83c07f 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -52,10 +52,11 @@ packages: appflowy_editor: dependency: "direct main" description: - name: appflowy_editor - sha256: "3ab567d8993ca06ea114c35bc38c07d2f0d7a5b00857f52d71fbe6a7f9d2ba7b" - url: "https://pub.dev" - source: hosted + path: "." + ref: "4f83b6f" + resolved-ref: "4f83b6feb92963f104f3f1f77a473a06aa4870f5" + url: "https://github.com/AppFlowy-IO/appflowy-editor.git" + source: git version: "1.0.4" appflowy_popover: dependency: "direct main" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 9c030ee0ac..ae73a6e4f2 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -42,11 +42,11 @@ dependencies: git: url: https://github.com/AppFlowy-IO/appflowy-board.git ref: a183c57 - appflowy_editor: ^1.0.4 - # appflowy_editor: - # git: - # url: https://github.com/AppFlowy-IO/appflowy-editor.git - # ref: d2460c9 + # appflowy_editor: ^1.0.4 + appflowy_editor: + git: + url: https://github.com/AppFlowy-IO/appflowy-editor.git + ref: 4f83b6f appflowy_popover: path: packages/appflowy_popover