feat: support slash menu on mobile (#7368)

* feat: support slash menu on mobile

* feat: support at menu on mobile

* feat: support plus menu on mobile
This commit is contained in:
Morn 2025-02-13 12:45:56 +08:00 committed by GitHub
parent b75fd673cd
commit 9e98680861
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1623 additions and 69 deletions

View File

@ -0,0 +1,41 @@
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart';
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const title = 'Test At Menu';
group('at menu', () {
testWidgets('show at menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowAtMenu(title);
final menuWidget = find.byType(MobileInlineActionsMenu);
expect(menuWidget, findsOneWidget);
});
testWidgets('search by at menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowAtMenu(title);
const searchText = gettingStarted;
await tester.ime.insertText(searchText);
final actionWidgets = find.byType(MobileInlineActionsWidget);
expect(actionWidgets, findsNWidgets(2));
});
testWidgets('tap at menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowAtMenu(title);
const searchText = gettingStarted;
await tester.ime.insertText(searchText);
final actionWidgets = find.byType(MobileInlineActionsWidget);
await tester.tap(actionWidgets.last);
expect(find.byType(MentionPageBlock), findsOneWidget);
});
});
}

View File

@ -1,8 +1,11 @@
import 'package:integration_test/integration_test.dart';
import 'at_menu_test.dart' as at_menu;
import 'at_menu_test.dart' as at_menu_test;
import 'page_style_test.dart' as page_style_test;
import 'plus_menu_test.dart' as plus_menu_test;
import 'simple_table_test.dart' as simple_table_test;
import 'slash_menu_test.dart' as slash_menu;
import 'title_test.dart' as title_test;
import 'toolbar_test.dart' as toolbar_test;
@ -13,6 +16,9 @@ void main() {
title_test.main();
page_style_test.main();
plus_menu_test.main();
at_menu_test.main();
simple_table_test.main();
toolbar_test.main();
slash_menu.main();
at_menu.main();
}

View File

@ -1,6 +1,9 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart';
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
@ -85,5 +88,32 @@ void main() {
equals(Selection.collapsed(Position(path: [2]))),
);
});
const title = 'Test Plus Menu';
testWidgets('show plus menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowPlusMenu(title);
final menuWidget = find.byType(MobileInlineActionsMenu);
expect(menuWidget, findsOneWidget);
});
testWidgets('search by plus menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowPlusMenu(title);
const searchText = gettingStarted;
await tester.ime.insertText(searchText);
final actionWidgets = find.byType(MobileInlineActionsWidget);
expect(actionWidgets, findsNWidgets(2));
});
testWidgets('tap plus menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowPlusMenu(title);
const searchText = gettingStarted;
await tester.ime.insertText(searchText);
final actionWidgets = find.byType(MobileInlineActionsWidget);
await tester.tap(actionWidgets.last);
expect(find.byType(MentionPageBlock), findsOneWidget);
});
});
}

View File

@ -0,0 +1,74 @@
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart';
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const title = 'Test Slash Menu';
group('slash menu', () {
testWidgets('show slash menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowSlashMenu(title);
final menuWidget = find.byType(MobileSelectionMenuWidget);
expect(menuWidget, findsOneWidget);
final items =
(menuWidget.evaluate().first.widget as MobileSelectionMenuWidget)
.items;
int i = 0;
for (final item in items) {
final localItem = mobileItems[i];
expect(item.name, localItem.name);
i++;
}
});
testWidgets('search by slash menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowSlashMenu(title);
const searchText = 'Heading';
await tester.ime.insertText(searchText);
final itemWidgets = find.byType(MobileSelectionMenuItemWidget);
int number = 0;
for (final mobileItem in mobileItems) {
for (final item in mobileItem.children) {
if (item.name.toLowerCase().contains(searchText.toLowerCase())) {
number++;
}
}
}
expect(itemWidgets, findsNWidgets(number));
});
testWidgets('tap to show submenu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile(title);
await tester.editor.tapLineOfEditorAt(0);
final listview = find.descendant(
of: find.byType(MobileSelectionMenuWidget),
matching: find.byType(ListView),
);
for (final item in mobileItems) {
await tester.editor.showSlashMenu();
await tester.scrollUntilVisible(
find.text(item.name),
50,
scrollable: listview,
duration: const Duration(milliseconds: 250),
);
await tester.tap(find.text(item.name));
final childrenLength = ((listview.evaluate().first.widget as ListView)
.childrenDelegate as SliverChildListDelegate)
.children
.length;
expect(childrenLength, item.children.length);
}
});
});
}

View File

@ -974,6 +974,27 @@ extension CommonOperations on WidgetTester {
..writeAsBytesSync(imagePath.buffer.asUint8List());
return EmojiIconData.custom(imageFile.path);
}
/// create new page and show slash menu
Future<void> createPageAndShowSlashMenu(String title) async {
await createNewDocumentOnMobile(title);
await editor.tapLineOfEditorAt(0);
await editor.showSlashMenu();
}
/// create new page and show at menu
Future<void> createPageAndShowAtMenu(String title) async {
await createNewDocumentOnMobile(title);
await editor.tapLineOfEditorAt(0);
await editor.showAtMenu();
}
/// create new page and show plus menu
Future<void> createPageAndShowPlusMenu(String title) async {
await createNewDocumentOnMobile(title);
await editor.tapLineOfEditorAt(0);
await editor.showPlusMenu();
}
}
extension SettingsFinder on CommonFinders {

View File

@ -0,0 +1,253 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'mobile_inline_actions_menu_group.dart';
extension _StartWithsSort on List<InlineActionsResult> {
void sortByStartsWithKeyword(String search) => sort(
(a, b) {
final aCount = a.startsWithKeywords
?.where(
(key) => search.toLowerCase().startsWith(key),
)
.length ??
0;
final bCount = b.startsWithKeywords
?.where(
(key) => search.toLowerCase().startsWith(key),
)
.length ??
0;
if (aCount > bCount) {
return -1;
} else if (bCount > aCount) {
return 1;
}
return 0;
},
);
}
const _invalidSearchesAmount = 10;
class MobileInlineActionsHandler extends StatefulWidget {
const MobileInlineActionsHandler({
super.key,
required this.results,
required this.editorState,
required this.menuService,
required this.onDismiss,
required this.style,
required this.service,
this.startCharAmount = 1,
this.startOffset = 0,
this.cancelBySpaceHandler,
});
final List<InlineActionsResult> results;
final EditorState editorState;
final InlineActionsMenuService menuService;
final VoidCallback onDismiss;
final InlineActionsMenuStyle style;
final int startCharAmount;
final InlineActionsService service;
final bool Function()? cancelBySpaceHandler;
final int startOffset;
@override
State<MobileInlineActionsHandler> createState() =>
_MobileInlineActionsHandlerState();
}
class _MobileInlineActionsHandlerState
extends State<MobileInlineActionsHandler> {
final _focusNode =
FocusNode(debugLabel: 'mobile_inline_actions_menu_handler');
late List<InlineActionsResult> results = widget.results;
int invalidCounter = 0;
late int startOffset;
String _search = '';
set search(String search) {
_search = search;
_doSearch();
}
Future<void> _doSearch() async {
final List<InlineActionsResult> newResults = [];
for (final handler in widget.service.handlers) {
final group = await handler.search(_search);
if (group.results.isNotEmpty) {
newResults.add(group);
}
}
invalidCounter = results.every((group) => group.results.isEmpty)
? invalidCounter + 1
: 0;
if (invalidCounter >= _invalidSearchesAmount) {
widget.onDismiss();
// Workaround to bring focus back to editor
await editorState.updateSelectionWithReason(editorState.selection);
return;
}
_resetSelection();
newResults.sortByStartsWithKeyword(_search);
setState(() => results = newResults);
}
void _resetSelection() {
_selectedGroup = 0;
_selectedIndex = 0;
}
int _selectedGroup = 0;
int _selectedIndex = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => _focusNode.requestFocus(),
);
startOffset = editorState.selection?.endIndex ?? 0;
keepEditorFocusNotifier.increase();
editorState.selectionNotifier.addListener(onSelectionChanged);
}
@override
void dispose() {
editorState.selectionNotifier.removeListener(onSelectionChanged);
_focusNode.dispose();
keepEditorFocusNotifier.decrease();
super.dispose();
}
@override
Widget build(BuildContext context) {
final width = editorState.renderBox!.size.width - 24 * 2;
return Focus(
focusNode: _focusNode,
child: Container(
constraints: BoxConstraints(
maxHeight: 192,
minWidth: width,
maxWidth: width,
),
margin: EdgeInsets.symmetric(horizontal: 24.0),
decoration: BoxDecoration(
color: widget.style.backgroundColor,
borderRadius: BorderRadius.circular(6.0),
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withValues(alpha: 0.1),
),
],
),
child: noResults
? SizedBox(
width: 150,
child: FlowyText.regular(
LocaleKeys.inlineActions_noResults.tr(),
),
)
: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Material(
color: Colors.transparent,
child: Padding(
padding: EdgeInsets.all(6.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: results
.where((g) => g.results.isNotEmpty)
.mapIndexed(
(index, group) => MobileInlineActionsGroup(
result: group,
editorState: editorState,
menuService: menuService,
style: widget.style,
onSelected: widget.onDismiss,
startOffset: startOffset - widget.startCharAmount,
endOffset:
_search.length + widget.startCharAmount,
isLastGroup: index == results.length - 1,
isGroupSelected: _selectedGroup == index,
selectedIndex: _selectedIndex,
onPreSelect: (int value) {
setState(() {
_selectedGroup = index;
_selectedIndex = value;
});
},
),
)
.toList(),
),
),
),
),
),
);
}
bool get noResults =>
results.isEmpty || results.every((e) => e.results.isEmpty);
int get groupLength => results.length;
int lengthOfGroup(int index) =>
results.length > index ? results[index].results.length : -1;
InlineActionsMenuItem handlerOf(int groupIndex, int handlerIndex) =>
results[groupIndex].results[handlerIndex];
EditorState get editorState => widget.editorState;
InlineActionsMenuService get menuService => widget.menuService;
void onSelectionChanged() {
final selection = editorState.selection;
if (selection == null) {
menuService.dismiss();
return;
}
if (!selection.isCollapsed) {
menuService.dismiss();
return;
}
final startOffset = widget.startOffset;
final endOffset = selection.end.offset;
if (endOffset < startOffset) {
menuService.dismiss();
return;
}
final node = editorState.getNodeAtPath(selection.start.path);
final text = node?.delta?.toPlainText() ?? '';
final search = text.substring(startOffset, endOffset);
this.search = search;
}
}

View File

@ -0,0 +1,151 @@
import 'dart:async';
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'mobile_inline_actions_handler.dart';
class MobileInlineActionsMenu extends InlineActionsMenuService {
MobileInlineActionsMenu({
required this.context,
required this.editorState,
required this.initialResults,
required this.style,
required this.service,
this.startCharAmount = 1,
this.cancelBySpaceHandler,
});
final BuildContext context;
final EditorState editorState;
final List<InlineActionsResult> initialResults;
final bool Function()? cancelBySpaceHandler;
final InlineActionsService service;
@override
final InlineActionsMenuStyle style;
final int startCharAmount;
OverlayEntry? _menuEntry;
@override
void dismiss() {
if (_menuEntry != null) {
editorState.service.keyboardService?.enable();
editorState.service.scrollService?.enable();
}
_menuEntry?.remove();
_menuEntry = null;
}
@override
Future<void> show() {
final completer = Completer<void>();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_show();
completer.complete();
});
return completer.future;
}
void _show() {
final selectionRects = editorState.selectionRects();
if (selectionRects.isEmpty) {
return;
}
const double menuHeight = 192.0;
const Offset menuOffset = Offset(0, 10);
final Offset editorOffset =
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final Size editorSize = editorState.renderBox!.size;
// 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,
MediaQuery.of(context).size.height - offset.dy,
);
}
final (left, top, right, bottom) = _getPosition(alignment, offset);
_menuEntry = OverlayEntry(
builder: (context) => SizedBox(
width: editorSize.width,
height: editorSize.height,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: dismiss,
child: Stack(
children: [
Positioned(
top: top,
bottom: bottom,
left: left,
right: right,
child: MobileInlineActionsHandler(
service: service,
results: initialResults,
editorState: editorState,
menuService: this,
onDismiss: dismiss,
style: style,
startCharAmount: startCharAmount,
cancelBySpaceHandler: cancelBySpaceHandler,
startOffset: editorState.selection?.start.offset ?? 0,
),
),
],
),
),
),
);
Overlay.of(context).insert(_menuEntry!);
editorState.service.keyboardService?.disable(showCursor: true);
editorState.service.scrollService?.disable();
}
(double? left, double? top, double? right, double? bottom) _getPosition(
Alignment alignment,
Offset offset,
) {
double? left, top, right, bottom;
switch (alignment) {
case Alignment.topLeft:
left = 0;
top = offset.dy;
break;
case Alignment.bottomLeft:
left = 0;
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);
}
}

View File

@ -0,0 +1,150 @@
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
class MobileInlineActionsGroup extends StatelessWidget {
const MobileInlineActionsGroup({
super.key,
required this.result,
required this.editorState,
required this.menuService,
required this.style,
required this.onSelected,
required this.startOffset,
required this.endOffset,
required this.onPreSelect,
this.isLastGroup = false,
this.isGroupSelected = false,
this.selectedIndex = 0,
});
final InlineActionsResult result;
final EditorState editorState;
final InlineActionsMenuService menuService;
final InlineActionsMenuStyle style;
final VoidCallback onSelected;
final ValueChanged<int> onPreSelect;
final int startOffset;
final int endOffset;
final bool isLastGroup;
final bool isGroupSelected;
final int selectedIndex;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (result.title != null) ...[
SizedBox(
height: 36,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Align(
alignment: Alignment.centerLeft,
child: FlowyText.medium(
result.title!,
color: style.groupTextColor,
fontSize: 12,
),
),
),
),
],
...result.results.mapIndexed(
(index, item) => GestureDetector(
onTapDown: (e) {
onPreSelect.call(index);
},
child: MobileInlineActionsWidget(
item: item,
editorState: editorState,
menuService: menuService,
isSelected: isGroupSelected && index == selectedIndex,
style: style,
onSelected: onSelected,
startOffset: startOffset,
endOffset: endOffset,
),
),
),
],
);
}
}
class MobileInlineActionsWidget extends StatelessWidget {
const MobileInlineActionsWidget({
super.key,
required this.item,
required this.editorState,
required this.menuService,
required this.isSelected,
required this.style,
required this.onSelected,
required this.startOffset,
required this.endOffset,
});
final InlineActionsMenuItem item;
final EditorState editorState;
final InlineActionsMenuService menuService;
final bool isSelected;
final InlineActionsMenuStyle style;
final VoidCallback onSelected;
final int startOffset;
final int endOffset;
@override
Widget build(BuildContext context) {
final hasIcon = item.icon != null;
return Container(
height: 36,
decoration: BoxDecoration(
color: isSelected ? style.menuItemSelectedColor : null,
borderRadius: BorderRadius.circular(6.0),
),
child: FlowyButton(
expand: true,
isSelected: isSelected,
text: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Align(
alignment: Alignment.centerLeft,
child: Row(
children: [
if (hasIcon) ...[
item.icon!.call(isSelected),
SizedBox(width: 12),
],
FlowyText.regular(
item.label,
figmaLineHeight: 18,
overflow: TextOverflow.ellipsis,
fontSize: 16,
color: style.menuItemSelectedTextColor,
),
],
),
),
),
onTap: () => _onPressed(context),
),
);
}
void _onPressed(BuildContext context) {
onSelected();
item.onSelected?.call(
context,
editorState,
menuService,
(startOffset, endOffset),
);
}
}

View File

@ -0,0 +1,215 @@
import 'dart:async';
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'mobile_selection_menu_widget.dart';
class MobileSelectionMenu extends SelectionMenuService {
MobileSelectionMenu({
required this.context,
required this.editorState,
required this.selectionMenuItems,
this.deleteSlashByDefault = false,
this.deleteKeywordsByDefault = false,
this.style = SelectionMenuStyle.light,
this.itemCountFilter = 0,
this.startOffset = 0,
this.singleColumn = false,
});
final BuildContext context;
final EditorState editorState;
final List<SelectionMenuItem> selectionMenuItems;
final bool deleteSlashByDefault;
final bool deleteKeywordsByDefault;
final bool singleColumn;
@override
final SelectionMenuStyle style;
OverlayEntry? _selectionMenuEntry;
Offset _offset = Offset.zero;
Alignment _alignment = Alignment.topLeft;
final int itemCountFilter;
final int startOffset;
@override
void dismiss() {
if (_selectionMenuEntry != null) {
editorState.service.keyboardService?.enable();
editorState.service.scrollService?.enable();
}
_selectionMenuEntry?.remove();
_selectionMenuEntry = null;
}
@override
Future<void> show() async {
final completer = Completer<void>();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_show();
completer.complete();
});
return completer.future;
}
void _show() {
final selectionRects = editorState.selectionRects();
if (selectionRects.isEmpty) {
return;
}
calculateSelectionMenuOffset(selectionRects.first);
final (left, top, right, bottom) = getPosition();
final editorHeight = editorState.renderBox!.size.height;
final editorWidth = editorState.renderBox!.size.width;
_selectionMenuEntry = OverlayEntry(
builder: (context) {
return SizedBox(
width: editorWidth,
height: editorHeight,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: dismiss,
child: Stack(
children: [
Positioned(
top: top,
bottom: bottom,
left: left,
right: right,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: MobileSelectionMenuWidget(
selectionMenuStyle: style,
singleColumn: singleColumn,
items: selectionMenuItems
..forEach((element) {
if (element is MobileSelectionMenuItem) {
element.deleteSlash = false;
element.deleteKeywords = deleteKeywordsByDefault;
for (final e in element.children) {
e.deleteSlash = deleteSlashByDefault;
e.deleteKeywords = deleteKeywordsByDefault;
e.onSelected = () {
dismiss();
};
}
} else {
element.deleteSlash = deleteSlashByDefault;
element.deleteKeywords = deleteKeywordsByDefault;
element.onSelected = () {
dismiss();
};
}
}),
maxItemInRow: 5,
editorState: editorState,
itemCountFilter: itemCountFilter,
startOffset: startOffset,
menuService: this,
onExit: () {
dismiss();
},
deleteSlashByDefault: deleteSlashByDefault,
),
),
),
],
),
),
);
},
);
Overlay.of(context, rootOverlay: true).insert(_selectionMenuEntry!);
editorState.service.keyboardService?.disable(showCursor: true);
editorState.service.scrollService?.disable();
}
@override
Alignment get alignment {
return _alignment;
}
@override
Offset get offset {
return _offset;
}
@override
(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);
}
void calculateSelectionMenuOffset(Rect rect) {
// Workaround: We can customize the padding through the [EditorStyle],
// but the coordinates of overlay are not properly converted currently.
// Just subtract the padding here as a result.
const menuHeight = 192.0;
const menuOffset = Offset(0, 10);
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 offset = bottomRight + menuOffset;
_offset = Offset(
offset.dx,
offset.dy,
);
// show above
if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) {
offset = topRight - menuOffset;
_alignment = Alignment.bottomLeft;
_offset = Offset(
offset.dx,
MediaQuery.of(context).size.height - offset.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,
);
}
}
}

View File

@ -0,0 +1,18 @@
import 'package:appflowy_editor/appflowy_editor.dart';
class MobileSelectionMenuItem extends SelectionMenuItem {
MobileSelectionMenuItem({
required super.getName,
required super.icon,
super.keywords = const [],
required super.handler,
this.children = const [],
super.nameBuilder,
super.deleteKeywords,
super.deleteSlash,
});
final List<SelectionMenuItem> children;
bool get isNotEmpty => children.isNotEmpty;
}

View File

@ -0,0 +1,76 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'mobile_selection_menu_item.dart';
class MobileSelectionMenuItemWidget extends StatelessWidget {
const MobileSelectionMenuItemWidget({
super.key,
required this.editorState,
required this.menuService,
required this.item,
required this.isSelected,
required this.selectionMenuStyle,
});
final EditorState editorState;
final SelectionMenuService menuService;
final SelectionMenuItem item;
final bool isSelected;
final SelectionMenuStyle selectionMenuStyle;
@override
Widget build(BuildContext context) {
final style = selectionMenuStyle;
final showRightArrow = item is MobileSelectionMenuItem &&
(item as MobileSelectionMenuItem).isNotEmpty;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: TextButton.icon(
icon: item.icon(
editorState,
false,
selectionMenuStyle,
),
style: ButtonStyle(
alignment: Alignment.centerLeft,
overlayColor: WidgetStateProperty.all(
style.selectionMenuItemSelectedColor,
),
backgroundColor: isSelected
? WidgetStateProperty.all(
style.selectionMenuItemSelectedColor,
)
: WidgetStateProperty.all(Colors.transparent),
),
label: Row(
children: [
item.nameBuilder?.call(item.name, style, false) ??
Text(
item.name,
textAlign: TextAlign.left,
style: TextStyle(
color: style.selectionMenuItemTextColor,
fontSize: 16.0,
),
),
if (showRightArrow) ...[
Spacer(),
Icon(
Icons.keyboard_arrow_right_rounded,
color: Color(0xff1E2022).withValues(alpha: 0.3),
),
],
],
),
onPressed: () {
item.handler(
editorState,
menuService,
context,
);
},
),
);
}
}

View File

@ -0,0 +1,298 @@
import 'dart:math';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'mobile_selection_menu_item.dart';
import 'mobile_selection_menu_item_widget.dart';
class MobileSelectionMenuWidget extends StatefulWidget {
const MobileSelectionMenuWidget({
super.key,
required this.items,
required this.itemCountFilter,
required this.maxItemInRow,
required this.menuService,
required this.editorState,
required this.onExit,
required this.selectionMenuStyle,
required this.deleteSlashByDefault,
required this.singleColumn,
required this.startOffset,
this.nameBuilder,
});
final List<SelectionMenuItem> items;
final int itemCountFilter;
final int maxItemInRow;
final SelectionMenuService menuService;
final EditorState editorState;
final VoidCallback onExit;
final SelectionMenuStyle selectionMenuStyle;
final bool deleteSlashByDefault;
final bool singleColumn;
final int startOffset;
final SelectionMenuItemNameBuilder? nameBuilder;
@override
State<MobileSelectionMenuWidget> createState() =>
_MobileSelectionMenuWidgetState();
}
class _MobileSelectionMenuWidgetState extends State<MobileSelectionMenuWidget> {
final _focusNode = FocusNode(debugLabel: 'popup_list_widget');
List<SelectionMenuItem> _showingItems = [];
int _searchCounter = 0;
EditorState get editorState => widget.editorState;
SelectionMenuService get menuService => widget.menuService;
String _keyword = '';
String get keyword => _keyword;
int selectedIndex = 0;
List<SelectionMenuItem> get filterItems {
final List<SelectionMenuItem> items = [];
for (final item in widget.items) {
if (item is MobileSelectionMenuItem) {
for (final childItem in item.children) {
items.add(childItem);
}
} else {
items.add(item);
}
}
return items;
}
set keyword(String newKeyword) {
_keyword = newKeyword;
// Search items according to the keyword, and calculate the length of
// the longest keyword, which is used to dismiss the selection_service.
var maxKeywordLength = 0;
final items = newKeyword.isEmpty
? widget.items
: filterItems
.where(
(item) => item.allKeywords.any((keyword) {
final value = keyword.contains(newKeyword.toLowerCase());
if (value) {
maxKeywordLength = max(maxKeywordLength, keyword.length);
}
return value;
}),
)
.toList(growable: false);
AppFlowyEditorLog.ui.debug('$items');
if (keyword.length >= maxKeywordLength + 2 &&
!(widget.deleteSlashByDefault && _searchCounter < 2)) {
return widget.onExit();
}
setState(() {
selectedIndex = 0;
_showingItems = items;
});
if (_showingItems.isEmpty) {
_searchCounter++;
} else {
_searchCounter = 0;
}
}
@override
void initState() {
super.initState();
final List<SelectionMenuItem> items = [];
for (final item in widget.items) {
if (item is MobileSelectionMenuItem) {
item.onSelected = () {
if (mounted) {
setState(() {
_showingItems = item.children
.map((e) => e..onSelected = widget.onExit)
.toList();
});
}
};
}
items.add(item);
}
_showingItems = items;
keepEditorFocusNotifier.increase();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
editorState.selectionNotifier.addListener(onSelectionChanged);
}
@override
void dispose() {
editorState.selectionNotifier.removeListener(onSelectionChanged);
_focusNode.dispose();
keepEditorFocusNotifier.decrease();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _focusNode,
child: DecoratedBox(
decoration: BoxDecoration(
color: widget.selectionMenuStyle.selectionMenuBackgroundColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withValues(alpha: 0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: _showingItems.isEmpty
? _buildNoResultsWidget(context)
: _buildResultsWidget(
context,
_showingItems,
widget.itemCountFilter,
),
),
);
}
void onSelectionChanged() {
final selection = editorState.selection;
if (selection == null) {
widget.onExit();
return;
}
if (!selection.isCollapsed) {
widget.onExit();
return;
}
final startOffset = widget.startOffset;
final endOffset = selection.end.offset;
if (endOffset < startOffset) {
widget.onExit();
return;
}
final node = editorState.getNodeAtPath(selection.start.path);
final text = node?.delta?.toPlainText() ?? '';
final search = text.substring(startOffset, endOffset);
keyword = search;
}
Widget _buildResultsWidget(
BuildContext buildContext,
List<SelectionMenuItem> items,
int itemCountFilter,
) {
if (widget.singleColumn) {
final List<Widget> itemWidgets = [];
for (var i = 0; i < items.length; i++) {
itemWidgets.add(
GestureDetector(
onTapDown: (e) {
setState(() {
selectedIndex = i;
});
},
child: MobileSelectionMenuItemWidget(
item: items[i],
isSelected: i == selectedIndex,
editorState: editorState,
menuService: menuService,
selectionMenuStyle: widget.selectionMenuStyle,
),
),
);
}
return ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 192,
minWidth: 240,
maxWidth: 240,
),
child: ListView(
shrinkWrap: true,
padding: EdgeInsets.zero,
children: itemWidgets,
),
);
} else {
final List<Widget> columns = [];
List<Widget> itemWidgets = [];
// apply item count filter
if (itemCountFilter > 0) {
items = items.take(itemCountFilter).toList();
}
for (var i = 0; i < items.length; i++) {
if (i != 0 && i % (widget.maxItemInRow) == 0) {
columns.add(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: itemWidgets,
),
);
itemWidgets = [];
}
itemWidgets.add(
MobileSelectionMenuItemWidget(
item: items[i],
isSelected: false,
editorState: editorState,
menuService: menuService,
selectionMenuStyle: widget.selectionMenuStyle,
),
);
}
if (itemWidgets.isNotEmpty) {
columns.add(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: itemWidgets,
),
);
itemWidgets = [];
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: columns,
);
}
}
Widget _buildNoResultsWidget(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(8.0),
child: SizedBox(
width: 140,
child: Material(
child: Text(
"No results",
style: TextStyle(fontSize: 18.0, color: Colors.grey),
textAlign: TextAlign.center,
),
),
),
);
}
}

View File

@ -9,6 +9,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
InlineActionsMenuService? _actionsMenuService;
Future<void> showLinkToPageMenu(
EditorState editorState,
SelectionMenuService menuService, {
@ -60,7 +61,7 @@ Future<void> showLinkToPageMenu(
startCharAmount: 0,
);
_actionsMenuService?.show();
await _actionsMenuService?.show();
}
}

View File

@ -1,3 +1,4 @@
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart';
import 'package:appflowy/plugins/inline_actions/handlers/child_page.dart';
import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
@ -47,6 +48,7 @@ CharacterShortcutEvent pageReferenceShortcutPlusSign(
);
InlineActionsMenuService? selectionMenuService;
Future<bool> inlinePageReferenceCommandHandler(
String character,
BuildContext context,
@ -56,7 +58,7 @@ Future<bool> inlinePageReferenceCommandHandler(
String? previousChar,
}) async {
final selection = editorState.selection;
if (UniversalPlatform.isMobile || selection == null) {
if (selection == null) {
return false;
}
@ -110,32 +112,47 @@ Future<bool> inlinePageReferenceCommandHandler(
}
if (context.mounted) {
selectionMenuService = InlineActionsMenu(
context: service.context!,
editorState: editorState,
service: service,
initialResults: initialResults,
style: style,
startCharAmount: previousChar != null ? 2 : 1,
cancelBySpaceHandler: () {
if (character == _plusChar) {
final currentSelection = editorState.selection;
if (currentSelection == null) {
return false;
}
// check if the space is after the character
if (currentSelection.isCollapsed &&
currentSelection.start.offset ==
selection.start.offset + character.length) {
_cancelInlinePageReferenceMenu(editorState);
return true;
}
}
return false;
},
);
keepEditorFocusNotifier.increase();
selectionMenuService?.dismiss();
selectionMenuService = UniversalPlatform.isMobile
? MobileInlineActionsMenu(
context: service.context!,
editorState: editorState,
service: service,
initialResults: initialResults,
style: style,
)
: InlineActionsMenu(
context: service.context!,
editorState: editorState,
service: service,
initialResults: initialResults,
style: style,
startCharAmount: previousChar != null ? 2 : 1,
cancelBySpaceHandler: () {
if (character == _plusChar) {
final currentSelection = editorState.selection;
if (currentSelection == null) {
return false;
}
// check if the space is after the character
if (currentSelection.isCollapsed &&
currentSelection.start.offset ==
selection.start.offset + character.length) {
_cancelInlinePageReferenceMenu(editorState);
return true;
}
}
return false;
},
);
// disable the keyboard service
editorState.service.keyboardService?.disable();
selectionMenuService?.show();
await selectionMenuService?.show();
// enable the keyboard service
editorState.service.keyboardService?.enable();
}
return true;

View File

@ -1,8 +1,6 @@
import 'dart:io';
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
@ -50,6 +48,7 @@ CharacterShortcutEvent customAppFlowySlashCommand({
}
SelectionMenuService? _selectionMenuService;
Future<bool> _showSlashMenu(
EditorState editorState, {
required SlashMenuItemsBuilder itemsBuilder,
@ -59,10 +58,6 @@ Future<bool> _showSlashMenu(
SelectionMenuStyle style = SelectionMenuStyle.light,
required Set<String> supportSlashMenuNodeTypes,
}) async {
if (UniversalPlatform.isMobile) {
return false;
}
final selection = editorState.selection;
if (selection == null) {
return false;
@ -99,25 +94,32 @@ Future<bool> _showSlashMenu(
final context = editorState.getNodeAtPath(selection.start.path)?.context;
if (context != null && context.mounted) {
_selectionMenuService = SelectionMenu(
context: context,
editorState: editorState,
selectionMenuItems: items,
deleteSlashByDefault: shouldInsertSlash,
deleteKeywordsByDefault: deleteKeywordsByDefault,
singleColumn: singleColumn,
style: style,
);
_selectionMenuService?.dismiss();
_selectionMenuService = UniversalPlatform.isMobile
? MobileSelectionMenu(
context: context,
editorState: editorState,
selectionMenuItems: items,
deleteSlashByDefault: shouldInsertSlash,
deleteKeywordsByDefault: deleteKeywordsByDefault,
singleColumn: singleColumn,
style: style,
startOffset: editorState.selection?.start.offset ?? 0,
)
: SelectionMenu(
context: context,
editorState: editorState,
selectionMenuItems: items,
deleteSlashByDefault: shouldInsertSlash,
deleteKeywordsByDefault: deleteKeywordsByDefault,
singleColumn: singleColumn,
style: style,
);
// disable the keyboard service
editorState.service.keyboardService?.disable();
if (!kIsWeb && Platform.environment.containsKey('FLUTTER_TEST')) {
await _selectionMenuService?.show();
} else {
await _selectionMenuService?.show();
}
await _selectionMenuService?.show();
// enable the keyboard service
editorState.service.keyboardService?.enable();
}

View File

@ -0,0 +1,156 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.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 'slash_menu_items.dart';
final List<MobileSelectionMenuItem> mobileItems = [
textStyleMobileSlashMenuItem,
listMobileSlashMenuItem,
toggleListMobileSlashMenuItem,
fileOrMediaMobileSlashMenuItem,
decorationsMobileSlashMenuItem,
tableMobileSlashMenuItem,
dateOrReminderMobileSlashMenuItem,
advancedMobileSlashMenuItem,
];
final List<MobileSelectionMenuItem> mobileItemsInTale = [
textStyleMobileSlashMenuItem,
listMobileSlashMenuItem,
toggleListMobileSlashMenuItem,
fileOrMediaMobileSlashMenuItem,
decorationsMobileSlashMenuItem,
dateOrReminderMobileSlashMenuItem,
advancedMobileSlashMenuItem,
];
SelectionMenuItemHandler _handler = (_, __, ___) {};
MobileSelectionMenuItem textStyleMobileSlashMenuItem = MobileSelectionMenuItem(
getName: LocaleKeys.document_slashMenu_name_textStyle.tr,
handler: _handler,
icon: (_, isSelected, style) => SelectableSvgWidget(
data: FlowySvgs.slash_menu_icon_text_s,
isSelected: isSelected,
style: style,
),
nameBuilder: slashMenuItemNameBuilder,
children: [
paragraphSlashMenuItem,
heading1SlashMenuItem,
heading2SlashMenuItem,
heading3SlashMenuItem,
],
);
MobileSelectionMenuItem listMobileSlashMenuItem = MobileSelectionMenuItem(
getName: LocaleKeys.document_slashMenu_name_list.tr,
handler: _handler,
icon: (_, isSelected, style) => SelectableSvgWidget(
data: FlowySvgs.slash_menu_icon_bulleted_list_s,
isSelected: isSelected,
style: style,
),
nameBuilder: slashMenuItemNameBuilder,
children: [
todoListSlashMenuItem,
bulletedListSlashMenuItem,
numberedListSlashMenuItem,
],
);
MobileSelectionMenuItem toggleListMobileSlashMenuItem = MobileSelectionMenuItem(
getName: LocaleKeys.document_slashMenu_name_toggleList.tr,
handler: _handler,
icon: (_, isSelected, style) => SelectableSvgWidget(
data: FlowySvgs.slash_menu_icon_toggle_s,
isSelected: isSelected,
style: style,
),
nameBuilder: slashMenuItemNameBuilder,
children: [
toggleListSlashMenuItem,
toggleHeading1SlashMenuItem,
toggleHeading2SlashMenuItem,
toggleHeading3SlashMenuItem,
],
);
MobileSelectionMenuItem fileOrMediaMobileSlashMenuItem =
MobileSelectionMenuItem(
getName: LocaleKeys.document_slashMenu_name_fileOrMedia.tr,
handler: _handler,
icon: (_, isSelected, style) => SelectableSvgWidget(
data: FlowySvgs.slash_menu_icon_file_s,
isSelected: isSelected,
style: style,
),
nameBuilder: slashMenuItemNameBuilder,
children: [
imageSlashMenuItem,
photoGallerySlashMenuItem,
fileSlashMenuItem,
],
);
MobileSelectionMenuItem decorationsMobileSlashMenuItem =
MobileSelectionMenuItem(
getName: LocaleKeys.document_slashMenu_name_decorations.tr,
handler: _handler,
icon: (_, isSelected, style) => SelectableSvgWidget(
data: FlowySvgs.slash_menu_icon_simple_table_s,
isSelected: isSelected,
style: style,
),
nameBuilder: slashMenuItemNameBuilder,
children: [
quoteSlashMenuItem,
dividerSlashMenuItem,
],
);
MobileSelectionMenuItem tableMobileSlashMenuItem = MobileSelectionMenuItem(
getName: LocaleKeys.document_slashMenu_name_table.tr,
handler: _handler,
icon: (_, isSelected, style) => SelectableSvgWidget(
data: FlowySvgs.slash_menu_icon_simple_table_s,
isSelected: isSelected,
style: style,
),
nameBuilder: slashMenuItemNameBuilder,
children: [tableSlashMenuItem],
);
MobileSelectionMenuItem dateOrReminderMobileSlashMenuItem =
MobileSelectionMenuItem(
getName: LocaleKeys.document_slashMenu_name_dateOrReminder.tr,
handler: _handler,
icon: (_, isSelected, style) => SelectableSvgWidget(
data: FlowySvgs.slash_menu_icon_date_or_reminder_s,
isSelected: isSelected,
style: style,
),
nameBuilder: slashMenuItemNameBuilder,
children: [dateOrReminderSlashMenuItem],
);
MobileSelectionMenuItem advancedMobileSlashMenuItem = MobileSelectionMenuItem(
getName: LocaleKeys.document_slashMenu_name_advanced.tr,
handler: _handler,
icon: (_, isSelected, style) => SelectableSvgWidget(
data: FlowySvgs.m_aa_font_color_m,
isSelected: isSelected,
style: style,
),
nameBuilder: slashMenuItemNameBuilder,
children: [
subPageSlashMenuItem,
calloutSlashMenuItem,
codeBlockSlashMenuItem,
mathEquationSlashMenuItem,
],
);

View File

@ -3,6 +3,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selec
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
/// Builder function for the slash menu item.
Widget slashMenuItemNameBuilder(
@ -49,9 +50,10 @@ class SlashMenuItemNameBuilder extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isMobile = UniversalPlatform.isMobile;
return FlowyText.regular(
name,
fontSize: 12.0,
fontSize: isMobile ? 16.0 : 12.0,
figmaLineHeight: 15.0,
color: isSelected
? style.selectionMenuItemSelectedTextColor
@ -80,9 +82,11 @@ class SlashMenuIconBuilder extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isMobile = UniversalPlatform.isMobile;
return SelectableSvgWidget(
data: data,
isSelected: isSelected,
size: isMobile ? Size.square(20) : null,
style: style,
);
}

View File

@ -1,7 +1,9 @@
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:universal_platform/universal_platform.dart';
import 'slash_menu_items/mobile_items.dart';
import 'slash_menu_items/slash_menu_items.dart';
/// Build slash menu items
@ -13,14 +15,22 @@ List<SelectionMenuItem> slashMenuItemsBuilder({
Node? node,
}) {
final isInTable = node != null && node.parentTableCellNode != null;
if (isInTable) {
return _simpleTableSlashMenuItems();
final isMobile = UniversalPlatform.isMobile;
if (isMobile) {
if (isInTable) {
return mobileItemsInTale;
} else {
return mobileItems;
}
} else {
return _defaultSlashMenuItems(
isLocalMode: isLocalMode,
documentBloc: documentBloc,
);
if (isInTable) {
return _simpleTableSlashMenuItems();
} else {
return _defaultSlashMenuItems(
isLocalMode: isLocalMode,
documentBloc: documentBloc,
);
}
}
}

View File

@ -1,3 +1,4 @@
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
@ -21,13 +22,14 @@ CharacterShortcutEvent inlineActionsCommand(
);
InlineActionsMenuService? selectionMenuService;
Future<bool> inlineActionsCommandHandler(
EditorState editorState,
InlineActionsService service,
InlineActionsMenuStyle style,
) async {
final selection = editorState.selection;
if (UniversalPlatform.isMobile || selection == null) {
if (selection == null) {
return false;
}
@ -50,15 +52,31 @@ Future<bool> inlineActionsCommandHandler(
}
if (service.context != null) {
selectionMenuService = InlineActionsMenu(
context: service.context!,
editorState: editorState,
service: service,
initialResults: initialResults,
style: style,
);
keepEditorFocusNotifier.increase();
selectionMenuService?.dismiss();
selectionMenuService = UniversalPlatform.isMobile
? MobileInlineActionsMenu(
context: service.context!,
editorState: editorState,
service: service,
initialResults: initialResults,
style: style,
)
: InlineActionsMenu(
context: service.context!,
editorState: editorState,
service: service,
initialResults: initialResults,
style: style,
);
selectionMenuService?.show();
// disable the keyboard service
editorState.service.keyboardService?.disable();
await selectionMenuService?.show();
// enable the keyboard service
editorState.service.keyboardService?.enable();
}
return true;

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
@ -7,7 +9,8 @@ import 'package:flutter/material.dart';
abstract class InlineActionsMenuService {
InlineActionsMenuStyle get style;
void show();
Future<void> show();
void dismiss();
}
@ -59,8 +62,13 @@ class InlineActionsMenu extends InlineActionsMenuService {
void _onSelectionUpdate() => selectionChangedByMenu = true;
@override
void show() {
WidgetsBinding.instance.addPostFrameCallback((_) => _show());
Future<void> show() {
final completer = Completer<void>();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_show();
completer.complete();
});
return completer.future;
}
void _show() {

View File

@ -1684,6 +1684,11 @@
"selectADocumentToLinkTo": "Select a Document to link to"
},
"name": {
"textStyle": "Text Style",
"list": "List",
"fileOrMedia": "File or Media",
"decorations": "Decorations",
"advanced": "Advanced",
"text": "Text",
"heading1": "Heading 1",
"heading2": "Heading 2",