Browse Source

Merge pull request #926 from LucasXu0/test/image

#918
Lucas.Xu 2 năm trước cách đây
mục cha
commit
a7f8c99710
35 tập tin đã thay đổi với 579 bổ sung240 xóa
  1. 4 0
      frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg
  2. 5 6
      frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart
  3. 1 0
      frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart
  4. 11 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart
  5. 4 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart
  6. 14 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart
  7. 8 6
      frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart
  8. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart
  9. 1 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart
  10. 58 6
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart
  11. 22 5
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart
  12. 20 23
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart
  13. 30 33
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart
  14. 57 43
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart
  15. 7 10
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart
  16. 18 21
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart
  17. 20 23
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart
  18. 6 9
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart
  19. 0 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart
  20. 20 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart
  21. 42 11
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart
  22. 29 18
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart
  23. 0 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart
  24. 15 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart
  25. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart
  26. 4 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart
  27. 12 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart
  28. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart
  29. 1 1
      frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart
  30. 5 4
      frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart
  31. 11 0
      frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart
  32. 2 0
      frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart
  33. 2 2
      frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart
  34. 126 1
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart
  35. 21 0
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.974 6.33301H7.35865C6.7922 6.33301 6.33301 6.7922 6.33301 7.35865V11.974C6.33301 12.5405 6.7922 12.9997 7.35865 12.9997H11.974C12.5405 12.9997 12.9997 12.5405 12.9997 11.974V7.35865C12.9997 6.7922 12.5405 6.33301 11.974 6.33301Z" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.53846 9.66667H4.02564C3.75362 9.66667 3.49275 9.55861 3.3004 9.36626C3.10806 9.17392 3 8.91304 3 8.64103V4.02564C3 3.75362 3.10806 3.49275 3.3004 3.3004C3.49275 3.10806 3.75362 3 4.02564 3H8.64103C8.91304 3 9.17392 3.10806 9.36626 3.3004C9.55861 3.49275 9.66667 3.75362 9.66667 4.02564V4.53846" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 5 - 6
frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart

@@ -45,10 +45,8 @@ class _MyHomePageState extends State<MyHomePage> {
   @override
   Widget build(BuildContext context) {
     return Scaffold(
-      body: Container(
-        alignment: Alignment.topCenter,
-        child: _buildEditor(context),
-      ),
+      extendBodyBehindAppBar: true,
+      body: _buildEditor(context),
       floatingActionButton: _buildExpandableFab(),
     );
   }
@@ -92,10 +90,11 @@ class _MyHomePageState extends State<MyHomePage> {
             ..handler = (message) {
               debugPrint(message);
             };
-          return Container(
-            padding: const EdgeInsets.all(20),
+          return SizedBox(
+            width: MediaQuery.of(context).size.width,
             child: AppFlowyEditor(
               editorState: _editorState,
+              editorStyle: const EditorStyle.defaultStyle(),
             ),
           );
         } else {

+ 1 - 0
frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart

@@ -2,6 +2,7 @@
 library appflowy_editor;
 
 export 'src/infra/log.dart';
+export 'src/render/style/editor_style.dart';
 export 'src/document/node.dart';
 export 'src/document/path.dart';
 export 'src/document/position.dart';

+ 11 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart

@@ -62,10 +62,17 @@ class StateTree {
       }
       return false;
     }
-    for (var i = 0; i < nodes.length; i++) {
-      final node = nodes[i];
-      insertedNode!.insertAfter(node);
-      insertedNode = node;
+    if (path.last <= 0) {
+      for (var i = 0; i < nodes.length; i++) {
+        final node = nodes[i];
+        insertedNode.insertBefore(node);
+      }
+    } else {
+      for (var i = 0; i < nodes.length; i++) {
+        final node = nodes[i];
+        insertedNode!.insertAfter(node);
+        insertedNode = node;
+      }
     }
     return true;
   }

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart

@@ -1,6 +1,7 @@
 import 'dart:async';
 import 'package:appflowy_editor/src/infra/log.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
+import 'package:appflowy_editor/src/render/style/editor_style.dart';
 import 'package:appflowy_editor/src/service/service.dart';
 import 'package:flutter/material.dart';
 
@@ -58,6 +59,9 @@ class EditorState {
   /// Stores the selection menu items.
   List<SelectionMenuItem> selectionMenuItems = [];
 
+  /// Stores the editor style.
+  EditorStyle editorStyle = const EditorStyle.defaultStyle();
+
   final UndoManager undoManager = UndoManager();
   Selection? _cursorSelection;
 

+ 14 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart

@@ -0,0 +1,14 @@
+import 'package:url_launcher/url_launcher_string.dart';
+
+Future<bool> safeLaunchUrl(String? href) async {
+  if (href == null) {
+    return Future.value(false);
+  }
+  final uri = Uri.parse(href);
+  // url_launcher cannot open a link without scheme.
+  final newHref = (uri.scheme.isNotEmpty ? href : 'http://$href').trim();
+  if (await canLaunchUrlString(newHref)) {
+    await launchUrlString(newHref);
+  }
+  return Future.value(true);
+}

+ 8 - 6
frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart

@@ -115,17 +115,18 @@ class TransactionBuilder {
   /// Inserts content at a specified index.
   /// Optionally, you may specify formatting attributes that are applied to the inserted string.
   /// By default, the formatting attributes before the insert position will be used.
-  insertText(TextNode node, int index, String content,
-      {Attributes? attributes, Attributes? removedAttributes}) {
+  insertText(
+    TextNode node,
+    int index,
+    String content, {
+    Attributes? attributes,
+  }) {
     var newAttributes = attributes;
     if (index != 0 && attributes == null) {
       newAttributes =
           node.delta.slice(max(index - 1, 0), index).first.attributes;
       if (newAttributes != null) {
         newAttributes = Attributes.from(newAttributes);
-        if (removedAttributes != null) {
-          newAttributes.addAll(removedAttributes);
-        }
       }
     }
     textEdit(
@@ -138,7 +139,8 @@ class TransactionBuilder {
         ),
     );
     afterSelection = Selection.collapsed(
-        Position(path: node.path, offset: index + content.length));
+      Position(path: node.path, offset: index + content.length),
+    );
   }
 
   /// Assigns formatting attributes to a range of text.

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart

@@ -33,7 +33,7 @@ class EditorNodeWidget extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return Column(
-      crossAxisAlignment: CrossAxisAlignment.center,
+      crossAxisAlignment: CrossAxisAlignment.start,
       children: node.children
           .map(
             (child) =>

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

@@ -17,6 +17,7 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
     }
     return ImageNodeWidget(
       key: context.node.key,
+      node: context.node,
       src: src,
       width: width,
       alignment: _textToAlignment(align),

+ 58 - 6
frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart

@@ -1,10 +1,15 @@
+import 'package:appflowy_editor/src/extensions/object_extensions.dart';
+import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/document/position.dart';
+import 'package:appflowy_editor/src/document/selection.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
-import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
+import 'package:appflowy_editor/src/render/selection/selectable.dart';
 import 'package:flutter/material.dart';
 
 class ImageNodeWidget extends StatefulWidget {
   const ImageNodeWidget({
     Key? key,
+    required this.node,
     required this.src,
     this.width,
     required this.alignment,
@@ -14,6 +19,7 @@ class ImageNodeWidget extends StatefulWidget {
     required this.onResize,
   }) : super(key: key);
 
+  final Node node;
   final String src;
   final double? width;
   final Alignment alignment;
@@ -26,7 +32,9 @@ class ImageNodeWidget extends StatefulWidget {
   State<ImageNodeWidget> createState() => _ImageNodeWidgetState();
 }
 
-class _ImageNodeWidgetState extends State<ImageNodeWidget> {
+class _ImageNodeWidgetState extends State<ImageNodeWidget> with Selectable {
+  final _imageKey = GlobalKey();
+
   double? _imageWidth;
   double _initial = 0;
   double _distance = 0;
@@ -42,7 +50,11 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
     _imageWidth = widget.width;
     _imageStreamListener = ImageStreamListener(
       (image, _) {
-        _imageWidth = image.image.width.toDouble();
+        _imageWidth = _imageKey.currentContext
+            ?.findRenderObject()
+            ?.unwrapOrNull<RenderBox>()
+            ?.size
+            .width;
       },
     );
   }
@@ -56,14 +68,54 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
   @override
   Widget build(BuildContext context) {
     // only support network image.
-
     return Container(
-      width: defaultMaxTextNodeWidth,
+      key: _imageKey,
       padding: const EdgeInsets.only(top: 8, bottom: 8),
       child: _buildNetworkImage(context),
     );
   }
 
+  @override
+  Position start() {
+    return Position(path: widget.node.path, offset: 0);
+  }
+
+  @override
+  Position end() {
+    return Position(path: widget.node.path, offset: 1);
+  }
+
+  @override
+  Position getPositionInOffset(Offset start) {
+    return end();
+  }
+
+  @override
+  Rect? getCursorRectInPosition(Position position) {
+    return null;
+  }
+
+  @override
+  List<Rect> getRectsInSelection(Selection selection) {
+    final renderBox = context.findRenderObject() as RenderBox;
+    return [Offset.zero & renderBox.size];
+  }
+
+  @override
+  Selection getSelectionInRange(Offset start, Offset end) {
+    if (start <= end) {
+      return Selection(start: this.start(), end: this.end());
+    } else {
+      return Selection(start: this.end(), end: this.start());
+    }
+  }
+
+  @override
+  Offset localToGlobal(Offset offset) {
+    final renderBox = context.findRenderObject() as RenderBox;
+    return renderBox.localToGlobal(offset);
+  }
+
   Widget _buildNetworkImage(BuildContext context) {
     return Align(
       alignment: widget.alignment,
@@ -87,7 +139,7 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
       loadingBuilder: (context, child, loadingProgress) =>
           loadingProgress == null ? child : _buildLoading(context),
       errorBuilder: (context, error, stackTrace) {
-        _imageWidth ??= defaultMaxTextNodeWidth;
+        // _imageWidth ??= defaultMaxTextNodeWidth;
         return _buildError(context);
       },
     );

+ 22 - 5
frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart

@@ -6,14 +6,18 @@ class LinkMenu extends StatefulWidget {
     Key? key,
     this.linkText,
     required this.onSubmitted,
+    required this.onOpenLink,
     required this.onCopyLink,
     required this.onRemoveLink,
+    required this.onFocusChange,
   }) : super(key: key);
 
   final String? linkText;
   final void Function(String text) onSubmitted;
+  final VoidCallback onOpenLink;
   final VoidCallback onCopyLink;
   final VoidCallback onRemoveLink;
+  final void Function(bool value) onFocusChange;
 
   @override
   State<LinkMenu> createState() => _LinkMenuState();
@@ -26,15 +30,14 @@ class _LinkMenuState extends State<LinkMenu> {
   @override
   void initState() {
     super.initState();
-
     _textEditingController.text = widget.linkText ?? '';
-    _focusNode.requestFocus();
+    _focusNode.addListener(_onFocusChange);
   }
 
   @override
   void dispose() {
-    _focusNode.dispose();
-
+    _textEditingController.dispose();
+    _focusNode.removeListener(_onFocusChange);
     super.dispose();
   }
 
@@ -67,6 +70,12 @@ class _LinkMenuState extends State<LinkMenu> {
               if (widget.linkText != null) ...[
                 _buildIconButton(
                   iconName: 'link',
+                  text: 'Open link',
+                  onPressed: widget.onOpenLink,
+                ),
+                _buildIconButton(
+                  iconName: 'copy',
+                  color: Colors.black,
                   text: 'Copy link',
                   onPressed: widget.onCopyLink,
                 ),
@@ -126,11 +135,15 @@ class _LinkMenuState extends State<LinkMenu> {
 
   Widget _buildIconButton({
     required String iconName,
+    Color? color,
     required String text,
     required VoidCallback onPressed,
   }) {
     return TextButton.icon(
-      icon: FlowySvg(name: iconName),
+      icon: FlowySvg(
+        name: iconName,
+        color: color,
+      ),
       style: TextButton.styleFrom(
         minimumSize: const Size.fromHeight(40),
         padding: EdgeInsets.zero,
@@ -148,4 +161,8 @@ class _LinkMenuState extends State<LinkMenu> {
       onPressed: onPressed,
     );
   }
+
+  void _onFocusChange() {
+    widget.onFocusChange(_focusNode.hasFocus);
+  }
 }

+ 20 - 23
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart

@@ -56,30 +56,27 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
 
   @override
   Widget build(BuildContext context) {
-    return SizedBox(
-      width: defaultMaxTextNodeWidth,
-      child: Padding(
-        padding: EdgeInsets.only(bottom: defaultLinePadding),
-        child: Row(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            FlowySvg(
-              key: iconKey,
-              width: _iconWidth,
-              height: _iconWidth,
-              padding: EdgeInsets.only(right: _iconRightPadding),
-              name: 'point',
+    return Padding(
+      padding: EdgeInsets.only(bottom: defaultLinePadding),
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          FlowySvg(
+            key: iconKey,
+            width: _iconWidth,
+            height: _iconWidth,
+            padding: EdgeInsets.only(right: _iconRightPadding),
+            name: 'point',
+          ),
+          Flexible(
+            child: FlowyRichText(
+              key: _richTextKey,
+              placeholderText: 'List',
+              textNode: widget.textNode,
+              editorState: widget.editorState,
             ),
-            Expanded(
-              child: FlowyRichText(
-                key: _richTextKey,
-                placeholderText: 'List',
-                textNode: widget.textNode,
-                editorState: widget.editorState,
-              ),
-            ),
-          ],
-        ),
+          )
+        ],
       ),
     );
   }

+ 30 - 33
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart

@@ -63,41 +63,38 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
 
   Widget _buildWithSingle(BuildContext context) {
     final check = widget.textNode.attributes.check;
-    return SizedBox(
-      width: defaultMaxTextNodeWidth,
-      child: Padding(
-        padding: EdgeInsets.only(bottom: defaultLinePadding),
-        child: Row(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            GestureDetector(
-              key: iconKey,
-              child: FlowySvg(
-                width: _iconWidth,
-                height: _iconWidth,
-                padding: EdgeInsets.only(right: _iconRightPadding),
-                name: check ? 'check' : 'uncheck',
-              ),
-              onTap: () {
-                TransactionBuilder(widget.editorState)
-                  ..updateNode(widget.textNode, {
-                    StyleKey.checkbox: !check,
-                  })
-                  ..commit();
-              },
+    return Padding(
+      padding: EdgeInsets.only(bottom: defaultLinePadding),
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          GestureDetector(
+            key: iconKey,
+            child: FlowySvg(
+              width: _iconWidth,
+              height: _iconWidth,
+              padding: EdgeInsets.only(right: _iconRightPadding),
+              name: check ? 'check' : 'uncheck',
             ),
-            Expanded(
-              child: FlowyRichText(
-                key: _richTextKey,
-                placeholderText: 'To-do',
-                textNode: widget.textNode,
-                textSpanDecorator: _textSpanDecorator,
-                placeholderTextSpanDecorator: _textSpanDecorator,
-                editorState: widget.editorState,
-              ),
+            onTap: () {
+              TransactionBuilder(widget.editorState)
+                ..updateNode(widget.textNode, {
+                  StyleKey.checkbox: !check,
+                })
+                ..commit();
+            },
+          ),
+          Flexible(
+            child: FlowyRichText(
+              key: _richTextKey,
+              placeholderText: 'To-do',
+              textNode: widget.textNode,
+              textSpanDecorator: _textSpanDecorator,
+              placeholderTextSpanDecorator: _textSpanDecorator,
+              editorState: widget.editorState,
             ),
-          ],
-        ),
+          ),
+        ],
       ),
     );
   }

+ 57 - 43
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart

@@ -1,6 +1,8 @@
 import 'dart:async';
 import 'dart:ui';
 
+import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
+import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
@@ -13,7 +15,6 @@ import 'package:appflowy_editor/src/document/text_delta.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
 import 'package:appflowy_editor/src/render/selection/selectable.dart';
-import 'package:url_launcher/url_launcher_string.dart';
 
 typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
 
@@ -42,7 +43,7 @@ class FlowyRichText extends StatefulWidget {
 }
 
 class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
-  final _textKey = GlobalKey();
+  var _textKey = GlobalKey();
   final _placeholderTextKey = GlobalKey();
 
   final _lineHeight = 1.5;
@@ -53,6 +54,17 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
   RenderParagraph get _placeholderRenderParagraph =>
       _placeholderTextKey.currentContext?.findRenderObject() as RenderParagraph;
 
+  @override
+  void didUpdateWidget(covariant FlowyRichText oldWidget) {
+    super.didUpdateWidget(oldWidget);
+
+    // https://github.com/flutter/flutter/issues/110342
+    if (_textKey.currentWidget is RichText) {
+      // Force refresh the RichText widget.
+      _textKey = GlobalKey();
+    }
+  }
+
   @override
   Widget build(BuildContext context) {
     return _buildRichText(context);
@@ -182,7 +194,9 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
     return RichText(
       key: _textKey,
       textHeightBehavior: const TextHeightBehavior(
-          applyHeightToFirstAscent: false, applyHeightToLastDescent: false),
+        applyHeightToFirstAscent: false,
+        applyHeightToLastDescent: false,
+      ),
       text: widget.textSpanDecorator != null
           ? widget.textSpanDecorator!(textSpan)
           : textSpan,
@@ -193,53 +207,23 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
     var offset = 0;
     return TextSpan(
       children: widget.textNode.delta.whereType<TextInsert>().map((insert) {
-        GestureRecognizer? gestureDetector;
+        GestureRecognizer? gestureRecognizer;
         if (insert.attributes?[StyleKey.href] != null) {
-          final startOffset = offset;
-          Timer? timer;
-          var tapCount = 0;
-          gestureDetector = TapGestureRecognizer()
-            ..onTap = () async {
-              // implement a simple double tap logic
-              tapCount += 1;
-              timer?.cancel();
-
-              if (tapCount == 2) {
-                tapCount = 0;
-                final href = insert.attributes![StyleKey.href];
-                final uri = Uri.parse(href);
-                // url_launcher cannot open a link without scheme.
-                final newHref =
-                    (uri.scheme.isNotEmpty ? href : 'http://$href').trim();
-                if (await canLaunchUrlString(newHref)) {
-                  await launchUrlString(newHref);
-                }
-                return;
-              }
-
-              timer = Timer(const Duration(milliseconds: 200), () {
-                tapCount = 0;
-                // update selection
-                final selection = Selection.single(
-                  path: widget.textNode.path,
-                  startOffset: startOffset,
-                  endOffset: startOffset + insert.length,
-                );
-                widget.editorState.service.selectionService
-                    .updateSelection(selection);
-                WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
-                  widget.editorState.service.toolbarService
-                      ?.triggerHandler('appflowy.toolbar.link');
-                });
-              });
-            };
+          gestureRecognizer = _buildTapHrefGestureRecognizer(
+            insert.attributes![StyleKey.href],
+            Selection.single(
+              path: widget.textNode.path,
+              startOffset: offset,
+              endOffset: offset + insert.length,
+            ),
+          );
         }
         offset += insert.length;
         final textSpan = RichTextStyle(
           attributes: insert.attributes ?? {},
           text: insert.content,
           height: _lineHeight,
-          gestureRecognizer: gestureDetector,
+          gestureRecognizer: gestureRecognizer,
         ).toTextSpan();
         return textSpan;
       }).toList(growable: false),
@@ -255,4 +239,34 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
           height: _lineHeight,
         ).toTextSpan()
       ]);
+
+  GestureRecognizer _buildTapHrefGestureRecognizer(
+      String href, Selection selection) {
+    Timer? timer;
+    var tapCount = 0;
+    final tapGestureRecognizer = TapGestureRecognizer()
+      ..onTap = () async {
+        // implement a simple double tap logic
+        tapCount += 1;
+        timer?.cancel();
+
+        if (tapCount == 2) {
+          tapCount = 0;
+          safeLaunchUrl(href);
+          return;
+        }
+
+        timer = Timer(const Duration(milliseconds: 200), () {
+          tapCount = 0;
+          WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+            showLinkMenu(
+              context,
+              widget.editorState,
+              customSelection: selection,
+            );
+          });
+        });
+      };
+    return tapGestureRecognizer;
+  }
 }

+ 7 - 10
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart

@@ -63,16 +63,13 @@ class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
         top: _topPadding,
         bottom: defaultLinePadding,
       ),
-      child: SizedBox(
-        width: defaultMaxTextNodeWidth,
-        child: FlowyRichText(
-          key: _richTextKey,
-          placeholderText: 'Heading',
-          placeholderTextSpanDecorator: _placeholderTextSpanDecorator,
-          textSpanDecorator: _textSpanDecorator,
-          textNode: widget.textNode,
-          editorState: widget.editorState,
-        ),
+      child: FlowyRichText(
+        key: _richTextKey,
+        placeholderText: 'Heading',
+        placeholderTextSpanDecorator: _placeholderTextSpanDecorator,
+        textSpanDecorator: _textSpanDecorator,
+        textNode: widget.textNode,
+        editorState: widget.editorState,
       ),
     );
   }

+ 18 - 21
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart

@@ -58,28 +58,25 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
   Widget build(BuildContext context) {
     return Padding(
         padding: EdgeInsets.only(bottom: defaultLinePadding),
-        child: SizedBox(
-          width: defaultMaxTextNodeWidth,
-          child: Row(
-            crossAxisAlignment: CrossAxisAlignment.start,
-            children: [
-              FlowySvg(
-                key: iconKey,
-                width: _iconWidth,
-                height: _iconWidth,
-                padding: EdgeInsets.only(right: _iconRightPadding),
-                number: widget.textNode.attributes.number,
+        child: Row(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            FlowySvg(
+              key: iconKey,
+              width: _iconWidth,
+              height: _iconWidth,
+              padding: EdgeInsets.only(right: _iconRightPadding),
+              number: widget.textNode.attributes.number,
+            ),
+            Flexible(
+              child: FlowyRichText(
+                key: _richTextKey,
+                placeholderText: 'List',
+                textNode: widget.textNode,
+                editorState: widget.editorState,
               ),
-              Expanded(
-                child: FlowyRichText(
-                  key: _richTextKey,
-                  placeholderText: 'List',
-                  textNode: widget.textNode,
-                  editorState: widget.editorState,
-                ),
-              ),
-            ],
-          ),
+            ),
+          ],
         ));
   }
 }

+ 20 - 23
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart

@@ -55,30 +55,27 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
 
   @override
   Widget build(BuildContext context) {
-    return SizedBox(
-      width: defaultMaxTextNodeWidth,
-      child: Padding(
-        padding: EdgeInsets.only(bottom: defaultLinePadding),
-        child: IntrinsicHeight(
-          child: Row(
-            crossAxisAlignment: CrossAxisAlignment.stretch,
-            children: [
-              FlowySvg(
-                key: iconKey,
-                width: _iconWidth,
-                padding: EdgeInsets.only(right: _iconRightPadding),
-                name: 'quote',
+    return Padding(
+      padding: EdgeInsets.only(bottom: defaultLinePadding),
+      child: IntrinsicHeight(
+        child: Row(
+          crossAxisAlignment: CrossAxisAlignment.stretch,
+          children: [
+            FlowySvg(
+              key: iconKey,
+              width: _iconWidth,
+              padding: EdgeInsets.only(right: _iconRightPadding),
+              name: 'quote',
+            ),
+            Flexible(
+              child: FlowyRichText(
+                key: _richTextKey,
+                placeholderText: 'Quote',
+                textNode: widget.textNode,
+                editorState: widget.editorState,
               ),
-              Expanded(
-                child: FlowyRichText(
-                  key: _richTextKey,
-                  placeholderText: 'Quote',
-                  textNode: widget.textNode,
-                  editorState: widget.editorState,
-                ),
-              ),
-            ],
-          ),
+            ),
+          ],
         ),
       ),
     );

+ 6 - 9
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart

@@ -52,15 +52,12 @@ class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
 
   @override
   Widget build(BuildContext context) {
-    return SizedBox(
-      width: defaultMaxTextNodeWidth,
-      child: Padding(
-        padding: EdgeInsets.only(bottom: defaultLinePadding),
-        child: FlowyRichText(
-          key: _richTextKey,
-          textNode: widget.textNode,
-          editorState: widget.editorState,
-        ),
+    return Padding(
+      padding: EdgeInsets.only(bottom: defaultLinePadding),
+      child: FlowyRichText(
+        key: _richTextKey,
+        textNode: widget.textNode,
+        editorState: widget.editorState,
       ),
     );
   }

+ 0 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart

@@ -61,7 +61,6 @@ class StyleKey {
 }
 
 // TODO: customize
-double defaultMaxTextNodeWidth = 780.0;
 double defaultLinePadding = 8.0;
 double baseFontSize = 16.0;
 String defaultHighlightColor = '0x6000BCF0';

+ 20 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart

@@ -0,0 +1,20 @@
+import 'package:flutter/material.dart';
+
+/// Editor style configuration
+class EditorStyle {
+  const EditorStyle({
+    required this.padding,
+  });
+
+  const EditorStyle.defaultStyle()
+      : padding = const EdgeInsets.fromLTRB(200.0, 0.0, 200.0, 0.0);
+
+  /// The margin of the document context from the editor.
+  final EdgeInsets padding;
+
+  EditorStyle copyWith({EdgeInsets? padding}) {
+    return EditorStyle(
+      padding: padding ?? this.padding,
+    );
+  }
+}

+ 42 - 11
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
 import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
@@ -132,7 +133,7 @@ List<ToolbarItem> defaultToolbarItems = [
     tooltipsMessage: 'Link',
     icon: const FlowySvg(name: 'toolbar/link'),
     validator: _onlyShowInSingleTextSelection,
-    handler: (editorState, context) => _showLinkMenu(editorState, context),
+    handler: (editorState, context) => showLinkMenu(context, editorState),
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.highlight',
@@ -157,7 +158,12 @@ ToolbarShowValidator _showInTextSelection = (editorState) {
 
 OverlayEntry? _linkMenuOverlay;
 EditorState? _editorState;
-void _showLinkMenu(EditorState editorState, BuildContext context) {
+bool _changeSelectionInner = false;
+void showLinkMenu(
+  BuildContext context,
+  EditorState editorState, {
+  Selection? customSelection,
+}) {
   final rects = editorState.service.selectionService.selectionRects;
   var maxBottom = 0.0;
   late Rect matchRect;
@@ -173,16 +179,19 @@ void _showLinkMenu(EditorState editorState, BuildContext context) {
 
   // Since the link menu will only show in single text selection,
   // We get the text node directly instead of judging details again.
-  final selection =
-      editorState.service.selectionService.currentSelection.value!;
+  final selection = customSelection ??
+      editorState.service.selectionService.currentSelection.value;
+  final node = editorState.service.selectionService.currentSelectedNodes;
+  if (selection == null || node.isEmpty || node.first is! TextNode) {
+    return;
+  }
   final index =
       selection.isBackward ? selection.start.offset : selection.end.offset;
   final length = (selection.start.offset - selection.end.offset).abs();
-  final node = editorState.service.selectionService.currentSelectedNodes.first
-      as TextNode;
+  final textNode = node.first as TextNode;
   String? linkText;
-  if (node.allSatisfyLinkInSelection(selection)) {
-    linkText = node.getAttributeInSelection(selection, StyleKey.href);
+  if (textNode.allSatisfyLinkInSelection(selection)) {
+    linkText = textNode.getAttributeInSelection(selection, StyleKey.href);
   }
   _linkMenuOverlay = OverlayEntry(builder: (context) {
     return Positioned(
@@ -191,9 +200,12 @@ void _showLinkMenu(EditorState editorState, BuildContext context) {
       child: Material(
         child: LinkMenu(
           linkText: linkText,
+          onOpenLink: () async {
+            await safeLaunchUrl(linkText);
+          },
           onSubmitted: (text) {
             TransactionBuilder(editorState)
-              ..formatText(node, index, length, {StyleKey.href: text})
+              ..formatText(textNode, index, length, {StyleKey.href: text})
               ..commit();
             _dismissLinkMenu();
           },
@@ -203,10 +215,17 @@ void _showLinkMenu(EditorState editorState, BuildContext context) {
           },
           onRemoveLink: () {
             TransactionBuilder(editorState)
-              ..formatText(node, index, length, {StyleKey.href: null})
+              ..formatText(textNode, index, length, {StyleKey.href: null})
               ..commit();
             _dismissLinkMenu();
           },
+          onFocusChange: (value) {
+            if (value && customSelection != null) {
+              _changeSelectionInner = true;
+              editorState.service.selectionService
+                  .updateSelection(customSelection);
+            }
+          },
         ),
       ),
     );
@@ -214,12 +233,24 @@ void _showLinkMenu(EditorState editorState, BuildContext context) {
   Overlay.of(context)?.insert(_linkMenuOverlay!);
 
   editorState.service.scrollService?.disable();
-  editorState.service.keyboardService?.disable();
   editorState.service.selectionService.currentSelection
       .addListener(_dismissLinkMenu);
 }
 
 void _dismissLinkMenu() {
+  // workaround: SelectionService has been released after hot reload.
+  final isSelectionDisposed =
+      _editorState?.service.selectionServiceKey.currentState == null;
+  if (isSelectionDisposed) {
+    return;
+  }
+  if (_editorState?.service.selectionService.currentSelection.value == null) {
+    return;
+  }
+  if (_changeSelectionInner) {
+    _changeSelectionInner = false;
+    return;
+  }
   _linkMenuOverlay?.remove();
   _linkMenuOverlay = null;
 

+ 29 - 18
frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart

@@ -1,5 +1,6 @@
 import 'package:appflowy_editor/src/render/image/image_node_builder.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
+import 'package:appflowy_editor/src/render/style/editor_style.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart';
 import 'package:flutter/material.dart';
 
@@ -36,6 +37,7 @@ class AppFlowyEditor extends StatefulWidget {
     this.customBuilders = const {},
     this.keyEventHandlers = const [],
     this.selectionMenuItems = const [],
+    this.editorStyle = const EditorStyle.defaultStyle(),
   }) : super(key: key);
 
   final EditorState editorState;
@@ -48,6 +50,8 @@ class AppFlowyEditor extends StatefulWidget {
 
   final List<SelectionMenuItem> selectionMenuItems;
 
+  final EditorStyle editorStyle;
+
   @override
   State<AppFlowyEditor> createState() => _AppFlowyEditorState();
 }
@@ -60,6 +64,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
     super.initState();
 
     editorState.selectionMenuItems = widget.selectionMenuItems;
+    editorState.editorStyle = widget.editorStyle;
     editorState.service.renderPluginService = _createRenderPlugin();
   }
 
@@ -68,6 +73,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
     super.didUpdateWidget(oldWidget);
 
     if (editorState.service != oldWidget.editorState.service) {
+      editorState.selectionMenuItems = widget.selectionMenuItems;
+      editorState.editorStyle = widget.editorStyle;
       editorState.service.renderPluginService = _createRenderPlugin();
     }
   }
@@ -76,27 +83,31 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
   Widget build(BuildContext context) {
     return AppFlowyScroll(
       key: editorState.service.scrollServiceKey,
-      child: AppFlowySelection(
-        key: editorState.service.selectionServiceKey,
-        editorState: editorState,
-        child: AppFlowyInput(
-          key: editorState.service.inputServiceKey,
+      child: Padding(
+        padding: widget.editorStyle.padding,
+        child: AppFlowySelection(
+          key: editorState.service.selectionServiceKey,
           editorState: editorState,
-          child: AppFlowyKeyboard(
-            key: editorState.service.keyboardServiceKey,
-            handlers: [
-              ...defaultKeyEventHandlers,
-              ...widget.keyEventHandlers,
-            ],
+          child: AppFlowyInput(
+            key: editorState.service.inputServiceKey,
             editorState: editorState,
-            child: FlowyToolbar(
-              key: editorState.service.toolbarServiceKey,
+            child: AppFlowyKeyboard(
+              key: editorState.service.keyboardServiceKey,
+              handlers: [
+                ...defaultKeyEventHandlers,
+                ...widget.keyEventHandlers,
+              ],
               editorState: editorState,
-              child: editorState.service.renderPluginService.buildPluginWidget(
-                NodeWidgetContext(
-                  context: context,
-                  node: editorState.document.root,
-                  editorState: editorState,
+              child: FlowyToolbar(
+                key: editorState.service.toolbarServiceKey,
+                editorState: editorState,
+                child:
+                    editorState.service.renderPluginService.buildPluginWidget(
+                  NodeWidgetContext(
+                    context: context,
+                    node: editorState.document.root,
+                    editorState: editorState,
+                  ),
                 ),
               ),
             ),

+ 0 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart

@@ -1,5 +1,4 @@
 import 'package:appflowy_editor/src/infra/log.dart';
-import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
@@ -150,9 +149,6 @@ class _AppFlowyInputState extends State<AppFlowyInput>
           textNode,
           delta.insertionOffset,
           delta.textInserted,
-          removedAttributes: {
-            StyleKey.href: null,
-          },
         )
         ..commit();
     } else {

+ 15 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/delete_text_handler.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart

@@ -11,10 +11,16 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
   var nodes = editorState.service.selectionService.currentSelectedNodes;
   nodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false);
   selection = selection.isBackward ? selection : selection.reversed;
-  // make sure all nodes is [TextNode].
   final textNodes = nodes.whereType<TextNode>().toList();
+  final nonTextNodes =
+      nodes.where((node) => node is! TextNode).toList(growable: false);
 
   final transactionBuilder = TransactionBuilder(editorState);
+
+  if (nonTextNodes.isNotEmpty) {
+    transactionBuilder.deleteNodes(nonTextNodes);
+  }
+
   if (textNodes.length == 1) {
     final textNode = textNodes.first;
     final index = textNode.delta.prevRunePosition(selection.start.offset);
@@ -68,10 +74,15 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
       }
     }
   } else {
-    _deleteNodes(transactionBuilder, textNodes, selection);
+    if (textNodes.isNotEmpty) {
+      _deleteTextNodes(transactionBuilder, textNodes, selection);
+    }
   }
 
   if (transactionBuilder.operations.isNotEmpty) {
+    if (nonTextNodes.isNotEmpty) {
+      transactionBuilder.afterSelection = Selection.collapsed(selection.start);
+    }
     transactionBuilder.commit();
   }
 
@@ -121,7 +132,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
       }
     }
   } else {
-    _deleteNodes(transactionBuilder, textNodes, selection);
+    _deleteTextNodes(transactionBuilder, textNodes, selection);
   }
 
   transactionBuilder.commit();
@@ -129,7 +140,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
   return KeyEventResult.handled;
 }
 
-void _deleteNodes(TransactionBuilder transactionBuilder,
+void _deleteTextNodes(TransactionBuilder transactionBuilder,
     List<TextNode> textNodes, Selection selection) {
   final first = textNodes.first;
   final last = textNodes.last;

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart

@@ -1,6 +1,6 @@
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart';
-import 'package:appflowy_editor/src/service/internal_key_event_handlers/delete_text_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspace_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart

@@ -129,6 +129,10 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
 
   void _onFocusChange(bool value) {
     Log.keyboard.debug('on keyboard event focus change $value');
+    isFocus = value;
+    if (!value) {
+      widget.editorState.service.selectionService.clearCursor();
+    }
   }
 
   KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {

+ 12 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart

@@ -57,6 +57,9 @@ abstract class AppFlowySelectionService {
   /// Clears the selection area, cursor area and the popup list area.
   void clearSelection();
 
+  /// Clears the cursor area.
+  void clearCursor();
+
   /// Returns the [Node]s in [Selection].
   List<Node> getNodesInSelection(Selection selection);
 
@@ -205,16 +208,23 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
     currentSelectedNodes = [];
     currentSelection.value = null;
 
+    clearCursor();
     // clear selection areas
     _selectionAreas
       ..forEach((overlay) => overlay.remove())
       ..clear();
     // clear cursor areas
+
+    // hide toolbar
+    editorState.service.toolbarService?.hide();
+  }
+
+  @override
+  void clearCursor() {
+    // clear cursor areas
     _cursorAreas
       ..forEach((overlay) => overlay.remove())
       ..clear();
-    // hide toolbar
-    editorState.service.toolbarService?.hide();
   }
 
   @override

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart

@@ -90,7 +90,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
         .where((item) => item.validator(widget.editorState))
         .toList(growable: false)
       ..sort((a, b) => a.type.compareTo(b.type));
-    if (items.isEmpty) {
+    if (filterItems.isEmpty) {
       return [];
     }
     final List<ToolbarItem> dividedItems = [filterItems.first];

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart

@@ -80,7 +80,7 @@ class EditorWidgetTester {
     } else {
       _editorState.service.selectionService.updateSelection(selection);
     }
-    await tester.pumpAndSettle();
+    await tester.pump(const Duration(milliseconds: 200));
 
     expect(_editorState.service.selectionService.currentSelection.value,
         selection);

+ 5 - 4
frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart

@@ -49,9 +49,10 @@ void main() async {
         final editorRect = tester.getRect(editorFinder);
 
         final leftImageRect = tester.getRect(imageFinder.at(0));
-        expect(leftImageRect.left, editorRect.left);
+        expect(leftImageRect.left, editor.editorState.editorStyle.padding.left);
         final rightImageRect = tester.getRect(imageFinder.at(2));
-        expect(rightImageRect.right, editorRect.right);
+        expect(rightImageRect.right,
+            editorRect.right - editor.editorState.editorStyle.padding.right);
         final centerImageRect = tester.getRect(imageFinder.at(1));
         expect(centerImageRect.left,
             (leftImageRect.left + rightImageRect.left) / 2.0);
@@ -73,8 +74,8 @@ void main() async {
         leftImage.onAlign(Alignment.centerRight);
         await tester.pump(const Duration(milliseconds: 100));
         expect(
-          tester.getRect(imageFinder.at(0)).left,
-          rightImageRect.left,
+          tester.getRect(imageFinder.at(0)).right,
+          rightImageRect.right,
         );
       });
     });

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

@@ -1,3 +1,6 @@
+import 'dart:collection';
+
+import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
@@ -20,6 +23,14 @@ void main() async {
 
         final widget = ImageNodeWidget(
           src: src,
+          node: Node(
+            type: 'image',
+            children: LinkedList(),
+            attributes: {
+              'image_src': src,
+              'align': 'center',
+            },
+          ),
           alignment: Alignment.center,
           onCopy: () {
             onCopyHit = true;

+ 2 - 0
frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart

@@ -12,8 +12,10 @@ void main() async {
       const link = 'appflowy.io';
       var submittedText = '';
       final linkMenu = LinkMenu(
+        onOpenLink: () {},
         onCopyLink: () {},
         onRemoveLink: () {},
+        onFocusChange: (value) {},
         onSubmitted: (text) {
           submittedText = text;
         },

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart

@@ -10,8 +10,8 @@ void main() async {
     TestWidgetsFlutterBinding.ensureInitialized();
   });
 
-  group('delete_text_handler.dart', () {
-    testWidgets('Presses backspace key in empty document', (tester) async {
+  group('checkbox_text_handler.dart', () {
+    testWidgets('Click checkbox icon', (tester) async {
       // Before
       //
       // [BIUS]Welcome to Appflowy 😁[BIUS]

+ 126 - 1
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/delete_text_handler_test.dart → frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart

@@ -1,7 +1,9 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
+import 'package:network_image_mock/network_image_mock.dart';
 import '../../infra/test_editor.dart';
 
 void main() async {
@@ -9,7 +11,7 @@ void main() async {
     TestWidgetsFlutterBinding.ensureInitialized();
   });
 
-  group('delete_text_handler.dart', () {
+  group('backspace_handler.dart', () {
     testWidgets('Presses backspace key in empty document', (tester) async {
       // Before
       //
@@ -167,6 +169,129 @@ void main() async {
   testWidgets('Presses delete key in styled text (quote)', (tester) async {
     await _deleteStyledTextByDelete(tester, StyleKey.quote);
   });
+
+  // Before
+  //
+  // Welcome to Appflowy 😁
+  // Welcome to Appflowy 😁
+  // [Image]
+  // Welcome to Appflowy 😁
+  // Welcome to Appflowy 😁
+  //
+  // After
+  //
+  // Welcome to Appflowy 😁
+  // Welcome to Appflowy 😁
+  //
+  testWidgets('Deletes the image surrounded by text', (tester) async {
+    mockNetworkImagesFor(() async {
+      const text = 'Welcome to Appflowy 😁';
+      const src = 'https://s1.ax1x.com/2022/08/26/v2sSbR.jpg';
+      final editor = tester.editor
+        ..insertTextNode(text)
+        ..insertTextNode(text)
+        ..insertImageNode(src)
+        ..insertTextNode(text)
+        ..insertTextNode(text);
+      await editor.startTesting();
+
+      expect(editor.documentLength, 5);
+      expect(find.byType(ImageNodeWidget), findsOneWidget);
+
+      await editor.updateSelection(
+        Selection(
+          start: Position(path: [1], offset: 0),
+          end: Position(path: [3], offset: text.length),
+        ),
+      );
+
+      await editor.pressLogicKey(LogicalKeyboardKey.backspace);
+      expect(editor.documentLength, 3);
+      expect(find.byType(ImageNodeWidget), findsNothing);
+      expect(
+        editor.documentSelection,
+        Selection.single(path: [1], startOffset: 0),
+      );
+    });
+  });
+
+  testWidgets('Deletes the first image, and selection is backward',
+      (tester) async {
+    await _deleteFirstImage(tester, true);
+  });
+
+  testWidgets('Deletes the first image, and selection is not backward',
+      (tester) async {
+    await _deleteFirstImage(tester, false);
+  });
+
+  testWidgets('Deletes the last image and selection is backward',
+      (tester) async {
+    await _deleteLastImage(tester, true);
+  });
+
+  testWidgets('Deletes the last image and selection is not backward',
+      (tester) async {
+    await _deleteLastImage(tester, false);
+  });
+}
+
+Future<void> _deleteFirstImage(WidgetTester tester, bool isBackward) async {
+  mockNetworkImagesFor(() async {
+    const text = 'Welcome to Appflowy 😁';
+    const src = 'https://s1.ax1x.com/2022/08/26/v2sSbR.jpg';
+    final editor = tester.editor
+      ..insertImageNode(src)
+      ..insertTextNode(text)
+      ..insertTextNode(text);
+    await editor.startTesting();
+
+    expect(editor.documentLength, 3);
+    expect(find.byType(ImageNodeWidget), findsOneWidget);
+
+    final start = Position(path: [0], offset: 0);
+    final end = Position(path: [1], offset: 1);
+    await editor.updateSelection(
+      Selection(
+        start: isBackward ? start : end,
+        end: isBackward ? end : start,
+      ),
+    );
+
+    await editor.pressLogicKey(LogicalKeyboardKey.backspace);
+    expect(editor.documentLength, 2);
+    expect(find.byType(ImageNodeWidget), findsNothing);
+    expect(editor.documentSelection, Selection.collapsed(start));
+  });
+}
+
+Future<void> _deleteLastImage(WidgetTester tester, bool isBackward) async {
+  mockNetworkImagesFor(() async {
+    const text = 'Welcome to Appflowy 😁';
+    const src = 'https://s1.ax1x.com/2022/08/26/v2sSbR.jpg';
+    final editor = tester.editor
+      ..insertTextNode(text)
+      ..insertTextNode(text)
+      ..insertImageNode(src);
+    await editor.startTesting();
+
+    expect(editor.documentLength, 3);
+    expect(find.byType(ImageNodeWidget), findsOneWidget);
+
+    final start = Position(path: [1], offset: 0);
+    final end = Position(path: [2], offset: 1);
+    await editor.updateSelection(
+      Selection(
+        start: isBackward ? start : end,
+        end: isBackward ? end : start,
+      ),
+    );
+
+    await editor.pressLogicKey(LogicalKeyboardKey.backspace);
+    expect(editor.documentLength, 2);
+    expect(find.byType(ImageNodeWidget), findsNothing);
+    expect(editor.documentSelection, Selection.collapsed(start));
+  });
 }
 
 Future<void> _deleteStyledTextByBackspace(

+ 21 - 0
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart

@@ -116,6 +116,27 @@ void main() async {
         (tester) async {
       _testMultipleSelection(tester, false);
     });
+
+    testWidgets('Presses enter key in the first line', (tester) async {
+      // Before
+      //
+      // Welcome to Appflowy 😁
+      //
+      // After
+      //
+      // [Empty Line]
+      // Welcome to Appflowy 😁
+      //
+      const text = 'Welcome to Appflowy 😁';
+      final editor = tester.editor..insertTextNode(text);
+      await editor.startTesting();
+      await editor.updateSelection(
+        Selection.single(path: [0], startOffset: 0),
+      );
+      await editor.pressLogicKey(LogicalKeyboardKey.enter);
+      expect(editor.documentLength, 2);
+      expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text);
+    });
   });
 }