فهرست منبع

Merge pull request #754 from LucasXu0/feat/flowy_editor_input_service

feat: implement bold text in toolbar service
Nathan.fooo 2 سال پیش
والد
کامیت
afd87a41d4
19فایلهای تغییر یافته به همراه349 افزوده شده و 143 حذف شده
  1. 3 3
      frontend/app_flowy/packages/flowy_editor/example/assets/example.json
  2. 10 7
      frontend/app_flowy/packages/flowy_editor/example/lib/main.dart
  3. 17 0
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart
  4. 1 1
      frontend/app_flowy/packages/flowy_editor/lib/document/attributes.dart
  5. 19 2
      frontend/app_flowy/packages/flowy_editor/lib/document/node.dart
  6. 6 1
      frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart
  7. 8 8
      frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart
  8. 1 1
      frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/checkbox_text.dart
  9. 1 1
      frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart
  10. 37 28
      frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart
  11. 42 40
      frontend/app_flowy/packages/flowy_editor/lib/render/selection/toolbar_widget.dart
  12. 128 25
      frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart
  13. 1 1
      frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart
  14. 60 12
      frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart
  15. 1 5
      frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart
  16. 5 2
      frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart
  17. 6 4
      frontend/app_flowy/packages/flowy_editor/lib/service/service.dart
  18. 1 1
      frontend/app_flowy/packages/flowy_editor/lib/service/toolbar_service.dart
  19. 2 1
      frontend/app_flowy/packages/flowy_editor/test/operation_test.dart

+ 3 - 3
frontend/app_flowy/packages/flowy_editor/example/assets/example.json

@@ -144,7 +144,7 @@
           }
         ],
         "attributes": {
-          "subtype": "bullet-list"
+          "subtype": "bulleted-list"
         }
       },
       {
@@ -155,7 +155,7 @@
           }
         ],
         "attributes": {
-          "subtype": "bullet-list"
+          "subtype": "bulleted-list"
         }
       },
       {
@@ -170,7 +170,7 @@
           }
         ],
         "attributes": {
-          "subtype": "bullet-list"
+          "subtype": "bulleted-list"
         }
       },
       {

+ 10 - 7
frontend/app_flowy/packages/flowy_editor/example/lib/main.dart

@@ -116,13 +116,16 @@ class _MyHomePageState extends State<MyHomePage> {
           _editorState = EditorState(
             document: document,
           );
-          return FlowyEditor(
-            key: editorKey,
-            editorState: _editorState,
-            keyEventHandlers: const [],
-            customBuilders: {
-              'image': ImageNodeBuilder(),
-            },
+          return Container(
+            padding: const EdgeInsets.only(left: 20, right: 20),
+            child: FlowyEditor(
+              key: editorKey,
+              editorState: _editorState,
+              keyEventHandlers: const [],
+              customBuilders: {
+                'image': ImageNodeBuilder(),
+              },
+            ),
             // shortcuts: [
             //   // TODO: this won't work, just a example for now.
             //   {

+ 17 - 0
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart

@@ -1,6 +1,23 @@
 import 'package:flowy_editor/flowy_editor.dart';
 import 'package:flutter/material.dart';
 
+/// 1. define your custom type in example.json
+///   For example I need to define an image plugin, then I define type equals
+///   "image", and add "image_src" into "attributes".
+///   {
+///     "type": "image",
+///     "attributes", { "image_src": "https://s1.ax1x.com/2022/07/28/vCgz1x.png" }
+///   }
+/// 2. create a class extends [NodeWidgetBuilder]
+/// 3. override the function `Widget build(NodeWidgetContext<Node> context)`
+///     and return a widget to render. The returned widget should be
+///     a StatefulWidget and mixin with [Selectable].
+///
+/// 4. override the getter `nodeValidator`
+///     to verify the data structure in [Node].
+/// 5. register the plugin with `type` to `flowy_editor` in `main.dart`.
+/// 6. Congratulations!
+
 class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
   @override
   Widget build(NodeWidgetContext<Node> context) {

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/document/attributes.dart

@@ -26,7 +26,7 @@ Attributes? composeAttributes(Attributes? a, Attributes? b) {
   a ??= {};
   b ??= {};
   final Attributes attributes = {};
-  attributes.addAll(b);
+  attributes.addAll(Map.from(b)..removeWhere((_, value) => value == null));
 
   for (final entry in a.entries) {
     if (!b.containsKey(entry.key)) {

+ 19 - 2
frontend/app_flowy/packages/flowy_editor/lib/document/node.dart

@@ -89,7 +89,11 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
         this.attributes['subtype'] != attributes['subtype'];
 
     for (final attribute in attributes.entries) {
-      this.attributes[attribute.key] = attribute.value;
+      if (attribute.value == null) {
+        this.attributes.remove(attribute.key);
+      } else {
+        this.attributes[attribute.key] = attribute.value;
+      }
     }
     // Notify the new attributes
     // if attributes contains 'subtype', should notify parent to rebuild node
@@ -178,7 +182,7 @@ class TextNode extends Node {
   }) : _delta = delta;
 
   TextNode.empty()
-      : _delta = Delta([TextInsert('')]),
+      : _delta = Delta([TextInsert(' ')]),
         super(
           type: 'text',
           children: LinkedList(),
@@ -201,6 +205,19 @@ class TextNode extends Node {
     return map;
   }
 
+  TextNode copyWith({
+    String? type,
+    LinkedList<Node>? children,
+    Attributes? attributes,
+    Delta? delta,
+  }) =>
+      TextNode(
+        type: type ?? this.type,
+        children: children ?? this.children,
+        attributes: attributes ?? this.attributes,
+        delta: delta ?? this.delta,
+      );
+
   // TODO: It's unneccesry to compute everytime.
   String toRawString() =>
       _delta.operations.whereType<TextInsert>().map((op) => op.content).join();

+ 6 - 1
frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart

@@ -60,7 +60,12 @@ class EditorState {
     for (final op in transaction.operations) {
       _applyOperation(op);
     }
-    updateCursorSelection(transaction.afterSelection);
+    // updateCursorSelection(transaction.afterSelection);
+
+    // FIXME: don't use delay
+    Future.delayed(const Duration(milliseconds: 16), () {
+      updateCursorSelection(transaction.afterSelection);
+    });
 
     if (options.recordUndo) {
       final undoItem = undoManager.getUndoHistoryItem();

+ 8 - 8
frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart

@@ -1,18 +1,18 @@
 import 'dart:collection';
-import 'package:flowy_editor/editor_state.dart';
+
+import 'package:flowy_editor/document/attributes.dart';
 import 'package:flowy_editor/document/node.dart';
 import 'package:flowy_editor/document/path.dart';
 import 'package:flowy_editor/document/position.dart';
-import 'package:flowy_editor/document/text_delta.dart';
-import 'package:flowy_editor/document/attributes.dart';
 import 'package:flowy_editor/document/selection.dart';
-
-import './operation.dart';
-import './transaction.dart';
+import 'package:flowy_editor/document/text_delta.dart';
+import 'package:flowy_editor/editor_state.dart';
+import 'package:flowy_editor/operation/operation.dart';
+import 'package:flowy_editor/operation/transaction.dart';
 
 /// A [TransactionBuilder] is used to build the transaction from the state.
 /// It will save make a snapshot of the cursor selection state automatically.
-/// The cursor can be resoted if the transaction is undo.
+/// The cursor can be resorted if the transaction is undo.
 
 class TransactionBuilder {
   final List<Operation> operations = [];
@@ -70,7 +70,7 @@ class TransactionBuilder {
   }
 
   textEdit(TextNode node, Delta Function() f) {
-    beforeSelection = state.service.selectionService.currentSelection;
+    beforeSelection = state.cursorSelection;
     final path = node.path;
 
     final delta = f();

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/checkbox_text.dart

@@ -21,7 +21,7 @@ class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
 
   @override
   NodeValidator<Node> get nodeValidator => ((node) {
-        return node.attributes.containsKey(StyleKey.check);
+        return node.attributes.containsKey(StyleKey.checkbox);
       });
 }
 

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart

@@ -74,7 +74,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
         _renderParagraph.getOffsetForCaret(textPosition, Rect.zero);
     final cursorHeight = widget.cursorHeight ??
         _renderParagraph.getFullHeightForCaret(textPosition) ??
-        5.0; // default height
+        18.0; // default height
     return Rect.fromLTWH(
       cursorOffset.dx - (widget.cursorWidth / 2),
       cursorOffset.dy,

+ 37 - 28
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart

@@ -25,26 +25,49 @@ class StyleKey {
   static String font = 'font';
   static String href = 'href';
 
+  static String subtype = 'subtype';
+  static String heading = 'heading';
+  static String h1 = 'h1';
+  static String h2 = 'h2';
+  static String h3 = 'h3';
+  static String h4 = 'h4';
+  static String h5 = 'h5';
+  static String h6 = 'h6';
+
+  static String bulletedList = 'bulleted-list';
+  static String numberList = 'number-list';
+
   static String quote = 'quote';
-  static String list = 'list';
-  static String number = 'number';
-  static String todo = 'todo';
+  static String checkbox = 'checkbox';
   static String code = 'code';
+  static String number = 'number';
 
-  static String subtype = 'subtype';
-  static String check = 'checkbox';
-  static String heading = 'heading';
+  static List<String> partialStyleKeys = [
+    StyleKey.bold,
+    StyleKey.italic,
+    StyleKey.underline,
+    StyleKey.strikethrough,
+  ];
+
+  static List<String> globalStyleKeys = [
+    StyleKey.heading,
+    StyleKey.checkbox,
+    StyleKey.bulletedList,
+    StyleKey.numberList,
+    StyleKey.quote,
+    StyleKey.code,
+  ];
 }
 
 double baseFontSize = 16.0;
 // TODO: customize.
 Map<String, double> headingToFontSize = {
-  'h1': baseFontSize + 15,
-  'h2': baseFontSize + 12,
-  'h3': baseFontSize + 9,
-  'h4': baseFontSize + 6,
-  'h5': baseFontSize + 3,
-  'h6': baseFontSize,
+  StyleKey.h1: baseFontSize + 15,
+  StyleKey.h2: baseFontSize + 12,
+  StyleKey.h3: baseFontSize + 9,
+  StyleKey.h4: baseFontSize + 6,
+  StyleKey.h5: baseFontSize + 3,
+  StyleKey.h6: baseFontSize,
 };
 
 extension NodeAttributesExtensions on Attributes {
@@ -73,13 +96,6 @@ extension NodeAttributesExtensions on Attributes {
     return null;
   }
 
-  String? get list {
-    if (containsKey(StyleKey.list) && this[StyleKey.list] is String) {
-      return this[StyleKey.list];
-    }
-    return null;
-  }
-
   int? get number {
     if (containsKey(StyleKey.number) && this[StyleKey.number] is int) {
       return this[StyleKey.number];
@@ -87,13 +103,6 @@ extension NodeAttributesExtensions on Attributes {
     return null;
   }
 
-  bool get todo {
-    if (containsKey(StyleKey.todo) && this[StyleKey.todo] is bool) {
-      return this[StyleKey.todo];
-    }
-    return false;
-  }
-
   bool get code {
     if (containsKey(StyleKey.code) && this[StyleKey.code] == true) {
       return this[StyleKey.code];
@@ -102,8 +111,8 @@ extension NodeAttributesExtensions on Attributes {
   }
 
   bool get check {
-    if (containsKey(StyleKey.check) && this[StyleKey.check] is bool) {
-      return this[StyleKey.check];
+    if (containsKey(StyleKey.checkbox) && this[StyleKey.checkbox] is bool) {
+      return this[StyleKey.checkbox];
     }
     return false;
   }

+ 42 - 40
frontend/app_flowy/packages/flowy_editor/lib/render/selection/toolbar_widget.dart

@@ -1,43 +1,39 @@
-import 'package:flowy_editor/editor_state.dart';
-import 'package:flowy_editor/infra/flowy_svg.dart';
+import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
 import 'package:flutter/material.dart';
 
-typedef ToolbarEventHandler = void Function(
-    EditorState editorState, String eventName);
-
-typedef ToolbarEventHandlers = List<Map<String, ToolbarEventHandler>>;
-ToolbarEventHandlers defaultToolbarEventHandlers = [
-  {
-    'bold': ((editorState, eventName) {}),
-    'italic': ((editorState, eventName) {}),
-    'strikethrough': ((editorState, eventName) {}),
-    'underline': ((editorState, eventName) {}),
-    'quote': ((editorState, eventName) {}),
-    'number_list': ((editorState, eventName) {}),
-    'bulleted_list': ((editorState, eventName) {}),
-  }
-];
-
-ToolbarEventHandlers defaultListToolbarEventHandlers = [
-  {
-    'h1': ((editorState, eventName) {}),
-  },
-  {
-    'h2': ((editorState, eventName) {}),
-  },
-  {
-    'h3': ((editorState, eventName) {}),
-  },
-  {
-    'bulleted_list': ((editorState, eventName) {}),
-  },
-  {
-    'quote': ((editorState, eventName) {}),
-  }
+import 'package:flowy_editor/editor_state.dart';
+import 'package:flowy_editor/infra/flowy_svg.dart';
+import 'package:flowy_editor/service/default_text_operations/format_rich_text_style.dart';
+
+typedef ToolbarEventHandler = void Function(EditorState editorState);
+
+typedef ToolbarEventHandlers = Map<String, ToolbarEventHandler>;
+
+ToolbarEventHandlers defaultToolbarEventHandlers = {
+  'bold': (editorState) => formatBold(editorState),
+  'italic': (editorState) => formatItalic(editorState),
+  'strikethrough': (editorState) => formatStrikethrough(editorState),
+  'underline': (editorState) => formatUnderline(editorState),
+  'quote': (editorState) => formatQuote(editorState),
+  'number_list': (editorState) {},
+  'bulleted_list': (editorState) => formatBulletedList(editorState),
+  'Text': (editorState) => formatText(editorState),
+  'H1': (editorState) => formatHeading(editorState, StyleKey.h1),
+  'H2': (editorState) => formatHeading(editorState, StyleKey.h2),
+  'H3': (editorState) => formatHeading(editorState, StyleKey.h3),
+};
+
+List<String> defaultListToolbarEventNames = [
+  'Text',
+  'H1',
+  'H2',
+  'H3',
+  // 'B-List',
+  // 'N-List',
 ];
 
 class ToolbarWidget extends StatefulWidget {
-  ToolbarWidget({
+  const ToolbarWidget({
     Key? key,
     required this.editorState,
     required this.layerLink,
@@ -137,7 +133,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> {
       preferBelow: false,
       message: name,
       child: GestureDetector(
-        onTap: onTap ?? () => debugPrint('toolbar tap $name'),
+        onTap: onTap ?? () => _onTap(name),
         child: SizedBox.fromSize(
           size: width != null
               ? Size(width, toolbarHeight)
@@ -154,9 +150,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> {
 
   void _onTapListToolbar(BuildContext context) {
     // TODO: implement more detailed UI.
-    final items = defaultListToolbarEventHandlers
-        .map((handler) => handler.keys.first)
-        .toList(growable: false);
+    final items = defaultListToolbarEventNames;
     final renderBox =
         _listToolbarKey.currentContext?.findRenderObject() as RenderBox;
     final offset = renderBox
@@ -198,7 +192,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> {
                     ),
                   ),
                   onTap: () {
-                    debugPrint('tap on $index');
+                    _onTap(items[index]);
                   },
                 );
               }),
@@ -210,6 +204,14 @@ class _ToolbarWidgetState extends State<ToolbarWidget> {
     Overlay.of(context)?.insert(_listToolbarOverlay!);
   }
 
+  void _onTap(String eventName) {
+    if (defaultToolbarEventHandlers.containsKey(eventName)) {
+      defaultToolbarEventHandlers[eventName]!(widget.editorState);
+      return;
+    }
+    assert(false, 'Could not find the event handler for $eventName');
+  }
+
   void _onSelectionChange() {
     _listToolbarOverlay?.remove();
     _listToolbarOverlay = null;

+ 128 - 25
frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart

@@ -1,9 +1,103 @@
+import 'package:flowy_editor/document/attributes.dart';
 import 'package:flowy_editor/document/node.dart';
 import 'package:flowy_editor/editor_state.dart';
+import 'package:flowy_editor/extensions/text_node_extensions.dart';
 import 'package:flowy_editor/operation/transaction_builder.dart';
+import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
 
-bool formatRichTextStyle(
-    EditorState editorState, Map<String, dynamic> attributes) {
+void formatText(EditorState editorState) {
+  formatTextNodes(editorState, {});
+}
+
+void formatHeading(EditorState editorState, String heading) {
+  formatTextNodes(editorState, {
+    StyleKey.subtype: StyleKey.heading,
+    StyleKey.heading: heading,
+  });
+}
+
+void formatQuote(EditorState editorState) {
+  formatTextNodes(editorState, {
+    StyleKey.subtype: StyleKey.quote,
+  });
+}
+
+void formatCheckbox(EditorState editorState) {
+  formatTextNodes(editorState, {
+    StyleKey.subtype: StyleKey.checkbox,
+    StyleKey.checkbox: false,
+  });
+}
+
+void formatBulletedList(EditorState editorState) {
+  formatTextNodes(editorState, {
+    StyleKey.subtype: StyleKey.bulletedList,
+  });
+}
+
+bool formatTextNodes(EditorState editorState, Attributes attributes) {
+  final nodes = editorState.service.selectionService.currentSelectedNodes.value;
+  final textNodes = nodes.whereType<TextNode>().toList();
+
+  if (textNodes.isEmpty) {
+    return false;
+  }
+
+  final builder = TransactionBuilder(editorState);
+
+  for (final textNode in textNodes) {
+    builder.updateNode(
+      textNode,
+      Attributes.fromIterable(
+        StyleKey.globalStyleKeys,
+        value: (_) => null,
+      )..addAll(attributes),
+    );
+  }
+
+  builder.commit();
+  return true;
+}
+
+bool formatBold(EditorState editorState) {
+  return formatRichTextPartialStyle(editorState, StyleKey.bold);
+}
+
+bool formatItalic(EditorState editorState) {
+  return formatRichTextPartialStyle(editorState, StyleKey.italic);
+}
+
+bool formatUnderline(EditorState editorState) {
+  return formatRichTextPartialStyle(editorState, StyleKey.underline);
+}
+
+bool formatStrikethrough(EditorState editorState) {
+  return formatRichTextPartialStyle(editorState, StyleKey.strikethrough);
+}
+
+bool formatRichTextPartialStyle(EditorState editorState, String styleKey) {
+  final selection = editorState.service.selectionService.currentSelection;
+  final nodes = editorState.service.selectionService.currentSelectedNodes.value;
+  final textNodes = nodes.whereType<TextNode>().toList(growable: false);
+
+  if (selection == null || textNodes.isEmpty) {
+    return false;
+  }
+
+  bool value = !textNodes.allSatisfyInSelection(styleKey, selection);
+  Attributes attributes = {
+    styleKey: value,
+  };
+  if (styleKey == StyleKey.underline && value) {
+    attributes[StyleKey.strikethrough] = null;
+  } else if (styleKey == StyleKey.strikethrough && value) {
+    attributes[StyleKey.underline] = null;
+  }
+
+  return formatRichTextStyle(editorState, attributes);
+}
+
+bool formatRichTextStyle(EditorState editorState, Attributes attributes) {
   final selection = editorState.service.selectionService.currentSelection;
   final nodes = editorState.service.selectionService.currentSelectedNodes.value;
   final textNodes = nodes.whereType<TextNode>().toList();
@@ -17,29 +111,38 @@ bool formatRichTextStyle(
   // 1. All nodes are text nodes.
   // 2. The first node is not TextNode.
   // 3. The last node is not TextNode.
-  for (var i = 0; i < textNodes.length; i++) {
-    final textNode = textNodes[i];
-    if (i == 0 && textNode == nodes.first) {
-      builder.formatText(
-        textNode,
-        selection.start.offset,
-        textNode.toRawString().length - selection.start.offset,
-        attributes,
-      );
-    } else if (i == textNodes.length - 1 && textNode == nodes.last) {
-      builder.formatText(
-        textNode,
-        0,
-        selection.end.offset,
-        attributes,
-      );
-    } else {
-      builder.formatText(
-        textNode,
-        0,
-        textNode.toRawString().length,
-        attributes,
-      );
+  if (nodes.length == textNodes.length && textNodes.length == 1) {
+    builder.formatText(
+      textNodes.first,
+      selection.start.offset,
+      selection.end.offset - selection.start.offset,
+      attributes,
+    );
+  } else {
+    for (var i = 0; i < textNodes.length; i++) {
+      final textNode = textNodes[i];
+      if (i == 0 && textNode == nodes.first) {
+        builder.formatText(
+          textNode,
+          selection.start.offset,
+          textNode.toRawString().length - selection.start.offset,
+          attributes,
+        );
+      } else if (i == textNodes.length - 1 && textNode == nodes.last) {
+        builder.formatText(
+          textNode,
+          0,
+          selection.end.offset,
+          attributes,
+        );
+      } else {
+        builder.formatText(
+          textNode,
+          0,
+          textNode.toRawString().length,
+          attributes,
+        );
+      }
     }
   }
 

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart

@@ -25,7 +25,7 @@ NodeWidgetBuilders defaultBuilders = {
   'text': RichTextNodeWidgetBuilder(),
   'text/checkbox': CheckboxNodeWidgetBuilder(),
   'text/heading': HeadingTextNodeWidgetBuilder(),
-  'text/bullet-list': BulletedListTextNodeWidgetBuilder(),
+  'text/bulleted-list': BulletedListTextNodeWidgetBuilder(),
   'text/number-list': NumberListTextNodeWidgetBuilder(),
   'text/quote': QuotedTextNodeWidgetBuilder(),
 };

+ 60 - 12
frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart

@@ -1,12 +1,17 @@
+import 'dart:collection';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
 import 'package:flowy_editor/document/node.dart';
 import 'package:flowy_editor/document/position.dart';
 import 'package:flowy_editor/document/selection.dart';
+import 'package:flowy_editor/document/text_delta.dart';
+import 'package:flowy_editor/extensions/node_extensions.dart';
+import 'package:flowy_editor/extensions/path_extensions.dart';
 import 'package:flowy_editor/operation/transaction_builder.dart';
+import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
 import 'package:flowy_editor/service/keyboard_service.dart';
-import 'package:flowy_editor/extensions/path_extensions.dart';
-import 'package:flowy_editor/extensions/node_extensions.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
 
 FlowyKeyEventHandler enterInEdgeOfTextNodeHandler = (editorState, event) {
   if (event.logicalKey != LogicalKeyboardKey.enter) {
@@ -23,20 +28,63 @@ FlowyKeyEventHandler enterInEdgeOfTextNodeHandler = (editorState, event) {
   }
 
   final textNode = nodes.first as TextNode;
-
   if (textNode.selectable!.end() == selection.end) {
-    TransactionBuilder(editorState)
-      ..insertNode(
-        textNode.path.next,
-        TextNode.empty(),
-      )
-      ..commit();
+    if (textNode.subtype != null && textNode.delta.length == 0) {
+      TransactionBuilder(editorState)
+        ..deleteNode(textNode)
+        ..insertNode(
+          textNode.path,
+          textNode.copyWith(
+            children: LinkedList(),
+            delta: Delta([TextInsert('')]),
+            attributes: {},
+          ),
+        )
+        ..afterSelection = Selection.collapsed(
+          Position(
+            path: textNode.path,
+            offset: 0,
+          ),
+        )
+        ..commit();
+    } else {
+      final needCopyAttributes = StyleKey.globalStyleKeys
+          .where((key) => key != StyleKey.heading)
+          .contains(textNode.subtype);
+      TransactionBuilder(editorState)
+        ..insertNode(
+          textNode.path.next,
+          textNode.copyWith(
+            children: LinkedList(),
+            delta: Delta([TextInsert('')]),
+            attributes: needCopyAttributes ? textNode.attributes : {},
+          ),
+        )
+        ..afterSelection = Selection.collapsed(
+          Position(
+            path: textNode.path.next,
+            offset: 0,
+          ),
+        )
+        ..commit();
+    }
+
     return KeyEventResult.handled;
   } else if (textNode.selectable!.start() == selection.start) {
     TransactionBuilder(editorState)
       ..insertNode(
         textNode.path,
-        TextNode.empty(),
+        textNode.copyWith(
+          children: LinkedList(),
+          delta: Delta([TextInsert('')]),
+          attributes: {},
+        ),
+      )
+      ..afterSelection = Selection.collapsed(
+        Position(
+          path: textNode.path.next,
+          offset: 0,
+        ),
       )
       ..commit();
     return KeyEventResult.handled;

+ 1 - 5
frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart

@@ -1,8 +1,6 @@
 import 'package:flutter/material.dart';
 
 import 'package:flowy_editor/document/node.dart';
-import 'package:flowy_editor/extensions/text_node_extensions.dart';
-import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
 import 'package:flowy_editor/service/default_text_operations/format_rich_text_style.dart';
 import 'package:flowy_editor/service/keyboard_service.dart';
 
@@ -23,9 +21,7 @@ FlowyKeyEventHandler updateTextStyleByCommandXHandler = (editorState, event) {
     // bold
     case 'B':
     case 'b':
-      formatRichTextStyle(editorState, {
-        StyleKey.bold: !textNodes.allSatisfyBoldInSelection(selection),
-      });
+      formatBold(editorState);
       return KeyEventResult.handled;
     default:
       break;

+ 5 - 2
frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart

@@ -233,6 +233,9 @@ class _FlowySelectionState extends State<FlowySelection>
 
   @override
   void dispose() {
+    clearSelection();
+    WidgetsBinding.instance.removeObserver(this);
+
     super.dispose();
   }
 
@@ -455,7 +458,7 @@ class _FlowySelectionState extends State<FlowySelection>
       ..forEach((overlay) => overlay.remove())
       ..clear();
     // clear toolbar
-    editorState.service.toolbarService.hide();
+    editorState.service.toolbarService?.hide();
   }
 
   void _updateSelection(Selection selection) {
@@ -526,7 +529,7 @@ class _FlowySelectionState extends State<FlowySelection>
 
     if (topmostRect != null && layerLink != null) {
       editorState.service.toolbarService
-          .showInOffset(topmostRect.topLeft, layerLink);
+          ?.showInOffset(topmostRect.topLeft, layerLink);
     }
   }
 

+ 6 - 4
frontend/app_flowy/packages/flowy_editor/lib/service/service.dart

@@ -23,9 +23,11 @@ class FlowyService {
 
   // toolbar service
   final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service');
-  ToolbarService get toolbarService {
-    assert(toolbarServiceKey.currentState != null &&
-        toolbarServiceKey.currentState is ToolbarService);
-    return toolbarServiceKey.currentState! as ToolbarService;
+  ToolbarService? get toolbarService {
+    if (toolbarServiceKey.currentState != null &&
+        toolbarServiceKey.currentState is ToolbarService) {
+      return toolbarServiceKey.currentState! as ToolbarService;
+    }
+    return null;
   }
 }

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

@@ -35,7 +35,7 @@ class _FlowyToolbarState extends State<FlowyToolbar> with ToolbarService {
         editorState: widget.editorState,
         layerLink: layerLink,
         offset: offset.translate(0, -37.0),
-        handlers: const [],
+        handlers: const {},
       ),
     );
     Overlay.of(context)?.insert(_toolbarOverlay!);

+ 2 - 1
frontend/app_flowy/packages/flowy_editor/test/operation_test.dart

@@ -8,6 +8,7 @@ import 'package:flowy_editor/editor_state.dart';
 import 'package:flowy_editor/document/state_tree.dart';
 
 void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
   group('transform path', () {
     test('transform path changed', () {
       expect(transformPath([0, 1], [0, 1]), [0, 2]);
@@ -87,7 +88,7 @@ void main() {
             "path": [0],
             "nodes": [item1.toJson()],
           }
-        ],
+        ]
       });
     });
     test("delete", () {