Skip to content

Commit b610123

Browse files
committed
fix: arrow key sometimes jump to wrong index in search panel
1 parent a080cff commit b610123

File tree

8 files changed

+416
-147
lines changed

8 files changed

+416
-147
lines changed

frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ class CommandPaletteModal extends StatelessWidget {
207207
final hasResult = state.combinedResponseItems.isNotEmpty,
208208
searching = state.searching;
209209
final spaceXl = theme.spacing.xl;
210+
final showRencentList = noQuery,
211+
showResultList = hasResult && hasQuery;
210212
return FlowyDialog(
211213
backgroundColor: theme.surfaceColorScheme.layer01,
212214
alignment: Alignment.topCenter,
@@ -224,14 +226,15 @@ class CommandPaletteModal extends StatelessWidget {
224226
padding: EdgeInsets.fromLTRB(spaceXl, spaceXl, spaceXl, 0),
225227
child: Column(
226228
children: [
227-
SearchField(query: state.query, isLoading: searching),
228-
if (noQuery)
229+
if (!showRencentList && !showResultList)
230+
SearchField(query: state.query, isLoading: searching),
231+
if (showRencentList)
229232
Flexible(
230233
child: RecentViewsList(
231234
onSelected: () => FlowyOverlay.pop(context),
232235
),
233236
),
234-
if (hasResult && hasQuery)
237+
if (showResultList)
235238
Flexible(
236239
child: SearchResultList(
237240
cachedViews: state.cachedViews,
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import 'dart:math';
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter/services.dart';
5+
6+
class KeyboardScroller<T> extends StatefulWidget {
7+
const KeyboardScroller({
8+
super.key,
9+
required this.controller,
10+
required this.list,
11+
required this.onSelect,
12+
required this.idGetter,
13+
required this.selectedIndexGetter,
14+
required this.builder,
15+
});
16+
17+
final ScrollController controller;
18+
final List<T> list;
19+
final ValueGetter<int> selectedIndexGetter;
20+
final ValueChanged<int> onSelect;
21+
final IdGetter<T> idGetter;
22+
final KeyboardScrollerBuilder builder;
23+
24+
@override
25+
State<KeyboardScroller<T>> createState() => _KeyboardScrollerState<T>();
26+
}
27+
28+
class _KeyboardScrollerState<T> extends State<KeyboardScroller<T>> {
29+
int get length => widget.list.length;
30+
31+
final AreaDetectors areaDetector = AreaDetectors();
32+
33+
@override
34+
void dispose() {
35+
areaDetector._dispose();
36+
super.dispose();
37+
}
38+
39+
@override
40+
Widget build(BuildContext context) {
41+
return Shortcuts(
42+
shortcuts: {
43+
SingleActivator(LogicalKeyboardKey.arrowUp):
44+
VoidCallbackIntent(() => _moveSelection(AxisDirection.up, context)),
45+
SingleActivator(LogicalKeyboardKey.arrowDown): VoidCallbackIntent(
46+
() => _moveSelection(AxisDirection.down, context),
47+
),
48+
},
49+
child: widget.builder.call(context, areaDetector),
50+
);
51+
}
52+
53+
bool _moveSelection(AxisDirection direction, BuildContext context) {
54+
if (length == 0) return false;
55+
final index = widget.selectedIndexGetter.call();
56+
int newIndex = index;
57+
final isUp = direction == AxisDirection.up;
58+
if (index < 0) {
59+
newIndex = isUp ? length - 1 : 0;
60+
} else {
61+
if (isUp) {
62+
newIndex = index == 0 ? length - 1 : index - 1;
63+
} else {
64+
newIndex = index == length - 1 ? 0 : index + 1;
65+
}
66+
}
67+
widget.onSelect(newIndex);
68+
_scrollToItem(index, newIndex, context);
69+
return true;
70+
}
71+
72+
void _scrollToItem(
73+
int from,
74+
int to,
75+
BuildContext context,
76+
) {
77+
if (!context.mounted) return;
78+
79+
/// scroll to the end
80+
if (to == length - 1) {
81+
widget.controller.jumpTo(widget.controller.position.maxScrollExtent);
82+
return;
83+
} else if (to == 0) {
84+
/// scroll to the start
85+
widget.controller.jumpTo(0);
86+
return;
87+
}
88+
final id = widget.idGetter(widget.list[to]);
89+
90+
final isTopArea = areaDetector.getAreaType(id) == AreaType.top;
91+
92+
final currentPosition = widget.controller.position.pixels;
93+
if (isTopArea && from > to) {
94+
widget.controller.jumpTo(max(0, currentPosition - 50));
95+
} else if (!isTopArea && from < to) {
96+
widget.controller.jumpTo(
97+
min(currentPosition + 50, widget.controller.position.maxScrollExtent),
98+
);
99+
}
100+
}
101+
}
102+
103+
typedef KeyboardScrollerBuilder = Widget Function(
104+
BuildContext context,
105+
AreaDetectors detectors,
106+
);
107+
108+
typedef IdGetter<T> = String Function(T t);
109+
110+
enum AreaType { top, bottom }
111+
112+
extension AreaTypeSearchPanelExtension on GlobalKey {
113+
AreaType? getAreaType(BuildContext context) {
114+
final renderObject = currentContext?.findRenderObject();
115+
if (renderObject is RenderBox) {
116+
final searchPanelHeight = min(MediaQuery.of(context).size.height - 100, 640);
117+
final position = renderObject.localToGlobal(Offset.zero);
118+
if (position.dy < searchPanelHeight / 2) {
119+
return AreaType.top;
120+
} else {
121+
return AreaType.bottom;
122+
}
123+
}
124+
return null;
125+
}
126+
}
127+
128+
class AreaDetectors {
129+
final Map<String, Set<ValueGetter<AreaType?>>> _detectors = {};
130+
131+
void addDetector(String key, ValueGetter<AreaType?> detector) {
132+
final set = _detectors[key] ?? {};
133+
set.add(detector);
134+
_detectors[key] = set;
135+
}
136+
137+
void removeDetector(String key, ValueGetter<AreaType?> detector) {
138+
final set = _detectors[key] ?? {};
139+
set.remove(detector);
140+
if (set.isEmpty) {
141+
_detectors.remove(key);
142+
} else {
143+
_detectors[key] = set;
144+
}
145+
}
146+
147+
AreaType? getAreaType(String key) {
148+
final set = _detectors[key] ?? {};
149+
if (set.isEmpty) return null;
150+
return set.first.call();
151+
}
152+
153+
void _dispose() {
154+
_detectors.clear();
155+
}
156+
}

frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart

Lines changed: 88 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import 'package:appflowy/features/workspace/logic/workspace_bloc.dart';
22
import 'package:appflowy/generated/locale_keys.g.dart';
3+
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
34
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
45
import 'package:appflowy/workspace/presentation/command_palette/navigation_bloc_extension.dart';
56
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_icon.dart';
67
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart';
8+
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
79
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
810
import 'package:appflowy_ui/appflowy_ui.dart';
911
import 'package:easy_localization/easy_localization.dart';
1012
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
1113
import 'package:flutter/material.dart';
1214
import 'package:flutter_bloc/flutter_bloc.dart';
1315

16+
import 'keyboard_scroller.dart';
1417
import 'page_preview.dart';
1518
import 'search_ask_ai_entrance.dart';
19+
import 'search_field.dart';
1620

1721
class RecentViewsList extends StatelessWidget {
1822
const RecentViewsList({super.key, required this.onSelected});
@@ -26,15 +30,54 @@ class RecentViewsList extends StatelessWidget {
2630
RecentViewsBloc()..add(const RecentViewsEvent.initial()),
2731
child: BlocBuilder<RecentViewsBloc, RecentViewsState>(
2832
builder: (context, state) {
33+
final recentViews = state.views.map((e) => e.item).toSet().toList();
34+
final bloc = context.read<RecentViewsBloc>();
2935
return LayoutBuilder(
3036
builder: (context, constrains) {
3137
final maxWidth = constrains.maxWidth;
3238
final hidePreview = maxWidth < 884;
33-
return Row(
34-
children: [
35-
buildLeftPanel(state, context, hidePreview),
36-
if (!hidePreview) buildPreview(state),
37-
],
39+
final commandPaletteState =
40+
context.read<CommandPaletteBloc>().state;
41+
return ScrollControllerBuilder(
42+
builder: (context, controller) {
43+
return KeyboardScroller<ViewPB>(
44+
onSelect: (index) {
45+
bloc.add(RecentViewsEvent.hoverView(recentViews[index]));
46+
},
47+
idGetter: (item) => item.id,
48+
list: recentViews,
49+
controller: controller,
50+
selectedIndexGetter: () => recentViews.indexWhere(
51+
(item) => item.id == bloc.state.hoveredView?.id,
52+
),
53+
builder: (context, detectors) {
54+
return Column(
55+
crossAxisAlignment: CrossAxisAlignment.start,
56+
mainAxisSize: MainAxisSize.min,
57+
children: [
58+
SearchField(
59+
query: commandPaletteState.query,
60+
isLoading: commandPaletteState.searching,
61+
),
62+
Flexible(
63+
child: Row(
64+
children: [
65+
buildLeftPanel(
66+
state: state,
67+
context: context,
68+
hidePreview: hidePreview,
69+
controller: controller,
70+
detectors: detectors,
71+
),
72+
if (!hidePreview) buildPreview(state),
73+
],
74+
),
75+
),
76+
],
77+
);
78+
},
79+
);
80+
},
3881
);
3982
},
4083
);
@@ -43,45 +86,48 @@ class RecentViewsList extends StatelessWidget {
4386
);
4487
}
4588

46-
Widget buildLeftPanel(
47-
RecentViewsState state,
48-
BuildContext context,
49-
bool hidePreview,
50-
) {
89+
Widget buildLeftPanel({
90+
required RecentViewsState state,
91+
required BuildContext context,
92+
required bool hidePreview,
93+
required ScrollController controller,
94+
required AreaDetectors detectors,
95+
}) {
5196
final workspaceState = context.read<UserWorkspaceBloc?>()?.state;
5297
final showAskingAI =
5398
workspaceState?.userProfile.workspaceType == WorkspaceTypePB.ServerW;
5499
return Flexible(
55100
child: Align(
56101
alignment: Alignment.topLeft,
57-
child: ScrollControllerBuilder(
58-
builder: (context, controller) {
59-
return Padding(
60-
padding: EdgeInsets.only(right: hidePreview ? 0 : 6),
61-
child: FlowyScrollbar(
62-
controller: controller,
63-
thumbVisibility: false,
64-
child: SingleChildScrollView(
65-
controller: controller,
66-
physics: const ClampingScrollPhysics(),
67-
child: Padding(
68-
padding: EdgeInsets.only(
69-
right: hidePreview ? 0 : 6,
70-
),
71-
child: Column(
72-
crossAxisAlignment: CrossAxisAlignment.start,
73-
children: [
74-
if (showAskingAI) SearchAskAiEntrance(),
75-
buildTitle(context),
76-
buildViewList(state, context, hidePreview),
77-
VSpace(16),
78-
],
102+
child: Padding(
103+
padding: EdgeInsets.only(right: hidePreview ? 0 : 6),
104+
child: FlowyScrollbar(
105+
controller: controller,
106+
thumbVisibility: false,
107+
child: SingleChildScrollView(
108+
controller: controller,
109+
physics: const ClampingScrollPhysics(),
110+
child: Padding(
111+
padding: EdgeInsets.only(
112+
right: hidePreview ? 0 : 6,
113+
),
114+
child: Column(
115+
crossAxisAlignment: CrossAxisAlignment.start,
116+
children: [
117+
if (showAskingAI) SearchAskAiEntrance(),
118+
buildTitle(context),
119+
buildViewList(
120+
state: state,
121+
context: context,
122+
hidePreview: hidePreview,
123+
detectors: detectors,
79124
),
80-
),
125+
VSpace(16),
126+
],
81127
),
82128
),
83-
);
84-
},
129+
),
130+
),
85131
),
86132
),
87133
);
@@ -107,11 +153,12 @@ class RecentViewsList extends StatelessWidget {
107153
);
108154
}
109155

110-
Widget buildViewList(
111-
RecentViewsState state,
112-
BuildContext context,
113-
bool hidePreview,
114-
) {
156+
Widget buildViewList({
157+
required RecentViewsState state,
158+
required BuildContext context,
159+
required bool hidePreview,
160+
required AreaDetectors detectors,
161+
}) {
115162
final recentViews = state.views.map((e) => e.item).toSet().toList();
116163

117164
if (recentViews.isEmpty) {
@@ -133,6 +180,7 @@ class RecentViewsList extends StatelessWidget {
133180
view: view,
134181
onSelected: onSelected,
135182
isNarrowWindow: hidePreview,
183+
detectors: detectors,
136184
);
137185
},
138186
);

frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class _AskAIFor extends StatelessWidget {
4040
padding: EdgeInsets.symmetric(vertical: theme.spacing.xs),
4141
child: AFBaseButton(
4242
borderRadius: spaceM,
43+
showFocusRing: false,
4344
padding: EdgeInsets.all(spaceL),
4445
backgroundColor: (context, isHovering, disable) {
4546
if (isHovering) {

0 commit comments

Comments
 (0)