Browse Source

feat: implement rich text component in flowy_ediotr and support markdown style rendering.

Lucas.Xu 2 years ago
parent
commit
45a8566e61
18 changed files with 853 additions and 455 deletions
  1. 19 2
      frontend/app_flowy/packages/flowy_editor/example/assets/document.json
  2. 1 2
      frontend/app_flowy/packages/flowy_editor/example/lib/main.dart
  3. 1 1
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart
  4. 1 1
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart
  5. 352 0
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/old_text_node_widget.dart
  6. 10 9
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart
  7. 0 352
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart
  8. 2 2
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart
  9. 2 2
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart
  10. 4 0
      frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart
  11. 2 0
      frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart
  12. 4 4
      frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart
  13. 215 0
      frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart
  14. 182 0
      frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart
  15. 2 9
      frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart
  16. 53 53
      frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart
  17. 0 18
      frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart
  18. 3 0
      frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart

+ 19 - 2
frontend/app_flowy/packages/flowy_editor/example/assets/document.json

@@ -37,7 +37,22 @@
         "type": "text",
         "delta": [{ "insert": "Click anywhere and just start typing." }],
         "attributes": {
-          "checkbox": true
+          "list": "todo",
+          "todo": false
+        }
+      },
+      {
+        "type": "text",
+        "delta": [{ "insert": "Click anywhere and just start typing." }],
+        "attributes": {
+          "list": "bullet"
+        }
+      },
+      {
+        "type": "text",
+        "delta": [{ "insert": "Click anywhere and just start typing." }],
+        "attributes": {
+          "list": "bullet"
         }
       },
       {
@@ -77,7 +92,9 @@
             "insert": "1. Click the '?' at the bottom right for help and support."
           }
         ],
-        "attributes": {}
+        "attributes": {
+          "quotes": true
+        }
       },
       {
         "type": "text",

+ 1 - 2
frontend/app_flowy/packages/flowy_editor/example/lib/main.dart

@@ -5,7 +5,7 @@ import 'package:example/plugin/document_node_widget.dart';
 import 'package:example/plugin/selected_text_node_widget.dart';
 import 'package:example/plugin/text_with_heading_node_widget.dart';
 import 'package:example/plugin/image_node_widget.dart';
-import 'package:example/plugin/text_node_widget.dart';
+import 'package:example/plugin/old_text_node_widget.dart';
 import 'package:example/plugin/text_with_check_box_node_widget.dart';
 import 'package:flutter/material.dart';
 import 'package:flowy_editor/flowy_editor.dart';
@@ -68,7 +68,6 @@ class _MyHomePageState extends State<MyHomePage> {
 
     renderPlugins
       ..register('editor', EditorNodeWidgetBuilder.create)
-      ..register('text', SelectedTextNodeBuilder.create)
       ..register('image', ImageNodeBuilder.create)
       ..register('text/with-checkbox', TextWithCheckBoxNodeBuilder.create)
       ..register('text/with-heading', TextWithHeadingNodeBuilder.create);

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart

@@ -9,7 +9,7 @@ class EditorNodeWidgetBuilder extends NodeWidgetBuilder {
   }) : super.create();
 
   @override
-  Widget build(BuildContext buildContext) {
+  Widget build(BuildContext context) {
     return SingleChildScrollView(
       key: key,
       child: _EditorNodeWidget(

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

@@ -11,7 +11,7 @@ class ImageNodeBuilder extends NodeWidgetBuilder {
   }) : super.create();
 
   @override
-  Widget build(BuildContext buildContext) {
+  Widget build(BuildContext context) {
     return _ImageNodeWidget(
       key: key,
       node: node,

+ 352 - 0
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/old_text_node_widget.dart

@@ -0,0 +1,352 @@
+// import 'package:flowy_editor/document/position.dart';
+// import 'package:flowy_editor/document/selection.dart';
+// import 'package:flutter/gestures.dart';
+// import 'package:flutter/material.dart';
+// import 'package:flowy_editor/flowy_editor.dart';
+// import 'package:flutter/services.dart';
+// import 'package:url_launcher/url_launcher_string.dart';
+// import 'flowy_selectable_text.dart';
+
+// class TextNodeBuilder extends NodeWidgetBuilder {
+//   TextNodeBuilder.create({
+//     required super.node,
+//     required super.editorState,
+//     required super.key,
+//   }) : super.create() {
+//     nodeValidator = ((node) {
+//       return node.type == 'text';
+//     });
+//   }
+
+//   @override
+//   Widget build(BuildContext context) {
+//     return _TextNodeWidget(key: key, node: node, editorState: editorState);
+//   }
+// }
+
+// class _TextNodeWidget extends StatefulWidget {
+//   final Node node;
+//   final EditorState editorState;
+
+//   const _TextNodeWidget({
+//     Key? key,
+//     required this.node,
+//     required this.editorState,
+//   }) : super(key: key);
+
+//   @override
+//   State<_TextNodeWidget> createState() => __TextNodeWidgetState();
+// }
+
+// class __TextNodeWidgetState extends State<_TextNodeWidget>
+//     implements DeltaTextInputClient {
+//   TextNode get node => widget.node as TextNode;
+//   EditorState get editorState => widget.editorState;
+//   bool _metaKeyDown = false;
+//   bool _shiftKeyDown = false;
+
+//   TextInputConnection? _textInputConnection;
+
+//   @override
+//   Widget build(BuildContext context) {
+//     return Column(
+//       crossAxisAlignment: CrossAxisAlignment.start,
+//       children: [
+//         FlowySelectableText.rich(
+//           node.toTextSpan(),
+//           showCursor: true,
+//           enableInteractiveSelection: true,
+//           onSelectionChanged: _onSelectionChanged,
+//           // autofocus: true,
+//           focusNode: FocusNode(
+//             onKey: _onKey,
+//           ),
+//         ),
+//         if (node.children.isNotEmpty)
+//           ...node.children.map(
+//             (e) => editorState.renderPlugins.buildWidget(
+//               context: NodeWidgetContext(
+//                 buildContext: context,
+//                 node: e,
+//                 editorState: editorState,
+//               ),
+//             ),
+//           ),
+//         const SizedBox(
+//           height: 10,
+//         ),
+//       ],
+//     );
+//   }
+
+//   KeyEventResult _onKey(FocusNode focusNode, RawKeyEvent event) {
+//     debugPrint('key: $event');
+//     if (event is RawKeyDownEvent) {
+//       final sel = _globalSelectionToLocal(node, editorState.cursorSelection);
+//       if (event.logicalKey == LogicalKeyboardKey.backspace) {
+//         _backDeleteTextAtSelection(sel);
+//         return KeyEventResult.handled;
+//       } else if (event.logicalKey == LogicalKeyboardKey.delete) {
+//         _forwardDeleteTextAtSelection(sel);
+//         return KeyEventResult.handled;
+//       } else if (event.logicalKey == LogicalKeyboardKey.metaLeft ||
+//           event.logicalKey == LogicalKeyboardKey.metaRight) {
+//         _metaKeyDown = true;
+//       } else if (event.logicalKey == LogicalKeyboardKey.shiftLeft ||
+//           event.logicalKey == LogicalKeyboardKey.shiftRight) {
+//         _shiftKeyDown = true;
+//       } else if (event.logicalKey == LogicalKeyboardKey.keyZ && _metaKeyDown) {
+//         if (_shiftKeyDown) {
+//           editorState.undoManager.redo();
+//         } else {
+//           editorState.undoManager.undo();
+//         }
+//       }
+//     } else if (event is RawKeyUpEvent) {
+//       if (event.logicalKey == LogicalKeyboardKey.metaLeft ||
+//           event.logicalKey == LogicalKeyboardKey.metaRight) {
+//         _metaKeyDown = false;
+//       }
+//       if (event.logicalKey == LogicalKeyboardKey.shiftLeft ||
+//           event.logicalKey == LogicalKeyboardKey.shiftRight) {
+//         _shiftKeyDown = false;
+//       }
+//     }
+//     return KeyEventResult.ignored;
+//   }
+
+//   void _onSelectionChanged(
+//       TextSelection selection, SelectionChangedCause? cause) {
+//     _textInputConnection?.close();
+//     _textInputConnection = TextInput.attach(
+//       this,
+//       const TextInputConfiguration(
+//         enableDeltaModel: true,
+//         inputType: TextInputType.multiline,
+//         textCapitalization: TextCapitalization.sentences,
+//       ),
+//     );
+//     editorState.cursorSelection = _localSelectionToGlobal(node, selection);
+//     _textInputConnection
+//       ?..show()
+//       ..setEditingState(
+//         TextEditingValue(
+//           text: node.toRawString(),
+//           selection: selection,
+//         ),
+//       );
+//   }
+
+//   _backDeleteTextAtSelection(TextSelection? sel) {
+//     if (sel == null) {
+//       return;
+//     }
+//     if (sel.start == 0) {
+//       return;
+//     }
+
+//     if (sel.isCollapsed) {
+//       TransactionBuilder(editorState)
+//         ..deleteText(node, sel.start - 1, 1)
+//         ..commit();
+//     } else {
+//       TransactionBuilder(editorState)
+//         ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset)
+//         ..commit();
+//     }
+
+//     _setEditingStateFromGlobal();
+//   }
+
+//   _forwardDeleteTextAtSelection(TextSelection? sel) {
+//     if (sel == null) {
+//       return;
+//     }
+
+//     if (sel.isCollapsed) {
+//       TransactionBuilder(editorState)
+//         ..deleteText(node, sel.start, 1)
+//         ..commit();
+//     } else {
+//       TransactionBuilder(editorState)
+//         ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset)
+//         ..commit();
+//     }
+//     _setEditingStateFromGlobal();
+//   }
+
+//   _setEditingStateFromGlobal() {
+//     _textInputConnection?.setEditingState(TextEditingValue(
+//         text: node.toRawString(),
+//         selection: _globalSelectionToLocal(node, editorState.cursorSelection) ??
+//             const TextSelection.collapsed(offset: 0)));
+//   }
+
+//   @override
+//   void connectionClosed() {
+//     // TODO: implement connectionClosed
+//   }
+
+//   @override
+//   // TODO: implement currentAutofillScope
+//   AutofillScope? get currentAutofillScope => throw UnimplementedError();
+
+//   @override
+//   // TODO: implement currentTextEditingValue
+//   TextEditingValue? get currentTextEditingValue => TextEditingValue(
+//       text: node.toRawString(),
+//       selection: _globalSelectionToLocal(node, editorState.cursorSelection) ??
+//           const TextSelection.collapsed(offset: 0));
+
+//   @override
+//   void insertTextPlaceholder(Size size) {
+//     // TODO: implement insertTextPlaceholder
+//   }
+
+//   @override
+//   void performAction(TextInputAction action) {}
+
+//   @override
+//   void performPrivateCommand(String action, Map<String, dynamic> data) {
+//     // TODO: implement performPrivateCommand
+//   }
+
+//   @override
+//   void removeTextPlaceholder() {
+//     // TODO: implement removeTextPlaceholder
+//   }
+
+//   @override
+//   void showAutocorrectionPromptRect(int start, int end) {
+//     // TODO: implement showAutocorrectionPromptRect
+//   }
+
+//   @override
+//   void showToolbar() {
+//     // TODO: implement showToolbar
+//   }
+
+//   @override
+//   void updateEditingValue(TextEditingValue value) {}
+
+//   @override
+//   void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
+//     for (final textDelta in textEditingDeltas) {
+//       if (textDelta is TextEditingDeltaInsertion) {
+//         TransactionBuilder(editorState)
+//           ..insertText(node, textDelta.insertionOffset, textDelta.textInserted)
+//           ..commit();
+//       } else if (textDelta is TextEditingDeltaDeletion) {
+//         TransactionBuilder(editorState)
+//           ..deleteText(node, textDelta.deletedRange.start,
+//               textDelta.deletedRange.end - textDelta.deletedRange.start)
+//           ..commit();
+//       }
+//     }
+//   }
+
+//   @override
+//   void updateFloatingCursor(RawFloatingCursorPoint point) {
+//     // TODO: implement updateFloatingCursor
+//   }
+// }
+
+// extension on TextNode {
+//   TextSpan toTextSpan() => TextSpan(
+//       children: delta.operations
+//           .whereType<TextInsert>()
+//           .map((op) => op.toTextSpan())
+//           .toList());
+// }
+
+// extension on TextInsert {
+//   TextSpan toTextSpan() {
+//     FontWeight? fontWeight;
+//     FontStyle? fontStyle;
+//     TextDecoration? decoration;
+//     GestureRecognizer? gestureRecognizer;
+//     Color? color;
+//     Color highLightColor = Colors.transparent;
+//     double fontSize = 16.0;
+//     final attributes = this.attributes;
+//     if (attributes?['bold'] == true) {
+//       fontWeight = FontWeight.bold;
+//     }
+//     if (attributes?['italic'] == true) {
+//       fontStyle = FontStyle.italic;
+//     }
+//     if (attributes?['underline'] == true) {
+//       decoration = TextDecoration.underline;
+//     }
+//     if (attributes?['strikethrough'] == true) {
+//       decoration = TextDecoration.lineThrough;
+//     }
+//     if (attributes?['highlight'] is String) {
+//       highLightColor = Color(int.parse(attributes!['highlight']));
+//     }
+//     if (attributes?['href'] is String) {
+//       color = const Color.fromARGB(255, 55, 120, 245);
+//       decoration = TextDecoration.underline;
+//       gestureRecognizer = TapGestureRecognizer()
+//         ..onTap = () {
+//           launchUrlString(attributes?['href']);
+//         };
+//     }
+//     final heading = attributes?['heading'] as String?;
+//     if (heading != null) {
+//       // TODO: make it better
+//       if (heading == 'h1') {
+//         fontSize = 30.0;
+//       } else if (heading == 'h2') {
+//         fontSize = 20.0;
+//       }
+//       fontWeight = FontWeight.bold;
+//     }
+//     return TextSpan(
+//       text: content,
+//       style: TextStyle(
+//         fontWeight: fontWeight,
+//         fontStyle: fontStyle,
+//         decoration: decoration,
+//         color: color,
+//         fontSize: fontSize,
+//         backgroundColor: highLightColor,
+//       ),
+//       recognizer: gestureRecognizer,
+//     );
+//   }
+// }
+
+// TextSelection? _globalSelectionToLocal(Node node, Selection? globalSel) {
+//   if (globalSel == null) {
+//     return null;
+//   }
+//   final nodePath = node.path;
+
+//   if (!pathEquals(nodePath, globalSel.start.path)) {
+//     return null;
+//   }
+//   if (globalSel.isCollapsed) {
+//     return TextSelection(
+//         baseOffset: globalSel.start.offset, extentOffset: globalSel.end.offset);
+//   } else {
+//     if (pathEquals(globalSel.start.path, globalSel.end.path)) {
+//       return TextSelection(
+//           baseOffset: globalSel.start.offset,
+//           extentOffset: globalSel.end.offset);
+//     }
+//   }
+//   return null;
+// }
+
+// Selection? _localSelectionToGlobal(Node node, TextSelection? sel) {
+//   if (sel == null) {
+//     return null;
+//   }
+//   final nodePath = node.path;
+
+//   return Selection(
+//     start: Position(path: nodePath, offset: sel.baseOffset),
+//     end: Position(path: nodePath, offset: sel.extentOffset),
+//   );
+// }

+ 10 - 9
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart

@@ -22,7 +22,7 @@ class SelectedTextNodeBuilder extends NodeWidgetBuilder {
   }
 
   @override
-  Widget build(BuildContext buildContext) {
+  Widget build(BuildContext context) {
     return _SelectedTextNodeWidget(
       key: key,
       node: node,
@@ -96,14 +96,15 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
   }
 
   @override
-  TextSelection? getCurrentTextSelection() {
-    return _textSelection;
-  }
-
-  @override
-  Offset getOffsetByTextSelection(TextSelection textSelection) {
-    final offset = _computeCursorRect(textSelection.baseOffset).center;
-    return _renderParagraph.localToGlobal(offset);
+  TextSelection? getTextSelectionInSelection(Selection selection) {
+    assert(selection.isCollapsed);
+    if (!selection.isCollapsed) {
+      return null;
+    }
+    return TextSelection(
+      baseOffset: selection.start.offset,
+      extentOffset: selection.end.offset,
+    );
   }
 
   @override

+ 0 - 352
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart

@@ -1,352 +0,0 @@
-import 'package:flowy_editor/document/position.dart';
-import 'package:flowy_editor/document/selection.dart';
-import 'package:flutter/gestures.dart';
-import 'package:flutter/material.dart';
-import 'package:flowy_editor/flowy_editor.dart';
-import 'package:flutter/services.dart';
-import 'package:url_launcher/url_launcher_string.dart';
-import 'flowy_selectable_text.dart';
-
-class TextNodeBuilder extends NodeWidgetBuilder {
-  TextNodeBuilder.create({
-    required super.node,
-    required super.editorState,
-    required super.key,
-  }) : super.create() {
-    nodeValidator = ((node) {
-      return node.type == 'text';
-    });
-  }
-
-  @override
-  Widget build(BuildContext buildContext) {
-    return _TextNodeWidget(key: key, node: node, editorState: editorState);
-  }
-}
-
-class _TextNodeWidget extends StatefulWidget {
-  final Node node;
-  final EditorState editorState;
-
-  const _TextNodeWidget({
-    Key? key,
-    required this.node,
-    required this.editorState,
-  }) : super(key: key);
-
-  @override
-  State<_TextNodeWidget> createState() => __TextNodeWidgetState();
-}
-
-class __TextNodeWidgetState extends State<_TextNodeWidget>
-    implements DeltaTextInputClient {
-  TextNode get node => widget.node as TextNode;
-  EditorState get editorState => widget.editorState;
-  bool _metaKeyDown = false;
-  bool _shiftKeyDown = false;
-
-  TextInputConnection? _textInputConnection;
-
-  @override
-  Widget build(BuildContext context) {
-    return Column(
-      crossAxisAlignment: CrossAxisAlignment.start,
-      children: [
-        FlowySelectableText.rich(
-          node.toTextSpan(),
-          showCursor: true,
-          enableInteractiveSelection: true,
-          onSelectionChanged: _onSelectionChanged,
-          // autofocus: true,
-          focusNode: FocusNode(
-            onKey: _onKey,
-          ),
-        ),
-        if (node.children.isNotEmpty)
-          ...node.children.map(
-            (e) => editorState.renderPlugins.buildWidget(
-              context: NodeWidgetContext(
-                buildContext: context,
-                node: e,
-                editorState: editorState,
-              ),
-            ),
-          ),
-        const SizedBox(
-          height: 10,
-        ),
-      ],
-    );
-  }
-
-  KeyEventResult _onKey(FocusNode focusNode, RawKeyEvent event) {
-    debugPrint('key: $event');
-    if (event is RawKeyDownEvent) {
-      final sel = _globalSelectionToLocal(node, editorState.cursorSelection);
-      if (event.logicalKey == LogicalKeyboardKey.backspace) {
-        _backDeleteTextAtSelection(sel);
-        return KeyEventResult.handled;
-      } else if (event.logicalKey == LogicalKeyboardKey.delete) {
-        _forwardDeleteTextAtSelection(sel);
-        return KeyEventResult.handled;
-      } else if (event.logicalKey == LogicalKeyboardKey.metaLeft ||
-          event.logicalKey == LogicalKeyboardKey.metaRight) {
-        _metaKeyDown = true;
-      } else if (event.logicalKey == LogicalKeyboardKey.shiftLeft ||
-          event.logicalKey == LogicalKeyboardKey.shiftRight) {
-        _shiftKeyDown = true;
-      } else if (event.logicalKey == LogicalKeyboardKey.keyZ && _metaKeyDown) {
-        if (_shiftKeyDown) {
-          editorState.undoManager.redo();
-        } else {
-          editorState.undoManager.undo();
-        }
-      }
-    } else if (event is RawKeyUpEvent) {
-      if (event.logicalKey == LogicalKeyboardKey.metaLeft ||
-          event.logicalKey == LogicalKeyboardKey.metaRight) {
-        _metaKeyDown = false;
-      }
-      if (event.logicalKey == LogicalKeyboardKey.shiftLeft ||
-          event.logicalKey == LogicalKeyboardKey.shiftRight) {
-        _shiftKeyDown = false;
-      }
-    }
-    return KeyEventResult.ignored;
-  }
-
-  void _onSelectionChanged(
-      TextSelection selection, SelectionChangedCause? cause) {
-    _textInputConnection?.close();
-    _textInputConnection = TextInput.attach(
-      this,
-      const TextInputConfiguration(
-        enableDeltaModel: true,
-        inputType: TextInputType.multiline,
-        textCapitalization: TextCapitalization.sentences,
-      ),
-    );
-    editorState.cursorSelection = _localSelectionToGlobal(node, selection);
-    _textInputConnection
-      ?..show()
-      ..setEditingState(
-        TextEditingValue(
-          text: node.toRawString(),
-          selection: selection,
-        ),
-      );
-  }
-
-  _backDeleteTextAtSelection(TextSelection? sel) {
-    if (sel == null) {
-      return;
-    }
-    if (sel.start == 0) {
-      return;
-    }
-
-    if (sel.isCollapsed) {
-      TransactionBuilder(editorState)
-        ..deleteText(node, sel.start - 1, 1)
-        ..commit();
-    } else {
-      TransactionBuilder(editorState)
-        ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset)
-        ..commit();
-    }
-
-    _setEditingStateFromGlobal();
-  }
-
-  _forwardDeleteTextAtSelection(TextSelection? sel) {
-    if (sel == null) {
-      return;
-    }
-
-    if (sel.isCollapsed) {
-      TransactionBuilder(editorState)
-        ..deleteText(node, sel.start, 1)
-        ..commit();
-    } else {
-      TransactionBuilder(editorState)
-        ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset)
-        ..commit();
-    }
-    _setEditingStateFromGlobal();
-  }
-
-  _setEditingStateFromGlobal() {
-    _textInputConnection?.setEditingState(TextEditingValue(
-        text: node.toRawString(),
-        selection: _globalSelectionToLocal(node, editorState.cursorSelection) ??
-            const TextSelection.collapsed(offset: 0)));
-  }
-
-  @override
-  void connectionClosed() {
-    // TODO: implement connectionClosed
-  }
-
-  @override
-  // TODO: implement currentAutofillScope
-  AutofillScope? get currentAutofillScope => throw UnimplementedError();
-
-  @override
-  // TODO: implement currentTextEditingValue
-  TextEditingValue? get currentTextEditingValue => TextEditingValue(
-      text: node.toRawString(),
-      selection: _globalSelectionToLocal(node, editorState.cursorSelection) ??
-          const TextSelection.collapsed(offset: 0));
-
-  @override
-  void insertTextPlaceholder(Size size) {
-    // TODO: implement insertTextPlaceholder
-  }
-
-  @override
-  void performAction(TextInputAction action) {}
-
-  @override
-  void performPrivateCommand(String action, Map<String, dynamic> data) {
-    // TODO: implement performPrivateCommand
-  }
-
-  @override
-  void removeTextPlaceholder() {
-    // TODO: implement removeTextPlaceholder
-  }
-
-  @override
-  void showAutocorrectionPromptRect(int start, int end) {
-    // TODO: implement showAutocorrectionPromptRect
-  }
-
-  @override
-  void showToolbar() {
-    // TODO: implement showToolbar
-  }
-
-  @override
-  void updateEditingValue(TextEditingValue value) {}
-
-  @override
-  void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
-    for (final textDelta in textEditingDeltas) {
-      if (textDelta is TextEditingDeltaInsertion) {
-        TransactionBuilder(editorState)
-          ..insertText(node, textDelta.insertionOffset, textDelta.textInserted)
-          ..commit();
-      } else if (textDelta is TextEditingDeltaDeletion) {
-        TransactionBuilder(editorState)
-          ..deleteText(node, textDelta.deletedRange.start,
-              textDelta.deletedRange.end - textDelta.deletedRange.start)
-          ..commit();
-      }
-    }
-  }
-
-  @override
-  void updateFloatingCursor(RawFloatingCursorPoint point) {
-    // TODO: implement updateFloatingCursor
-  }
-}
-
-extension on TextNode {
-  TextSpan toTextSpan() => TextSpan(
-      children: delta.operations
-          .whereType<TextInsert>()
-          .map((op) => op.toTextSpan())
-          .toList());
-}
-
-extension on TextInsert {
-  TextSpan toTextSpan() {
-    FontWeight? fontWeight;
-    FontStyle? fontStyle;
-    TextDecoration? decoration;
-    GestureRecognizer? gestureRecognizer;
-    Color? color;
-    Color highLightColor = Colors.transparent;
-    double fontSize = 16.0;
-    final attributes = this.attributes;
-    if (attributes?['bold'] == true) {
-      fontWeight = FontWeight.bold;
-    }
-    if (attributes?['italic'] == true) {
-      fontStyle = FontStyle.italic;
-    }
-    if (attributes?['underline'] == true) {
-      decoration = TextDecoration.underline;
-    }
-    if (attributes?['strikethrough'] == true) {
-      decoration = TextDecoration.lineThrough;
-    }
-    if (attributes?['highlight'] is String) {
-      highLightColor = Color(int.parse(attributes!['highlight']));
-    }
-    if (attributes?['href'] is String) {
-      color = const Color.fromARGB(255, 55, 120, 245);
-      decoration = TextDecoration.underline;
-      gestureRecognizer = TapGestureRecognizer()
-        ..onTap = () {
-          launchUrlString(attributes?['href']);
-        };
-    }
-    final heading = attributes?['heading'] as String?;
-    if (heading != null) {
-      // TODO: make it better
-      if (heading == 'h1') {
-        fontSize = 30.0;
-      } else if (heading == 'h2') {
-        fontSize = 20.0;
-      }
-      fontWeight = FontWeight.bold;
-    }
-    return TextSpan(
-      text: content,
-      style: TextStyle(
-        fontWeight: fontWeight,
-        fontStyle: fontStyle,
-        decoration: decoration,
-        color: color,
-        fontSize: fontSize,
-        backgroundColor: highLightColor,
-      ),
-      recognizer: gestureRecognizer,
-    );
-  }
-}
-
-TextSelection? _globalSelectionToLocal(Node node, Selection? globalSel) {
-  if (globalSel == null) {
-    return null;
-  }
-  final nodePath = node.path;
-
-  if (!pathEquals(nodePath, globalSel.start.path)) {
-    return null;
-  }
-  if (globalSel.isCollapsed) {
-    return TextSelection(
-        baseOffset: globalSel.start.offset, extentOffset: globalSel.end.offset);
-  } else {
-    if (pathEquals(globalSel.start.path, globalSel.end.path)) {
-      return TextSelection(
-          baseOffset: globalSel.start.offset,
-          extentOffset: globalSel.end.offset);
-    }
-  }
-  return null;
-}
-
-Selection? _localSelectionToGlobal(Node node, TextSelection? sel) {
-  if (sel == null) {
-    return null;
-  }
-  final nodePath = node.path;
-
-  return Selection(
-    start: Position(path: nodePath, offset: sel.baseOffset),
-    end: Position(path: nodePath, offset: sel.extentOffset),
-  );
-}

+ 2 - 2
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart

@@ -12,7 +12,7 @@ class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder {
   bool get isCompleted => node.attributes['checkbox'] as bool;
 
   @override
-  Widget build(BuildContext buildContext) {
+  Widget build(BuildContext context) {
     return Row(
       crossAxisAlignment: CrossAxisAlignment.start,
       children: [
@@ -20,7 +20,7 @@ class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder {
         Expanded(
           child: renderPlugins.buildWidget(
             context: NodeWidgetContext(
-              buildContext: buildContext,
+              buildContext: context,
               node: node,
               editorState: editorState,
             ),

+ 2 - 2
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart

@@ -27,13 +27,13 @@ class TextWithHeadingNodeBuilder extends NodeWidgetBuilder {
   }
 
   @override
-  Widget build(BuildContext buildContext) {
+  Widget build(BuildContext context) {
     return Column(
       children: [
         buildPadding(),
         renderPlugins.buildWidget(
           context: NodeWidgetContext(
-            buildContext: buildContext,
+            buildContext: context,
             node: node,
             editorState: editorState,
           ),

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

@@ -1,4 +1,5 @@
 import 'dart:async';
+import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart';
 import 'package:flowy_editor/service/service.dart';
 import 'package:flutter/material.dart';
 
@@ -25,6 +26,7 @@ class ApplyOptions {
 class EditorState {
   final StateTree document;
   final RenderPlugins renderPlugins;
+
   List<Node> selectedNodes = [];
 
   // Service reference.
@@ -39,6 +41,8 @@ class EditorState {
     required this.document,
     required this.renderPlugins,
   }) {
+    // FIXME: abstract render plugins as a service.
+    renderPlugins.register('text', RichTextNodeWidgetBuilder.create);
     undoManager.state = this;
   }
 

+ 2 - 0
frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart

@@ -12,3 +12,5 @@ export 'package:flowy_editor/operation/transaction_builder.dart';
 export 'package:flowy_editor/operation/operation.dart';
 export 'package:flowy_editor/editor_state.dart';
 export 'package:flowy_editor/service/editor_service.dart';
+export 'package:flowy_editor/document/selection.dart';
+export 'package:flowy_editor/document/position.dart';

+ 4 - 4
frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart

@@ -26,14 +26,14 @@ class NodeWidgetBuilder<T extends Node> {
   /// Render the current [Node]
   /// and the layout style of [Node.Children].
   Widget build(
-    BuildContext buildContext,
+    BuildContext context,
   ) =>
       throw UnimplementedError();
 
   /// TODO: refactore this part.
   /// return widget embedded with ChangeNotifier and widget itself.
   Widget call(
-    BuildContext buildContext,
+    BuildContext context,
   ) {
     /// TODO: Validate the node
     /// if failed, stop call build function,
@@ -43,10 +43,10 @@ class NodeWidgetBuilder<T extends Node> {
           'Node validate failure, node = { type: ${node.type}, attributes: ${node.attributes} }');
     }
 
-    return _build(buildContext);
+    return _build(context);
   }
 
-  Widget _build(BuildContext buildContext) {
+  Widget _build(BuildContext context) {
     return CompositedTransformTarget(
       link: node.layerLink,
       child: ChangeNotifierProvider.value(

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

@@ -0,0 +1,215 @@
+import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
+import 'package:flowy_editor/flowy_editor.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+
+class RichTextNodeWidgetBuilder extends NodeWidgetBuilder {
+  RichTextNodeWidgetBuilder.create({
+    required super.editorState,
+    required super.node,
+    required super.key,
+  }) : super.create();
+
+  @override
+  Widget build(BuildContext context) {
+    return FlowyRichText(
+      key: key,
+      textNode: node as TextNode,
+      editorState: editorState,
+    );
+  }
+}
+
+class FlowyRichText extends StatefulWidget {
+  const FlowyRichText({
+    Key? key,
+    this.cursorHeight,
+    this.cursorWidth = 2.0,
+    required this.textNode,
+    required this.editorState,
+  }) : super(key: key);
+
+  final double? cursorHeight;
+  final double cursorWidth;
+  final TextNode textNode;
+  final EditorState editorState;
+
+  @override
+  State<FlowyRichText> createState() => _FlowyRichTextState();
+}
+
+class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
+  final _textKey = GlobalKey();
+  final _decorationKey = GlobalKey();
+
+  EditorState get _editorState => widget.editorState;
+  TextNode get _textNode => widget.textNode;
+  RenderParagraph get _renderParagraph =>
+      _textKey.currentContext?.findRenderObject() as RenderParagraph;
+
+  @override
+  Widget build(BuildContext context) {
+    final attributes = _textNode.attributes;
+    // TODO: use factory method ??
+    if (attributes.list == 'todo') {
+      return _buildTodoListRichText(context);
+    } else if (attributes.list == 'bullet') {
+      return _buildBulletedListRichText(context);
+    } else if (attributes.quotes == true) {
+      return _buildQuotedRichText(context);
+    }
+    return _buildRichText(context);
+  }
+
+  @override
+  Position start() => Position(path: _textNode.path, offset: 0);
+
+  @override
+  Position end() =>
+      Position(path: _textNode.path, offset: _textNode.toRawString().length);
+
+  @override
+  Rect getCursorRectInPosition(Position position) {
+    final textPosition = TextPosition(offset: position.offset);
+    final baseRect = frontWidgetRect();
+    final cursorOffset =
+        _renderParagraph.getOffsetForCaret(textPosition, Rect.zero);
+    final cursorHeight = widget.cursorHeight ??
+        _renderParagraph.getFullHeightForCaret(textPosition) ??
+        5.0; // default height
+    return Rect.fromLTWH(
+      baseRect.centerRight.dx + cursorOffset.dx - (widget.cursorWidth / 2),
+      cursorOffset.dy,
+      widget.cursorWidth,
+      cursorHeight,
+    );
+  }
+
+  @override
+  Position getPositionInOffset(Offset start) {
+    final offset = _renderParagraph.globalToLocal(start);
+    final baseOffset = _renderParagraph.getPositionForOffset(offset).offset;
+    return Position(path: _textNode.path, offset: baseOffset);
+  }
+
+  @override
+  List<Rect> getRectsInSelection(Selection selection) {
+    assert(pathEquals(selection.start.path, selection.end.path) &&
+        pathEquals(selection.start.path, _textNode.path));
+
+    final textSelection = TextSelection(
+      baseOffset: selection.start.offset,
+      extentOffset: selection.end.offset,
+    );
+    final baseRect = frontWidgetRect();
+    return _renderParagraph.getBoxesForSelection(textSelection).map((box) {
+      final rect = box.toRect();
+      return rect.translate(baseRect.centerRight.dx, 0);
+    }).toList();
+  }
+
+  @override
+  Selection getSelectionInRange(Offset start, Offset end) {
+    final localStart = _renderParagraph.globalToLocal(start);
+    final localEnd = _renderParagraph.globalToLocal(end);
+    final baseOffset = _renderParagraph.getPositionForOffset(localStart).offset;
+    final extentOffset = _renderParagraph.getPositionForOffset(localEnd).offset;
+    return Selection.single(
+      path: _textNode.path,
+      startOffset: baseOffset,
+      endOffset: extentOffset,
+    );
+  }
+
+  Widget _buildRichText(BuildContext context) {
+    if (_textNode.children.isEmpty) {
+      return _buildSingleRichText(context);
+    } else {
+      return _buildRichTextWithChildren(context);
+    }
+  }
+
+  Widget _buildRichTextWithChildren(BuildContext context) {
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        _buildSingleRichText(context),
+        ..._textNode.children
+            .map(
+              (child) => _editorState.renderPlugins.buildWidget(
+                context: NodeWidgetContext(
+                  buildContext: context,
+                  node: child,
+                  editorState: _editorState,
+                ),
+              ),
+            )
+            .toList()
+      ],
+    );
+  }
+
+  Widget _buildSingleRichText(BuildContext context) {
+    return Expanded(child: RichText(key: _textKey, text: _textSpan));
+  }
+
+  Widget _buildTodoListRichText(BuildContext context) {
+    return Row(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        GestureDetector(
+          child: Icon(
+              key: _decorationKey,
+              _textNode.attributes.todo
+                  ? Icons.square_rounded
+                  : Icons.square_outlined),
+          onTap: () => TransactionBuilder(_editorState)
+            ..updateNode(_textNode, {
+              'todo': !_textNode.attributes.todo,
+            })
+            ..commit(),
+        ),
+        _buildRichText(context),
+      ],
+    );
+  }
+
+  Widget _buildBulletedListRichText(BuildContext context) {
+    return Row(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Icon(key: _decorationKey, Icons.circle),
+        _buildRichText(context),
+      ],
+    );
+  }
+
+  Widget _buildQuotedRichText(BuildContext context) {
+    return Row(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Icon(key: _decorationKey, Icons.format_quote),
+        _buildRichText(context),
+      ],
+    );
+  }
+
+  Rect frontWidgetRect() {
+    // FIXME: find a more elegant way to solve this situation.
+    if (_textNode.attributes.list != null) {
+      final renderBox =
+          _decorationKey.currentContext?.findRenderObject() as RenderBox;
+      return renderBox.localToGlobal(Offset.zero) & renderBox.size;
+    }
+    return Rect.zero;
+  }
+
+  TextSpan get _textSpan => TextSpan(
+      children: _textNode.delta.operations
+          .whereType<TextInsert>()
+          .map((insert) => RichTextStyle(
+                attributes: insert.attributes ?? {},
+                text: insert.content,
+              ).toTextSpan())
+          .toList(growable: false));
+}

+ 182 - 0
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart

@@ -0,0 +1,182 @@
+import 'package:flowy_editor/document/attributes.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+
+class StyleKey {
+  static String bold = 'bold';
+  static String italic = 'italic';
+  static String underline = 'underline';
+  static String strikethrough = 'strikethrough';
+  static String color = 'color';
+  static String font = 'font';
+  static String href = 'href';
+  static String heading = 'heading';
+  static String quotes = 'quotes';
+  static String list = 'list';
+  static String todo = 'todo';
+  static String code = 'code';
+}
+
+extension AttributesExtensions on Attributes {
+  bool get bold {
+    return (containsKey(StyleKey.bold) && this[StyleKey.bold] == true);
+  }
+
+  bool get italic {
+    return (containsKey(StyleKey.italic) && this[StyleKey.italic] == true);
+  }
+
+  bool get underline {
+    return (containsKey(StyleKey.underline) &&
+        this[StyleKey.underline] == true);
+  }
+
+  bool get strikethrough {
+    return (containsKey(StyleKey.strikethrough) &&
+        this[StyleKey.strikethrough] == true);
+  }
+
+  Color? get color {
+    if (containsKey(StyleKey.color) && this[StyleKey.color] is String) {
+      return Color(
+        int.parse(this[StyleKey.color]),
+      );
+    }
+    return null;
+  }
+
+  String? get font {
+    // TODO: unspport now.
+    return null;
+  }
+
+  String? get href {
+    if (containsKey(StyleKey.href) && this[StyleKey.href] is String) {
+      return this[StyleKey.href];
+    }
+    return null;
+  }
+
+  String? get heading {
+    if (containsKey(StyleKey.heading) && this[StyleKey.heading] is String) {
+      return this[StyleKey.heading];
+    }
+    return null;
+  }
+
+  bool get quotes {
+    if (containsKey(StyleKey.quotes) && this[StyleKey.quotes] == true) {
+      return this[StyleKey.quotes];
+    }
+    return false;
+  }
+
+  String? get list {
+    if (containsKey(StyleKey.list) && this[StyleKey.list] is String) {
+      return this[StyleKey.list];
+    }
+    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];
+    }
+    return false;
+  }
+}
+
+///
+/// Supported partial rendering types:
+///   bold, italic,
+///   underline, strikethrough,
+///   color, font,
+///   href
+///
+/// Supported global rendering types:
+///   heading: h1, h2, h3, h4, h5, h6,
+///   block quotes,
+///   list: ordered list, bulleted list,
+///   code block
+///
+class RichTextStyle {
+  // TODO: customize
+  RichTextStyle({
+    required this.attributes,
+    required this.text,
+  });
+
+  final Attributes attributes;
+  final String text;
+
+  TextSpan toTextSpan() {
+    return TextSpan(
+      text: text,
+      style: TextStyle(
+        fontWeight: fontWeight,
+        fontStyle: fontStyle,
+        fontSize: fontSize,
+        color: textColor,
+        decoration: textDecoration,
+      ),
+      recognizer: recognizer,
+    );
+  }
+
+  // bold
+  FontWeight get fontWeight =>
+      attributes.bold ? FontWeight.bold : FontWeight.normal;
+
+  // underline or strikethrough
+  TextDecoration get textDecoration {
+    if (attributes.underline || attributes.href != null) {
+      return TextDecoration.underline;
+    } else if (attributes.strikethrough) {
+      return TextDecoration.lineThrough;
+    }
+    return TextDecoration.none;
+  }
+
+  // font
+  FontStyle get fontStyle =>
+      attributes.italic ? FontStyle.italic : FontStyle.normal;
+
+  // text color
+  Color get textColor {
+    if (attributes.href != null) {
+      return Colors.lightBlue;
+    }
+    return attributes.color ?? Colors.black;
+  }
+
+  // font size
+  double get fontSize {
+    final heading = attributes.heading;
+    if (heading != null) {
+      final headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
+      final fontSizes = [30.0, 28.0, 26.0, 24.0, 22.0, 20.0];
+      return fontSizes[headings.indexOf(heading)];
+    } else {
+      return 18.0;
+    }
+  }
+
+  // recognizer
+  GestureRecognizer? get recognizer {
+    final href = attributes.href;
+    if (href != null) {
+      return TapGestureRecognizer()
+        ..onTap = () async {
+          // FIXME: launch the url
+        };
+    }
+    return null;
+  }
+}

+ 2 - 9
frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart

@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
 
 ///
 mixin Selectable<T extends StatefulWidget> on State<T> {
-  /// Returns a [List] of the [Rect] selection sorrounded by start and end
+  /// Returns a [List] of the [Rect] selection surrounded by start and end
   ///   in current widget.
   ///
   /// [start] and [end] are the offsets under the global coordinate system.
@@ -32,12 +32,5 @@ mixin Selectable<T extends StatefulWidget> on State<T> {
   ///
   /// Only the widget rendered by [TextNode] need to implement the detail,
   ///   and the rest can return null.
-  TextSelection? getCurrentTextSelection() => null;
-
-  /// For [TextNode] only.
-  ///
-  /// Retruns a [Offset].
-  /// Only the widget rendered by [TextNode] need to implement the detail,
-  ///   and the rest can return [Offset.zero].
-  Offset getOffsetByTextSelection(TextSelection textSelection) => Offset.zero;
+  TextSelection? getTextSelectionInSelection(Selection selection) => null;
 }

+ 53 - 53
frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart

@@ -12,58 +12,58 @@ FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) {
     return KeyEventResult.ignored;
   }
 
-  final selectionNodes = editorState.selectedNodes;
-  if (selectionNodes.length == 1 && selectionNodes.first is TextNode) {
-    final node = selectionNodes.first.unwrapOrNull<TextNode>();
-    final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
-    if (selectable != null) {
-      final textSelection = selectable.getCurrentTextSelection();
-      if (textSelection != null) {
-        if (textSelection.isCollapsed) {
-          /// Three cases:
-          /// Delete the zero character,
-          ///   1. if there is still text node in front of it, then merge them.
-          ///   2. if not, just ignore
-          /// Delete the non-zero character,
-          ///   3. delete the single character.
-          if (textSelection.baseOffset == 0) {
-            if (node?.previous != null && node?.previous is TextNode) {
-              final previous = node!.previous! as TextNode;
-              final newTextSelection = TextSelection.collapsed(
-                  offset: previous.toRawString().length);
-              final selectionService = editorState.service.selectionService;
-              final previousSelectable =
-                  previous.key?.currentState?.unwrapOrNull<Selectable>();
-              final newOfset = previousSelectable
-                  ?.getOffsetByTextSelection(newTextSelection);
-              if (newOfset != null) {
-                // selectionService.updateCursor(newOfset);
-              }
-              // merge
-              TransactionBuilder(editorState)
-                ..deleteNode(node)
-                ..insertText(
-                    previous, previous.toRawString().length, node.toRawString())
-                ..commit();
-              return KeyEventResult.handled;
-            } else {
-              return KeyEventResult.ignored;
-            }
-          } else {
-            TransactionBuilder(editorState)
-              ..deleteText(node!, textSelection.baseOffset - 1, 1)
-              ..commit();
-            final newTextSelection =
-                TextSelection.collapsed(offset: textSelection.baseOffset - 1);
-            final selectionService = editorState.service.selectionService;
-            final newOfset =
-                selectable.getOffsetByTextSelection(newTextSelection);
-            // selectionService.updateCursor(newOfset);
-            return KeyEventResult.handled;
-          }
-        }
-      }
-    }
-  }
+  // final selectionNodes = editorState.selectedNodes;
+  // if (selectionNodes.length == 1 && selectionNodes.first is TextNode) {
+  //   final node = selectionNodes.first.unwrapOrNull<TextNode>();
+  //   final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
+  //   if (selectable != null) {
+  //     final textSelection = selectable.getCurrentTextSelection();
+  //     if (textSelection != null) {
+  //       if (textSelection.isCollapsed) {
+  //         /// Three cases:
+  //         /// Delete the zero character,
+  //         ///   1. if there is still text node in front of it, then merge them.
+  //         ///   2. if not, just ignore
+  //         /// Delete the non-zero character,
+  //         ///   3. delete the single character.
+  //         if (textSelection.baseOffset == 0) {
+  //           if (node?.previous != null && node?.previous is TextNode) {
+  //             final previous = node!.previous! as TextNode;
+  //             final newTextSelection = TextSelection.collapsed(
+  //                 offset: previous.toRawString().length);
+  //             final selectionService = editorState.service.selectionService;
+  //             final previousSelectable =
+  //                 previous.key?.currentState?.unwrapOrNull<Selectable>();
+  //             final newOfset = previousSelectable
+  //                 ?.getOffsetByTextSelection(newTextSelection);
+  //             if (newOfset != null) {
+  //               // selectionService.updateCursor(newOfset);
+  //             }
+  //             // merge
+  //             TransactionBuilder(editorState)
+  //               ..deleteNode(node)
+  //               ..insertText(
+  //                   previous, previous.toRawString().length, node.toRawString())
+  //               ..commit();
+  //             return KeyEventResult.handled;
+  //           } else {
+  //             return KeyEventResult.ignored;
+  //           }
+  //         } else {
+  //           TransactionBuilder(editorState)
+  //             ..deleteText(node!, textSelection.baseOffset - 1, 1)
+  //             ..commit();
+  //           final newTextSelection =
+  //               TextSelection.collapsed(offset: textSelection.baseOffset - 1);
+  //           final selectionService = editorState.service.selectionService;
+  //           final newOfset =
+  //               selectable.getOffsetByTextSelection(newTextSelection);
+  //           // selectionService.updateCursor(newOfset);
+  //           return KeyEventResult.handled;
+  //         }
+  //       }
+  //     }
+  //   }
+  // }
   return KeyEventResult.ignored;
 };

+ 0 - 18
frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart

@@ -1,6 +1,4 @@
-import 'package:flowy_editor/flowy_editor.dart';
 import 'package:flowy_editor/service/keyboard_service.dart';
-import 'package:flowy_editor/extensions/object_extensions.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
@@ -10,21 +8,5 @@ FlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
     return KeyEventResult.ignored;
   }
 
-  final selectedNodes = editorState.selectedNodes;
-  if (selectedNodes.length != 1) {
-    return KeyEventResult.ignored;
-  }
-
-  final textNode = selectedNodes.first.unwrapOrNull<TextNode>();
-  final selectable = textNode?.key?.currentState?.unwrapOrNull<Selectable>();
-  final textSelection = selectable?.getCurrentTextSelection();
-  // if (textNode != null && selectable != null && textSelection != null) {
-  //   final offset = selectable.getOffsetByTextSelection(textSelection);
-  //   final rect = selectable.getCursorRect(offset);
-  //   editorState.service.floatingToolbarService
-  //       .showInOffset(rect.topLeft, textNode.layerLink);
-  //   return KeyEventResult.handled;
-  // }
-
   return KeyEventResult.ignored;
 };

+ 3 - 0
frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart

@@ -299,6 +299,9 @@ class _FlowySelectionState extends State<FlowySelection>
     panEndOffset = details.globalPosition;
 
     final nodes = getNodesInRange(panStartOffset!, panEndOffset!);
+    if (nodes.isEmpty) {
+      return;
+    }
     final first = nodes.first.selectable;
     final last = nodes.last.selectable;