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:
|
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/**
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
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',
|
'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'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue