feat: support customizing image menu (#265)
This commit is contained in:
parent
ed75bf61df
commit
435f1a6f9a
22 changed files with 260 additions and 205 deletions
|
|
@ -15,6 +15,5 @@ analyzer:
|
|||
exclude:
|
||||
- lib/src/l10n/**
|
||||
# Remove the below directory until migration is complete.
|
||||
- example/**
|
||||
- test/**
|
||||
- lib/src/service/**
|
||||
|
|
|
|||
|
|
@ -57,6 +57,15 @@ class _HomePageState extends State<HomePage> {
|
|||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void reassemble() {
|
||||
super.reassemble();
|
||||
|
||||
_jsonString = Future.value(
|
||||
jsonEncode(_editorState.document.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
|
|||
|
|
@ -183,10 +183,24 @@ class SimpleEditor extends StatelessWidget {
|
|||
EditorState editorState,
|
||||
ScrollController? scrollController,
|
||||
) {
|
||||
return AppFlowyEditor.standard(
|
||||
editorStyle: const EditorStyle.desktop(),
|
||||
final customBlockComponentBuilders = {
|
||||
...standardBlockComponentBuilderMap,
|
||||
ImageBlockKeys.type: ImageBlockComponentBuilder(
|
||||
showMenu: true,
|
||||
menuBuilder: (node, _) {
|
||||
return const Positioned(
|
||||
right: 10,
|
||||
child: Text('Sample Menu'),
|
||||
);
|
||||
},
|
||||
)
|
||||
};
|
||||
return AppFlowyEditor.custom(
|
||||
editorState: editorState,
|
||||
scrollController: scrollController,
|
||||
blockComponentBuilders: customBlockComponentBuilders,
|
||||
commandShortcutEvents: standardCommandShortcutEvents,
|
||||
characterShortcutEvents: standardCharacterShortcutEvents,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/selection_menu/selection_menu_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/render/style/plugin_styles.dart';
|
||||
export 'src/plugins/markdown/encoder/delta_markdown_encoder.dart';
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export 'heading_block_component/heading_character_shortcut.dart';
|
|||
|
||||
// image
|
||||
export 'image_block_component/image_block_component.dart';
|
||||
export 'image_block_component/image_upload_widget.dart';
|
||||
|
||||
// divider
|
||||
export 'divider_block_component/divider_block_component.dart';
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ final CharacterShortcutEvent convertMinusesToDivider = CharacterShortcutEvent(
|
|||
character: '-',
|
||||
handler: (editorState) async =>
|
||||
await _convertSyntaxToDivider(editorState, '--') ||
|
||||
await _convertSyntaxToDivider(editorState, ' —'),
|
||||
await _convertSyntaxToDivider(editorState, '—'),
|
||||
);
|
||||
|
||||
final CharacterShortcutEvent convertStarsToDivider = CharacterShortcutEvent(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
SelectionMenuItem dividerMenuItem = SelectionMenuItem(
|
||||
|
|
|
|||
|
|
@ -4,22 +4,6 @@ import 'package:provider/provider.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 {
|
||||
ImageBlockKeys._();
|
||||
|
||||
|
|
@ -46,23 +30,10 @@ class ImageBlockKeys {
|
|||
///
|
||||
/// The value is a double.
|
||||
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({
|
||||
required ImageSourceType imageSourceType,
|
||||
String? content,
|
||||
String? url,
|
||||
required String url,
|
||||
String align = 'center',
|
||||
double? height,
|
||||
double? width,
|
||||
|
|
@ -71,23 +42,34 @@ Node imageNode({
|
|||
type: ImageBlockKeys.type,
|
||||
attributes: {
|
||||
ImageBlockKeys.url: url,
|
||||
ImageBlockKeys.content: content,
|
||||
ImageBlockKeys.align: align,
|
||||
ImageBlockKeys.height: height,
|
||||
ImageBlockKeys.width: width,
|
||||
ImageBlockKeys.imageSourceType: imageSourceType.name,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
typedef ImageBlockComponentMenuBuilder = Widget Function(
|
||||
Node node,
|
||||
ImageBlockComponentWidgetState state,
|
||||
);
|
||||
|
||||
class ImageBlockComponentBuilder extends BlockComponentBuilder {
|
||||
ImageBlockComponentBuilder({
|
||||
this.configuration = const BlockComponentConfiguration(),
|
||||
this.showMenu = false,
|
||||
this.menuBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
final BlockComponentConfiguration configuration;
|
||||
|
||||
/// Whether to show the menu of this block component.
|
||||
final bool showMenu;
|
||||
|
||||
///
|
||||
final ImageBlockComponentMenuBuilder? menuBuilder;
|
||||
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
|
|
@ -100,6 +82,8 @@ class ImageBlockComponentBuilder extends BlockComponentBuilder {
|
|||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
showMenu: showMenu,
|
||||
menuBuilder: menuBuilder,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -114,41 +98,51 @@ class ImageBlockComponentWidget extends BlockComponentStatefulWidget {
|
|||
super.showActions,
|
||||
super.actionBuilder,
|
||||
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
|
||||
State<ImageBlockComponentWidget> createState() =>
|
||||
_ImageBlockComponentWidgetState();
|
||||
ImageBlockComponentWidgetState();
|
||||
}
|
||||
|
||||
class _ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
|
||||
class ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
|
||||
with SelectableMixin {
|
||||
final imageKey = GlobalKey();
|
||||
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
|
||||
|
||||
late final editorState = Provider.of<EditorState>(context, listen: false);
|
||||
|
||||
final showActionsNotifier = ValueNotifier<bool>(false);
|
||||
|
||||
bool alwaysShowMenu = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final node = widget.node;
|
||||
final attributes = node.attributes;
|
||||
final src = attributes[ImageBlockKeys.url];
|
||||
final content = attributes[ImageBlockKeys.content];
|
||||
|
||||
final alignment = AlignmentExtension.fromString(
|
||||
attributes[ImageBlockKeys.align] ?? 'center',
|
||||
);
|
||||
final width = attributes[ImageBlockKeys.width]?.toDouble() ??
|
||||
MediaQuery.of(context).size.width;
|
||||
final imageSourceType = ImageSourceType.fromString(
|
||||
attributes[ImageBlockKeys.imageSourceType],
|
||||
);
|
||||
final height = attributes[ImageBlockKeys.height]?.toDouble();
|
||||
|
||||
Widget child = ResizableImage(
|
||||
key: imageKey,
|
||||
src: src,
|
||||
content: content,
|
||||
width: width,
|
||||
height: height,
|
||||
editable: editorState.editable,
|
||||
alignment: alignment,
|
||||
type: imageSourceType,
|
||||
onResize: (width) {
|
||||
final transaction = editorState.transaction
|
||||
..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;
|
||||
}
|
||||
|
||||
|
|
@ -191,8 +210,17 @@ class _ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
|
|||
}
|
||||
|
||||
@override
|
||||
List<Rect> getRectsInSelection(Selection selection) =>
|
||||
[Offset.zero & _renderBox.size];
|
||||
List<Rect> getRectsInSelection(Selection selection) {
|
||||
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
|
||||
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:io';
|
||||
|
||||
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 '../../util/file_picker/file_picker_impl.dart';
|
||||
import 'package:file_picker/file_picker.dart' as fp;
|
||||
|
|
@ -11,11 +10,14 @@ enum ImageFromFileStatus {
|
|||
selected,
|
||||
}
|
||||
|
||||
typedef OnInsertImage = void Function(String url);
|
||||
|
||||
void showImageMenu(
|
||||
OverlayState container,
|
||||
EditorState editorState,
|
||||
SelectionMenuService menuService,
|
||||
) {
|
||||
SelectionMenuService menuService, {
|
||||
OnInsertImage? onInsertImage,
|
||||
}) {
|
||||
menuService.dismiss();
|
||||
|
||||
final (left, top, bottom) = menuService.getPosition();
|
||||
|
|
@ -23,10 +25,13 @@ void showImageMenu(
|
|||
late final OverlayEntry imageMenuEntry;
|
||||
|
||||
void insertImage(
|
||||
String src, {
|
||||
ImageSourceType imageSourceType = ImageSourceType.network,
|
||||
}) {
|
||||
editorState.insertImageNode(src, imageSourceType);
|
||||
String url,
|
||||
) {
|
||||
if (onInsertImage != null) {
|
||||
onInsertImage(url);
|
||||
} else {
|
||||
editorState.insertImageNode(url);
|
||||
}
|
||||
menuService.dismiss();
|
||||
imageMenuEntry.remove();
|
||||
keepEditorFocusNotifier.value -= 1;
|
||||
|
|
@ -41,7 +46,7 @@ void showImageMenu(
|
|||
builder: (context) => UploadImageMenu(
|
||||
backgroundColor: menuService.style.selectionMenuBackgroundColor,
|
||||
headerColor: menuService.style.selectionMenuItemTextColor,
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
width: MediaQuery.of(context).size.width * 0.4,
|
||||
onSubmitted: insertImage,
|
||||
onUpload: insertImage,
|
||||
),
|
||||
|
|
@ -63,7 +68,7 @@ class UploadImageMenu extends StatefulWidget {
|
|||
final Color headerColor;
|
||||
final double width;
|
||||
final void Function(String text) onSubmitted;
|
||||
final void Function(String text, {ImageSourceType imageSourceType}) onUpload;
|
||||
final void Function(String text) onUpload;
|
||||
|
||||
@override
|
||||
State<UploadImageMenu> createState() => _UploadImageMenuState();
|
||||
|
|
@ -94,8 +99,8 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
|
|||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: 350,
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
height: 300,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 10.0),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.backgroundColor,
|
||||
boxShadow: [
|
||||
|
|
@ -111,18 +116,18 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
|
|||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
const Align(
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
width: 300,
|
||||
child: TabBar(
|
||||
tabs: [
|
||||
tabs: const [
|
||||
Tab(text: 'Upload Image'),
|
||||
Tab(text: 'URL Image'),
|
||||
],
|
||||
labelColor: Colors.black,
|
||||
labelColor: widget.headerColor,
|
||||
unselectedLabelColor: Colors.grey,
|
||||
indicatorColor: Color(0xff00BCF0),
|
||||
indicatorColor: const Color(0xff00BCF0),
|
||||
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() {
|
||||
return TextField(
|
||||
focusNode: _focusNode,
|
||||
|
|
@ -185,7 +178,6 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
|
|||
|
||||
Widget _buildUploadButton(
|
||||
BuildContext context,
|
||||
ImageSourceType imageSourceType,
|
||||
) {
|
||||
return SizedBox(
|
||||
width: 170,
|
||||
|
|
@ -200,17 +192,13 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
|
|||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
if (imageSourceType == ImageSourceType.network) {
|
||||
if (_localImagePath != null) {
|
||||
widget.onUpload(
|
||||
_localImagePath!,
|
||||
);
|
||||
} else if (_textEditingController.text.isNotEmpty) {
|
||||
widget.onUpload(
|
||||
_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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
const SizedBox(height: 16.0),
|
||||
_buildInput(),
|
||||
const SizedBox(height: 18.0),
|
||||
_buildUploadButton(
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _buildUploadButton(
|
||||
context,
|
||||
ImageSourceType.network,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -249,7 +238,6 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
|
|||
alignment: Alignment.centerRight,
|
||||
child: _buildUploadButton(
|
||||
context,
|
||||
ImageSourceType.file,
|
||||
),
|
||||
)
|
||||
],
|
||||
|
|
@ -308,10 +296,9 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
|
|||
}
|
||||
}
|
||||
|
||||
extension on EditorState {
|
||||
extension InsertImage on EditorState {
|
||||
Future<void> insertImageNode(
|
||||
String src,
|
||||
ImageSourceType imageSourceType,
|
||||
) async {
|
||||
final selection = this.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
|
|
@ -329,16 +316,16 @@ extension on EditorState {
|
|||
..insertNode(
|
||||
node.path,
|
||||
imageNode(
|
||||
url: imageSourceType == ImageSourceType.network ? src : null,
|
||||
content: imageSourceType == ImageSourceType.file ? src : null,
|
||||
imageSourceType: imageSourceType,
|
||||
url: src,
|
||||
),
|
||||
)
|
||||
..deleteNode(node);
|
||||
} else {
|
||||
transaction.insertNode(
|
||||
node.path.next,
|
||||
imageNode(url: src, imageSourceType: imageSourceType),
|
||||
imageNode(
|
||||
url: src,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/editor/block_component/image_block_component/base64_image.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ResizableImage extends StatefulWidget {
|
||||
|
|
@ -8,18 +8,17 @@ class ResizableImage extends StatefulWidget {
|
|||
required this.alignment,
|
||||
required this.editable,
|
||||
required this.onResize,
|
||||
required this.type,
|
||||
required this.width,
|
||||
this.src,
|
||||
this.content,
|
||||
required this.src,
|
||||
this.height,
|
||||
});
|
||||
|
||||
final String? src;
|
||||
final String? content;
|
||||
final String src;
|
||||
final double width;
|
||||
final double? height;
|
||||
final Alignment alignment;
|
||||
final bool editable;
|
||||
final ImageSourceType type;
|
||||
|
||||
final void Function(double width) onResize;
|
||||
|
||||
@override
|
||||
|
|
@ -50,6 +49,7 @@ class _ResizableImageState extends State<ResizableImage> {
|
|||
alignment: widget.alignment,
|
||||
child: SizedBox(
|
||||
width: imageWidth - moveDistance,
|
||||
height: widget.height,
|
||||
child: MouseRegion(
|
||||
onEnter: (event) => setState(() {
|
||||
onFocus = true;
|
||||
|
|
@ -65,9 +65,12 @@ class _ResizableImageState extends State<ResizableImage> {
|
|||
|
||||
Widget _buildResizableImage(BuildContext context) {
|
||||
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(
|
||||
widget.src!,
|
||||
widget.src,
|
||||
width: widget.width,
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
|
|
@ -81,11 +84,10 @@ class _ResizableImageState extends State<ResizableImage> {
|
|||
errorBuilder: (context, error, stackTrace) => _buildError(context),
|
||||
);
|
||||
child = _cacheImage!;
|
||||
} else if (widget.type == ImageSourceType.file && widget.content != null) {
|
||||
_cacheImage ??= imageFromBase64String(widget.content!);
|
||||
child = _cacheImage!;
|
||||
} else {
|
||||
child = _buildError(context);
|
||||
// load local file
|
||||
_cacheImage ??= Image.file(File(url));
|
||||
child = _cacheImage!;
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
|
|
@ -94,7 +96,7 @@ class _ResizableImageState extends State<ResizableImage> {
|
|||
_buildEdgeGesture(
|
||||
context,
|
||||
top: 0,
|
||||
left: 0,
|
||||
left: 5,
|
||||
bottom: 0,
|
||||
width: 5,
|
||||
onUpdate: (distance) {
|
||||
|
|
@ -106,7 +108,7 @@ class _ResizableImageState extends State<ResizableImage> {
|
|||
_buildEdgeGesture(
|
||||
context,
|
||||
top: 0,
|
||||
right: 0,
|
||||
right: 5,
|
||||
bottom: 0,
|
||||
width: 5,
|
||||
onUpdate: (distance) {
|
||||
|
|
@ -195,6 +197,7 @@ class _ResizableImageState extends State<ResizableImage> {
|
|||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(5.0),
|
||||
),
|
||||
border: Border.all(width: 1, color: Colors.white),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,9 +17,16 @@ Future<void> onDelete(
|
|||
if (selection.isSingle) {
|
||||
if (deletion.composing.isValid || !deletion.deletedRange.isCollapsed) {
|
||||
final node = editorState.getNodesInSelection(selection).first;
|
||||
final transaction = editorState.transaction;
|
||||
final start = deletion.deletedRange.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);
|
||||
await editorState.apply(transaction);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -156,33 +156,7 @@ class NonDeltaTextInputService extends TextInputService with TextInputClient {
|
|||
) {}
|
||||
|
||||
@override
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
void performSelector(String selectorName) {}
|
||||
|
||||
@override
|
||||
void insertContent(KeyboardInsertedContent content) {}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,7 @@ CharacterShortcutEvent customSlashCommand(
|
|||
character: '/',
|
||||
handler: (editorState) => _showSlashMenu(
|
||||
editorState,
|
||||
[
|
||||
...standardSelectionMenuItems,
|
||||
...items,
|
||||
],
|
||||
items,
|
||||
shouldInsertSlash: shouldInsertSlash,
|
||||
style: style,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -328,7 +328,6 @@ class HTMLToNodesConverter {
|
|||
// only support network image
|
||||
return imageNode(
|
||||
url: src,
|
||||
imageSourceType: ImageSourceType.network,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -195,7 +195,6 @@ class DocumentHTMLDecoder extends Converter<String, Document> {
|
|||
// only support network image
|
||||
return imageNode(
|
||||
url: src,
|
||||
imageSourceType: ImageSourceType.network,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ void showImageUploadMenu(
|
|||
child: Material(
|
||||
child: ImageUploadMenu(
|
||||
editorState: editorState,
|
||||
onSubmitted: editorState.insertImageNode,
|
||||
onUpload: editorState.insertImageNode,
|
||||
onSubmitted: (src) {},
|
||||
onUpload: (src) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -103,8 +103,6 @@ class _ImageUploadMenuState extends State<ImageUploadMenu> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
const SizedBox(height: 16.0),
|
||||
_buildInput(),
|
||||
const SizedBox(height: 18.0),
|
||||
_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() {
|
||||
return TextField(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
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';
|
||||
|
||||
// TODO: this file is too long, need to refactor.
|
||||
|
|
|
|||
90
test/customer/custom_image_menu_test.dart
Normal file
90
test/customer/custom_image_menu_test.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -346,11 +346,9 @@ const nestedDelta = {
|
|||
'type': 'image',
|
||||
'data': {
|
||||
'url': 'https://richtexteditor.com/images/editor-image.png',
|
||||
'content': null,
|
||||
'align': 'center',
|
||||
'height': null,
|
||||
'width': null,
|
||||
'imageSourceType': 'network'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -399,11 +397,9 @@ const nestedDelta = {
|
|||
'type': 'image',
|
||||
'data': {
|
||||
'url': 'https://richtexteditor.com/images/editor-image.png',
|
||||
'content': null,
|
||||
'align': 'center',
|
||||
'height': null,
|
||||
'width': null,
|
||||
'imageSourceType': 'network'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -350,7 +350,6 @@ const nestedDelta = {
|
|||
'align': 'center',
|
||||
'height': null,
|
||||
'width': null,
|
||||
'imageSourceType': 'network'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -399,11 +398,9 @@ const nestedDelta = {
|
|||
'type': 'image',
|
||||
'data': {
|
||||
'url': 'https://richtexteditor.com/images/editor-image.png',
|
||||
'content': null,
|
||||
'align': 'center',
|
||||
'height': null,
|
||||
'width': null,
|
||||
'imageSourceType': 'network'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue