feat: improve reference menus (#4301)

* feat: improve reference menus

* fix: limit page results in reference menus

* fix: custom title for specific type refs

* fix: insert pages

* fix: enable scrolling on item focus change

* fix: enable shift+tab to navigate

* fix: properly offset menu

* fix: review comments

* fix: remove bottom padding on last group
This commit is contained in:
Mathias Mogensen 2024-01-08 15:28:36 +01:00 committed by GitHub
parent 75d394fb6e
commit 332a677d20
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 353 additions and 379 deletions

View File

@ -1,9 +1,10 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -30,7 +31,7 @@ void main() {
// Select result
final optionFinder = find.descendant(
of: find.byType(LinkToPageMenu),
of: find.byType(InlineActionsHandler),
matching: find.text(name),
);

View File

@ -1,9 +1,9 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy/plugins/database/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/text_cell/text_cell.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@ -155,7 +155,7 @@ Future<void> insertReferenceDatabase(
layout.referencedMenuName,
);
final linkToPageMenu = find.byType(LinkToPageMenu);
final linkToPageMenu = find.byType(InlineActionsHandler);
expect(linkToPageMenu, findsOneWidget);
final referencedDatabase = find.descendant(
of: linkToPageMenu,

View File

@ -73,6 +73,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
handlers: [
InlinePageReferenceService(
currentViewId: documentBloc.view.id,
limitResults: 5,
).inlinePageReferenceDelegate,
DateReferenceService(context).dateReferenceDelegate,
ReminderReferenceService(context).reminderReferenceDelegate,

View File

@ -14,6 +14,7 @@ extension InsertDatabase on EditorState {
if (selection == null || !selection.isCollapsed) {
return;
}
final node = getNodeAtPath(selection.end.path);
if (node == null) {
return;
@ -52,19 +53,9 @@ extension InsertDatabase on EditorState {
);
}
late Transaction transaction;
if (viewType == ViewLayoutPB.Document) {
transaction = await _insertDocumentReference(
childView,
selection,
node,
);
} else {
transaction = await _insertDatabaseReference(
childView,
selection.end.path,
);
}
final Transaction transaction = viewType == ViewLayoutPB.Document
? await _insertDocumentReference(childView, selection, node)
: await _insertDatabaseReference(childView, selection.end.path);
await apply(transaction);
}

View File

@ -1,252 +1,71 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.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_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void showLinkToPageMenu(
OverlayState container,
InlineActionsMenuService? _actionsMenuService;
Future<void> showLinkToPageMenu(
EditorState editorState,
SelectionMenuService menuService,
ViewLayoutPB pageType,
) {
) async {
menuService.dismiss();
_actionsMenuService?.dismiss();
final alignment = menuService.alignment;
final offset = menuService.offset;
final top = alignment == Alignment.topLeft ? offset.dy : null;
final bottom = alignment == Alignment.bottomLeft ? offset.dy : null;
keepEditorFocusNotifier.increase();
late OverlayEntry linkToPageMenuEntry;
linkToPageMenuEntry = FullScreenOverlayEntry(
top: top,
bottom: bottom,
left: offset.dx,
dismissCallback: () => keepEditorFocusNotifier.decrease(),
builder: (context) => Material(
color: Colors.transparent,
child: LinkToPageMenu(
editorState: editorState,
layoutType: pageType,
hintText: pageType.toHintText(),
onSelected: (appPB, viewPB) async {
try {
await editorState.insertReferencePage(viewPB, pageType);
linkToPageMenuEntry.remove();
} on FlowyError catch (e) {
if (context.mounted) {
Dialogs.show(
child: FlowyErrorPage.message(
e.msg,
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
context,
);
}
}
},
),
),
).build();
container.insert(linkToPageMenuEntry);
}
class LinkToPageMenu extends StatefulWidget {
const LinkToPageMenu({
super.key,
required this.editorState,
required this.layoutType,
required this.hintText,
required this.onSelected,
});
final EditorState editorState;
final ViewLayoutPB layoutType;
final String hintText;
final void Function(ViewPB view, ViewPB childView) onSelected;
@override
State<LinkToPageMenu> createState() => _LinkToPageMenuState();
}
class _LinkToPageMenuState extends State<LinkToPageMenu> {
final _focusNode = FocusNode(debugLabel: 'reference_list_widget');
EditorStyle get style => widget.editorState.editorStyle;
int _selectedIndex = 0;
final int _totalItems = 0;
Future<List<ViewPB>>? _availableLayout;
final List<ViewPB> _items = [];
Future<List<ViewPB>> fetchItems() async {
final items =
await ViewBackendService().fetchViewsWithLayoutType(widget.layoutType);
_items
..clear()
..addAll(items);
return items;
final rootContext = editorState.document.root.context;
if (rootContext == null) {
return;
}
@override
void initState() {
_availableLayout = fetchItems();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
super.initState();
final service = InlineActionsService(
context: rootContext,
handlers: [
InlinePageReferenceService(
currentViewId: "",
viewLayout: pageType,
customTitle: titleFromPageType(pageType),
insertPage: pageType != ViewLayoutPB.Document,
limitResults: 15,
).inlinePageReferenceDelegate,
],
);
final List<InlineActionsResult> initialResults = [];
for (final handler in service.handlers) {
final group = await handler();
if (group.results.isNotEmpty) {
initialResults.add(group);
}
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Focus(
focusNode: _focusNode,
onKey: _onKey,
child: Container(
width: 300,
padding: const EdgeInsets.fromLTRB(10, 6, 10, 6),
decoration: BoxDecoration(
color: theme.cardColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: _buildListWidget(
context,
_selectedIndex,
_availableLayout,
),
),
if (rootContext.mounted) {
_actionsMenuService = InlineActionsMenu(
context: rootContext,
editorState: editorState,
service: service,
initialResults: initialResults,
style: Theme.of(editorState.document.root.context!).brightness ==
Brightness.light
? const InlineActionsMenuStyle.light()
: const InlineActionsMenuStyle.dark(),
startCharAmount: 0,
);
}
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
if (event is! RawKeyDownEvent ||
_availableLayout == null ||
_items.isEmpty) {
return KeyEventResult.ignored;
}
final acceptedKeys = [
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.tab,
LogicalKeyboardKey.enter,
];
if (!acceptedKeys.contains(event.logicalKey)) {
return KeyEventResult.handled;
}
var newSelectedIndex = _selectedIndex;
if (event.logicalKey == LogicalKeyboardKey.arrowDown &&
newSelectedIndex != _totalItems - 1) {
newSelectedIndex += 1;
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp &&
newSelectedIndex != 0) {
newSelectedIndex -= 1;
} else if (event.logicalKey == LogicalKeyboardKey.tab) {
newSelectedIndex += 1;
newSelectedIndex %= _totalItems;
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
widget.onSelected(
_items[_selectedIndex],
_items[_selectedIndex],
);
}
setState(() {
_selectedIndex = newSelectedIndex;
});
return KeyEventResult.handled;
}
Widget _buildListWidget(
BuildContext context,
int selectedIndex,
Future<List<ViewPB>>? items,
) {
int index = 0;
return FutureBuilder<List<ViewPB>>(
future: items,
builder: (context, snapshot) {
if (snapshot.hasData &&
snapshot.connectionState == ConnectionState.done) {
final views = snapshot.data;
final List<Widget> children = [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: FlowyText.regular(
widget.hintText,
fontSize: 10,
color: Colors.grey,
),
),
];
if (views != null && views.isNotEmpty) {
for (final view in views) {
children.add(
FlowyButton(
isSelected: index == _selectedIndex,
leftIcon: view.defaultIcon(),
text: FlowyText.regular(view.name),
onTap: () => widget.onSelected(view, view),
),
);
index += 1;
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
}
return const Center(child: CircularProgressIndicator());
},
);
_actionsMenuService?.show();
}
}
extension on ViewLayoutPB {
String toHintText() {
switch (this) {
case ViewLayoutPB.Grid:
return LocaleKeys.document_slashMenu_grid_selectAGridToLinkTo.tr();
case ViewLayoutPB.Board:
return LocaleKeys.document_slashMenu_board_selectABoardToLinkTo.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.document_slashMenu_calendar_selectACalendarToLinkTo
.tr();
case ViewLayoutPB.Document:
return LocaleKeys.document_slashMenu_document_selectADocumentToLinkTo
.tr();
default:
throw Exception('Unknown layout type');
}
}
}
String titleFromPageType(ViewLayoutPB layout) => switch (layout) {
ViewLayoutPB.Grid => LocaleKeys.inlineActions_gridReference.tr(),
ViewLayoutPB.Document => LocaleKeys.inlineActions_docReference.tr(),
ViewLayoutPB.Board => LocaleKeys.inlineActions_boardReference.tr(),
ViewLayoutPB.Calendar => LocaleKeys.inlineActions_calReference.tr(),
_ => LocaleKeys.inlineActions_pageReference.tr(),
};

View File

@ -85,6 +85,7 @@ Future<bool> inlinePageReferenceCommandHandler(
handlers: [
InlinePageReferenceService(
currentViewId: currentViewId,
limitResults: 10,
).inlinePageReferenceDelegate,
],
);
@ -100,7 +101,7 @@ Future<bool> inlinePageReferenceCommandHandler(
}
}
if (service.context != null) {
if (context.mounted) {
selectionMenuService = InlineActionsMenu(
context: service.context!,
editorState: editorState,

View File

@ -5,7 +5,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selec
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
// Document Reference
@ -17,14 +16,8 @@ SelectionMenuItem referencedDocumentMenuItem = SelectionMenuItem(
style: style,
),
keywords: ['page', 'notes', 'referenced page', 'referenced document'],
handler: (editorState, menuService, context) {
showLinkToPageMenu(
Overlay.of(context),
editorState,
menuService,
ViewLayoutPB.Document,
);
},
handler: (editorState, menuService, context) =>
showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Document),
);
// Database References
@ -37,15 +30,8 @@ SelectionMenuItem referencedGridMenuItem = SelectionMenuItem(
style: style,
),
keywords: ['referenced', 'grid', 'database'],
handler: (editorState, menuService, context) {
final container = Overlay.of(context);
showLinkToPageMenu(
container,
editorState,
menuService,
ViewLayoutPB.Grid,
);
},
handler: (editorState, menuService, context) =>
showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Grid),
);
SelectionMenuItem referencedBoardMenuItem = SelectionMenuItem(
@ -56,15 +42,8 @@ SelectionMenuItem referencedBoardMenuItem = SelectionMenuItem(
style: style,
),
keywords: ['referenced', 'board', 'kanban'],
handler: (editorState, menuService, context) {
final container = Overlay.of(context);
showLinkToPageMenu(
container,
editorState,
menuService,
ViewLayoutPB.Board,
);
},
handler: (editorState, menuService, context) =>
showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Board),
);
SelectionMenuItem referencedCalendarMenuItem = SelectionMenuItem(
@ -75,12 +54,6 @@ SelectionMenuItem referencedCalendarMenuItem = SelectionMenuItem(
style: style,
),
keywords: ['referenced', 'calendar', 'database'],
handler: (editorState, menuService, context) {
showLinkToPageMenu(
Overlay.of(context),
editorState,
menuService,
ViewLayoutPB.Calendar,
);
},
handler: (editorState, menuService, context) =>
showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Calendar),
);

View File

@ -1,19 +1,30 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_text.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
class InlinePageReferenceService {
InlinePageReferenceService({
required this.currentViewId,
this.viewLayout,
this.customTitle,
this.insertPage = false,
this.limitResults = 0,
}) {
init();
}
@ -21,38 +32,48 @@ class InlinePageReferenceService {
final Completer _initCompleter = Completer<void>();
final String currentViewId;
final ViewLayoutPB? viewLayout;
final String? customTitle;
late final ViewBackendService service;
/// Defaults to false, if set to true the Page
/// will be inserted as a Reference
/// When false, a link to the view will be inserted
///
final bool insertPage;
/// Defaults to 0 where there are no limits
/// Anything above 0 will limit the page reference results
/// to [limitResults].
///
final int limitResults;
final ViewBackendService service = ViewBackendService();
List<InlineActionsMenuItem> _items = [];
List<InlineActionsMenuItem> _filtered = [];
Future<void> init() async {
service = ViewBackendService();
_generatePageItems(currentViewId).then((value) {
_items = value;
_filtered = value;
_initCompleter.complete();
});
_items = await _generatePageItems(currentViewId, viewLayout);
_filtered = limitResults > 0 ? _items.take(limitResults).toList() : _items;
_initCompleter.complete();
}
Future<List<InlineActionsMenuItem>> _filterItems(String? search) async {
await _initCompleter.future;
if (search == null || search.isEmpty) {
return _items;
}
final items = (search == null || search.isEmpty)
? _items
: _items.where(
(item) =>
item.keywords != null &&
item.keywords!.isNotEmpty &&
item.keywords!.any(
(keyword) => keyword.contains(search.toLowerCase()),
),
);
return _items
.where(
(item) =>
item.keywords != null &&
item.keywords!.isNotEmpty &&
item.keywords!.any(
(keyword) => keyword.contains(search.toLowerCase()),
),
)
.toList();
return limitResults > 0
? items.take(limitResults).toList()
: items.toList();
}
Future<InlineActionsResult> inlinePageReferenceDelegate([
@ -61,15 +82,24 @@ class InlinePageReferenceService {
_filtered = await _filterItems(search);
return InlineActionsResult(
title: LocaleKeys.inlineActions_pageReference.tr(),
title: customTitle?.isNotEmpty == true
? customTitle!
: LocaleKeys.inlineActions_pageReference.tr(),
results: _filtered,
);
}
Future<List<InlineActionsMenuItem>> _generatePageItems(
String currentViewId,
ViewLayoutPB? viewLayout,
) async {
final views = await service.fetchViews();
late List<ViewPB> views;
if (viewLayout != null) {
views = await service.fetchViewsWithLayoutType(viewLayout);
} else {
views = await service.fetchViews();
}
if (views.isEmpty) {
return [];
}
@ -93,37 +123,15 @@ class InlinePageReferenceService {
lineHeight: 1.3,
)
: view.defaultIcon(),
onSelected: (context, editorState, menuService, replace) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return;
}
final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
if (node == null || delta == null) {
return;
}
// @page name -> $
// preload the page infos
pageMemorizer[view.id] = view;
final transaction = editorState.transaction
..replaceText(
node,
replace.$1,
replace.$2,
'\$',
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.page.name,
MentionBlockKeys.pageId: view.id,
},
},
);
await editorState.apply(transaction);
},
onSelected: (context, editorState, menuService, replace) => insertPage
? _onInsertPageRef(view, context, editorState, replace)
: _onInsertLinkRef(
view,
context,
editorState,
menuService,
replace,
),
);
pages.add(pageSelectionMenuItem);
@ -131,4 +139,84 @@ class InlinePageReferenceService {
return pages;
}
Future<void> _onInsertPageRef(
ViewPB view,
BuildContext context,
EditorState editorState,
(int, int) replace,
) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return;
}
final node = editorState.getNodeAtPath(selection.start.path);
if (node != null) {
// Delete search term
if (replace.$2 > 0) {
final transaction = editorState.transaction
..deleteText(node, replace.$1, replace.$2);
await editorState.apply(transaction);
}
// Insert newline before inserting referenced database
if (node.delta?.toPlainText().isNotEmpty == true) {
await editorState.insertNewLine();
}
}
try {
await editorState.insertReferencePage(view, view.layout);
} on FlowyError catch (e) {
if (context.mounted) {
Dialogs.show(
context,
child: FlowyErrorPage.message(
e.msg,
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
);
}
}
}
Future<void> _onInsertLinkRef(
ViewPB view,
BuildContext context,
EditorState editorState,
InlineActionsMenuService menuService,
(int, int) replace,
) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return;
}
final node = editorState.getNodeAtPath(selection.start.path);
final delta = node?.delta;
if (node == null || delta == null) {
return;
}
// @page name -> $
// preload the page infos
pageMemorizer[view.id] = view;
final transaction = editorState.transaction
..replaceText(
node,
replace.$1,
replace.$2,
'\$',
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.page.name,
MentionBlockKeys.pageId: view.id,
},
},
);
await editorState.apply(transaction);
}
}

View File

@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
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';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
abstract class InlineActionsMenuService {
InlineActionsMenuStyle get style;
@ -69,7 +70,8 @@ class InlineActionsMenu extends InlineActionsMenuService {
return;
}
const double menuHeight = 200.0;
const double menuHeight = 300.0;
const double menuWidth = 200.0;
const Offset menuOffset = Offset(0, 10);
final Offset editorOffset =
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
@ -93,13 +95,14 @@ class InlineActionsMenu extends InlineActionsMenuService {
}
// Show on the left
if (offset.dx > editorSize.width / 2) {
final windowWidth = MediaQuery.of(context).size.width;
if (offset.dx > (windowWidth - menuWidth)) {
alignment = alignment == Alignment.topLeft
? Alignment.topRight
: Alignment.bottomRight;
offset = Offset(
editorSize.width - offset.dx,
windowWidth - offset.dx,
offset.dy,
);
}

View File

@ -1,3 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
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';
@ -7,8 +10,14 @@ 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 'package:flutter/services.dart';
/// All heights are in physical pixels
const double _groupTextHeight = 14; // 12 height + 2 bottom spacing
const double _groupBottomSpacing = 6;
const double _itemHeight = 30; // 26 height + 4 vertical spacing (2*2)
const double _menuHeight = 300;
const double _contentHeight = 260;
extension _StartWithsSort on List<InlineActionsResult> {
void sortByStartsWithKeyword(String search) => sort(
@ -68,6 +77,7 @@ class InlineActionsHandler extends StatefulWidget {
class _InlineActionsHandlerState extends State<InlineActionsHandler> {
final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler');
final _scrollController = ScrollController();
late List<InlineActionsResult> results = widget.results;
int invalidCounter = 0;
@ -126,7 +136,8 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
return Focus(
focusNode: _focusNode,
onKey: onKey,
child: DecoratedBox(
child: Container(
constraints: BoxConstraints.loose(const Size(200, _menuHeight)),
decoration: BoxDecoration(
color: widget.style.backgroundColor,
borderRadius: BorderRadius.circular(6.0),
@ -147,24 +158,29 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
LocaleKeys.inlineActions_noResults.tr(),
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: results
.where((g) => g.results.isNotEmpty)
.mapIndexed(
(index, group) => InlineActionsGroup(
result: group,
editorState: widget.editorState,
menuService: widget.menuService,
style: widget.style,
isGroupSelected: _selectedGroup == index,
selectedIndex: _selectedIndex,
onSelected: widget.onDismiss,
startOffset: startOffset - widget.startCharAmount,
endOffset: _search.length + widget.startCharAmount,
),
)
.toList(),
: SingleChildScrollView(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: results
.where((g) => g.results.isNotEmpty)
.mapIndexed(
(index, group) => InlineActionsGroup(
result: group,
editorState: widget.editorState,
menuService: widget.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,
),
)
.toList(),
),
),
),
),
@ -210,11 +226,18 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
}
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
widget.onDismiss();
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.backspace) {
if (_search.isEmpty) {
if (_canDeleteLastCharacter()) {
widget.editorState.deleteBackward();
} else {
// Workaround for editor regaining focus
widget.editorState.apply(
widget.editorState.transaction
..afterSelection = widget.editorState.selection,
);
}
widget.onDismiss();
widget.editorState.deleteBackward();
} else {
widget.onSelectionUpdate();
widget.editorState.deleteBackward();
@ -296,24 +319,78 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
}
void _moveSelection(LogicalKeyboardKey key) {
if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab].contains(key)) {
if (_selectedIndex < lengthOfGroup(_selectedGroup) - 1) {
_selectedIndex += 1;
} else if (_selectedGroup < groupLength - 1) {
_selectedGroup += 1;
_selectedIndex = 0;
}
} else if (key == LogicalKeyboardKey.arrowUp) {
bool didChange = false;
if (key == LogicalKeyboardKey.arrowUp ||
(key == LogicalKeyboardKey.tab &&
RawKeyboard.instance.isShiftPressed)) {
if (_selectedIndex == 0 && _selectedGroup > 0) {
_selectedGroup -= 1;
_selectedIndex = lengthOfGroup(_selectedGroup) - 1;
didChange = true;
} else if (_selectedIndex > 0) {
_selectedIndex -= 1;
didChange = true;
}
} else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab]
.contains(key)) {
if (_selectedIndex < lengthOfGroup(_selectedGroup) - 1) {
_selectedIndex += 1;
didChange = true;
} else if (_selectedGroup < groupLength - 1) {
_selectedGroup += 1;
_selectedIndex = 0;
didChange = true;
}
}
if (mounted) {
if (mounted && didChange) {
setState(() {});
_scrollToItem();
}
}
void _scrollToItem() {
final groups = _selectedGroup + 1;
int items = 0;
for (int i = 0; i <= _selectedGroup; i++) {
items += lengthOfGroup(i);
}
// Remove the leftover items
items -= lengthOfGroup(_selectedGroup) - (_selectedIndex + 1);
/// The offset is roughly calculated by:
/// - Amount of Groups passed
/// - Amount of Items passed
final double offset =
(_groupTextHeight + _groupBottomSpacing) * groups + _itemHeight * items;
// We have a buffer so that when moving up, we show items above the currently
// selected item. The buffer is the height of 2 items
if (offset <= _scrollController.offset + _itemHeight * 2) {
// We want to show the user some options above the newly
// focused one, therefore we take the offset and subtract
// the height of three items (current + 2)
_scrollController.animateTo(
offset - _itemHeight * 3,
duration: const Duration(milliseconds: 200),
curve: Curves.easeIn,
);
} else if (offset >
_scrollController.offset +
_contentHeight -
_itemHeight -
_groupTextHeight) {
// The same here, we want to show the options below the
// newly focused item when moving downwards, therefore we add
// 2 times the item height to the offset
_scrollController.animateTo(
offset - _contentHeight + _itemHeight * 2,
duration: const Duration(milliseconds: 200),
curve: Curves.easeIn,
);
}
}
@ -334,4 +411,18 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
startOffset - 1 + _search.length,
);
}
bool _canDeleteLastCharacter() {
final selection = widget.editorState.selection;
if (selection == null || !selection.isCollapsed) {
return false;
}
final delta = widget.editorState.getNodeAtPath(selection.start.path)?.delta;
if (delta == null) {
return false;
}
return delta.isNotEmpty;
}
}

View File

@ -16,6 +16,7 @@ class InlineActionsGroup extends StatelessWidget {
required this.onSelected,
required this.startOffset,
required this.endOffset,
this.isLastGroup = false,
this.isGroupSelected = false,
this.selectedIndex = 0,
});
@ -28,13 +29,14 @@ class InlineActionsGroup extends StatelessWidget {
final int startOffset;
final int endOffset;
final bool isLastGroup;
final bool isGroupSelected;
final int selectedIndex;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
padding: isLastGroup ? EdgeInsets.zero : const EdgeInsets.only(bottom: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@ -1000,6 +1000,10 @@
"inlineActions": {
"noResults": "No results",
"pageReference": "Page reference",
"docReference": "Document reference",
"boardReference": "Board reference",
"calReference": "Calendar reference",
"gridReference": "Grid reference",
"date": "Date",
"reminder": {
"groupTitle": "Reminder",