From 6af85fbe56b809e94df4b8f3f12c41753feb14dc Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Wed, 24 Aug 2022 15:42:01 +0800 Subject: [PATCH] feat: add image entry into selection menu --- .../assets/images/selection_menu/image.svg | 5 + .../example/assets/example.json | 2 +- .../src/render/image/image_node_builder.dart | 9 + .../src/render/image/image_node_widget.dart | 47 +++-- .../src/render/image/image_upload_widget.dart | 194 ++++++++++++++++++ .../selection_menu_item_widget.dart | 2 +- .../selection_menu_service.dart | 26 ++- .../selection_menu/selection_menu_widget.dart | 9 +- .../render/image/image_node_widget_test.dart | 1 + .../selection_menu_item_widget_test.dart | 2 +- 10 files changed, 270 insertions(+), 27 deletions(-) create mode 100644 frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/image.svg create mode 100644 frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart 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; }, );