fix: crash when trying to delete emoji (#7787)

* fix: emoji picker error on desktop

* fix: test errors
This commit is contained in:
Morn 2025-04-21 10:22:02 +08:00 committed by GitHub
parent c7bf8bb1ba
commit f8927b1843
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 85 additions and 255 deletions

View file

@ -1,44 +1,63 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
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:easy_localization/easy_localization.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';
import '../../shared/keyboard.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
Future<void> prepare(WidgetTester tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await tester.editor.tapLineOfEditorAt(0);
}
// May be better to move this to an existing test but unsure what it fits with
group('Keyboard shortcuts related to emojis', () {
testWidgets('cmd/ctrl+alt+e shortcut opens the emoji picker',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await prepare(tester);
final Finder editor = find.byType(AppFlowyEditor);
await tester.tap(editor);
await tester.pumpAndSettle();
expect(find.byType(EmojiHandler), findsNothing);
expect(find.byType(EmojiSelectionMenu), findsNothing);
await FlowyTestKeyboard.simulateKeyDownEvent(
[
Platform.isMacOS
? LogicalKeyboardKey.meta
: LogicalKeyboardKey.control,
LogicalKeyboardKey.alt,
LogicalKeyboardKey.keyE,
],
tester: tester,
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyE,
isAltPressed: true,
isMetaPressed: Platform.isMacOS,
isControlPressed: !Platform.isMacOS,
);
await tester.pumpAndSettle(Duration(seconds: 1));
expect(find.byType(EmojiHandler), findsOneWidget);
expect(find.byType(EmojiSelectionMenu), findsOneWidget);
/// press backspace to hide the emoji picker
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
expect(find.byType(EmojiHandler), findsNothing);
});
testWidgets('insert emoji by slash menu', (tester) async {
await prepare(tester);
await tester.editor.showSlashMenu();
/// show emoji picler
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_emoji.tr(),
offset: 100,
);
await tester.pumpAndSettle(Duration(seconds: 1));
expect(find.byType(EmojiHandler), findsOneWidget);
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
final firstNode =
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
/// except the emoji is in document
expect(firstNode.delta!.toPlainText().contains('😀'), true);
});
});
@ -47,10 +66,7 @@ void main() {
WidgetTester tester, {
String? search,
}) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await tester.editor.tapLineOfEditorAt(0);
await prepare(tester);
await tester.ime.insertText(':${search ?? 'a'}');
await tester.pumpAndSettle(Duration(seconds: 1));
}

View file

@ -1,10 +1,13 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart';
import 'package:appflowy/plugins/emoji/emoji_actions_command.dart';
import 'package:appflowy/plugins/emoji/emoji_menu.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
import 'slash_menu_item_builder.dart';
@ -37,11 +40,16 @@ extension on EditorState {
}) async {
final container = Overlay.of(context);
menuService.dismiss();
showEmojiPickerMenu(
container,
this,
menuService.alignment,
menuService.offset,
);
if (UniversalPlatform.isMobile || selection == null) {
return;
}
final node = getNodeAtPath(selection!.end.path);
final delta = node?.delta;
if (node == null || delta == null || node.type == CodeBlockKeys.type) {
return;
}
emojiMenuService = EmojiMenu(editorState: this, overlay: container);
emojiMenuService?.show('');
}
}

View file

@ -18,7 +18,7 @@ CharacterShortcutEvent emojiCommand(BuildContext context) =>
},
handlerWithCharacter: (editorState, character) {
emojiMenuService = EmojiMenu(
context: context,
overlay: Overlay.of(context),
editorState: editorState,
);
return emojiCommandHandler(editorState, context, character);
@ -40,10 +40,7 @@ Future<bool> emojiCommandHandler(
final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
if (node == null ||
delta == null ||
delta.isEmpty ||
node.type == CodeBlockKeys.type) {
if (node == null || delta == null || node.type == CodeBlockKeys.type) {
return false;
}

View file

@ -22,7 +22,6 @@ class EmojiHandler extends StatefulWidget {
required this.onDismiss,
required this.onSelectionUpdate,
required this.onEmojiSelect,
this.startCharAmount = 1,
this.cancelBySpaceHandler,
this.initialSearchText = '',
});
@ -32,7 +31,6 @@ class EmojiHandler extends StatefulWidget {
final VoidCallback onDismiss;
final VoidCallback onSelectionUpdate;
final SelectEmojiItemHandler onEmojiSelect;
final int startCharAmount;
final String initialSearchText;
final bool Function()? cancelBySpaceHandler;
@ -54,6 +52,8 @@ class _EmojiHandlerState extends State<EmojiHandler> {
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
);
int get startCharAmount => widget.initialSearchText.length;
set search(String search) {
_search = search;
_doSearch();
@ -68,7 +68,8 @@ class _EmojiHandlerState extends State<EmojiHandler> {
(_) => focusNode.requestFocus(),
);
startOffset = (widget.editorState.selection?.endIndex ?? 0) - 1;
startOffset =
(widget.editorState.selection?.endIndex ?? 0) - startCharAmount;
if (kCachedEmojiData != null) {
loadEmojis(kCachedEmojiData!);
@ -194,7 +195,8 @@ class _EmojiHandlerState extends State<EmojiHandler> {
void _doSearch() {
if (!loaded || !mounted) return;
if (_search.startsWith(' ') || _search.isEmpty) {
final enableEmptySearch = widget.initialSearchText.isEmpty;
if ((_search.startsWith(' ') || _search.isEmpty) && !enableEmptySearch) {
widget.onDismiss.call();
return;
}
@ -232,6 +234,10 @@ class _EmojiHandlerState extends State<EmojiHandler> {
widget.onDismiss.call();
} else if (event.logicalKey == LogicalKeyboardKey.backspace) {
if (_search.isEmpty) {
if (widget.initialSearchText.isEmpty) {
widget.onDismiss.call();
return KeyEventResult.handled;
}
if (_canDeleteLastCharacter()) {
widget.editorState.deleteBackward();
} else {
@ -276,7 +282,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
void onSelect(int index) {
widget.onEmojiSelect.call(
context,
(startOffset - widget.startCharAmount, startOffset + _search.length),
(startOffset - startCharAmount, startOffset + _search.length),
emojiData.getEmojiById(searchedEmojis[index].id),
);
widget.onDismiss.call();

View file

@ -12,21 +12,19 @@ abstract class EmojiMenuService {
class EmojiMenu extends EmojiMenuService {
EmojiMenu({
required this.context,
required this.overlay,
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 OverlayState overlay;
final bool Function()? cancelBySpaceHandler;
final int startCharAmount;
Offset _offset = Offset.zero;
Alignment _alignment = Alignment.topLeft;
OverlayEntry? _menuEntry;
@ -97,7 +95,6 @@ class EmojiMenu extends EmojiMenuService {
menuService: this,
onDismiss: dismiss,
onSelectionUpdate: _onSelectionUpdate,
startCharAmount: startCharAmount,
cancelBySpaceHandler: cancelBySpaceHandler,
initialSearchText: initialCharacter,
onEmojiSelect: (
@ -132,8 +129,9 @@ class EmojiMenu extends EmojiMenuService {
),
);
Overlay.of(context).insert(_menuEntry!);
overlay.insert(_menuEntry!);
keepEditorFocusNotifier.increase();
editorState.service.keyboardService?.disable(showCursor: true);
editorState.service.scrollService?.disable();
selectionService.currentSelection.addListener(_onSelectionChange);

View file

@ -1,139 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.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 'package:flowy_infra_ui/style_widget/decoration.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
SelectionMenuItem emojiMenuItem = SelectionMenuItem(
getName: LocaleKeys.document_plugins_emoji.tr,
icon: (editorState, onSelected, style) => SelectableIconWidget(
icon: Icons.emoji_emotions_outlined,
isSelected: onSelected,
style: style,
),
keywords: ['emoji'],
handler: (editorState, menuService, context) {
final container = Overlay.of(context);
menuService.dismiss();
showEmojiPickerMenu(
container,
editorState,
menuService.alignment,
menuService.offset,
);
},
);
void showEmojiPickerMenu(
OverlayState container,
EditorState editorState,
Alignment alignment,
Offset offset,
) {
(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);
}
final (left, top, right, bottom) = getPosition();
keepEditorFocusNotifier.increase();
late OverlayEntry emojiPickerMenuEntry;
emojiPickerMenuEntry = FullScreenOverlayEntry(
left: left,
top: top,
bottom: bottom,
right: right,
dismissCallback: () => keepEditorFocusNotifier.decrease(),
builder: (context) => Material(
type: MaterialType.transparency,
child: Container(
width: 360,
height: 380,
padding: const EdgeInsets.all(4.0),
decoration: FlowyDecoration.decoration(
Theme.of(context).cardColor,
Theme.of(context).colorScheme.shadow,
),
child: EmojiSelectionMenu(
onSubmitted: (emoji) {
editorState.insertTextAtCurrentSelection(emoji);
emojiPickerMenuEntry.remove();
},
onExit: () {
// close emoji panel
emojiPickerMenuEntry.remove();
},
),
),
),
).build();
container.insert(emojiPickerMenuEntry);
}
class EmojiSelectionMenu extends StatefulWidget {
const EmojiSelectionMenu({
super.key,
required this.onSubmitted,
required this.onExit,
});
final void Function(String emoji) onSubmitted;
final void Function() onExit;
@override
State<EmojiSelectionMenu> createState() => _EmojiSelectionMenuState();
}
class _EmojiSelectionMenuState extends State<EmojiSelectionMenu> {
@override
void initState() {
super.initState();
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent);
}
bool _handleGlobalKeyEvent(KeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.escape &&
event is KeyDownEvent) {
//triggers on esc
widget.onExit();
return true;
}
return false;
}
@override
void deactivate() {
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
super.deactivate();
}
@override
Widget build(BuildContext context) {
return FlowyEmojiPicker(
onEmojiSelected: (r) => widget.onSubmitted(r.emoji),
);
}
}

View file

@ -1,4 +1,3 @@
export 'emoji_menu_item.dart';
export 'emoji_shortcut_event.dart';
export 'src/emji_picker_config.dart';
export 'src/emoji_picker.dart';

View file

@ -1,5 +1,7 @@
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy/plugins/emoji/emoji_actions_command.dart';
import 'package:appflowy/plugins/emoji/emoji_menu.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:flutter/material.dart';
final CommandShortcutEvent emojiShortcutEvent = CommandShortcutEvent(
@ -15,73 +17,16 @@ CommandShortcutEventHandler _emojiShortcutHandler = (editorState) {
if (selection == null) {
return KeyEventResult.ignored;
}
final context = editorState.getNodeAtPath(selection.start.path)?.context;
if (context == null) {
final node = editorState.getNodeAtPath(selection.start.path);
final context = node?.context;
if (node == null ||
context == null ||
node.delta == null ||
node.type == CodeBlockKeys.type) {
return KeyEventResult.ignored;
}
final container = Overlay.of(context);
Alignment alignment = Alignment.topLeft;
Offset offset = Offset.zero;
final selectionService = editorState.service.selectionService;
final selectionRects = selectionService.selectionRects;
if (selectionRects.isEmpty) {
return KeyEventResult.ignored;
}
final rect = selectionRects.first;
// Calculate the offset and alignment
// Don't like these values being hardcoded but unsure how to grab the
// values dynamically to match the /emoji command.
const menuHeight = 380.0;
const menuOffset = Offset(10, 10); // Tried (0, 10) but that looked off
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 newOffset = bottomRight + menuOffset;
offset = Offset(
newOffset.dx,
newOffset.dy,
);
// show above
if (newOffset.dy + menuHeight >= editorOffset.dy + editorHeight) {
newOffset = topRight - menuOffset;
alignment = Alignment.bottomLeft;
offset = Offset(
newOffset.dx,
editorHeight + editorOffset.dy - newOffset.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,
);
}
showEmojiPickerMenu(
container,
editorState,
alignment,
offset,
);
emojiMenuService = EmojiMenu(editorState: editorState, overlay: container);
emojiMenuService?.show('');
return KeyEventResult.handled;
};