mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-04-23 22:27:13 -04:00
fix: crash when trying to delete emoji (#7787)
* fix: emoji picker error on desktop * fix: test errors
This commit is contained in:
parent
c7bf8bb1ba
commit
f8927b1843
8 changed files with 85 additions and 255 deletions
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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('');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue