mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
3491ffdd08
commit
e2f6f68923
@ -139,6 +139,8 @@ class _DocumentPageState extends State<DocumentPage> {
|
||||
boardMenuItem,
|
||||
// Grid
|
||||
gridMenuItem,
|
||||
// Callout
|
||||
calloutMenuItem,
|
||||
],
|
||||
themeData: theme.copyWith(extensions: [
|
||||
...theme.extensions.values,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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:
|
||||
|
@ -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");
|
||||
|
Loading…
Reference in New Issue
Block a user