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:
Morn 2025-03-26 13:24:16 +08:00 committed by GitHub
parent cfca70ae14
commit 24bb1b58a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 747 additions and 1 deletions

View File

@ -1,8 +1,10 @@
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_editor/appflowy_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_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -39,4 +41,114 @@ void main() {
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);
});
});
}

View File

@ -13,7 +13,6 @@ void main() {
hotkeys_test.main();
emoji_shortcut_test.main();
hotkeys_test.main();
emoji_shortcut_test.main();
share_markdown_test.main();
import_files_test.main();
zoom_in_out_test.main();

View File

@ -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/numbered_list_block_shortcuts.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_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@ -82,5 +83,9 @@ List<CharacterShortcutEvent> buildCharacterShortcutEvents(
documentBloc.documentId,
styleCustomizer.inlineActionsMenuStyleBuilder(),
),
/// show emoji list
/// - Using `:`
emojiCommand(context),
];
}

View File

@ -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;
}

View 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,
);

View 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);
}
}