Przeglądaj źródła

Merge pull request #1144 from LucasXu0/web_support

Support web platform for AppFlowy Editor
Lucas.Xu 2 lat temu
rodzic
commit
55be554cc7
32 zmienionych plików z 973 dodań i 452 usunięć
  1. 5 0
      frontend/app_flowy/packages/appflowy_editor/example/.firebaserc
  2. 23 0
      frontend/app_flowy/packages/appflowy_editor/example/firebase.json
  3. 51 22
      frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart
  4. 0 165
      frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/image_node_widget.dart
  5. 0 100
      frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/youtube_link_node_widget.dart
  6. 0 2
      frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift
  7. 0 6
      frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock
  8. 2 2
      frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml
  9. 36 31
      frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart
  10. 2 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart
  11. 30 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/path_extensions.dart
  12. 46 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/infra/infra.dart
  13. 2 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart
  14. 56 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/built_in_text_widget.dart
  15. 6 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart
  16. 6 46
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart
  17. 6 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart
  18. 6 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart
  19. 22 6
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart
  20. 41 26
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart
  21. 25 22
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart
  22. 34 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart
  23. 8 3
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart
  24. 24 9
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart
  25. 6 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart
  26. 5 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event.dart
  27. 153 0
      frontend/app_flowy/packages/appflowy_editor/test/document/node_test.dart
  28. 38 0
      frontend/app_flowy/packages/appflowy_editor/test/extensions/path_extensions_test.dart
  29. 51 0
      frontend/app_flowy/packages/appflowy_editor/test/infra/infra_test.dart
  30. 1 0
      frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart
  31. 137 1
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart
  32. 151 0
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart

+ 5 - 0
frontend/app_flowy/packages/appflowy_editor/example/.firebaserc

@@ -0,0 +1,5 @@
+{
+  "projects": {
+    "default": "appflowy-editor"
+  }
+}

+ 23 - 0
frontend/app_flowy/packages/appflowy_editor/example/firebase.json

@@ -0,0 +1,23 @@
+{
+  "hosting": {
+    "public": "build/web",
+    "ignore": [
+      "firebase.json",
+      "**/.*",
+      "**/node_modules/**"
+    ],
+    "rewrites": [
+      {
+        "source": "**",
+        "destination": "/index.html"
+      }
+    ],
+    "headers": [ {
+      "source": "**/*.@(png|jpg|jpeg|gif)",
+      "headers": [ {
+        "key": "Access-Control-Allow-Origin",
+        "value": "*"
+      } ]
+    } ]
+  }
+}

+ 51 - 22
frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart

@@ -1,13 +1,16 @@
 import 'dart:convert';
 import 'dart:io';
 
-import 'package:example/plugin/underscore_to_italic.dart';
+import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
+
+import 'package:example/plugin/underscore_to_italic.dart';
+import 'package:file_picker/file_picker.dart';
 import 'package:flutter_localizations/flutter_localizations.dart';
 import 'package:google_fonts/google_fonts.dart';
-
 import 'package:path_provider/path_provider.dart';
+import 'package:universal_html/html.dart' as html;
 
 import 'package:appflowy_editor/appflowy_editor.dart';
 
@@ -112,6 +115,7 @@ class _MyHomePageState extends State<MyHomePage> {
             child: AppFlowyEditor(
               editorState: _editorState!,
               editorStyle: _editorStyle,
+              editable: true,
               shortcutEvents: [
                 underscoreToItalic,
               ],
@@ -148,7 +152,7 @@ class _MyHomePageState extends State<MyHomePage> {
         ),
         ActionButton(
           icon: const Icon(Icons.import_export),
-          onPressed: () => _importDocument(),
+          onPressed: () async => await _importDocument(),
         ),
         ActionButton(
           icon: const Icon(Icons.color_lens),
@@ -167,28 +171,53 @@ class _MyHomePageState extends State<MyHomePage> {
   void _exportDocument(EditorState editorState) async {
     final document = editorState.document.toJson();
     final json = jsonEncode(document);
-    final directory = await getTemporaryDirectory();
-    final path = directory.path;
-    final file = File('$path/editor.json');
-    await file.writeAsString(json);
-
-    if (mounted) {
-      ScaffoldMessenger.of(context).showSnackBar(
-        SnackBar(
-          content: Text('The document is saved to the ${file.path}'),
-        ),
-      );
+    if (kIsWeb) {
+      final blob = html.Blob([json], 'text/plain', 'native');
+      html.AnchorElement(
+        href: html.Url.createObjectUrlFromBlob(blob).toString(),
+      )
+        ..setAttribute('download', 'editor.json')
+        ..click();
+    } else {
+      final directory = await getTemporaryDirectory();
+      final path = directory.path;
+      final file = File('$path/editor.json');
+      await file.writeAsString(json);
+
+      if (mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(
+            content: Text('The document is saved to the ${file.path}'),
+          ),
+        );
+      }
     }
   }
 
-  void _importDocument() async {
-    final directory = await getTemporaryDirectory();
-    final path = directory.path;
-    final file = File('$path/editor.json');
-    setState(() {
-      _editorState = null;
-      _jsonString = file.readAsString();
-    });
+  Future<void> _importDocument() async {
+    if (kIsWeb) {
+      final result = await FilePicker.platform.pickFiles(
+        allowMultiple: false,
+        allowedExtensions: ['json'],
+        type: FileType.custom,
+      );
+      final bytes = result?.files.first.bytes;
+      if (bytes != null) {
+        final jsonString = const Utf8Decoder().convert(bytes);
+        setState(() {
+          _editorState = null;
+          _jsonString = Future.value(jsonString);
+        });
+      }
+    } else {
+      final directory = await getTemporaryDirectory();
+      final path = '${directory.path}/editor.json';
+      final file = File(path);
+      setState(() {
+        _editorState = null;
+        _jsonString = file.readAsString();
+      });
+    }
   }
 
   void _switchToPage(int pageIndex) {

+ 0 - 165
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/image_node_widget.dart

@@ -1,165 +0,0 @@
-import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:flutter/material.dart';
-
-/// 1. define your custom type in example.json
-///   For example I need to define an image plugin, then I define type equals
-///   "image", and add "image_src" into "attributes".
-///   {
-///     "type": "image",
-///     "attributes", { "image_src": "https://s1.ax1x.com/2022/07/28/vCgz1x.png" }
-///   }
-/// 2. create a class extends [NodeWidgetBuilder]
-/// 3. override the function `Widget build(NodeWidgetContext<Node> context)`
-///     and return a widget to render. The returned widget should be
-///     a StatefulWidget and mixin with [SelectableMixin].
-///
-/// 4. override the getter `nodeValidator`
-///     to verify the data structure in [Node].
-/// 5. register the plugin with `type` to `AppFlowyEditor` in `main.dart`.
-/// 6. Congratulations!
-
-class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
-  @override
-  Widget build(NodeWidgetContext<Node> context) {
-    return ImageNodeWidget(
-      key: context.node.key,
-      node: context.node,
-      editorState: context.editorState,
-    );
-  }
-
-  @override
-  NodeValidator<Node> get nodeValidator => ((node) {
-        return node.type == 'image';
-      });
-}
-
-const double placeholderHeight = 132;
-
-class ImageNodeWidget extends StatefulWidget {
-  final Node node;
-  final EditorState editorState;
-
-  const ImageNodeWidget({
-    Key? key,
-    required this.node,
-    required this.editorState,
-  }) : super(key: key);
-
-  @override
-  State<ImageNodeWidget> createState() => _ImageNodeWidgetState();
-}
-
-class _ImageNodeWidgetState extends State<ImageNodeWidget>
-    with SelectableMixin {
-  bool isHovered = false;
-  Node get node => widget.node;
-  EditorState get editorState => widget.editorState;
-  String get src => widget.node.attributes['image_src'] as String;
-
-  @override
-  Position end() {
-    return Position(path: node.path, offset: 0);
-  }
-
-  @override
-  Position start() {
-    return Position(path: node.path, offset: 0);
-  }
-
-  @override
-  List<Rect> getRectsInSelection(Selection selection) {
-    return [];
-  }
-
-  @override
-  Selection getSelectionInRange(Offset start, Offset end) {
-    return Selection.collapsed(Position(path: node.path, offset: 0));
-  }
-
-  @override
-  Offset localToGlobal(Offset offset) {
-    throw UnimplementedError();
-  }
-
-  @override
-  Position getPositionInOffset(Offset start) {
-    return Position(path: node.path, offset: 0);
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return _build(context);
-  }
-
-  Widget _loadingBuilder(
-      BuildContext context, Widget widget, ImageChunkEvent? evt) {
-    if (evt == null) {
-      return widget;
-    }
-    return Container(
-      alignment: Alignment.center,
-      height: placeholderHeight,
-      child: const Text("Loading..."),
-    );
-  }
-
-  Widget _errorBuilder(
-      BuildContext context, Object obj, StackTrace? stackTrace) {
-    return Container(
-      alignment: Alignment.center,
-      height: placeholderHeight,
-      child: const Text("Error..."),
-    );
-  }
-
-  Widget _frameBuilder(
-    BuildContext context,
-    Widget child,
-    int? frame,
-    bool wasSynchronouslyLoaded,
-  ) {
-    if (frame == null) {
-      return Container(
-        alignment: Alignment.center,
-        height: placeholderHeight,
-        child: const Text("Loading..."),
-      );
-    }
-
-    return child;
-  }
-
-  Widget _build(BuildContext context) {
-    return Column(
-      children: [
-        MouseRegion(
-            onEnter: (event) {
-              setState(() {
-                isHovered = true;
-              });
-            },
-            onExit: (event) {
-              setState(() {
-                isHovered = false;
-              });
-            },
-            child: Container(
-              clipBehavior: Clip.antiAlias,
-              decoration: BoxDecoration(
-                  border: Border.all(
-                    color: isHovered ? Colors.blue : Colors.grey,
-                  ),
-                  borderRadius: const BorderRadius.all(Radius.circular(20))),
-              child: Image.network(
-                src,
-                width: MediaQuery.of(context).size.width,
-                frameBuilder: _frameBuilder,
-                loadingBuilder: _loadingBuilder,
-                errorBuilder: _errorBuilder,
-              ),
-            )),
-      ],
-    );
-  }
-}

+ 0 - 100
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/youtube_link_node_widget.dart

@@ -1,100 +0,0 @@
-import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:flutter/material.dart';
-import 'package:pod_player/pod_player.dart';
-
-class YouTubeLinkNodeBuilder extends NodeWidgetBuilder<Node> {
-  @override
-  Widget build(NodeWidgetContext<Node> context) {
-    return LinkNodeWidget(
-      key: context.node.key,
-      node: context.node,
-      editorState: context.editorState,
-    );
-  }
-
-  @override
-  NodeValidator<Node> get nodeValidator => ((node) {
-        return node.type == 'youtube_link';
-      });
-}
-
-class LinkNodeWidget extends StatefulWidget {
-  final Node node;
-  final EditorState editorState;
-
-  const LinkNodeWidget({
-    Key? key,
-    required this.node,
-    required this.editorState,
-  }) : super(key: key);
-
-  @override
-  State<LinkNodeWidget> createState() => _YouTubeLinkNodeWidgetState();
-}
-
-class _YouTubeLinkNodeWidgetState extends State<LinkNodeWidget>
-    with SelectableMixin {
-  Node get node => widget.node;
-  EditorState get editorState => widget.editorState;
-  String get src => widget.node.attributes['youtube_link'] as String;
-
-  @override
-  Position end() {
-    // TODO: implement end
-    throw UnimplementedError();
-  }
-
-  @override
-  Position start() {
-    // TODO: implement start
-    throw UnimplementedError();
-  }
-
-  @override
-  List<Rect> getRectsInSelection(Selection selection) {
-    // TODO: implement getRectsInSelection
-    throw UnimplementedError();
-  }
-
-  @override
-  Selection getSelectionInRange(Offset start, Offset end) {
-    // TODO: implement getSelectionInRange
-    throw UnimplementedError();
-  }
-
-  @override
-  Offset localToGlobal(Offset offset) {
-    throw UnimplementedError();
-  }
-
-  @override
-  Position getPositionInOffset(Offset start) {
-    // TODO: implement getPositionInOffset
-    throw UnimplementedError();
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return _build(context);
-  }
-
-  late final PodPlayerController controller;
-
-  @override
-  void initState() {
-    controller = PodPlayerController(
-      playVideoFrom: PlayVideoFrom.network(
-        src,
-      ),
-    )..initialise();
-    super.initState();
-  }
-
-  Widget _build(BuildContext context) {
-    return Column(
-      children: [
-        PodVideoPlayer(controller: controller),
-      ],
-    );
-  }
-}

+ 0 - 2
frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift

@@ -8,11 +8,9 @@ import Foundation
 import path_provider_macos
 import rich_clipboard_macos
 import url_launcher_macos
-import wakelock_macos
 
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
   PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
   RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin"))
   UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
-  WakelockMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockMacosPlugin"))
 }

+ 0 - 6
frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock

@@ -6,15 +6,12 @@ PODS:
     - FlutterMacOS
   - url_launcher_macos (0.0.1):
     - FlutterMacOS
-  - wakelock_macos (0.0.1):
-    - FlutterMacOS
 
 DEPENDENCIES:
   - FlutterMacOS (from `Flutter/ephemeral`)
   - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`)
   - rich_clipboard_macos (from `Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos`)
   - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
-  - wakelock_macos (from `Flutter/ephemeral/.symlinks/plugins/wakelock_macos/macos`)
 
 EXTERNAL SOURCES:
   FlutterMacOS:
@@ -25,15 +22,12 @@ EXTERNAL SOURCES:
     :path: Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos
   url_launcher_macos:
     :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
-  wakelock_macos:
-    :path: Flutter/ephemeral/.symlinks/plugins/wakelock_macos/macos
 
 SPEC CHECKSUMS:
   FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424
   path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19
   rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c
   url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3
-  wakelock_macos: bc3f2a9bd8d2e6c89fee1e1822e7ddac3bd004a9
 
 PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c
 

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml

@@ -37,12 +37,12 @@ dependencies:
     path: ../
   provider: ^6.0.3
   url_launcher: ^6.1.5
-  video_player: ^2.4.5
-  pod_player: 0.0.8
   path_provider: ^2.0.11
   google_fonts: ^3.0.1
   flutter_localizations:
     sdk: flutter
+  file_picker: ^5.0.1
+  universal_html: ^2.0.8
 
 dev_dependencies:
   flutter_test:

+ 36 - 31
frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart

@@ -193,16 +193,24 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
     return parent!._path([index, ...previous]);
   }
 
-  Node deepClone() {
-    final newNode = Node(
-        type: type, children: LinkedList<Node>(), attributes: {...attributes});
-
-    for (final node in children) {
-      final newNode = node.deepClone();
-      newNode.parent = this;
-      newNode.children.add(newNode);
+  Node copyWith({
+    String? type,
+    LinkedList<Node>? children,
+    Attributes? attributes,
+  }) {
+    final node = Node(
+      type: type ?? this.type,
+      attributes: attributes ?? {..._attributes},
+      children: children ?? LinkedList(),
+    );
+    if (children == null && this.children.isNotEmpty) {
+      for (final child in this.children) {
+        node.children.add(
+          child.copyWith()..parent = node,
+        );
+      }
     }
-    return newNode;
+    return node;
   }
 }
 
@@ -215,7 +223,10 @@ class TextNode extends Node {
     LinkedList<Node>? children,
     Attributes? attributes,
   })  : _delta = delta,
-        super(children: children ?? LinkedList(), attributes: attributes ?? {});
+        super(
+          children: children ?? LinkedList(),
+          attributes: attributes ?? {},
+        );
 
   TextNode.empty({Attributes? attributes})
       : _delta = Delta([TextInsert('')]),
@@ -241,33 +252,27 @@ class TextNode extends Node {
     return map;
   }
 
+  @override
   TextNode copyWith({
     String? type,
     LinkedList<Node>? children,
     Attributes? attributes,
     Delta? delta,
-  }) =>
-      TextNode(
-        type: type ?? this.type,
-        children: children ?? this.children,
-        attributes: attributes ?? _attributes,
-        delta: delta ?? this.delta,
-      );
-
-  @override
-  TextNode deepClone() {
-    final newNode = TextNode(
-        type: type,
-        children: LinkedList<Node>(),
-        delta: delta.slice(0),
-        attributes: {...attributes});
-
-    for (final node in children) {
-      final newNode = node.deepClone();
-      newNode.parent = this;
-      newNode.children.add(newNode);
+  }) {
+    final textNode = TextNode(
+      type: type ?? this.type,
+      children: children,
+      attributes: attributes ?? _attributes,
+      delta: delta ?? this.delta,
+    );
+    if (children == null && this.children.isNotEmpty) {
+      for (final child in this.children) {
+        textNode.children.add(
+          child.copyWith()..parent = textNode,
+        );
+      }
     }
-    return newNode;
+    return textNode;
   }
 
   String toRawString() => _delta.toRawString();

+ 2 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart

@@ -40,11 +40,9 @@ class Selection {
   bool get isCollapsed => start == end;
   bool get isSingle => pathEquals(start.path, end.path);
   bool get isForward =>
-      (start.path >= end.path && !pathEquals(start.path, end.path)) ||
-      (isSingle && start.offset > end.offset);
+      (start.path > end.path) || (isSingle && start.offset > end.offset);
   bool get isBackward =>
-      (start.path <= end.path && !pathEquals(start.path, end.path)) ||
-      (isSingle && start.offset < end.offset);
+      (start.path < end.path) || (isSingle && start.offset < end.offset);
 
   Selection get normalize {
     if (isForward) {

+ 30 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/path_extensions.dart

@@ -4,22 +4,52 @@ import 'dart:math';
 
 extension PathExtensions on Path {
   bool operator >=(Path other) {
+    if (pathEquals(this, other)) {
+      return true;
+    }
+    return this > other;
+  }
+
+  bool operator >(Path other) {
+    if (pathEquals(this, other)) {
+      return false;
+    }
     final length = min(this.length, other.length);
     for (var i = 0; i < length; i++) {
       if (this[i] < other[i]) {
         return false;
+      } else if (this[i] > other[i]) {
+        return true;
       }
     }
+    if (this.length < other.length) {
+      return false;
+    }
     return true;
   }
 
   bool operator <=(Path other) {
+    if (pathEquals(this, other)) {
+      return true;
+    }
+    return this < other;
+  }
+
+  bool operator <(Path other) {
+    if (pathEquals(this, other)) {
+      return false;
+    }
     final length = min(this.length, other.length);
     for (var i = 0; i < length; i++) {
       if (this[i] > other[i]) {
         return false;
+      } else if (this[i] < other[i]) {
+        return true;
       }
     }
+    if (this.length > other.length) {
+      return false;
+    }
     return true;
   }
 

+ 46 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/infra/infra.dart

@@ -0,0 +1,46 @@
+import 'package:appflowy_editor/src/document/node.dart';
+
+class Infra {
+// find the forward nearest text node
+  static TextNode? forwardNearestTextNode(Node node) {
+    var previous = node.previous;
+    while (previous != null) {
+      final lastTextNode = findLastTextNode(previous);
+      if (lastTextNode != null) {
+        return lastTextNode;
+      }
+      if (previous is TextNode) {
+        return previous;
+      }
+      previous = previous.previous;
+    }
+    final parent = node.parent;
+    if (parent != null) {
+      if (parent is TextNode) {
+        return parent;
+      }
+      return forwardNearestTextNode(parent);
+    }
+    return null;
+  }
+
+  // find the last text node
+  static TextNode? findLastTextNode(Node node) {
+    final children = node.children.toList(growable: false).reversed;
+    for (final child in children) {
+      if (child.children.isNotEmpty) {
+        final result = findLastTextNode(child);
+        if (result != null) {
+          return result;
+        }
+      }
+      if (child is TextNode) {
+        return child;
+      }
+    }
+    if (node is TextNode) {
+      return node;
+    }
+    return null;
+  }
+}

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

@@ -36,7 +36,7 @@ class TransactionBuilder {
   /// Inserts a sequence of nodes at the position of path.
   insertNodes(Path path, List<Node> nodes) {
     beforeSelection = state.cursorSelection;
-    add(InsertOperation(path, nodes.map((node) => node.deepClone()).toList()));
+    add(InsertOperation(path, nodes.map((node) => node.copyWith()).toList()));
   }
 
   /// Updates the attributes of nodes.
@@ -75,7 +75,7 @@ class TransactionBuilder {
       nodes.add(node);
     }
 
-    add(DeleteOperation(path, nodes.map((node) => node.deepClone()).toList()));
+    add(DeleteOperation(path, nodes.map((node) => node.copyWith()).toList()));
   }
 
   textEdit(TextNode node, Delta Function() f) {

+ 56 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/built_in_text_widget.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
 import 'package:flutter/material.dart';
 
 abstract class BuiltInTextWidget extends StatefulWidget {
@@ -59,3 +60,58 @@ mixin BuiltInStyleMixin<T extends BuiltInTextWidget> on State<T> {
     return const EdgeInsets.all(0);
   }
 }
+
+mixin BuiltInTextWidgetMixin<T extends BuiltInTextWidget> on State<T>
+    implements DefaultSelectable {
+  @override
+  Widget build(BuildContext context) {
+    if (widget.textNode.children.isEmpty) {
+      return buildWithSingle(context);
+    } else {
+      return buildWithChildren(context);
+    }
+  }
+
+  Widget buildWithSingle(BuildContext context);
+
+  Widget buildWithChildren(BuildContext context) {
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        buildWithSingle(context),
+        Row(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            // TODO: customize
+            const SizedBox(
+              width: 20,
+            ),
+            Expanded(
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: widget.textNode.children
+                    .map(
+                      (child) => widget.editorState.service.renderPluginService
+                          .buildPluginWidget(
+                        child is TextNode
+                            ? NodeWidgetContext<TextNode>(
+                                context: context,
+                                node: child,
+                                editorState: widget.editorState,
+                              )
+                            : NodeWidgetContext<Node>(
+                                context: context,
+                                node: child,
+                                editorState: widget.editorState,
+                              ),
+                      ),
+                    )
+                    .toList(),
+              ),
+            )
+          ],
+        )
+      ],
+    );
+  }
+}

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

@@ -45,7 +45,11 @@ class BulletedListTextNodeWidget extends BuiltInTextWidget {
 // customize
 
 class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
-    with SelectableMixin, DefaultSelectable, BuiltInStyleMixin {
+    with
+        SelectableMixin,
+        DefaultSelectable,
+        BuiltInStyleMixin,
+        BuiltInTextWidgetMixin {
   @override
   final iconKey = GlobalKey();
 
@@ -61,7 +65,7 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
   }
 
   @override
-  Widget build(BuildContext context) {
+  Widget buildWithSingle(BuildContext context) {
     return Padding(
       padding: padding,
       child: Row(

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

@@ -46,7 +46,11 @@ class CheckboxNodeWidget extends BuiltInTextWidget {
 }
 
 class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
-    with SelectableMixin, DefaultSelectable, BuiltInStyleMixin {
+    with
+        SelectableMixin,
+        DefaultSelectable,
+        BuiltInStyleMixin,
+        BuiltInTextWidgetMixin {
   @override
   final iconKey = GlobalKey();
 
@@ -62,15 +66,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
   }
 
   @override
-  Widget build(BuildContext context) {
-    if (widget.textNode.children.isEmpty) {
-      return _buildWithSingle(context);
-    } else {
-      return _buildWithChildren(context);
-    }
-  }
-
-  Widget _buildWithSingle(BuildContext context) {
+  Widget buildWithSingle(BuildContext context) {
     final check = widget.textNode.attributes.check;
     return Padding(
       padding: padding,
@@ -106,40 +102,4 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
       ),
     );
   }
-
-  Widget _buildWithChildren(BuildContext context) {
-    return Column(
-      crossAxisAlignment: CrossAxisAlignment.start,
-      children: [
-        _buildWithSingle(context),
-        Row(
-          children: [
-            const SizedBox(
-              width: 20,
-            ),
-            Column(
-              children: widget.textNode.children
-                  .map(
-                    (child) => widget.editorState.service.renderPluginService
-                        .buildPluginWidget(
-                      child is TextNode
-                          ? NodeWidgetContext<TextNode>(
-                              context: context,
-                              node: child,
-                              editorState: widget.editorState,
-                            )
-                          : NodeWidgetContext<Node>(
-                              context: context,
-                              node: child,
-                              editorState: widget.editorState,
-                            ),
-                    ),
-                  )
-                  .toList(),
-            )
-          ],
-        )
-      ],
-    );
-  }
 }

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

@@ -43,7 +43,11 @@ class RichTextNodeWidget extends BuiltInTextWidget {
 // customize
 
 class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
-    with SelectableMixin, DefaultSelectable, BuiltInStyleMixin {
+    with
+        SelectableMixin,
+        DefaultSelectable,
+        BuiltInStyleMixin,
+        BuiltInTextWidgetMixin {
   @override
   GlobalKey? get iconKey => null;
 
@@ -59,7 +63,7 @@ class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
   }
 
   @override
-  Widget build(BuildContext context) {
+  Widget buildWithSingle(BuildContext context) {
     return Padding(
       padding: padding,
       child: FlowyRichText(

+ 6 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart

@@ -38,6 +38,7 @@ class AppFlowyEditor extends StatefulWidget {
     this.customBuilders = const {},
     this.shortcutEvents = const [],
     this.selectionMenuItems = const [],
+    this.editable = true,
     required this.editorStyle,
   }) : super(key: key);
 
@@ -53,6 +54,8 @@ class AppFlowyEditor extends StatefulWidget {
 
   final EditorStyle editorStyle;
 
+  final bool editable;
+
   @override
   State<AppFlowyEditor> createState() => _AppFlowyEditorState();
 }
@@ -106,11 +109,14 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
           cursorColor: widget.editorStyle.cursorColor,
           selectionColor: widget.editorStyle.selectionColor,
           editorState: editorState,
+          editable: widget.editable,
           child: AppFlowyInput(
             key: editorState.service.inputServiceKey,
             editorState: editorState,
+            editable: widget.editable,
             child: AppFlowyKeyboard(
               key: editorState.service.keyboardServiceKey,
+              editable: widget.editable,
               shortcutEvents: [
                 ...builtInShortcutEvents,
                 ...widget.shortcutEvents,

+ 22 - 6
frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy_editor/src/infra/log.dart';
+import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
@@ -43,11 +44,13 @@ abstract class AppFlowyInputService {
 class AppFlowyInput extends StatefulWidget {
   const AppFlowyInput({
     Key? key,
+    this.editable = true,
     required this.editorState,
     required this.child,
   }) : super(key: key);
 
   final EditorState editorState;
+  final bool editable;
   final Widget child;
 
   @override
@@ -61,26 +64,39 @@ class _AppFlowyInputState extends State<AppFlowyInput>
 
   EditorState get _editorState => widget.editorState;
 
+  // Disable space shortcut on the Web platform.
+  final Map<ShortcutActivator, Intent> _shortcuts = kIsWeb
+      ? {
+          LogicalKeySet(LogicalKeyboardKey.space):
+              DoNothingAndStopPropagationIntent(),
+        }
+      : {};
+
   @override
   void initState() {
     super.initState();
 
-    _editorState.service.selectionService.currentSelection
-        .addListener(_onSelectionChange);
+    if (widget.editable) {
+      _editorState.service.selectionService.currentSelection
+          .addListener(_onSelectionChange);
+    }
   }
 
   @override
   void dispose() {
-    close();
-    _editorState.service.selectionService.currentSelection
-        .removeListener(_onSelectionChange);
+    if (widget.editable) {
+      close();
+      _editorState.service.selectionService.currentSelection
+          .removeListener(_onSelectionChange);
+    }
 
     super.dispose();
   }
 
   @override
   Widget build(BuildContext context) {
-    return Container(
+    return Shortcuts(
+      shortcuts: _shortcuts,
       child: widget.child,
     );
   }

+ 41 - 26
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart

@@ -1,8 +1,9 @@
+import 'package:appflowy_editor/src/infra/infra.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/extensions/path_extensions.dart';
 
 // Handle delete text.
 ShortcutEventHandler deleteTextHandler = (editorState, event) {
@@ -121,32 +122,46 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
 }
 
 KeyEventResult _backDeleteToPreviousTextNode(
-    EditorState editorState,
-    TextNode textNode,
-    TransactionBuilder transactionBuilder,
-    List<Node> nonTextNodes,
-    Selection selection) {
-  var previous = textNode.previous;
+  EditorState editorState,
+  TextNode textNode,
+  TransactionBuilder transactionBuilder,
+  List<Node> nonTextNodes,
+  Selection selection,
+) {
+  if (textNode.next == null &&
+      textNode.children.isEmpty &&
+      textNode.parent?.parent != null) {
+    transactionBuilder
+      ..deleteNode(textNode)
+      ..insertNode(textNode.parent!.path.next, textNode)
+      ..afterSelection = Selection.collapsed(
+        Position(path: textNode.parent!.path.next, offset: 0),
+      )
+      ..commit();
+    return KeyEventResult.handled;
+  }
+
   bool prevIsNumberList = false;
-  while (previous != null) {
-    if (previous is TextNode) {
-      if (previous.subtype == BuiltInAttributeKey.numberList) {
-        prevIsNumberList = true;
-      }
+  final previousTextNode = Infra.forwardNearestTextNode(textNode);
+  if (previousTextNode != null) {
+    if (previousTextNode.subtype == BuiltInAttributeKey.numberList) {
+      prevIsNumberList = true;
+    }
 
-      transactionBuilder
-        ..mergeText(previous, textNode)
-        ..deleteNode(textNode)
-        ..afterSelection = Selection.collapsed(
-          Position(
-            path: previous.path,
-            offset: previous.toRawString().length,
-          ),
-        );
-      break;
-    } else {
-      previous = previous.previous;
+    transactionBuilder.mergeText(previousTextNode, textNode);
+    if (textNode.children.isNotEmpty) {
+      transactionBuilder.insertNodes(
+        previousTextNode.path.next,
+        textNode.children.toList(growable: false),
+      );
     }
+    transactionBuilder.deleteNode(textNode);
+    transactionBuilder.afterSelection = Selection.collapsed(
+      Position(
+        path: previousTextNode.path,
+        offset: previousTextNode.toRawString().length,
+      ),
+    );
   }
 
   if (transactionBuilder.operations.isNotEmpty) {
@@ -157,8 +172,8 @@ KeyEventResult _backDeleteToPreviousTextNode(
   }
 
   if (prevIsNumberList) {
-    makeFollowingNodesIncremental(
-        editorState, previous!.path, transactionBuilder.afterSelection!);
+    makeFollowingNodesIncremental(editorState, previousTextNode!.path,
+        transactionBuilder.afterSelection!);
   }
 
   return KeyEventResult.handled;

+ 25 - 22
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart

@@ -1,9 +1,9 @@
+import 'dart:collection';
+
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
 
 import 'package:appflowy_editor/src/extensions/path_extensions.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
 import './number_list_helper.dart';
 
 /// Handle some cases where enter is pressed and shift is not pressed.
@@ -16,10 +16,6 @@ import './number_list_helper.dart';
 ///   2.2 or insert a empty text node before.
 ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
     (editorState, event) {
-  if (event.logicalKey != LogicalKeyboardKey.enter || event.isShiftPressed) {
-    return KeyEventResult.ignored;
-  }
-
   var selection = editorState.service.selectionService.currentSelection.value;
   var nodes = editorState.service.selectionService.currentSelectedNodes;
   if (selection == null) {
@@ -124,7 +120,10 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
         TransactionBuilder(editorState)
           ..insertNode(
             textNode.path,
-            TextNode.empty(),
+            textNode.copyWith(
+              children: LinkedList(),
+              delta: Delta(),
+            ),
           )
           ..afterSelection = afterSelection
           ..commit();
@@ -142,21 +141,25 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
     Position(path: nextPath, offset: 0),
   );
 
-  TransactionBuilder(editorState)
-    ..insertNode(
-      textNode.path.next,
-      textNode.copyWith(
-        attributes: attributes,
-        delta: textNode.delta.slice(selection.end.offset),
-      ),
-    )
-    ..deleteText(
-      textNode,
-      selection.start.offset,
-      textNode.toRawString().length - selection.start.offset,
-    )
-    ..afterSelection = afterSelection
-    ..commit();
+  final transactionBuilder = TransactionBuilder(editorState);
+  transactionBuilder.insertNode(
+    textNode.path.next,
+    textNode.copyWith(
+      attributes: attributes,
+      delta: textNode.delta.slice(selection.end.offset),
+    ),
+  );
+  transactionBuilder.deleteText(
+    textNode,
+    selection.start.offset,
+    textNode.toRawString().length - selection.start.offset,
+  );
+  if (textNode.children.isNotEmpty) {
+    final children = textNode.children.toList(growable: false);
+    transactionBuilder.deleteNodes(children);
+  }
+  transactionBuilder.afterSelection = afterSelection;
+  transactionBuilder.commit();
 
   // If the new type of a text node is number list,
   // the numbers of the following nodes should be incremental.

+ 34 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart

@@ -0,0 +1,34 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/material.dart';
+
+ShortcutEventHandler tabHandler = (editorState, event) {
+  // Only Supports BulletedList For Now.
+
+  final selection = editorState.service.selectionService.currentSelection.value;
+  final textNodes = editorState.service.selectionService.currentSelectedNodes
+      .whereType<TextNode>();
+  if (textNodes.length != 1 || selection == null || !selection.isSingle) {
+    return KeyEventResult.ignored;
+  }
+
+  final textNode = textNodes.first;
+  final previous = textNode.previous;
+  if (textNode.subtype != BuiltInAttributeKey.bulletedList ||
+      previous == null ||
+      previous.subtype != BuiltInAttributeKey.bulletedList) {
+    return KeyEventResult.handled;
+  }
+
+  final path = previous.path + [previous.children.length];
+  final afterSelection = Selection(
+    start: selection.start.copyWith(path: path),
+    end: selection.end.copyWith(path: path),
+  );
+  TransactionBuilder(editorState)
+    ..deleteNode(textNode)
+    ..insertNode(path, textNode)
+    ..setAfterSelection(afterSelection)
+    ..commit();
+
+  return KeyEventResult.handled;
+};

+ 8 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart

@@ -42,6 +42,7 @@ abstract class AppFlowyKeyboardService {
 class AppFlowyKeyboard extends StatefulWidget {
   const AppFlowyKeyboard({
     Key? key,
+    this.editable = true,
     required this.shortcutEvents,
     required this.editorState,
     required this.child,
@@ -50,6 +51,7 @@ class AppFlowyKeyboard extends StatefulWidget {
   final EditorState editorState;
   final Widget child;
   final List<ShortcutEvent> shortcutEvents;
+  final bool editable;
 
   @override
   State<AppFlowyKeyboard> createState() => _AppFlowyKeyboardState();
@@ -62,7 +64,6 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
   bool isFocus = true;
 
   @override
-  // TODO: implement shortcutEvents
   List<ShortcutEvent> get shortcutEvents => widget.shortcutEvents;
 
   @override
@@ -91,8 +92,12 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
 
   @override
   void enable() {
-    isFocus = true;
-    _focusNode.requestFocus();
+    if (widget.editable) {
+      isFocus = true;
+      _focusNode.requestFocus();
+    } else {
+      disable();
+    }
   }
 
   @override

+ 24 - 9
frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart

@@ -84,6 +84,7 @@ class AppFlowySelection extends StatefulWidget {
     Key? key,
     this.cursorColor = const Color(0xFF00BCF0),
     this.selectionColor = const Color.fromARGB(53, 111, 201, 231),
+    this.editable = true,
     required this.editorState,
     required this.child,
   }) : super(key: key);
@@ -92,6 +93,7 @@ class AppFlowySelection extends StatefulWidget {
   final Widget child;
   final Color cursorColor;
   final Color selectionColor;
+  final bool editable;
 
   @override
   State<AppFlowySelection> createState() => _AppFlowySelectionState();
@@ -144,15 +146,21 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
 
   @override
   Widget build(BuildContext context) {
-    return SelectionGestureDetector(
-      onPanStart: _onPanStart,
-      onPanUpdate: _onPanUpdate,
-      onPanEnd: _onPanEnd,
-      onTapDown: _onTapDown,
-      onDoubleTapDown: _onDoubleTapDown,
-      onTripleTapDown: _onTripleTapDown,
-      child: widget.child,
-    );
+    if (!widget.editable) {
+      return Container(
+        child: widget.child,
+      );
+    } else {
+      return SelectionGestureDetector(
+        onPanStart: _onPanStart,
+        onPanUpdate: _onPanUpdate,
+        onPanEnd: _onPanEnd,
+        onTapDown: _onTapDown,
+        onDoubleTapDown: _onDoubleTapDown,
+        onTripleTapDown: _onTripleTapDown,
+        child: widget.child,
+      );
+    }
   }
 
   @override
@@ -184,6 +192,10 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
 
   @override
   void updateSelection(Selection? selection) {
+    if (!widget.editable) {
+      return;
+    }
+
     selectionRects.clear();
     clearSelection();
 
@@ -323,6 +335,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
 
     // compute the selection in range.
     if (first != null && last != null) {
+      Log.selection.debug('first = $first, last = $last');
       final start =
           first.getSelectionInRange(panStartOffset, panEndOffset).start;
       final end = last.getSelectionInRange(panStartOffset, panEndOffset).end;
@@ -353,6 +366,8 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
     final normalizedSelection = selection.normalize;
     assert(normalizedSelection.isBackward);
 
+    Log.selection.debug('update selection areas, $normalizedSelection');
+
     for (var i = 0; i < backwardNodes.length; i++) {
       final node = backwardNodes[i];
       final selectable = node.selectable;

+ 6 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart

@@ -9,6 +9,7 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_und
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/format_style_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/tab_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart';
 import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart';
 
@@ -243,4 +244,9 @@ List<ShortcutEvent> builtInShortcutEvents = [
     command: 'page down',
     handler: pageDownHandler,
   ),
+  ShortcutEvent(
+    key: 'Tab',
+    command: 'tab',
+    handler: tabHandler,
+  ),
 ];

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

@@ -2,6 +2,7 @@ import 'dart:io';
 
 import 'package:appflowy_editor/src/service/shortcut_event/keybinding.dart';
 import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
+import 'package:flutter/foundation.dart';
 
 /// Defines the implementation of shortcut event.
 class ShortcutEvent {
@@ -56,7 +57,10 @@ class ShortcutEvent {
     String? linuxCommand,
   }) {
     var matched = false;
-    if (Platform.isWindows &&
+    if (kIsWeb && command != null && command.isNotEmpty) {
+      this.command = command;
+      matched = true;
+    } else if (Platform.isWindows &&
         windowsCommand != null &&
         windowsCommand.isNotEmpty) {
       this.command = windowsCommand;

+ 153 - 0
frontend/app_flowy/packages/appflowy_editor/test/document/node_test.dart

@@ -0,0 +1,153 @@
+import 'dart:collection';
+
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  group('node.dart', () {
+    test('test node copyWith', () {
+      final node = Node(
+        type: 'example',
+        children: LinkedList(),
+        attributes: {
+          'example': 'example',
+        },
+      );
+      expect(node.toJson(), {
+        'type': 'example',
+        'attributes': {
+          'example': 'example',
+        },
+      });
+      expect(
+        node.copyWith().toJson(),
+        node.toJson(),
+      );
+
+      final nodeWithChildren = Node(
+        type: 'example',
+        children: LinkedList()..add(node),
+        attributes: {
+          'example': 'example',
+        },
+      );
+      expect(nodeWithChildren.toJson(), {
+        'type': 'example',
+        'attributes': {
+          'example': 'example',
+        },
+        'children': [
+          {
+            'type': 'example',
+            'attributes': {
+              'example': 'example',
+            },
+          },
+        ],
+      });
+      expect(
+        nodeWithChildren.copyWith().toJson(),
+        nodeWithChildren.toJson(),
+      );
+    });
+
+    test('test textNode copyWith', () {
+      final textNode = TextNode(
+        type: 'example',
+        children: LinkedList(),
+        attributes: {
+          'example': 'example',
+        },
+        delta: Delta()..insert('AppFlowy'),
+      );
+      expect(textNode.toJson(), {
+        'type': 'example',
+        'attributes': {
+          'example': 'example',
+        },
+        'delta': [
+          {'insert': 'AppFlowy'},
+        ],
+      });
+      expect(
+        textNode.copyWith().toJson(),
+        textNode.toJson(),
+      );
+
+      final textNodeWithChildren = TextNode(
+        type: 'example',
+        children: LinkedList()..add(textNode),
+        attributes: {
+          'example': 'example',
+        },
+        delta: Delta()..insert('AppFlowy'),
+      );
+      expect(textNodeWithChildren.toJson(), {
+        'type': 'example',
+        'attributes': {
+          'example': 'example',
+        },
+        'delta': [
+          {'insert': 'AppFlowy'},
+        ],
+        'children': [
+          {
+            'type': 'example',
+            'attributes': {
+              'example': 'example',
+            },
+            'delta': [
+              {'insert': 'AppFlowy'},
+            ],
+          },
+        ],
+      });
+      expect(
+        textNodeWithChildren.copyWith().toJson(),
+        textNodeWithChildren.toJson(),
+      );
+    });
+
+    test('test node path', () {
+      Node previous = Node(
+        type: 'example',
+        attributes: {},
+        children: LinkedList(),
+      );
+      const len = 10;
+      for (var i = 0; i < len; i++) {
+        final node = Node(
+          type: 'example_$i',
+          attributes: {},
+          children: LinkedList(),
+        );
+        previous.children.add(node..parent = previous);
+        previous = node;
+      }
+      expect(previous.path, List.filled(len, 0));
+    });
+
+    test('test copy with', () {
+      final child = Node(
+        type: 'child',
+        attributes: {},
+        children: LinkedList(),
+      );
+      final base = Node(
+        type: 'base',
+        attributes: {},
+        children: LinkedList()..add(child),
+      );
+      final node = base.copyWith(
+        type: 'node',
+      );
+      expect(identical(node.attributes, base.attributes), false);
+      expect(identical(node.children, base.children), false);
+      expect(identical(node.children.first, base.children.first), false);
+    });
+  });
+}

+ 38 - 0
frontend/app_flowy/packages/appflowy_editor/test/extensions/path_extensions_test.dart

@@ -0,0 +1,38 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:appflowy_editor/src/extensions/path_extensions.dart';
+
+void main() async {
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  group('path_extensions.dart', () {
+    test('test path equality', () {
+      var p1 = [0, 0];
+      var p2 = [0];
+
+      expect(p1 > p2, true);
+      expect(p1 >= p2, true);
+      expect(p1 < p2, false);
+      expect(p1 <= p2, false);
+
+      p1 = [1, 1, 2];
+      p2 = [1, 1, 3];
+
+      expect(p2 > p1, true);
+      expect(p2 >= p1, true);
+      expect(p2 < p1, false);
+      expect(p2 <= p1, false);
+
+      p1 = [2, 0, 1];
+      p2 = [2, 0, 1];
+
+      expect(p2 > p1, false);
+      expect(p1 > p2, false);
+      expect(p2 >= p1, true);
+      expect(p2 <= p1, true);
+      expect(pathEquals(p1, p2), true);
+    });
+  });
+}

+ 51 - 0
frontend/app_flowy/packages/appflowy_editor/test/infra/infra_test.dart

@@ -0,0 +1,51 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/infra/infra.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('infra.dart', () {
+    test('find the last text node', () {
+      // * Welcome to Appflowy 😁
+      //  * Welcome to Appflowy 😁
+      //  * Welcome to Appflowy 😁
+      //    * Welcome to Appflowy 😁
+      //    * Welcome to Appflowy 😁
+      //      * Welcome to Appflowy 😁
+      //      * Welcome to Appflowy 😁
+      const text = 'Welcome to Appflowy 😁';
+      TextNode textNode() {
+        return TextNode(
+          type: 'text',
+          delta: Delta()..insert(text),
+        );
+      }
+
+      final node110 = textNode();
+      final node111 = textNode();
+      final node11 = textNode()
+        ..insert(node110)
+        ..insert(node111);
+      final node10 = textNode();
+      final node1 = textNode()
+        ..insert(node10)
+        ..insert(node11);
+      final node0 = textNode();
+      final node = textNode()
+        ..insert(node0)
+        ..insert(node1);
+
+      expect(Infra.findLastTextNode(node)?.path, [1, 1, 1]);
+      expect(Infra.findLastTextNode(node0)?.path, [0]);
+      expect(Infra.findLastTextNode(node1)?.path, [1, 1, 1]);
+      expect(Infra.findLastTextNode(node10)?.path, [1, 0]);
+      expect(Infra.findLastTextNode(node11)?.path, [1, 1, 1]);
+
+      expect(Infra.forwardNearestTextNode(node111)?.path, [1, 1, 0]);
+      expect(Infra.forwardNearestTextNode(node110)?.path, [1, 1]);
+      expect(Infra.forwardNearestTextNode(node11)?.path, [1, 0]);
+      expect(Infra.forwardNearestTextNode(node10)?.path, [1]);
+      expect(Infra.forwardNearestTextNode(node1)?.path, [0]);
+      expect(Infra.forwardNearestTextNode(node0)?.path, []);
+    });
+  });
+}

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

@@ -19,6 +19,7 @@ class EditorWidgetTester {
   EditorState get editorState => _editorState;
   Node get root => _editorState.document.root;
 
+  StateTree get document => _editorState.document;
   int get documentLength => _editorState.document.root.children.length;
   Selection? get documentSelection =>
       _editorState.service.selectionService.currentSelection.value;

+ 137 - 1
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart

@@ -1,10 +1,12 @@
+import 'dart:collection';
+
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
+import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:network_image_mock/network_image_mock.dart';
 import '../../infra/test_editor.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
 
 void main() async {
   setUpAll(() {
@@ -267,6 +269,140 @@ void main() async {
       BuiltInAttributeKey.h1,
     );
   });
+
+  testWidgets('Delete the nested bulleted list', (tester) async {
+    // * Welcome to Appflowy 😁
+    //  * Welcome to Appflowy 😁
+    //    * Welcome to Appflowy 😁
+    const text = 'Welcome to Appflowy 😁';
+    final node = TextNode(
+      type: 'text',
+      delta: Delta()..insert(text),
+      attributes: {
+        BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
+      },
+    );
+    node.insert(
+      node.copyWith()
+        ..insert(
+          node.copyWith(),
+        ),
+    );
+
+    final editor = tester.editor..insert(node);
+    await editor.startTesting();
+
+    // * Welcome to Appflowy 😁
+    //  * Welcome to Appflowy 😁
+    // Welcome to Appflowy 😁
+    await editor.updateSelection(
+      Selection.single(path: [0, 0, 0], startOffset: 0),
+    );
+    await editor.pressLogicKey(LogicalKeyboardKey.backspace);
+    expect(editor.nodeAtPath([0, 0, 0])?.subtype, null);
+    await editor.updateSelection(
+      Selection.single(path: [0, 0, 0], startOffset: 0),
+    );
+    await editor.pressLogicKey(LogicalKeyboardKey.backspace);
+    expect(editor.nodeAtPath([0, 1]) != null, true);
+    await editor.updateSelection(
+      Selection.single(path: [0, 1], startOffset: 0),
+    );
+    await editor.pressLogicKey(LogicalKeyboardKey.backspace);
+    expect(editor.nodeAtPath([1]) != null, true);
+    await editor.updateSelection(
+      Selection.single(path: [1], startOffset: 0),
+    );
+
+    // * Welcome to Appflowy 😁
+    //  * Welcome to Appflowy 😁Welcome to Appflowy 😁
+    await editor.pressLogicKey(LogicalKeyboardKey.backspace);
+    expect(
+      editor.documentSelection,
+      Selection.single(path: [0, 0], startOffset: text.length),
+    );
+    expect((editor.nodeAtPath([0, 0]) as TextNode).toRawString(), text * 2);
+  });
+
+  testWidgets('Delete the complicated nested bulleted list', (tester) async {
+    // * Welcome to Appflowy 😁
+    //  * Welcome to Appflowy 😁
+    //  * Welcome to Appflowy 😁
+    //    * Welcome to Appflowy 😁
+    //    * Welcome to Appflowy 😁
+    const text = 'Welcome to Appflowy 😁';
+    final node = TextNode(
+      type: 'text',
+      delta: Delta()..insert(text),
+      attributes: {
+        BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
+      },
+    );
+
+    node
+      ..insert(
+        node.copyWith(children: LinkedList()),
+      )
+      ..insert(
+        node.copyWith(children: LinkedList())
+          ..insert(
+            node.copyWith(children: LinkedList()),
+          )
+          ..insert(
+            node.copyWith(children: LinkedList()),
+          ),
+      );
+
+    final editor = tester.editor..insert(node);
+    await editor.startTesting();
+
+    await editor.updateSelection(
+      Selection.single(path: [0, 1], startOffset: 0),
+    );
+    await editor.pressLogicKey(LogicalKeyboardKey.backspace);
+    expect(
+      editor.nodeAtPath([0, 1])!.subtype != BuiltInAttributeKey.bulletedList,
+      true,
+    );
+    expect(
+      editor.nodeAtPath([0, 1, 0])!.subtype,
+      BuiltInAttributeKey.bulletedList,
+    );
+    expect(
+      editor.nodeAtPath([0, 1, 1])!.subtype,
+      BuiltInAttributeKey.bulletedList,
+    );
+    expect(find.byType(FlowyRichText), findsNWidgets(5));
+
+    // Before
+    // * Welcome to Appflowy 😁
+    //  * Welcome to Appflowy 😁
+    //  Welcome to Appflowy 😁
+    //    * Welcome to Appflowy 😁
+    //    * Welcome to Appflowy 😁
+    // After
+    // * Welcome to Appflowy 😁
+    //  * Welcome to Appflowy 😁Welcome to Appflowy 😁
+    //  * Welcome to Appflowy 😁
+    //  * Welcome to Appflowy 😁
+    await editor.pressLogicKey(LogicalKeyboardKey.backspace);
+    expect(
+      editor.nodeAtPath([0, 0])!.subtype == BuiltInAttributeKey.bulletedList,
+      true,
+    );
+    expect(
+      (editor.nodeAtPath([0, 0]) as TextNode).toRawString() == text * 2,
+      true,
+    );
+    expect(
+      editor.nodeAtPath([0, 1])!.subtype == BuiltInAttributeKey.bulletedList,
+      true,
+    );
+    expect(
+      editor.nodeAtPath([0, 2])!.subtype == BuiltInAttributeKey.bulletedList,
+      true,
+    );
+  });
 }
 
 Future<void> _deleteFirstImage(WidgetTester tester, bool isBackward) async {

+ 151 - 0
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart

@@ -0,0 +1,151 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import '../../infra/test_editor.dart';
+
+void main() async {
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  group('tab_handler.dart', () {
+    testWidgets('press tab in plain text', (tester) async {
+      const text = 'Welcome to Appflowy 😁';
+      final editor = tester.editor
+        ..insertTextNode(text)
+        ..insertTextNode(text);
+      await editor.startTesting();
+      final document = editor.document;
+
+      var selection = Selection.single(path: [0], startOffset: 0);
+      await editor.updateSelection(selection);
+      await editor.pressLogicKey(LogicalKeyboardKey.tab);
+
+      // nothing happens
+      expect(editor.documentSelection, selection);
+      expect(editor.document.toJson(), document.toJson());
+
+      selection = Selection.single(path: [1], startOffset: 0);
+      await editor.updateSelection(selection);
+      await editor.pressLogicKey(LogicalKeyboardKey.tab);
+
+      // nothing happens
+      expect(editor.documentSelection, selection);
+      expect(editor.document.toJson(), document.toJson());
+    });
+
+    testWidgets('press tab in bulleted list', (tester) async {
+      const text = 'Welcome to Appflowy 😁';
+      final editor = tester.editor
+        ..insertTextNode(
+          text,
+          attributes: {
+            BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList
+          },
+        )
+        ..insertTextNode(
+          text,
+          attributes: {
+            BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList
+          },
+        )
+        ..insertTextNode(
+          text,
+          attributes: {
+            BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList
+          },
+        );
+      await editor.startTesting();
+      var document = editor.document;
+
+      var selection = Selection.single(path: [0], startOffset: 0);
+      await editor.updateSelection(selection);
+      await editor.pressLogicKey(LogicalKeyboardKey.tab);
+
+      // nothing happens
+      expect(editor.documentSelection, selection);
+      expect(editor.document.toJson(), document.toJson());
+
+      // Before
+      // * Welcome to Appflowy 😁
+      // * Welcome to Appflowy 😁
+      // * Welcome to Appflowy 😁
+      // After
+      // * Welcome to Appflowy 😁
+      //  * Welcome to Appflowy 😁
+      //  * Welcome to Appflowy 😁
+
+      selection = Selection.single(path: [1], startOffset: 0);
+      await editor.updateSelection(selection);
+
+      await editor.pressLogicKey(LogicalKeyboardKey.tab);
+
+      expect(
+        editor.documentSelection,
+        Selection.single(path: [0, 0], startOffset: 0),
+      );
+      expect(editor.nodeAtPath([0])!.subtype, BuiltInAttributeKey.bulletedList);
+      expect(editor.nodeAtPath([1])!.subtype, BuiltInAttributeKey.bulletedList);
+      expect(editor.nodeAtPath([2]), null);
+      expect(
+          editor.nodeAtPath([0, 0])!.subtype, BuiltInAttributeKey.bulletedList);
+
+      selection = Selection.single(path: [1], startOffset: 0);
+      await editor.updateSelection(selection);
+      await editor.pressLogicKey(LogicalKeyboardKey.tab);
+
+      expect(
+        editor.documentSelection,
+        Selection.single(path: [0, 1], startOffset: 0),
+      );
+      expect(editor.nodeAtPath([0])!.subtype, BuiltInAttributeKey.bulletedList);
+      expect(editor.nodeAtPath([1]), null);
+      expect(editor.nodeAtPath([2]), null);
+      expect(
+          editor.nodeAtPath([0, 0])!.subtype, BuiltInAttributeKey.bulletedList);
+      expect(
+          editor.nodeAtPath([0, 1])!.subtype, BuiltInAttributeKey.bulletedList);
+
+      // Before
+      // * Welcome to Appflowy 😁
+      //  * Welcome to Appflowy 😁
+      //  * Welcome to Appflowy 😁
+      // After
+      // * Welcome to Appflowy 😁
+      //  * Welcome to Appflowy 😁
+      //    * Welcome to Appflowy 😁
+      document = editor.document;
+      selection = Selection.single(path: [0, 0], startOffset: 0);
+      await editor.updateSelection(selection);
+      await editor.pressLogicKey(LogicalKeyboardKey.tab);
+
+      expect(
+        editor.documentSelection,
+        Selection.single(path: [0, 0], startOffset: 0),
+      );
+      expect(editor.document.toJson(), document.toJson());
+
+      selection = Selection.single(path: [0, 1], startOffset: 0);
+      await editor.updateSelection(selection);
+      await editor.pressLogicKey(LogicalKeyboardKey.tab);
+
+      expect(
+        editor.documentSelection,
+        Selection.single(path: [0, 0, 0], startOffset: 0),
+      );
+      expect(
+        editor.nodeAtPath([0])!.subtype,
+        BuiltInAttributeKey.bulletedList,
+      );
+      expect(
+        editor.nodeAtPath([0, 0])!.subtype,
+        BuiltInAttributeKey.bulletedList,
+      );
+      expect(editor.nodeAtPath([0, 1]), null);
+      expect(
+        editor.nodeAtPath([0, 0, 0])!.subtype,
+        BuiltInAttributeKey.bulletedList,
+      );
+    });
+  });
+}