mirror of
				https://github.com/AppFlowy-IO/AppFlowy.git
				synced 2025-11-04 03:54:44 +00:00 
			
		
		
		
	feat: support use ":" keyword to create emojis (#7582)
* feat: add ability to use : keyword to create emojis(#2797) * fix: emoji position error * chore: add integration test * chore: dismiss emoji picker while starting searching with space
This commit is contained in:
		
							parent
							
								
									cfca70ae14
								
							
						
					
					
						commit
						24bb1b58a0
					
				@ -1,8 +1,10 @@
 | 
				
			|||||||
import 'dart:io';
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:appflowy/plugins/emoji/emoji_handler.dart';
 | 
				
			||||||
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
 | 
					import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
 | 
				
			||||||
import 'package:appflowy_editor/appflowy_editor.dart';
 | 
					import 'package:appflowy_editor/appflowy_editor.dart';
 | 
				
			||||||
import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart';
 | 
					import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart';
 | 
				
			||||||
 | 
					import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 | 
				
			||||||
import 'package:flutter/services.dart';
 | 
					import 'package:flutter/services.dart';
 | 
				
			||||||
import 'package:flutter_test/flutter_test.dart';
 | 
					import 'package:flutter_test/flutter_test.dart';
 | 
				
			||||||
import 'package:integration_test/integration_test.dart';
 | 
					import 'package:integration_test/integration_test.dart';
 | 
				
			||||||
@ -39,4 +41,114 @@ void main() {
 | 
				
			|||||||
      expect(find.byType(EmojiSelectionMenu), findsOneWidget);
 | 
					      expect(find.byType(EmojiSelectionMenu), findsOneWidget);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  group('insert emoji by colon', () {
 | 
				
			||||||
 | 
					    Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester) async {
 | 
				
			||||||
 | 
					      await tester.initializeAppFlowy();
 | 
				
			||||||
 | 
					      await tester.tapAnonymousSignInButton();
 | 
				
			||||||
 | 
					      await tester.createNewPageWithNameUnderParent();
 | 
				
			||||||
 | 
					      await tester.editor.tapLineOfEditorAt(0);
 | 
				
			||||||
 | 
					      await tester.ime.insertText(':');
 | 
				
			||||||
 | 
					      await tester.pumpAndSettle(Duration(seconds: 1));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    testWidgets('insert with click', (tester) async {
 | 
				
			||||||
 | 
					      await createNewDocumentAndShowEmojiList(tester);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// emoji list is showing
 | 
				
			||||||
 | 
					      final emojiHandler = find.byType(EmojiHandler);
 | 
				
			||||||
 | 
					      expect(emojiHandler, findsOneWidget);
 | 
				
			||||||
 | 
					      final emojiButtons =
 | 
				
			||||||
 | 
					          find.descendant(of: emojiHandler, matching: find.byType(FlowyButton));
 | 
				
			||||||
 | 
					      final firstTextFinder = find.descendant(
 | 
				
			||||||
 | 
					        of: emojiButtons.first,
 | 
				
			||||||
 | 
					        matching: find.byType(FlowyText),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      final emojiText =
 | 
				
			||||||
 | 
					          (firstTextFinder.evaluate().first.widget as FlowyText).text;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// click first emoji item
 | 
				
			||||||
 | 
					      await tester.tapButton(emojiButtons.first);
 | 
				
			||||||
 | 
					      final firstNode =
 | 
				
			||||||
 | 
					          tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// except the emoji is in document
 | 
				
			||||||
 | 
					      expect(emojiText.contains(firstNode.delta!.toPlainText()), true);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    testWidgets('insert with arrow and enter', (tester) async {
 | 
				
			||||||
 | 
					      await createNewDocumentAndShowEmojiList(tester);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// emoji list is showing
 | 
				
			||||||
 | 
					      final emojiHandler = find.byType(EmojiHandler);
 | 
				
			||||||
 | 
					      expect(emojiHandler, findsOneWidget);
 | 
				
			||||||
 | 
					      final emojiButtons =
 | 
				
			||||||
 | 
					          find.descendant(of: emojiHandler, matching: find.byType(FlowyButton));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// tap arrow down and arrow up
 | 
				
			||||||
 | 
					      await tester.simulateKeyEvent(LogicalKeyboardKey.arrowUp);
 | 
				
			||||||
 | 
					      await tester.simulateKeyEvent(LogicalKeyboardKey.arrowDown);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final firstTextFinder = find.descendant(
 | 
				
			||||||
 | 
					        of: emojiButtons.first,
 | 
				
			||||||
 | 
					        matching: find.byType(FlowyText),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      final emojiText =
 | 
				
			||||||
 | 
					          (firstTextFinder.evaluate().first.widget as FlowyText).text;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// tap enter
 | 
				
			||||||
 | 
					      await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
 | 
				
			||||||
 | 
					      final firstNode =
 | 
				
			||||||
 | 
					          tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// except the emoji is in document
 | 
				
			||||||
 | 
					      expect(emojiText.contains(firstNode.delta!.toPlainText()), true);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    testWidgets('insert with searching', (tester) async {
 | 
				
			||||||
 | 
					      await createNewDocumentAndShowEmojiList(tester);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// search for `smiling eyes`, IME is not working, use keyboard input
 | 
				
			||||||
 | 
					      final searchText = [
 | 
				
			||||||
 | 
					        LogicalKeyboardKey.keyS,
 | 
				
			||||||
 | 
					        LogicalKeyboardKey.keyM,
 | 
				
			||||||
 | 
					        LogicalKeyboardKey.keyI,
 | 
				
			||||||
 | 
					        LogicalKeyboardKey.keyL,
 | 
				
			||||||
 | 
					        LogicalKeyboardKey.keyI,
 | 
				
			||||||
 | 
					        LogicalKeyboardKey.keyN,
 | 
				
			||||||
 | 
					        LogicalKeyboardKey.keyG,
 | 
				
			||||||
 | 
					        LogicalKeyboardKey.space,
 | 
				
			||||||
 | 
					        LogicalKeyboardKey.keyE,
 | 
				
			||||||
 | 
					        LogicalKeyboardKey.keyY,
 | 
				
			||||||
 | 
					        LogicalKeyboardKey.keyE,
 | 
				
			||||||
 | 
					        LogicalKeyboardKey.keyS,
 | 
				
			||||||
 | 
					      ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      for (final key in searchText) {
 | 
				
			||||||
 | 
					        await tester.simulateKeyEvent(key);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// tap enter
 | 
				
			||||||
 | 
					      await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
 | 
				
			||||||
 | 
					      final firstNode =
 | 
				
			||||||
 | 
					          tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// except the emoji is in document
 | 
				
			||||||
 | 
					      expect(firstNode.delta!.toPlainText().contains('😄'), true);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    testWidgets('start searching with sapce', (tester) async {
 | 
				
			||||||
 | 
					      await createNewDocumentAndShowEmojiList(tester);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// emoji list is showing
 | 
				
			||||||
 | 
					      final emojiHandler = find.byType(EmojiHandler);
 | 
				
			||||||
 | 
					      expect(emojiHandler, findsOneWidget);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// input space
 | 
				
			||||||
 | 
					      await tester.simulateKeyEvent(LogicalKeyboardKey.space);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// emoji list is dismissed
 | 
				
			||||||
 | 
					      expect(emojiHandler, findsNothing);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -13,7 +13,6 @@ void main() {
 | 
				
			|||||||
  hotkeys_test.main();
 | 
					  hotkeys_test.main();
 | 
				
			||||||
  emoji_shortcut_test.main();
 | 
					  emoji_shortcut_test.main();
 | 
				
			||||||
  hotkeys_test.main();
 | 
					  hotkeys_test.main();
 | 
				
			||||||
  emoji_shortcut_test.main();
 | 
					 | 
				
			||||||
  share_markdown_test.main();
 | 
					  share_markdown_test.main();
 | 
				
			||||||
  import_files_test.main();
 | 
					  import_files_test.main();
 | 
				
			||||||
  zoom_in_out_test.main();
 | 
					  zoom_in_out_test.main();
 | 
				
			||||||
 | 
				
			|||||||
@ -7,6 +7,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da
 | 
				
			|||||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/heading_block_shortcuts.dart';
 | 
					import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/heading_block_shortcuts.dart';
 | 
				
			||||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/numbered_list_block_shortcuts.dart';
 | 
					import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/numbered_list_block_shortcuts.dart';
 | 
				
			||||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
 | 
					import 'package:appflowy/plugins/document/presentation/editor_style.dart';
 | 
				
			||||||
 | 
					import 'package:appflowy/plugins/emoji/emoji_actions_command.dart';
 | 
				
			||||||
import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart';
 | 
					import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart';
 | 
				
			||||||
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
 | 
					import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
 | 
				
			||||||
import 'package:appflowy_editor/appflowy_editor.dart';
 | 
					import 'package:appflowy_editor/appflowy_editor.dart';
 | 
				
			||||||
@ -82,5 +83,9 @@ List<CharacterShortcutEvent> buildCharacterShortcutEvents(
 | 
				
			|||||||
      documentBloc.documentId,
 | 
					      documentBloc.documentId,
 | 
				
			||||||
      styleCustomizer.inlineActionsMenuStyleBuilder(),
 | 
					      styleCustomizer.inlineActionsMenuStyleBuilder(),
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// show emoji list
 | 
				
			||||||
 | 
					    /// - Using `:`
 | 
				
			||||||
 | 
					    emojiCommand(context),
 | 
				
			||||||
  ];
 | 
					  ];
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,44 @@
 | 
				
			|||||||
 | 
					import 'package:appflowy_editor/appflowy_editor.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/cupertino.dart';
 | 
				
			||||||
 | 
					import 'package:universal_platform/universal_platform.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'emoji_menu.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emojiCharacter = ':';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CharacterShortcutEvent emojiCommand(BuildContext context) =>
 | 
				
			||||||
 | 
					    CharacterShortcutEvent(
 | 
				
			||||||
 | 
					      key: 'Opens Emoji Menu',
 | 
				
			||||||
 | 
					      character: emojiCharacter,
 | 
				
			||||||
 | 
					      handler: (editorState) {
 | 
				
			||||||
 | 
					        emojiMenuService ??= EmojiMenu(
 | 
				
			||||||
 | 
					          context: context,
 | 
				
			||||||
 | 
					          editorState: editorState,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        return emojiCommandHandler(editorState, context);
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					EmojiMenuService? emojiMenuService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Future<bool> emojiCommandHandler(
 | 
				
			||||||
 | 
					  EditorState editorState,
 | 
				
			||||||
 | 
					  BuildContext context,
 | 
				
			||||||
 | 
					) async {
 | 
				
			||||||
 | 
					  final selection = editorState.selection;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (UniversalPlatform.isMobile || selection == null) {
 | 
				
			||||||
 | 
					    return false;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!selection.isCollapsed) {
 | 
				
			||||||
 | 
					    await editorState.deleteSelection(selection);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  await editorState.insertTextAtPosition(
 | 
				
			||||||
 | 
					    emojiCharacter,
 | 
				
			||||||
 | 
					    position: selection.start,
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  emojiMenuService?.show();
 | 
				
			||||||
 | 
					  return true;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										382
									
								
								frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										382
									
								
								frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,382 @@
 | 
				
			|||||||
 | 
					import 'dart:async';
 | 
				
			||||||
 | 
					import 'dart:math';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
 | 
				
			||||||
 | 
					import 'package:appflowy_editor/appflowy_editor.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:flowy_infra_ui/style_widget/button.dart';
 | 
				
			||||||
 | 
					import 'package:flowy_infra_ui/style_widget/text.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/services.dart';
 | 
				
			||||||
 | 
					import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
 | 
				
			||||||
 | 
					import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'emoji_menu.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class EmojiHandler extends StatefulWidget {
 | 
				
			||||||
 | 
					  const EmojiHandler({
 | 
				
			||||||
 | 
					    super.key,
 | 
				
			||||||
 | 
					    required this.editorState,
 | 
				
			||||||
 | 
					    required this.menuService,
 | 
				
			||||||
 | 
					    required this.onDismiss,
 | 
				
			||||||
 | 
					    required this.onSelectionUpdate,
 | 
				
			||||||
 | 
					    required this.onEmojiSelect,
 | 
				
			||||||
 | 
					    this.startCharAmount = 1,
 | 
				
			||||||
 | 
					    this.cancelBySpaceHandler,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final EditorState editorState;
 | 
				
			||||||
 | 
					  final EmojiMenuService menuService;
 | 
				
			||||||
 | 
					  final VoidCallback onDismiss;
 | 
				
			||||||
 | 
					  final VoidCallback onSelectionUpdate;
 | 
				
			||||||
 | 
					  final SelectEmojiItemHandler onEmojiSelect;
 | 
				
			||||||
 | 
					  final int startCharAmount;
 | 
				
			||||||
 | 
					  final bool Function()? cancelBySpaceHandler;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  State<EmojiHandler> createState() => _EmojiHandlerState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _EmojiHandlerState extends State<EmojiHandler> {
 | 
				
			||||||
 | 
					  final _focusNode = FocusNode(debugLabel: 'emoji_menu_handler');
 | 
				
			||||||
 | 
					  final ItemScrollController controller = ItemScrollController();
 | 
				
			||||||
 | 
					  late EmojiData emojiData;
 | 
				
			||||||
 | 
					  final List<Emoji> searchedEmojis = [];
 | 
				
			||||||
 | 
					  bool loaded = false;
 | 
				
			||||||
 | 
					  int invalidCounter = 0;
 | 
				
			||||||
 | 
					  late int startOffset;
 | 
				
			||||||
 | 
					  String _search = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  set search(String search) {
 | 
				
			||||||
 | 
					    _search = search;
 | 
				
			||||||
 | 
					    _doSearch();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final ValueNotifier<int> _selectedIndexNotifier = ValueNotifier(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    WidgetsBinding.instance.addPostFrameCallback(
 | 
				
			||||||
 | 
					      (_) => _focusNode.requestFocus(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    startOffset = widget.editorState.selection?.endIndex ?? 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (kCachedEmojiData != null) {
 | 
				
			||||||
 | 
					      loadEmojis(kCachedEmojiData!);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      EmojiData.builtIn().then(
 | 
				
			||||||
 | 
					        (value) {
 | 
				
			||||||
 | 
					          kCachedEmojiData = value;
 | 
				
			||||||
 | 
					          loadEmojis(value);
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    _focusNode.dispose();
 | 
				
			||||||
 | 
					    _selectedIndexNotifier.dispose();
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final noEmojis = searchedEmojis.isEmpty;
 | 
				
			||||||
 | 
					    return Focus(
 | 
				
			||||||
 | 
					      focusNode: _focusNode,
 | 
				
			||||||
 | 
					      onKeyEvent: onKeyEvent,
 | 
				
			||||||
 | 
					      child: Container(
 | 
				
			||||||
 | 
					        constraints: const BoxConstraints(maxHeight: 400, maxWidth: 300),
 | 
				
			||||||
 | 
					        decoration: BoxDecoration(
 | 
				
			||||||
 | 
					          borderRadius: BorderRadius.circular(6.0),
 | 
				
			||||||
 | 
					          color: Theme.of(context).cardColor,
 | 
				
			||||||
 | 
					          boxShadow: [
 | 
				
			||||||
 | 
					            BoxShadow(
 | 
				
			||||||
 | 
					              blurRadius: 5,
 | 
				
			||||||
 | 
					              spreadRadius: 1,
 | 
				
			||||||
 | 
					              color: Colors.black.withAlpha(25),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        child: noEmojis
 | 
				
			||||||
 | 
					            ? SizedBox(
 | 
				
			||||||
 | 
					                width: 400,
 | 
				
			||||||
 | 
					                height: 40,
 | 
				
			||||||
 | 
					                child: Center(
 | 
				
			||||||
 | 
					                  child: SizedBox.square(
 | 
				
			||||||
 | 
					                    dimension: 20,
 | 
				
			||||||
 | 
					                    child: CircularProgressIndicator(),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              )
 | 
				
			||||||
 | 
					            : ScrollablePositionedList.builder(
 | 
				
			||||||
 | 
					                itemCount: searchedEmojis.length,
 | 
				
			||||||
 | 
					                itemScrollController: controller,
 | 
				
			||||||
 | 
					                padding: EdgeInsets.all(8),
 | 
				
			||||||
 | 
					                shrinkWrap: true,
 | 
				
			||||||
 | 
					                physics: const ClampingScrollPhysics(),
 | 
				
			||||||
 | 
					                itemBuilder: (ctx, index) {
 | 
				
			||||||
 | 
					                  return ValueListenableBuilder(
 | 
				
			||||||
 | 
					                    valueListenable: _selectedIndexNotifier,
 | 
				
			||||||
 | 
					                    builder: (context, value, __) {
 | 
				
			||||||
 | 
					                      final selectedEmoji = searchedEmojis[index];
 | 
				
			||||||
 | 
					                      final displayedEmoji =
 | 
				
			||||||
 | 
					                          emojiData.getEmojiById(selectedEmoji.id);
 | 
				
			||||||
 | 
					                      final isSelected = value == index;
 | 
				
			||||||
 | 
					                      return SizedBox(
 | 
				
			||||||
 | 
					                        height: 32,
 | 
				
			||||||
 | 
					                        child: FlowyButton(
 | 
				
			||||||
 | 
					                          text: FlowyText.medium(
 | 
				
			||||||
 | 
					                            '$displayedEmoji ${selectedEmoji.name}',
 | 
				
			||||||
 | 
					                            lineHeight: 1.0,
 | 
				
			||||||
 | 
					                            overflow: TextOverflow.ellipsis,
 | 
				
			||||||
 | 
					                          ),
 | 
				
			||||||
 | 
					                          isSelected: isSelected,
 | 
				
			||||||
 | 
					                          onTap: () => onSelect(index),
 | 
				
			||||||
 | 
					                        ),
 | 
				
			||||||
 | 
					                      );
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void changeSelectedIndex(int index) => _selectedIndexNotifier.value = index;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void loadEmojis(EmojiData data) {
 | 
				
			||||||
 | 
					    emojiData = data;
 | 
				
			||||||
 | 
					    searchedEmojis.clear();
 | 
				
			||||||
 | 
					    searchedEmojis.addAll(emojiData.emojis.values);
 | 
				
			||||||
 | 
					    if (mounted) {
 | 
				
			||||||
 | 
					      setState(() {
 | 
				
			||||||
 | 
					        loaded = true;
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> _doSearch() async {
 | 
				
			||||||
 | 
					    if (!loaded) return;
 | 
				
			||||||
 | 
					    if (_search.startsWith(' ')) {
 | 
				
			||||||
 | 
					      widget.onDismiss.call();
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    final searchEmojiData = emojiData.filterByKeyword(_search);
 | 
				
			||||||
 | 
					    setState(() {
 | 
				
			||||||
 | 
					      searchedEmojis.clear();
 | 
				
			||||||
 | 
					      searchedEmojis.addAll(searchEmojiData.emojis.values);
 | 
				
			||||||
 | 
					      changeSelectedIndex(0);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    if (searchedEmojis.isEmpty) {
 | 
				
			||||||
 | 
					      widget.onDismiss.call();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  KeyEventResult onKeyEvent(focus, KeyEvent event) {
 | 
				
			||||||
 | 
					    if (event is! KeyDownEvent) {
 | 
				
			||||||
 | 
					      return KeyEventResult.ignored;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const moveKeys = [
 | 
				
			||||||
 | 
					      LogicalKeyboardKey.arrowUp,
 | 
				
			||||||
 | 
					      LogicalKeyboardKey.arrowDown,
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (event.logicalKey == LogicalKeyboardKey.enter) {
 | 
				
			||||||
 | 
					      onSelect(_selectedIndexNotifier.value);
 | 
				
			||||||
 | 
					      return KeyEventResult.handled;
 | 
				
			||||||
 | 
					    } else if (event.logicalKey == LogicalKeyboardKey.escape) {
 | 
				
			||||||
 | 
					      // Workaround to bring focus back to editor
 | 
				
			||||||
 | 
					      widget.editorState
 | 
				
			||||||
 | 
					          .updateSelectionWithReason(widget.editorState.selection);
 | 
				
			||||||
 | 
					      widget.onDismiss.call();
 | 
				
			||||||
 | 
					    } else if (event.logicalKey == LogicalKeyboardKey.backspace) {
 | 
				
			||||||
 | 
					      if (_search.isEmpty) {
 | 
				
			||||||
 | 
					        if (_canDeleteLastCharacter()) {
 | 
				
			||||||
 | 
					          widget.editorState.deleteBackward();
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          // Workaround for editor regaining focus
 | 
				
			||||||
 | 
					          widget.editorState.apply(
 | 
				
			||||||
 | 
					            widget.editorState.transaction
 | 
				
			||||||
 | 
					              ..afterSelection = widget.editorState.selection,
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        widget.onDismiss.call();
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        widget.onSelectionUpdate();
 | 
				
			||||||
 | 
					        widget.editorState.deleteBackward();
 | 
				
			||||||
 | 
					        _deleteCharacterAtSelection();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return KeyEventResult.handled;
 | 
				
			||||||
 | 
					    } else if (event.character != null &&
 | 
				
			||||||
 | 
					        ![
 | 
				
			||||||
 | 
					          ...moveKeys,
 | 
				
			||||||
 | 
					          LogicalKeyboardKey.arrowLeft,
 | 
				
			||||||
 | 
					          LogicalKeyboardKey.arrowRight,
 | 
				
			||||||
 | 
					        ].contains(event.logicalKey)) {
 | 
				
			||||||
 | 
					      /// Prevents dismissal of context menu by notifying the parent
 | 
				
			||||||
 | 
					      /// that the selection change occurred from the handler.
 | 
				
			||||||
 | 
					      widget.onSelectionUpdate();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (event.logicalKey == LogicalKeyboardKey.space) {
 | 
				
			||||||
 | 
					        final cancelBySpaceHandler = widget.cancelBySpaceHandler;
 | 
				
			||||||
 | 
					        if (cancelBySpaceHandler != null && cancelBySpaceHandler()) {
 | 
				
			||||||
 | 
					          return KeyEventResult.handled;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Interpolation to avoid having a getter for private variable
 | 
				
			||||||
 | 
					      _insertCharacter(event.character!);
 | 
				
			||||||
 | 
					      return KeyEventResult.handled;
 | 
				
			||||||
 | 
					    } else if (moveKeys.contains(event.logicalKey)) {
 | 
				
			||||||
 | 
					      _moveSelection(event.logicalKey);
 | 
				
			||||||
 | 
					      return KeyEventResult.handled;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if ([LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight]
 | 
				
			||||||
 | 
					        .contains(event.logicalKey)) {
 | 
				
			||||||
 | 
					      widget.onSelectionUpdate();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      event.logicalKey == LogicalKeyboardKey.arrowLeft
 | 
				
			||||||
 | 
					          ? widget.editorState.moveCursorForward()
 | 
				
			||||||
 | 
					          : widget.editorState.moveCursorBackward(SelectionMoveRange.character);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// If cursor moves before @ then dismiss menu
 | 
				
			||||||
 | 
					      /// If cursor moves after @search.length then dismiss menu
 | 
				
			||||||
 | 
					      final selection = widget.editorState.selection;
 | 
				
			||||||
 | 
					      if (selection != null &&
 | 
				
			||||||
 | 
					          (selection.endIndex < startOffset ||
 | 
				
			||||||
 | 
					              selection.endIndex > (startOffset + _search.length))) {
 | 
				
			||||||
 | 
					        widget.onDismiss.call();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      /// Workaround: When using the move cursor methods, it seems the
 | 
				
			||||||
 | 
					      ///  focus goes back to the editor, this makes sure this handler
 | 
				
			||||||
 | 
					      ///  receives the next keypress.
 | 
				
			||||||
 | 
					      ///
 | 
				
			||||||
 | 
					      _focusNode.requestFocus();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return KeyEventResult.handled;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return KeyEventResult.handled;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void onSelect(int index) {
 | 
				
			||||||
 | 
					    widget.onEmojiSelect.call(
 | 
				
			||||||
 | 
					      context,
 | 
				
			||||||
 | 
					      (
 | 
				
			||||||
 | 
					        startOffset - widget.startCharAmount,
 | 
				
			||||||
 | 
					        _search.length + widget.startCharAmount
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      emojiData.getEmojiById(searchedEmojis[index].id),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    widget.onDismiss.call();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _insertCharacter(String character) {
 | 
				
			||||||
 | 
					    widget.editorState.insertTextAtCurrentSelection(character);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final selection = widget.editorState.selection;
 | 
				
			||||||
 | 
					    if (selection == null || !selection.isCollapsed) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final delta = widget.editorState.getNodeAtPath(selection.end.path)?.delta;
 | 
				
			||||||
 | 
					    if (delta == null) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    search = widget.editorState
 | 
				
			||||||
 | 
					        .getTextInSelection(
 | 
				
			||||||
 | 
					          selection.copyWith(
 | 
				
			||||||
 | 
					            start: selection.start.copyWith(offset: startOffset),
 | 
				
			||||||
 | 
					            end: selection.start
 | 
				
			||||||
 | 
					                .copyWith(offset: startOffset + _search.length + 1),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .join();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _moveSelection(LogicalKeyboardKey key) {
 | 
				
			||||||
 | 
					    bool didChange = false;
 | 
				
			||||||
 | 
					    final index = _selectedIndexNotifier.value;
 | 
				
			||||||
 | 
					    if (key == LogicalKeyboardKey.arrowUp ||
 | 
				
			||||||
 | 
					        (key == LogicalKeyboardKey.tab &&
 | 
				
			||||||
 | 
					            HardwareKeyboard.instance.isShiftPressed)) {
 | 
				
			||||||
 | 
					      if (index == 0) {
 | 
				
			||||||
 | 
					        changeSelectedIndex(max(0, searchedEmojis.length - 1));
 | 
				
			||||||
 | 
					        didChange = true;
 | 
				
			||||||
 | 
					      } else if (index > 0) {
 | 
				
			||||||
 | 
					        changeSelectedIndex(index - 1);
 | 
				
			||||||
 | 
					        didChange = true;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab]
 | 
				
			||||||
 | 
					        .contains(key)) {
 | 
				
			||||||
 | 
					      if (index < searchedEmojis.length - 1) {
 | 
				
			||||||
 | 
					        changeSelectedIndex(index + 1);
 | 
				
			||||||
 | 
					        didChange = true;
 | 
				
			||||||
 | 
					      } else if (index == searchedEmojis.length - 1) {
 | 
				
			||||||
 | 
					        changeSelectedIndex(0);
 | 
				
			||||||
 | 
					        didChange = true;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (mounted && didChange) {
 | 
				
			||||||
 | 
					      _scrollToItem();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _scrollToItem() {
 | 
				
			||||||
 | 
					    final noEmojis = searchedEmojis.isEmpty;
 | 
				
			||||||
 | 
					    if (noEmojis) return;
 | 
				
			||||||
 | 
					    controller.scrollTo(
 | 
				
			||||||
 | 
					      index: _selectedIndexNotifier.value,
 | 
				
			||||||
 | 
					      duration: const Duration(milliseconds: 200),
 | 
				
			||||||
 | 
					      alignment: 0.5,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _deleteCharacterAtSelection() {
 | 
				
			||||||
 | 
					    final selection = widget.editorState.selection;
 | 
				
			||||||
 | 
					    if (selection == null || !selection.isCollapsed) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final node = widget.editorState.getNodeAtPath(selection.end.path);
 | 
				
			||||||
 | 
					    final delta = node?.delta;
 | 
				
			||||||
 | 
					    if (node == null || delta == null) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    search = delta.toPlainText().substring(
 | 
				
			||||||
 | 
					          startOffset,
 | 
				
			||||||
 | 
					          startOffset - 1 + _search.length,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _canDeleteLastCharacter() {
 | 
				
			||||||
 | 
					    final selection = widget.editorState.selection;
 | 
				
			||||||
 | 
					    if (selection == null || !selection.isCollapsed) {
 | 
				
			||||||
 | 
					      return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final delta = widget.editorState.getNodeAtPath(selection.start.path)?.delta;
 | 
				
			||||||
 | 
					    if (delta == null) {
 | 
				
			||||||
 | 
					      return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return delta.isNotEmpty;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					typedef SelectEmojiItemHandler = void Function(
 | 
				
			||||||
 | 
					  BuildContext context,
 | 
				
			||||||
 | 
					  (int start, int end) replacement,
 | 
				
			||||||
 | 
					  String emoji,
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
							
								
								
									
										204
									
								
								frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,204 @@
 | 
				
			|||||||
 | 
					import 'package:appflowy_editor/appflowy_editor.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'emoji_actions_command.dart';
 | 
				
			||||||
 | 
					import 'emoji_handler.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					abstract class EmojiMenuService {
 | 
				
			||||||
 | 
					  void show();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void dismiss();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class EmojiMenu extends EmojiMenuService {
 | 
				
			||||||
 | 
					  EmojiMenu({
 | 
				
			||||||
 | 
					    required this.context,
 | 
				
			||||||
 | 
					    required this.editorState,
 | 
				
			||||||
 | 
					    this.startCharAmount = 1,
 | 
				
			||||||
 | 
					    this.cancelBySpaceHandler,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final BuildContext context;
 | 
				
			||||||
 | 
					  final EditorState editorState;
 | 
				
			||||||
 | 
					  final bool Function()? cancelBySpaceHandler;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final int startCharAmount;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  OverlayEntry? _menuEntry;
 | 
				
			||||||
 | 
					  bool selectionChangedByMenu = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dismiss() {
 | 
				
			||||||
 | 
					    if (_menuEntry != null) {
 | 
				
			||||||
 | 
					      editorState.service.keyboardService?.enable();
 | 
				
			||||||
 | 
					      editorState.service.scrollService?.enable();
 | 
				
			||||||
 | 
					      keepEditorFocusNotifier.decrease();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _menuEntry?.remove();
 | 
				
			||||||
 | 
					    _menuEntry = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // workaround: SelectionService has been released after hot reload.
 | 
				
			||||||
 | 
					    final isSelectionDisposed =
 | 
				
			||||||
 | 
					        editorState.service.selectionServiceKey.currentState == null;
 | 
				
			||||||
 | 
					    if (!isSelectionDisposed) {
 | 
				
			||||||
 | 
					      final selectionService = editorState.service.selectionService;
 | 
				
			||||||
 | 
					      selectionService.currentSelection.removeListener(_onSelectionChange);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    emojiMenuService = null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _onSelectionUpdate() => selectionChangedByMenu = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void show() {
 | 
				
			||||||
 | 
					    WidgetsBinding.instance.addPostFrameCallback((_) => _show());
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _show() {
 | 
				
			||||||
 | 
					    final selectionService = editorState.service.selectionService;
 | 
				
			||||||
 | 
					    final selectionRects = selectionService.selectionRects;
 | 
				
			||||||
 | 
					    if (selectionRects.isEmpty) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const menuHeight = 400.0, menuWidth = 300.0;
 | 
				
			||||||
 | 
					    const Offset menuOffset = Offset(0, 10);
 | 
				
			||||||
 | 
					    final Offset editorOffset =
 | 
				
			||||||
 | 
					        editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
 | 
				
			||||||
 | 
					    final Size editorSize = editorState.renderBox!.size;
 | 
				
			||||||
 | 
					    final editorHeight = editorSize.height, editorWidth = editorSize.width;
 | 
				
			||||||
 | 
					    // 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,
 | 
				
			||||||
 | 
					        editorHeight - offset.dy,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Show on the left
 | 
				
			||||||
 | 
					    if (offset.dx > (editorWidth - menuWidth)) {
 | 
				
			||||||
 | 
					      alignment = alignment == Alignment.topLeft
 | 
				
			||||||
 | 
					          ? Alignment.topRight
 | 
				
			||||||
 | 
					          : Alignment.bottomRight;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      offset = Offset(
 | 
				
			||||||
 | 
					        editorWidth - offset.dx,
 | 
				
			||||||
 | 
					        offset.dy,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final (left, top, right, bottom) = _getPosition(alignment, offset);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _menuEntry = OverlayEntry(
 | 
				
			||||||
 | 
					      builder: (context) => SizedBox(
 | 
				
			||||||
 | 
					        height: editorSize.height,
 | 
				
			||||||
 | 
					        width: editorSize.width,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // GestureDetector handles clicks outside of the context menu,
 | 
				
			||||||
 | 
					        // to dismiss the context menu.
 | 
				
			||||||
 | 
					        child: GestureDetector(
 | 
				
			||||||
 | 
					          behavior: HitTestBehavior.opaque,
 | 
				
			||||||
 | 
					          onTap: dismiss,
 | 
				
			||||||
 | 
					          child: Stack(
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              Positioned(
 | 
				
			||||||
 | 
					                top: top,
 | 
				
			||||||
 | 
					                bottom: bottom,
 | 
				
			||||||
 | 
					                left: left,
 | 
				
			||||||
 | 
					                right: right,
 | 
				
			||||||
 | 
					                child: EmojiHandler(
 | 
				
			||||||
 | 
					                  editorState: editorState,
 | 
				
			||||||
 | 
					                  menuService: this,
 | 
				
			||||||
 | 
					                  onDismiss: dismiss,
 | 
				
			||||||
 | 
					                  onSelectionUpdate: _onSelectionUpdate,
 | 
				
			||||||
 | 
					                  startCharAmount: startCharAmount,
 | 
				
			||||||
 | 
					                  cancelBySpaceHandler: cancelBySpaceHandler,
 | 
				
			||||||
 | 
					                  onEmojiSelect: (
 | 
				
			||||||
 | 
					                    BuildContext context,
 | 
				
			||||||
 | 
					                    (int, int) replacement,
 | 
				
			||||||
 | 
					                    String emoji,
 | 
				
			||||||
 | 
					                  ) async {
 | 
				
			||||||
 | 
					                    final selection = editorState.selection;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    if (selection == null) return;
 | 
				
			||||||
 | 
					                    final node =
 | 
				
			||||||
 | 
					                        editorState.document.nodeAtPath(selection.end.path);
 | 
				
			||||||
 | 
					                    if (node == null) return;
 | 
				
			||||||
 | 
					                    final transaction = editorState.transaction
 | 
				
			||||||
 | 
					                      ..replaceText(
 | 
				
			||||||
 | 
					                        node,
 | 
				
			||||||
 | 
					                        replacement.$1,
 | 
				
			||||||
 | 
					                        replacement.$2,
 | 
				
			||||||
 | 
					                        emoji,
 | 
				
			||||||
 | 
					                      );
 | 
				
			||||||
 | 
					                    await editorState.apply(transaction);
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Overlay.of(context).insert(_menuEntry!);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    editorState.service.keyboardService?.disable(showCursor: true);
 | 
				
			||||||
 | 
					    editorState.service.scrollService?.disable();
 | 
				
			||||||
 | 
					    selectionService.currentSelection.addListener(_onSelectionChange);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _onSelectionChange() {
 | 
				
			||||||
 | 
					    // workaround: SelectionService has been released after hot reload.
 | 
				
			||||||
 | 
					    final isSelectionDisposed =
 | 
				
			||||||
 | 
					        editorState.service.selectionServiceKey.currentState == null;
 | 
				
			||||||
 | 
					    if (!isSelectionDisposed) {
 | 
				
			||||||
 | 
					      final selectionService = editorState.service.selectionService;
 | 
				
			||||||
 | 
					      if (selectionService.currentSelection.value == null) {
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!selectionChangedByMenu) {
 | 
				
			||||||
 | 
					      return dismiss();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    selectionChangedByMenu = false;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  (double? left, double? top, double? right, double? bottom) _getPosition(
 | 
				
			||||||
 | 
					    Alignment alignment,
 | 
				
			||||||
 | 
					    Offset offset,
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					    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);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user