Skip to content

Commit e2f6f68

Browse files
abichingerLucasXu0
andauthored
feat: node widget action menu (#1783)
* feat: add action menu * feat: add customActionMenuBuilder * docs: add comments to action menu classes * fix: enable callout * test: add action menu tests add AppFlowyRenderPluginService.getBuilder * fix: appflowy_editor exports * fix: action menu * chore: add of function to EditorStyle * fix: action menu test --------- Co-authored-by: Lucas.Xu <[email protected]>
1 parent 3491ffd commit e2f6f68

File tree

19 files changed

+738
-342
lines changed

19 files changed

+738
-342
lines changed

frontend/app_flowy/lib/plugins/document/document_page.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ class _DocumentPageState extends State<DocumentPage> {
139139
boardMenuItem,
140140
// Grid
141141
gridMenuItem,
142+
// Callout
143+
calloutMenuItem,
142144
],
143145
themeData: theme.copyWith(extensions: [
144146
...theme.extensions.values,

frontend/app_flowy/lib/plugins/document/editor_styles.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ EditorStyle customEditorTheme(BuildContext context) {
2323
fontFamily: 'poppins-Bold',
2424
),
2525
backgroundColor: Theme.of(context).colorScheme.surface,
26+
selectionMenuItemSelectedIconColor: Theme.of(context).colorScheme.primary,
2627
);
2728
return editorStyle;
2829
}

frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,5 @@ export 'src/plugins/quill_delta/delta_document_encoder.dart';
4545
export 'src/commands/text/text_commands.dart';
4646
export 'src/render/toolbar/toolbar_item.dart';
4747
export 'src/extensions/node_extensions.dart';
48+
export 'src/render/action_menu/action_menu.dart';
49+
export 'src/render/action_menu/action_menu_item.dart';
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import 'package:appflowy_editor/src/core/document/node.dart';
2+
import 'package:appflowy_editor/src/core/document/path.dart';
3+
import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart';
4+
import 'package:appflowy_editor/src/render/style/editor_style.dart';
5+
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
6+
import 'package:flutter/material.dart';
7+
import 'package:provider/provider.dart';
8+
9+
/// [ActionProvider] is an optional mixin to define the actions of a node widget.
10+
mixin ActionProvider<T extends Node> on NodeWidgetBuilder<T> {
11+
List<ActionMenuItem> actions(NodeWidgetContext<T> context);
12+
}
13+
14+
class ActionMenuArenaMember {
15+
final ActionMenuState state;
16+
final VoidCallback listener;
17+
18+
const ActionMenuArenaMember({required this.state, required this.listener});
19+
}
20+
21+
/// Decides which action menu is visible.
22+
/// The menu with the greatest [Node.path] wins.
23+
class ActionMenuArena {
24+
final Map<Path, ActionMenuArenaMember> _members = {};
25+
final Set<Path> _visible = {};
26+
27+
ActionMenuArena._singleton();
28+
static final instance = ActionMenuArena._singleton();
29+
30+
void add(ActionMenuState menuState) {
31+
final member = ActionMenuArenaMember(
32+
state: menuState,
33+
listener: () {
34+
final len = _visible.length;
35+
if (menuState.isHover || menuState.isPinned) {
36+
_visible.add(menuState.path);
37+
} else {
38+
_visible.remove(menuState.path);
39+
}
40+
if (len != _visible.length) {
41+
_notifyAllVisible();
42+
}
43+
},
44+
);
45+
menuState.addListener(member.listener);
46+
_members[menuState.path] = member;
47+
}
48+
49+
void _notifyAllVisible() {
50+
for (var path in _visible) {
51+
_members[path]?.state.notify();
52+
}
53+
}
54+
55+
void remove(ActionMenuState menuState) {
56+
final member = _members.remove(menuState.path);
57+
if (member != null) {
58+
menuState.removeListener(member.listener);
59+
_visible.remove(menuState.path);
60+
}
61+
}
62+
63+
bool isVisible(Path path) {
64+
var sorted = _visible.toList()
65+
..sort(
66+
(a, b) => a <= b ? 1 : -1,
67+
);
68+
return sorted.isNotEmpty && path == sorted.first;
69+
}
70+
}
71+
72+
/// Used to manage the state of each [ActionMenuOverlay].
73+
class ActionMenuState extends ChangeNotifier {
74+
final Path path;
75+
76+
ActionMenuState(this.path) {
77+
ActionMenuArena.instance.add(this);
78+
}
79+
80+
@override
81+
void dispose() {
82+
ActionMenuArena.instance.remove(this);
83+
super.dispose();
84+
}
85+
86+
bool _isHover = false;
87+
bool _isPinned = false;
88+
89+
bool get isPinned => _isPinned;
90+
bool get isHover => _isHover;
91+
bool get isVisible => ActionMenuArena.instance.isVisible(path);
92+
93+
set isPinned(bool value) {
94+
if (_isPinned == value) {
95+
return;
96+
}
97+
_isPinned = value;
98+
notifyListeners();
99+
}
100+
101+
set isHover(bool value) {
102+
if (_isHover == value) {
103+
return;
104+
}
105+
_isHover = value;
106+
notifyListeners();
107+
}
108+
109+
void notify() {
110+
notifyListeners();
111+
}
112+
}
113+
114+
/// The default widget to render an action menu
115+
class ActionMenuWidget extends StatelessWidget {
116+
final List<ActionMenuItem> items;
117+
118+
const ActionMenuWidget({super.key, required this.items});
119+
120+
@override
121+
Widget build(BuildContext context) {
122+
final editorStyle = EditorStyle.of(context);
123+
124+
return Card(
125+
color: editorStyle?.selectionMenuBackgroundColor,
126+
elevation: 3.0,
127+
child: Row(
128+
mainAxisSize: MainAxisSize.min,
129+
children: items.map((item) {
130+
return ActionMenuItemWidget(
131+
item: item,
132+
);
133+
}).toList(),
134+
),
135+
);
136+
}
137+
}
138+
139+
class ActionMenuOverlay extends StatelessWidget {
140+
final Widget child;
141+
final List<ActionMenuItem> items;
142+
final Positioned Function(BuildContext context, List<ActionMenuItem> items)?
143+
customActionMenuBuilder;
144+
145+
const ActionMenuOverlay({
146+
super.key,
147+
required this.items,
148+
required this.child,
149+
this.customActionMenuBuilder,
150+
});
151+
152+
@override
153+
Widget build(BuildContext context) {
154+
final menuState = Provider.of<ActionMenuState>(context);
155+
156+
return MouseRegion(
157+
onEnter: (_) {
158+
menuState.isHover = true;
159+
},
160+
onExit: (_) {
161+
menuState.isHover = false;
162+
},
163+
onHover: (_) {
164+
menuState.isHover = true;
165+
},
166+
child: Stack(
167+
children: [
168+
child,
169+
if (menuState.isVisible) _buildMenu(context),
170+
],
171+
),
172+
);
173+
}
174+
175+
Positioned _buildMenu(BuildContext context) {
176+
return customActionMenuBuilder != null
177+
? customActionMenuBuilder!(context, items)
178+
: Positioned(top: 5, right: 5, child: ActionMenuWidget(items: items));
179+
}
180+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import 'package:appflowy_editor/appflowy_editor.dart';
2+
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
3+
import 'package:flutter/material.dart';
4+
5+
/// Represents a single action inside an action menu.
6+
///
7+
/// [itemWrapper] can be used to wrap the [ActionMenuItemWidget] with another
8+
/// widget (e.g. a popover).
9+
class ActionMenuItem {
10+
final Widget Function({double? size, Color? color}) iconBuilder;
11+
final Function()? onPressed;
12+
final bool Function()? selected;
13+
final Widget Function(Widget item)? itemWrapper;
14+
15+
ActionMenuItem({
16+
required this.iconBuilder,
17+
required this.onPressed,
18+
this.selected,
19+
this.itemWrapper,
20+
});
21+
22+
factory ActionMenuItem.icon({
23+
required IconData iconData,
24+
required Function()? onPressed,
25+
bool Function()? selected,
26+
Widget Function(Widget item)? itemWrapper,
27+
}) {
28+
return ActionMenuItem(
29+
iconBuilder: ({size, color}) {
30+
return Icon(
31+
iconData,
32+
size: size,
33+
color: color,
34+
);
35+
},
36+
onPressed: onPressed,
37+
selected: selected,
38+
itemWrapper: itemWrapper,
39+
);
40+
}
41+
42+
factory ActionMenuItem.svg({
43+
required String name,
44+
required Function()? onPressed,
45+
bool Function()? selected,
46+
Widget Function(Widget item)? itemWrapper,
47+
}) {
48+
return ActionMenuItem(
49+
iconBuilder: ({size, color}) {
50+
return FlowySvg(
51+
name: name,
52+
color: color,
53+
width: size,
54+
height: size,
55+
);
56+
},
57+
onPressed: onPressed,
58+
selected: selected,
59+
itemWrapper: itemWrapper,
60+
);
61+
}
62+
63+
factory ActionMenuItem.separator() {
64+
return ActionMenuItem(
65+
iconBuilder: ({size, color}) {
66+
return FlowySvg(
67+
name: 'image_toolbar/divider',
68+
color: color,
69+
height: size,
70+
);
71+
},
72+
onPressed: null,
73+
);
74+
}
75+
}
76+
77+
class ActionMenuItemWidget extends StatelessWidget {
78+
final ActionMenuItem item;
79+
final double iconSize;
80+
81+
const ActionMenuItemWidget({
82+
super.key,
83+
required this.item,
84+
this.iconSize = 20,
85+
});
86+
87+
@override
88+
Widget build(BuildContext context) {
89+
final editorStyle = EditorStyle.of(context);
90+
final isSelected = item.selected?.call() ?? false;
91+
final color = isSelected
92+
? editorStyle?.selectionMenuItemSelectedIconColor
93+
: editorStyle?.selectionMenuItemIconColor;
94+
95+
var icon = item.iconBuilder(size: iconSize, color: color);
96+
var itemWidget = Padding(
97+
padding: const EdgeInsets.all(3),
98+
child: item.onPressed != null
99+
? MouseRegion(
100+
cursor: SystemMouseCursors.click,
101+
child: GestureDetector(
102+
onTap: item.onPressed,
103+
child: icon,
104+
),
105+
)
106+
: icon,
107+
);
108+
109+
return item.itemWrapper?.call(itemWidget) ?? itemWidget;
110+
}
111+
}

0 commit comments

Comments
 (0)