Ver Fonte

feat: add image entry into selection menu

Lucas.Xu há 2 anos atrás
pai
commit
6af85fbe56

+ 5 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/image.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="#333333"/>
+<circle cx="5.5" cy="6.5" r="1" stroke="#333333"/>
+<path d="M5 13L10.112 8.45603C10.4211 8.18126 10.8674 8.12513 11.235 8.31482L14.5 10" stroke="#333333"/>
+</svg>

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/example/assets/example.json

@@ -62,7 +62,7 @@
       {
       {
         "type": "image",
         "type": "image",
         "attributes": {
         "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"
           "align": "center"
         }
         }
       },
       },

+ 9 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart

@@ -11,9 +11,11 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
   Widget build(NodeWidgetContext<Node> context) {
   Widget build(NodeWidgetContext<Node> context) {
     final src = context.node.attributes['image_src'];
     final src = context.node.attributes['image_src'];
     final align = context.node.attributes['align'];
     final align = context.node.attributes['align'];
+    final width = context.node.attributes['width'];
     return ImageNodeWidget(
     return ImageNodeWidget(
       key: context.node.key,
       key: context.node.key,
       src: src,
       src: src,
+      width: width,
       alignment: _textToAlignment(align),
       alignment: _textToAlignment(align),
       onCopy: () {
       onCopy: () {
         RichClipboard.setData(RichClipboardData(text: src));
         RichClipboard.setData(RichClipboardData(text: src));
@@ -30,6 +32,13 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
           })
           })
           ..commit();
           ..commit();
       },
       },
+      onResize: (width) {
+        TransactionBuilder(context.editorState)
+          ..updateNode(context.node, {
+            'width': width,
+          })
+          ..commit();
+      },
     );
     );
   }
   }
 
 

+ 34 - 13
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/infra/flowy_svg.dart';
-import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 
 
 class ImageNodeWidget extends StatefulWidget {
 class ImageNodeWidget extends StatefulWidget {
   const ImageNodeWidget({
   const ImageNodeWidget({
     Key? key,
     Key? key,
     required this.src,
     required this.src,
+    this.width,
     required this.alignment,
     required this.alignment,
     required this.onCopy,
     required this.onCopy,
     required this.onDelete,
     required this.onDelete,
     required this.onAlign,
     required this.onAlign,
+    required this.onResize,
   }) : super(key: key);
   }) : super(key: key);
 
 
   final String src;
   final String src;
+  final double? width;
   final Alignment alignment;
   final Alignment alignment;
   final VoidCallback onCopy;
   final VoidCallback onCopy;
   final VoidCallback onDelete;
   final VoidCallback onDelete;
   final void Function(Alignment alignment) onAlign;
   final void Function(Alignment alignment) onAlign;
+  final void Function(double width) onResize;
 
 
   @override
   @override
   State<ImageNodeWidget> createState() => _ImageNodeWidgetState();
   State<ImageNodeWidget> createState() => _ImageNodeWidgetState();
 }
 }
 
 
 class _ImageNodeWidgetState extends State<ImageNodeWidget> {
 class _ImageNodeWidgetState extends State<ImageNodeWidget> {
-  double? imageWidth = defaultMaxTextNodeWidth;
+  double? _imageWidth;
   double _initial = 0;
   double _initial = 0;
   double _distance = 0;
   double _distance = 0;
   bool _onFocus = false;
   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
   @override
   Widget build(BuildContext context) {
   Widget build(BuildContext context) {
     // only support network image.
     // only support network image.
@@ -52,18 +76,13 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
   Widget _buildResizableImage(BuildContext context) {
   Widget _buildResizableImage(BuildContext context) {
     final networkImage = Image.network(
     final networkImage = Image.network(
       widget.src,
       widget.src,
-      width: imageWidth == null ? null : imageWidth! - _distance,
+      width: _imageWidth == null ? null : _imageWidth! - _distance,
       loadingBuilder: (context, child, loadingProgress) =>
       loadingBuilder: (context, child, loadingProgress) =>
           loadingProgress == null ? child : _buildLoading(context),
           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(
     return Stack(
       children: [
       children: [
@@ -108,7 +127,7 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
 
 
   Widget _buildLoading(BuildContext context) {
   Widget _buildLoading(BuildContext context) {
     return SizedBox(
     return SizedBox(
-      width: imageWidth,
+      width: _imageWidth,
       height: 300,
       height: 300,
       child: Row(
       child: Row(
         mainAxisAlignment: MainAxisAlignment.center,
         mainAxisAlignment: MainAxisAlignment.center,
@@ -151,9 +170,11 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
           }
           }
         },
         },
         onHorizontalDragEnd: (details) {
         onHorizontalDragEnd: (details) {
-          imageWidth = imageWidth! - _distance;
+          _imageWidth = _imageWidth! - _distance;
           _initial = 0;
           _initial = 0;
           _distance = 0;
           _distance = 0;
+
+          widget.onResize(_imageWidth!);
         },
         },
         child: MouseRegion(
         child: MouseRegion(
           cursor: SystemMouseCursors.resizeLeftRight,
           cursor: SystemMouseCursors.resizeLeftRight,

+ 194 - 0
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<ImageUploadMenu> createState() => _ImageUploadMenuState();
+}
+
+class _ImageUploadMenuState extends State<ImageUploadMenu> {
+  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>(
+            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();
+  }
+}

+ 1 - 1
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: () {
           onPressed: () {
-            item.handler(editorState, menuService);
+            item.handler(editorState, menuService, context);
           },
           },
         ),
         ),
       ),
       ),

+ 18 - 8
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/editor_state.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.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/rich_text/rich_text_style.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.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';
 import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
@@ -23,6 +24,7 @@ class SelectionMenu implements SelectionMenuService {
 
 
   OverlayEntry? _selectionMenuEntry;
   OverlayEntry? _selectionMenuEntry;
   bool _selectionUpdateByInner = false;
   bool _selectionUpdateByInner = false;
+  Offset? _topLeft;
 
 
   @override
   @override
   void dismiss() {
   void dismiss() {
@@ -53,6 +55,7 @@ class SelectionMenu implements SelectionMenuService {
       return;
       return;
     }
     }
     final offset = selectionRects.first.bottomRight + const Offset(10, 10);
     final offset = selectionRects.first.bottomRight + const Offset(10, 10);
+    _topLeft = offset;
 
 
     _selectionMenuEntry = OverlayEntry(builder: (context) {
     _selectionMenuEntry = OverlayEntry(builder: (context) {
       return Positioned(
       return Positioned(
@@ -84,8 +87,9 @@ class SelectionMenu implements SelectionMenuService {
   }
   }
 
 
   @override
   @override
-  // TODO: implement topLeft
-  Offset get topLeft => throw UnimplementedError();
+  Offset get topLeft {
+    return _topLeft ?? Offset.zero;
+  }
 
 
   void _onSelectionChange() {
   void _onSelectionChange() {
     // workaround: SelectionService has been released after hot reload.
     // workaround: SelectionService has been released after hot reload.
@@ -115,7 +119,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     name: 'Text',
     name: 'Text',
     icon: _selectionMenuIcon('text'),
     icon: _selectionMenuIcon('text'),
     keywords: ['text'],
     keywords: ['text'],
-    handler: (editorState, menuService) {
+    handler: (editorState, _, __) {
       insertTextNodeAfterSelection(editorState, {});
       insertTextNodeAfterSelection(editorState, {});
     },
     },
   ),
   ),
@@ -123,7 +127,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     name: 'Heading 1',
     name: 'Heading 1',
     icon: _selectionMenuIcon('h1'),
     icon: _selectionMenuIcon('h1'),
     keywords: ['heading 1, h1'],
     keywords: ['heading 1, h1'],
-    handler: (editorState, menuService) {
+    handler: (editorState, _, __) {
       insertHeadingAfterSelection(editorState, StyleKey.h1);
       insertHeadingAfterSelection(editorState, StyleKey.h1);
     },
     },
   ),
   ),
@@ -131,7 +135,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     name: 'Heading 2',
     name: 'Heading 2',
     icon: _selectionMenuIcon('h2'),
     icon: _selectionMenuIcon('h2'),
     keywords: ['heading 2, h2'],
     keywords: ['heading 2, h2'],
-    handler: (editorState, menuService) {
+    handler: (editorState, _, __) {
       insertHeadingAfterSelection(editorState, StyleKey.h2);
       insertHeadingAfterSelection(editorState, StyleKey.h2);
     },
     },
   ),
   ),
@@ -139,15 +143,21 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     name: 'Heading 3',
     name: 'Heading 3',
     icon: _selectionMenuIcon('h3'),
     icon: _selectionMenuIcon('h3'),
     keywords: ['heading 3, h3'],
     keywords: ['heading 3, h3'],
-    handler: (editorState, menuService) {
+    handler: (editorState, _, __) {
       insertHeadingAfterSelection(editorState, StyleKey.h3);
       insertHeadingAfterSelection(editorState, StyleKey.h3);
     },
     },
   ),
   ),
+  SelectionMenuItem(
+    name: 'Image',
+    icon: _selectionMenuIcon('image'),
+    keywords: ['image'],
+    handler: showImageUploadMenu,
+  ),
   SelectionMenuItem(
   SelectionMenuItem(
     name: 'Bulleted list',
     name: 'Bulleted list',
     icon: _selectionMenuIcon('bulleted_list'),
     icon: _selectionMenuIcon('bulleted_list'),
     keywords: ['bulleted list', 'list', 'unordered list'],
     keywords: ['bulleted list', 'list', 'unordered list'],
-    handler: (editorState, menuService) {
+    handler: (editorState, _, __) {
       insertBulletedListAfterSelection(editorState);
       insertBulletedListAfterSelection(editorState);
     },
     },
   ),
   ),
@@ -155,7 +165,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     name: 'Checkbox',
     name: 'Checkbox',
     icon: _selectionMenuIcon('checkbox'),
     icon: _selectionMenuIcon('checkbox'),
     keywords: ['todo list', 'list', 'checkbox list'],
     keywords: ['todo list', 'list', 'checkbox list'],
-    handler: (editorState, menuService) {
+    handler: (editorState, _, __) {
       insertCheckboxAfterSelection(editorState);
       insertCheckboxAfterSelection(editorState);
     },
     },
   ),
   ),

+ 6 - 3
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.
   /// The keywords are used to quickly retrieve items.
   final List<String> keywords;
   final List<String> keywords;
-  final void Function(EditorState editorState, SelectionMenuService menuService)
-      handler;
+  final void Function(
+    EditorState editorState,
+    SelectionMenuService menuService,
+    BuildContext context,
+  ) handler;
 }
 }
 
 
 class SelectionMenuWidget extends StatefulWidget {
 class SelectionMenuWidget extends StatefulWidget {
@@ -203,7 +206,7 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
       if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) {
       if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) {
         _deleteLastCharacters(length: keyword.length + 1);
         _deleteLastCharacters(length: keyword.length + 1);
         _showingItems[_selectedIndex]
         _showingItems[_selectedIndex]
-            .handler(widget.editorState, widget.menuService);
+            .handler(widget.editorState, widget.menuService, context);
         return KeyEventResult.handled;
         return KeyEventResult.handled;
       }
       }
     } else if (event.logicalKey == LogicalKeyboardKey.escape) {
     } else if (event.logicalKey == LogicalKeyboardKey.escape) {

+ 1 - 0
frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart

@@ -30,6 +30,7 @@ void main() async {
           onAlign: (alignment) {
           onAlign: (alignment) {
             onAlignHit = true;
             onAlignHit = true;
           },
           },
+          onResize: (width) {},
         );
         );
 
 
         await tester.pumpWidget(
         await tester.pumpWidget(

+ 1 - 1
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',
         name: 'example',
         icon: icon,
         icon: icon,
         keywords: ['example A', 'example B'],
         keywords: ['example A', 'example B'],
-        handler: (editorState, menuService) {
+        handler: (editorState, menuService, context) {
           flag = true;
           flag = true;
         },
         },
       );
       );