From 555d08e8ced0efc52b0d78658d999b51cca9c17a Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 14 Nov 2024 13:34:24 +0800 Subject: [PATCH] feat: add toggle heading in plus menu on mobile (#6784) * chore: add logs in space bloc * feat: add toggle headings in plus menu * test: add toggle heading block test * test: toogle heading 1 block test * test: add toggle heading selection test * fix: toggle headings test * chore: update new toggle heading icons --- .../mobile/document/document_test_runner.dart | 2 + .../mobile/document/plus_menu_test.dart | 89 +++++++++++++++++++ .../shared/common_operations.dart | 54 +++++++++++ .../base/type_option_menu_item.dart | 24 +++-- .../option/turn_into_option_action.dart | 6 +- .../add_block_toolbar_item.dart | 67 ++++++++++++-- .../slash_menu/slash_menu_items.dart | 6 +- .../toggle/toggle_block_component.dart | 1 + .../application/sidebar/space/space_bloc.dart | 10 +++ .../flowy_icons/16x/toggle_heading1.svg | 4 + .../flowy_icons/16x/toggle_heading2.svg | 4 + .../flowy_icons/16x/toggle_heading3.svg | 4 + 12 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart create mode 100644 frontend/resources/flowy_icons/16x/toggle_heading1.svg create mode 100644 frontend/resources/flowy_icons/16x/toggle_heading2.svg create mode 100644 frontend/resources/flowy_icons/16x/toggle_heading3.svg diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart b/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart index c719051174..015e11676c 100644 --- a/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart @@ -1,6 +1,7 @@ import 'package:integration_test/integration_test.dart'; import 'page_style_test.dart' as page_style_test; +import 'plus_menu_test.dart' as plus_menu_test; import 'title_test.dart' as title_test; void main() { @@ -9,4 +10,5 @@ void main() { // Document integration tests title_test.main(); page_style_test.main(); + plus_menu_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart new file mode 100644 index 0000000000..bdd84f9098 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document plus menu:', () { + testWidgets('add the toggle heading blocks via plus menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createNewDocumentOnMobile('toggle heading blocks'); + + final editorState = tester.editor.getCurrentEditorState(); + // focus on the editor + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [0])), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + // open the plus menu and select the toggle heading block + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_toggleHeading1.tr(), + ); + + // check the block is inserted + final block1 = editorState.getNodeAtPath([0])!; + expect(block1.type, equals(ToggleListBlockKeys.type)); + expect(block1.attributes[ToggleListBlockKeys.level], equals(1)); + + // click the expand button won't cancel the selection + await tester.tapButton(find.byIcon(Icons.arrow_right)); + expect( + editorState.selection, + equals(Selection.collapsed(Position(path: [0]))), + ); + + // focus on the next line + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [1])), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + // open the plus menu and select the toggle heading block + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_toggleHeading2.tr(), + ); + + // check the block is inserted + final block2 = editorState.getNodeAtPath([1])!; + expect(block2.type, equals(ToggleListBlockKeys.type)); + expect(block2.attributes[ToggleListBlockKeys.level], equals(2)); + + // focus on the next line + await tester.pumpAndSettle(); + + // open the plus menu and select the toggle heading block + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_toggleHeading3.tr(), + ); + + // check the block is inserted + final block3 = editorState.getNodeAtPath([2])!; + expect(block3.type, equals(ToggleListBlockKeys.type)); + expect(block3.attributes[ToggleListBlockKeys.level], equals(3)); + + // wait a few milliseconds to ensure the selection is updated + await Future.delayed(const Duration(milliseconds: 100)); + // check the selection is collapsed + expect( + editorState.selection, + equals(Selection.collapsed(Position(path: [2]))), + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart index 4649413717..5aef1e34ec 100644 --- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -4,8 +4,10 @@ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart'; import 'package:appflowy/plugins/shared/share/share_button.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart'; @@ -42,6 +44,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_platform/universal_platform.dart'; import 'emoji.dart'; import 'util.dart'; @@ -787,6 +790,57 @@ extension CommonOperations on WidgetTester { await tap(finder); await pumpAndSettle(const Duration(seconds: 2)); } + + /// Create a new document on mobile + Future createNewDocumentOnMobile(String name) async { + final createPageButton = find.byKey( + BottomNavigationBarItemType.add.valueKey, + ); + await tapButton(createPageButton); + expect(find.byType(MobileDocumentScreen), findsOneWidget); + + final title = editor.findDocumentTitle(''); + expect(title, findsOneWidget); + final textField = widget(title); + expect(textField.focusNode!.hasFocus, isTrue); + + // input new name and press done button + await enterText(title, name); + await testTextInput.receiveAction(TextInputAction.done); + await pumpAndSettle(); + final newTitle = editor.findDocumentTitle(name); + expect(newTitle, findsOneWidget); + expect(textField.controller!.text, name); + } + + /// Open the plus menu + Future openPlusMenuAndClickButton(String buttonName) async { + assert( + UniversalPlatform.isMobile, + 'This method is only supported on mobile platforms', + ); + + final plusMenuButton = find.byKey(addBlockToolbarItemKey); + final addMenuItem = find.byType(AddBlockMenu); + await tapButton(plusMenuButton); + await pumpUntilFound(addMenuItem); + + final toggleHeading1 = find.byWidgetPredicate( + (widget) => + widget is TypeOptionMenuItem && widget.value.text == buttonName, + ); + final scrollable = find.ancestor( + of: find.byType(TypeOptionGridView), + matching: find.byType(Scrollable), + ); + await scrollUntilVisible( + toggleHeading1, + 100, + scrollable: scrollable, + ); + await tapButton(toggleHeading1); + await pumpUntilNotFound(addMenuItem); + } } extension SettingsFinder on CommonFinders { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/type_option_menu_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/type_option_menu_item.dart index 5e4595a1e5..e0c3140ea9 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/type_option_menu_item.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/type_option_menu_item.dart @@ -9,12 +9,14 @@ class TypeOptionMenuItemValue { required this.text, required this.backgroundColor, required this.onTap, + this.iconPadding, }); final T value; final FlowySvgData icon; final String text; final Color backgroundColor; + final EdgeInsets? iconPadding; final void Function(BuildContext context, T value) onTap; } @@ -22,7 +24,7 @@ class TypeOptionMenu extends StatelessWidget { const TypeOptionMenu({ super.key, required this.values, - this.width = 94, + this.width = 98, this.iconWidth = 72, this.scaleFactor = 1.0, this.maxAxisSpacing = 18, @@ -39,17 +41,18 @@ class TypeOptionMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return _GridView( + return TypeOptionGridView( crossAxisCount: crossAxisCount, mainAxisSpacing: maxAxisSpacing * scaleFactor, itemWidth: width * scaleFactor, children: values .map( - (value) => _TypeOptionMenuItem( + (value) => TypeOptionMenuItem( value: value, width: width, iconWidth: iconWidth, scaleFactor: scaleFactor, + iconPadding: value.iconPadding, ), ) .toList(), @@ -57,18 +60,21 @@ class TypeOptionMenu extends StatelessWidget { } } -class _TypeOptionMenuItem extends StatelessWidget { - const _TypeOptionMenuItem({ +class TypeOptionMenuItem extends StatelessWidget { + const TypeOptionMenuItem({ + super.key, required this.value, this.width = 94, this.iconWidth = 72, this.scaleFactor = 1.0, + this.iconPadding, }); final TypeOptionMenuItemValue value; final double iconWidth; final double width; final double scaleFactor; + final EdgeInsets? iconPadding; double get scaledIconWidth => iconWidth * scaleFactor; double get scaledWidth => width * scaleFactor; @@ -88,7 +94,8 @@ class _TypeOptionMenuItem extends StatelessWidget { borderRadius: BorderRadius.circular(24 * scaleFactor), ), ), - padding: EdgeInsets.all(21 * scaleFactor), + padding: EdgeInsets.all(21 * scaleFactor) + + (iconPadding ?? EdgeInsets.zero), child: FlowySvg( value.icon, ), @@ -113,8 +120,9 @@ class _TypeOptionMenuItem extends StatelessWidget { } } -class _GridView extends StatelessWidget { - const _GridView({ +class TypeOptionGridView extends StatelessWidget { + const TypeOptionGridView({ + super.key, required this.children, required this.crossAxisCount, required this.mainAxisSpacing, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart index 430542ae08..ac3774a511 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart @@ -299,11 +299,11 @@ class _TurnInfoButton extends StatelessWidget { } else if (type == ToggleListBlockKeys.type) { switch (level) { case 1: - return FlowySvgs.slash_menu_icon_h1_s; + return FlowySvgs.toggle_heading1_s; case 2: - return FlowySvgs.slash_menu_icon_h2_s; + return FlowySvgs.toggle_heading2_s; case 3: - return FlowySvgs.slash_menu_icon_h3_s; + return FlowySvgs.toggle_heading3_s; default: return FlowySvgs.slash_menu_icon_toggle_s; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart index 7e894ca95b..7f003a22d2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; @@ -19,11 +17,16 @@ import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +@visibleForTesting +const addBlockToolbarItemKey = ValueKey('add_block_toolbar_item'); + final addBlockToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, service, __, onAction) { return AppFlowyMobileToolbarIconItem( + key: addBlockToolbarItemKey, editorState: editorState, icon: FlowySvgs.m_toolbar_add_m, onTap: () { @@ -75,12 +78,13 @@ Future showAddBlockMenu( enableDraggableScrollable: true, builder: (_) => Padding( padding: EdgeInsets.all(16 * context.scale), - child: _AddBlockMenu(selection: selection, editorState: editorState), + child: AddBlockMenu(selection: selection, editorState: editorState), ), ); -class _AddBlockMenu extends StatelessWidget { - const _AddBlockMenu({ +class AddBlockMenu extends StatelessWidget { + const AddBlockMenu({ + super.key, required this.selection, required this.editorState, }); @@ -100,7 +104,32 @@ class _AddBlockMenu extends StatelessWidget { AppGlobals.rootNavKey.currentContext?.pop(true); Future.delayed( const Duration(milliseconds: 100), - () => editorState.insertBlockAfterCurrentSelection(selection, node), + () async { + // if current selected block is a empty paragraph block, replace it with the new block. + if (selection.isCollapsed) { + final currentNode = editorState.getNodeAtPath(selection.end.path); + final text = currentNode?.delta?.toPlainText(); + if (currentNode != null && + currentNode.type == ParagraphBlockKeys.type && + text != null && + text.isEmpty) { + final transaction = editorState.transaction; + transaction.insertNode( + selection.end.path.next, + node, + ); + transaction.deleteNode(currentNode); + transaction.afterSelection = Selection.collapsed( + Position(path: selection.end.path), + ); + transaction.selectionExtraInfo = {}; + await editorState.apply(transaction); + return; + } + } + + await editorState.insertBlockAfterCurrentSelection(selection, node); + }, ); } @@ -182,6 +211,32 @@ class _AddBlockMenu extends StatelessWidget { onTap: (_, __) => _insertBlock(toggleListBlockNode()), ), + // toggle headings + TypeOptionMenuItemValue( + value: ToggleListBlockKeys.type, + backgroundColor: colorMap[ToggleListBlockKeys.type]!, + text: LocaleKeys.document_slashMenu_name_toggleHeading1.tr(), + icon: FlowySvgs.toggle_heading1_s, + iconPadding: const EdgeInsets.all(3), + onTap: (_, __) => _insertBlock(toggleHeadingNode()), + ), + TypeOptionMenuItemValue( + value: ToggleListBlockKeys.type, + backgroundColor: colorMap[ToggleListBlockKeys.type]!, + text: LocaleKeys.document_slashMenu_name_toggleHeading2.tr(), + icon: FlowySvgs.toggle_heading2_s, + iconPadding: const EdgeInsets.all(3), + onTap: (_, __) => _insertBlock(toggleHeadingNode(level: 2)), + ), + TypeOptionMenuItemValue( + value: ToggleListBlockKeys.type, + backgroundColor: colorMap[ToggleListBlockKeys.type]!, + text: LocaleKeys.document_slashMenu_name_toggleHeading3.tr(), + icon: FlowySvgs.toggle_heading3_s, + iconPadding: const EdgeInsets.all(3), + onTap: (_, __) => _insertBlock(toggleHeadingNode(level: 3)), + ), + // image TypeOptionMenuItemValue( value: ImageBlockKeys.type, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart index 217245e75f..1358c08abf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart @@ -81,7 +81,7 @@ final toggleHeading1SlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_toggleHeading1.tr(), nameBuilder: _slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( - data: FlowySvgs.slash_menu_icon_h1_s, + data: FlowySvgs.toggle_heading1_s, isSelected: isSelected, style: style, ), @@ -99,7 +99,7 @@ final toggleHeading2SlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_toggleHeading2.tr(), nameBuilder: _slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( - data: FlowySvgs.slash_menu_icon_h2_s, + data: FlowySvgs.toggle_heading2_s, isSelected: isSelected, style: style, ), @@ -117,7 +117,7 @@ final toggleHeading3SlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_toggleHeading3.tr(), nameBuilder: _slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( - data: FlowySvgs.slash_menu_icon_h3_s, + data: FlowySvgs.toggle_heading3_s, isSelected: isSelected, style: style, ), 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 aab3181127..cb49ca792d 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 @@ -341,6 +341,7 @@ class _ToggleListBlockComponentWidgetState ..updateNode(node, { ToggleListBlockKeys.collapsed: !collapsed, }); + transaction.afterSelection = editorState.selection; await editorState.apply(transaction); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart index c15e1509c1..46d4943ddf 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -328,6 +328,16 @@ class SpaceBloc extends Bloc { didReceiveSpaceUpdate: () async { final (spaces, _, _) = await _getSpaces(); final currentSpace = await _getLastOpenedSpace(spaces); + + for (var i = 0; i < spaces.length; i++) { + Log.info( + 'did receive space update[$i]: ${spaces[i].name}(${spaces[i].id})', + ); + } + Log.info( + 'did receive space update, current space: ${currentSpace?.name}(${currentSpace?.id})', + ); + emit( state.copyWith( spaces: spaces, diff --git a/frontend/resources/flowy_icons/16x/toggle_heading1.svg b/frontend/resources/flowy_icons/16x/toggle_heading1.svg new file mode 100644 index 0000000000..8392acb665 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toggle_heading1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/toggle_heading2.svg b/frontend/resources/flowy_icons/16x/toggle_heading2.svg new file mode 100644 index 0000000000..1b0721777e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toggle_heading2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/toggle_heading3.svg b/frontend/resources/flowy_icons/16x/toggle_heading3.svg new file mode 100644 index 0000000000..0939a5e997 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toggle_heading3.svg @@ -0,0 +1,4 @@ + + + +