feat: support customizing image menu (#265)

This commit is contained in:
Lucas.Xu 2023-06-30 13:40:18 +08:00 • committed by GitHub
commit 435f1a6f9a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 260 additions and 205 deletions

View file

@ -15,6 +15,5 @@ analyzer:
exclude: exclude:
- lib/src/l10n/** - lib/src/l10n/**
# Remove the below directory until migration is complete. # Remove the below directory until migration is complete.
- example/**
- test/** - test/**
- lib/src/service/** - lib/src/service/**

View file

@ -57,6 +57,15 @@ class _HomePageState extends State<HomePage> {
); );
} }
@override
void reassemble() {
super.reassemble();
_jsonString = Future.value(
jsonEncode(_editorState.document.toJson()),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(

View file

@ -183,10 +183,24 @@ class SimpleEditor extends StatelessWidget {
EditorState editorState, EditorState editorState,
ScrollController? scrollController, ScrollController? scrollController,
) { ) {
return AppFlowyEditor.standard( final customBlockComponentBuilders = {
editorStyle: const EditorStyle.desktop(), ...standardBlockComponentBuilderMap,
ImageBlockKeys.type: ImageBlockComponentBuilder(
showMenu: true,
menuBuilder: (node, _) {
return const Positioned(
right: 10,
child: Text('Sample Menu'),
);
},
)
};
return AppFlowyEditor.custom(
editorState: editorState, editorState: editorState,
scrollController: scrollController, scrollController: scrollController,
blockComponentBuilders: customBlockComponentBuilders,
commandShortcutEvents: standardCommandShortcutEvents,
characterShortcutEvents: standardCharacterShortcutEvents,
); );
} }
} }

View file

@ -33,6 +33,7 @@ export 'src/render/rich_text/flowy_rich_text.dart';
export 'src/render/rich_text/flowy_rich_text_keys.dart'; export 'src/render/rich_text/flowy_rich_text_keys.dart';
export 'src/render/selection_menu/selection_menu_widget.dart'; export 'src/render/selection_menu/selection_menu_widget.dart';
export 'src/render/selection_menu/selection_menu_item_widget.dart'; export 'src/render/selection_menu/selection_menu_item_widget.dart';
export 'src/render/selection_menu/selection_menu_icon.dart';
export 'src/l10n/l10n.dart'; export 'src/l10n/l10n.dart';
export 'src/render/style/plugin_styles.dart'; export 'src/render/style/plugin_styles.dart';
export 'src/plugins/markdown/encoder/delta_markdown_encoder.dart'; export 'src/plugins/markdown/encoder/delta_markdown_encoder.dart';

View file

@ -25,6 +25,7 @@ export 'heading_block_component/heading_character_shortcut.dart';
// image // image
export 'image_block_component/image_block_component.dart'; export 'image_block_component/image_block_component.dart';
export 'image_block_component/image_upload_widget.dart';
// divider // divider
export 'divider_block_component/divider_block_component.dart'; export 'divider_block_component/divider_block_component.dart';

View file

@ -12,7 +12,7 @@ final CharacterShortcutEvent convertMinusesToDivider = CharacterShortcutEvent(
character: '-', character: '-',
handler: (editorState) async => handler: (editorState) async =>
await _convertSyntaxToDivider(editorState, '--') || await _convertSyntaxToDivider(editorState, '--') ||
await _convertSyntaxToDivider(editorState, ' —'), await _convertSyntaxToDivider(editorState, '—'),
); );
final CharacterShortcutEvent convertStarsToDivider = CharacterShortcutEvent( final CharacterShortcutEvent convertStarsToDivider = CharacterShortcutEvent(

View file

@ -1,5 +1,4 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
SelectionMenuItem dividerMenuItem = SelectionMenuItem( SelectionMenuItem dividerMenuItem = SelectionMenuItem(

View file

@ -4,22 +4,6 @@ import 'package:provider/provider.dart';
import 'resizable_image.dart'; import 'resizable_image.dart';
enum ImageSourceType {
network,
file;
static ImageSourceType fromString(String value) {
switch (value) {
case 'network':
return ImageSourceType.network;
case 'file':
return ImageSourceType.file;
default:
return ImageSourceType.network; // compatible with old version
}
}
}
class ImageBlockKeys { class ImageBlockKeys {
ImageBlockKeys._(); ImageBlockKeys._();
@ -46,23 +30,10 @@ class ImageBlockKeys {
/// ///
/// The value is a double. /// The value is a double.
static const String height = 'height'; static const String height = 'height';
/// The image source type of a image block.
///
/// The value is a String.
/// network, file
static const String imageSourceType = 'imageSourceType';
/// The image content of a image block.
///
/// base64 image content
static const String content = 'content';
} }
Node imageNode({ Node imageNode({
required ImageSourceType imageSourceType, required String url,
String? content,
String? url,
String align = 'center', String align = 'center',
double? height, double? height,
double? width, double? width,
@ -71,23 +42,34 @@ Node imageNode({
type: ImageBlockKeys.type, type: ImageBlockKeys.type,
attributes: { attributes: {
ImageBlockKeys.url: url, ImageBlockKeys.url: url,
ImageBlockKeys.content: content,
ImageBlockKeys.align: align, ImageBlockKeys.align: align,
ImageBlockKeys.height: height, ImageBlockKeys.height: height,
ImageBlockKeys.width: width, ImageBlockKeys.width: width,
ImageBlockKeys.imageSourceType: imageSourceType.name,
}, },
); );
} }
typedef ImageBlockComponentMenuBuilder = Widget Function(
Node node,
ImageBlockComponentWidgetState state,
);
class ImageBlockComponentBuilder extends BlockComponentBuilder { class ImageBlockComponentBuilder extends BlockComponentBuilder {
ImageBlockComponentBuilder({ ImageBlockComponentBuilder({
this.configuration = const BlockComponentConfiguration(), this.configuration = const BlockComponentConfiguration(),
this.showMenu = false,
this.menuBuilder,
}); });
@override @override
final BlockComponentConfiguration configuration; final BlockComponentConfiguration configuration;
/// Whether to show the menu of this block component.
final bool showMenu;
///
final ImageBlockComponentMenuBuilder? menuBuilder;
@override @override
BlockComponentWidget build(BlockComponentContext blockComponentContext) { BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node; final node = blockComponentContext.node;
@ -100,6 +82,8 @@ class ImageBlockComponentBuilder extends BlockComponentBuilder {
blockComponentContext, blockComponentContext,
state, state,
), ),
showMenu: showMenu,
menuBuilder: menuBuilder,
); );
} }
@ -114,41 +98,51 @@ class ImageBlockComponentWidget extends BlockComponentStatefulWidget {
super.showActions, super.showActions,
super.actionBuilder, super.actionBuilder,
super.configuration = const BlockComponentConfiguration(), super.configuration = const BlockComponentConfiguration(),
this.showMenu = false,
this.menuBuilder,
}); });
/// Whether to show the menu of this block component.
final bool showMenu;
final ImageBlockComponentMenuBuilder? menuBuilder;
@override @override
State<ImageBlockComponentWidget> createState() => State<ImageBlockComponentWidget> createState() =>
_ImageBlockComponentWidgetState(); ImageBlockComponentWidgetState();
} }
class _ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget> class ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
with SelectableMixin { with SelectableMixin {
final imageKey = GlobalKey();
RenderBox get _renderBox => context.findRenderObject() as RenderBox; RenderBox get _renderBox => context.findRenderObject() as RenderBox;
late final editorState = Provider.of<EditorState>(context, listen: false); late final editorState = Provider.of<EditorState>(context, listen: false);
final showActionsNotifier = ValueNotifier<bool>(false);
bool alwaysShowMenu = true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final node = widget.node; final node = widget.node;
final attributes = node.attributes; final attributes = node.attributes;
final src = attributes[ImageBlockKeys.url]; final src = attributes[ImageBlockKeys.url];
final content = attributes[ImageBlockKeys.content];
final alignment = AlignmentExtension.fromString( final alignment = AlignmentExtension.fromString(
attributes[ImageBlockKeys.align] ?? 'center', attributes[ImageBlockKeys.align] ?? 'center',
); );
final width = attributes[ImageBlockKeys.width]?.toDouble() ?? final width = attributes[ImageBlockKeys.width]?.toDouble() ??
MediaQuery.of(context).size.width; MediaQuery.of(context).size.width;
final imageSourceType = ImageSourceType.fromString( final height = attributes[ImageBlockKeys.height]?.toDouble();
attributes[ImageBlockKeys.imageSourceType],
);
Widget child = ResizableImage( Widget child = ResizableImage(
key: imageKey,
src: src, src: src,
content: content,
width: width, width: width,
height: height,
editable: editorState.editable, editable: editorState.editable,
alignment: alignment, alignment: alignment,
type: imageSourceType,
onResize: (width) { onResize: (width) {
final transaction = editorState.transaction final transaction = editorState.transaction
..updateNode(node, { ..updateNode(node, {
@ -166,6 +160,31 @@ class _ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
); );
} }
if (widget.showMenu && widget.menuBuilder != null) {
child = MouseRegion(
onEnter: (_) => showActionsNotifier.value = true,
onExit: (_) {
if (!alwaysShowMenu) {
showActionsNotifier.value = false;
}
},
hitTestBehavior: HitTestBehavior.opaque,
opaque: false,
child: ValueListenableBuilder<bool>(
valueListenable: showActionsNotifier,
builder: (context, value, child) {
return Stack(
children: [
child!,
if (value) widget.menuBuilder!(widget.node, this),
],
);
},
child: child,
),
);
}
return child; return child;
} }
@ -191,8 +210,17 @@ class _ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
} }
@override @override
List<Rect> getRectsInSelection(Selection selection) => List<Rect> getRectsInSelection(Selection selection) {
[Offset.zero & _renderBox.size]; final parentBox = context.findRenderObject();
final dividerBox = imageKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && dividerBox is RenderBox) {
return [
dividerBox.localToGlobal(Offset.zero, ancestor: parentBox) &
dividerBox.size
];
}
return [Offset.zero & _renderBox.size];
}
@override @override
Selection getSelectionInRange(Offset start, Offset end) => Selection.single( Selection getSelectionInRange(Offset start, Offset end) => Selection.single(

View file

@ -1,7 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/block_component/image_block_component/base64_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../util/file_picker/file_picker_impl.dart'; import '../../util/file_picker/file_picker_impl.dart';
import 'package:file_picker/file_picker.dart' as fp; import 'package:file_picker/file_picker.dart' as fp;
@ -11,11 +10,14 @@ enum ImageFromFileStatus {
selected, selected,
} }
typedef OnInsertImage = void Function(String url);
void showImageMenu( void showImageMenu(
OverlayState container, OverlayState container,
EditorState editorState, EditorState editorState,
SelectionMenuService menuService, SelectionMenuService menuService, {
) { OnInsertImage? onInsertImage,
}) {
menuService.dismiss(); menuService.dismiss();
final (left, top, bottom) = menuService.getPosition(); final (left, top, bottom) = menuService.getPosition();
@ -23,10 +25,13 @@ void showImageMenu(
late final OverlayEntry imageMenuEntry; late final OverlayEntry imageMenuEntry;
void insertImage( void insertImage(
String src, { String url,
ImageSourceType imageSourceType = ImageSourceType.network, ) {
}) { if (onInsertImage != null) {
editorState.insertImageNode(src, imageSourceType); onInsertImage(url);
} else {
editorState.insertImageNode(url);
}
menuService.dismiss(); menuService.dismiss();
imageMenuEntry.remove(); imageMenuEntry.remove();
keepEditorFocusNotifier.value -= 1; keepEditorFocusNotifier.value -= 1;
@ -41,7 +46,7 @@ void showImageMenu(
builder: (context) => UploadImageMenu( builder: (context) => UploadImageMenu(
backgroundColor: menuService.style.selectionMenuBackgroundColor, backgroundColor: menuService.style.selectionMenuBackgroundColor,
headerColor: menuService.style.selectionMenuItemTextColor, headerColor: menuService.style.selectionMenuItemTextColor,
width: MediaQuery.of(context).size.width * 0.5, width: MediaQuery.of(context).size.width * 0.4,
onSubmitted: insertImage, onSubmitted: insertImage,
onUpload: insertImage, onUpload: insertImage,
), ),
@ -63,7 +68,7 @@ class UploadImageMenu extends StatefulWidget {
final Color headerColor; final Color headerColor;
final double width; final double width;
final void Function(String text) onSubmitted; final void Function(String text) onSubmitted;
final void Function(String text, {ImageSourceType imageSourceType}) onUpload; final void Function(String text) onUpload;
@override @override
State<UploadImageMenu> createState() => _UploadImageMenuState(); State<UploadImageMenu> createState() => _UploadImageMenuState();
@ -94,8 +99,8 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: widget.width, width: widget.width,
height: 350, height: 300,
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 10.0),
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.backgroundColor, color: widget.backgroundColor,
boxShadow: [ boxShadow: [
@ -111,18 +116,18 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
length: 2, length: 2,
child: Column( child: Column(
children: [ children: [
const Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: SizedBox( child: SizedBox(
width: 300, width: 300,
child: TabBar( child: TabBar(
tabs: [ tabs: const [
Tab(text: 'Upload Image'), Tab(text: 'Upload Image'),
Tab(text: 'URL Image'), Tab(text: 'URL Image'),
], ],
labelColor: Colors.black, labelColor: widget.headerColor,
unselectedLabelColor: Colors.grey, unselectedLabelColor: Colors.grey,
indicatorColor: Color(0xff00BCF0), indicatorColor: const Color(0xff00BCF0),
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
), ),
), ),
@ -142,18 +147,6 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
); );
} }
Widget _buildHeader(BuildContext context) {
return Text(
'URL Image',
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 14.0,
color: widget.headerColor,
fontWeight: FontWeight.w500,
),
);
}
Widget _buildInput() { Widget _buildInput() {
return TextField( return TextField(
focusNode: _focusNode, focusNode: _focusNode,
@ -185,7 +178,6 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
Widget _buildUploadButton( Widget _buildUploadButton(
BuildContext context, BuildContext context,
ImageSourceType imageSourceType,
) { ) {
return SizedBox( return SizedBox(
width: 170, width: 170,
@ -200,17 +192,13 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
), ),
), ),
onPressed: () async { onPressed: () async {
if (imageSourceType == ImageSourceType.network) { if (_localImagePath != null) {
widget.onUpload(
_localImagePath!,
);
} else if (_textEditingController.text.isNotEmpty) {
widget.onUpload( widget.onUpload(
_textEditingController.text, _textEditingController.text,
imageSourceType: imageSourceType,
);
} else if (imageSourceType == ImageSourceType.file &&
_localImagePath != null) {
final content = await base64StringFromImage(_localImagePath!);
widget.onUpload(
content,
imageSourceType: imageSourceType,
); );
} }
}, },
@ -226,14 +214,15 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildHeader(context),
const SizedBox(height: 16.0), const SizedBox(height: 16.0),
_buildInput(), _buildInput(),
const SizedBox(height: 18.0), const SizedBox(height: 18.0),
_buildUploadButton( Align(
context, alignment: Alignment.centerRight,
ImageSourceType.network, child: _buildUploadButton(
), context,
),
)
], ],
); );
} }
@ -249,7 +238,6 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: _buildUploadButton( child: _buildUploadButton(
context, context,
ImageSourceType.file,
), ),
) )
], ],
@ -308,10 +296,9 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
} }
} }
extension on EditorState { extension InsertImage on EditorState {
Future<void> insertImageNode( Future<void> insertImageNode(
String src, String src,
ImageSourceType imageSourceType,
) async { ) async {
final selection = this.selection; final selection = this.selection;
if (selection == null || !selection.isCollapsed) { if (selection == null || !selection.isCollapsed) {
@ -329,16 +316,16 @@ extension on EditorState {
..insertNode( ..insertNode(
node.path, node.path,
imageNode( imageNode(
url: imageSourceType == ImageSourceType.network ? src : null, url: src,
content: imageSourceType == ImageSourceType.file ? src : null,
imageSourceType: imageSourceType,
), ),
) )
..deleteNode(node); ..deleteNode(node);
} else { } else {
transaction.insertNode( transaction.insertNode(
node.path.next, node.path.next,
imageNode(url: src, imageSourceType: imageSourceType), imageNode(
url: src,
),
); );
} }

View file

@ -1,5 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'dart:io';
import 'package:appflowy_editor/src/editor/block_component/image_block_component/base64_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ResizableImage extends StatefulWidget { class ResizableImage extends StatefulWidget {
@ -8,18 +8,17 @@ class ResizableImage extends StatefulWidget {
required this.alignment, required this.alignment,
required this.editable, required this.editable,
required this.onResize, required this.onResize,
required this.type,
required this.width, required this.width,
this.src, required this.src,
this.content, this.height,
}); });
final String? src; final String src;
final String? content;
final double width; final double width;
final double? height;
final Alignment alignment; final Alignment alignment;
final bool editable; final bool editable;
final ImageSourceType type;
final void Function(double width) onResize; final void Function(double width) onResize;
@override @override
@ -50,6 +49,7 @@ class _ResizableImageState extends State<ResizableImage> {
alignment: widget.alignment, alignment: widget.alignment,
child: SizedBox( child: SizedBox(
width: imageWidth - moveDistance, width: imageWidth - moveDistance,
height: widget.height,
child: MouseRegion( child: MouseRegion(
onEnter: (event) => setState(() { onEnter: (event) => setState(() {
onFocus = true; onFocus = true;
@ -65,9 +65,12 @@ class _ResizableImageState extends State<ResizableImage> {
Widget _buildResizableImage(BuildContext context) { Widget _buildResizableImage(BuildContext context) {
Widget child; Widget child;
if (widget.type == ImageSourceType.network && widget.src != null) { final regex = RegExp('^(http|https)://');
final url = widget.src;
if (regex.hasMatch(url)) {
// load network image
_cacheImage ??= Image.network( _cacheImage ??= Image.network(
widget.src!, widget.src,
width: widget.width, width: widget.width,
gaplessPlayback: true, gaplessPlayback: true,
loadingBuilder: (context, child, loadingProgress) { loadingBuilder: (context, child, loadingProgress) {
@ -81,11 +84,10 @@ class _ResizableImageState extends State<ResizableImage> {
errorBuilder: (context, error, stackTrace) => _buildError(context), errorBuilder: (context, error, stackTrace) => _buildError(context),
); );
child = _cacheImage!; child = _cacheImage!;
} else if (widget.type == ImageSourceType.file && widget.content != null) {
_cacheImage ??= imageFromBase64String(widget.content!);
child = _cacheImage!;
} else { } else {
child = _buildError(context); // load local file
_cacheImage ??= Image.file(File(url));
child = _cacheImage!;
} }
return Stack( return Stack(
children: [ children: [
@ -94,7 +96,7 @@ class _ResizableImageState extends State<ResizableImage> {
_buildEdgeGesture( _buildEdgeGesture(
context, context,
top: 0, top: 0,
left: 0, left: 5,
bottom: 0, bottom: 0,
width: 5, width: 5,
onUpdate: (distance) { onUpdate: (distance) {
@ -106,7 +108,7 @@ class _ResizableImageState extends State<ResizableImage> {
_buildEdgeGesture( _buildEdgeGesture(
context, context,
top: 0, top: 0,
right: 0, right: 5,
bottom: 0, bottom: 0,
width: 5, width: 5,
onUpdate: (distance) { onUpdate: (distance) {
@ -195,6 +197,7 @@ class _ResizableImageState extends State<ResizableImage> {
borderRadius: const BorderRadius.all( borderRadius: const BorderRadius.all(
Radius.circular(5.0), Radius.circular(5.0),
), ),
border: Border.all(width: 1, color: Colors.white),
), ),
), ),
) )

View file

@ -17,9 +17,16 @@ Future<void> onDelete(
if (selection.isSingle) { if (selection.isSingle) {
if (deletion.composing.isValid || !deletion.deletedRange.isCollapsed) { if (deletion.composing.isValid || !deletion.deletedRange.isCollapsed) {
final node = editorState.getNodesInSelection(selection).first; final node = editorState.getNodesInSelection(selection).first;
final transaction = editorState.transaction;
final start = deletion.deletedRange.start; final start = deletion.deletedRange.start;
final length = deletion.deletedRange.end - start; final length = deletion.deletedRange.end - start;
// final firstNodeContainsDelta = editorState.document.root.children
// .firstWhereOrNull((element) => element.delta != null);
// if (firstNodeContainsDelta != null &&
// node.path.equals(firstNodeContainsDelta.path) && start == 0) {
// }
final transaction = editorState.transaction;
transaction.deleteText(node, start, length); transaction.deleteText(node, start, length);
await editorState.apply(transaction); await editorState.apply(transaction);
return; return;

View file

@ -156,33 +156,7 @@ class NonDeltaTextInputService extends TextInputService with TextInputClient {
) {} ) {}
@override @override
void performSelector(String selectorName) { void performSelector(String selectorName) {}
final currentTextEditingValue = this.currentTextEditingValue;
if (currentTextEditingValue == null) {
return;
}
// magic string from flutter callback
if (selectorName == 'deleteBackward:') {
final oldText = currentTextEditingValue.text;
final selection = currentTextEditingValue.selection;
final deleteRange = selection.isCollapsed
? TextRange(
start: selection.start - 1,
end: selection.end,
)
: selection;
onDelete(
TextEditingDeltaDeletion(
oldText: oldText,
deletedRange: deleteRange,
selection: const TextSelection.collapsed(
offset: -1,
), // just pass a invalid value, because we don't use this selection inside.
composing: TextRange.empty,
),
);
}
}
@override @override
void insertContent(KeyboardInsertedContent content) {} void insertContent(KeyboardInsertedContent content) {}

View file

@ -25,10 +25,7 @@ CharacterShortcutEvent customSlashCommand(
character: '/', character: '/',
handler: (editorState) => _showSlashMenu( handler: (editorState) => _showSlashMenu(
editorState, editorState,
[ items,
...standardSelectionMenuItems,
...items,
],
shouldInsertSlash: shouldInsertSlash, shouldInsertSlash: shouldInsertSlash,
style: style, style: style,
), ),

View file

@ -328,7 +328,6 @@ class HTMLToNodesConverter {
// only support network image // only support network image
return imageNode( return imageNode(
url: src, url: src,
imageSourceType: ImageSourceType.network,
); );
} }

View file

@ -195,7 +195,6 @@ class DocumentHTMLDecoder extends Converter<String, Document> {
// only support network image // only support network image
return imageNode( return imageNode(
url: src, url: src,
imageSourceType: ImageSourceType.network,
); );
} }

View file

@ -28,8 +28,8 @@ void showImageUploadMenu(
child: Material( child: Material(
child: ImageUploadMenu( child: ImageUploadMenu(
editorState: editorState, editorState: editorState,
onSubmitted: editorState.insertImageNode, onSubmitted: (src) {},
onUpload: editorState.insertImageNode, onUpload: (src) {},
), ),
), ),
), ),
@ -103,8 +103,6 @@ class _ImageUploadMenuState extends State<ImageUploadMenu> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildHeader(context),
const SizedBox(height: 16.0),
_buildInput(), _buildInput(),
const SizedBox(height: 18.0), const SizedBox(height: 18.0),
_buildUploadButton(context), _buildUploadButton(context),
@ -113,18 +111,6 @@ class _ImageUploadMenuState extends State<ImageUploadMenu> {
); );
} }
Widget _buildHeader(BuildContext context) {
return Text(
'URL Image',
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 14.0,
color: style?.selectionMenuItemTextColor ?? Colors.black,
fontWeight: FontWeight.w500,
),
);
}
Widget _buildInput() { Widget _buildInput() {
return TextField( return TextField(
focusNode: _focusNode, focusNode: _focusNode,
@ -176,25 +162,3 @@ class _ImageUploadMenuState extends State<ImageUploadMenu> {
); );
} }
} }
extension InsertImage on EditorState {
void insertImageNode(String src) {
final selection = service.selectionService.currentSelection.value;
if (selection == null) {
return;
}
final imageNode = Node(
type: 'image',
attributes: {
'image_src': src,
'align': 'center',
},
);
final transaction = this.transaction;
transaction.insertNode(
selection.start.path,
imageNode,
);
apply(transaction);
}
}

View file

@ -1,6 +1,4 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/block_component/image_block_component/image_upload_widget.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// TODO: this file is too long, need to refactor. // TODO: this file is too long, need to refactor.

View file

@ -0,0 +1,90 @@
import 'dart:ui';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:network_image_mock/network_image_mock.dart';
void main() async {
// column
// - text field
// - editor
// 1. When the text field is focused, the editor's cursor should be disabled.
// 2. When the editor is focused, the text field's cursor should be disabled.
// 3. Tapping a non-first line of the editor should still allow the editor to grab focus.
testWidgets('text field + editor', (tester) async {
await mockNetworkImagesFor(() async {
const widget = CustomImageMenu();
await tester.pumpWidget(widget);
await tester.pumpAndSettle();
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(
tester.getCenter(find.byType(ImageBlockComponentWidget)),
);
await tester.pumpAndSettle(const Duration(seconds: 1));
// expect to see the menu
expect(find.text(menu), findsOneWidget);
});
});
}
const menu = 'Here\'s a custom menu!';
class CustomImageMenu extends StatelessWidget {
const CustomImageMenu({super.key});
@override
Widget build(BuildContext context) {
const url =
'https://images.unsplash.com/photo-1682961941145-e73336a53bc6?dl=katsuma-tanaka-cWpkMDSQbWQ-unsplash.jpg';
final document = Document.blank()
..insert(
[0],
[
imageNode(
url: url,
width: 400,
height: 400,
),
],
);
final customBlockComponentBuilders = {
...standardBlockComponentBuilderMap,
ImageBlockKeys.type: ImageBlockComponentBuilder(
showMenu: true,
menuBuilder: (node, _) {
return const Positioned(
right: 10,
child: Text(menu),
);
},
)
};
final editorState = EditorState(document: document);
return MaterialApp(
home: Scaffold(
body: SafeArea(
child: Container(
width: 500,
decoration: BoxDecoration(
border: Border.all(color: Colors.blue),
),
child: AppFlowyEditor.custom(
editorState: editorState,
blockComponentBuilders: customBlockComponentBuilders,
commandShortcutEvents: standardCommandShortcutEvents,
characterShortcutEvents: standardCharacterShortcutEvents,
),
),
),
),
);
}
}

View file

@ -346,11 +346,9 @@ const nestedDelta = {
'type': 'image', 'type': 'image',
'data': { 'data': {
'url': 'https://richtexteditor.com/images/editor-image.png', 'url': 'https://richtexteditor.com/images/editor-image.png',
'content': null,
'align': 'center', 'align': 'center',
'height': null, 'height': null,
'width': null, 'width': null,
'imageSourceType': 'network'
} }
}, },
{ {
@ -399,11 +397,9 @@ const nestedDelta = {
'type': 'image', 'type': 'image',
'data': { 'data': {
'url': 'https://richtexteditor.com/images/editor-image.png', 'url': 'https://richtexteditor.com/images/editor-image.png',
'content': null,
'align': 'center', 'align': 'center',
'height': null, 'height': null,
'width': null, 'width': null,
'imageSourceType': 'network'
} }
} }
], ],

View file

@ -350,7 +350,6 @@ const nestedDelta = {
'align': 'center', 'align': 'center',
'height': null, 'height': null,
'width': null, 'width': null,
'imageSourceType': 'network'
} }
}, },
{ {
@ -399,11 +398,9 @@ const nestedDelta = {
'type': 'image', 'type': 'image',
'data': { 'data': {
'url': 'https://richtexteditor.com/images/editor-image.png', 'url': 'https://richtexteditor.com/images/editor-image.png',
'content': null,
'align': 'center', 'align': 'center',
'height': null, 'height': null,
'width': null, 'width': null,
'imageSourceType': 'network'
} }
} }
], ],

Some files were not shown because too many files have changed in this diff Show more