mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-11-01 02:24:37 +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 '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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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