diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/image.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/image.svg
new file mode 100644
index 0000000000..0e2aafe0ec
--- /dev/null
+++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/image.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json b/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json
index e9edcfa268..c6b27d9ae1 100644
--- a/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json
+++ b/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json
@@ -62,7 +62,7 @@
{
"type": "image",
"attributes": {
- "image_src": "https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb",
+ "image_src": "https://s1.ax1x.com/2022/08/24/vgAJED.png",
"align": "center"
}
},
diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart
index cab86a5dc4..f62378fc1a 100644
--- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart
+++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart
@@ -11,9 +11,11 @@ class ImageNodeBuilder extends NodeWidgetBuilder {
Widget build(NodeWidgetContext context) {
final src = context.node.attributes['image_src'];
final align = context.node.attributes['align'];
+ final width = context.node.attributes['width'];
return ImageNodeWidget(
key: context.node.key,
src: src,
+ width: width,
alignment: _textToAlignment(align),
onCopy: () {
RichClipboard.setData(RichClipboardData(text: src));
@@ -30,6 +32,13 @@ class ImageNodeBuilder extends NodeWidgetBuilder {
})
..commit();
},
+ onResize: (width) {
+ TransactionBuilder(context.editorState)
+ ..updateNode(context.node, {
+ 'width': width,
+ })
+ ..commit();
+ },
);
}
diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart
index f5ffdeb4ce..110cff01b3 100644
--- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart
+++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart
@@ -1,33 +1,57 @@
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
-import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:flutter/material.dart';
class ImageNodeWidget extends StatefulWidget {
const ImageNodeWidget({
Key? key,
required this.src,
+ this.width,
required this.alignment,
required this.onCopy,
required this.onDelete,
required this.onAlign,
+ required this.onResize,
}) : super(key: key);
final String src;
+ final double? width;
final Alignment alignment;
final VoidCallback onCopy;
final VoidCallback onDelete;
final void Function(Alignment alignment) onAlign;
+ final void Function(double width) onResize;
@override
State createState() => _ImageNodeWidgetState();
}
class _ImageNodeWidgetState extends State {
- double? imageWidth = defaultMaxTextNodeWidth;
+ double? _imageWidth;
double _initial = 0;
double _distance = 0;
bool _onFocus = false;
+ ImageStream? _imageStream;
+ late ImageStreamListener _imageStreamListener;
+
+ @override
+ void initState() {
+ super.initState();
+
+ _imageWidth = widget.width;
+ _imageStreamListener = ImageStreamListener(
+ (image, _) {
+ _imageWidth = image.image.width.toDouble();
+ },
+ );
+ }
+
+ @override
+ void dispose() {
+ _imageStream?.removeListener(_imageStreamListener);
+ super.dispose();
+ }
+
@override
Widget build(BuildContext context) {
// only support network image.
@@ -52,18 +76,13 @@ class _ImageNodeWidgetState extends State {
Widget _buildResizableImage(BuildContext context) {
final networkImage = Image.network(
widget.src,
- width: imageWidth == null ? null : imageWidth! - _distance,
+ width: _imageWidth == null ? null : _imageWidth! - _distance,
loadingBuilder: (context, child, loadingProgress) =>
loadingProgress == null ? child : _buildLoading(context),
);
- if (imageWidth == null) {
- networkImage.image.resolve(const ImageConfiguration()).addListener(
- ImageStreamListener(
- (image, _) {
- imageWidth = image.image.width.toDouble();
- },
- ),
- );
+ if (_imageWidth == null) {
+ _imageStream = networkImage.image.resolve(const ImageConfiguration())
+ ..addListener(_imageStreamListener);
}
return Stack(
children: [
@@ -108,7 +127,7 @@ class _ImageNodeWidgetState extends State {
Widget _buildLoading(BuildContext context) {
return SizedBox(
- width: imageWidth,
+ width: _imageWidth,
height: 300,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -151,9 +170,11 @@ class _ImageNodeWidgetState extends State {
}
},
onHorizontalDragEnd: (details) {
- imageWidth = imageWidth! - _distance;
+ _imageWidth = _imageWidth! - _distance;
_initial = 0;
_distance = 0;
+
+ widget.onResize(_imageWidth!);
},
child: MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart
new file mode 100644
index 0000000000..c7353532ab
--- /dev/null
+++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart
@@ -0,0 +1,194 @@
+import 'dart:collection';
+
+import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/editor_state.dart';
+import 'package:appflowy_editor/src/infra/flowy_svg.dart';
+import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
+import 'package:flutter/material.dart';
+
+OverlayEntry? _imageUploadMenu;
+void showImageUploadMenu(
+ EditorState editorState,
+ SelectionMenuService menuService,
+ BuildContext context,
+) {
+ menuService.dismiss();
+
+ _imageUploadMenu?.remove();
+ _imageUploadMenu = OverlayEntry(builder: (context) {
+ return Positioned(
+ top: menuService.topLeft.dy,
+ left: menuService.topLeft.dx,
+ child: Material(
+ child: ImageUploadMenu(
+ onSubmitted: (text) {
+ _dismissImageUploadMenu();
+ editorState.insertImageNode(text);
+ },
+ onUpload: (text) {
+ _dismissImageUploadMenu();
+ editorState.insertImageNode(text);
+ },
+ ),
+ ),
+ );
+ });
+
+ Overlay.of(context)?.insert(_imageUploadMenu!);
+}
+
+void _dismissImageUploadMenu() {
+ _imageUploadMenu?.remove();
+ _imageUploadMenu = null;
+}
+
+class ImageUploadMenu extends StatefulWidget {
+ const ImageUploadMenu({
+ Key? key,
+ required this.onSubmitted,
+ required this.onUpload,
+ }) : super(key: key);
+
+ final void Function(String text) onSubmitted;
+ final void Function(String text) onUpload;
+
+ @override
+ State createState() => _ImageUploadMenuState();
+}
+
+class _ImageUploadMenuState extends State {
+ final _textEditingController = TextEditingController();
+ final _focusNode = FocusNode();
+
+ @override
+ void initState() {
+ super.initState();
+ _focusNode.requestFocus();
+ }
+
+ @override
+ void dispose() {
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: 300,
+ padding: const EdgeInsets.all(24.0),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ boxShadow: [
+ BoxShadow(
+ blurRadius: 5,
+ spreadRadius: 1,
+ color: Colors.black.withOpacity(0.1),
+ ),
+ ],
+ borderRadius: BorderRadius.circular(6.0),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildHeader(context),
+ const SizedBox(height: 16.0),
+ _buildInput(),
+ const SizedBox(height: 18.0),
+ _buildUploadButton(context),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildHeader(BuildContext context) {
+ return const Text(
+ 'URL Image',
+ textAlign: TextAlign.left,
+ style: TextStyle(
+ fontSize: 14.0,
+ color: Colors.black,
+ fontWeight: FontWeight.w500,
+ ),
+ );
+ }
+
+ Widget _buildInput() {
+ return TextField(
+ focusNode: _focusNode,
+ style: const TextStyle(fontSize: 14.0),
+ textAlign: TextAlign.left,
+ controller: _textEditingController,
+ onSubmitted: widget.onSubmitted,
+ decoration: InputDecoration(
+ hintText: 'URL',
+ hintStyle: const TextStyle(fontSize: 14.0),
+ contentPadding: const EdgeInsets.all(16.0),
+ isDense: true,
+ suffixIcon: IconButton(
+ padding: const EdgeInsets.all(4.0),
+ icon: const FlowySvg(
+ name: 'clear',
+ width: 24,
+ height: 24,
+ ),
+ onPressed: () {
+ _textEditingController.clear();
+ },
+ ),
+ border: const OutlineInputBorder(
+ borderRadius: BorderRadius.all(Radius.circular(12.0)),
+ borderSide: BorderSide(color: Color(0xFFBDBDBD)),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildUploadButton(BuildContext context) {
+ return SizedBox(
+ width: 170,
+ height: 48,
+ child: TextButton(
+ style: ButtonStyle(
+ backgroundColor: MaterialStateProperty.all(const Color(0xFF00BCF0)),
+ shape: MaterialStateProperty.all(
+ RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12.0),
+ ),
+ ),
+ ),
+ onPressed: () {
+ widget.onUpload(_textEditingController.text);
+ },
+ child: const Text(
+ 'Upload',
+ style: TextStyle(color: Colors.white, fontSize: 14.0),
+ ),
+ ),
+ );
+ }
+}
+
+extension on EditorState {
+ void insertImageNode(String src) {
+ final selection = service.selectionService.currentSelection.value;
+ if (selection == null) {
+ return;
+ }
+ final imageNode = Node(
+ type: 'image',
+ children: LinkedList(),
+ attributes: {
+ 'image_src': src,
+ 'align': 'center',
+ },
+ );
+ TransactionBuilder(this)
+ ..insertNode(
+ selection.start.path,
+ imageNode,
+ )
+ ..commit();
+ }
+}
diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart
index 36e0a2e02e..3b7307f039 100644
--- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart
+++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart
@@ -45,7 +45,7 @@ class SelectionMenuItemWidget extends StatelessWidget {
),
),
onPressed: () {
- item.handler(editorState, menuService);
+ item.handler(editorState, menuService, context);
},
),
),
diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart
index 94fa6190d8..7f4f803610 100644
--- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart
+++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart
@@ -1,5 +1,6 @@
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
+import 'package:appflowy_editor/src/render/image/image_upload_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
@@ -23,6 +24,7 @@ class SelectionMenu implements SelectionMenuService {
OverlayEntry? _selectionMenuEntry;
bool _selectionUpdateByInner = false;
+ Offset? _topLeft;
@override
void dismiss() {
@@ -53,6 +55,7 @@ class SelectionMenu implements SelectionMenuService {
return;
}
final offset = selectionRects.first.bottomRight + const Offset(10, 10);
+ _topLeft = offset;
_selectionMenuEntry = OverlayEntry(builder: (context) {
return Positioned(
@@ -84,8 +87,9 @@ class SelectionMenu implements SelectionMenuService {
}
@override
- // TODO: implement topLeft
- Offset get topLeft => throw UnimplementedError();
+ Offset get topLeft {
+ return _topLeft ?? Offset.zero;
+ }
void _onSelectionChange() {
// workaround: SelectionService has been released after hot reload.
@@ -115,7 +119,7 @@ final List _defaultSelectionMenuItems = [
name: 'Text',
icon: _selectionMenuIcon('text'),
keywords: ['text'],
- handler: (editorState, menuService) {
+ handler: (editorState, _, __) {
insertTextNodeAfterSelection(editorState, {});
},
),
@@ -123,7 +127,7 @@ final List _defaultSelectionMenuItems = [
name: 'Heading 1',
icon: _selectionMenuIcon('h1'),
keywords: ['heading 1, h1'],
- handler: (editorState, menuService) {
+ handler: (editorState, _, __) {
insertHeadingAfterSelection(editorState, StyleKey.h1);
},
),
@@ -131,7 +135,7 @@ final List _defaultSelectionMenuItems = [
name: 'Heading 2',
icon: _selectionMenuIcon('h2'),
keywords: ['heading 2, h2'],
- handler: (editorState, menuService) {
+ handler: (editorState, _, __) {
insertHeadingAfterSelection(editorState, StyleKey.h2);
},
),
@@ -139,15 +143,21 @@ final List _defaultSelectionMenuItems = [
name: 'Heading 3',
icon: _selectionMenuIcon('h3'),
keywords: ['heading 3, h3'],
- handler: (editorState, menuService) {
+ handler: (editorState, _, __) {
insertHeadingAfterSelection(editorState, StyleKey.h3);
},
),
+ SelectionMenuItem(
+ name: 'Image',
+ icon: _selectionMenuIcon('image'),
+ keywords: ['image'],
+ handler: showImageUploadMenu,
+ ),
SelectionMenuItem(
name: 'Bulleted list',
icon: _selectionMenuIcon('bulleted_list'),
keywords: ['bulleted list', 'list', 'unordered list'],
- handler: (editorState, menuService) {
+ handler: (editorState, _, __) {
insertBulletedListAfterSelection(editorState);
},
),
@@ -155,7 +165,7 @@ final List _defaultSelectionMenuItems = [
name: 'Checkbox',
icon: _selectionMenuIcon('checkbox'),
keywords: ['todo list', 'list', 'checkbox list'],
- handler: (editorState, menuService) {
+ handler: (editorState, _, __) {
insertCheckboxAfterSelection(editorState);
},
),
diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart
index 70f7bbc337..f73251081f 100644
--- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart
+++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart
@@ -22,8 +22,11 @@ class SelectionMenuItem {
///
/// The keywords are used to quickly retrieve items.
final List keywords;
- final void Function(EditorState editorState, SelectionMenuService menuService)
- handler;
+ final void Function(
+ EditorState editorState,
+ SelectionMenuService menuService,
+ BuildContext context,
+ ) handler;
}
class SelectionMenuWidget extends StatefulWidget {
@@ -203,7 +206,7 @@ class _SelectionMenuWidgetState extends State {
if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) {
_deleteLastCharacters(length: keyword.length + 1);
_showingItems[_selectedIndex]
- .handler(widget.editorState, widget.menuService);
+ .handler(widget.editorState, widget.menuService, context);
return KeyEventResult.handled;
}
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart
index 4cb68a488f..d2f774d33f 100644
--- a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart
+++ b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart
@@ -30,6 +30,7 @@ void main() async {
onAlign: (alignment) {
onAlignHit = true;
},
+ onResize: (width) {},
);
await tester.pumpWidget(
diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_item_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_item_widget_test.dart
index 1488b15b18..01c1403738 100644
--- a/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_item_widget_test.dart
+++ b/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_item_widget_test.dart
@@ -20,7 +20,7 @@ void main() async {
name: 'example',
icon: icon,
keywords: ['example A', 'example B'],
- handler: (editorState, menuService) {
+ handler: (editorState, menuService, context) {
flag = true;
},
);