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 <lucas.xu@appflowy.io>
This commit is contained in:
abichinger 2023-02-07 03:03:36 +01:00 committed by GitHub
parent 3491ffdd08
commit e2f6f68923
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 738 additions and 342 deletions

View File

@ -139,6 +139,8 @@ class _DocumentPageState extends State<DocumentPage> {
boardMenuItem,
// Grid
gridMenuItem,
// Callout
calloutMenuItem,
],
themeData: theme.copyWith(extensions: [
...theme.extensions.values,

View File

@ -23,6 +23,7 @@ EditorStyle customEditorTheme(BuildContext context) {
fontFamily: 'poppins-Bold',
),
backgroundColor: Theme.of(context).colorScheme.surface,
selectionMenuItemSelectedIconColor: Theme.of(context).colorScheme.primary,
);
return editorStyle;
}

View File

@ -45,3 +45,5 @@ export 'src/plugins/quill_delta/delta_document_encoder.dart';
export 'src/commands/text/text_commands.dart';
export 'src/render/toolbar/toolbar_item.dart';
export 'src/extensions/node_extensions.dart';
export 'src/render/action_menu/action_menu.dart';
export 'src/render/action_menu/action_menu_item.dart';

View File

@ -0,0 +1,180 @@
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart';
import 'package:appflowy_editor/src/render/style/editor_style.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// [ActionProvider] is an optional mixin to define the actions of a node widget.
mixin ActionProvider<T extends Node> on NodeWidgetBuilder<T> {
List<ActionMenuItem> actions(NodeWidgetContext<T> context);
}
class ActionMenuArenaMember {
final ActionMenuState state;
final VoidCallback listener;
const ActionMenuArenaMember({required this.state, required this.listener});
}
/// Decides which action menu is visible.
/// The menu with the greatest [Node.path] wins.
class ActionMenuArena {
final Map<Path, ActionMenuArenaMember> _members = {};
final Set<Path> _visible = {};
ActionMenuArena._singleton();
static final instance = ActionMenuArena._singleton();
void add(ActionMenuState menuState) {
final member = ActionMenuArenaMember(
state: menuState,
listener: () {
final len = _visible.length;
if (menuState.isHover || menuState.isPinned) {
_visible.add(menuState.path);
} else {
_visible.remove(menuState.path);
}
if (len != _visible.length) {
_notifyAllVisible();
}
},
);
menuState.addListener(member.listener);
_members[menuState.path] = member;
}
void _notifyAllVisible() {
for (var path in _visible) {
_members[path]?.state.notify();
}
}
void remove(ActionMenuState menuState) {
final member = _members.remove(menuState.path);
if (member != null) {
menuState.removeListener(member.listener);
_visible.remove(menuState.path);
}
}
bool isVisible(Path path) {
var sorted = _visible.toList()
..sort(
(a, b) => a <= b ? 1 : -1,
);
return sorted.isNotEmpty && path == sorted.first;
}
}
/// Used to manage the state of each [ActionMenuOverlay].
class ActionMenuState extends ChangeNotifier {
final Path path;
ActionMenuState(this.path) {
ActionMenuArena.instance.add(this);
}
@override
void dispose() {
ActionMenuArena.instance.remove(this);
super.dispose();
}
bool _isHover = false;
bool _isPinned = false;
bool get isPinned => _isPinned;
bool get isHover => _isHover;
bool get isVisible => ActionMenuArena.instance.isVisible(path);
set isPinned(bool value) {
if (_isPinned == value) {
return;
}
_isPinned = value;
notifyListeners();
}
set isHover(bool value) {
if (_isHover == value) {
return;
}
_isHover = value;
notifyListeners();
}
void notify() {
notifyListeners();
}
}
/// The default widget to render an action menu
class ActionMenuWidget extends StatelessWidget {
final List<ActionMenuItem> items;
const ActionMenuWidget({super.key, required this.items});
@override
Widget build(BuildContext context) {
final editorStyle = EditorStyle.of(context);
return Card(
color: editorStyle?.selectionMenuBackgroundColor,
elevation: 3.0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: items.map((item) {
return ActionMenuItemWidget(
item: item,
);
}).toList(),
),
);
}
}
class ActionMenuOverlay extends StatelessWidget {
final Widget child;
final List<ActionMenuItem> items;
final Positioned Function(BuildContext context, List<ActionMenuItem> items)?
customActionMenuBuilder;
const ActionMenuOverlay({
super.key,
required this.items,
required this.child,
this.customActionMenuBuilder,
});
@override
Widget build(BuildContext context) {
final menuState = Provider.of<ActionMenuState>(context);
return MouseRegion(
onEnter: (_) {
menuState.isHover = true;
},
onExit: (_) {
menuState.isHover = false;
},
onHover: (_) {
menuState.isHover = true;
},
child: Stack(
children: [
child,
if (menuState.isVisible) _buildMenu(context),
],
),
);
}
Positioned _buildMenu(BuildContext context) {
return customActionMenuBuilder != null
? customActionMenuBuilder!(context, items)
: Positioned(top: 5, right: 5, child: ActionMenuWidget(items: items));
}
}

View File

@ -0,0 +1,111 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:flutter/material.dart';
/// Represents a single action inside an action menu.
///
/// [itemWrapper] can be used to wrap the [ActionMenuItemWidget] with another
/// widget (e.g. a popover).
class ActionMenuItem {
final Widget Function({double? size, Color? color}) iconBuilder;
final Function()? onPressed;
final bool Function()? selected;
final Widget Function(Widget item)? itemWrapper;
ActionMenuItem({
required this.iconBuilder,
required this.onPressed,
this.selected,
this.itemWrapper,
});
factory ActionMenuItem.icon({
required IconData iconData,
required Function()? onPressed,
bool Function()? selected,
Widget Function(Widget item)? itemWrapper,
}) {
return ActionMenuItem(
iconBuilder: ({size, color}) {
return Icon(
iconData,
size: size,
color: color,
);
},
onPressed: onPressed,
selected: selected,
itemWrapper: itemWrapper,
);
}
factory ActionMenuItem.svg({
required String name,
required Function()? onPressed,
bool Function()? selected,
Widget Function(Widget item)? itemWrapper,
}) {
return ActionMenuItem(
iconBuilder: ({size, color}) {
return FlowySvg(
name: name,
color: color,
width: size,
height: size,
);
},
onPressed: onPressed,
selected: selected,
itemWrapper: itemWrapper,
);
}
factory ActionMenuItem.separator() {
return ActionMenuItem(
iconBuilder: ({size, color}) {
return FlowySvg(
name: 'image_toolbar/divider',
color: color,
height: size,
);
},
onPressed: null,
);
}
}
class ActionMenuItemWidget extends StatelessWidget {
final ActionMenuItem item;
final double iconSize;
const ActionMenuItemWidget({
super.key,
required this.item,
this.iconSize = 20,
});
@override
Widget build(BuildContext context) {
final editorStyle = EditorStyle.of(context);
final isSelected = item.selected?.call() ?? false;
final color = isSelected
? editorStyle?.selectionMenuItemSelectedIconColor
: editorStyle?.selectionMenuItemIconColor;
var icon = item.iconBuilder(size: iconSize, color: color);
var itemWidget = Padding(
padding: const EdgeInsets.all(3),
child: item.onPressed != null
? MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: item.onPressed,
child: icon,
),
)
: icon,
);
return item.itemWrapper?.call(itemWidget) ?? itemWidget;
}
}

View File

@ -1,11 +1,14 @@
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/infra/clipboard.dart';
import 'package:appflowy_editor/src/render/action_menu/action_menu.dart';
import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:flutter/material.dart';
import 'image_node_widget.dart';
class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
class ImageNodeBuilder extends NodeWidgetBuilder<Node>
with ActionProvider<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
final src = context.node.attributes['image_src'];
@ -20,21 +23,6 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
src: src,
width: width,
alignment: _textToAlignment(align),
onCopy: () {
AppFlowyClipboard.setData(text: src);
},
onDelete: () {
final transaction = context.editorState.transaction
..deleteNode(context.node);
context.editorState.apply(transaction);
},
onAlign: (alignment) {
final transaction = context.editorState.transaction
..updateNode(context.node, {
'align': _alignmentToText(alignment),
});
context.editorState.apply(transaction);
},
onResize: (width) {
final transaction = context.editorState.transaction
..updateNode(context.node, {
@ -52,6 +40,52 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
node.attributes.containsKey('align');
});
@override
List<ActionMenuItem> actions(NodeWidgetContext<Node> context) {
return [
ActionMenuItem.svg(
name: 'image_toolbar/align_left',
selected: () {
final align = context.node.attributes['align'];
return _textToAlignment(align) == Alignment.centerLeft;
},
onPressed: () => _onAlign(context, Alignment.centerLeft),
),
ActionMenuItem.svg(
name: 'image_toolbar/align_center',
selected: () {
final align = context.node.attributes['align'];
return _textToAlignment(align) == Alignment.center;
},
onPressed: () => _onAlign(context, Alignment.center),
),
ActionMenuItem.svg(
name: 'image_toolbar/align_right',
selected: () {
final align = context.node.attributes['align'];
return _textToAlignment(align) == Alignment.centerRight;
},
onPressed: () => _onAlign(context, Alignment.centerRight),
),
ActionMenuItem.separator(),
ActionMenuItem.svg(
name: 'image_toolbar/copy',
onPressed: () {
final src = context.node.attributes['image_src'];
AppFlowyClipboard.setData(text: src);
},
),
ActionMenuItem.svg(
name: 'image_toolbar/delete',
onPressed: () {
final transaction = context.editorState.transaction
..deleteNode(context.node);
context.editorState.apply(transaction);
},
),
];
}
Alignment _textToAlignment(String text) {
if (text == 'left') {
return Alignment.centerLeft;
@ -69,4 +103,12 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
}
return 'center';
}
void _onAlign(NodeWidgetContext context, Alignment alignment) {
final transaction = context.editorState.transaction
..updateNode(context.node, {
'align': _alignmentToText(alignment),
});
context.editorState.apply(transaction);
}
}

View File

@ -1,8 +1,7 @@
import 'package:appflowy_editor/src/extensions/object_extensions.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/extensions/object_extensions.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:flutter/material.dart';
@ -13,9 +12,6 @@ class ImageNodeWidget extends StatefulWidget {
required this.src,
this.width,
required this.alignment,
required this.onCopy,
required this.onDelete,
required this.onAlign,
required this.onResize,
}) : super(key: key);
@ -23,9 +19,6 @@ class ImageNodeWidget extends StatefulWidget {
final String src;
final double? width;
final Alignment alignment;
final VoidCallback onCopy;
final VoidCallback onDelete;
final void Function(Alignment alignment) onAlign;
final void Function(double width) onResize;
@override
@ -146,8 +139,12 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget>
widget.src,
width: _imageWidth == null ? null : _imageWidth! - _distance,
gaplessPlayback: true,
loadingBuilder: (context, child, loadingProgress) =>
loadingProgress == null ? child : _buildLoading(context),
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null ||
loadingProgress.cumulativeBytesLoaded ==
loadingProgress.expectedTotalBytes) return child;
return _buildLoading(context);
},
errorBuilder: (context, error, stackTrace) {
// _imageWidth ??= defaultMaxTextNodeWidth;
return _buildError(context);
@ -184,16 +181,6 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget>
});
},
),
if (_onFocus)
ImageToolbar(
top: 8,
right: 8,
height: 30,
alignment: widget.alignment,
onAlign: widget.onAlign,
onCopy: widget.onCopy,
onDelete: widget.onDelete,
)
],
);
}
@ -282,121 +269,3 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget>
);
}
}
@visibleForTesting
class ImageToolbar extends StatelessWidget {
const ImageToolbar({
Key? key,
required this.top,
required this.right,
required this.height,
required this.alignment,
required this.onCopy,
required this.onDelete,
required this.onAlign,
}) : super(key: key);
final double top;
final double right;
final double height;
final Alignment alignment;
final VoidCallback onCopy;
final VoidCallback onDelete;
final void Function(Alignment alignment) onAlign;
@override
Widget build(BuildContext context) {
return Positioned(
top: top,
right: right,
height: height,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF333333),
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(8.0),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(6.0, 4.0, 0.0, 4.0),
icon: FlowySvg(
name: 'image_toolbar/align_left',
color: alignment == Alignment.centerLeft
? const Color(0xFF00BCF0)
: null,
),
onPressed: () {
onAlign(Alignment.centerLeft);
},
),
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 4.0),
icon: FlowySvg(
name: 'image_toolbar/align_center',
color: alignment == Alignment.center
? const Color(0xFF00BCF0)
: null,
),
onPressed: () {
onAlign(Alignment.center);
},
),
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(0.0, 4.0, 4.0, 4.0),
icon: FlowySvg(
name: 'image_toolbar/align_right',
color: alignment == Alignment.centerRight
? const Color(0xFF00BCF0)
: null,
),
onPressed: () {
onAlign(Alignment.centerRight);
},
),
const Center(
child: FlowySvg(
name: 'image_toolbar/divider',
),
),
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(4.0, 4.0, 0.0, 4.0),
icon: const FlowySvg(
name: 'image_toolbar/copy',
),
onPressed: () {
onCopy();
},
),
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(0.0, 4.0, 6.0, 4.0),
icon: const FlowySvg(
name: 'image_toolbar/delete',
),
onPressed: () {
onDelete();
},
),
],
),
),
);
}
}

View File

@ -158,6 +158,10 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
);
}
static EditorStyle? of(BuildContext context) {
return Theme.of(context).extension<EditorStyle>();
}
static final light = EditorStyle(
padding: const EdgeInsets.fromLTRB(200.0, 0.0, 200.0, 0.0),
backgroundColor: Colors.white,
@ -166,8 +170,8 @@ class EditorStyle extends ThemeExtension<EditorStyle> {
selectionMenuBackgroundColor: const Color(0xFFFFFFFF),
selectionMenuItemTextColor: const Color(0xFF333333),
selectionMenuItemIconColor: const Color(0xFF333333),
selectionMenuItemSelectedTextColor: const Color(0xFF333333),
selectionMenuItemSelectedIconColor: const Color(0xFF333333),
selectionMenuItemSelectedTextColor: const Color.fromARGB(255, 56, 91, 247),
selectionMenuItemSelectedIconColor: const Color.fromARGB(255, 56, 91, 247),
selectionMenuItemSelectedColor: const Color(0xFFE0F8FF),
textPadding: const EdgeInsets.symmetric(vertical: 8.0),
textStyle: const TextStyle(fontSize: 16.0, color: Colors.black),

View File

@ -1,16 +1,15 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/flutter/overlay.dart';
import 'package:appflowy_editor/src/render/image/image_node_builder.dart';
import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
import 'package:appflowy_editor/src/render/editor/editor_entry.dart';
import 'package:appflowy_editor/src/render/image/image_node_builder.dart';
import 'package:appflowy_editor/src/render/rich_text/bulleted_list_text.dart';
import 'package:appflowy_editor/src/render/rich_text/checkbox_text.dart';
import 'package:appflowy_editor/src/render/rich_text/heading_text.dart';
import 'package:appflowy_editor/src/render/rich_text/number_list_text.dart';
import 'package:appflowy_editor/src/render/rich_text/quoted_text.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text.dart';
import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
NodeWidgetBuilders defaultBuilders = {
'editor': EditorEntryWidgetBuilder(),
@ -33,6 +32,7 @@ class AppFlowyEditor extends StatefulWidget {
this.toolbarItems = const [],
this.editable = true,
this.autoFocus = false,
this.customActionMenuBuilder,
ThemeData? themeData,
}) : super(key: key) {
this.themeData = themeData ??
@ -61,6 +61,9 @@ class AppFlowyEditor extends StatefulWidget {
/// Set the value to true to focus the editor on the start of the document.
final bool autoFocus;
final Positioned Function(BuildContext context, List<ActionMenuItem> items)?
customActionMenuBuilder;
@override
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
}
@ -171,5 +174,6 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
...defaultBuilders,
...widget.customBuilders,
},
customActionMenuBuilder: widget.customActionMenuBuilder,
);
}

View File

@ -1,6 +1,8 @@
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/log.dart';
import 'package:appflowy_editor/src/render/action_menu/action_menu.dart';
import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -29,6 +31,9 @@ abstract class AppFlowyRenderPluginService {
/// UnRegister plugin with specified [name].
void unRegister(String name);
/// Returns a [NodeWidgetBuilder], if one has been registered for [name]
NodeWidgetBuilder? getBuilder(String name);
Widget buildPluginWidget(NodeWidgetContext context);
}
@ -57,9 +62,13 @@ class NodeWidgetContext<T extends Node> {
}
class AppFlowyRenderPlugin extends AppFlowyRenderPluginService {
final Positioned Function(BuildContext context, List<ActionMenuItem> items)?
customActionMenuBuilder;
AppFlowyRenderPlugin({
required this.editorState,
required NodeWidgetBuilders builders,
this.customActionMenuBuilder,
}) {
registerAll(builders);
}
@ -106,6 +115,11 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService {
_builders.remove(name);
}
@override
NodeWidgetBuilder? getBuilder(String name) {
return _builders[name];
}
Widget _autoUpdateNodeWidget(
NodeWidgetBuilder builder, NodeWidgetContext context) {
Widget notifier;
@ -116,7 +130,7 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService {
return Consumer<TextNode>(
builder: ((_, value, child) {
Log.ui.debug('TextNode is rebuilding...');
return builder.build(context);
return _buildWithActions(builder, context);
}),
);
});
@ -127,7 +141,7 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService {
return Consumer<Node>(
builder: ((_, value, child) {
Log.ui.debug('Node is rebuilding...');
return builder.build(context);
return _buildWithActions(builder, context);
}),
);
});
@ -138,6 +152,22 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService {
);
}
Widget _buildWithActions(
NodeWidgetBuilder builder, NodeWidgetContext context) {
if (builder is ActionProvider) {
return ChangeNotifierProvider(
create: (_) => ActionMenuState(context.node.path),
child: ActionMenuOverlay(
items: builder.actions(context),
customActionMenuBuilder: customActionMenuBuilder,
child: builder.build(context),
),
);
} else {
return builder.build(context);
}
}
void _validatePlugin(String name) {
final paths = name.split('/');
if (paths.length > 2) {

View File

@ -68,7 +68,7 @@ class EditorWidgetTester {
);
}
void insertImageNode(String src, {String? align}) {
void insertImageNode(String src, {String? align, double? width}) {
insert(
Node(
type: 'image',
@ -76,6 +76,7 @@ class EditorWidgetTester {
attributes: {
'image_src': src,
'align': align ?? 'center',
...width != null ? {'width': width} : {},
},
),
);
@ -161,6 +162,40 @@ class EditorWidgetTester {
..disableSealTimer = true
..disbaleRules = true;
}
bool runAction(int actionIndex, Node node) {
final builder = editorState.service.renderPluginService.getBuilder(node.id);
if (builder is! ActionProvider) {
return false;
}
final buildContext = node.key.currentContext;
if (buildContext == null) {
return false;
}
final context = node is TextNode
? NodeWidgetContext<TextNode>(
context: buildContext,
node: node,
editorState: editorState,
)
: NodeWidgetContext<Node>(
context: buildContext,
node: node,
editorState: editorState,
);
final actions =
builder.actions(context).where((a) => a.onPressed != null).toList();
if (actionIndex > actions.length) {
return false;
}
final action = actions[actionIndex];
action.onPressed!();
return true;
}
}
extension TestString on String {

View File

@ -0,0 +1,165 @@
import 'package:appflowy_editor/src/render/action_menu/action_menu.dart';
import 'package:appflowy_editor/src/render/action_menu/action_menu_item.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('action_menu.dart', () {
testWidgets('hover and tap action', (tester) async {
var actionHit = false;
final widget = ActionMenuOverlay(
items: [
ActionMenuItem.icon(
iconData: Icons.download,
onPressed: () => actionHit = true,
)
],
child: const SizedBox(
height: 100,
width: 100,
),
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ChangeNotifierProvider(
create: (context) => ActionMenuState([]),
child: widget,
),
),
),
);
expect(find.byType(ActionMenuWidget), findsNothing);
final actionMenuOverlay = find.byType(ActionMenuOverlay);
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
await tester.pump();
await gesture.moveTo(tester.getCenter(actionMenuOverlay));
await tester.pumpAndSettle();
final actionMenu = find.byType(ActionMenuWidget);
expect(actionMenu, findsOneWidget);
final action = find.descendant(
of: actionMenu,
matching: find.byType(ActionMenuItemWidget),
);
expect(action, findsOneWidget);
await tester.tap(action);
expect(actionHit, true);
});
testWidgets('stacked action menu overlays', (tester) async {
final childWidget = ChangeNotifierProvider(
create: (context) => ActionMenuState([0, 0]),
child: ActionMenuOverlay(
items: [
ActionMenuItem(
iconBuilder: ({color, size}) => const Text("child"),
onPressed: null,
)
],
child: const SizedBox(
height: 100,
width: 100,
),
),
);
final parentWidget = ChangeNotifierProvider(
create: (context) => ActionMenuState([0]),
child: ActionMenuOverlay(
items: [
ActionMenuItem(
iconBuilder: ({color, size}) => const Text("parent"),
onPressed: null,
)
],
child: childWidget,
),
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(child: parentWidget),
),
),
);
expect(find.byType(ActionMenuWidget), findsNothing);
final overlays = find.byType(ActionMenuOverlay);
expect(
tester.getCenter(overlays.at(0)),
tester.getCenter(overlays.at(1)),
);
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
await tester.pump();
await gesture.moveTo(tester.getCenter(overlays.at(0)));
await tester.pumpAndSettle();
final actionMenu = find.byType(ActionMenuWidget);
expect(actionMenu, findsOneWidget);
expect(find.text("child"), findsOneWidget);
expect(find.text("parent"), findsNothing);
});
testWidgets('customActionMenuBuilder', (tester) async {
final widget = ActionMenuOverlay(
items: [
ActionMenuItem.icon(
iconData: Icons.download,
onPressed: null,
)
],
customActionMenuBuilder: (context, items) {
return const Positioned.fill(
child: Center(
child: Text("custom"),
),
);
},
child: const SizedBox(
height: 100,
width: 100,
),
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ChangeNotifierProvider(
create: (context) => ActionMenuState([]),
child: widget,
),
),
),
);
expect(find.text("custom"), findsNothing);
final actionMenuOverlay = find.byType(ActionMenuOverlay);
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
await tester.pump();
await gesture.moveTo(tester.getCenter(actionMenuOverlay));
await tester.pumpAndSettle();
expect(find.text("custom"), findsOneWidget);
});
});
}

View File

@ -1,4 +1,3 @@
import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
import 'package:appflowy_editor/src/service/editor_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@ -22,6 +21,7 @@ void main() async {
..insertImageNode(src)
..insertTextNode(text);
await editor.startTesting();
await tester.pumpAndSettle();
expect(editor.documentLength, 3);
expect(find.byType(Image), findsOneWidget);
@ -35,11 +35,12 @@ void main() async {
'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
final editor = tester.editor
..insertTextNode(text)
..insertImageNode(src, align: 'left')
..insertImageNode(src, align: 'center')
..insertImageNode(src, align: 'right')
..insertImageNode(src, align: 'left', width: 100)
..insertImageNode(src, align: 'center', width: 100)
..insertImageNode(src, align: 'right', width: 100)
..insertTextNode(text);
await editor.startTesting();
await tester.pumpAndSettle();
expect(editor.documentLength, 5);
final imageFinder = find.byType(Image);
@ -60,20 +61,17 @@ void main() async {
expect(leftImageRect.size, centerImageRect.size);
expect(rightImageRect.size, centerImageRect.size);
final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
final leftImageNode = editor.document.nodeAtPath([1]);
final leftImage =
tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
leftImage.onAlign(Alignment.center);
await tester.pump(const Duration(milliseconds: 100));
expect(editor.runAction(1, leftImageNode!), true); // align center
await tester.pump();
expect(
tester.getRect(imageFinder.at(0)).left,
centerImageRect.left,
);
leftImage.onAlign(Alignment.centerRight);
await tester.pump(const Duration(milliseconds: 100));
expect(editor.runAction(2, leftImageNode), true); // align right
await tester.pump();
expect(
tester.getRect(imageFinder.at(0)).right,
rightImageRect.right,
@ -96,10 +94,10 @@ void main() async {
final imageFinder = find.byType(Image);
expect(imageFinder, findsOneWidget);
final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
final image =
tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
image.onCopy();
final imageNode = editor.document.nodeAtPath([1]);
expect(editor.runAction(3, imageNode!), true); // copy
await tester.pump();
});
});
@ -119,10 +117,8 @@ void main() async {
final imageFinder = find.byType(Image);
expect(imageFinder, findsNWidgets(2));
final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
final image =
tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
image.onDelete();
final imageNode = editor.document.nodeAtPath([1]);
expect(editor.runAction(4, imageNode!), true); // delete
await tester.pump(const Duration(milliseconds: 100));
expect(editor.documentLength, 3);

View File

@ -2,7 +2,6 @@ import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:network_image_mock/network_image_mock.dart';
@ -15,14 +14,12 @@ void main() async {
group('image_node_widget.dart', () {
testWidgets('build the image node widget', (tester) async {
mockNetworkImagesFor(() async {
var onCopyHit = false;
var onDeleteHit = false;
var onAlignHit = false;
const src =
'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
final widget = ImageNodeWidget(
src: src,
width: 100,
node: Node(
type: 'image',
children: LinkedList(),
@ -32,15 +29,6 @@ void main() async {
},
),
alignment: Alignment.center,
onCopy: () {
onCopyHit = true;
},
onDelete: () {
onDeleteHit = true;
},
onAlign: (alignment) {
onAlignHit = true;
},
onResize: (width) {},
);
@ -51,41 +39,20 @@ void main() async {
),
),
);
expect(find.byType(ImageNodeWidget), findsOneWidget);
await tester.pumpAndSettle();
final gesture =
await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
final imageNodeFinder = find.byType(ImageNodeWidget);
expect(imageNodeFinder, findsOneWidget);
expect(find.byType(ImageToolbar), findsNothing);
final imageFinder = find.byType(Image);
expect(imageFinder, findsOneWidget);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(tester.getCenter(find.byType(ImageNodeWidget)));
await tester.pump();
final imageNodeRect = tester.getRect(imageNodeFinder);
final imageRect = tester.getRect(imageFinder);
expect(find.byType(ImageToolbar), findsOneWidget);
final iconFinder = find.byType(IconButton);
expect(iconFinder, findsNWidgets(5));
await tester.tap(iconFinder.at(0));
expect(onAlignHit, true);
onAlignHit = false;
await tester.tap(iconFinder.at(1));
expect(onAlignHit, true);
onAlignHit = false;
await tester.tap(iconFinder.at(2));
expect(onAlignHit, true);
onAlignHit = false;
await tester.tap(iconFinder.at(3));
expect(onCopyHit, true);
await tester.tap(iconFinder.at(4));
expect(onDeleteHit, true);
expect(imageRect.width, 100);
expect((imageNodeRect.left - imageRect.left).abs(),
(imageNodeRect.right - imageRect.right).abs());
});
});
});

View File

@ -6,8 +6,8 @@ import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/color_picker.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
const String kCalloutType = 'callout';
const String kCalloutAttrColor = 'color';
@ -28,7 +28,8 @@ SelectionMenuItem calloutMenuItem = SelectionMenuItem.node(
},
);
class CalloutNodeWidgetBuilder extends NodeWidgetBuilder<Node> {
class CalloutNodeWidgetBuilder extends NodeWidgetBuilder<Node>
with ActionProvider<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _CalloutWidget(
@ -40,6 +41,61 @@ class CalloutNodeWidgetBuilder extends NodeWidgetBuilder<Node> {
@override
NodeValidator<Node> get nodeValidator => (node) => node.type == kCalloutType;
_CalloutWidgetState? _getState(NodeWidgetContext<Node> context) {
return context.node.key.currentState as _CalloutWidgetState?;
}
BuildContext? _getBuildContext(NodeWidgetContext<Node> context) {
return context.node.key.currentContext;
}
@override
List<ActionMenuItem> actions(NodeWidgetContext<Node> context) {
return [
ActionMenuItem.icon(
iconData: Icons.color_lens_outlined,
onPressed: () {
final state = _getState(context);
final ctx = _getBuildContext(context);
if (state == null || ctx == null) {
return;
}
final menuState = Provider.of<ActionMenuState>(ctx, listen: false);
menuState.isPinned = true;
state.colorPopoverController.show();
},
itemWrapper: (item) {
final state = _getState(context);
final ctx = _getBuildContext(context);
if (state == null || ctx == null) {
return item;
}
return AppFlowyPopover(
controller: state.colorPopoverController,
popupBuilder: (context) => state._buildColorPicker(),
constraints: BoxConstraints.loose(const Size(200, 460)),
triggerActions: 0,
offset: const Offset(0, 30),
child: item,
onClose: () {
final menuState =
Provider.of<ActionMenuState>(ctx, listen: false);
menuState.isPinned = false;
},
);
},
),
ActionMenuItem.svg(
name: 'delete',
onPressed: () {
final transaction = context.editorState.transaction
..deleteNode(context.node);
context.editorState.apply(transaction);
},
),
];
}
}
class _CalloutWidget extends StatefulWidget {
@ -57,7 +113,6 @@ class _CalloutWidget extends StatefulWidget {
}
class _CalloutWidgetState extends State<_CalloutWidget> with SelectableMixin {
bool isHover = false;
final PopoverController colorPopoverController = PopoverController();
final PopoverController emojiPopoverController = PopoverController();
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
@ -82,27 +137,6 @@ class _CalloutWidgetState extends State<_CalloutWidget> with SelectableMixin {
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) {
setState(() {
isHover = true;
});
},
onExit: (_) {
setState(() {
isHover = false;
});
},
child: Stack(
children: [
_buildCallout(),
Positioned(top: 5, right: 5, child: _buildMenu()),
],
),
);
}
Widget _buildCallout() {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
@ -149,35 +183,11 @@ class _CalloutWidgetState extends State<_CalloutWidget> with SelectableMixin {
Size size = const Size(200, 460),
}) {
return AppFlowyPopover(
controller: controller,
constraints: BoxConstraints.loose(size),
triggerActions: 0,
popupBuilder: popupBuilder,
child: child);
}
Widget _buildMenu() {
return _popover(
controller: colorPopoverController,
popupBuilder: (context) => _buildColorPicker(),
child: isHover
? Wrap(
children: [
FlowyIconButton(
icon: const Icon(Icons.color_lens_outlined),
onPressed: () {
colorPopoverController.show();
},
),
FlowyIconButton(
icon: const Icon(Icons.delete_forever_outlined),
onPressed: () {
deleteNode();
},
)
],
)
: const SizedBox(width: 0),
controller: controller,
constraints: BoxConstraints.loose(size),
triggerActions: 0,
popupBuilder: popupBuilder,
child: child,
);
}

View File

@ -1,5 +1,4 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/src/infra/svg.dart';
import 'package:flutter/material.dart';
import 'package:highlight/highlight.dart' as highlight;
import 'package:highlight/languages/all.dart';
@ -9,7 +8,8 @@ const String kCodeBlockSubType = 'code_block';
const String kCodeBlockAttrTheme = 'theme';
const String kCodeBlockAttrLanguage = 'language';
class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode>
with ActionProvider<TextNode> {
@override
Widget build(NodeWidgetContext<TextNode> context) {
return _CodeBlockNodeWidge(
@ -24,6 +24,20 @@ class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
return node is TextNode &&
node.attributes[kCodeBlockAttrTheme] is String;
};
@override
List<ActionMenuItem> actions(NodeWidgetContext<TextNode> context) {
return [
ActionMenuItem.svg(
name: 'delete',
onPressed: () {
final transaction = context.editorState.transaction
..deleteNode(context.node);
context.editorState.apply(transaction);
},
),
];
}
}
class _CodeBlockNodeWidge extends StatefulWidget {
@ -44,7 +58,6 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
with SelectableMixin, DefaultSelectable {
final _richTextKey = GlobalKey(debugLabel: kCodeBlockType);
final _padding = const EdgeInsets.only(left: 20, top: 30, bottom: 30);
bool _isHover = false;
String? get _language =>
widget.textNode.attributes[kCodeBlockAttrLanguage] as String?;
String? _detectLanguage;
@ -61,20 +74,11 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
@override
Widget build(BuildContext context) {
return InkWell(
onHover: (value) {
setState(() {
_isHover = value;
});
},
onTap: () {},
child: Stack(
children: [
_buildCodeBlock(context),
_buildSwitchCodeButton(context),
if (_isHover) _buildDeleteButton(context),
],
),
return Stack(
children: [
_buildCodeBlock(context),
_buildSwitchCodeButton(context),
],
);
}
@ -137,26 +141,6 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
);
}
Widget _buildDeleteButton(BuildContext context) {
return Positioned(
top: -5,
right: -5,
child: IconButton(
icon: Svg(
name: 'delete',
color: widget.editorState.editorStyle.selectionMenuItemIconColor,
width: 16,
height: 16,
),
onPressed: () {
final transaction = widget.editorState.transaction
..deleteNode(widget.textNode);
widget.editorState.apply(transaction);
},
),
);
}
// Copy from flutter.highlight package.
// https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
List<TextSpan> _convert(List<highlight.Node> nodes) {

View File

@ -1,5 +1,4 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/src/infra/svg.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_math_fork/flutter_math.dart';
@ -55,7 +54,8 @@ SelectionMenuItem mathEquationMenuItem = SelectionMenuItem(
},
);
class MathEquationNodeWidgetBuidler extends NodeWidgetBuilder<Node> {
class MathEquationNodeWidgetBuidler extends NodeWidgetBuilder<Node>
with ActionProvider<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _MathEquationNodeWidget(
@ -68,6 +68,20 @@ class MathEquationNodeWidgetBuidler extends NodeWidgetBuilder<Node> {
@override
NodeValidator<Node> get nodeValidator =>
(node) => node.attributes[kMathEquationAttr] is String;
@override
List<ActionMenuItem> actions(NodeWidgetContext<Node> context) {
return [
ActionMenuItem.svg(
name: "delete",
onPressed: () {
final transaction = context.editorState.transaction
..deleteNode(context.node);
context.editorState.apply(transaction);
},
),
];
}
}
class _MathEquationNodeWidget extends StatefulWidget {
@ -104,7 +118,6 @@ class _MathEquationNodeWidgetState extends State<_MathEquationNodeWidget> {
child: Stack(
children: [
_buildMathEquation(context),
if (_isHover) _buildDeleteButton(context),
],
),
);
@ -136,26 +149,6 @@ class _MathEquationNodeWidgetState extends State<_MathEquationNodeWidget> {
);
}
Widget _buildDeleteButton(BuildContext context) {
return Positioned(
top: -5,
right: -5,
child: IconButton(
icon: Svg(
name: 'delete',
color: widget.editorState.editorStyle.selectionMenuItemIconColor,
width: 16,
height: 16,
),
onPressed: () {
final transaction = widget.editorState.transaction
..deleteNode(widget.node);
widget.editorState.apply(transaction);
},
),
);
}
void showEditingDialog() {
showDialog(
context: context,

View File

@ -24,6 +24,7 @@ dependencies:
highlight: ^0.7.0
shared_preferences: ^2.0.15
flutter_svg: ^1.1.1+1
provider: ^6.0.3
dev_dependencies:
flutter_test:

View File

@ -1,7 +1,6 @@
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'package:flowy_infra_ui/style_widget/decoration.dart';
import 'package:flutter/material.dart';
class AppFlowyPopover extends StatelessWidget {
final Widget child;
@ -43,6 +42,7 @@ class AppFlowyPopover extends StatelessWidget {
asBarrier: asBarrier,
triggerActions: triggerActions,
windowPadding: windowPadding,
offset: offset,
popupBuilder: (context) {
final child = popupBuilder(context);
debugPrint("Show popover: $child");