mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
75d394fb6e
commit
332a677d20
@ -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),
|
||||
);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -73,6 +73,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
handlers: [
|
||||
InlinePageReferenceService(
|
||||
currentViewId: documentBloc.view.id,
|
||||
limitResults: 5,
|
||||
).inlinePageReferenceDelegate,
|
||||
DateReferenceService(context).dateReferenceDelegate,
|
||||
ReminderReferenceService(context).reminderReferenceDelegate,
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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(),
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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: [
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user