2025-03-26 13:24:16 +08:00
|
|
|
import 'dart:math';
|
|
|
|
|
|
|
|
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
|
2025-04-01 09:33:54 +08:00
|
|
|
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
|
2025-03-26 13:24:16 +08:00
|
|
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
2025-04-01 09:33:54 +08:00
|
|
|
import 'package:flowy_infra/size.dart';
|
2025-03-26 13:24:16 +08:00
|
|
|
|
|
|
|
import 'package:flowy_infra_ui/style_widget/button.dart';
|
|
|
|
import 'package:flowy_infra_ui/style_widget/text.dart';
|
2025-04-01 09:33:54 +08:00
|
|
|
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
2025-03-26 13:24:16 +08:00
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'package:flutter_emoji_mart/flutter_emoji_mart.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,
|
2025-04-02 14:15:14 +08:00
|
|
|
this.initialSearchText = '',
|
2025-03-26 13:24:16 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
final EditorState editorState;
|
|
|
|
final EmojiMenuService menuService;
|
|
|
|
final VoidCallback onDismiss;
|
|
|
|
final VoidCallback onSelectionUpdate;
|
|
|
|
final SelectEmojiItemHandler onEmojiSelect;
|
|
|
|
final int startCharAmount;
|
2025-04-02 14:15:14 +08:00
|
|
|
final String initialSearchText;
|
2025-03-26 13:24:16 +08:00
|
|
|
final bool Function()? cancelBySpaceHandler;
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<EmojiHandler> createState() => _EmojiHandlerState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _EmojiHandlerState extends State<EmojiHandler> {
|
2025-04-01 09:33:54 +08:00
|
|
|
final focusNode = FocusNode(debugLabel: 'emoji_menu_handler');
|
|
|
|
final scrollController = ScrollController();
|
2025-03-26 13:24:16 +08:00
|
|
|
late EmojiData emojiData;
|
|
|
|
final List<Emoji> searchedEmojis = [];
|
|
|
|
bool loaded = false;
|
|
|
|
int invalidCounter = 0;
|
|
|
|
late int startOffset;
|
2025-04-02 14:15:14 +08:00
|
|
|
late String _search = widget.initialSearchText;
|
2025-04-01 09:33:54 +08:00
|
|
|
double emojiHeight = 36.0;
|
|
|
|
final configuration = EmojiPickerConfiguration(
|
|
|
|
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
|
|
|
|
);
|
2025-03-26 13:24:16 +08:00
|
|
|
|
|
|
|
set search(String search) {
|
|
|
|
_search = search;
|
|
|
|
_doSearch();
|
|
|
|
}
|
|
|
|
|
2025-04-01 09:33:54 +08:00
|
|
|
final ValueNotifier<int> selectedIndexNotifier = ValueNotifier(0);
|
2025-03-26 13:24:16 +08:00
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
2025-04-01 09:33:54 +08:00
|
|
|
(_) => focusNode.requestFocus(),
|
2025-03-26 13:24:16 +08:00
|
|
|
);
|
|
|
|
|
2025-04-02 14:15:14 +08:00
|
|
|
startOffset = (widget.editorState.selection?.endIndex ?? 0) - 1;
|
2025-03-26 13:24:16 +08:00
|
|
|
|
|
|
|
if (kCachedEmojiData != null) {
|
|
|
|
loadEmojis(kCachedEmojiData!);
|
|
|
|
} else {
|
|
|
|
EmojiData.builtIn().then(
|
|
|
|
(value) {
|
|
|
|
kCachedEmojiData = value;
|
|
|
|
loadEmojis(value);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
2025-04-01 09:33:54 +08:00
|
|
|
focusNode.dispose();
|
|
|
|
selectedIndexNotifier.dispose();
|
|
|
|
scrollController.dispose();
|
2025-03-26 13:24:16 +08:00
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final noEmojis = searchedEmojis.isEmpty;
|
|
|
|
return Focus(
|
2025-04-01 09:33:54 +08:00
|
|
|
focusNode: focusNode,
|
2025-03-26 13:24:16 +08:00
|
|
|
onKeyEvent: onKeyEvent,
|
|
|
|
child: Container(
|
2025-04-01 09:33:54 +08:00
|
|
|
constraints: const BoxConstraints(maxHeight: 392, maxWidth: 360),
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
2025-03-26 13:24:16 +08:00
|
|
|
decoration: BoxDecoration(
|
|
|
|
borderRadius: BorderRadius.circular(6.0),
|
|
|
|
color: Theme.of(context).cardColor,
|
|
|
|
boxShadow: [
|
|
|
|
BoxShadow(
|
|
|
|
blurRadius: 5,
|
|
|
|
spreadRadius: 1,
|
|
|
|
color: Colors.black.withAlpha(25),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
2025-04-01 09:33:54 +08:00
|
|
|
child: noEmojis ? buildLoading() : buildEmojis(),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget buildLoading() {
|
|
|
|
return SizedBox(
|
|
|
|
width: 400,
|
|
|
|
height: 40,
|
|
|
|
child: Center(
|
|
|
|
child: SizedBox.square(
|
|
|
|
dimension: 20,
|
|
|
|
child: CircularProgressIndicator(),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget buildEmojis() {
|
|
|
|
return SizedBox(
|
|
|
|
height:
|
|
|
|
(searchedEmojis.length / configuration.perLine).ceil() * emojiHeight,
|
|
|
|
child: GridView.builder(
|
|
|
|
controller: scrollController,
|
|
|
|
itemCount: searchedEmojis.length,
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
|
|
crossAxisCount: configuration.perLine,
|
|
|
|
),
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
final currentEmoji = searchedEmojis[index];
|
|
|
|
final emojiId = currentEmoji.id;
|
|
|
|
final emoji = emojiData.getEmojiById(
|
|
|
|
emojiId,
|
|
|
|
skinTone: configuration.defaultSkinTone,
|
|
|
|
);
|
|
|
|
return ValueListenableBuilder(
|
|
|
|
valueListenable: selectedIndexNotifier,
|
|
|
|
builder: (context, value, child) {
|
|
|
|
final isSelected = value == index;
|
|
|
|
return SizedBox.square(
|
|
|
|
dimension: emojiHeight,
|
|
|
|
child: FlowyButton(
|
|
|
|
isSelected: isSelected,
|
|
|
|
margin: EdgeInsets.zero,
|
|
|
|
radius: Corners.s8Border,
|
|
|
|
text: ManualTooltip(
|
|
|
|
key: ValueKey('$emojiId-$isSelected'),
|
|
|
|
message: currentEmoji.name,
|
|
|
|
showAutomaticlly: isSelected,
|
|
|
|
preferBelow: false,
|
|
|
|
child: FlowyText.emoji(
|
|
|
|
emoji,
|
|
|
|
fontSize: configuration.emojiSize,
|
|
|
|
),
|
2025-03-26 13:24:16 +08:00
|
|
|
),
|
2025-04-01 09:33:54 +08:00
|
|
|
onTap: () => onSelect(index),
|
2025-03-26 13:24:16 +08:00
|
|
|
),
|
2025-04-01 09:33:54 +08:00
|
|
|
);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
},
|
2025-03-26 13:24:16 +08:00
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2025-04-01 09:33:54 +08:00
|
|
|
void changeSelectedIndex(int index) => selectedIndexNotifier.value = index;
|
2025-03-26 13:24:16 +08:00
|
|
|
|
|
|
|
void loadEmojis(EmojiData data) {
|
|
|
|
emojiData = data;
|
|
|
|
searchedEmojis.clear();
|
|
|
|
searchedEmojis.addAll(emojiData.emojis.values);
|
|
|
|
if (mounted) {
|
|
|
|
setState(() {
|
|
|
|
loaded = true;
|
|
|
|
});
|
|
|
|
}
|
2025-04-02 14:15:14 +08:00
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
_doSearch();
|
|
|
|
});
|
2025-03-26 13:24:16 +08:00
|
|
|
}
|
|
|
|
|
2025-04-02 14:15:14 +08:00
|
|
|
void _doSearch() {
|
|
|
|
if (!loaded || !mounted) return;
|
|
|
|
if (_search.startsWith(' ') || _search.isEmpty) {
|
2025-03-26 13:24:16 +08:00
|
|
|
widget.onDismiss.call();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
final searchEmojiData = emojiData.filterByKeyword(_search);
|
|
|
|
setState(() {
|
|
|
|
searchedEmojis.clear();
|
|
|
|
searchedEmojis.addAll(searchEmojiData.emojis.values);
|
|
|
|
changeSelectedIndex(0);
|
2025-04-01 09:33:54 +08:00
|
|
|
_scrollToItem();
|
2025-03-26 13:24:16 +08:00
|
|
|
});
|
|
|
|
if (searchedEmojis.isEmpty) {
|
|
|
|
widget.onDismiss.call();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
KeyEventResult onKeyEvent(focus, KeyEvent event) {
|
2025-04-01 09:33:54 +08:00
|
|
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
2025-03-26 13:24:16 +08:00
|
|
|
return KeyEventResult.ignored;
|
|
|
|
}
|
|
|
|
|
|
|
|
const moveKeys = [
|
|
|
|
LogicalKeyboardKey.arrowUp,
|
|
|
|
LogicalKeyboardKey.arrowDown,
|
2025-04-01 09:33:54 +08:00
|
|
|
LogicalKeyboardKey.arrowLeft,
|
|
|
|
LogicalKeyboardKey.arrowRight,
|
2025-03-26 13:24:16 +08:00
|
|
|
];
|
|
|
|
|
|
|
|
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
2025-04-01 09:33:54 +08:00
|
|
|
onSelect(selectedIndexNotifier.value);
|
2025-03-26 13:24:16 +08:00
|
|
|
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 &&
|
2025-04-01 09:33:54 +08:00
|
|
|
!moveKeys.contains(event.logicalKey)) {
|
2025-03-26 13:24:16 +08:00
|
|
|
/// 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;
|
|
|
|
}
|
|
|
|
|
|
|
|
return KeyEventResult.handled;
|
|
|
|
}
|
|
|
|
|
|
|
|
void onSelect(int index) {
|
|
|
|
widget.onEmojiSelect.call(
|
|
|
|
context,
|
2025-04-02 14:15:14 +08:00
|
|
|
(startOffset - widget.startCharAmount, startOffset + _search.length),
|
2025-03-26 13:24:16 +08:00
|
|
|
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),
|
2025-04-02 14:15:14 +08:00
|
|
|
end: selection.start.copyWith(offset: startOffset + _search.length + 1),
|
2025-03-26 13:24:16 +08:00
|
|
|
),
|
|
|
|
)
|
|
|
|
.join();
|
|
|
|
}
|
|
|
|
|
|
|
|
void _moveSelection(LogicalKeyboardKey key) {
|
2025-04-01 09:33:54 +08:00
|
|
|
final index = selectedIndexNotifier.value,
|
|
|
|
perLine = configuration.perLine,
|
|
|
|
remainder = index % perLine,
|
|
|
|
length = searchedEmojis.length,
|
|
|
|
currentLine = index ~/ perLine,
|
|
|
|
maxLine = (length / perLine).ceil();
|
|
|
|
|
|
|
|
final heightBefore = currentLine * emojiHeight;
|
|
|
|
if (key == LogicalKeyboardKey.arrowUp) {
|
|
|
|
if (currentLine == 0) {
|
|
|
|
final exceptLine = max(0, maxLine - 1);
|
|
|
|
changeSelectedIndex(min(exceptLine * perLine + remainder, length - 1));
|
|
|
|
} else if (currentLine > 0) {
|
|
|
|
changeSelectedIndex(index - perLine);
|
|
|
|
}
|
|
|
|
} else if (key == LogicalKeyboardKey.arrowDown) {
|
|
|
|
if (currentLine == maxLine - 1) {
|
|
|
|
changeSelectedIndex(remainder);
|
|
|
|
} else if (currentLine < maxLine - 1) {
|
|
|
|
changeSelectedIndex(min(index + perLine, length - 1));
|
|
|
|
}
|
|
|
|
} else if (key == LogicalKeyboardKey.arrowLeft) {
|
2025-03-26 13:24:16 +08:00
|
|
|
if (index == 0) {
|
2025-04-01 09:33:54 +08:00
|
|
|
changeSelectedIndex(length - 1);
|
2025-03-26 13:24:16 +08:00
|
|
|
} else if (index > 0) {
|
|
|
|
changeSelectedIndex(index - 1);
|
|
|
|
}
|
2025-04-01 09:33:54 +08:00
|
|
|
} else if (key == LogicalKeyboardKey.arrowRight) {
|
|
|
|
if (index == length - 1) {
|
2025-03-26 13:24:16 +08:00
|
|
|
changeSelectedIndex(0);
|
2025-04-01 09:33:54 +08:00
|
|
|
} else if (index < length - 1) {
|
|
|
|
changeSelectedIndex(index + 1);
|
2025-03-26 13:24:16 +08:00
|
|
|
}
|
|
|
|
}
|
2025-04-01 09:33:54 +08:00
|
|
|
final heightAfter =
|
|
|
|
(selectedIndexNotifier.value ~/ configuration.perLine) * emojiHeight;
|
2025-03-26 13:24:16 +08:00
|
|
|
|
2025-04-01 09:33:54 +08:00
|
|
|
if (mounted && (heightAfter != heightBefore)) {
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
_scrollToItem();
|
|
|
|
});
|
2025-03-26 13:24:16 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void _scrollToItem() {
|
|
|
|
final noEmojis = searchedEmojis.isEmpty;
|
2025-04-01 09:33:54 +08:00
|
|
|
if (noEmojis || !mounted) return;
|
|
|
|
final currentItem = selectedIndexNotifier.value;
|
|
|
|
final exceptHeight = (currentItem ~/ configuration.perLine) * emojiHeight;
|
|
|
|
final maxExtent = scrollController.position.maxScrollExtent;
|
|
|
|
final jumpTo = (exceptHeight - maxExtent > 10 * emojiHeight)
|
|
|
|
? exceptHeight
|
|
|
|
: min(exceptHeight, maxExtent);
|
|
|
|
scrollController.animateTo(
|
|
|
|
jumpTo,
|
|
|
|
duration: Duration(milliseconds: 300),
|
|
|
|
curve: Curves.linear,
|
2025-03-26 13:24:16 +08:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
2025-04-02 14:15:14 +08:00
|
|
|
startOffset + _search.length - 1,
|
2025-03-26 13:24:16 +08:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
|
|
|
);
|