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
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/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/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra/uuid.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
@ -30,7 +31,7 @@ void main() {
// Select result // Select result
final optionFinder = find.descendant( final optionFinder = find.descendant(
of: find.byType(LinkToPageMenu), of: find.byType(InlineActionsHandler),
matching: find.text(name), matching: find.text(name),
); );

View File

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

View File

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

View File

@ -14,6 +14,7 @@ extension InsertDatabase on EditorState {
if (selection == null || !selection.isCollapsed) { if (selection == null || !selection.isCollapsed) {
return; return;
} }
final node = getNodeAtPath(selection.end.path); final node = getNodeAtPath(selection.end.path);
if (node == null) { if (node == null) {
return; return;
@ -52,19 +53,9 @@ extension InsertDatabase on EditorState {
); );
} }
late Transaction transaction; final Transaction transaction = viewType == ViewLayoutPB.Document
if (viewType == ViewLayoutPB.Document) { ? await _insertDocumentReference(childView, selection, node)
transaction = await _insertDocumentReference( : await _insertDatabaseReference(childView, selection.end.path);
childView,
selection,
node,
);
} else {
transaction = await _insertDatabaseReference(
childView,
selection.end.path,
);
}
await apply(transaction); 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/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart'; import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_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_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.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( InlineActionsMenuService? _actionsMenuService;
OverlayState container, Future<void> showLinkToPageMenu(
EditorState editorState, EditorState editorState,
SelectionMenuService menuService, SelectionMenuService menuService,
ViewLayoutPB pageType, ViewLayoutPB pageType,
) { ) async {
menuService.dismiss(); menuService.dismiss();
_actionsMenuService?.dismiss();
final alignment = menuService.alignment; final rootContext = editorState.document.root.context;
final offset = menuService.offset; if (rootContext == null) {
final top = alignment == Alignment.topLeft ? offset.dy : null; return;
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;
} }
@override final service = InlineActionsService(
void initState() { context: rootContext,
_availableLayout = fetchItems(); handlers: [
WidgetsBinding.instance.addPostFrameCallback((_) { InlinePageReferenceService(
_focusNode.requestFocus(); currentViewId: "",
}); viewLayout: pageType,
super.initState(); 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 if (rootContext.mounted) {
void dispose() { _actionsMenuService = InlineActionsMenu(
_focusNode.dispose(); context: rootContext,
super.dispose(); editorState: editorState,
} service: service,
initialResults: initialResults,
@override style: Theme.of(editorState.document.root.context!).brightness ==
Widget build(BuildContext context) { Brightness.light
final theme = Theme.of(context); ? const InlineActionsMenuStyle.light()
return Focus( : const InlineActionsMenuStyle.dark(),
focusNode: _focusNode, startCharAmount: 0,
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,
),
),
); );
}
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { _actionsMenuService?.show();
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());
},
);
} }
} }
extension on ViewLayoutPB { String titleFromPageType(ViewLayoutPB layout) => switch (layout) {
String toHintText() { ViewLayoutPB.Grid => LocaleKeys.inlineActions_gridReference.tr(),
switch (this) { ViewLayoutPB.Document => LocaleKeys.inlineActions_docReference.tr(),
case ViewLayoutPB.Grid: ViewLayoutPB.Board => LocaleKeys.inlineActions_boardReference.tr(),
return LocaleKeys.document_slashMenu_grid_selectAGridToLinkTo.tr(); ViewLayoutPB.Calendar => LocaleKeys.inlineActions_calReference.tr(),
case ViewLayoutPB.Board: _ => LocaleKeys.inlineActions_pageReference.tr(),
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');
}
}
}

View File

@ -85,6 +85,7 @@ Future<bool> inlinePageReferenceCommandHandler(
handlers: [ handlers: [
InlinePageReferenceService( InlinePageReferenceService(
currentViewId: currentViewId, currentViewId: currentViewId,
limitResults: 10,
).inlinePageReferenceDelegate, ).inlinePageReferenceDelegate,
], ],
); );
@ -100,7 +101,7 @@ Future<bool> inlinePageReferenceCommandHandler(
} }
} }
if (service.context != null) { if (context.mounted) {
selectionMenuService = InlineActionsMenu( selectionMenuService = InlineActionsMenu(
context: service.context!, context: service.context!,
editorState: editorState, 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_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
// Document Reference // Document Reference
@ -17,14 +16,8 @@ SelectionMenuItem referencedDocumentMenuItem = SelectionMenuItem(
style: style, style: style,
), ),
keywords: ['page', 'notes', 'referenced page', 'referenced document'], keywords: ['page', 'notes', 'referenced page', 'referenced document'],
handler: (editorState, menuService, context) { handler: (editorState, menuService, context) =>
showLinkToPageMenu( showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Document),
Overlay.of(context),
editorState,
menuService,
ViewLayoutPB.Document,
);
},
); );
// Database References // Database References
@ -37,15 +30,8 @@ SelectionMenuItem referencedGridMenuItem = SelectionMenuItem(
style: style, style: style,
), ),
keywords: ['referenced', 'grid', 'database'], keywords: ['referenced', 'grid', 'database'],
handler: (editorState, menuService, context) { handler: (editorState, menuService, context) =>
final container = Overlay.of(context); showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Grid),
showLinkToPageMenu(
container,
editorState,
menuService,
ViewLayoutPB.Grid,
);
},
); );
SelectionMenuItem referencedBoardMenuItem = SelectionMenuItem( SelectionMenuItem referencedBoardMenuItem = SelectionMenuItem(
@ -56,15 +42,8 @@ SelectionMenuItem referencedBoardMenuItem = SelectionMenuItem(
style: style, style: style,
), ),
keywords: ['referenced', 'board', 'kanban'], keywords: ['referenced', 'board', 'kanban'],
handler: (editorState, menuService, context) { handler: (editorState, menuService, context) =>
final container = Overlay.of(context); showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Board),
showLinkToPageMenu(
container,
editorState,
menuService,
ViewLayoutPB.Board,
);
},
); );
SelectionMenuItem referencedCalendarMenuItem = SelectionMenuItem( SelectionMenuItem referencedCalendarMenuItem = SelectionMenuItem(
@ -75,12 +54,6 @@ SelectionMenuItem referencedCalendarMenuItem = SelectionMenuItem(
style: style, style: style,
), ),
keywords: ['referenced', 'calendar', 'database'], keywords: ['referenced', 'calendar', 'database'],
handler: (editorState, menuService, context) { handler: (editorState, menuService, context) =>
showLinkToPageMenu( showLinkToPageMenu(editorState, menuService, ViewLayoutPB.Calendar),
Overlay.of(context),
editorState,
menuService,
ViewLayoutPB.Calendar,
);
},
); );

View File

@ -1,19 +1,30 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_text.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_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_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/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.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:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.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 { class InlinePageReferenceService {
InlinePageReferenceService({ InlinePageReferenceService({
required this.currentViewId, required this.currentViewId,
this.viewLayout,
this.customTitle,
this.insertPage = false,
this.limitResults = 0,
}) { }) {
init(); init();
} }
@ -21,38 +32,48 @@ class InlinePageReferenceService {
final Completer _initCompleter = Completer<void>(); final Completer _initCompleter = Completer<void>();
final String currentViewId; 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> _items = [];
List<InlineActionsMenuItem> _filtered = []; List<InlineActionsMenuItem> _filtered = [];
Future<void> init() async { Future<void> init() async {
service = ViewBackendService(); _items = await _generatePageItems(currentViewId, viewLayout);
_filtered = limitResults > 0 ? _items.take(limitResults).toList() : _items;
_generatePageItems(currentViewId).then((value) { _initCompleter.complete();
_items = value;
_filtered = value;
_initCompleter.complete();
});
} }
Future<List<InlineActionsMenuItem>> _filterItems(String? search) async { Future<List<InlineActionsMenuItem>> _filterItems(String? search) async {
await _initCompleter.future; await _initCompleter.future;
if (search == null || search.isEmpty) { final items = (search == null || search.isEmpty)
return _items; ? _items
} : _items.where(
(item) =>
item.keywords != null &&
item.keywords!.isNotEmpty &&
item.keywords!.any(
(keyword) => keyword.contains(search.toLowerCase()),
),
);
return _items return limitResults > 0
.where( ? items.take(limitResults).toList()
(item) => : items.toList();
item.keywords != null &&
item.keywords!.isNotEmpty &&
item.keywords!.any(
(keyword) => keyword.contains(search.toLowerCase()),
),
)
.toList();
} }
Future<InlineActionsResult> inlinePageReferenceDelegate([ Future<InlineActionsResult> inlinePageReferenceDelegate([
@ -61,15 +82,24 @@ class InlinePageReferenceService {
_filtered = await _filterItems(search); _filtered = await _filterItems(search);
return InlineActionsResult( return InlineActionsResult(
title: LocaleKeys.inlineActions_pageReference.tr(), title: customTitle?.isNotEmpty == true
? customTitle!
: LocaleKeys.inlineActions_pageReference.tr(),
results: _filtered, results: _filtered,
); );
} }
Future<List<InlineActionsMenuItem>> _generatePageItems( Future<List<InlineActionsMenuItem>> _generatePageItems(
String currentViewId, String currentViewId,
ViewLayoutPB? viewLayout,
) async { ) 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) { if (views.isEmpty) {
return []; return [];
} }
@ -93,37 +123,15 @@ class InlinePageReferenceService {
lineHeight: 1.3, lineHeight: 1.3,
) )
: view.defaultIcon(), : view.defaultIcon(),
onSelected: (context, editorState, menuService, replace) async { onSelected: (context, editorState, menuService, replace) => insertPage
final selection = editorState.selection; ? _onInsertPageRef(view, context, editorState, replace)
if (selection == null || !selection.isCollapsed) { : _onInsertLinkRef(
return; view,
} context,
editorState,
final node = editorState.getNodeAtPath(selection.end.path); menuService,
final delta = node?.delta; replace,
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);
},
); );
pages.add(pageSelectionMenuItem); pages.add(pageSelectionMenuItem);
@ -131,4 +139,84 @@ class InlinePageReferenceService {
return pages; 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_result.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_service.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/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
abstract class InlineActionsMenuService { abstract class InlineActionsMenuService {
InlineActionsMenuStyle get style; InlineActionsMenuStyle get style;
@ -69,7 +70,8 @@ class InlineActionsMenu extends InlineActionsMenuService {
return; return;
} }
const double menuHeight = 200.0; const double menuHeight = 300.0;
const double menuWidth = 200.0;
const Offset menuOffset = Offset(0, 10); const Offset menuOffset = Offset(0, 10);
final Offset editorOffset = final Offset editorOffset =
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
@ -93,13 +95,14 @@ class InlineActionsMenu extends InlineActionsMenuService {
} }
// Show on the left // 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 = alignment == Alignment.topLeft
? Alignment.topRight ? Alignment.topRight
: Alignment.bottomRight; : Alignment.bottomRight;
offset = Offset( offset = Offset(
editorSize.width - offset.dx, windowWidth - offset.dx,
offset.dy, 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/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/inline_actions/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_result.dart';
@ -7,8 +10,14 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.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> { extension _StartWithsSort on List<InlineActionsResult> {
void sortByStartsWithKeyword(String search) => sort( void sortByStartsWithKeyword(String search) => sort(
@ -68,6 +77,7 @@ class InlineActionsHandler extends StatefulWidget {
class _InlineActionsHandlerState extends State<InlineActionsHandler> { class _InlineActionsHandlerState extends State<InlineActionsHandler> {
final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler'); final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler');
final _scrollController = ScrollController();
late List<InlineActionsResult> results = widget.results; late List<InlineActionsResult> results = widget.results;
int invalidCounter = 0; int invalidCounter = 0;
@ -126,7 +136,8 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
return Focus( return Focus(
focusNode: _focusNode, focusNode: _focusNode,
onKey: onKey, onKey: onKey,
child: DecoratedBox( child: Container(
constraints: BoxConstraints.loose(const Size(200, _menuHeight)),
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.style.backgroundColor, color: widget.style.backgroundColor,
borderRadius: BorderRadius.circular(6.0), borderRadius: BorderRadius.circular(6.0),
@ -147,24 +158,29 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
LocaleKeys.inlineActions_noResults.tr(), LocaleKeys.inlineActions_noResults.tr(),
), ),
) )
: Column( : SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.start, controller: _scrollController,
children: results physics: const ClampingScrollPhysics(),
.where((g) => g.results.isNotEmpty) child: Column(
.mapIndexed( crossAxisAlignment: CrossAxisAlignment.start,
(index, group) => InlineActionsGroup( children: results
result: group, .where((g) => g.results.isNotEmpty)
editorState: widget.editorState, .mapIndexed(
menuService: widget.menuService, (index, group) => InlineActionsGroup(
style: widget.style, result: group,
isGroupSelected: _selectedGroup == index, editorState: widget.editorState,
selectedIndex: _selectedIndex, menuService: widget.menuService,
onSelected: widget.onDismiss, style: widget.style,
startOffset: startOffset - widget.startCharAmount, onSelected: widget.onDismiss,
endOffset: _search.length + widget.startCharAmount, startOffset: startOffset - widget.startCharAmount,
), endOffset: _search.length + widget.startCharAmount,
) isLastGroup: index == results.length - 1,
.toList(), isGroupSelected: _selectedGroup == index,
selectedIndex: _selectedIndex,
),
)
.toList(),
),
), ),
), ),
), ),
@ -210,11 +226,18 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
} }
} else if (event.logicalKey == LogicalKeyboardKey.escape) { } else if (event.logicalKey == LogicalKeyboardKey.escape) {
widget.onDismiss(); widget.onDismiss();
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.backspace) { } else if (event.logicalKey == LogicalKeyboardKey.backspace) {
if (_search.isEmpty) { 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.onDismiss();
widget.editorState.deleteBackward();
} else { } else {
widget.onSelectionUpdate(); widget.onSelectionUpdate();
widget.editorState.deleteBackward(); widget.editorState.deleteBackward();
@ -296,24 +319,78 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
} }
void _moveSelection(LogicalKeyboardKey key) { void _moveSelection(LogicalKeyboardKey key) {
if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab].contains(key)) { bool didChange = false;
if (_selectedIndex < lengthOfGroup(_selectedGroup) - 1) {
_selectedIndex += 1; if (key == LogicalKeyboardKey.arrowUp ||
} else if (_selectedGroup < groupLength - 1) { (key == LogicalKeyboardKey.tab &&
_selectedGroup += 1; RawKeyboard.instance.isShiftPressed)) {
_selectedIndex = 0;
}
} else if (key == LogicalKeyboardKey.arrowUp) {
if (_selectedIndex == 0 && _selectedGroup > 0) { if (_selectedIndex == 0 && _selectedGroup > 0) {
_selectedGroup -= 1; _selectedGroup -= 1;
_selectedIndex = lengthOfGroup(_selectedGroup) - 1; _selectedIndex = lengthOfGroup(_selectedGroup) - 1;
didChange = true;
} else if (_selectedIndex > 0) { } else if (_selectedIndex > 0) {
_selectedIndex -= 1; _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(() {}); 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, 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.onSelected,
required this.startOffset, required this.startOffset,
required this.endOffset, required this.endOffset,
this.isLastGroup = false,
this.isGroupSelected = false, this.isGroupSelected = false,
this.selectedIndex = 0, this.selectedIndex = 0,
}); });
@ -28,13 +29,14 @@ class InlineActionsGroup extends StatelessWidget {
final int startOffset; final int startOffset;
final int endOffset; final int endOffset;
final bool isLastGroup;
final bool isGroupSelected; final bool isGroupSelected;
final int selectedIndex; final int selectedIndex;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 6), padding: isLastGroup ? EdgeInsets.zero : const EdgeInsets.only(bottom: 6),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View File

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