mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-11-01 18:43:22 +00:00
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:
parent
b75fd673cd
commit
9e98680861
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user