mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-11-09 15:08:28 +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 '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/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:appflowy_editor/src/editor/editor_component/service/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/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:integration_test/integration_test.dart';
|
import 'package:integration_test/integration_test.dart';
|
||||||
@ -39,4 +41,114 @@ void main() {
|
|||||||
expect(find.byType(EmojiSelectionMenu), findsOneWidget);
|
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();
|
hotkeys_test.main();
|
||||||
emoji_shortcut_test.main();
|
emoji_shortcut_test.main();
|
||||||
hotkeys_test.main();
|
hotkeys_test.main();
|
||||||
emoji_shortcut_test.main();
|
|
||||||
share_markdown_test.main();
|
share_markdown_test.main();
|
||||||
import_files_test.main();
|
import_files_test.main();
|
||||||
zoom_in_out_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/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_plugins/shortcuts/numbered_list_block_shortcuts.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_style.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_command.dart';
|
||||||
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
|
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
@ -82,5 +83,9 @@ List<CharacterShortcutEvent> buildCharacterShortcutEvents(
|
|||||||
documentBloc.documentId,
|
documentBloc.documentId,
|
||||||
styleCustomizer.inlineActionsMenuStyleBuilder(),
|
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