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(String character); void dismiss(); } class EmojiMenu extends EmojiMenuService { EmojiMenu({ required this.context, required this.editorState, this.startCharAmount = 1, this.cancelBySpaceHandler, this.menuHeight = 400, this.menuWidth = 300, }); final BuildContext context; final EditorState editorState; final double menuHeight; final double menuWidth; final bool Function()? cancelBySpaceHandler; final int startCharAmount; Offset _offset = Offset.zero; Alignment _alignment = Alignment.topLeft; OverlayEntry? _menuEntry; bool selectionChangedByMenu = false; String initialCharacter = ''; @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(String character) { initialCharacter = character; WidgetsBinding.instance.addPostFrameCallback((_) => _show()); } void _show() { final selectionService = editorState.service.selectionService; final selectionRects = selectionService.selectionRects; if (selectionRects.isEmpty) { return; } final Size editorSize = editorState.renderBox!.size; calculateSelectionMenuOffset(selectionRects.first); final (left, top, right, bottom) = _getPosition(); _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, initialSearchText: initialCharacter, 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 ..deleteText( node, replacement.$1, replacement.$2 - replacement.$1, ) ..insertText( node, replacement.$1, 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() { 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 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, editorHeight + editorOffset.dy - offset.dy, ); } // show on right if (_offset.dx + menuWidth < editorOffset.dx + editorWidth) { _offset = Offset( _offset.dx, _offset.dy, ); } else if (offset.dx - editorOffset.dx > menuWidth) { // show on left _alignment = _alignment == Alignment.topLeft ? Alignment.topRight : Alignment.bottomRight; _offset = Offset( editorWidth - _offset.dx + editorOffset.dx, _offset.dy, ); } } }