瀏覽代碼

Merge pull request #726 from LucasXu0/feat/flowy_editor_input_service

feat: add input service to handle text editing.
Lucas.Xu 2 年之前
父節點
當前提交
6b59050ef3
共有 36 個文件被更改,包括 1920 次插入544 次删除
  1. 4 0
      frontend/app_flowy/packages/flowy_editor/assets/images/check.svg
  2. 3 0
      frontend/app_flowy/packages/flowy_editor/assets/images/point.svg
  3. 3 0
      frontend/app_flowy/packages/flowy_editor/assets/images/quote.svg
  4. 3 0
      frontend/app_flowy/packages/flowy_editor/assets/images/uncheck.svg
  5. 19 2
      frontend/app_flowy/packages/flowy_editor/example/assets/document.json
  6. 193 0
      frontend/app_flowy/packages/flowy_editor/example/assets/example.json
  7. 234 0
      frontend/app_flowy/packages/flowy_editor/example/lib/expandable_floating_action_button.dart
  8. 91 48
      frontend/app_flowy/packages/flowy_editor/example/lib/main.dart
  9. 1 1
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart
  10. 5 2
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart
  11. 352 0
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/old_text_node_widget.dart
  12. 10 9
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart
  13. 0 352
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart
  14. 2 2
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart
  15. 2 2
      frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart
  16. 36 1
      frontend/app_flowy/packages/flowy_editor/example/pubspec.lock
  17. 1 0
      frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml
  18. 9 0
      frontend/app_flowy/packages/flowy_editor/lib/document/node.dart
  19. 4 0
      frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart
  20. 0 2
      frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart
  21. 11 0
      frontend/app_flowy/packages/flowy_editor/lib/extensions/path_extensions.dart
  22. 2 0
      frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart
  23. 39 0
      frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart
  24. 15 15
      frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart
  25. 282 0
      frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart
  26. 231 0
      frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart
  27. 1 1
      frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart
  28. 2 9
      frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart
  29. 21 14
      frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart
  30. 193 0
      frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart
  31. 53 53
      frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart
  32. 46 0
      frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart
  33. 0 18
      frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart
  34. 46 12
      frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart
  35. 3 0
      frontend/app_flowy/packages/flowy_editor/lib/service/service.dart
  36. 3 1
      frontend/app_flowy/packages/flowy_editor/pubspec.yaml

+ 4 - 0
frontend/app_flowy/packages/flowy_editor/assets/images/check.svg

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="2" y="2" width="12" height="12" rx="4" fill="#00BCF0"/>
+<path d="M6 8L7.61538 9.5L10.5 6.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
frontend/app_flowy/packages/flowy_editor/assets/images/point.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="6" y="6" width="4" height="4" rx="2" fill="#333333"/>
+</svg>

+ 3 - 0
frontend/app_flowy/packages/flowy_editor/assets/images/quote.svg

@@ -0,0 +1,3 @@
+<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
+  <rect width="40" height="160" x="80" y="20" fill="#00BCF0"/>
+</svg>

+ 3 - 0
frontend/app_flowy/packages/flowy_editor/assets/images/uncheck.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="2.5" y="2.5" width="11" height="11" rx="3.5" stroke="#BDBDBD"/>
+</svg>

+ 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",

+ 193 - 0
frontend/app_flowy/packages/flowy_editor/example/assets/example.json

@@ -0,0 +1,193 @@
+{
+  "document": {
+    "type": "editor",
+    "attributes": {},
+    "children": [
+      {
+        "type": "image",
+        "attributes": {
+          "image_src": "https://images.pexels.com/photos/2253275/pexels-photo-2253275.jpeg?cs=srgb&dl=pexels-helena-lopes-2253275.jpg&fm=jpg"
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "🌶 Read Me"
+          }
+        ],
+        "attributes": {
+          "heading": "h1"
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "👋 Welcome to Appflowy"
+          }
+        ],
+        "attributes": {
+          "heading": "h2"
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Here are the basics:"
+          }
+        ],
+        "attributes": {
+          "heading": "h3"
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          { "insert": "Click " },
+          { "insert": "anywhere", "attributes": { "underline": true } },
+          { "insert": " and just typing." }
+        ],
+        "attributes": {
+          "list": "todo",
+          "todo": true
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Hit"
+          },
+          {
+            "insert": "  /  ",
+            "attributes": { "highlightColor": "0xFFFFFF00" }
+          },
+          {
+            "insert": "to see all the types of content you can add - entity, headers, videos, sub pages, etc."
+          }
+        ],
+        "attributes": {
+          "list": "todo",
+          "todo": true
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Highlight any text, and use the menu that pops up to "
+          },
+          { "insert": "style", "attributes": { "bold": true } },
+          { "insert": " your ", "attributes": { "italic": true } },
+          { "insert": "writing", "attributes": { "strikethrough": true } },
+          { "insert": "." }
+        ],
+        "attributes": {
+          "list": "todo",
+          "todo": true
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Here are the examples:"
+          }
+        ],
+        "attributes": {
+          "heading": "h3"
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Hello world"
+          }
+        ],
+        "attributes": {
+          "list": "bullet"
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Hello world"
+          }
+        ],
+        "attributes": {
+          "list": "bullet"
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Hello world"
+          }
+        ],
+        "attributes": {
+          "list": "bullet"
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Hello world"
+          }
+        ],
+        "attributes": {
+          "quote": true
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Hello world"
+          }
+        ],
+        "attributes": {
+          "quote": true
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Hello world"
+          }
+        ],
+        "attributes": {
+          "number": 1
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Hello world"
+          }
+        ],
+        "attributes": {
+          "number": 2
+        }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Hello world"
+          }
+        ],
+        "attributes": {
+          "number": 3
+        }
+      }
+    ]
+  }
+}

+ 234 - 0
frontend/app_flowy/packages/flowy_editor/example/lib/expandable_floating_action_button.dart

@@ -0,0 +1,234 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+
+// copy from https://docs.flutter.dev/cookbook/effects/expandable-fab
+@immutable
+class ExpandableFab extends StatefulWidget {
+  const ExpandableFab({
+    super.key,
+    this.initialOpen,
+    required this.distance,
+    required this.children,
+  });
+
+  final bool? initialOpen;
+  final double distance;
+  final List<Widget> children;
+
+  @override
+  State<ExpandableFab> createState() => _ExpandableFabState();
+}
+
+class _ExpandableFabState extends State<ExpandableFab>
+    with SingleTickerProviderStateMixin {
+  late final AnimationController _controller;
+  late final Animation<double> _expandAnimation;
+  bool _open = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _open = widget.initialOpen ?? false;
+    _controller = AnimationController(
+      value: _open ? 1.0 : 0.0,
+      duration: const Duration(milliseconds: 250),
+      vsync: this,
+    );
+    _expandAnimation = CurvedAnimation(
+      curve: Curves.fastOutSlowIn,
+      reverseCurve: Curves.easeOutQuad,
+      parent: _controller,
+    );
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+
+  void _toggle() {
+    setState(() {
+      _open = !_open;
+      if (_open) {
+        _controller.forward();
+      } else {
+        _controller.reverse();
+      }
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox.expand(
+      child: Stack(
+        alignment: Alignment.bottomRight,
+        clipBehavior: Clip.none,
+        children: [
+          _buildTapToCloseFab(),
+          ..._buildExpandingActionButtons(),
+          _buildTapToOpenFab(),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildTapToCloseFab() {
+    return SizedBox(
+      width: 56.0,
+      height: 56.0,
+      child: Center(
+        child: Material(
+          shape: const CircleBorder(),
+          clipBehavior: Clip.antiAlias,
+          elevation: 4.0,
+          child: InkWell(
+            onTap: _toggle,
+            child: Padding(
+              padding: const EdgeInsets.all(8.0),
+              child: Icon(
+                Icons.close,
+                color: Theme.of(context).primaryColor,
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  List<Widget> _buildExpandingActionButtons() {
+    final children = <Widget>[];
+    final count = widget.children.length;
+    final step = 90.0 / (count - 1);
+    for (var i = 0, angleInDegrees = 0.0;
+        i < count;
+        i++, angleInDegrees += step) {
+      children.add(
+        _ExpandingActionButton(
+          directionInDegrees: angleInDegrees,
+          maxDistance: widget.distance,
+          progress: _expandAnimation,
+          child: widget.children[i],
+        ),
+      );
+    }
+    return children;
+  }
+
+  Widget _buildTapToOpenFab() {
+    return IgnorePointer(
+      ignoring: _open,
+      child: AnimatedContainer(
+        transformAlignment: Alignment.center,
+        transform: Matrix4.diagonal3Values(
+          _open ? 0.7 : 1.0,
+          _open ? 0.7 : 1.0,
+          1.0,
+        ),
+        duration: const Duration(milliseconds: 250),
+        curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
+        child: AnimatedOpacity(
+          opacity: _open ? 0.0 : 1.0,
+          curve: const Interval(0.25, 1.0, curve: Curves.easeInOut),
+          duration: const Duration(milliseconds: 250),
+          child: FloatingActionButton(
+            onPressed: _toggle,
+            child: const Icon(Icons.create),
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+@immutable
+class _ExpandingActionButton extends StatelessWidget {
+  const _ExpandingActionButton({
+    required this.directionInDegrees,
+    required this.maxDistance,
+    required this.progress,
+    required this.child,
+  });
+
+  final double directionInDegrees;
+  final double maxDistance;
+  final Animation<double> progress;
+  final Widget child;
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedBuilder(
+      animation: progress,
+      builder: (context, child) {
+        final offset = Offset.fromDirection(
+          directionInDegrees * (math.pi / 180.0),
+          progress.value * maxDistance,
+        );
+        return Positioned(
+          right: 4.0 + offset.dx,
+          bottom: 4.0 + offset.dy,
+          child: Transform.rotate(
+            angle: (1.0 - progress.value) * math.pi / 2,
+            child: child!,
+          ),
+        );
+      },
+      child: FadeTransition(
+        opacity: progress,
+        child: child,
+      ),
+    );
+  }
+}
+
+@immutable
+class ActionButton extends StatelessWidget {
+  const ActionButton({
+    super.key,
+    this.onPressed,
+    required this.icon,
+  });
+
+  final VoidCallback? onPressed;
+  final Widget icon;
+
+  @override
+  Widget build(BuildContext context) {
+    final theme = Theme.of(context);
+    return Material(
+      shape: const CircleBorder(),
+      clipBehavior: Clip.antiAlias,
+      color: theme.colorScheme.secondary,
+      elevation: 4.0,
+      child: IconButton(
+        onPressed: onPressed,
+        icon: icon,
+        color: theme.colorScheme.onSecondary,
+      ),
+    );
+  }
+}
+
+@immutable
+class FakeItem extends StatelessWidget {
+  const FakeItem({
+    super.key,
+    required this.isBig,
+  });
+
+  final bool isBig;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 24.0),
+      height: isBig ? 128.0 : 36.0,
+      decoration: BoxDecoration(
+        borderRadius: const BorderRadius.all(Radius.circular(8.0)),
+        color: Colors.grey.shade300,
+      ),
+    );
+  }
+}

+ 91 - 48
frontend/app_flowy/packages/flowy_editor/example/lib/main.dart

@@ -1,10 +1,11 @@
 import 'dart:convert';
 
+import 'package:example/expandable_floating_action_button.dart';
 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';
@@ -60,13 +61,13 @@ class MyHomePage extends StatefulWidget {
 class _MyHomePageState extends State<MyHomePage> {
   final RenderPlugins renderPlugins = RenderPlugins();
   late EditorState _editorState;
+  int page = 0;
   @override
   void initState() {
     super.initState();
 
     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);
@@ -80,53 +81,95 @@ class _MyHomePageState extends State<MyHomePage> {
         // the App.build method, and use it to set our appbar title.
         title: Text(widget.title),
       ),
-      body: FutureBuilder<String>(
-        future: rootBundle.loadString('assets/document.json'),
-        builder: (context, snapshot) {
-          if (!snapshot.hasData) {
-            return const Center(
-              child: CircularProgressIndicator(),
-            );
-          } else {
-            final data = Map<String, Object>.from(json.decode(snapshot.data!));
-            final document = StateTree.fromJson(data);
-            _editorState = EditorState(
-              document: document,
-              renderPlugins: renderPlugins,
-            );
-            return FlowyEditor(
-              editorState: _editorState,
-              keyEventHandlers: const [],
-              shortcuts: [
-                // TODO: this won't work, just a example for now.
-                {
-                  'h1': (editorState, eventName) {
-                    debugPrint('shortcut => $eventName');
-                    final selectedNodes = editorState.selectedNodes;
-                    if (selectedNodes.isEmpty) {
-                      return;
-                    }
-                    final textNode = selectedNodes.first as TextNode;
-                    TransactionBuilder(editorState)
-                      ..formatText(textNode, 0, textNode.toRawString().length, {
-                        'heading': 'h1',
-                      })
-                      ..commit();
-                  }
-                },
-                {
-                  'bold': (editorState, eventName) =>
-                      debugPrint('shortcut => $eventName')
-                },
-                {
-                  'underline': (editorState, eventName) =>
-                      debugPrint('shortcut => $eventName')
-                },
-              ],
-            );
-          }
-        },
+      body: _buildBody(),
+      floatingActionButton: ExpandableFab(
+        distance: 112.0,
+        children: [
+          ActionButton(
+            onPressed: () {
+              if (page == 0) return;
+              setState(() {
+                page = 0;
+              });
+            },
+            icon: const Icon(Icons.note_add),
+          ),
+          ActionButton(
+            onPressed: () {
+              if (page == 1) return;
+              setState(() {
+                page = 1;
+              });
+            },
+            icon: const Icon(Icons.text_fields),
+          ),
+        ],
       ),
     );
   }
+
+  Widget _buildBody() {
+    if (page == 0) {
+      return _buildFlowyEditor();
+    } else if (page == 1) {
+      return _buildTextField();
+    }
+    return Container();
+  }
+
+  Widget _buildFlowyEditor() {
+    return FutureBuilder<String>(
+      future: rootBundle.loadString('assets/example.json'),
+      builder: (context, snapshot) {
+        if (!snapshot.hasData) {
+          return const Center(
+            child: CircularProgressIndicator(),
+          );
+        } else {
+          final data = Map<String, Object>.from(json.decode(snapshot.data!));
+          final document = StateTree.fromJson(data);
+          _editorState = EditorState(
+            document: document,
+            renderPlugins: renderPlugins,
+          );
+          return FlowyEditor(
+            editorState: _editorState,
+            keyEventHandlers: const [],
+            shortcuts: [
+              // TODO: this won't work, just a example for now.
+              {
+                'h1': (editorState, eventName) {
+                  debugPrint('shortcut => $eventName');
+                  final selectedNodes = editorState.selectedNodes;
+                  if (selectedNodes.isEmpty) {
+                    return;
+                  }
+                  final textNode = selectedNodes.first as TextNode;
+                  TransactionBuilder(editorState)
+                    ..formatText(textNode, 0, textNode.toRawString().length, {
+                      'heading': 'h1',
+                    })
+                    ..commit();
+                }
+              },
+              {
+                'bold': (editorState, eventName) =>
+                    debugPrint('shortcut => $eventName')
+              },
+              {
+                'underline': (editorState, eventName) =>
+                    debugPrint('shortcut => $eventName')
+              },
+            ],
+          );
+        }
+      },
+    );
+  }
+
+  Widget _buildTextField() {
+    return const Center(
+      child: TextField(),
+    );
+  }
 }

+ 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(

+ 5 - 2
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,
@@ -83,7 +83,10 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
   Widget _build(BuildContext context) {
     return Column(
       children: [
-        Image.network(src),
+        Image.network(
+          src,
+          height: 150.0,
+        ),
         if (node.children.isNotEmpty)
           Column(
             crossAxisAlignment: CrossAxisAlignment.start,

+ 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,
           ),

+ 36 - 1
frontend/app_flowy/packages/flowy_editor/example/pubspec.lock

@@ -76,6 +76,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.0.1"
+  flutter_svg:
+    dependency: transitive
+    description:
+      name: flutter_svg
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.1.1+1"
   flutter_test:
     dependency: "direct dev"
     description: flutter
@@ -135,6 +142,27 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.8.1"
+  path_drawing:
+    dependency: transitive
+    description:
+      name: path_drawing
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.0"
+  path_parsing:
+    dependency: transitive
+    description:
+      name: path_parsing
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.0"
+  petitparser:
+    dependency: transitive
+    description:
+      name: petitparser
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "5.0.0"
   plugin_platform_interface:
     dependency: transitive
     description:
@@ -259,6 +287,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.1.2"
+  xml:
+    dependency: transitive
+    description:
+      name: xml
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "6.1.0"
 sdks:
   dart: ">=2.17.0 <3.0.0"
-  flutter: ">=2.10.0"
+  flutter: ">=2.11.0-0.1.pre"

+ 1 - 0
frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml

@@ -64,6 +64,7 @@ flutter:
   # To add assets to your application, add an assets section, like this:
   assets:
     - document.json
+    - example.json
   #   - images/a_dot_ham.jpeg
 
   # An image asset can refer to one or more resolution-specific "variants", see

+ 9 - 0
frontend/app_flowy/packages/flowy_editor/lib/document/node.dart

@@ -1,6 +1,7 @@
 import 'dart:collection';
 import 'package:flowy_editor/document/path.dart';
 import 'package:flowy_editor/document/text_delta.dart';
+import 'package:flowy_editor/operation/operation.dart';
 import 'package:flutter/material.dart';
 import './attributes.dart';
 
@@ -176,6 +177,14 @@ class TextNode extends Node {
     required Delta delta,
   }) : _delta = delta;
 
+  TextNode.empty()
+      : _delta = Delta([TextInsert('')]),
+        super(
+          type: 'text',
+          children: LinkedList(),
+          attributes: {},
+        );
+
   Delta get delta {
     return _delta;
   }

+ 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;
   }
 

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

@@ -1,5 +1,3 @@
-import 'dart:math';
-
 import 'package:flowy_editor/document/node.dart';
 import 'package:flowy_editor/document/selection.dart';
 import 'package:flowy_editor/extensions/object_extensions.dart';

+ 11 - 0
frontend/app_flowy/packages/flowy_editor/lib/extensions/path_extensions.dart

@@ -22,4 +22,15 @@ extension PathExtensions on Path {
     }
     return true;
   }
+
+  Path get next {
+    Path nextPath = Path.from(this, growable: true);
+    if (isEmpty) {
+      return nextPath;
+    }
+    final last = nextPath.last;
+    return nextPath
+      ..removeLast()
+      ..add(last + 1);
+  }
 }

+ 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';

+ 39 - 0
frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart

@@ -0,0 +1,39 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/svg.dart';
+
+class FlowySvg extends StatelessWidget {
+  const FlowySvg({
+    Key? key,
+    this.name,
+    this.size = const Size(20, 20),
+    this.color,
+    this.number,
+  }) : super(key: key);
+
+  final String? name;
+  final Size size;
+  final Color? color;
+  final int? number;
+
+  @override
+  Widget build(BuildContext context) {
+    if (name != null) {
+      return SizedBox.fromSize(
+        size: size,
+        child: SvgPicture.asset(
+          'assets/images/$name.svg',
+          color: color,
+          package: 'flowy_editor',
+        ),
+      );
+    } else if (number != null) {
+      final numberText =
+          '<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg"><text x="30" y="150" fill="black" font-size="160">$number.</text></svg>';
+      return SizedBox.fromSize(
+        size: size,
+        child: SvgPicture.string(numberText),
+      );
+    }
+    return Container();
+  }
+}

+ 15 - 15
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 embeded with ChangeNotifier and widget itself.
+  /// 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,20 +43,20 @@ class NodeWidgetBuilder<T extends Node> {
           'Node validate failure, node = { type: ${node.type}, attributes: ${node.attributes} }');
     }
 
-    return _buildNodeChangeNotifier(buildContext);
+    return _build(context);
   }
 
-  Widget _buildNodeChangeNotifier(BuildContext buildContext) {
-    return ChangeNotifierProvider.value(
-      value: node,
-      builder: (_, __) => Consumer<T>(
-        builder: ((context, value, child) {
-          debugPrint('Node changed, and rebuilding...');
-          return CompositedTransformTarget(
-            link: node.layerLink,
-            child: build(context),
-          );
-        }),
+  Widget _build(BuildContext context) {
+    return CompositedTransformTarget(
+      link: node.layerLink,
+      child: ChangeNotifierProvider.value(
+        value: node,
+        builder: (context, child) => Consumer<T>(
+          builder: ((context, value, child) {
+            debugPrint('Node is rebuilding...');
+            return build(context);
+          }),
+        ),
       ),
     );
   }

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

@@ -0,0 +1,282 @@
+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/editor_state.dart';
+import 'package:flowy_editor/document/path.dart';
+import 'package:flowy_editor/operation/transaction_builder.dart';
+import 'package:flowy_editor/render/node_widget_builder.dart';
+import 'package:flowy_editor/render/render_plugins.dart';
+import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
+import 'package:flowy_editor/infra/flowy_svg.dart';
+import 'package:flowy_editor/extensions/object_extensions.dart';
+import 'package:flowy_editor/render/selection/selectable.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.quote == true) {
+      return _buildQuotedRichText(context);
+    } else if (attributes.heading != null) {
+      return _buildHeadingRichText(context);
+    } else if (attributes.number != null) {
+      return _buildNumberListRichText(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 SizedBox(
+      width:
+          MediaQuery.of(context).size.width - 20, // FIXME: use the const value
+      child: RichText(key: _textKey, text: _decorateTextSpanWithGlobalStyle),
+    );
+  }
+
+  Widget _buildTodoListRichText(BuildContext context) {
+    final name = _textNode.attributes.todo ? 'check' : 'uncheck';
+    return Row(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        GestureDetector(
+          child: FlowySvg(
+            key: _decorationKey,
+            name: name,
+          ),
+          onTap: () => TransactionBuilder(_editorState)
+            ..updateNode(_textNode, {
+              'todo': !_textNode.attributes.todo,
+            })
+            ..commit(),
+        ),
+        _buildRichText(context),
+      ],
+    );
+  }
+
+  Widget _buildBulletedListRichText(BuildContext context) {
+    return Row(
+      crossAxisAlignment: CrossAxisAlignment.center,
+      children: [
+        FlowySvg(
+          key: _decorationKey,
+          name: 'point',
+        ),
+        _buildRichText(context),
+      ],
+    );
+  }
+
+  Widget _buildNumberListRichText(BuildContext context) {
+    return Row(
+      crossAxisAlignment: CrossAxisAlignment.center,
+      children: [
+        FlowySvg(
+          key: _decorationKey,
+          number: _textNode.attributes.number,
+        ),
+        _buildRichText(context),
+      ],
+    );
+  }
+
+  Widget _buildQuotedRichText(BuildContext context) {
+    return Row(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        FlowySvg(
+          key: _decorationKey,
+          name: 'quote',
+        ),
+        _buildRichText(context),
+      ],
+    );
+  }
+
+  Widget _buildHeadingRichText(BuildContext context) {
+    // TODO: customize
+    return Column(
+      children: [
+        const Padding(padding: EdgeInsets.only(top: 5)),
+        _buildRichText(context),
+        const Padding(padding: EdgeInsets.only(top: 5)),
+      ],
+    );
+  }
+
+  Rect frontWidgetRect() {
+    // FIXME: find a more elegant way to solve this situation.
+    final renderBox = _decorationKey.currentContext
+        ?.findRenderObject()
+        ?.unwrapOrNull<RenderBox>();
+    if (renderBox != null) {
+      return renderBox.localToGlobal(Offset.zero) & renderBox.size;
+    }
+    return Rect.zero;
+  }
+
+  TextSpan get _decorateTextSpanWithGlobalStyle => TextSpan(
+        children: _textSpan.children
+            ?.whereType<TextSpan>()
+            .map(
+              (span) => TextSpan(
+                text: span.text,
+                style: span.style?.copyWith(
+                  fontSize: _textNode.attributes.fontSize,
+                  color: _textNode.attributes.quoteColor,
+                ),
+                recognizer: span.recognizer,
+              ),
+            )
+            .toList(),
+      );
+
+  TextSpan get _textSpan => TextSpan(
+      children: _textNode.delta.operations
+          .whereType<TextInsert>()
+          .map((insert) => RichTextStyle(
+                attributes: insert.attributes ?? {},
+                text: insert.content,
+              ).toTextSpan())
+          .toList(growable: false));
+}

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

@@ -0,0 +1,231 @@
+import 'package:flowy_editor/document/attributes.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+
+///
+/// Supported partial rendering types:
+///   bold, italic,
+///   underline, strikethrough,
+///   color, font,
+///   href
+///
+/// Supported global rendering types:
+///   heading: h1, h2, h3, h4, h5, h6, ...
+///   block quote,
+///   list: ordered list, bulleted list,
+///   code block
+///
+class StyleKey {
+  static String bold = 'bold';
+  static String italic = 'italic';
+  static String underline = 'underline';
+  static String strikethrough = 'strikethrough';
+  static String color = 'color';
+  static String highlightColor = 'highlightColor';
+  static String font = 'font';
+  static String href = 'href';
+
+  static String heading = 'heading';
+  static String quote = 'quote';
+  static String list = 'list';
+  static String number = 'number';
+  static String todo = 'todo';
+  static String code = '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,
+};
+
+extension NodeAttributesExtensions on Attributes {
+  String? get heading {
+    if (containsKey(StyleKey.heading) && this[StyleKey.heading] is String) {
+      return this[StyleKey.heading];
+    }
+    return null;
+  }
+
+  double get fontSize {
+    if (heading != null) {
+      return headingToFontSize[heading]!;
+    }
+    return baseFontSize;
+  }
+
+  bool get quote {
+    if (containsKey(StyleKey.quote) && this[StyleKey.quote] == true) {
+      return this[StyleKey.quote];
+    }
+    return false;
+  }
+
+  Color? get quoteColor {
+    if (quote) {
+      return Colors.grey;
+    }
+    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];
+    }
+    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;
+  }
+}
+
+extension DeltaAttributesExtensions 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;
+  }
+
+  Color? get hightlightColor {
+    if (containsKey(StyleKey.highlightColor) &&
+        this[StyleKey.highlightColor] is String) {
+      return Color(
+        int.parse(this[StyleKey.highlightColor]),
+      );
+    }
+    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;
+  }
+}
+
+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,
+        backgroundColor: backgroundColor,
+        decoration: textDecoration,
+      ),
+      recognizer: recognizer,
+    );
+  }
+
+  // bold
+  FontWeight get fontWeight {
+    if (attributes.bold) {
+      return FontWeight.bold;
+    }
+    return 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;
+  }
+
+  Color get backgroundColor {
+    return attributes.hightlightColor ?? Colors.transparent;
+  }
+
+  // font size
+  double get fontSize {
+    return baseFontSize;
+  }
+
+  // recognizer
+  GestureRecognizer? get recognizer {
+    final href = attributes.href;
+    if (href != null) {
+      return TapGestureRecognizer()
+        ..onTap = () async {
+          // FIXME: launch the url
+        };
+    }
+    return null;
+  }
+}

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart

@@ -11,7 +11,7 @@ class CursorWidget extends StatefulWidget {
     this.blinkingInterval = 0.5,
   }) : super(key: key);
 
-  final double blinkingInterval;
+  final double blinkingInterval; // milliseconds
   final Color color;
   final Rect rect;
   final LayerLink layerLink;

+ 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;
 }

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

@@ -1,4 +1,6 @@
 import 'package:flowy_editor/render/selection/floating_shortcut_widget.dart';
+import 'package:flowy_editor/service/input_service.dart';
+import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart';
 import 'package:flowy_editor/service/shortcut_service.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart';
@@ -36,22 +38,27 @@ class _FlowyEditorState extends State<FlowyEditor> {
     return FlowySelection(
       key: editorState.service.selectionServiceKey,
       editorState: editorState,
-      child: FlowyKeyboard(
-        key: editorState.service.keyboardServiceKey,
-        handlers: [
-          slashShortcutHandler,
-          flowyDeleteNodesHandler,
-          deleteSingleTextNodeHandler,
-          arrowKeysHandler,
-          ...widget.keyEventHandlers,
-        ],
+      child: FlowyInput(
+        key: editorState.service.inputServiceKey,
         editorState: editorState,
-        child: FloatingShortcut(
-          key: editorState.service.floatingShortcutServiceKey,
-          size: const Size(200, 150), // TODO: support customize size.
+        child: FlowyKeyboard(
+          key: editorState.service.keyboardServiceKey,
+          handlers: [
+            slashShortcutHandler,
+            flowyDeleteNodesHandler,
+            deleteSingleTextNodeHandler,
+            arrowKeysHandler,
+            enterInEdgeOfTextNodeHandler,
+            ...widget.keyEventHandlers,
+          ],
           editorState: editorState,
-          floatingShortcuts: widget.shortcuts,
-          child: editorState.build(context),
+          child: FloatingShortcut(
+            key: editorState.service.floatingShortcutServiceKey,
+            size: const Size(200, 150), // TODO: support customize size.
+            editorState: editorState,
+            floatingShortcuts: widget.shortcuts,
+            child: editorState.build(context),
+          ),
         ),
       ),
     );

+ 193 - 0
frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart

@@ -0,0 +1,193 @@
+import 'package:flowy_editor/document/position.dart';
+import 'package:flowy_editor/document/selection.dart';
+import 'package:flowy_editor/editor_state.dart';
+import 'package:flowy_editor/document/node.dart';
+import 'package:flowy_editor/operation/transaction_builder.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+mixin FlowyInputService {
+  void attach(TextEditingValue textEditingValue);
+  void setTextEditingValue(TextEditingValue textEditingValue);
+  void apply(List<TextEditingDelta> deltas);
+  void close();
+}
+
+/// process input
+class FlowyInput extends StatefulWidget {
+  const FlowyInput({
+    Key? key,
+    required this.editorState,
+    required this.child,
+  }) : super(key: key);
+
+  final EditorState editorState;
+  final Widget child;
+
+  @override
+  State<FlowyInput> createState() => _FlowyInputState();
+}
+
+class _FlowyInputState extends State<FlowyInput>
+    with FlowyInputService
+    implements DeltaTextInputClient {
+  TextInputConnection? _textInputConnection;
+
+  EditorState get _editorState => widget.editorState;
+
+  @override
+  void initState() {
+    super.initState();
+
+    _editorState.service.selectionService.currentSelectedNodes
+        .addListener(_onSelectedNodesChange);
+  }
+
+  @override
+  void dispose() {
+    _editorState.service.selectionService.currentSelectedNodes
+        .removeListener(_onSelectedNodesChange);
+
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      child: widget.child,
+    );
+  }
+
+  @override
+  void attach(TextEditingValue textEditingValue) {
+    if (_textInputConnection != null) {
+      return;
+    }
+
+    _textInputConnection = TextInput.attach(
+      this,
+      const TextInputConfiguration(
+        // TODO: customize
+        enableDeltaModel: true,
+        inputType: TextInputType.multiline,
+        textCapitalization: TextCapitalization.sentences,
+      ),
+    );
+
+    _textInputConnection
+      ?..show()
+      ..setEditingState(textEditingValue);
+  }
+
+  @override
+  void setTextEditingValue(TextEditingValue textEditingValue) {
+    assert(_textInputConnection != null,
+        'Must call `attach` before set textEditingValue');
+    if (_textInputConnection != null) {
+      _textInputConnection?.setEditingState(textEditingValue);
+    }
+  }
+
+  @override
+  void apply(List<TextEditingDelta> deltas) {
+// TODO: implement the detail
+    for (final delta in deltas) {
+      if (delta is TextEditingDeltaInsertion) {
+      } else if (delta is TextEditingDeltaDeletion) {
+      } else if (delta is TextEditingDeltaReplacement) {
+      } else if (delta is TextEditingDeltaNonTextUpdate) {
+        // We don't need to care the [TextEditingDeltaNonTextUpdate].
+        // Do nothing.
+      }
+    }
+  }
+
+  @override
+  void close() {
+    _textInputConnection?.close();
+    _textInputConnection = null;
+  }
+
+  @override
+  void connectionClosed() {
+    // TODO: implement connectionClosed
+  }
+
+  @override
+  // TODO: implement currentAutofillScope
+  AutofillScope? get currentAutofillScope => throw UnimplementedError();
+
+  @override
+  // TODO: implement currentTextEditingValue
+  TextEditingValue? get currentTextEditingValue => throw UnimplementedError();
+
+  @override
+  void insertTextPlaceholder(Size size) {
+    // TODO: implement insertTextPlaceholder
+  }
+
+  @override
+  void performAction(TextInputAction action) {
+    // TODO: implement performAction
+  }
+
+  @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) {
+    // TODO: implement updateEditingValue
+  }
+
+  @override
+  void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
+    debugPrint(textEditingDeltas.map((delta) => delta.toString()).toString());
+
+    apply(textEditingDeltas);
+  }
+
+  @override
+  void updateFloatingCursor(RawFloatingCursorPoint point) {
+    // TODO: implement updateFloatingCursor
+  }
+
+  void _onSelectedNodesChange() {
+    final nodes =
+        _editorState.service.selectionService.currentSelectedNodes.value;
+    final selection = _editorState.service.selectionService.currentSelection;
+    // FIXME: upward.
+    if (nodes.isNotEmpty && selection != null) {
+      final textNodes = nodes.whereType<TextNode>();
+      final text = textNodes.fold<String>(
+          '', (sum, textNode) => '$sum${textNode.toRawString()}\n');
+      attach(
+        TextEditingValue(
+          text: text,
+          selection: TextSelection(
+            baseOffset: selection.start.offset,
+            extentOffset: selection.end.offset,
+          ),
+        ),
+      );
+    } else {
+      close();
+    }
+  }
+}

+ 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;
 };

+ 46 - 0
frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart

@@ -0,0 +1,46 @@
+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/operation/transaction_builder.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) {
+    return KeyEventResult.ignored;
+  }
+
+  final nodes = editorState.service.selectionService.currentSelectedNodes.value;
+  final selection = editorState.service.selectionService.currentSelection;
+  if (selection == null ||
+      nodes.length != 1 ||
+      nodes.first is! TextNode ||
+      !selection.isCollapsed) {
+    return KeyEventResult.ignored;
+  }
+
+  final textNode = nodes.first as TextNode;
+
+  if (textNode.selectable!.end() == selection.end) {
+    TransactionBuilder(editorState)
+      ..insertNode(
+        textNode.path.next,
+        TextNode.empty(),
+      )
+      ..commit();
+    return KeyEventResult.handled;
+  } else if (textNode.selectable!.start() == selection.start) {
+    TransactionBuilder(editorState)
+      ..insertNode(
+        textNode.path,
+        TextNode.empty(),
+      )
+      ..commit();
+    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;
 };

+ 46 - 12
frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart

@@ -17,7 +17,8 @@ mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
   /// Returns the currently selected [Node]s.
   ///
   /// The order of the return is determined according to the selected order.
-  List<Node> get currentSelectedNodes;
+  ValueNotifier<List<Node>> get currentSelectedNodes;
+  Selection? get currentSelection;
 
   /// ------------------ Selection ------------------------
 
@@ -95,7 +96,7 @@ class FlowySelection extends StatefulWidget {
 }
 
 class _FlowySelectionState extends State<FlowySelection>
-    with FlowySelectionService {
+    with FlowySelectionService, WidgetsBindingObserver {
   final _cursorKey = GlobalKey(debugLabel: 'cursor');
 
   final List<OverlayEntry> _selectionOverlays = [];
@@ -112,12 +113,37 @@ class _FlowySelectionState extends State<FlowySelection>
   EditorState get editorState => widget.editorState;
 
   @override
-  List<Node> currentSelectedNodes = [];
+  Selection? currentSelection;
+
+  @override
+  ValueNotifier<List<Node>> currentSelectedNodes = ValueNotifier([]);
 
   @override
   List<Node> getNodesInSelection(Selection selection) =>
       _selectedNodesInSelection(editorState.document.root, selection);
 
+  @override
+  void initState() {
+    super.initState();
+
+    WidgetsBinding.instance.addObserver(this);
+  }
+
+  @override
+  void didChangeMetrics() {
+    super.didChangeMetrics();
+
+    // Need to refresh the selection when the metrics changed.
+    if (currentSelection != null) {
+      updateSelection(currentSelection!);
+    }
+  }
+
+  @override
+  void dispose() {
+    super.dispose();
+  }
+
   @override
   Widget build(BuildContext context) {
     return RawGestureDetector(
@@ -136,8 +162,8 @@ class _FlowySelectionState extends State<FlowySelection>
         TapGestureRecognizer:
             GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
           () => TapGestureRecognizer(),
-          (recongizer) {
-            recongizer.onTapDown = _onTapDown;
+          (recognizer) {
+            recognizer.onTapDown = _onTapDown;
           },
         )
       },
@@ -151,8 +177,10 @@ class _FlowySelectionState extends State<FlowySelection>
 
     // cursor
     if (selection.isCollapsed) {
+      debugPrint('Update cursor');
       _updateCursor(selection.start);
     } else {
+      debugPrint('Update selection');
       _updateSelection(selection);
     }
   }
@@ -167,9 +195,9 @@ class _FlowySelectionState extends State<FlowySelection>
     if (end != null) {
       return computeNodesInRange(editorState.document.root, start, end);
     } else {
-      final reuslt = computeNodeInOffset(editorState.document.root, start);
-      if (reuslt != null) {
-        return [reuslt];
+      final result = computeNodeInOffset(editorState.document.root, start);
+      if (result != null) {
+        return [result];
       }
     }
     return [];
@@ -271,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;
 
@@ -292,7 +323,8 @@ class _FlowySelectionState extends State<FlowySelection>
   }
 
   void _clearSelection() {
-    currentSelectedNodes = [];
+    currentSelection = null;
+    currentSelectedNodes.value = [];
 
     // clear selection
     _selectionOverlays
@@ -302,7 +334,7 @@ class _FlowySelectionState extends State<FlowySelection>
     _cursorOverlays
       ..forEach((overlay) => overlay.remove())
       ..clear();
-    // clear floating shortcusts
+    // clear floating shortcuts
     editorState.service.floatingShortcutServiceKey.currentState
         ?.unwrapOrNull<FlowyFloatingShortcutService>()
         ?.hide();
@@ -312,7 +344,8 @@ class _FlowySelectionState extends State<FlowySelection>
     final nodes =
         _selectedNodesInSelection(editorState.document.root, selection);
 
-    currentSelectedNodes = nodes;
+    currentSelection = selection;
+    currentSelectedNodes.value = nodes;
 
     var index = 0;
     for (final node in nodes) {
@@ -374,7 +407,8 @@ class _FlowySelectionState extends State<FlowySelection>
       return;
     }
 
-    currentSelectedNodes = [node];
+    currentSelection = Selection.collapsed(position);
+    currentSelectedNodes.value = [node];
 
     final selectable = node.selectable;
     final rect = selectable?.getCursorRectInPosition(position);

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

@@ -14,6 +14,9 @@ class FlowyService {
   // keyboard service
   final keyboardServiceKey = GlobalKey(debugLabel: 'flowy_keyboard_service');
 
+  // input service
+  final inputServiceKey = GlobalKey(debugLabel: 'flowy_input_service');
+
   // floating shortcut service
   final floatingShortcutServiceKey =
       GlobalKey(debugLabel: 'flowy_floating_shortcut_service');

+ 3 - 1
frontend/app_flowy/packages/flowy_editor/pubspec.yaml

@@ -11,6 +11,7 @@ dependencies:
   flutter:
     sdk: flutter
 
+  flutter_svg: ^1.1.1+1
   provider: ^6.0.3
 
 dev_dependencies:
@@ -26,7 +27,8 @@ flutter:
 
   # To add assets to your package, add an assets section, like this:
   assets:
-    - document.json
+    - assets/images/uncheck.svg
+    - assets/images/
   #   - images/a_dot_burr.jpeg
   #   - images/a_dot_ham.jpeg
   #