From 9e986808619cb511b876db10a02be2ce536ff6d7 Mon Sep 17 00:00:00 2001 From: Morn Date: Thu, 13 Feb 2025 12:45:56 +0800 Subject: [PATCH] feat: support slash menu on mobile (#7368) * feat: support slash menu on mobile * feat: support at menu on mobile * feat: support plus menu on mobile --- .../mobile/document/at_menu_test.dart | 41 +++ .../mobile/document/document_test_runner.dart | 6 + .../mobile/document/plus_menu_test.dart | 30 ++ .../mobile/document/slash_menu_test.dart | 74 +++++ .../shared/common_operations.dart | 21 ++ .../mobile_inline_actions_handler.dart | 253 +++++++++++++++ .../mobile_inline_actions_menu.dart | 151 +++++++++ .../mobile_inline_actions_menu_group.dart | 150 +++++++++ .../selection_menu/mobile_selection_menu.dart | 215 +++++++++++++ .../mobile_selection_menu_item.dart | 18 ++ .../mobile_selection_menu_item_widget.dart | 76 +++++ .../mobile_selection_menu_widget.dart | 298 ++++++++++++++++++ .../base/link_to_page_widget.dart | 3 +- .../base/page_reference_commands.dart | 69 ++-- .../slash_menu/slash_command.dart | 46 +-- .../slash_menu_items/mobile_items.dart | 156 +++++++++ .../slash_menu_item_builder.dart | 6 +- .../slash_menu/slash_menu_items_builder.dart | 24 +- .../inline_actions_command.dart | 36 ++- .../inline_actions/inline_actions_menu.dart | 14 +- frontend/resources/translations/en.json | 5 + 21 files changed, 1623 insertions(+), 69 deletions(-) create mode 100644 frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart create mode 100644 frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_handler.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart create mode 100644 frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart new file mode 100644 index 0000000000..394cacffd6 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart @@ -0,0 +1,41 @@ +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const title = 'Test At Menu'; + + group('at menu', () { + testWidgets('show at menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowAtMenu(title); + final menuWidget = find.byType(MobileInlineActionsMenu); + expect(menuWidget, findsOneWidget); + }); + + testWidgets('search by at menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowAtMenu(title); + const searchText = gettingStarted; + await tester.ime.insertText(searchText); + final actionWidgets = find.byType(MobileInlineActionsWidget); + expect(actionWidgets, findsNWidgets(2)); + }); + + testWidgets('tap at menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowAtMenu(title); + const searchText = gettingStarted; + await tester.ime.insertText(searchText); + final actionWidgets = find.byType(MobileInlineActionsWidget); + await tester.tap(actionWidgets.last); + expect(find.byType(MentionPageBlock), findsOneWidget); + }); + }); +} 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 e19896b310..90d5ca6d0d 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,8 +1,11 @@ import 'package:integration_test/integration_test.dart'; +import 'at_menu_test.dart' as at_menu; +import 'at_menu_test.dart' as at_menu_test; import 'page_style_test.dart' as page_style_test; import 'plus_menu_test.dart' as plus_menu_test; import 'simple_table_test.dart' as simple_table_test; +import 'slash_menu_test.dart' as slash_menu; import 'title_test.dart' as title_test; import 'toolbar_test.dart' as toolbar_test; @@ -13,6 +16,9 @@ void main() { title_test.main(); page_style_test.main(); plus_menu_test.main(); + at_menu_test.main(); simple_table_test.main(); toolbar_test.main(); + slash_menu.main(); + at_menu.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 index bdd84f9098..b54c543f7e 100644 --- a/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart +++ b/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.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'; @@ -85,5 +88,32 @@ void main() { equals(Selection.collapsed(Position(path: [2]))), ); }); + + const title = 'Test Plus Menu'; + testWidgets('show plus menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowPlusMenu(title); + final menuWidget = find.byType(MobileInlineActionsMenu); + expect(menuWidget, findsOneWidget); + }); + + testWidgets('search by plus menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowPlusMenu(title); + const searchText = gettingStarted; + await tester.ime.insertText(searchText); + final actionWidgets = find.byType(MobileInlineActionsWidget); + expect(actionWidgets, findsNWidgets(2)); + }); + + testWidgets('tap plus menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowPlusMenu(title); + const searchText = gettingStarted; + await tester.ime.insertText(searchText); + final actionWidgets = find.byType(MobileInlineActionsWidget); + await tester.tap(actionWidgets.last); + expect(find.byType(MentionPageBlock), findsOneWidget); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart new file mode 100644 index 0000000000..5a200c0add --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart @@ -0,0 +1,74 @@ +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart'; +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const title = 'Test Slash Menu'; + + group('slash menu', () { + testWidgets('show slash menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowSlashMenu(title); + final menuWidget = find.byType(MobileSelectionMenuWidget); + expect(menuWidget, findsOneWidget); + final items = + (menuWidget.evaluate().first.widget as MobileSelectionMenuWidget) + .items; + int i = 0; + for (final item in items) { + final localItem = mobileItems[i]; + expect(item.name, localItem.name); + i++; + } + }); + + testWidgets('search by slash menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createPageAndShowSlashMenu(title); + const searchText = 'Heading'; + await tester.ime.insertText(searchText); + final itemWidgets = find.byType(MobileSelectionMenuItemWidget); + int number = 0; + for (final mobileItem in mobileItems) { + for (final item in mobileItem.children) { + if (item.name.toLowerCase().contains(searchText.toLowerCase())) { + number++; + } + } + } + expect(itemWidgets, findsNWidgets(number)); + }); + + testWidgets('tap to show submenu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createNewDocumentOnMobile(title); + await tester.editor.tapLineOfEditorAt(0); + final listview = find.descendant( + of: find.byType(MobileSelectionMenuWidget), + matching: find.byType(ListView), + ); + for (final item in mobileItems) { + await tester.editor.showSlashMenu(); + await tester.scrollUntilVisible( + find.text(item.name), + 50, + scrollable: listview, + duration: const Duration(milliseconds: 250), + ); + await tester.tap(find.text(item.name)); + final childrenLength = ((listview.evaluate().first.widget as ListView) + .childrenDelegate as SliverChildListDelegate) + .children + .length; + expect(childrenLength, item.children.length); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart index d7da1f4d49..9bc663ff12 100644 --- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -974,6 +974,27 @@ extension CommonOperations on WidgetTester { ..writeAsBytesSync(imagePath.buffer.asUint8List()); return EmojiIconData.custom(imageFile.path); } + + /// create new page and show slash menu + Future createPageAndShowSlashMenu(String title) async { + await createNewDocumentOnMobile(title); + await editor.tapLineOfEditorAt(0); + await editor.showSlashMenu(); + } + + /// create new page and show at menu + Future createPageAndShowAtMenu(String title) async { + await createNewDocumentOnMobile(title); + await editor.tapLineOfEditorAt(0); + await editor.showAtMenu(); + } + + /// create new page and show plus menu + Future createPageAndShowPlusMenu(String title) async { + await createNewDocumentOnMobile(title); + await editor.tapLineOfEditorAt(0); + await editor.showPlusMenu(); + } } extension SettingsFinder on CommonFinders { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_handler.dart b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_handler.dart new file mode 100644 index 0000000000..74d5e56bce --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_handler.dart @@ -0,0 +1,253 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_inline_actions_menu_group.dart'; + +extension _StartWithsSort on List { + void sortByStartsWithKeyword(String search) => sort( + (a, b) { + final aCount = a.startsWithKeywords + ?.where( + (key) => search.toLowerCase().startsWith(key), + ) + .length ?? + 0; + + final bCount = b.startsWithKeywords + ?.where( + (key) => search.toLowerCase().startsWith(key), + ) + .length ?? + 0; + + if (aCount > bCount) { + return -1; + } else if (bCount > aCount) { + return 1; + } + + return 0; + }, + ); +} + +const _invalidSearchesAmount = 10; + +class MobileInlineActionsHandler extends StatefulWidget { + const MobileInlineActionsHandler({ + super.key, + required this.results, + required this.editorState, + required this.menuService, + required this.onDismiss, + required this.style, + required this.service, + this.startCharAmount = 1, + this.startOffset = 0, + this.cancelBySpaceHandler, + }); + + final List results; + final EditorState editorState; + final InlineActionsMenuService menuService; + final VoidCallback onDismiss; + final InlineActionsMenuStyle style; + final int startCharAmount; + final InlineActionsService service; + final bool Function()? cancelBySpaceHandler; + final int startOffset; + + @override + State createState() => + _MobileInlineActionsHandlerState(); +} + +class _MobileInlineActionsHandlerState + extends State { + final _focusNode = + FocusNode(debugLabel: 'mobile_inline_actions_menu_handler'); + + late List results = widget.results; + int invalidCounter = 0; + late int startOffset; + + String _search = ''; + + set search(String search) { + _search = search; + _doSearch(); + } + + Future _doSearch() async { + final List newResults = []; + for (final handler in widget.service.handlers) { + final group = await handler.search(_search); + + if (group.results.isNotEmpty) { + newResults.add(group); + } + } + + invalidCounter = results.every((group) => group.results.isEmpty) + ? invalidCounter + 1 + : 0; + + if (invalidCounter >= _invalidSearchesAmount) { + widget.onDismiss(); + + // Workaround to bring focus back to editor + await editorState.updateSelectionWithReason(editorState.selection); + + return; + } + + _resetSelection(); + + newResults.sortByStartsWithKeyword(_search); + setState(() => results = newResults); + } + + void _resetSelection() { + _selectedGroup = 0; + _selectedIndex = 0; + } + + int _selectedGroup = 0; + int _selectedIndex = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _focusNode.requestFocus(), + ); + + startOffset = editorState.selection?.endIndex ?? 0; + keepEditorFocusNotifier.increase(); + editorState.selectionNotifier.addListener(onSelectionChanged); + } + + @override + void dispose() { + editorState.selectionNotifier.removeListener(onSelectionChanged); + _focusNode.dispose(); + keepEditorFocusNotifier.decrease(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final width = editorState.renderBox!.size.width - 24 * 2; + return Focus( + focusNode: _focusNode, + child: Container( + constraints: BoxConstraints( + maxHeight: 192, + minWidth: width, + maxWidth: width, + ), + margin: EdgeInsets.symmetric(horizontal: 24.0), + decoration: BoxDecoration( + color: widget.style.backgroundColor, + borderRadius: BorderRadius.circular(6.0), + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withValues(alpha: 0.1), + ), + ], + ), + child: noResults + ? SizedBox( + width: 150, + child: FlowyText.regular( + LocaleKeys.inlineActions_noResults.tr(), + ), + ) + : SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Material( + color: Colors.transparent, + child: Padding( + padding: EdgeInsets.all(6.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: results + .where((g) => g.results.isNotEmpty) + .mapIndexed( + (index, group) => MobileInlineActionsGroup( + result: group, + editorState: editorState, + menuService: menuService, + style: widget.style, + onSelected: widget.onDismiss, + startOffset: startOffset - widget.startCharAmount, + endOffset: + _search.length + widget.startCharAmount, + isLastGroup: index == results.length - 1, + isGroupSelected: _selectedGroup == index, + selectedIndex: _selectedIndex, + onPreSelect: (int value) { + setState(() { + _selectedGroup = index; + _selectedIndex = value; + }); + }, + ), + ) + .toList(), + ), + ), + ), + ), + ), + ); + } + + bool get noResults => + results.isEmpty || results.every((e) => e.results.isEmpty); + + int get groupLength => results.length; + + int lengthOfGroup(int index) => + results.length > index ? results[index].results.length : -1; + + InlineActionsMenuItem handlerOf(int groupIndex, int handlerIndex) => + results[groupIndex].results[handlerIndex]; + + EditorState get editorState => widget.editorState; + + InlineActionsMenuService get menuService => widget.menuService; + + void onSelectionChanged() { + final selection = editorState.selection; + if (selection == null) { + menuService.dismiss(); + return; + } + if (!selection.isCollapsed) { + menuService.dismiss(); + return; + } + final startOffset = widget.startOffset; + final endOffset = selection.end.offset; + if (endOffset < startOffset) { + menuService.dismiss(); + return; + } + final node = editorState.getNodeAtPath(selection.start.path); + final text = node?.delta?.toPlainText() ?? ''; + final search = text.substring(startOffset, endOffset); + this.search = search; + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart new file mode 100644 index 0000000000..6166671391 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_inline_actions_handler.dart'; + +class MobileInlineActionsMenu extends InlineActionsMenuService { + MobileInlineActionsMenu({ + required this.context, + required this.editorState, + required this.initialResults, + required this.style, + required this.service, + this.startCharAmount = 1, + this.cancelBySpaceHandler, + }); + + final BuildContext context; + final EditorState editorState; + final List initialResults; + final bool Function()? cancelBySpaceHandler; + final InlineActionsService service; + + @override + final InlineActionsMenuStyle style; + + final int startCharAmount; + + OverlayEntry? _menuEntry; + + @override + void dismiss() { + if (_menuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + } + + _menuEntry?.remove(); + _menuEntry = null; + } + + @override + Future show() { + final completer = Completer(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _show(); + completer.complete(); + }); + return completer.future; + } + + void _show() { + final selectionRects = editorState.selectionRects(); + if (selectionRects.isEmpty) { + return; + } + + const double menuHeight = 192.0; + const Offset menuOffset = Offset(0, 10); + final Offset editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final Size editorSize = editorState.renderBox!.size; + + // Default to opening the overlay below + Alignment alignment = Alignment.topLeft; + + final firstRect = selectionRects.first; + Offset offset = firstRect.bottomRight + menuOffset; + + // Show above + if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) { + offset = firstRect.topRight - menuOffset; + alignment = Alignment.bottomLeft; + + offset = Offset( + offset.dx, + MediaQuery.of(context).size.height - offset.dy, + ); + } + + final (left, top, right, bottom) = _getPosition(alignment, offset); + + _menuEntry = OverlayEntry( + builder: (context) => SizedBox( + width: editorSize.width, + height: editorSize.height, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + Positioned( + top: top, + bottom: bottom, + left: left, + right: right, + child: MobileInlineActionsHandler( + service: service, + results: initialResults, + editorState: editorState, + menuService: this, + onDismiss: dismiss, + style: style, + startCharAmount: startCharAmount, + cancelBySpaceHandler: cancelBySpaceHandler, + startOffset: editorState.selection?.start.offset ?? 0, + ), + ), + ], + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + } + + (double? left, double? top, double? right, double? bottom) _getPosition( + Alignment alignment, + Offset offset, + ) { + double? left, top, right, bottom; + switch (alignment) { + case Alignment.topLeft: + left = 0; + top = offset.dy; + break; + case Alignment.bottomLeft: + left = 0; + bottom = offset.dy; + break; + case Alignment.topRight: + right = offset.dx; + top = offset.dy; + break; + case Alignment.bottomRight: + right = offset.dx; + bottom = offset.dy; + break; + } + + return (left, top, right, bottom); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart new file mode 100644 index 0000000000..765534f18d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart @@ -0,0 +1,150 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class MobileInlineActionsGroup extends StatelessWidget { + const MobileInlineActionsGroup({ + super.key, + required this.result, + required this.editorState, + required this.menuService, + required this.style, + required this.onSelected, + required this.startOffset, + required this.endOffset, + required this.onPreSelect, + this.isLastGroup = false, + this.isGroupSelected = false, + this.selectedIndex = 0, + }); + + final InlineActionsResult result; + final EditorState editorState; + final InlineActionsMenuService menuService; + final InlineActionsMenuStyle style; + final VoidCallback onSelected; + final ValueChanged onPreSelect; + final int startOffset; + final int endOffset; + + final bool isLastGroup; + final bool isGroupSelected; + final int selectedIndex; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (result.title != null) ...[ + SizedBox( + height: 36, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Align( + alignment: Alignment.centerLeft, + child: FlowyText.medium( + result.title!, + color: style.groupTextColor, + fontSize: 12, + ), + ), + ), + ), + ], + ...result.results.mapIndexed( + (index, item) => GestureDetector( + onTapDown: (e) { + onPreSelect.call(index); + }, + child: MobileInlineActionsWidget( + item: item, + editorState: editorState, + menuService: menuService, + isSelected: isGroupSelected && index == selectedIndex, + style: style, + onSelected: onSelected, + startOffset: startOffset, + endOffset: endOffset, + ), + ), + ), + ], + ); + } +} + +class MobileInlineActionsWidget extends StatelessWidget { + const MobileInlineActionsWidget({ + super.key, + required this.item, + required this.editorState, + required this.menuService, + required this.isSelected, + required this.style, + required this.onSelected, + required this.startOffset, + required this.endOffset, + }); + + final InlineActionsMenuItem item; + final EditorState editorState; + final InlineActionsMenuService menuService; + final bool isSelected; + final InlineActionsMenuStyle style; + final VoidCallback onSelected; + final int startOffset; + final int endOffset; + + @override + Widget build(BuildContext context) { + final hasIcon = item.icon != null; + return Container( + height: 36, + decoration: BoxDecoration( + color: isSelected ? style.menuItemSelectedColor : null, + borderRadius: BorderRadius.circular(6.0), + ), + child: FlowyButton( + expand: true, + isSelected: isSelected, + text: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Align( + alignment: Alignment.centerLeft, + child: Row( + children: [ + if (hasIcon) ...[ + item.icon!.call(isSelected), + SizedBox(width: 12), + ], + FlowyText.regular( + item.label, + figmaLineHeight: 18, + overflow: TextOverflow.ellipsis, + fontSize: 16, + color: style.menuItemSelectedTextColor, + ), + ], + ), + ), + ), + onTap: () => _onPressed(context), + ), + ); + } + + void _onPressed(BuildContext context) { + onSelected(); + item.onSelected?.call( + context, + editorState, + menuService, + (startOffset, endOffset), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart new file mode 100644 index 0000000000..38b8c15436 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart @@ -0,0 +1,215 @@ +import 'dart:async'; + +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_selection_menu_widget.dart'; + +class MobileSelectionMenu extends SelectionMenuService { + MobileSelectionMenu({ + required this.context, + required this.editorState, + required this.selectionMenuItems, + this.deleteSlashByDefault = false, + this.deleteKeywordsByDefault = false, + this.style = SelectionMenuStyle.light, + this.itemCountFilter = 0, + this.startOffset = 0, + this.singleColumn = false, + }); + + final BuildContext context; + final EditorState editorState; + final List selectionMenuItems; + final bool deleteSlashByDefault; + final bool deleteKeywordsByDefault; + final bool singleColumn; + + @override + final SelectionMenuStyle style; + + OverlayEntry? _selectionMenuEntry; + Offset _offset = Offset.zero; + Alignment _alignment = Alignment.topLeft; + final int itemCountFilter; + final int startOffset; + + @override + void dismiss() { + if (_selectionMenuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + } + + _selectionMenuEntry?.remove(); + _selectionMenuEntry = null; + } + + @override + Future show() async { + final completer = Completer(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _show(); + completer.complete(); + }); + return completer.future; + } + + void _show() { + final selectionRects = editorState.selectionRects(); + if (selectionRects.isEmpty) { + return; + } + + calculateSelectionMenuOffset(selectionRects.first); + final (left, top, right, bottom) = getPosition(); + + final editorHeight = editorState.renderBox!.size.height; + final editorWidth = editorState.renderBox!.size.width; + + _selectionMenuEntry = OverlayEntry( + builder: (context) { + return SizedBox( + width: editorWidth, + height: editorHeight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + Positioned( + top: top, + bottom: bottom, + left: left, + right: right, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: MobileSelectionMenuWidget( + selectionMenuStyle: style, + singleColumn: singleColumn, + items: selectionMenuItems + ..forEach((element) { + if (element is MobileSelectionMenuItem) { + element.deleteSlash = false; + element.deleteKeywords = deleteKeywordsByDefault; + for (final e in element.children) { + e.deleteSlash = deleteSlashByDefault; + e.deleteKeywords = deleteKeywordsByDefault; + e.onSelected = () { + dismiss(); + }; + } + } else { + element.deleteSlash = deleteSlashByDefault; + element.deleteKeywords = deleteKeywordsByDefault; + element.onSelected = () { + dismiss(); + }; + } + }), + maxItemInRow: 5, + editorState: editorState, + itemCountFilter: itemCountFilter, + startOffset: startOffset, + menuService: this, + onExit: () { + dismiss(); + }, + deleteSlashByDefault: deleteSlashByDefault, + ), + ), + ), + ], + ), + ), + ); + }, + ); + + Overlay.of(context, rootOverlay: true).insert(_selectionMenuEntry!); + + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + } + + @override + Alignment get alignment { + return _alignment; + } + + @override + Offset get offset { + return _offset; + } + + @override + (double? left, double? top, double? right, double? bottom) getPosition() { + double? left, top, right, bottom; + switch (alignment) { + case Alignment.topLeft: + left = offset.dx; + top = offset.dy; + break; + case Alignment.bottomLeft: + left = offset.dx; + bottom = offset.dy; + break; + case Alignment.topRight: + right = offset.dx; + top = offset.dy; + break; + case Alignment.bottomRight: + right = offset.dx; + bottom = offset.dy; + break; + } + + return (left, top, right, bottom); + } + + void calculateSelectionMenuOffset(Rect rect) { + // Workaround: We can customize the padding through the [EditorStyle], + // but the coordinates of overlay are not properly converted currently. + // Just subtract the padding here as a result. + const menuHeight = 192.0; + const menuOffset = Offset(0, 10); + final editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final editorHeight = editorState.renderBox!.size.height; + final editorWidth = editorState.renderBox!.size.width; + + // show below default + _alignment = Alignment.topLeft; + final bottomRight = rect.bottomRight; + final topRight = rect.topRight; + var offset = bottomRight + menuOffset; + _offset = Offset( + offset.dx, + offset.dy, + ); + + // show above + if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) { + offset = topRight - menuOffset; + _alignment = Alignment.bottomLeft; + + _offset = Offset( + offset.dx, + MediaQuery.of(context).size.height - offset.dy, + ); + } + + // show on left + if (_offset.dx - editorOffset.dx > editorWidth / 2) { + _alignment = _alignment == Alignment.topLeft + ? Alignment.topRight + : Alignment.bottomRight; + + _offset = Offset( + editorWidth - _offset.dx + editorOffset.dx, + _offset.dy, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item.dart new file mode 100644 index 0000000000..22e202816e --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item.dart @@ -0,0 +1,18 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +class MobileSelectionMenuItem extends SelectionMenuItem { + MobileSelectionMenuItem({ + required super.getName, + required super.icon, + super.keywords = const [], + required super.handler, + this.children = const [], + super.nameBuilder, + super.deleteKeywords, + super.deleteSlash, + }); + + final List children; + + bool get isNotEmpty => children.isNotEmpty; +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart new file mode 100644 index 0000000000..ed152931eb --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart @@ -0,0 +1,76 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_selection_menu_item.dart'; + +class MobileSelectionMenuItemWidget extends StatelessWidget { + const MobileSelectionMenuItemWidget({ + super.key, + required this.editorState, + required this.menuService, + required this.item, + required this.isSelected, + required this.selectionMenuStyle, + }); + + final EditorState editorState; + final SelectionMenuService menuService; + final SelectionMenuItem item; + final bool isSelected; + final SelectionMenuStyle selectionMenuStyle; + + @override + Widget build(BuildContext context) { + final style = selectionMenuStyle; + final showRightArrow = item is MobileSelectionMenuItem && + (item as MobileSelectionMenuItem).isNotEmpty; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: TextButton.icon( + icon: item.icon( + editorState, + false, + selectionMenuStyle, + ), + style: ButtonStyle( + alignment: Alignment.centerLeft, + overlayColor: WidgetStateProperty.all( + style.selectionMenuItemSelectedColor, + ), + backgroundColor: isSelected + ? WidgetStateProperty.all( + style.selectionMenuItemSelectedColor, + ) + : WidgetStateProperty.all(Colors.transparent), + ), + label: Row( + children: [ + item.nameBuilder?.call(item.name, style, false) ?? + Text( + item.name, + textAlign: TextAlign.left, + style: TextStyle( + color: style.selectionMenuItemTextColor, + fontSize: 16.0, + ), + ), + if (showRightArrow) ...[ + Spacer(), + Icon( + Icons.keyboard_arrow_right_rounded, + color: Color(0xff1E2022).withValues(alpha: 0.3), + ), + ], + ], + ), + onPressed: () { + item.handler( + editorState, + menuService, + context, + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart new file mode 100644 index 0000000000..14b4caa3e8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart @@ -0,0 +1,298 @@ +import 'dart:math'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_selection_menu_item.dart'; +import 'mobile_selection_menu_item_widget.dart'; + +class MobileSelectionMenuWidget extends StatefulWidget { + const MobileSelectionMenuWidget({ + super.key, + required this.items, + required this.itemCountFilter, + required this.maxItemInRow, + required this.menuService, + required this.editorState, + required this.onExit, + required this.selectionMenuStyle, + required this.deleteSlashByDefault, + required this.singleColumn, + required this.startOffset, + this.nameBuilder, + }); + + final List items; + final int itemCountFilter; + final int maxItemInRow; + + final SelectionMenuService menuService; + final EditorState editorState; + + final VoidCallback onExit; + + final SelectionMenuStyle selectionMenuStyle; + + final bool deleteSlashByDefault; + final bool singleColumn; + final int startOffset; + + final SelectionMenuItemNameBuilder? nameBuilder; + + @override + State createState() => + _MobileSelectionMenuWidgetState(); +} + +class _MobileSelectionMenuWidgetState extends State { + final _focusNode = FocusNode(debugLabel: 'popup_list_widget'); + + List _showingItems = []; + + int _searchCounter = 0; + + EditorState get editorState => widget.editorState; + + SelectionMenuService get menuService => widget.menuService; + + String _keyword = ''; + + String get keyword => _keyword; + + int selectedIndex = 0; + + List get filterItems { + final List items = []; + for (final item in widget.items) { + if (item is MobileSelectionMenuItem) { + for (final childItem in item.children) { + items.add(childItem); + } + } else { + items.add(item); + } + } + return items; + } + + set keyword(String newKeyword) { + _keyword = newKeyword; + + // Search items according to the keyword, and calculate the length of + // the longest keyword, which is used to dismiss the selection_service. + var maxKeywordLength = 0; + + final items = newKeyword.isEmpty + ? widget.items + : filterItems + .where( + (item) => item.allKeywords.any((keyword) { + final value = keyword.contains(newKeyword.toLowerCase()); + if (value) { + maxKeywordLength = max(maxKeywordLength, keyword.length); + } + return value; + }), + ) + .toList(growable: false); + + AppFlowyEditorLog.ui.debug('$items'); + + if (keyword.length >= maxKeywordLength + 2 && + !(widget.deleteSlashByDefault && _searchCounter < 2)) { + return widget.onExit(); + } + setState(() { + selectedIndex = 0; + _showingItems = items; + }); + + if (_showingItems.isEmpty) { + _searchCounter++; + } else { + _searchCounter = 0; + } + } + + @override + void initState() { + super.initState(); + + final List items = []; + for (final item in widget.items) { + if (item is MobileSelectionMenuItem) { + item.onSelected = () { + if (mounted) { + setState(() { + _showingItems = item.children + .map((e) => e..onSelected = widget.onExit) + .toList(); + }); + } + }; + } + items.add(item); + } + _showingItems = items; + + keepEditorFocusNotifier.increase(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + editorState.selectionNotifier.addListener(onSelectionChanged); + } + + @override + void dispose() { + editorState.selectionNotifier.removeListener(onSelectionChanged); + _focusNode.dispose(); + keepEditorFocusNotifier.decrease(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: _focusNode, + child: DecoratedBox( + decoration: BoxDecoration( + color: widget.selectionMenuStyle.selectionMenuBackgroundColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withValues(alpha: 0.1), + ), + ], + borderRadius: BorderRadius.circular(6.0), + ), + child: _showingItems.isEmpty + ? _buildNoResultsWidget(context) + : _buildResultsWidget( + context, + _showingItems, + widget.itemCountFilter, + ), + ), + ); + } + + void onSelectionChanged() { + final selection = editorState.selection; + if (selection == null) { + widget.onExit(); + return; + } + if (!selection.isCollapsed) { + widget.onExit(); + return; + } + final startOffset = widget.startOffset; + final endOffset = selection.end.offset; + if (endOffset < startOffset) { + widget.onExit(); + return; + } + final node = editorState.getNodeAtPath(selection.start.path); + final text = node?.delta?.toPlainText() ?? ''; + final search = text.substring(startOffset, endOffset); + keyword = search; + } + + Widget _buildResultsWidget( + BuildContext buildContext, + List items, + int itemCountFilter, + ) { + if (widget.singleColumn) { + final List itemWidgets = []; + for (var i = 0; i < items.length; i++) { + itemWidgets.add( + GestureDetector( + onTapDown: (e) { + setState(() { + selectedIndex = i; + }); + }, + child: MobileSelectionMenuItemWidget( + item: items[i], + isSelected: i == selectedIndex, + editorState: editorState, + menuService: menuService, + selectionMenuStyle: widget.selectionMenuStyle, + ), + ), + ); + } + return ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 192, + minWidth: 240, + maxWidth: 240, + ), + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, + children: itemWidgets, + ), + ); + } else { + final List columns = []; + List itemWidgets = []; + // apply item count filter + if (itemCountFilter > 0) { + items = items.take(itemCountFilter).toList(); + } + + for (var i = 0; i < items.length; i++) { + if (i != 0 && i % (widget.maxItemInRow) == 0) { + columns.add( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: itemWidgets, + ), + ); + itemWidgets = []; + } + itemWidgets.add( + MobileSelectionMenuItemWidget( + item: items[i], + isSelected: false, + editorState: editorState, + menuService: menuService, + selectionMenuStyle: widget.selectionMenuStyle, + ), + ); + } + if (itemWidgets.isNotEmpty) { + columns.add( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: itemWidgets, + ), + ); + itemWidgets = []; + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: columns, + ); + } + } + + Widget _buildNoResultsWidget(BuildContext context) { + return const Padding( + padding: EdgeInsets.all(8.0), + child: SizedBox( + width: 140, + child: Material( + child: Text( + "No results", + style: TextStyle(fontSize: 18.0, color: Colors.grey), + textAlign: TextAlign.center, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart index ce4f44e72b..3f1440e100 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart @@ -9,6 +9,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; InlineActionsMenuService? _actionsMenuService; + Future showLinkToPageMenu( EditorState editorState, SelectionMenuService menuService, { @@ -60,7 +61,7 @@ Future showLinkToPageMenu( startCharAmount: 0, ); - _actionsMenuService?.show(); + await _actionsMenuService?.show(); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart index 079a062837..14402434b2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/handlers/child_page.dart'; import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; @@ -47,6 +48,7 @@ CharacterShortcutEvent pageReferenceShortcutPlusSign( ); InlineActionsMenuService? selectionMenuService; + Future inlinePageReferenceCommandHandler( String character, BuildContext context, @@ -56,7 +58,7 @@ Future inlinePageReferenceCommandHandler( String? previousChar, }) async { final selection = editorState.selection; - if (UniversalPlatform.isMobile || selection == null) { + if (selection == null) { return false; } @@ -110,32 +112,47 @@ Future inlinePageReferenceCommandHandler( } if (context.mounted) { - selectionMenuService = InlineActionsMenu( - context: service.context!, - editorState: editorState, - service: service, - initialResults: initialResults, - style: style, - startCharAmount: previousChar != null ? 2 : 1, - cancelBySpaceHandler: () { - if (character == _plusChar) { - final currentSelection = editorState.selection; - if (currentSelection == null) { - return false; - } - // check if the space is after the character - if (currentSelection.isCollapsed && - currentSelection.start.offset == - selection.start.offset + character.length) { - _cancelInlinePageReferenceMenu(editorState); - return true; - } - } - return false; - }, - ); + keepEditorFocusNotifier.increase(); + selectionMenuService?.dismiss(); + selectionMenuService = UniversalPlatform.isMobile + ? MobileInlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + ) + : InlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + startCharAmount: previousChar != null ? 2 : 1, + cancelBySpaceHandler: () { + if (character == _plusChar) { + final currentSelection = editorState.selection; + if (currentSelection == null) { + return false; + } + // check if the space is after the character + if (currentSelection.isCollapsed && + currentSelection.start.offset == + selection.start.offset + character.length) { + _cancelInlinePageReferenceMenu(editorState); + return true; + } + } + return false; + }, + ); + // disable the keyboard service + editorState.service.keyboardService?.disable(); - selectionMenuService?.show(); + await selectionMenuService?.show(); + + // enable the keyboard service + editorState.service.keyboardService?.enable(); } return true; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart index f8bf2a40a7..839182bf16 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart @@ -1,8 +1,6 @@ -import 'dart:io'; - +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -50,6 +48,7 @@ CharacterShortcutEvent customAppFlowySlashCommand({ } SelectionMenuService? _selectionMenuService; + Future _showSlashMenu( EditorState editorState, { required SlashMenuItemsBuilder itemsBuilder, @@ -59,10 +58,6 @@ Future _showSlashMenu( SelectionMenuStyle style = SelectionMenuStyle.light, required Set supportSlashMenuNodeTypes, }) async { - if (UniversalPlatform.isMobile) { - return false; - } - final selection = editorState.selection; if (selection == null) { return false; @@ -99,25 +94,32 @@ Future _showSlashMenu( final context = editorState.getNodeAtPath(selection.start.path)?.context; if (context != null && context.mounted) { - _selectionMenuService = SelectionMenu( - context: context, - editorState: editorState, - selectionMenuItems: items, - deleteSlashByDefault: shouldInsertSlash, - deleteKeywordsByDefault: deleteKeywordsByDefault, - singleColumn: singleColumn, - style: style, - ); + _selectionMenuService?.dismiss(); + _selectionMenuService = UniversalPlatform.isMobile + ? MobileSelectionMenu( + context: context, + editorState: editorState, + selectionMenuItems: items, + deleteSlashByDefault: shouldInsertSlash, + deleteKeywordsByDefault: deleteKeywordsByDefault, + singleColumn: singleColumn, + style: style, + startOffset: editorState.selection?.start.offset ?? 0, + ) + : SelectionMenu( + context: context, + editorState: editorState, + selectionMenuItems: items, + deleteSlashByDefault: shouldInsertSlash, + deleteKeywordsByDefault: deleteKeywordsByDefault, + singleColumn: singleColumn, + style: style, + ); // disable the keyboard service editorState.service.keyboardService?.disable(); - if (!kIsWeb && Platform.environment.containsKey('FLUTTER_TEST')) { - await _selectionMenuService?.show(); - } else { - await _selectionMenuService?.show(); - } - + await _selectionMenuService?.show(); // enable the keyboard service editorState.service.keyboardService?.enable(); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart new file mode 100644 index 0000000000..2cf4ab45f0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart @@ -0,0 +1,156 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_items.dart'; + +final List mobileItems = [ + textStyleMobileSlashMenuItem, + listMobileSlashMenuItem, + toggleListMobileSlashMenuItem, + fileOrMediaMobileSlashMenuItem, + decorationsMobileSlashMenuItem, + tableMobileSlashMenuItem, + dateOrReminderMobileSlashMenuItem, + advancedMobileSlashMenuItem, +]; + +final List mobileItemsInTale = [ + textStyleMobileSlashMenuItem, + listMobileSlashMenuItem, + toggleListMobileSlashMenuItem, + fileOrMediaMobileSlashMenuItem, + decorationsMobileSlashMenuItem, + dateOrReminderMobileSlashMenuItem, + advancedMobileSlashMenuItem, +]; + +SelectionMenuItemHandler _handler = (_, __, ___) {}; + +MobileSelectionMenuItem textStyleMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_textStyle.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_text_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + paragraphSlashMenuItem, + heading1SlashMenuItem, + heading2SlashMenuItem, + heading3SlashMenuItem, + ], +); + +MobileSelectionMenuItem listMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_list.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_bulleted_list_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + todoListSlashMenuItem, + bulletedListSlashMenuItem, + numberedListSlashMenuItem, + ], +); + +MobileSelectionMenuItem toggleListMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_toggleList.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_toggle_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + toggleListSlashMenuItem, + toggleHeading1SlashMenuItem, + toggleHeading2SlashMenuItem, + toggleHeading3SlashMenuItem, + ], +); + +MobileSelectionMenuItem fileOrMediaMobileSlashMenuItem = + MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_fileOrMedia.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_file_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + imageSlashMenuItem, + photoGallerySlashMenuItem, + fileSlashMenuItem, + ], +); + +MobileSelectionMenuItem decorationsMobileSlashMenuItem = + MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_decorations.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_simple_table_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + quoteSlashMenuItem, + dividerSlashMenuItem, + ], +); + +MobileSelectionMenuItem tableMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_table.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_simple_table_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [tableSlashMenuItem], +); + +MobileSelectionMenuItem dateOrReminderMobileSlashMenuItem = + MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_dateOrReminder.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_date_or_reminder_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [dateOrReminderSlashMenuItem], +); + +MobileSelectionMenuItem advancedMobileSlashMenuItem = MobileSelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_advanced.tr, + handler: _handler, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.m_aa_font_color_m, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, + children: [ + subPageSlashMenuItem, + calloutSlashMenuItem, + codeBlockSlashMenuItem, + mathEquationSlashMenuItem, + ], +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart index 85f8a6895b..fada0addd9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart @@ -3,6 +3,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selec import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; /// Builder function for the slash menu item. Widget slashMenuItemNameBuilder( @@ -49,9 +50,10 @@ class SlashMenuItemNameBuilder extends StatelessWidget { @override Widget build(BuildContext context) { + final isMobile = UniversalPlatform.isMobile; return FlowyText.regular( name, - fontSize: 12.0, + fontSize: isMobile ? 16.0 : 12.0, figmaLineHeight: 15.0, color: isSelected ? style.selectionMenuItemSelectedTextColor @@ -80,9 +82,11 @@ class SlashMenuIconBuilder extends StatelessWidget { @override Widget build(BuildContext context) { + final isMobile = UniversalPlatform.isMobile; return SelectableSvgWidget( data: data, isSelected: isSelected, + size: isMobile ? Size.square(20) : null, style: style, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart index 3e9bdef355..2cbc723a57 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart @@ -1,7 +1,9 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:universal_platform/universal_platform.dart'; +import 'slash_menu_items/mobile_items.dart'; import 'slash_menu_items/slash_menu_items.dart'; /// Build slash menu items @@ -13,14 +15,22 @@ List slashMenuItemsBuilder({ Node? node, }) { final isInTable = node != null && node.parentTableCellNode != null; - - if (isInTable) { - return _simpleTableSlashMenuItems(); + final isMobile = UniversalPlatform.isMobile; + if (isMobile) { + if (isInTable) { + return mobileItemsInTale; + } else { + return mobileItems; + } } else { - return _defaultSlashMenuItems( - isLocalMode: isLocalMode, - documentBloc: documentBloc, - ); + if (isInTable) { + return _simpleTableSlashMenuItems(); + } else { + return _defaultSlashMenuItems( + isLocalMode: isLocalMode, + documentBloc: documentBloc, + ); + } } } diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart index 0ef4ac7c09..e0e03e7dec 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; @@ -21,13 +22,14 @@ CharacterShortcutEvent inlineActionsCommand( ); InlineActionsMenuService? selectionMenuService; + Future inlineActionsCommandHandler( EditorState editorState, InlineActionsService service, InlineActionsMenuStyle style, ) async { final selection = editorState.selection; - if (UniversalPlatform.isMobile || selection == null) { + if (selection == null) { return false; } @@ -50,15 +52,31 @@ Future inlineActionsCommandHandler( } if (service.context != null) { - selectionMenuService = InlineActionsMenu( - context: service.context!, - editorState: editorState, - service: service, - initialResults: initialResults, - style: style, - ); + keepEditorFocusNotifier.increase(); + selectionMenuService?.dismiss(); + selectionMenuService = UniversalPlatform.isMobile + ? MobileInlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + ) + : InlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + ); - selectionMenuService?.show(); + // disable the keyboard service + editorState.service.keyboardService?.disable(); + + await selectionMenuService?.show(); + + // enable the keyboard service + editorState.service.keyboardService?.enable(); } return true; diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart index dadc4ebf6f..651e739abc 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; @@ -7,7 +9,8 @@ import 'package:flutter/material.dart'; abstract class InlineActionsMenuService { InlineActionsMenuStyle get style; - void show(); + Future show(); + void dismiss(); } @@ -59,8 +62,13 @@ class InlineActionsMenu extends InlineActionsMenuService { void _onSelectionUpdate() => selectionChangedByMenu = true; @override - void show() { - WidgetsBinding.instance.addPostFrameCallback((_) => _show()); + Future show() { + final completer = Completer(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _show(); + completer.complete(); + }); + return completer.future; } void _show() { diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 32d8490524..4de514aab4 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -1684,6 +1684,11 @@ "selectADocumentToLinkTo": "Select a Document to link to" }, "name": { + "textStyle": "Text Style", + "list": "List", + "fileOrMedia": "File or Media", + "decorations": "Decorations", + "advanced": "Advanced", "text": "Text", "heading1": "Heading 1", "heading2": "Heading 2",