Переглянути джерело

Merge branch 'main' into feat/markdown_syntax_to_code_text

Lucas.Xu 2 роки тому
батько
коміт
3156568594
100 змінених файлів з 1778 додано та 683 видалено
  1. 25 0
      CHANGELOG.md
  2. 3 2
      frontend/app_flowy/assets/translations/en.json
  3. 1 1
      frontend/app_flowy/assets/translations/fr-FR.json
  4. 22 8
      frontend/app_flowy/assets/translations/zh-CN.json
  5. 7 7
      frontend/app_flowy/lib/plugins/board/application/board_bloc.dart
  6. 5 3
      frontend/app_flowy/lib/plugins/board/board.dart
  7. 6 14
      frontend/app_flowy/lib/plugins/board/presentation/board_page.dart
  8. 18 7
      frontend/app_flowy/lib/plugins/doc/document.dart
  9. 1 1
      frontend/app_flowy/lib/plugins/doc/document_page.dart
  10. 5 2
      frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_format_bloc.dart
  11. 5 3
      frontend/app_flowy/lib/plugins/grid/grid.dart
  12. 5 8
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/url_cell.dart
  13. 3 1
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart
  14. 4 4
      frontend/app_flowy/lib/plugins/trash/menu.dart
  15. 4 5
      frontend/app_flowy/lib/plugins/util.dart
  16. 3 3
      frontend/app_flowy/lib/startup/plugin/plugin.dart
  17. 5 5
      frontend/app_flowy/lib/startup/tasks/app_widget.dart
  18. 5 4
      frontend/app_flowy/lib/user/application/user_settings_service.dart
  19. 79 37
      frontend/app_flowy/lib/workspace/application/appearance.dart
  20. 3 3
      frontend/app_flowy/lib/workspace/application/view/view_listener.dart
  21. 23 29
      frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart
  22. 8 5
      frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart
  23. 1 2
      frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart
  24. 1 2
      frontend/app_flowy/lib/workspace/presentation/settings/settings_dialog.dart
  25. 11 4
      frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart
  26. 5 4
      frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart
  27. 4 3
      frontend/app_flowy/packages/appflowy_board/CHANGELOG.md
  28. 1 1
      frontend/app_flowy/packages/appflowy_board/example/lib/main.dart
  29. 7 0
      frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart
  30. 7 4
      frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart
  31. 36 52
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart
  32. 8 8
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart
  33. 8 7
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group.dart
  34. 14 8
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group_data.dart
  35. 9 25
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart
  36. 48 19
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart
  37. 6 6
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart
  38. 111 58
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart
  39. 1 1
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_mixin.dart
  40. 3 2
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart
  41. 24 20
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart
  42. 1 1
      frontend/app_flowy/packages/appflowy_board/pubspec.yaml
  43. 9 0
      frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart
  44. 277 0
      frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart
  45. 1 0
      frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml
  46. 4 0
      frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart
  47. 35 0
      frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_nl_NL.arb
  48. 34 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart
  49. 83 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart
  50. 67 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart
  51. 43 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart
  52. 4 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart
  53. 4 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart
  54. 5 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart
  55. 1 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart
  56. 8 11
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart
  57. 13 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart
  58. 21 14
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart
  59. 8 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart
  60. 3 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart
  61. 5 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart
  62. 8 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart
  63. 21 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart
  64. 10 3
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart
  65. 2 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart
  66. 12 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart
  67. 5 7
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart
  68. 20 7
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart
  69. 45 0
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart
  70. 12 8
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart
  71. 4 4
      frontend/rust-lib/Cargo.lock
  72. 9 0
      frontend/rust-lib/flowy-folder/src/entities/view.rs
  73. 23 7
      frontend/rust-lib/flowy-folder/src/services/view/controller.rs
  74. 1 1
      frontend/rust-lib/flowy-folder/src/services/view/event_handler.rs
  75. 1 1
      frontend/rust-lib/flowy-grid/Cargo.toml
  76. 8 1
      frontend/rust-lib/flowy-grid/src/entities/cell_entities.rs
  77. 1 0
      frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs
  78. 91 20
      frontend/rust-lib/flowy-grid/src/services/cell/cell_operation.rs
  79. 10 0
      frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs
  80. 11 0
      frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option.rs
  81. 10 1
      frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/format.rs
  82. 5 0
      frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_tests.rs
  83. 29 8
      frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option.rs
  84. 15 0
      frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/select_option.rs
  85. 2 0
      frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/mod.rs
  86. 49 0
      frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_tests.rs
  87. 29 85
      frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_type_option.rs
  88. 10 0
      frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option.rs
  89. 8 3
      frontend/rust-lib/flowy-grid/src/services/grid_editor.rs
  90. 16 6
      frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs
  91. 5 1
      frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs
  92. 45 62
      frontend/rust-lib/flowy-grid/src/services/group/configuration.rs
  93. 30 24
      frontend/rust-lib/flowy-grid/src/services/group/controller.rs
  94. 3 2
      frontend/rust-lib/flowy-grid/src/services/group/entities.rs
  95. 21 3
      frontend/rust-lib/flowy-grid/src/services/group/group_util.rs
  96. 23 1
      frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs
  97. 8 2
      frontend/rust-lib/flowy-user/src/entities/user_setting.rs
  98. 4 4
      shared-lib/Cargo.lock
  99. 1 1
      shared-lib/flowy-grid-data-model/Cargo.toml
  100. 0 8
      shared-lib/flowy-grid-data-model/src/revision/group_rev.rs

+ 25 - 0
CHANGELOG.md

@@ -1,5 +1,30 @@
 # Release Notes
 
+## Version 0.0.5.3 - 09/26/2022
+
+New features
+- Open the next page automatically after deleting the current page
+- Refresh the Kanban board after altering a property type
+
+### Bug Fixes
+- Fix switch board bug
+- Fix delete the Kanban board's row error
+- Remove duplicate time format
+- Fix can't delete field in property edit panel
+- Adjust some display UI issues
+
+
+## Version 0.0.5.2 - 09/16/2022
+
+New features
+- Enable adding a new card to the "No Status" group
+- Fix some bugs
+
+### Bug Fixes
+- Fix cannot open AppFlowy error
+- Fix delete the Kanban board's row error
+
+
 ## Version 0.0.5.1 - 09/14/2022
 
 New features

+ 3 - 2
frontend/app_flowy/assets/translations/en.json

@@ -199,7 +199,8 @@
       "delete": "Delete",
       "textPlaceholder": "Empty",
       "copyProperty": "Copied property to clipboard",
-      "count": "Count"
+      "count": "Count",
+      "newRow": "New row"
     },
     "selectOption": {
       "create": "Create",
@@ -231,4 +232,4 @@
       "create_new_card": "New"
     }
   }
-}
+}

+ 1 - 1
frontend/app_flowy/assets/translations/fr-FR.json

@@ -149,7 +149,7 @@
   "grid": {
     "settings": {
       "filter": "Filtrer",
-      "sortBy": "Trier par",
+      "sortBy": "Filtrer par",
       "Properties": "Propriétés"
     },
     "field": {

+ 22 - 8
frontend/app_flowy/assets/translations/zh-CN.json

@@ -94,7 +94,14 @@
   },
   "tooltip": {
     "lightMode": "切换到亮色模式",
-    "darkMode": "切换到暗色模式"
+    "darkMode": "切换到暗色模式",
+    "openAsPage": "作为页面打开",
+    "addNewRow": "增加一行",
+    "openMenu": "点击打开菜单"
+  },
+  "sideBar": {
+    "openSidebar": "打开侧边栏",
+    "closeSidebar": "关闭侧边栏"
   },
   "notifications": {
     "export": {
@@ -149,15 +156,12 @@
       "darkLabel": "夜间模式"
     }
   },
-  "sideBar": {
-    "openSidebar": "打开侧边栏",
-    "closeSidebar": "关闭侧边栏"
-  },
   "grid": {
     "settings": {
       "filter": "过滤器",
       "sortBy": "排序",
-      "Properties": "属性"
+      "Properties": "属性",
+      "group": "组"
     },
     "field": {
       "hide": "隐藏",
@@ -186,13 +190,17 @@
       "addSelectOption": "添加一个标签",
       "optionTitle": "标签",
       "addOption": "添加标签",
-      "editProperty": "编辑列属性"
+      "editProperty": "编辑列属性",
+      "newColumn": "增加一列",
+      "deleteFieldPromptMessage": "确定要删除这个属性吗? "
     },
     "row": {
       "duplicate": "复制",
       "delete": "删除",
       "textPlaceholder": "空",
-      "copyProperty": "复制列"
+      "copyProperty": "复制列",
+      "count": "数量",
+      "newRow": "添加一行"
     },
     "selectOption": {
       "create": "新建",
@@ -218,5 +226,11 @@
       "timeHintTextInTwelveHour": "01:00 PM",
       "timeHintTextInTwentyFourHour": "13:00"
     }
+  },
+  "board": {
+    "column": {
+      "create_new_card": "新建"
+    },
+    "menuName": "看板"
   }
 }

+ 7 - 7
frontend/app_flowy/lib/plugins/board/application/board_bloc.dart

@@ -36,21 +36,21 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
         super(BoardState.initial(view.id)) {
     boardController = AppFlowyBoardController(
       onMoveGroup: (
-        fromColumnId,
+        fromGroupId,
         fromIndex,
-        toColumnId,
+        toGroupId,
         toIndex,
       ) {
-        _moveGroup(fromColumnId, toColumnId);
+        _moveGroup(fromGroupId, toGroupId);
       },
       onMoveGroupItem: (
-        columnId,
+        groupId,
         fromIndex,
         toIndex,
       ) {
-        final fromRow = groupControllers[columnId]?.rowAtIndex(fromIndex);
-        final toRow = groupControllers[columnId]?.rowAtIndex(toIndex);
-        _moveRow(fromRow, columnId, toRow);
+        final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex);
+        final toRow = groupControllers[groupId]?.rowAtIndex(toIndex);
+        _moveRow(fromRow, groupId, toRow);
       },
       onMoveGroupItemToGroup: (
         fromGroupId,

+ 5 - 3
frontend/app_flowy/lib/plugins/board/board.dart

@@ -68,9 +68,11 @@ class GridPluginDisplay extends PluginDisplay {
   @override
   Widget buildWidget(PluginContext context) {
     notifier.isDeleted.addListener(() {
-      if (notifier.isDeleted.value) {
-        context.onDeleted(view);
-      }
+      notifier.isDeleted.value.fold(() => null, (deletedView) {
+        if (deletedView.hasIndex()) {
+          context.onDeleted(view, deletedView.index);
+        }
+      });
     });
 
     return BoardPage(key: ValueKey(view.id), view: view);

+ 6 - 14
frontend/app_flowy/lib/plugins/board/presentation/board_page.dart

@@ -69,7 +69,6 @@ class BoardContent extends StatefulWidget {
 
 class _BoardContentState extends State<BoardContent> {
   late AppFlowyBoardScrollController scrollManager;
-  final Map<String, ValueKey> cardKeysCache = {};
 
   final config = AppFlowyBoardConfig(
     groupBackgroundColor: HexColor.fromHex('#F7F8FC'),
@@ -104,8 +103,8 @@ class _BoardContentState extends State<BoardContent> {
 
   Widget _buildBoard(BuildContext context) {
     return ChangeNotifierProvider.value(
-      value: Provider.of<AppearanceSettingModel>(context, listen: true),
-      child: Selector<AppearanceSettingModel, AppTheme>(
+      value: Provider.of<AppearanceSetting>(context, listen: true),
+      child: Selector<AppearanceSetting, AppTheme>(
         selector: (ctx, notifier) => notifier.theme,
         builder: (ctx, theme, child) => Expanded(
           child: AppFlowyBoard(
@@ -139,7 +138,7 @@ class _BoardContentState extends State<BoardContent> {
                 .read<BoardBloc>()
                 .add(BoardEvent.endEditRow(editingRow.row.id));
           } else {
-            scrollManager.scrollToBottom(editingRow.columnId, () {
+            scrollManager.scrollToBottom(editingRow.columnId, (boardContext) {
               context
                   .read<BoardBloc>()
                   .add(BoardEvent.endEditRow(editingRow.row.id));
@@ -247,15 +246,8 @@ class _BoardContentState extends State<BoardContent> {
     );
 
     final groupItemId = columnItem.id + group.id;
-    ValueKey? key = cardKeysCache[groupItemId];
-    if (key == null) {
-      final newKey = ValueKey(groupItemId);
-      cardKeysCache[groupItemId] = newKey;
-      key = newKey;
-    }
-
     return AppFlowyGroupCard(
-      key: key,
+      key: ValueKey(groupItemId),
       margin: config.cardPadding,
       decoration: _makeBoxDecoration(context),
       child: BoardCard(
@@ -331,8 +323,8 @@ class _ToolbarBlocAdaptor extends StatelessWidget {
         );
 
         return ChangeNotifierProvider.value(
-          value: Provider.of<AppearanceSettingModel>(context, listen: true),
-          child: Selector<AppearanceSettingModel, AppTheme>(
+          value: Provider.of<AppearanceSetting>(context, listen: true),
+          child: Selector<AppearanceSetting, AppTheme>(
             selector: (ctx, notifier) => notifier.theme,
             builder: (ctx, theme, child) {
               return BoardToolbar(toolbarContext: toolbarContext);

+ 18 - 7
frontend/app_flowy/lib/plugins/doc/document.dart

@@ -74,15 +74,26 @@ class DocumentPlugin extends Plugin<int> {
 class DocumentPluginDisplay extends PluginDisplay with NavigationItem {
   final ViewPluginNotifier notifier;
   ViewPB get view => notifier.view;
+  int? deletedViewIndex;
 
   DocumentPluginDisplay({required this.notifier, Key? key});
 
   @override
-  Widget buildWidget(PluginContext context) => DocumentPage(
-        view: view,
-        onDeleted: () => context.onDeleted(view),
-        key: ValueKey(view.id),
-      );
+  Widget buildWidget(PluginContext context) {
+    notifier.isDeleted.addListener(() {
+      notifier.isDeleted.value.fold(() => null, (deletedView) {
+        if (deletedView.hasIndex()) {
+          deletedViewIndex = deletedView.index;
+        }
+      });
+    });
+
+    return DocumentPage(
+      view: view,
+      onDeleted: () => context.onDeleted(view, deletedViewIndex),
+      key: ValueKey(view.id),
+    );
+  }
 
   @override
   Widget get leftBarItem => ViewLeftBarItem(view: view);
@@ -120,8 +131,8 @@ class DocumentShareButton extends StatelessWidget {
         child: BlocBuilder<DocShareBloc, DocShareState>(
           builder: (context, state) {
             return ChangeNotifierProvider.value(
-              value: Provider.of<AppearanceSettingModel>(context, listen: true),
-              child: Selector<AppearanceSettingModel, Locale>(
+              value: Provider.of<AppearanceSetting>(context, listen: true),
+              child: Selector<AppearanceSetting, Locale>(
                 selector: (ctx, notifier) => notifier.locale,
                 builder: (ctx, _, child) => ConstrainedBox(
                   constraints: const BoxConstraints.expand(

+ 1 - 1
frontend/app_flowy/lib/plugins/doc/document_page.dart

@@ -134,7 +134,7 @@ class _DocumentPageState extends State<DocumentPage> {
 
   Widget _renderToolbar(quill.QuillController controller) {
     return ChangeNotifierProvider.value(
-      value: Provider.of<AppearanceSettingModel>(context, listen: true),
+      value: Provider.of<AppearanceSetting>(context, listen: true),
       child: EditorToolbar.basic(
         controller: controller,
       ),

+ 5 - 2
frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_format_bloc.dart

@@ -11,7 +11,10 @@ class NumberFormatBloc extends Bloc<NumberFormatEvent, NumberFormatState> {
         event.map(setFilter: (_SetFilter value) {
           final List<NumberFormat> formats = List.from(NumberFormat.values);
           if (value.filter.isNotEmpty) {
-            formats.retainWhere((element) => element.title().toLowerCase().contains(value.filter.toLowerCase()));
+            formats.retainWhere((element) => element
+                .title()
+                .toLowerCase()
+                .contains(value.filter.toLowerCase()));
           }
           emit(state.copyWith(formats: formats, filter: value.filter));
         });
@@ -91,7 +94,7 @@ extension NumberFormatExtension on NumberFormat {
       case NumberFormat.Percent:
         return "Percent";
       case NumberFormat.PhilippinePeso:
-        return "Percent";
+        return "PhilippinePeso";
       case NumberFormat.Pound:
         return "Pound";
       case NumberFormat.Rand:

+ 5 - 3
frontend/app_flowy/lib/plugins/grid/grid.dart

@@ -70,9 +70,11 @@ class GridPluginDisplay extends PluginDisplay {
   @override
   Widget buildWidget(PluginContext context) {
     notifier.isDeleted.addListener(() {
-      if (notifier.isDeleted.value) {
-        context.onDeleted(view);
-      }
+      notifier.isDeleted.value.fold(() => null, (deletedView) {
+        if (deletedView.hasIndex()) {
+          context.onDeleted(view, deletedView.index);
+        }
+      });
     });
 
     return GridPage(key: ValueKey(view.id), view: view);

+ 5 - 8
frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/url_cell.dart

@@ -97,7 +97,6 @@ class GridURLCell extends GridCellWidget {
 
 class _GridURLCellState extends GridCellState<GridURLCell> {
   final _popoverController = PopoverController();
-  GridURLCellController? _cellContext;
   late URLCellBloc _cellBloc;
 
   @override
@@ -132,6 +131,7 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
             controller: _popoverController,
             constraints: BoxConstraints.loose(const Size(300, 160)),
             direction: PopoverDirection.bottomWithLeftAligned,
+            triggerActions: PopoverTriggerFlags.none,
             offset: const Offset(0, 20),
             child: SizedBox.expand(
               child: GestureDetector(
@@ -144,7 +144,8 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
             ),
             popupBuilder: (BuildContext popoverContext) {
               return URLEditorPopover(
-                cellController: _cellContext!,
+                cellController: widget.cellControllerBuilder.build()
+                    as GridURLCellController,
               );
             },
             onClose: () {
@@ -166,17 +167,13 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
     final uri = Uri.parse(url);
     if (url.isNotEmpty && await canLaunchUrl(uri)) {
       await launchUrl(uri);
-    } else {
-      _cellContext =
-          widget.cellControllerBuilder.build() as GridURLCellController;
-      widget.onCellEditing.value = true;
-      _popoverController.show();
     }
   }
 
   @override
   void requestBeginFocus() {
-    _openUrlOrEdit(_cellBloc.state.url);
+    widget.onCellEditing.value = true;
+    _popoverController.show();
   }
 
   @override

+ 3 - 1
frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart

@@ -1,4 +1,6 @@
+import 'package:app_flowy/generated/locale_keys.g.dart';
 import 'package:app_flowy/plugins/grid/application/grid_bloc.dart';
+import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra/image.dart';
 import 'package:flowy_infra/theme.dart';
 import 'package:flowy_infra_ui/style_widget/button.dart';
@@ -13,7 +15,7 @@ class GridAddRowButton extends StatelessWidget {
   Widget build(BuildContext context) {
     final theme = context.watch<AppTheme>();
     return FlowyButton(
-      text: const FlowyText.medium('New row', fontSize: 12),
+      text: FlowyText.medium(LocaleKeys.grid_row_newRow.tr(), fontSize: 12),
       hoverColor: theme.shader6,
       onTap: () => context.read<GridBloc>().add(const GridEvent.createRow()),
       leftIcon: svgWidget("home/add", color: theme.iconColor),

+ 4 - 4
frontend/app_flowy/lib/plugins/trash/menu.dart

@@ -33,8 +33,8 @@ class MenuTrash extends StatelessWidget {
   Widget _render(BuildContext context) {
     return Row(children: [
       ChangeNotifierProvider.value(
-        value: Provider.of<AppearanceSettingModel>(context, listen: true),
-        child: Selector<AppearanceSettingModel, AppTheme>(
+        value: Provider.of<AppearanceSetting>(context, listen: true),
+        child: Selector<AppearanceSetting, AppTheme>(
           selector: (ctx, notifier) => notifier.theme,
           builder: (ctx, theme, child) => SizedBox(
               width: 16,
@@ -44,8 +44,8 @@ class MenuTrash extends StatelessWidget {
       ),
       const HSpace(6),
       ChangeNotifierProvider.value(
-        value: Provider.of<AppearanceSettingModel>(context, listen: true),
-        child: Selector<AppearanceSettingModel, Locale>(
+        value: Provider.of<AppearanceSetting>(context, listen: true),
+        child: Selector<AppearanceSetting, Locale>(
           selector: (ctx, notifier) => notifier.locale,
           builder: (ctx, _, child) =>
               FlowyText.medium(LocaleKeys.trash_text.tr(), fontSize: 12),

+ 4 - 5
frontend/app_flowy/lib/plugins/util.dart

@@ -1,15 +1,16 @@
 import 'package:app_flowy/startup/plugin/plugin.dart';
 import 'package:app_flowy/workspace/application/view/view_listener.dart';
+import 'package:dartz/dartz.dart';
 import 'package:flowy_sdk/log.dart';
 import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
 import 'package:flutter/material.dart';
 
-class ViewPluginNotifier extends PluginNotifier {
+class ViewPluginNotifier extends PluginNotifier<Option<DeletedViewPB>> {
   final ViewListener? _viewListener;
   ViewPB view;
 
   @override
-  final ValueNotifier<bool> isDeleted = ValueNotifier(false);
+  final ValueNotifier<Option<DeletedViewPB>> isDeleted = ValueNotifier(none());
 
   @override
   final ValueNotifier<int> isDisplayChanged = ValueNotifier(0);
@@ -27,9 +28,7 @@ class ViewPluginNotifier extends PluginNotifier {
       );
     }, onViewMoveToTrash: (result) {
       result.fold(
-        (deletedView) {
-          isDeleted.value = true;
-        },
+        (deletedView) => isDeleted.value = some(deletedView),
         (err) => Log.error(err),
       );
     });

+ 3 - 3
frontend/app_flowy/lib/startup/plugin/plugin.dart

@@ -32,9 +32,9 @@ abstract class Plugin<T> {
   }
 }
 
-abstract class PluginNotifier {
+abstract class PluginNotifier<T> {
   /// Notify if the plugin get deleted
-  ValueNotifier<bool> get isDeleted;
+  ValueNotifier<T> get isDeleted;
 
   /// Notify if the [PluginDisplay]'s content was changed
   ValueNotifier<int> get isDisplayChanged;
@@ -67,7 +67,7 @@ abstract class PluginDisplay with NavigationItem {
 
 class PluginContext {
   // calls when widget of the plugin get deleted
-  final Function(ViewPB) onDeleted;
+  final Function(ViewPB, int?) onDeleted;
 
   PluginContext({required this.onDeleted});
 }

+ 5 - 5
frontend/app_flowy/lib/startup/tasks/app_widget.dart

@@ -17,8 +17,8 @@ class InitAppWidgetTask extends LaunchTask {
   @override
   Future<void> initialize(LaunchContext context) async {
     final widget = context.getIt<EntryPoint>().create();
-    final setting = await UserSettingsService().getAppearanceSettings();
-    final settingModel = AppearanceSettingModel(setting);
+    final setting = await SettingsFFIService().getAppearanceSetting();
+    final settingModel = AppearanceSetting(setting);
     final app = ApplicationWidget(
       settingModel: settingModel,
       child: widget,
@@ -58,7 +58,7 @@ class InitAppWidgetTask extends LaunchTask {
 
 class ApplicationWidget extends StatelessWidget {
   final Widget child;
-  final AppearanceSettingModel settingModel;
+  final AppearanceSetting settingModel;
 
   const ApplicationWidget({
     Key? key,
@@ -75,10 +75,10 @@ class ApplicationWidget extends StatelessWidget {
         const minWidth = 600.0;
         setWindowMinSize(const Size(minWidth, minWidth / ratio));
         settingModel.readLocaleWhenAppLaunch(context);
-        AppTheme theme = context.select<AppearanceSettingModel, AppTheme>(
+        AppTheme theme = context.select<AppearanceSetting, AppTheme>(
           (value) => value.theme,
         );
-        Locale locale = context.select<AppearanceSettingModel, Locale>(
+        Locale locale = context.select<AppearanceSetting, Locale>(
           (value) => value.locale,
         );
 

+ 5 - 4
frontend/app_flowy/lib/user/application/user_settings_service.dart

@@ -4,8 +4,8 @@ import 'package:flowy_sdk/flowy_sdk.dart';
 import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
 import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart';
 
-class UserSettingsService {
-  Future<AppearanceSettingsPB> getAppearanceSettings() async {
+class SettingsFFIService {
+  Future<AppearanceSettingsPB> getAppearanceSetting() async {
     final result = await UserEventGetAppearanceSetting().send();
 
     return result.fold(
@@ -18,7 +18,8 @@ class UserSettingsService {
     );
   }
 
-  Future<Either<Unit, FlowyError>> setAppearanceSettings(AppearanceSettingsPB settings) {
-    return UserEventSetAppearanceSetting(settings).send();
+  Future<Either<Unit, FlowyError>> setAppearanceSetting(
+      AppearanceSettingsPB setting) {
+    return UserEventSetAppearanceSetting(setting).send();
   }
 }

+ 79 - 37
frontend/app_flowy/lib/workspace/application/appearance.dart

@@ -8,72 +8,114 @@ import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart';
 import 'package:flutter/material.dart';
 import 'package:easy_localization/easy_localization.dart';
 
-class AppearanceSettingModel extends ChangeNotifier with EquatableMixin {
-  AppearanceSettingsPB setting;
+/// [AppearanceSetting] is used to modify the appear setting of AppFlowy application. Including the [Locale], [AppTheme], etc.
+class AppearanceSetting extends ChangeNotifier with EquatableMixin {
+  final AppearanceSettingsPB _setting;
   AppTheme _theme;
   Locale _locale;
-  Timer? _saveOperation;
+  Timer? _debounceSaveOperation;
 
-  AppearanceSettingModel(this.setting)
-      : _theme = AppTheme.fromName(name: setting.theme),
-        _locale =
-            Locale(setting.locale.languageCode, setting.locale.countryCode);
+  AppearanceSetting(AppearanceSettingsPB setting)
+      : _setting = setting,
+        _theme = AppTheme.fromName(name: setting.theme),
+        _locale = Locale(
+          setting.locale.languageCode,
+          setting.locale.countryCode,
+        );
 
+  /// Returns the current [AppTheme]
   AppTheme get theme => _theme;
-  Locale get locale => _locale;
 
-  Future<void> save() async {
-    _saveOperation?.cancel();
-    _saveOperation = Timer(const Duration(seconds: 2), () async {
-      await UserSettingsService().setAppearanceSettings(setting);
-    });
-  }
+  /// Returns the current [Locale]
+  Locale get locale => _locale;
 
-  @override
-  List<Object> get props {
-    return [setting.hashCode];
-  }
+  /// Updates the current theme and notify the listeners the theme was changed.
+  /// Do nothing if the passed in themeType equal to the current theme type.
+  ///
+  void setTheme(ThemeType themeType) {
+    if (_theme.ty == themeType) {
+      return;
+    }
 
-  void swapTheme() {
-    final themeType =
-        (_theme.ty == ThemeType.light ? ThemeType.dark : ThemeType.light);
+    _theme = AppTheme.fromType(themeType);
+    _setting.theme = themeTypeToString(themeType);
+    _saveAppearSetting();
 
-    if (_theme.ty != themeType) {
-      _theme = AppTheme.fromType(themeType);
-      setting.theme = themeTypeToString(themeType);
-      notifyListeners();
-      save();
-    }
+    notifyListeners();
   }
 
+  /// Updates the current locale and notify the listeners the locale was changed
+  /// Fallback to [en] locale If the newLocale is not supported.
+  ///
   void setLocale(BuildContext context, Locale newLocale) {
     if (!context.supportedLocales.contains(newLocale)) {
-      Log.warn("Unsupported locale: $newLocale");
+      Log.warn("Unsupported locale: $newLocale, Fallback to locale: en");
       newLocale = const Locale('en');
-      Log.debug("Fallback to locale: $newLocale");
     }
 
     context.setLocale(newLocale);
 
     if (_locale != newLocale) {
       _locale = newLocale;
-      setting.locale.languageCode = _locale.languageCode;
-      setting.locale.countryCode = _locale.countryCode ?? "";
+      _setting.locale.languageCode = _locale.languageCode;
+      _setting.locale.countryCode = _locale.countryCode ?? "";
+      _saveAppearSetting();
+
       notifyListeners();
-      save();
     }
   }
 
-  void readLocaleWhenAppLaunch(BuildContext context) {
-    if (setting.resetAsDefault) {
-      setting.resetAsDefault = false;
-      save();
+  /// Saves key/value setting to disk.
+  /// Removes the key if the passed in value is null
+  void setKeyValue(String key, String? value) {
+    if (key.isEmpty) {
+      Log.warn("The key should not be empty");
+      return;
+    }
 
+    if (value == null) {
+      _setting.settingKeyValue.remove(key);
+    }
+
+    if (_setting.settingKeyValue[key] != value) {
+      if (value == null) {
+        _setting.settingKeyValue.remove(key);
+      } else {
+        _setting.settingKeyValue[key] = value;
+      }
+
+      _saveAppearSetting();
+      notifyListeners();
+    }
+  }
+
+  /// Called when the application launch.
+  /// Uses the device locale when open the application for the first time
+  void readLocaleWhenAppLaunch(BuildContext context) {
+    if (_setting.resetToDefault) {
+      _setting.resetToDefault = false;
+      _saveAppearSetting();
       setLocale(context, context.deviceLocale);
       return;
     }
 
-    // when opening app the first time
     setLocale(context, _locale);
   }
+
+  Future<void> _saveAppearSetting() async {
+    _debounceSaveOperation?.cancel();
+    _debounceSaveOperation = Timer(
+      const Duration(seconds: 1),
+      () {
+        SettingsFFIService().setAppearanceSetting(_setting).then((result) {
+          result.fold((l) => null, (error) => Log.error(error));
+        });
+      },
+    );
+  }
+
+  @override
+  List<Object> get props {
+    return [_setting.hashCode];
+  }
 }

+ 3 - 3
frontend/app_flowy/lib/workspace/application/view/view_listener.dart

@@ -16,7 +16,7 @@ typedef UpdateViewNotifiedValue = Either<ViewPB, FlowyError>;
 // Restore the view from trash
 typedef RestoreViewNotifiedValue = Either<ViewPB, FlowyError>;
 // Move the view to trash
-typedef MoveToTrashNotifiedValue = Either<ViewIdPB, FlowyError>;
+typedef MoveToTrashNotifiedValue = Either<DeletedViewPB, FlowyError>;
 
 class ViewListener {
   StreamSubscription<SubscribeObject>? _subscription;
@@ -98,8 +98,8 @@ class ViewListener {
         break;
       case FolderNotification.ViewMoveToTrash:
         result.fold(
-          (payload) =>
-              _moveToTrashNotifier.value = left(ViewIdPB.fromBuffer(payload)),
+          (payload) => _moveToTrashNotifier.value =
+              left(DeletedViewPB.fromBuffer(payload)),
           (error) => _moveToTrashNotifier.value = right(error),
         );
         break;

+ 23 - 29
frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart

@@ -34,19 +34,6 @@ class HomeScreen extends StatefulWidget {
 }
 
 class _HomeScreenState extends State<HomeScreen> {
-  ViewPB? initialView;
-
-  @override
-  void initState() {
-    super.initState();
-  }
-
-  @override
-  void didUpdateWidget(covariant HomeScreen oldWidget) {
-    initialView = null;
-    super.didUpdateWidget(oldWidget);
-  }
-
   @override
   Widget build(BuildContext context) {
     return MultiBlocProvider(
@@ -129,26 +116,29 @@ class _HomeScreenState extends State<HomeScreen> {
       required BuildContext context,
       required HomeState state}) {
     final workspaceSetting = state.workspaceSetting;
-    if (initialView == null && workspaceSetting.hasLatestView()) {
-      initialView = workspaceSetting.latestView;
-      final plugin = makePlugin(
-        pluginType: initialView!.pluginType,
-        data: initialView,
-      );
-      getIt<HomeStackManager>().setPlugin(plugin);
-    }
-
     final homeMenu = HomeMenu(
       user: widget.user,
       workspaceSetting: workspaceSetting,
       collapsedNotifier: getIt<HomeStackManager>().collapsedNotifier,
     );
 
-    final latestView =
-        workspaceSetting.hasLatestView() ? workspaceSetting.latestView : null;
-    if (getIt<MenuSharedState>().latestOpenView == null) {
-      /// AppFlowy will open the view that the last time the user opened it. The _buildHomeMenu will get called when AppFlowy's screen resizes. So we only set the latestOpenView when it's null.
-      getIt<MenuSharedState>().latestOpenView = latestView;
+    // Only open the last opened view if the [HomeStackManager] current opened
+    // plugin is blank and the last opened view is not null.
+    //
+    // All opened widgets that display on the home screen are in the form
+    // of plugins. There is a list of built-in plugins defined in the
+    // [PluginType] enum, including board, grid and trash.
+    if (getIt<HomeStackManager>().plugin.ty == PluginType.blank) {
+      // Open the last opened view.
+      if (workspaceSetting.hasLatestView()) {
+        final view = workspaceSetting.latestView;
+        final plugin = makePlugin(
+          pluginType: view.pluginType,
+          data: view,
+        );
+        getIt<HomeStackManager>().setPlugin(plugin);
+        getIt<MenuSharedState>().latestOpenView = view;
+      }
     }
 
     return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu));
@@ -261,14 +251,18 @@ class HomeScreenStackAdaptor extends HomeStackDelegate {
   });
 
   @override
-  void didDeleteStackWidget(ViewPB view) {
+  void didDeleteStackWidget(ViewPB view, int? index) {
     final homeService = HomeService();
     homeService.readApp(appId: view.appId).then((result) {
       result.fold(
         (appPB) {
           final List<ViewPB> views = appPB.belongings.items;
           if (views.isNotEmpty) {
-            final lastView = views.last;
+            var lastView = views.last;
+            if (index != null && index != 0 && views.length > index - 1) {
+              lastView = views[index - 1];
+            }
+
             final plugin = makePlugin(
               pluginType: lastView.pluginType,
               data: lastView,

+ 8 - 5
frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart

@@ -18,7 +18,7 @@ import 'home_layout.dart';
 typedef NavigationCallback = void Function(String id);
 
 abstract class HomeStackDelegate {
-  void didDeleteStackWidget(ViewPB view);
+  void didDeleteStackWidget(ViewPB view, int? index);
 }
 
 class HomeStack extends StatelessWidget {
@@ -41,9 +41,11 @@ class HomeStack extends StatelessWidget {
           child: Container(
             color: theme.surface,
             child: FocusTraversalGroup(
-              child: getIt<HomeStackManager>().stackWidget(onDeleted: (view) {
-                delegate.didDeleteStackWidget(view);
-              }),
+              child: getIt<HomeStackManager>().stackWidget(
+                onDeleted: (view, index) {
+                  delegate.didDeleteStackWidget(view, index);
+                },
+              ),
             ),
           ),
         ),
@@ -144,6 +146,7 @@ class HomeStackManager {
   }
 
   PublishNotifier<bool> get collapsedNotifier => _notifier.collapsedNotifier;
+  Plugin get plugin => _notifier.plugin;
 
   void setPlugin(Plugin newPlugin) {
     _notifier.plugin = newPlugin;
@@ -167,7 +170,7 @@ class HomeStackManager {
     );
   }
 
-  Widget stackWidget({required Function(ViewPB) onDeleted}) {
+  Widget stackWidget({required Function(ViewPB, int?) onDeleted}) {
     return MultiProvider(
       providers: [ChangeNotifierProvider.value(value: _notifier)],
       child: Consumer(builder: (ctx, HomeStackNotifier notifier, child) {

+ 1 - 2
frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart

@@ -89,8 +89,7 @@ class _MenuAppState extends State<MenuApp> {
                 hasIcon: false,
               ),
               header: ChangeNotifierProvider.value(
-                value:
-                    Provider.of<AppearanceSettingModel>(context, listen: true),
+                value: Provider.of<AppearanceSetting>(context, listen: true),
                 child: MenuAppHeader(widget.app),
               ),
               expanded: ViewSection(appViewData: viewDataContext),

+ 1 - 2
frontend/app_flowy/lib/workspace/presentation/settings/settings_dialog.dart

@@ -33,8 +33,7 @@ class SettingsDialog extends StatelessWidget {
           ..add(const SettingsDialogEvent.initial()),
         child: BlocBuilder<SettingsDialogBloc, SettingsDialogState>(
             builder: (context, state) => ChangeNotifierProvider.value(
-                  value: Provider.of<AppearanceSettingModel>(context,
-                      listen: true),
+                  value: Provider.of<AppearanceSetting>(context, listen: true),
                   child: FlowyDialog(
                     title: Text(
                       LocaleKeys.settings_title.tr(),

+ 11 - 4
frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart

@@ -13,7 +13,7 @@ class SettingsAppearanceView extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    final theme = context.watch<AppTheme>();
+    final theme = context.read<AppTheme>();
 
     return SingleChildScrollView(
       child: Column(
@@ -30,9 +30,7 @@ class SettingsAppearanceView extends StatelessWidget {
               ),
               Toggle(
                 value: theme.isDark,
-                onChanged: (val) {
-                  context.read<AppearanceSettingModel>().swapTheme();
-                },
+                onChanged: (_) => setTheme(context),
                 style: ToggleStyle.big(theme),
               ),
               Text(
@@ -48,4 +46,13 @@ class SettingsAppearanceView extends StatelessWidget {
       ),
     );
   }
+
+  void setTheme(BuildContext context) {
+    final theme = context.read<AppTheme>();
+    if (theme.isDark) {
+      context.read<AppearanceSetting>().setTheme(ThemeType.light);
+    } else {
+      context.read<AppearanceSetting>().setTheme(ThemeType.dark);
+    }
+  }
 }

+ 5 - 4
frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart

@@ -13,7 +13,7 @@ class SettingsLanguageView extends StatelessWidget {
   Widget build(BuildContext context) {
     context.watch<AppTheme>();
     return ChangeNotifierProvider.value(
-      value: Provider.of<AppearanceSettingModel>(context, listen: true),
+      value: Provider.of<AppearanceSetting>(context, listen: true),
       child: SingleChildScrollView(
         child: Column(
           crossAxisAlignment: CrossAxisAlignment.start,
@@ -43,7 +43,8 @@ class LanguageSelectorDropdown extends StatefulWidget {
   }) : super(key: key);
 
   @override
-  State<LanguageSelectorDropdown> createState() => _LanguageSelectorDropdownState();
+  State<LanguageSelectorDropdown> createState() =>
+      _LanguageSelectorDropdownState();
 }
 
 class _LanguageSelectorDropdownState extends State<LanguageSelectorDropdown> {
@@ -77,10 +78,10 @@ class _LanguageSelectorDropdownState extends State<LanguageSelectorDropdown> {
         ),
         child: DropdownButtonHideUnderline(
           child: DropdownButton<Locale>(
-            value: context.read<AppearanceSettingModel>().locale,
+            value: context.read<AppearanceSetting>().locale,
             onChanged: (val) {
               setState(() {
-                context.read<AppearanceSettingModel>().setLocale(context, val!);
+                context.read<AppearanceSetting>().setLocale(context, val!);
               });
             },
             icon: const Visibility(

+ 4 - 3
frontend/app_flowy/packages/appflowy_board/CHANGELOG.md

@@ -1,3 +1,5 @@
+# 0.0.8
+* Enable drag and drop group  
 # 0.0.7
 * Rename some classes
 * Add documentation
@@ -7,7 +9,7 @@
 
 # 0.0.5
 * Optimize insert card animation
-* Enable insert card at the end of the column
+* Enable insert card at the end of the group 
 * Fix some bugs
 
 # 0.0.4
@@ -24,6 +26,5 @@
 
 # 0.0.1
 
-* Support drag and drop column
-* Support drag and drop column items from one to another
+* Support drag and drop group items from one to another
 

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

@@ -34,7 +34,7 @@ class _MyAppState extends State<MyApp> {
           appBar: AppBar(
             title: const Text('AppFlowy Board'),
           ),
-          body: _examples[_currentIndex],
+          body: Container(color: Colors.white, child: _examples[_currentIndex]),
           bottomNavigationBar: BottomNavigationBar(
             fixedColor: _bottomNavigationColor,
             showSelectedLabels: true,

+ 7 - 0
frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart

@@ -21,8 +21,11 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
     },
   );
 
+  late AppFlowyBoardScrollController boardController;
+
   @override
   void initState() {
+    boardController = AppFlowyBoardScrollController();
     final group1 = AppFlowyGroupData(id: "To Do", name: "To Do", items: [
       TextItem("Card 1"),
       TextItem("Card 2"),
@@ -67,12 +70,16 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
             child: _buildCard(groupItem),
           );
         },
+        boardScrollController: boardController,
         footerBuilder: (context, columnData) {
           return AppFlowyGroupFooter(
             icon: const Icon(Icons.add, size: 20),
             title: const Text('New'),
             height: 50,
             margin: config.groupItemPadding,
+            onAddButtonClick: () {
+              boardController.scrollToBottom(columnData.id, (p0) {});
+            },
           );
         },
         headerBuilder: (context, columnData) {

+ 7 - 4
frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart

@@ -8,25 +8,28 @@ class Log {
 
   static void info(String? message) {
     if (enableLog) {
-      debugPrint('ℹ️[Info]=> $message');
+      debugPrint('AppFlowyBoard: ℹ️[Info]=> $message');
     }
   }
 
   static void debug(String? message) {
     if (enableLog) {
-      debugPrint('🐛[Debug] - ${DateTime.now().second}=> $message');
+      debugPrint(
+          'AppFlowyBoard: 🐛[Debug] - ${DateTime.now().second}=> $message');
     }
   }
 
   static void warn(String? message) {
     if (enableLog) {
-      debugPrint('🐛[Warn] - ${DateTime.now().second} => $message');
+      debugPrint(
+          'AppFlowyBoard: 🐛[Warn] - ${DateTime.now().second} => $message');
     }
   }
 
   static void trace(String? message) {
     if (enableLog) {
-      debugPrint('❗️[Trace] - ${DateTime.now().second}=> $message');
+      debugPrint(
+          'AppFlowyBoard: ❗️[Trace] - ${DateTime.now().second}=> $message');
     }
   }
 }

+ 36 - 52
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart

@@ -13,10 +13,8 @@ import '../rendering/board_overlay.dart';
 class AppFlowyBoardScrollController {
   AppFlowyBoardState? _groupState;
 
-  void scrollToBottom(String groupId, VoidCallback? completed) {
-    _groupState
-        ?.getReorderFlexState(groupId: groupId)
-        ?.scrollToBottom(completed);
+  void scrollToBottom(String groupId, void Function(BuildContext)? completed) {
+    _groupState?.reorderFlexActionMap[groupId]?.scrollToBottom(completed);
   }
 }
 
@@ -133,7 +131,7 @@ class AppFlowyBoard extends StatelessWidget {
             dataController: controller,
             scrollController: scrollController,
             scrollManager: boardScrollController,
-            columnsState: _groupState,
+            groupState: _groupState,
             background: background,
             delegate: _phantomController,
             groupConstraints: groupConstraints,
@@ -158,7 +156,7 @@ class _AppFlowyBoardContent extends StatefulWidget {
   final ReorderFlexConfig reorderFlexConfig;
   final BoxConstraints groupConstraints;
   final AppFlowyBoardScrollController? scrollManager;
-  final AppFlowyBoardState columnsState;
+  final AppFlowyBoardState groupState;
   final AppFlowyBoardCardBuilder cardBuilder;
   final AppFlowyBoardHeaderBuilder? headerBuilder;
   final AppFlowyBoardFooterBuilder? footerBuilder;
@@ -171,7 +169,7 @@ class _AppFlowyBoardContent extends StatefulWidget {
     required this.delegate,
     required this.dataController,
     required this.scrollManager,
-    required this.columnsState,
+    required this.groupState,
     this.scrollController,
     this.background,
     required this.groupConstraints,
@@ -192,8 +190,6 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
       GlobalKey(debugLabel: '$_AppFlowyBoardContent overlay key');
   late BoardOverlayEntry _overlayEntry;
 
-  final Map<String, GlobalObjectKey> _reorderFlexKeys = {};
-
   @override
   void initState() {
     _overlayEntry = BoardOverlayEntry(
@@ -202,7 +198,7 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
           reorderFlexId: widget.dataController.identifier,
           acceptedReorderFlexId: widget.dataController.groupIds,
           delegate: widget.delegate,
-          columnsState: widget.columnsState,
+          columnsState: widget.groupState,
         );
 
         final reorderFlex = ReorderFlex(
@@ -212,7 +208,7 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
           dataSource: widget.dataController,
           direction: Axis.horizontal,
           interceptor: interceptor,
-          reorderable: false,
+          reorderable: true,
           children: _buildColumns(),
         );
 
@@ -257,18 +253,16 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
           dataController: widget.dataController,
         );
 
-        if (_reorderFlexKeys[columnData.id] == null) {
-          _reorderFlexKeys[columnData.id] = GlobalObjectKey(columnData.id);
-        }
+        final reorderFlexAction = ReorderFlexActionImpl();
+        widget.groupState.reorderFlexActionMap[columnData.id] =
+            reorderFlexAction;
 
-        GlobalObjectKey reorderFlexKey = _reorderFlexKeys[columnData.id]!;
         return ChangeNotifierProvider.value(
           key: ValueKey(columnData.id),
           value: widget.dataController.getGroupController(columnData.id),
           child: Consumer<AppFlowyGroupController>(
             builder: (context, value, child) {
               final boardColumn = AppFlowyBoardGroup(
-                reorderFlexKey: reorderFlexKey,
                 // key: PageStorageKey<String>(columnData.id),
                 margin: _marginFromIndex(columnIndex),
                 itemMargin: widget.config.groupItemPadding,
@@ -281,11 +275,11 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
                 onReorder: widget.dataController.moveGroupItem,
                 cornerRadius: widget.config.cornerRadius,
                 backgroundColor: widget.config.groupBackgroundColor,
-                dragStateStorage: widget.columnsState,
-                dragTargetIndexKeyStorage: widget.columnsState,
+                dragStateStorage: widget.groupState,
+                dragTargetKeys: widget.groupState,
+                reorderFlexAction: reorderFlexAction,
               );
 
-              widget.columnsState.addGroup(columnData.id, boardColumn);
               return ConstrainedBox(
                 constraints: widget.groupConstraints,
                 child: boardColumn,
@@ -356,71 +350,61 @@ class AppFlowyGroupContext {
 }
 
 class AppFlowyBoardState extends DraggingStateStorage
-    with ReorderDragTargetIndexKeyStorage {
-  /// Quick access to the [AppFlowyBoardGroup], the [GlobalKey] is bind to the
-  /// AppFlowyBoardGroup's [ReorderFlex] widget.
-  final Map<String, GlobalKey> groupReorderFlexKeys = {};
+    with ReorderDragTargeKeys {
   final Map<String, DraggingState> groupDragStates = {};
-  final Map<String, Map<String, GlobalObjectKey>> groupDragDragTargets = {};
-
-  void addGroup(String groupId, AppFlowyBoardGroup groupWidget) {
-    groupReorderFlexKeys[groupId] = groupWidget.reorderFlexKey;
-  }
+  final Map<String, Map<String, GlobalObjectKey>> groupDragTargetKeys = {};
 
-  ReorderFlexState? getReorderFlexState({required String groupId}) {
-    final flexGlobalKey = groupReorderFlexKeys[groupId];
-    if (flexGlobalKey == null) return null;
-    if (flexGlobalKey.currentState is! ReorderFlexState) return null;
-    final state = flexGlobalKey.currentState as ReorderFlexState;
-    return state;
-  }
-
-  ReorderFlex? getReorderFlex({required String groupId}) {
-    final flexGlobalKey = groupReorderFlexKeys[groupId];
-    if (flexGlobalKey == null) return null;
-    if (flexGlobalKey.currentWidget is! ReorderFlex) return null;
-    final widget = flexGlobalKey.currentWidget as ReorderFlex;
-    return widget;
-  }
+  /// Quick access to the [AppFlowyBoardGroup], the [GlobalKey] is bind to the
+  /// AppFlowyBoardGroup's [ReorderFlex] widget.
+  final Map<String, ReorderFlexActionImpl> reorderFlexActionMap = {};
 
   @override
-  DraggingState? read(String reorderFlexId) {
+  DraggingState? readState(String reorderFlexId) {
     return groupDragStates[reorderFlexId];
   }
 
   @override
-  void write(String reorderFlexId, DraggingState state) {
+  void insertState(String reorderFlexId, DraggingState state) {
     Log.trace('$reorderFlexId Write dragging state: $state');
     groupDragStates[reorderFlexId] = state;
   }
 
   @override
-  void remove(String reorderFlexId) {
+  void removeState(String reorderFlexId) {
     groupDragStates.remove(reorderFlexId);
   }
 
   @override
-  void addKey(
+  void insertDragTarget(
     String reorderFlexId,
     String key,
     GlobalObjectKey<State<StatefulWidget>> value,
   ) {
-    Map<String, GlobalObjectKey>? group = groupDragDragTargets[reorderFlexId];
+    Map<String, GlobalObjectKey>? group = groupDragTargetKeys[reorderFlexId];
     if (group == null) {
       group = {};
-      groupDragDragTargets[reorderFlexId] = group;
+      groupDragTargetKeys[reorderFlexId] = group;
     }
     group[key] = value;
   }
 
   @override
-  GlobalObjectKey<State<StatefulWidget>>? readKey(
-      String reorderFlexId, String key) {
-    Map<String, GlobalObjectKey>? group = groupDragDragTargets[reorderFlexId];
+  GlobalObjectKey<State<StatefulWidget>>? getDragTarget(
+    String reorderFlexId,
+    String key,
+  ) {
+    Map<String, GlobalObjectKey>? group = groupDragTargetKeys[reorderFlexId];
     if (group != null) {
       return group[key];
     } else {
       return null;
     }
   }
+
+  @override
+  void removeDragTarget(String reorderFlexId) {
+    groupDragTargetKeys.remove(reorderFlexId);
+  }
 }
+
+class ReorderFlexActionImpl extends ReorderFlexAction {}

+ 8 - 8
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart

@@ -217,15 +217,15 @@ class AppFlowyBoardController extends ChangeNotifier
     final fromGroupItem = fromGroupController.removeAt(fromGroupIndex);
     if (toGroupController.items.length > toGroupIndex) {
       assert(toGroupController.items[toGroupIndex] is PhantomGroupItem);
-    }
 
-    toGroupController.replace(toGroupIndex, fromGroupItem);
-    onMoveGroupItemToGroup?.call(
-      fromGroupId,
-      fromGroupIndex,
-      toGroupId,
-      toGroupIndex,
-    );
+      toGroupController.replace(toGroupIndex, fromGroupItem);
+      onMoveGroupItemToGroup?.call(
+        fromGroupId,
+        fromGroupIndex,
+        toGroupId,
+        toGroupIndex,
+      );
+    }
   }
 
   @override

+ 8 - 7
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group.dart

@@ -90,21 +90,21 @@ class AppFlowyBoardGroup extends StatefulWidget {
 
   final DraggingStateStorage? dragStateStorage;
 
-  final ReorderDragTargetIndexKeyStorage? dragTargetIndexKeyStorage;
+  final ReorderDragTargeKeys? dragTargetKeys;
 
-  final GlobalObjectKey reorderFlexKey;
+  final ReorderFlexAction? reorderFlexAction;
 
   const AppFlowyBoardGroup({
     Key? key,
-    required this.reorderFlexKey,
     this.headerBuilder,
     this.footerBuilder,
     required this.cardBuilder,
     required this.onReorder,
     required this.dataSource,
     required this.phantomController,
+    this.reorderFlexAction,
     this.dragStateStorage,
-    this.dragTargetIndexKeyStorage,
+    this.dragTargetKeys,
     this.scrollController,
     this.onDragStarted,
     this.onDragEnded,
@@ -112,7 +112,7 @@ class AppFlowyBoardGroup extends StatefulWidget {
     this.itemMargin = EdgeInsets.zero,
     this.cornerRadius = 0.0,
     this.backgroundColor = Colors.transparent,
-  })  : config = const ReorderFlexConfig(setStateWhenEndDrag: false),
+  })  : config = const ReorderFlexConfig(),
         super(key: key);
 
   @override
@@ -146,9 +146,9 @@ class _AppFlowyBoardGroupState extends State<AppFlowyBoardGroup> {
         );
 
         Widget reorderFlex = ReorderFlex(
-          key: widget.reorderFlexKey,
+          key: ValueKey(widget.groupId),
           dragStateStorage: widget.dragStateStorage,
-          dragTargetIndexKeyStorage: widget.dragTargetIndexKeyStorage,
+          dragTargetKeys: widget.dragTargetKeys,
           scrollController: widget.scrollController,
           config: widget.config,
           onDragStarted: (index) {
@@ -168,6 +168,7 @@ class _AppFlowyBoardGroupState extends State<AppFlowyBoardGroup> {
           },
           dataSource: widget.dataSource,
           interceptor: interceptor,
+          reorderFlexAction: widget.reorderFlexAction,
           children: children,
         );
 

+ 14 - 8
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group_data.dart

@@ -41,7 +41,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
   void updateGroupName(String newName) {
     if (groupData.headerData.groupName != newName) {
       groupData.headerData.groupName = newName;
-      notifyListeners();
+      _notify();
     }
   }
 
@@ -56,7 +56,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
     Log.debug('[$AppFlowyGroupController] $groupData remove item at $index');
     final item = groupData._items.removeAt(index);
     if (notify) {
-      notifyListeners();
+      _notify();
     }
     return item;
   }
@@ -81,7 +81,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
         '[$AppFlowyGroupController] $groupData move item from $fromIndex to $toIndex');
     final item = groupData._items.removeAt(fromIndex);
     groupData._items.insert(toIndex, item);
-    notifyListeners();
+    _notify();
     return true;
   }
 
@@ -102,7 +102,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
         groupData._items.add(item);
       }
 
-      if (notify) notifyListeners();
+      if (notify) _notify();
       return true;
     }
   }
@@ -112,7 +112,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
       return false;
     } else {
       groupData._items.add(item);
-      if (notify) notifyListeners();
+      if (notify) _notify();
       return true;
     }
   }
@@ -124,6 +124,8 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
       Log.debug('[$AppFlowyGroupController] $groupData add $newItem');
     } else {
       if (index >= groupData._items.length) {
+        Log.warn(
+            '[$AppFlowyGroupController] unexpected items length, index should less than the count of the items. Index: $index, items count: ${items.length}');
         return;
       }
 
@@ -133,7 +135,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
           '[$AppFlowyGroupController] $groupData replace $removedItem with $newItem at $index');
     }
 
-    notifyListeners();
+    _notify();
   }
 
   void replaceOrInsertItem(AppFlowyGroupItem newItem) {
@@ -141,10 +143,10 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
     if (index != -1) {
       groupData._items.removeAt(index);
       groupData._items.insert(index, newItem);
-      notifyListeners();
+      _notify();
     } else {
       groupData._items.add(newItem);
-      notifyListeners();
+      _notify();
     }
   }
 
@@ -152,6 +154,10 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
     return groupData._items.indexWhere((element) => element.id == item.id) !=
         -1;
   }
+
+  void _notify() {
+    notifyListeners();
+  }
 }
 
 /// [AppFlowyGroupData] represents the data of each group of the Board.

+ 9 - 25
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart

@@ -22,6 +22,8 @@ class FlexDragTargetData extends DragTargetData {
 
   Size? get feedbackSize => _state.feedbackSize;
 
+  bool get isDragging => _state.isDragging();
+
   final String dragTargetId;
 
   Offset dragTargetOffset = Offset.zero;
@@ -48,47 +50,28 @@ class FlexDragTargetData extends DragTargetData {
 
   bool isOverlapWithWidgets(List<GlobalObjectKey> widgetKeys) {
     final renderBox = dragTargetIndexKey.currentContext?.findRenderObject();
-
     if (renderBox == null) return false;
     if (renderBox is! RenderBox) return false;
     final size = feedbackSize ?? Size.zero;
-    final Rect rect = dragTargetOffset & size;
 
+    final Rect dragTargetRect = renderBox.localToGlobal(Offset.zero) & size;
     for (final widgetKey in widgetKeys) {
       final renderObject = widgetKey.currentContext?.findRenderObject();
       if (renderObject != null && renderObject is RenderBox) {
         Rect widgetRect =
             renderObject.localToGlobal(Offset.zero) & renderObject.size;
-        // return rect.overlaps(widgetRect);
-        if (rect.right <= widgetRect.left || widgetRect.right <= rect.left) {
-          return false;
-        }
-
-        if (rect.bottom <= widgetRect.top || widgetRect.bottom <= rect.top) {
-          return false;
-        }
-        return true;
+        return dragTargetRect.overlaps(widgetRect);
       }
     }
 
-    // final HitTestResult result = HitTestResult();
-    // WidgetsBinding.instance.hitTest(result, position);
-    // for (final HitTestEntry entry in result.path) {
-    //   final HitTestTarget target = entry.target;
-    //   if (target is RenderMetaData) {
-    //     print(target.metaData);
-    //   }
-    //   print(target);
-    // }
-
     return false;
   }
 }
 
 abstract class DraggingStateStorage {
-  void write(String reorderFlexId, DraggingState state);
-  void remove(String reorderFlexId);
-  DraggingState? read(String reorderFlexId);
+  void insertState(String reorderFlexId, DraggingState state);
+  void removeState(String reorderFlexId);
+  DraggingState? readState(String reorderFlexId);
 }
 
 class DraggingState {
@@ -113,7 +96,7 @@ class DraggingState {
   int currentIndex = -1;
 
   /// The widget to move the dragging widget too after the current index.
-  int nextIndex = 0;
+  int nextIndex = -1;
 
   /// Whether or not we are currently scrolling this view to show a widget.
   bool scrolling = false;
@@ -149,6 +132,7 @@ class DraggingState {
     dragStartIndex = -1;
     phantomIndex = -1;
     currentIndex = -1;
+    nextIndex = -1;
     _draggingWidget = null;
   }
 

+ 48 - 19
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart

@@ -1,3 +1,4 @@
+import 'package:appflowy_board/src/utils/log.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/scheduler.dart';
 import 'package:provider/provider.dart';
@@ -72,11 +73,15 @@ class ReorderDragTarget<T extends DragTargetData> extends StatefulWidget {
   final ReorderFlexDraggableTargetBuilder? draggableTargetBuilder;
 
   final AnimationController insertAnimationController;
+
   final AnimationController deleteAnimationController;
 
   final bool useMoveAnimation;
+
   final bool draggable;
 
+  final double draggingOpacity;
+
   const ReorderDragTarget({
     Key? key,
     required this.child,
@@ -93,6 +98,7 @@ class ReorderDragTarget<T extends DragTargetData> extends StatefulWidget {
     this.onAccept,
     this.onLeave,
     this.draggableTargetBuilder,
+    this.draggingOpacity = 0.3,
   }) : super(key: key);
 
   @override
@@ -163,6 +169,7 @@ class _ReorderDragTargetState<T extends DragTargetData>
           feedback: feedbackBuilder,
           childWhenDragging: IgnorePointerWidget(
             useIntrinsicSize: !widget.useMoveAnimation,
+            opacity: widget.draggingOpacity,
             child: widget.child,
           ),
           onDragStarted: () {
@@ -184,8 +191,9 @@ class _ReorderDragTargetState<T extends DragTargetData>
           /// When the drag does not end inside a DragTarget widget, the
           /// drag fails, but we still reorder the widget to the last position it
           /// had been dragged to.
-          onDraggableCanceled: (Velocity velocity, Offset offset) =>
-              widget.onDragEnded(widget.dragTargetData),
+          onDraggableCanceled: (Velocity velocity, Offset offset) {
+            widget.onDragEnded(widget.dragTargetData);
+          },
           child: widget.child,
         );
 
@@ -193,7 +201,10 @@ class _ReorderDragTargetState<T extends DragTargetData>
   }
 
   Widget _buildDraggableFeedback(
-      BuildContext context, BoxConstraints constraints, Widget child) {
+    BuildContext context,
+    BoxConstraints constraints,
+    Widget child,
+  ) {
     return Transform(
       transform: Matrix4.rotationZ(0),
       alignment: FractionalOffset.topLeft,
@@ -203,7 +214,7 @@ class _ReorderDragTargetState<T extends DragTargetData>
         clipBehavior: Clip.hardEdge,
         child: ConstrainedBox(
           constraints: constraints,
-          child: Opacity(opacity: 0.3, child: child),
+          child: Opacity(opacity: widget.draggingOpacity, child: child),
         ),
       ),
     );
@@ -221,8 +232,11 @@ class DragTargetAnimation {
   // where the widget used to be.
   late AnimationController phantomController;
 
+  // Uses to simulate the insert animation when card was moved from on group to
+  // another group. Check out the [FakeDragTarget].
   late AnimationController insertController;
 
+  // Used to remove the phantom
   late AnimationController deleteController;
 
   DragTargetAnimation({
@@ -238,7 +252,7 @@ class DragTargetAnimation {
         value: 0, vsync: vsync, duration: reorderAnimationDuration);
 
     insertController = AnimationController(
-        value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 200));
+        value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 100));
 
     deleteController = AnimationController(
         value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 1));
@@ -269,8 +283,11 @@ class DragTargetAnimation {
 class IgnorePointerWidget extends StatelessWidget {
   final Widget? child;
   final bool useIntrinsicSize;
+  final double opacity;
+
   const IgnorePointerWidget({
     required this.child,
+    required this.opacity,
     this.useIntrinsicSize = false,
     Key? key,
   }) : super(key: key);
@@ -281,11 +298,10 @@ class IgnorePointerWidget extends StatelessWidget {
         ? child
         : SizedBox(width: 0.0, height: 0.0, child: child);
 
-    final opacity = useIntrinsicSize ? 0.3 : 0.0;
     return IgnorePointer(
       ignoring: true,
       child: Opacity(
-        opacity: opacity,
+        opacity: useIntrinsicSize ? opacity : 0.0,
         child: sizedChild,
       ),
     );
@@ -295,8 +311,10 @@ class IgnorePointerWidget extends StatelessWidget {
 class AbsorbPointerWidget extends StatelessWidget {
   final Widget? child;
   final bool useIntrinsicSize;
+  final double opacity;
   const AbsorbPointerWidget({
     required this.child,
+    required this.opacity,
     this.useIntrinsicSize = false,
     Key? key,
   }) : super(key: key);
@@ -307,10 +325,9 @@ class AbsorbPointerWidget extends StatelessWidget {
         ? child
         : SizedBox(width: 0.0, height: 0.0, child: child);
 
-    final opacity = useIntrinsicSize ? 0.3 : 0.0;
     return AbsorbPointer(
       child: Opacity(
-        opacity: opacity,
+        opacity: useIntrinsicSize ? opacity : 0.0,
         child: sizedChild,
       ),
     );
@@ -423,7 +440,6 @@ abstract class FakeDragTargetEventData {
 }
 
 class FakeDragTarget<T extends DragTargetData> extends StatefulWidget {
-  final Duration animationDuration;
   final FakeDragTargetEventTrigger eventTrigger;
   final FakeDragTargetEventData eventData;
   final DragTargetOnStarted onDragStarted;
@@ -442,7 +458,6 @@ class FakeDragTarget<T extends DragTargetData> extends StatefulWidget {
     required this.insertAnimationController,
     required this.deleteAnimationController,
     required this.child,
-    this.animationDuration = const Duration(milliseconds: 250),
   }) : super(key: key);
 
   @override
@@ -468,6 +483,7 @@ class _FakeDragTargetState<T extends DragTargetData>
     // });
 
     widget.eventTrigger.fakeOnDragEnded(() {
+      Log.trace("[$FakeDragTarget] on drag end");
       WidgetsBinding.instance.addPostFrameCallback((_) {
         widget.onDragEnded(widget.eventData.dragTargetData as T);
       });
@@ -476,6 +492,13 @@ class _FakeDragTargetState<T extends DragTargetData>
     super.initState();
   }
 
+  @override
+  void dispose() {
+    widget.insertAnimationController
+        .removeStatusListener(_onInsertedAnimationStatusChanged);
+    super.dispose();
+  }
+
   @override
   Widget build(BuildContext context) {
     if (simulateDragging) {
@@ -483,6 +506,7 @@ class _FakeDragTargetState<T extends DragTargetData>
         sizeFactor: widget.deleteAnimationController,
         axis: Axis.vertical,
         child: AbsorbPointerWidget(
+          opacity: 0.3,
           child: widget.child,
         ),
       );
@@ -492,6 +516,7 @@ class _FakeDragTargetState<T extends DragTargetData>
         axis: Axis.vertical,
         child: AbsorbPointerWidget(
           useIntrinsicSize: true,
+          opacity: 0.3,
           child: widget.child,
         ),
       );
@@ -503,14 +528,18 @@ class _FakeDragTargetState<T extends DragTargetData>
     WidgetsBinding.instance.addPostFrameCallback((_) {
       if (!mounted) return;
       setState(() {
-        simulateDragging = true;
-        widget.deleteAnimationController.reverse(from: 1.0);
-        widget.onWillAccept(widget.eventData.dragTargetData as T);
-        widget.onDragStarted(
-          widget.child,
-          widget.eventData.index,
-          widget.eventData.feedbackSize,
-        );
+        if (widget.onWillAccept(widget.eventData.dragTargetData as T)) {
+          Log.trace("[$FakeDragTarget] on drag start");
+          simulateDragging = true;
+          widget.deleteAnimationController.reverse(from: 1.0);
+          widget.onDragStarted(
+            widget.child,
+            widget.eventData.index,
+            widget.eventData.feedbackSize,
+          );
+        } else {
+          Log.trace("[$FakeDragTarget] cancel start drag");
+        }
       });
     });
   }

+ 6 - 6
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart

@@ -35,7 +35,7 @@ abstract class DragTargetInterceptor {
 
 abstract class OverlapDragTargetDelegate {
   void cancel();
-  void moveTo(
+  void dragTargetDidMoveToReorderFlex(
     String reorderFlexId,
     FlexDragTargetData dragTargetData,
     int dragTargetIndex,
@@ -81,7 +81,7 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor {
       delegate.cancel();
     } else {
       // Ignore the event if the dragTarget overlaps with the other column's dragTargets.
-      final columnKeys = columnsState.groupDragDragTargets[dragTargetId];
+      final columnKeys = columnsState.groupDragTargetKeys[dragTargetId];
       if (columnKeys != null) {
         final keys = columnKeys.values.toList();
         if (dragTargetData.isOverlapWithWidgets(keys)) {
@@ -99,10 +99,10 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor {
         if (index != -1) {
           Log.trace(
               '[$OverlappingDragTargetInterceptor] move to $dragTargetId at $index');
-          delegate.moveTo(dragTargetId, dragTargetData, index);
+          delegate.dragTargetDidMoveToReorderFlex(
+              dragTargetId, dragTargetData, index);
 
-          columnsState
-              .getReorderFlexState(groupId: dragTargetId)
+          columnsState.reorderFlexActionMap[dragTargetId]
               ?.resetDragTargetIndex(index);
         }
       });
@@ -153,7 +153,7 @@ class CrossReorderFlexDragTargetInterceptor extends DragTargetInterceptor {
       /// it means the dragTarget is dragging on the top of its own list.
       /// Otherwise, it means the dargTarget was moved to another list.
       Log.trace(
-          "[$CrossReorderFlexDragTargetInterceptor] $reorderFlexId accept ${dragTargetData.reorderFlexId} ${reorderFlexId != dragTargetData.reorderFlexId}");
+          "[$CrossReorderFlexDragTargetInterceptor] $reorderFlexId should accept ${dragTargetData.reorderFlexId} : ${reorderFlexId != dragTargetData.reorderFlexId}");
       return reorderFlexId != dragTargetData.reorderFlexId;
     } else {
       Log.trace(

+ 111 - 58
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart

@@ -31,27 +31,43 @@ abstract class ReoderFlexItem {
   String get id;
 }
 
-abstract class ReorderDragTargetIndexKeyStorage {
-  void addKey(String reorderFlexId, String key, GlobalObjectKey value);
-  GlobalObjectKey? readKey(String reorderFlexId, String key);
+/// Cache each dragTarget's key.
+/// For the moment, the key is used to locate the render object that will
+/// be passed in the [ScrollPosition]'s [ensureVisible] function.
+///
+abstract class ReorderDragTargeKeys {
+  void insertDragTarget(
+    String reorderFlexId,
+    String key,
+    GlobalObjectKey value,
+  );
+
+  GlobalObjectKey? getDragTarget(
+    String reorderFlexId,
+    String key,
+  );
+
+  void removeDragTarget(String reorderFlexId);
+}
+
+abstract class ReorderFlexAction {
+  void Function(void Function(BuildContext)?)? _scrollToBottom;
+  void Function(void Function(BuildContext)?) get scrollToBottom =>
+      _scrollToBottom!;
+
+  void Function(int)? _resetDragTargetIndex;
+  void Function(int) get resetDragTargetIndex => _resetDragTargetIndex!;
 }
 
 class ReorderFlexConfig {
   /// The opacity of the dragging widget
-  final double draggingWidgetOpacity = 0.3;
+  final double draggingWidgetOpacity = 0.4;
 
   // How long an animation to reorder an element
-  final Duration reorderAnimationDuration = const Duration(milliseconds: 300);
+  final Duration reorderAnimationDuration = const Duration(milliseconds: 200);
 
   // How long an animation to scroll to an off-screen element
-  final Duration scrollAnimationDuration = const Duration(milliseconds: 300);
-
-  /// Determines if setSatte method needs to be called when the drag is complete.
-  /// Default value is [true].
-  ///
-  /// If the [ReorderFlex] needs to be rebuild after the [ReorderFlex] end dragging,
-  /// the [setStateWhenEndDrag] should set to [true].
-  final bool setStateWhenEndDrag;
+  final Duration scrollAnimationDuration = const Duration(milliseconds: 200);
 
   final bool useMoveAnimation;
 
@@ -59,7 +75,6 @@ class ReorderFlexConfig {
 
   const ReorderFlexConfig({
     this.useMoveAnimation = true,
-    this.setStateWhenEndDrag = true,
   }) : useMovePlaceholder = !useMoveAnimation;
 }
 
@@ -86,9 +101,12 @@ class ReorderFlex extends StatefulWidget {
 
   final DragTargetInterceptor? interceptor;
 
+  /// Save the [DraggingState] if the current [ReorderFlex] get reinitialize.
   final DraggingStateStorage? dragStateStorage;
 
-  final ReorderDragTargetIndexKeyStorage? dragTargetIndexKeyStorage;
+  final ReorderDragTargeKeys? dragTargetKeys;
+
+  final ReorderFlexAction? reorderFlexAction;
 
   final bool reorderable;
 
@@ -101,10 +119,11 @@ class ReorderFlex extends StatefulWidget {
     required this.onReorder,
     this.reorderable = true,
     this.dragStateStorage,
-    this.dragTargetIndexKeyStorage,
+    this.dragTargetKeys,
     this.onDragStarted,
     this.onDragEnded,
     this.interceptor,
+    this.reorderFlexAction,
     this.direction = Axis.vertical,
   })  : assert(children.every((Widget w) => w.key != null),
             'All child must have a key.'),
@@ -117,7 +136,7 @@ class ReorderFlex extends StatefulWidget {
 }
 
 class ReorderFlexState extends State<ReorderFlex>
-    with ReorderFlexMinxi, TickerProviderStateMixin<ReorderFlex> {
+    with ReorderFlexMixin, TickerProviderStateMixin<ReorderFlex> {
   /// Controls scrolls and measures scroll progress.
   late ScrollController _scrollController;
 
@@ -139,22 +158,31 @@ class ReorderFlexState extends State<ReorderFlex>
   void initState() {
     _notifier = ReorderFlexNotifier();
     final flexId = widget.reorderFlexId;
-    dragState = widget.dragStateStorage?.read(flexId) ??
+    dragState = widget.dragStateStorage?.readState(flexId) ??
         DraggingState(widget.reorderFlexId);
     Log.trace('[DragTarget] init dragState: $dragState');
 
-    widget.dragStateStorage?.remove(flexId);
+    widget.dragStateStorage?.removeState(flexId);
 
     _animation = DragTargetAnimation(
       reorderAnimationDuration: widget.config.reorderAnimationDuration,
       entranceAnimateStatusChanged: (status) {
         if (status == AnimationStatus.completed) {
+          if (dragState.nextIndex == -1) return;
           setState(() => _requestAnimationToNextIndex());
         }
       },
       vsync: this,
     );
 
+    widget.reorderFlexAction?._scrollToBottom = (fn) {
+      scrollToBottom(fn);
+    };
+
+    widget.reorderFlexAction?._resetDragTargetIndex = (index) {
+      resetDragTargetIndex(index);
+    };
+
     super.initState();
   }
 
@@ -191,7 +219,7 @@ class ReorderFlexState extends State<ReorderFlex>
 
       final indexKey = GlobalObjectKey(child.key!);
       // Save the index key for quick access
-      widget.dragTargetIndexKeyStorage?.addKey(
+      widget.dragTargetKeys?.insertDragTarget(
         widget.reorderFlexId,
         item.id,
         indexKey,
@@ -243,8 +271,12 @@ class ReorderFlexState extends State<ReorderFlex>
   /// [childIndex]: the index of the child in a list
   Widget _wrap(Widget child, int childIndex, GlobalObjectKey indexKey) {
     return Builder(builder: (context) {
-      final ReorderDragTarget dragTarget =
-          _buildDragTarget(context, child, childIndex, indexKey);
+      final ReorderDragTarget dragTarget = _buildDragTarget(
+        context,
+        child,
+        childIndex,
+        indexKey,
+      );
       int shiftedIndex = childIndex;
 
       if (dragState.isOverlapWithPhantom()) {
@@ -349,6 +381,15 @@ class ReorderFlexState extends State<ReorderFlex>
     });
   }
 
+  static ReorderFlexState of(BuildContext context) {
+    if (context is StatefulElement && context.state is ReorderFlexState) {
+      return context.state as ReorderFlexState;
+    }
+    final ReorderFlexState? result =
+        context.findAncestorStateOfType<ReorderFlexState>();
+    return result!;
+  }
+
   ReorderDragTarget _buildDragTarget(
     BuildContext builderContext,
     Widget child,
@@ -371,18 +412,24 @@ class ReorderFlexState extends State<ReorderFlex>
             "[DragTarget] Group:[${widget.dataSource.identifier}] start dragging item at $draggingIndex");
         _startDragging(draggingWidget, draggingIndex, size);
         widget.onDragStarted?.call(draggingIndex);
-        widget.dragStateStorage?.remove(widget.reorderFlexId);
+        widget.dragStateStorage?.removeState(widget.reorderFlexId);
       },
       onDragMoved: (dragTargetData, offset) {
         dragTargetData.dragTargetOffset = offset;
       },
       onDragEnded: (dragTargetData) {
-        if (!mounted) return;
+        if (!mounted) {
+          Log.warn(
+              "[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging but current widget was unmounted");
+          return;
+        }
         Log.debug(
             "[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging");
+
         _notifier.updateDragTargetIndex(-1);
+        _animation.insertController.stop();
 
-        onDragEnded() {
+        setState(() {
           if (dragTargetData.reorderFlexId == widget.reorderFlexId) {
             _onReordered(
               dragState.dragStartIndex,
@@ -391,13 +438,7 @@ class ReorderFlexState extends State<ReorderFlex>
           }
           dragState.endDragging();
           widget.onDragEnded?.call();
-        }
-
-        if (widget.config.setStateWhenEndDrag) {
-          setState(() => onDragEnded());
-        } else {
-          onDragEnded();
-        }
+        });
       },
       onWillAccept: (FlexDragTargetData dragTargetData) {
         // Do not receive any events if the Insert item is animating.
@@ -405,19 +446,23 @@ class ReorderFlexState extends State<ReorderFlex>
           return false;
         }
 
-        assert(widget.dataSource.items.length > dragTargetIndex);
-        if (_interceptDragTarget(dragTargetData, (interceptor) {
-          interceptor.onWillAccept(
-            context: builderContext,
-            reorderFlexState: this,
-            dragTargetData: dragTargetData,
-            dragTargetId: reorderFlexItem.id,
-            dragTargetIndex: dragTargetIndex,
-          );
-        })) {
-          return true;
+        if (dragTargetData.isDragging) {
+          assert(widget.dataSource.items.length > dragTargetIndex);
+          if (_interceptDragTarget(dragTargetData, (interceptor) {
+            interceptor.onWillAccept(
+              context: builderContext,
+              reorderFlexState: this,
+              dragTargetData: dragTargetData,
+              dragTargetId: reorderFlexItem.id,
+              dragTargetIndex: dragTargetIndex,
+            );
+          })) {
+            return true;
+          } else {
+            return handleOnWillAccept(builderContext, dragTargetIndex);
+          }
         } else {
-          return handleOnWillAccept(builderContext, dragTargetIndex);
+          return false;
         }
       },
       onAccept: (dragTargetData) {
@@ -438,6 +483,7 @@ class ReorderFlexState extends State<ReorderFlex>
       draggableTargetBuilder: widget.interceptor?.draggableTargetBuilder,
       useMoveAnimation: widget.config.useMoveAnimation,
       draggable: widget.reorderable,
+      draggingOpacity: widget.config.draggingWidgetOpacity,
       child: child,
     );
   }
@@ -485,8 +531,12 @@ class ReorderFlexState extends State<ReorderFlex>
   }
 
   void resetDragTargetIndex(int dragTargetIndex) {
+    if (dragTargetIndex > widget.dataSource.items.length) {
+      return;
+    }
+
     dragState.setStartDraggingIndex(dragTargetIndex);
-    widget.dragStateStorage?.write(
+    widget.dragStateStorage?.insertState(
       widget.reorderFlexId,
       dragState,
     );
@@ -521,6 +571,9 @@ class ReorderFlexState extends State<ReorderFlex>
   }
 
   void _onReordered(int fromIndex, int toIndex) {
+    if (toIndex == -1) return;
+    if (fromIndex == -1) return;
+
     if (fromIndex != toIndex) {
       widget.onReorder.call(fromIndex, toIndex);
     }
@@ -577,46 +630,46 @@ class ReorderFlexState extends State<ReorderFlex>
     }
   }
 
-  void scrollToBottom(VoidCallback? completed) {
+  void scrollToBottom(void Function(BuildContext)? completed) {
     if (_scrolling) {
-      completed?.call();
+      completed?.call(context);
       return;
     }
 
     if (widget.dataSource.items.isNotEmpty) {
       final item = widget.dataSource.items.last;
-      final indexKey = widget.dragTargetIndexKeyStorage?.readKey(
+      final dragTargetKey = widget.dragTargetKeys?.getDragTarget(
         widget.reorderFlexId,
         item.id,
       );
-      if (indexKey == null) {
-        completed?.call();
+      if (dragTargetKey == null) {
+        completed?.call(context);
         return;
       }
 
-      final indexContext = indexKey.currentContext;
-      if (indexContext == null || _scrollController.hasClients == false) {
-        completed?.call();
+      final dragTargetContext = dragTargetKey.currentContext;
+      if (dragTargetContext == null || _scrollController.hasClients == false) {
+        completed?.call(context);
         return;
       }
 
-      final renderObject = indexContext.findRenderObject();
-      if (renderObject != null) {
+      final dragTargetRenderObject = dragTargetContext.findRenderObject();
+      if (dragTargetRenderObject != null) {
         _scrolling = true;
         _scrollController.position
             .ensureVisible(
-          renderObject,
+          dragTargetRenderObject,
           alignment: 0.5,
           duration: const Duration(milliseconds: 120),
         )
             .then((value) {
           setState(() {
             _scrolling = false;
-            completed?.call();
+            completed?.call(context);
           });
         });
       } else {
-        completed?.call();
+        completed?.call(context);
       }
     }
   }

+ 1 - 1
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_mixin.dart

@@ -3,7 +3,7 @@ import 'package:flutter/widgets.dart';
 import '../transitions.dart';
 import 'drag_target.dart';
 
-mixin ReorderFlexMinxi {
+mixin ReorderFlexMixin {
   @protected
   Widget makeAppearingWidget(
     Widget child,

+ 3 - 2
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart

@@ -94,7 +94,8 @@ class BoardPhantomController extends OverlapDragTargetDelegate
 
   /// Remove the phantom in the group if it contains phantom
   void _removePhantom(String groupId) {
-    if (delegate.removePhantom(groupId)) {
+    final didRemove = delegate.removePhantom(groupId);
+    if (didRemove) {
       phantomState.notifyDidRemovePhantom(groupId);
       phantomState.removeGroupListener(groupId);
     }
@@ -195,7 +196,7 @@ class BoardPhantomController extends OverlapDragTargetDelegate
   }
 
   @override
-  void moveTo(
+  void dragTargetDidMoveToReorderFlex(
     String reorderFlexId,
     FlexDragTargetData dragTargetData,
     int dragTargetIndex,

+ 24 - 20
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart

@@ -1,50 +1,54 @@
+import 'package:appflowy_board/src/utils/log.dart';
+
 import 'phantom_controller.dart';
 import 'package:flutter/material.dart';
 
 class GroupPhantomState {
-  final _states = <String, GroupState>{};
+  final _groupStates = <String, GroupState>{};
+  final _groupIsDragging = <String, bool>{};
 
   void setGroupIsDragging(String groupId, bool isDragging) {
-    _stateWithId(groupId).isDragging = isDragging;
+    _groupIsDragging[groupId] = isDragging;
   }
 
   bool isDragging(String groupId) {
-    return _stateWithId(groupId).isDragging;
+    return _groupIsDragging[groupId] ?? false;
   }
 
   void addGroupListener(String groupId, PassthroughPhantomListener listener) {
-    _stateWithId(groupId).notifier.addListener(
-          onInserted: (index) => listener.onInserted?.call(index),
-          onDeleted: () => listener.onDragEnded?.call(),
-        );
+    if (_groupStates[groupId] == null) {
+      Log.debug("[$GroupPhantomState] add group listener: $groupId");
+      _groupStates[groupId] = GroupState();
+      _groupStates[groupId]?.notifier.addListener(
+            onInserted: (index) => listener.onInserted?.call(index),
+            onDeleted: () => listener.onDragEnded?.call(),
+          );
+    }
   }
 
   void removeGroupListener(String groupId) {
-    _stateWithId(groupId).notifier.dispose();
-    _states.remove(groupId);
+    Log.debug("[$GroupPhantomState] remove group listener: $groupId");
+    final groupState = _groupStates.remove(groupId);
+    groupState?.dispose();
   }
 
   void notifyDidInsertPhantom(String groupId, int index) {
-    _stateWithId(groupId).notifier.insert(index);
+    _groupStates[groupId]?.notifier.insert(index);
   }
 
   void notifyDidRemovePhantom(String groupId) {
-    _stateWithId(groupId).notifier.remove();
-  }
-
-  GroupState _stateWithId(String groupId) {
-    var state = _states[groupId];
-    if (state == null) {
-      state = GroupState();
-      _states[groupId] = state;
-    }
-    return state;
+    Log.debug("[$GroupPhantomState] $groupId remove phantom");
+    _groupStates[groupId]?.notifier.remove();
   }
 }
 
 class GroupState {
   bool isDragging = false;
   final notifier = PassthroughPhantomNotifier();
+
+  void dispose() {
+    notifier.dispose();
+  }
 }
 
 abstract class PassthroughPhantomListener {

+ 1 - 1
frontend/app_flowy/packages/appflowy_board/pubspec.yaml

@@ -1,6 +1,6 @@
 name: appflowy_board
 description: AppFlowyBoard is a board-style widget that consists of multi-groups. It supports drag and drop between different groups. 
-version: 0.0.7
+version: 0.0.8
 homepage: https://github.com/AppFlowy-IO/AppFlowy
 repository: https://github.com/AppFlowy-IO/AppFlowy/tree/main/frontend/app_flowy/packages/appflowy_board
 

+ 9 - 0
frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart

@@ -1,6 +1,7 @@
 import 'dart:convert';
 import 'dart:io';
 
+import 'package:example/plugin/code_block_node_widget.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -116,9 +117,17 @@ class _MyHomePageState extends State<MyHomePage> {
               editorState: _editorState!,
               editorStyle: _editorStyle,
               editable: true,
+              customBuilders: {
+                'text/code_block': CodeBlockNodeWidgetBuilder(),
+              },
               shortcutEvents: [
+                enterInCodeBlock,
+                ignoreKeysInCodeBlock,
                 underscoreToItalic,
               ],
+              selectionMenuItems: [
+                codeBlockItem,
+              ],
             ),
           );
         } else {

+ 277 - 0
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart

@@ -0,0 +1,277 @@
+import 'dart:collection';
+
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/material.dart';
+import 'package:highlight/highlight.dart' as highlight;
+import 'package:highlight/languages/all.dart';
+
+ShortcutEvent enterInCodeBlock = ShortcutEvent(
+  key: 'Enter in code block',
+  command: 'enter',
+  handler: _enterInCodeBlockHandler,
+);
+
+ShortcutEvent ignoreKeysInCodeBlock = ShortcutEvent(
+  key: 'White space in code block',
+  command: 'space,slash,shift+underscore',
+  handler: _ignorekHandler,
+);
+
+ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) {
+  final selection = editorState.service.selectionService.currentSelection.value;
+  final nodes = editorState.service.selectionService.currentSelectedNodes;
+  final codeBlockNode =
+      nodes.whereType<TextNode>().where((node) => node.id == 'text/code_block');
+  if (codeBlockNode.length != 1 || selection == null) {
+    return KeyEventResult.ignored;
+  }
+  if (selection.isCollapsed) {
+    TransactionBuilder(editorState)
+      ..insertText(codeBlockNode.first, selection.end.offset, '\n')
+      ..commit();
+    return KeyEventResult.handled;
+  }
+  return KeyEventResult.ignored;
+};
+
+ShortcutEventHandler _ignorekHandler = (editorState, event) {
+  final nodes = editorState.service.selectionService.currentSelectedNodes;
+  final codeBlockNodes =
+      nodes.whereType<TextNode>().where((node) => node.id == 'text/code_block');
+  if (codeBlockNodes.length == 1) {
+    return KeyEventResult.skipRemainingHandlers;
+  }
+  return KeyEventResult.ignored;
+};
+
+SelectionMenuItem codeBlockItem = SelectionMenuItem(
+  name: 'Code Block',
+  icon: const Icon(Icons.abc),
+  keywords: ['code block'],
+  handler: (editorState, _, __) {
+    final selection =
+        editorState.service.selectionService.currentSelection.value;
+    final textNodes = editorState.service.selectionService.currentSelectedNodes
+        .whereType<TextNode>();
+    if (selection == null || textNodes.isEmpty) {
+      return;
+    }
+    if (textNodes.first.toRawString().isEmpty) {
+      TransactionBuilder(editorState)
+        ..updateNode(textNodes.first, {
+          'subtype': 'code_block',
+          'theme': 'vs',
+          'language': null,
+        })
+        ..afterSelection = selection
+        ..commit();
+    } else {
+      TransactionBuilder(editorState)
+        ..insertNode(
+          selection.end.path.next,
+          TextNode(
+            type: 'text',
+            children: LinkedList(),
+            attributes: {
+              'subtype': 'code_block',
+              'theme': 'vs',
+              'language': null,
+            },
+            delta: Delta()..insert('\n'),
+          ),
+        )
+        ..afterSelection = selection
+        ..commit();
+    }
+  },
+);
+
+class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
+  @override
+  Widget build(NodeWidgetContext<TextNode> context) {
+    return _CodeBlockNodeWidge(
+      key: context.node.key,
+      textNode: context.node,
+      editorState: context.editorState,
+    );
+  }
+
+  @override
+  NodeValidator<Node> get nodeValidator => (node) {
+        return node is TextNode && node.attributes['theme'] is String;
+      };
+}
+
+class _CodeBlockNodeWidge extends StatefulWidget {
+  const _CodeBlockNodeWidge({
+    Key? key,
+    required this.textNode,
+    required this.editorState,
+  }) : super(key: key);
+
+  final TextNode textNode;
+  final EditorState editorState;
+
+  @override
+  State<_CodeBlockNodeWidge> createState() => __CodeBlockNodeWidgeState();
+}
+
+class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
+    with SelectableMixin, DefaultSelectable {
+  final _richTextKey = GlobalKey(debugLabel: 'code_block_text');
+  final _padding = const EdgeInsets.only(left: 20, top: 20, bottom: 20);
+  String? get _language => widget.textNode.attributes['language'] as String?;
+  String? _detectLanguage;
+
+  @override
+  SelectableMixin<StatefulWidget> get forward =>
+      _richTextKey.currentState as SelectableMixin;
+
+  @override
+  GlobalKey<State<StatefulWidget>>? get iconKey => null;
+
+  @override
+  Offset get baseOffset => super.baseOffset + _padding.topLeft;
+
+  @override
+  Widget build(BuildContext context) {
+    return Stack(
+      children: [
+        _buildCodeBlock(context),
+        _buildSwitchCodeButton(context),
+      ],
+    );
+  }
+
+  Widget _buildCodeBlock(BuildContext context) {
+    final result = highlight.highlight.parse(
+      widget.textNode.toRawString(),
+      language: _language,
+      autoDetection: _language == null,
+    );
+    _detectLanguage = _language ?? result.language;
+    final code = result.nodes;
+    final codeTextSpan = _convert(code!);
+    return Container(
+      decoration: BoxDecoration(
+        borderRadius: const BorderRadius.all(Radius.circular(8.0)),
+        color: Colors.grey.withOpacity(0.1),
+      ),
+      padding: _padding,
+      width: MediaQuery.of(context).size.width,
+      child: FlowyRichText(
+        key: _richTextKey,
+        textNode: widget.textNode,
+        editorState: widget.editorState,
+        textSpanDecorator: (textSpan) => TextSpan(
+          style: widget.editorState.editorStyle.textStyle.defaultTextStyle,
+          children: codeTextSpan,
+        ),
+      ),
+    );
+  }
+
+  Widget _buildSwitchCodeButton(BuildContext context) {
+    return Positioned(
+      top: -5,
+      right: 0,
+      child: DropdownButton<String>(
+        value: _detectLanguage,
+        onChanged: (value) {
+          TransactionBuilder(widget.editorState)
+            ..updateNode(widget.textNode, {
+              'language': value,
+            })
+            ..commit();
+        },
+        items: allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
+          return DropdownMenuItem<String>(
+            value: value,
+            child: Text(
+              value,
+              style: const TextStyle(fontSize: 12.0),
+            ),
+          );
+        }).toList(growable: false),
+      ),
+    );
+  }
+
+  // Copy from flutter.highlight package.
+  // https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
+  List<TextSpan> _convert(List<highlight.Node> nodes) {
+    List<TextSpan> spans = [];
+    var currentSpans = spans;
+    List<List<TextSpan>> stack = [];
+
+    _traverse(highlight.Node node) {
+      if (node.value != null) {
+        currentSpans.add(node.className == null
+            ? TextSpan(text: node.value)
+            : TextSpan(
+                text: node.value,
+                style: _builtInCodeBlockTheme[node.className!]));
+      } else if (node.children != null) {
+        List<TextSpan> tmp = [];
+        currentSpans.add(TextSpan(
+            children: tmp, style: _builtInCodeBlockTheme[node.className!]));
+        stack.add(currentSpans);
+        currentSpans = tmp;
+
+        for (var n in node.children!) {
+          _traverse(n);
+          if (n == node.children!.last) {
+            currentSpans = stack.isEmpty ? spans : stack.removeLast();
+          }
+        }
+      }
+    }
+
+    for (var node in nodes) {
+      _traverse(node);
+    }
+
+    return spans;
+  }
+}
+
+const _builtInCodeBlockTheme = {
+  'root':
+      TextStyle(backgroundColor: Color(0xffffffff), color: Color(0xff000000)),
+  'comment': TextStyle(color: Color(0xff007400)),
+  'quote': TextStyle(color: Color(0xff007400)),
+  'tag': TextStyle(color: Color(0xffaa0d91)),
+  'attribute': TextStyle(color: Color(0xffaa0d91)),
+  'keyword': TextStyle(color: Color(0xffaa0d91)),
+  'selector-tag': TextStyle(color: Color(0xffaa0d91)),
+  'literal': TextStyle(color: Color(0xffaa0d91)),
+  'name': TextStyle(color: Color(0xffaa0d91)),
+  'variable': TextStyle(color: Color(0xff3F6E74)),
+  'template-variable': TextStyle(color: Color(0xff3F6E74)),
+  'code': TextStyle(color: Color(0xffc41a16)),
+  'string': TextStyle(color: Color(0xffc41a16)),
+  'meta-string': TextStyle(color: Color(0xffc41a16)),
+  'regexp': TextStyle(color: Color(0xff0E0EFF)),
+  'link': TextStyle(color: Color(0xff0E0EFF)),
+  'title': TextStyle(color: Color(0xff1c00cf)),
+  'symbol': TextStyle(color: Color(0xff1c00cf)),
+  'bullet': TextStyle(color: Color(0xff1c00cf)),
+  'number': TextStyle(color: Color(0xff1c00cf)),
+  'section': TextStyle(color: Color(0xff643820)),
+  'meta': TextStyle(color: Color(0xff643820)),
+  'type': TextStyle(color: Color(0xff5c2699)),
+  'built_in': TextStyle(color: Color(0xff5c2699)),
+  'builtin-name': TextStyle(color: Color(0xff5c2699)),
+  'params': TextStyle(color: Color(0xff5c2699)),
+  'attr': TextStyle(color: Color(0xff836C28)),
+  'subst': TextStyle(color: Color(0xff000000)),
+  'formula': TextStyle(
+      backgroundColor: Color(0xffeeeeee), fontStyle: FontStyle.italic),
+  'addition': TextStyle(backgroundColor: Color(0xffbaeeba)),
+  'deletion': TextStyle(backgroundColor: Color(0xffffc8bd)),
+  'selector-id': TextStyle(color: Color(0xff9b703f)),
+  'selector-class': TextStyle(color: Color(0xff9b703f)),
+  'doctag': TextStyle(fontWeight: FontWeight.bold),
+  'strong': TextStyle(fontWeight: FontWeight.bold),
+  'emphasis': TextStyle(fontStyle: FontStyle.italic),
+};

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

@@ -43,6 +43,7 @@ dependencies:
     sdk: flutter
   file_picker: ^5.0.1
   universal_html: ^2.0.8
+  highlight: ^0.7.0
 
 dev_dependencies:
   flutter_test:

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

@@ -28,4 +28,8 @@ export 'src/service/shortcut_event/keybinding.dart';
 export 'src/service/shortcut_event/shortcut_event.dart';
 export 'src/service/shortcut_event/shortcut_event_handler.dart';
 export 'src/extensions/attributes_extension.dart';
+export 'src/extensions/path_extensions.dart';
+export 'src/render/rich_text/default_selectable.dart';
+export 'src/render/rich_text/flowy_rich_text.dart';
+export 'src/render/selection_menu/selection_menu_widget.dart';
 export 'src/l10n/l10n.dart';

+ 35 - 0
frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_nl_NL.arb

@@ -0,0 +1,35 @@
+{
+  "@@locale": "nl-NL",
+  "bold": "Vet",
+  "@bold": {},
+  "bulletedList": "Opsommingstekens",
+  "@bulletedList": {},
+  "checkbox": "Selectievakje",
+  "@checkbox": {},
+  "embedCode": "Invoegcode",
+  "@embedCode": {},
+  "heading1": "H1",
+  "@heading1": {},
+  "heading2": "H2",
+  "@heading2": {},
+  "heading3": "H3",
+  "@heading3": {},
+  "highlight": "Highlight",
+  "@highlight": {},
+  "image": "Afbeelding",
+  "@image": {},
+  "italic": "Cursief",
+  "@italic": {},
+  "link": "",
+  "@link": {},
+  "numberedList": "Nummering",
+  "@numberedList": {},
+  "quote": "Quote",
+  "@quote": {},
+  "strikethrough": "Doorhalen",
+  "@strikethrough": {},
+  "text": "Tekst",
+  "@text": {},
+  "underline": "Onderstrepen",
+  "@underline": {}
+}

+ 34 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart

@@ -0,0 +1,34 @@
+import 'dart:async';
+
+import 'package:appflowy_editor/src/commands/text_command_infra.dart';
+import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/document/path.dart';
+import 'package:appflowy_editor/src/editor_state.dart';
+import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:flutter/widgets.dart';
+
+Future<void> insertContextInText(
+  EditorState editorState,
+  int index,
+  String content, {
+  Path? path,
+  TextNode? textNode,
+}) async {
+  final result = getTextNodeToBeFormatted(
+    editorState,
+    path: path,
+    textNode: textNode,
+  );
+
+  final completer = Completer<void>();
+
+  TransactionBuilder(editorState)
+    ..insertText(result, index, content)
+    ..commit();
+
+  WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+    completer.complete();
+  });
+
+  return completer.future;
+}

+ 83 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart

@@ -0,0 +1,83 @@
+import 'package:appflowy_editor/src/commands/format_text.dart';
+import 'package:appflowy_editor/src/commands/text_command_infra.dart';
+import 'package:appflowy_editor/src/document/attributes.dart';
+import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
+import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/document/path.dart';
+import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/editor_state.dart';
+
+Future<void> formatBuiltInTextAttributes(
+  EditorState editorState,
+  String key,
+  Attributes attributes, {
+  Selection? selection,
+  Path? path,
+  TextNode? textNode,
+}) async {
+  final result = getTextNodeToBeFormatted(
+    editorState,
+    path: path,
+    textNode: textNode,
+  );
+  if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
+    // remove all the existing style
+    final newAttributes = result.attributes
+      ..removeWhere((key, value) {
+        if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
+          return true;
+        }
+        return false;
+      })
+      ..addAll(attributes)
+      ..addAll({
+        BuiltInAttributeKey.subtype: key,
+      });
+    return updateTextNodeAttributes(
+      editorState,
+      newAttributes,
+      textNode: textNode,
+    );
+  } else if (BuiltInAttributeKey.partialStyleKeys.contains(key)) {
+    return updateTextNodeDeltaAttributes(
+      editorState,
+      selection,
+      attributes,
+      textNode: textNode,
+    );
+  }
+}
+
+Future<void> formatTextToCheckbox(
+  EditorState editorState,
+  bool check, {
+  Path? path,
+  TextNode? textNode,
+}) async {
+  return formatBuiltInTextAttributes(
+    editorState,
+    BuiltInAttributeKey.checkbox,
+    {
+      BuiltInAttributeKey.checkbox: check,
+    },
+    path: path,
+    textNode: textNode,
+  );
+}
+
+Future<void> formatLinkInText(
+  EditorState editorState,
+  String? link, {
+  Path? path,
+  TextNode? textNode,
+}) async {
+  return formatBuiltInTextAttributes(
+    editorState,
+    BuiltInAttributeKey.href,
+    {
+      BuiltInAttributeKey.href: link,
+    },
+    path: path,
+    textNode: textNode,
+  );
+}

+ 67 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart

@@ -0,0 +1,67 @@
+import 'dart:async';
+
+import 'package:appflowy_editor/src/commands/text_command_infra.dart';
+import 'package:appflowy_editor/src/document/attributes.dart';
+import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/document/path.dart';
+import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/editor_state.dart';
+import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:flutter/widgets.dart';
+
+Future<void> updateTextNodeAttributes(
+  EditorState editorState,
+  Attributes attributes, {
+  Path? path,
+  TextNode? textNode,
+}) async {
+  final result = getTextNodeToBeFormatted(
+    editorState,
+    path: path,
+    textNode: textNode,
+  );
+
+  final completer = Completer<void>();
+
+  TransactionBuilder(editorState)
+    ..updateNode(result, attributes)
+    ..commit();
+
+  WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+    completer.complete();
+  });
+
+  return completer.future;
+}
+
+Future<void> updateTextNodeDeltaAttributes(
+  EditorState editorState,
+  Selection? selection,
+  Attributes attributes, {
+  Path? path,
+  TextNode? textNode,
+}) {
+  final result = getTextNodeToBeFormatted(
+    editorState,
+    path: path,
+    textNode: textNode,
+  );
+  final newSelection = getSelection(editorState, selection: selection);
+
+  final completer = Completer<void>();
+
+  TransactionBuilder(editorState)
+    ..formatText(
+      result,
+      newSelection.startIndex,
+      newSelection.length,
+      attributes,
+    )
+    ..commit();
+
+  WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+    completer.complete();
+  });
+
+  return completer.future;
+}

+ 43 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart

@@ -0,0 +1,43 @@
+import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/document/path.dart';
+import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/editor_state.dart';
+
+// get formatted [TextNode]
+TextNode getTextNodeToBeFormatted(
+  EditorState editorState, {
+  Path? path,
+  TextNode? textNode,
+}) {
+  final currentSelection =
+      editorState.service.selectionService.currentSelection.value;
+  TextNode result;
+  if (textNode != null) {
+    result = textNode;
+  } else if (path != null) {
+    result = editorState.document.nodeAtPath(path) as TextNode;
+  } else if (currentSelection != null && currentSelection.isCollapsed) {
+    result = editorState.document.nodeAtPath(currentSelection.start.path)
+        as TextNode;
+  } else {
+    throw Exception('path and textNode cannot be null at the same time');
+  }
+  return result;
+}
+
+Selection getSelection(
+  EditorState editorState, {
+  Selection? selection,
+}) {
+  final currentSelection =
+      editorState.service.selectionService.currentSelection.value;
+  Selection result;
+  if (selection != null) {
+    result = selection;
+  } else if (currentSelection != null) {
+    result = currentSelection;
+  } else {
+    throw Exception('path and textNode cannot be null at the same time');
+  }
+  return result;
+}

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

@@ -93,12 +93,14 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
   }
 
   void updateAttributes(Attributes attributes) {
-    bool shouldNotifyParent = _attributes['subtype'] != attributes['subtype'];
-
+    final oldAttributes = {..._attributes};
     _attributes = composeAttributes(_attributes, attributes) ?? {};
+
     // Notifies the new attributes
     // if attributes contains 'subtype', should notify parent to rebuild node
     // else, just notify current node.
+    bool shouldNotifyParent =
+        _attributes['subtype'] != oldAttributes['subtype'];
     shouldNotifyParent ? parent?.notifyListeners() : notifyListeners();
   }
 

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

@@ -53,6 +53,10 @@ class Selection {
 
   Selection get reversed => copyWith(start: end, end: start);
 
+  int get startIndex => normalize.start.offset;
+  int get endIndex => normalize.end.offset;
+  int get length => endIndex - startIndex;
+
   Selection collapse({bool atStart = false}) {
     if (atStart) {
       return Selection(start: start, end: start);

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

@@ -72,6 +72,8 @@ class EditorState {
   // TODO: only for testing.
   bool disableSealTimer = false;
 
+  bool editable = true;
+
   Selection? get cursorSelection {
     return _cursorSelection;
   }
@@ -112,6 +114,9 @@ class EditorState {
   /// should record the transaction in undo/redo stack.
   apply(Transaction transaction,
       [ApplyOptions options = const ApplyOptions()]) {
+    if (!editable) {
+      return;
+    }
     // TODO: validate the transation.
     for (final op in transaction.operations) {
       _applyOperation(op);

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

@@ -31,6 +31,7 @@ class _LinkMenuState extends State<LinkMenu> {
   void initState() {
     super.initState();
     _textEditingController.text = widget.linkText ?? '';
+    _focusNode.requestFocus();
     _focusNode.addListener(_onFocusChange);
   }
 

+ 8 - 11
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart

@@ -1,15 +1,8 @@
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/editor_state.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/commands/format_built_in_text.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
 import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
-import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
-import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart';
-import 'package:appflowy_editor/src/render/selection/selectable.dart';
-import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
 
-import 'package:appflowy_editor/src/service/render_plugin_service.dart';
-import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
 import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
 import 'package:flutter/material.dart';
 
@@ -81,8 +74,12 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
               padding: iconPadding,
               name: check ? 'check' : 'uncheck',
             ),
-            onTap: () {
-              formatCheckbox(widget.editorState, !check);
+            onTap: () async {
+              await formatTextToCheckbox(
+                widget.editorState,
+                !check,
+                textNode: widget.textNode,
+              );
             },
           ),
           Flexible(

+ 13 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart

@@ -18,6 +18,8 @@ import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
 import 'package:appflowy_editor/src/render/selection/selectable.dart';
 import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
 
+const _kRichTextDebugMode = false;
+
 typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
 
 class FlowyRichText extends StatefulWidget {
@@ -261,6 +263,17 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
         ),
       );
     }
+    if (_kRichTextDebugMode) {
+      textSpans.add(
+        TextSpan(
+          text: '${widget.textNode.path}',
+          style: const TextStyle(
+            backgroundColor: Colors.red,
+            fontSize: 16.0,
+          ),
+        ),
+      );
+    }
     return TextSpan(
       children: textSpans,
     );

+ 21 - 14
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/commands/format_built_in_text.dart';
 import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
 import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
@@ -8,7 +9,6 @@ import 'package:appflowy_editor/src/service/default_text_operations/format_rich_
 
 import 'package:flutter/material.dart';
 import 'package:rich_clipboard/rich_clipboard.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
 
 typedef ToolbarItemEventHandler = void Function(
     EditorState editorState, BuildContext context);
@@ -120,7 +120,7 @@ List<ToolbarItem> defaultToolbarItems = [
       name: 'toolbar/bold',
       color: isHighlight ? Colors.lightBlue : null,
     ),
-    validator: _showInTextSelection,
+    validator: _showInBuiltInTextSelection,
     highlightCallback: (editorState) => _allSatisfy(
       editorState,
       BuiltInAttributeKey.bold,
@@ -136,7 +136,7 @@ List<ToolbarItem> defaultToolbarItems = [
       name: 'toolbar/italic',
       color: isHighlight ? Colors.lightBlue : null,
     ),
-    validator: _showInTextSelection,
+    validator: _showInBuiltInTextSelection,
     highlightCallback: (editorState) => _allSatisfy(
       editorState,
       BuiltInAttributeKey.italic,
@@ -152,7 +152,7 @@ List<ToolbarItem> defaultToolbarItems = [
       name: 'toolbar/underline',
       color: isHighlight ? Colors.lightBlue : null,
     ),
-    validator: _showInTextSelection,
+    validator: _showInBuiltInTextSelection,
     highlightCallback: (editorState) => _allSatisfy(
       editorState,
       BuiltInAttributeKey.underline,
@@ -168,7 +168,7 @@ List<ToolbarItem> defaultToolbarItems = [
       name: 'toolbar/strikethrough',
       color: isHighlight ? Colors.lightBlue : null,
     ),
-    validator: _showInTextSelection,
+    validator: _showInBuiltInTextSelection,
     highlightCallback: (editorState) => _allSatisfy(
       editorState,
       BuiltInAttributeKey.strikethrough,
@@ -184,7 +184,7 @@ List<ToolbarItem> defaultToolbarItems = [
       name: 'toolbar/code',
       color: isHighlight ? Colors.lightBlue : null,
     ),
-    validator: _showInTextSelection,
+    validator: _showInBuiltInTextSelection,
     highlightCallback: (editorState) => _allSatisfy(
       editorState,
       BuiltInAttributeKey.code,
@@ -248,7 +248,7 @@ List<ToolbarItem> defaultToolbarItems = [
       name: 'toolbar/highlight',
       color: isHighlight ? Colors.lightBlue : null,
     ),
-    validator: _showInTextSelection,
+    validator: _showInBuiltInTextSelection,
     highlightCallback: (editorState) => _allSatisfy(
       editorState,
       BuiltInAttributeKey.backgroundColor,
@@ -262,13 +262,22 @@ List<ToolbarItem> defaultToolbarItems = [
 ];
 
 ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) {
+  final result = _showInBuiltInTextSelection(editorState);
+  if (!result) {
+    return false;
+  }
   final nodes = editorState.service.selectionService.currentSelectedNodes;
   return (nodes.length == 1 && nodes.first is TextNode);
 };
 
-ToolbarItemValidator _showInTextSelection = (editorState) {
+ToolbarItemValidator _showInBuiltInTextSelection = (editorState) {
   final nodes = editorState.service.selectionService.currentSelectedNodes
-      .whereType<TextNode>();
+      .whereType<TextNode>()
+      .where(
+        (textNode) =>
+            BuiltInAttributeKey.globalStyleKeys.contains(textNode.subtype) ||
+            textNode.subtype == null,
+      );
   return nodes.isNotEmpty;
 };
 
@@ -337,11 +346,8 @@ void showLinkMenu(
           onOpenLink: () async {
             await safeLaunchUrl(linkText);
           },
-          onSubmitted: (text) {
-            TransactionBuilder(editorState)
-              ..formatText(
-                  textNode, index, length, {BuiltInAttributeKey.href: text})
-              ..commit();
+          onSubmitted: (text) async {
+            await formatLinkInText(editorState, text, textNode: textNode);
             _dismissLinkMenu();
           },
           onCopyLink: () {
@@ -369,6 +375,7 @@ void showLinkMenu(
   Overlay.of(context)?.insert(_linkMenuOverlay!);
 
   editorState.service.scrollService?.disable();
+  editorState.service.keyboardService?.disable();
   editorState.service.selectionService.currentSelection
       .addListener(_dismissLinkMenu);
 }

+ 8 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart

@@ -103,13 +103,17 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
   final builder = TransactionBuilder(editorState);
 
   for (final textNode in textNodes) {
+    var newAttributes = {...textNode.attributes};
+    for (final globalStyleKey in BuiltInAttributeKey.globalStyleKeys) {
+      if (newAttributes.keys.contains(globalStyleKey)) {
+        newAttributes[globalStyleKey] = null;
+      }
+    }
+    newAttributes.addAll(attributes);
     builder
       ..updateNode(
         textNode,
-        Attributes.fromIterable(
-          BuiltInAttributeKey.globalStyleKeys,
-          value: (_) => null,
-        )..addAll(attributes),
+        newAttributes,
       )
       ..afterSelection = Selection.collapsed(
         Position(

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

@@ -72,6 +72,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
     editorState.selectionMenuItems = widget.selectionMenuItems;
     editorState.editorStyle = widget.editorStyle;
     editorState.service.renderPluginService = _createRenderPlugin();
+    editorState.editable = widget.editable;
   }
 
   @override
@@ -84,6 +85,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
     }
 
     editorState.editorStyle = widget.editorStyle;
+    editorState.editable = widget.editable;
     services = null;
   }
 
@@ -118,8 +120,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
               key: editorState.service.keyboardServiceKey,
               editable: widget.editable,
               shortcutEvents: [
-                ...builtInShortcutEvents,
                 ...widget.shortcutEvents,
+                ...builtInShortcutEvents,
               ],
               editorState: editorState,
               child: FlowyToolbar(

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

@@ -297,7 +297,11 @@ class _AppFlowyInputState extends State<AppFlowyInput>
         _updateCaretPosition(textNodes.first, selection);
       }
     } else {
-      // close();
+      // https://github.com/flutter/flutter/issues/104944
+      // Disable IME for the Web.
+      if (kIsWeb) {
+        close();
+      }
     }
   }
 

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

@@ -117,12 +117,17 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
         makeFollowingNodesIncremental(editorState, insertPath, afterSelection,
             beginNum: prevNumber);
       } else {
+        bool needCopyAttributes = ![
+          BuiltInAttributeKey.heading,
+          BuiltInAttributeKey.quote,
+        ].contains(subtype);
         TransactionBuilder(editorState)
           ..insertNode(
             textNode.path,
             textNode.copyWith(
               children: LinkedList(),
               delta: Delta(),
+              attributes: needCopyAttributes ? null : {},
             ),
           )
           ..afterSelection = afterSelection
@@ -173,7 +178,9 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
 Attributes _attributesFromPreviousLine(TextNode textNode) {
   final prevAttributes = textNode.attributes;
   final subType = textNode.subtype;
-  if (subType == null || subType == BuiltInAttributeKey.heading) {
+  if (subType == null ||
+      subType == BuiltInAttributeKey.heading ||
+      subType == BuiltInAttributeKey.quote) {
     return {};
   }
 

+ 21 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart

@@ -0,0 +1,21 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/commands/edit_text.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+
+ShortcutEventHandler spaceOnWebHandler = (editorState, event) {
+  final selection = editorState.service.selectionService.currentSelection.value;
+  final textNodes = editorState.service.selectionService.currentSelectedNodes
+      .whereType<TextNode>()
+      .toList(growable: false);
+  if (selection == null ||
+      !selection.isCollapsed ||
+      !kIsWeb ||
+      textNodes.length != 1) {
+    return KeyEventResult.ignored;
+  }
+
+  insertContextInText(editorState, selection.startIndex, ' ');
+
+  return KeyEventResult.handled;
+};

+ 10 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart

@@ -13,12 +13,19 @@ ShortcutEventHandler tabHandler = (editorState, event) {
 
   final textNode = textNodes.first;
   final previous = textNode.previous;
-  if (textNode.subtype != BuiltInAttributeKey.bulletedList ||
-      previous == null ||
-      previous.subtype != BuiltInAttributeKey.bulletedList) {
+
+  if (textNode.subtype != BuiltInAttributeKey.bulletedList) {
+    TransactionBuilder(editorState)
+      ..insertText(textNode, selection.end.offset, ' ' * 4)
+      ..commit();
     return KeyEventResult.handled;
   }
 
+  if (previous == null ||
+      previous.subtype != BuiltInAttributeKey.bulletedList) {
+    return KeyEventResult.ignored;
+  }
+
   final path = previous.path + [previous.children.length];
   final afterSelection = Selection(
     start: selection.start.copyWith(path: path),

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

@@ -124,6 +124,8 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
         final result = shortcutEvent.handler(widget.editorState, event);
         if (result == KeyEventResult.handled) {
           return KeyEventResult.handled;
+        } else if (result == KeyEventResult.skipRemainingHandlers) {
+          return KeyEventResult.skipRemainingHandlers;
         }
         continue;
       }

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

@@ -10,9 +10,11 @@ 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/space_on_web_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';
+import 'package:flutter/foundation.dart';
 
 //
 List<ShortcutEvent> builtInShortcutEvents = [
@@ -255,4 +257,14 @@ List<ShortcutEvent> builtInShortcutEvents = [
     command: 'backquote',
     handler: backquoteToCodeHandler,
   ),
+  // https://github.com/flutter/flutter/issues/104944
+  // Workaround: Using space editing on the web platform often results in errors,
+  //  so adding a shortcut event to handle the space input instead of using the
+  //  `input_service`.
+  if (kIsWeb)
+    ShortcutEvent(
+      key: 'Space on the Web',
+      command: 'space',
+      handler: spaceOnWebHandler,
+    ),
 ];

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

@@ -38,14 +38,17 @@ class _FlowyToolbarState extends State<FlowyToolbar>
   @override
   void showInOffset(Offset offset, LayerLink layerLink) {
     hide();
-
+    final items = _filterItems(defaultToolbarItems);
+    if (items.isEmpty) {
+      return;
+    }
     _toolbarOverlay = OverlayEntry(
       builder: (context) => ToolbarWidget(
         key: _toolbarWidgetKey,
         editorState: widget.editorState,
         layerLink: layerLink,
         offset: offset,
-        items: _filterItems(defaultToolbarItems),
+        items: items,
       ),
     );
     Overlay.of(context)?.insert(_toolbarOverlay!);
@@ -102,9 +105,4 @@ class _FlowyToolbarState extends State<FlowyToolbar>
     }
     return dividedItems;
   }
-
-  // List<ToolbarItem> _highlightItems(
-  //   List<ToolbarItem> items,
-  //   Selection selection,
-  // ) {}
 }

+ 20 - 7
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart

@@ -2,7 +2,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
 import '../../infra/test_editor.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
 
 void main() async {
   setUpAll(() {
@@ -171,13 +170,27 @@ Future<void> _testStyleNeedToBeCopy(WidgetTester tester, String style) async {
     LogicalKeyboardKey.enter,
   );
   expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0));
-  expect(editor.nodeAtPath([4])?.subtype, style);
 
-  await editor.pressLogicKey(
-    LogicalKeyboardKey.enter,
-  );
-  expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0));
-  expect(editor.nodeAtPath([4])?.subtype, null);
+  if ([BuiltInAttributeKey.heading, BuiltInAttributeKey.quote]
+      .contains(style)) {
+    expect(editor.nodeAtPath([4])?.subtype, null);
+
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.enter,
+    );
+    expect(
+        editor.documentSelection, Selection.single(path: [5], startOffset: 0));
+    expect(editor.nodeAtPath([5])?.subtype, null);
+  } else {
+    expect(editor.nodeAtPath([4])?.subtype, style);
+
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.enter,
+    );
+    expect(
+        editor.documentSelection, Selection.single(path: [4], startOffset: 0));
+    expect(editor.nodeAtPath([4])?.subtype, null);
+  }
 }
 
 Future<void> _testMultipleSelection(

+ 45 - 0
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart

@@ -0,0 +1,45 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/foundation.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('space_on_web_handler.dart', () {
+    testWidgets('Presses space key on web', (tester) async {
+      if (!kIsWeb) return;
+      const count = 10;
+      const text = 'Welcome to Appflowy 😁';
+      final editor = tester.editor;
+      for (var i = 0; i < count; i++) {
+        editor.insertTextNode(text);
+      }
+      await editor.startTesting();
+
+      for (var i = 0; i < count; i++) {
+        await editor.updateSelection(
+          Selection.single(path: [i], startOffset: 1),
+        );
+        await editor.pressLogicKey(LogicalKeyboardKey.space);
+        expect(
+          (editor.nodeAtPath([i]) as TextNode).toRawString(),
+          'W elcome to Appflowy 😁',
+        );
+      }
+      for (var i = 0; i < count; i++) {
+        await editor.updateSelection(
+          Selection.single(path: [i], startOffset: text.length + 1),
+        );
+        await editor.pressLogicKey(LogicalKeyboardKey.space);
+        expect(
+          (editor.nodeAtPath([i]) as TextNode).toRawString(),
+          'W elcome to Appflowy 😁 ',
+        );
+      }
+    });
+  });
+}

+ 12 - 8
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart

@@ -15,23 +15,24 @@ void main() async {
         ..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());
+      expect(
+        editor.documentSelection,
+        Selection.single(path: [0], startOffset: 4),
+      );
 
       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());
+      expect(
+        editor.documentSelection,
+        Selection.single(path: [1], startOffset: 4),
+      );
     });
 
     testWidgets('press tab in bulleted list', (tester) async {
@@ -63,7 +64,10 @@ void main() async {
       await editor.pressLogicKey(LogicalKeyboardKey.tab);
 
       // nothing happens
-      expect(editor.documentSelection, selection);
+      expect(
+        editor.documentSelection,
+        Selection.single(path: [0], startOffset: 0),
+      );
       expect(editor.document.toJson(), document.toJson());
 
       // Before

+ 4 - 4
frontend/rust-lib/Cargo.lock

@@ -1444,9 +1444,9 @@ checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7"
 
 [[package]]
 name = "hashbrown"
-version = "0.11.2"
+version = "0.12.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
 
 [[package]]
 name = "heck"
@@ -1610,9 +1610,9 @@ checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
 
 [[package]]
 name = "indexmap"
-version = "1.8.1"
+version = "1.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee"
+checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
 dependencies = [
  "autocfg",
  "hashbrown",

+ 9 - 0
frontend/rust-lib/flowy-folder/src/entities/view.rs

@@ -206,6 +206,15 @@ impl std::convert::From<&str> for ViewIdPB {
     }
 }
 
+#[derive(Default, ProtoBuf, Clone, Debug)]
+pub struct DeletedViewPB {
+    #[pb(index = 1)]
+    pub view_id: String,
+
+    #[pb(index = 2, one_of)]
+    pub index: Option<i32>,
+}
+
 impl std::ops::Deref for ViewIdPB {
     type Target = str;
 

+ 23 - 7
frontend/rust-lib/flowy-folder/src/services/view/controller.rs

@@ -1,5 +1,5 @@
 pub use crate::entities::view::ViewDataTypePB;
-use crate::entities::{ViewInfoPB, ViewLayoutTypePB};
+use crate::entities::{DeletedViewPB, ViewInfoPB, ViewLayoutTypePB};
 use crate::manager::{ViewDataProcessor, ViewDataProcessorMap};
 use crate::{
     dart_notification::{send_dart_notification, FolderNotification},
@@ -122,12 +122,12 @@ impl ViewController {
             .await
     }
 
-    #[tracing::instrument(level = "debug", skip(self, view_id), fields(view_id = %view_id.value), err)]
-    pub(crate) async fn read_view(&self, view_id: ViewIdPB) -> Result<ViewRevision, FlowyError> {
+    #[tracing::instrument(level = "debug", skip(self, view_id), err)]
+    pub(crate) async fn read_view(&self, view_id: &str) -> Result<ViewRevision, FlowyError> {
         let view_rev = self
             .persistence
             .begin_transaction(|transaction| {
-                let view = transaction.read_view(&view_id.value)?;
+                let view = transaction.read_view(view_id)?;
                 let trash_ids = self.trash_controller.read_trash_ids(&transaction)?;
                 if trash_ids.contains(&view.id) {
                     return Err(FlowyError::record_not_found());
@@ -135,7 +135,6 @@ impl ViewController {
                 Ok(view)
             })
             .await?;
-        let _ = self.read_view_on_server(view_id);
         Ok(view_rev)
     }
 
@@ -201,9 +200,26 @@ impl ViewController {
                 let _ = KV::remove(LATEST_VIEW_ID);
             }
         }
-        let view_id_pb = ViewIdPB::from(view_id.as_str());
+
+        let deleted_view = self
+            .persistence
+            .begin_transaction(|transaction| {
+                let view = transaction.read_view(&view_id)?;
+                let views = read_belonging_views_on_local(&view.app_id, self.trash_controller.clone(), &transaction)?;
+
+                let index = views
+                    .iter()
+                    .position(|view| view.id == view_id)
+                    .map(|index| index as i32);
+                Ok(DeletedViewPB {
+                    view_id: view_id.clone(),
+                    index,
+                })
+            })
+            .await?;
+
         send_dart_notification(&view_id, FolderNotification::ViewMoveToTrash)
-            .payload(view_id_pb)
+            .payload(deleted_view)
             .send();
 
         let processor = self.get_data_processor_from_view_id(&view_id).await?;

+ 1 - 1
frontend/rust-lib/flowy-folder/src/services/view/event_handler.rs

@@ -31,7 +31,7 @@ pub(crate) async fn read_view_handler(
     controller: AppData<Arc<ViewController>>,
 ) -> DataResult<ViewPB, FlowyError> {
     let view_id: ViewIdPB = data.into_inner();
-    let view_rev = controller.read_view(view_id.clone()).await?;
+    let view_rev = controller.read_view(&view_id.value).await?;
     data_result(view_rev.into())
 }
 

+ 1 - 1
frontend/rust-lib/flowy-grid/Cargo.toml

@@ -34,7 +34,7 @@ rayon = "1.5.2"
 serde = { version = "1.0", features = ["derive"] }
 serde_json = {version = "1.0"}
 serde_repr = "0.1"
-indexmap = {version = "1.8.1", features = ["serde"]}
+indexmap = {version = "1.9.1", features = ["serde"]}
 fancy-regex = "0.10.0"
 regex = "1.5.6"
 url = { version = "2"}

+ 8 - 1
frontend/rust-lib/flowy-grid/src/entities/cell_entities.rs

@@ -1,3 +1,4 @@
+use crate::entities::FieldType;
 use flowy_derive::ProtoBuf;
 use flowy_error::ErrorCode;
 use flowy_grid_data_model::parser::NotEmptyStr;
@@ -74,15 +75,20 @@ pub struct GridCellPB {
     #[pb(index = 1)]
     pub field_id: String,
 
+    // The data was encoded in field_type's data type
     #[pb(index = 2)]
     pub data: Vec<u8>,
+
+    #[pb(index = 3, one_of)]
+    pub field_type: Option<FieldType>,
 }
 
 impl GridCellPB {
-    pub fn new(field_id: &str, data: Vec<u8>) -> Self {
+    pub fn new(field_id: &str, field_type: FieldType, data: Vec<u8>) -> Self {
         Self {
             field_id: field_id.to_owned(),
             data,
+            field_type: Some(field_type),
         }
     }
 
@@ -90,6 +96,7 @@ impl GridCellPB {
         Self {
             field_id: field_id.to_owned(),
             data: vec![],
+            field_type: None,
         }
     }
 }

+ 1 - 0
frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs

@@ -98,6 +98,7 @@ pub struct MoveGroupPayloadPB {
     pub to_group_id: String,
 }
 
+#[derive(Debug)]
 pub struct MoveGroupParams {
     pub view_id: String,
     pub from_group_id: String,

+ 91 - 20
frontend/rust-lib/flowy-grid/src/services/cell/cell_operation.rs

@@ -24,14 +24,28 @@ pub trait CellDisplayable<CD> {
         decoded_field_type: &FieldType,
         field_rev: &FieldRevision,
     ) -> FlowyResult<CellBytes>;
+
+    fn display_string(
+        &self,
+        cell_data: CellData<CD>,
+        decoded_field_type: &FieldType,
+        field_rev: &FieldRevision,
+    ) -> FlowyResult<String>;
 }
 
 // CD: Short for CellData. This type is the type return by apply_changeset function.
 // CS: Short for Changeset. Parse the string into specific Changeset type.
 pub trait CellDataOperation<CD, CS> {
-    /// The cell_data is able to parse into the specific data if CD impl the FromCellData trait.
-    /// For example:
-    /// URLCellData, DateCellData. etc.
+    /// Decode the cell data into `CD` that is certain type of data.
+    ///
+    /// Each `CD` type represents as a specific field type data. For example:
+    /// FieldType::URL => URLCellData
+    /// FieldType::Date=> DateCellData
+    ///
+    /// `decoded_field_type`: the field type of the cell data
+    ///
+    /// Returns the error if the cell data can't be parsed into `CD`.
+    ///
     fn decode_cell_data(
         &self,
         cell_data: CellData<CD>,
@@ -77,16 +91,16 @@ pub fn apply_cell_data_changeset<C: ToString, T: AsRef<FieldRevision>>(
 pub fn decode_any_cell_data<T: TryInto<AnyCellData, Error = FlowyError> + Debug>(
     data: T,
     field_rev: &FieldRevision,
-) -> CellBytes {
+) -> (FieldType, CellBytes) {
+    let to_field_type = field_rev.ty.into();
     match data.try_into() {
         Ok(any_cell_data) => {
             let AnyCellData { data, field_type } = any_cell_data;
-            let to_field_type = field_rev.ty.into();
-            match try_decode_cell_data(data.into(), field_rev, &field_type, &to_field_type) {
-                Ok(cell_bytes) => cell_bytes,
+            match try_decode_cell_data(data.into(), &field_type, &to_field_type, field_rev) {
+                Ok(cell_bytes) => (field_type, cell_bytes),
                 Err(e) => {
                     tracing::error!("Decode cell data failed, {:?}", e);
-                    CellBytes::default()
+                    (field_type, CellBytes::default())
                 }
             }
         }
@@ -94,42 +108,93 @@ pub fn decode_any_cell_data<T: TryInto<AnyCellData, Error = FlowyError> + Debug>
             // It's okay to ignore this error, because it's okay that the current cell can't
             // display the existing cell data. For example, the UI of the text cell will be blank if
             // the type of the data of cell is Number.
-            CellBytes::default()
+
+            (to_field_type, CellBytes::default())
+        }
+    }
+}
+
+pub fn decode_cell_data_to_string(
+    cell_data: CellData<String>,
+    from_field_type: &FieldType,
+    to_field_type: &FieldType,
+    field_rev: &FieldRevision,
+) -> FlowyResult<String> {
+    let cell_data = cell_data.try_into_inner()?;
+    let get_cell_display_str = || {
+        let field_type: FieldTypeRevision = to_field_type.into();
+        let result = match to_field_type {
+            FieldType::RichText => field_rev
+                .get_type_option::<RichTextTypeOptionPB>(field_type)?
+                .display_string(cell_data.into(), from_field_type, field_rev),
+            FieldType::Number => field_rev
+                .get_type_option::<NumberTypeOptionPB>(field_type)?
+                .display_string(cell_data.into(), from_field_type, field_rev),
+            FieldType::DateTime => field_rev
+                .get_type_option::<DateTypeOptionPB>(field_type)?
+                .display_string(cell_data.into(), from_field_type, field_rev),
+            FieldType::SingleSelect => field_rev
+                .get_type_option::<SingleSelectTypeOptionPB>(field_type)?
+                .display_string(cell_data.into(), from_field_type, field_rev),
+            FieldType::MultiSelect => field_rev
+                .get_type_option::<MultiSelectTypeOptionPB>(field_type)?
+                .display_string(cell_data.into(), from_field_type, field_rev),
+            FieldType::Checkbox => field_rev
+                .get_type_option::<CheckboxTypeOptionPB>(field_type)?
+                .display_string(cell_data.into(), from_field_type, field_rev),
+            FieldType::URL => field_rev
+                .get_type_option::<URLTypeOptionPB>(field_type)?
+                .display_string(cell_data.into(), from_field_type, field_rev),
+        };
+        Some(result)
+    };
+
+    match get_cell_display_str() {
+        Some(Ok(s)) => Ok(s),
+        Some(Err(err)) => {
+            tracing::error!("{:?}", err);
+            Ok("".to_owned())
         }
+        None => Ok("".to_owned()),
     }
 }
 
+/// Use the `to_field_type`'s TypeOption to parse the cell data into `from_field_type` type's data.
+///
+/// Each `FieldType` has its corresponding `TypeOption` that implements the `CellDisplayable`
+/// and `CellDataOperation` traits.
+///
 pub fn try_decode_cell_data(
     cell_data: CellData<String>,
+    from_field_type: &FieldType,
+    to_field_type: &FieldType,
     field_rev: &FieldRevision,
-    s_field_type: &FieldType,
-    t_field_type: &FieldType,
 ) -> FlowyResult<CellBytes> {
     let cell_data = cell_data.try_into_inner()?;
     let get_cell_data = || {
-        let field_type: FieldTypeRevision = t_field_type.into();
-        let data = match t_field_type {
+        let field_type: FieldTypeRevision = to_field_type.into();
+        let data = match to_field_type {
             FieldType::RichText => field_rev
                 .get_type_option::<RichTextTypeOptionPB>(field_type)?
-                .decode_cell_data(cell_data.into(), s_field_type, field_rev),
+                .decode_cell_data(cell_data.into(), from_field_type, field_rev),
             FieldType::Number => field_rev
                 .get_type_option::<NumberTypeOptionPB>(field_type)?
-                .decode_cell_data(cell_data.into(), s_field_type, field_rev),
+                .decode_cell_data(cell_data.into(), from_field_type, field_rev),
             FieldType::DateTime => field_rev
                 .get_type_option::<DateTypeOptionPB>(field_type)?
-                .decode_cell_data(cell_data.into(), s_field_type, field_rev),
+                .decode_cell_data(cell_data.into(), from_field_type, field_rev),
             FieldType::SingleSelect => field_rev
                 .get_type_option::<SingleSelectTypeOptionPB>(field_type)?
-                .decode_cell_data(cell_data.into(), s_field_type, field_rev),
+                .decode_cell_data(cell_data.into(), from_field_type, field_rev),
             FieldType::MultiSelect => field_rev
                 .get_type_option::<MultiSelectTypeOptionPB>(field_type)?
-                .decode_cell_data(cell_data.into(), s_field_type, field_rev),
+                .decode_cell_data(cell_data.into(), from_field_type, field_rev),
             FieldType::Checkbox => field_rev
                 .get_type_option::<CheckboxTypeOptionPB>(field_type)?
-                .decode_cell_data(cell_data.into(), s_field_type, field_rev),
+                .decode_cell_data(cell_data.into(), from_field_type, field_rev),
             FieldType::URL => field_rev
                 .get_type_option::<URLTypeOptionPB>(field_type)?
-                .decode_cell_data(cell_data.into(), s_field_type, field_rev),
+                .decode_cell_data(cell_data.into(), from_field_type, field_rev),
         };
         Some(data)
     };
@@ -224,6 +289,12 @@ where
     }
 }
 
+impl std::convert::From<usize> for CellData<String> {
+    fn from(n: usize) -> Self {
+        CellData(Some(n.to_string()))
+    }
+}
+
 impl<T> std::convert::From<T> for CellData<T> {
     fn from(val: T) -> Self {
         CellData(Some(val))

+ 10 - 0
frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs

@@ -48,6 +48,16 @@ impl CellDisplayable<CheckboxCellData> for CheckboxTypeOptionPB {
         let cell_data = cell_data.try_into_inner()?;
         Ok(CellBytes::new(cell_data))
     }
+
+    fn display_string(
+        &self,
+        cell_data: CellData<CheckboxCellData>,
+        _decoded_field_type: &FieldType,
+        _field_rev: &FieldRevision,
+    ) -> FlowyResult<String> {
+        let cell_data = cell_data.try_into_inner()?;
+        Ok(cell_data.to_string())
+    }
 }
 
 impl CellDataOperation<CheckboxCellData, String> for CheckboxTypeOptionPB {

+ 11 - 0
frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option.rs

@@ -127,6 +127,17 @@ impl CellDisplayable<DateTimestamp> for DateTypeOptionPB {
         let date_cell_data = self.today_desc_from_timestamp(timestamp);
         CellBytes::from(date_cell_data)
     }
+
+    fn display_string(
+        &self,
+        cell_data: CellData<DateTimestamp>,
+        _decoded_field_type: &FieldType,
+        _field_rev: &FieldRevision,
+    ) -> FlowyResult<String> {
+        let timestamp = cell_data.try_into_inner()?;
+        let date_cell_data = self.today_desc_from_timestamp(timestamp);
+        Ok(date_cell_data.date)
+    }
 }
 
 impl CellDataOperation<DateTimestamp, DateCellChangesetPB> for DateTypeOptionPB {

+ 10 - 1
frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/format.rs

@@ -70,6 +70,15 @@ define_currency_set!(
             symbol: "RUB",
             symbol_first: false,
         },
+        PERCENT : {
+            code: "",
+            exponent: 2,
+            locale: EnIn,
+            minor_units: 1,
+            name: "percent",
+            symbol: "%",
+            symbol_first: false,
+        },
         USD : {
             code: "USD",
             exponent: 2,
@@ -435,7 +444,7 @@ impl NumberFormat {
             NumberFormat::Leu => number_currency::RON,
             NumberFormat::ArgentinePeso => number_currency::ARS,
             NumberFormat::UruguayanPeso => number_currency::UYU,
-            NumberFormat::Percent => number_currency::USD,
+            NumberFormat::Percent => number_currency::PERCENT,
         }
     }
 

+ 5 - 0
frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_tests.rs

@@ -93,6 +93,11 @@ mod tests {
                     assert_number(&type_option, "€0.5", "€0,5", &field_type, &field_rev);
                     assert_number(&type_option, "€1844", "€1.844", &field_type, &field_rev);
                 }
+                NumberFormat::Percent => {
+                    assert_number(&type_option, "1", "1%", &field_type, &field_rev);
+                    assert_number(&type_option, "10.1", "10.1%", &field_type, &field_rev);
+                    assert_number(&type_option, "100", "100%", &field_type, &field_rev);
+                }
                 _ => {}
             }
         }

+ 29 - 8
frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option.rs

@@ -1,6 +1,6 @@
 use crate::entities::FieldType;
 use crate::impl_type_option;
-use crate::services::cell::{CellBytes, CellData, CellDataChangeset, CellDataOperation};
+use crate::services::cell::{CellBytes, CellData, CellDataChangeset, CellDataOperation, CellDisplayable};
 use crate::services::field::type_options::number_type_option::format::*;
 use crate::services::field::{BoxTypeOptionBuilder, NumberCellData, TypeOptionBuilder};
 use bytes::Bytes;
@@ -77,7 +77,7 @@ impl NumberTypeOptionPB {
 
     pub(crate) fn format_cell_data(&self, s: &str) -> FlowyResult<NumberCellData> {
         match self.format {
-            NumberFormat::Num | NumberFormat::Percent => match Decimal::from_str(s) {
+            NumberFormat::Num => match Decimal::from_str(s) {
                 Ok(value, ..) => Ok(NumberCellData::from_decimal(value)),
                 Err(_) => Ok(NumberCellData::new()),
             },
@@ -102,22 +102,43 @@ pub(crate) fn strip_currency_symbol<T: ToString>(s: T) -> String {
     s
 }
 
+impl CellDisplayable<String> for NumberTypeOptionPB {
+    fn display_data(
+        &self,
+        cell_data: CellData<String>,
+        _decoded_field_type: &FieldType,
+        _field_rev: &FieldRevision,
+    ) -> FlowyResult<CellBytes> {
+        let cell_data: String = cell_data.try_into_inner()?;
+        match self.format_cell_data(&cell_data) {
+            Ok(num) => Ok(CellBytes::new(num.to_string())),
+            Err(_) => Ok(CellBytes::default()),
+        }
+    }
+
+    fn display_string(
+        &self,
+        cell_data: CellData<String>,
+        _decoded_field_type: &FieldType,
+        _field_rev: &FieldRevision,
+    ) -> FlowyResult<String> {
+        let cell_data: String = cell_data.try_into_inner()?;
+        Ok(cell_data)
+    }
+}
+
 impl CellDataOperation<String, String> for NumberTypeOptionPB {
     fn decode_cell_data(
         &self,
         cell_data: CellData<String>,
         decoded_field_type: &FieldType,
-        _field_rev: &FieldRevision,
+        field_rev: &FieldRevision,
     ) -> FlowyResult<CellBytes> {
         if decoded_field_type.is_date() {
             return Ok(CellBytes::default());
         }
 
-        let cell_data: String = cell_data.try_into_inner()?;
-        match self.format_cell_data(&cell_data) {
-            Ok(num) => Ok(CellBytes::new(num.to_string())),
-            Err(_) => Ok(CellBytes::default()),
-        }
+        self.display_data(cell_data, decoded_field_type, field_rev)
     }
 
     fn apply_changeset(

+ 15 - 0
frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/select_option.rs

@@ -120,6 +120,21 @@ where
     ) -> FlowyResult<CellBytes> {
         CellBytes::from(self.selected_select_option(cell_data))
     }
+
+    fn display_string(
+        &self,
+        cell_data: CellData<SelectOptionIds>,
+        _decoded_field_type: &FieldType,
+        _field_rev: &FieldRevision,
+    ) -> FlowyResult<String> {
+        Ok(self
+            .selected_select_option(cell_data)
+            .select_options
+            .into_iter()
+            .map(|option| option.name)
+            .collect::<Vec<String>>()
+            .join(SELECTION_IDS_SEPARATOR))
+    }
 }
 
 pub fn select_option_operation(field_rev: &FieldRevision) -> FlowyResult<Box<dyn SelectOptionOperation>> {

+ 2 - 0
frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/mod.rs

@@ -1,3 +1,5 @@
 #![allow(clippy::module_inception)]
+mod text_tests;
 mod text_type_option;
+
 pub use text_type_option::*;

+ 49 - 0
frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_tests.rs

@@ -0,0 +1,49 @@
+#[cfg(test)]
+mod tests {
+    use crate::entities::FieldType;
+    use crate::services::cell::CellDataOperation;
+    use crate::services::field::FieldBuilder;
+    use crate::services::field::*;
+
+    // Test parser the cell data which field's type is FieldType::Date to cell data
+    // which field's type is FieldType::Text
+    #[test]
+    fn date_type_to_text_type() {
+        let type_option = RichTextTypeOptionPB::default();
+        let field_type = FieldType::DateTime;
+        let field_rev = FieldBuilder::from_field_type(&field_type).build();
+
+        assert_eq!(
+            type_option
+                .decode_cell_data(1647251762.into(), &field_type, &field_rev)
+                .unwrap()
+                .parser::<TextCellDataParser>()
+                .unwrap()
+                .as_ref(),
+            "Mar 14,2022"
+        );
+    }
+
+    // Test parser the cell data which field's type is FieldType::SingleSelect to cell data
+    // which field's type is FieldType::Text
+    #[test]
+    fn single_select_to_text_type() {
+        let type_option = RichTextTypeOptionPB::default();
+
+        let field_type = FieldType::SingleSelect;
+        let done_option = SelectOptionPB::new("Done");
+        let option_id = done_option.id.clone();
+        let single_select = SingleSelectTypeOptionBuilder::default().add_option(done_option.clone());
+        let field_rev = FieldBuilder::new(single_select).build();
+
+        assert_eq!(
+            type_option
+                .decode_cell_data(option_id.into(), &field_type, &field_rev)
+                .unwrap()
+                .parser::<TextCellDataParser>()
+                .unwrap()
+                .to_string(),
+            done_option.name,
+        );
+    }
+}

+ 29 - 85
frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_type_option.rs

@@ -1,8 +1,8 @@
 use crate::entities::FieldType;
 use crate::impl_type_option;
 use crate::services::cell::{
-    try_decode_cell_data, CellBytes, CellBytesParser, CellData, CellDataChangeset, CellDataOperation, CellDisplayable,
-    FromCellString,
+    decode_cell_data_to_string, CellBytes, CellBytesParser, CellData, CellDataChangeset, CellDataOperation,
+    CellDisplayable, FromCellString,
 };
 use crate::services::field::{BoxTypeOptionBuilder, TypeOptionBuilder};
 use bytes::Bytes;
@@ -44,6 +44,16 @@ impl CellDisplayable<String> for RichTextTypeOptionPB {
         let cell_str: String = cell_data.try_into_inner()?;
         Ok(CellBytes::new(cell_str))
     }
+
+    fn display_string(
+        &self,
+        cell_data: CellData<String>,
+        _decoded_field_type: &FieldType,
+        _field_rev: &FieldRevision,
+    ) -> FlowyResult<String> {
+        let cell_str: String = cell_data.try_into_inner()?;
+        Ok(cell_str)
+    }
 }
 
 impl CellDataOperation<String, String> for RichTextTypeOptionPB {
@@ -57,8 +67,10 @@ impl CellDataOperation<String, String> for RichTextTypeOptionPB {
             || decoded_field_type.is_single_select()
             || decoded_field_type.is_multi_select()
             || decoded_field_type.is_number()
+            || decoded_field_type.is_url()
         {
-            try_decode_cell_data(cell_data, field_rev, decoded_field_type, decoded_field_type)
+            let s = decode_cell_data_to_string(cell_data, decoded_field_type, decoded_field_type, field_rev);
+            Ok(CellBytes::new(s.unwrap_or_else(|_| "".to_owned())))
         } else {
             self.display_data(cell_data, decoded_field_type, field_rev)
         }
@@ -85,6 +97,14 @@ impl AsRef<str> for TextCellData {
     }
 }
 
+impl std::ops::Deref for TextCellData {
+    type Target = String;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
 impl FromCellString for TextCellData {
     fn from_cell_str(s: &str) -> FlowyResult<Self>
     where
@@ -94,6 +114,12 @@ impl FromCellString for TextCellData {
     }
 }
 
+impl ToString for TextCellData {
+    fn to_string(&self) -> String {
+        self.0.clone()
+    }
+}
+
 pub struct TextCellDataParser();
 impl CellBytesParser for TextCellDataParser {
     type Object = TextCellData;
@@ -104,85 +130,3 @@ impl CellBytesParser for TextCellDataParser {
         }
     }
 }
-
-#[cfg(test)]
-mod tests {
-    use crate::entities::FieldType;
-    use crate::services::cell::CellDataOperation;
-
-    use crate::services::field::FieldBuilder;
-    use crate::services::field::*;
-
-    #[test]
-    fn text_description_test() {
-        let type_option = RichTextTypeOptionPB::default();
-
-        // date
-        let field_type = FieldType::DateTime;
-        let date_time_field_rev = FieldBuilder::from_field_type(&field_type).build();
-
-        assert_eq!(
-            type_option
-                .decode_cell_data(1647251762.to_string().into(), &field_type, &date_time_field_rev)
-                .unwrap()
-                .parser::<DateCellDataParser>()
-                .unwrap()
-                .date,
-            "Mar 14,2022".to_owned()
-        );
-
-        // Single select
-        let done_option = SelectOptionPB::new("Done");
-        let done_option_id = done_option.id.clone();
-        let single_select = SingleSelectTypeOptionBuilder::default().add_option(done_option.clone());
-        let single_select_field_rev = FieldBuilder::new(single_select).build();
-
-        assert_eq!(
-            type_option
-                .decode_cell_data(
-                    done_option_id.into(),
-                    &FieldType::SingleSelect,
-                    &single_select_field_rev
-                )
-                .unwrap()
-                .parser::<SelectOptionCellDataParser>()
-                .unwrap()
-                .select_options,
-            vec![done_option],
-        );
-
-        // Multiple select
-        let google_option = SelectOptionPB::new("Google");
-        let facebook_option = SelectOptionPB::new("Facebook");
-        let ids = vec![google_option.id.clone(), facebook_option.id.clone()].join(SELECTION_IDS_SEPARATOR);
-        let cell_data_changeset = SelectOptionCellChangeset::from_insert(&ids).to_str();
-        let multi_select = MultiSelectTypeOptionBuilder::default()
-            .add_option(google_option.clone())
-            .add_option(facebook_option.clone());
-        let multi_select_field_rev = FieldBuilder::new(multi_select).build();
-        let multi_type_option = MultiSelectTypeOptionPB::from(&multi_select_field_rev);
-        let cell_data = multi_type_option
-            .apply_changeset(cell_data_changeset.into(), None)
-            .unwrap();
-        assert_eq!(
-            type_option
-                .decode_cell_data(cell_data.into(), &FieldType::MultiSelect, &multi_select_field_rev)
-                .unwrap()
-                .parser::<SelectOptionCellDataParser>()
-                .unwrap()
-                .select_options,
-            vec![google_option, facebook_option]
-        );
-
-        //Number
-        let number = NumberTypeOptionBuilder::default().set_format(NumberFormat::USD);
-        let number_field_rev = FieldBuilder::new(number).build();
-        assert_eq!(
-            type_option
-                .decode_cell_data("18443".to_owned().into(), &FieldType::Number, &number_field_rev)
-                .unwrap()
-                .to_string(),
-            "$18,443".to_owned()
-        );
-    }
-}

+ 10 - 0
frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option.rs

@@ -42,6 +42,16 @@ impl CellDisplayable<URLCellDataPB> for URLTypeOptionPB {
         let cell_data: URLCellDataPB = cell_data.try_into_inner()?;
         CellBytes::from(cell_data)
     }
+
+    fn display_string(
+        &self,
+        cell_data: CellData<URLCellDataPB>,
+        _decoded_field_type: &FieldType,
+        _field_rev: &FieldRevision,
+    ) -> FlowyResult<String> {
+        let cell_data: URLCellDataPB = cell_data.try_into_inner()?;
+        Ok(cell_data.content)
+    }
 }
 
 impl CellDataOperation<URLCellDataPB, String> for URLTypeOptionPB {

+ 8 - 3
frontend/rust-lib/flowy-grid/src/services/grid_editor.rs

@@ -368,6 +368,7 @@ impl GridRevisionEditor {
         Ok(row_pb)
     }
 
+    #[tracing::instrument(level = "trace", skip_all, err)]
     pub async fn move_group(&self, params: MoveGroupParams) -> FlowyResult<()> {
         let _ = self.view_manager.move_group(params).await?;
         Ok(())
@@ -435,14 +436,18 @@ impl GridRevisionEditor {
     }
 
     pub async fn get_cell(&self, params: &GridCellIdParams) -> Option<GridCellPB> {
-        let cell_bytes = self.get_cell_bytes(params).await?;
-        Some(GridCellPB::new(&params.field_id, cell_bytes.to_vec()))
+        let (field_type, cell_bytes) = self.decode_any_cell_data(params).await?;
+        Some(GridCellPB::new(&params.field_id, field_type, cell_bytes.to_vec()))
     }
 
     pub async fn get_cell_bytes(&self, params: &GridCellIdParams) -> Option<CellBytes> {
+        let (_, cell_data) = self.decode_any_cell_data(params).await?;
+        Some(cell_data)
+    }
+
+    async fn decode_any_cell_data(&self, params: &GridCellIdParams) -> Option<(FieldType, CellBytes)> {
         let field_rev = self.get_field_rev(&params.field_id).await?;
         let row_rev = self.block_manager.get_row_rev(&params.row_id).await.ok()??;
-
         let cell_rev = row_rev.cells.get(&params.field_id)?.clone();
         Some(decode_any_cell_data(cell_rev.data, &field_rev))
     }

+ 16 - 6
frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs

@@ -173,6 +173,7 @@ impl GridViewRevisionEditor {
         Ok(groups.into_iter().map(GroupPB::from).collect())
     }
 
+    #[tracing::instrument(level = "trace", skip(self), err)]
     pub(crate) async fn move_group(&self, params: MoveGroupParams) -> FlowyResult<()> {
         let _ = self
             .group_controller
@@ -180,7 +181,7 @@ impl GridViewRevisionEditor {
             .await
             .move_group(&params.from_group_id, &params.to_group_id)?;
         match self.group_controller.read().await.get_group(&params.from_group_id) {
-            None => {}
+            None => tracing::warn!("Can not find the group with id: {}", params.from_group_id),
             Some((index, group)) => {
                 let inserted_group = InsertedGroupPB {
                     group: GroupPB::from(group),
@@ -201,6 +202,10 @@ impl GridViewRevisionEditor {
         Ok(())
     }
 
+    pub(crate) async fn group_id(&self) -> String {
+        self.group_controller.read().await.field_id().to_owned()
+    }
+
     pub(crate) async fn get_setting(&self) -> GridSettingPB {
         let field_revs = self.field_delegate.get_field_revs().await;
         let grid_setting = make_grid_setting(&*self.pad.read().await, &field_revs);
@@ -224,7 +229,11 @@ impl GridViewRevisionEditor {
             let _ = self
                 .modify(|pad| {
                     let configuration = default_group_configuration(&field_rev);
-                    let changeset = pad.insert_group(&params.field_id, &params.field_type_rev, configuration)?;
+                    let changeset = pad.insert_or_update_group_configuration(
+                        &params.field_id,
+                        &params.field_type_rev,
+                        configuration,
+                    )?;
                     Ok(changeset)
                 })
                 .await?;
@@ -492,10 +501,11 @@ impl GroupConfigurationWriter for GroupConfigurationWriterImpl {
         let field_id = field_id.to_owned();
 
         wrap_future(async move {
-            let changeset = view_pad
-                .write()
-                .await
-                .insert_group(&field_id, &field_type, group_configuration)?;
+            let changeset = view_pad.write().await.insert_or_update_group_configuration(
+                &field_id,
+                &field_type,
+                group_configuration,
+            )?;
 
             if let Some(changeset) = changeset {
                 let _ = apply_change(&user_id, rev_manager, changeset).await?;

+ 5 - 1
frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs

@@ -178,12 +178,16 @@ impl GridViewManager {
     #[tracing::instrument(level = "trace", skip(self), err)]
     pub(crate) async fn did_update_field(&self, field_id: &str, is_type_option_changed: bool) -> FlowyResult<()> {
         let view_editor = self.get_default_view_editor().await?;
+        // Only the field_id of the updated field is equal to the field_id of the group.
+        // Update the group
+        if view_editor.group_id().await != field_id {
+            return Ok(());
+        }
         if is_type_option_changed {
             let _ = view_editor.group_by_field(field_id).await?;
         } else {
             let _ = view_editor.did_update_field(field_id).await?;
         }
-
         Ok(())
     }
 

+ 45 - 62
frontend/rust-lib/flowy-grid/src/services/group/configuration.rs

@@ -1,5 +1,5 @@
 use crate::entities::{GroupPB, GroupViewChangesetPB};
-use crate::services::group::{default_group_configuration, GeneratedGroup, Group};
+use crate::services::group::{default_group_configuration, make_default_group, GeneratedGroup, Group};
 use flowy_error::{FlowyError, FlowyResult};
 use flowy_grid_data_model::revision::{
     FieldRevision, FieldTypeRevision, GroupConfigurationContentSerde, GroupConfigurationRevision, GroupRevision,
@@ -29,10 +29,7 @@ impl<T> std::fmt::Display for GroupContext<T> {
         self.groups_map.iter().for_each(|(_, group)| {
             let _ = f.write_fmt(format_args!("Group:{} has {} rows \n", group.id, group.rows.len()));
         });
-        let _ = f.write_fmt(format_args!(
-            "Default group has {} rows \n",
-            self.default_group.rows.len()
-        ));
+
         Ok(())
     }
 }
@@ -44,7 +41,7 @@ pub struct GroupContext<C> {
     field_rev: Arc<FieldRevision>,
     groups_map: IndexMap<String, Group>,
     /// default_group is used to store the rows that don't belong to any groups.
-    default_group: Group,
+    // default_group: Group,
     writer: Arc<dyn GroupConfigurationWriter>,
 }
 
@@ -59,16 +56,6 @@ where
         reader: Arc<dyn GroupConfigurationReader>,
         writer: Arc<dyn GroupConfigurationWriter>,
     ) -> FlowyResult<Self> {
-        let default_group_id = format!("{}_default_group", view_id);
-        let default_group = Group {
-            id: default_group_id,
-            field_id: field_rev.id.clone(),
-            name: format!("No {}", field_rev.name),
-            is_default: true,
-            is_visible: true,
-            rows: vec![],
-            filter_content: "".to_string(),
-        };
         let configuration = match reader.get_configuration().await {
             None => {
                 let default_configuration = default_group_configuration(&field_rev);
@@ -80,24 +67,22 @@ where
             Some(configuration) => configuration,
         };
 
-        // let configuration = C::from_configuration_content(&configuration_rev.content)?;
         Ok(Self {
             view_id,
             field_rev,
             groups_map: IndexMap::new(),
-            default_group,
             writer,
             configuration,
             configuration_content: PhantomData,
         })
     }
 
-    pub(crate) fn get_default_group(&self) -> &Group {
-        &self.default_group
+    pub(crate) fn get_default_group(&self) -> Option<&Group> {
+        self.groups_map.get(&self.field_rev.id)
     }
 
-    pub(crate) fn get_mut_default_group(&mut self) -> &mut Group {
-        &mut self.default_group
+    pub(crate) fn get_mut_default_group(&mut self) -> Option<&mut Group> {
+        self.groups_map.get_mut(&self.field_rev.id)
     }
 
     /// Returns the groups without the default group
@@ -122,8 +107,6 @@ where
         self.groups_map.iter_mut().for_each(|(_, group)| {
             each(group);
         });
-
-        each(&mut self.default_group);
     }
 
     pub(crate) fn move_group(&mut self, from_id: &str, to_id: &str) -> FlowyResult<()> {
@@ -131,18 +114,23 @@ where
         let to_index = self.groups_map.get_index_of(to_id);
         match (from_index, to_index) {
             (Some(from_index), Some(to_index)) => {
-                self.groups_map.swap_indices(from_index, to_index);
+                self.groups_map.move_index(from_index, to_index);
+
                 self.mut_configuration(|configuration| {
                     let from_index = configuration.groups.iter().position(|group| group.id == from_id);
                     let to_index = configuration.groups.iter().position(|group| group.id == to_id);
-                    if let (Some(from), Some(to)) = (from_index, to_index) {
-                        configuration.groups.swap(from, to);
+                    tracing::info!("Configuration groups: {:?} ", configuration.groups);
+                    if let (Some(from), Some(to)) = &(from_index, to_index) {
+                        tracing::trace!("Move group from index:{:?} to index:{:?}", from_index, to_index);
+                        let group = configuration.groups.remove(*from);
+                        configuration.groups.insert(*to, group);
                     }
-                    true
+
+                    from_index.is_some() && to_index.is_some()
                 })?;
                 Ok(())
             }
-            _ => Err(FlowyError::out_of_bounds()),
+            _ => Err(FlowyError::record_not_found().context("Moving group failed. Groups are not exist")),
         }
     }
 
@@ -150,7 +138,6 @@ where
     pub(crate) fn init_groups(
         &mut self,
         generated_groups: Vec<GeneratedGroup>,
-        reset: bool,
     ) -> FlowyResult<Option<GroupViewChangesetPB>> {
         let mut new_groups = vec![];
         let mut filter_content_map = HashMap::new();
@@ -159,16 +146,17 @@ where
             new_groups.push(generate_group.group_rev);
         });
 
+        let mut old_groups = self.configuration.groups.clone();
+        if !old_groups.iter().any(|group| group.id == self.field_rev.id) {
+            old_groups.push(make_default_group(&self.field_rev));
+        }
+
         let MergeGroupResult {
             mut all_group_revs,
             new_group_revs,
             updated_group_revs: _,
             deleted_group_revs,
-        } = if reset {
-            merge_groups(&[], new_groups)
-        } else {
-            merge_groups(&self.configuration.groups, new_groups)
-        };
+        } = merge_groups(old_groups, new_groups);
 
         let deleted_group_ids = deleted_group_revs
             .into_iter()
@@ -197,31 +185,23 @@ where
                     Some(pos) => {
                         let mut old_group = configuration.groups.remove(pos);
                         group_rev.update_with_other(&old_group);
+                        is_changed = is_group_changed(group_rev, &old_group);
 
-                        // Take the GroupRevision if the name has changed
-                        if is_group_changed(group_rev, &old_group) {
-                            old_group.name = group_rev.name.clone();
-                            is_changed = true;
-                            configuration.groups.insert(pos, old_group);
-                        }
+                        old_group.name = group_rev.name.clone();
+                        configuration.groups.insert(pos, old_group);
                     }
                 }
             }
             is_changed
         })?;
 
-        // The len of the filter_content_map should equal to the len of the all_group_revs
-        debug_assert_eq!(filter_content_map.len(), all_group_revs.len());
         all_group_revs.into_iter().for_each(|group_rev| {
-            if let Some(filter_content) = filter_content_map.get(&group_rev.id) {
-                let group = Group::new(
-                    group_rev.id,
-                    self.field_rev.id.clone(),
-                    group_rev.name,
-                    filter_content.clone(),
-                );
-                self.groups_map.insert(group.id.clone(), group);
-            }
+            let filter_content = filter_content_map
+                .get(&group_rev.id)
+                .cloned()
+                .unwrap_or_else(|| "".to_owned());
+            let group = Group::new(group_rev.id, self.field_rev.id.clone(), group_rev.name, filter_content);
+            self.groups_map.insert(group.id.clone(), group);
         });
 
         let new_groups = new_group_revs
@@ -269,6 +249,7 @@ where
         Ok(())
     }
 
+    #[tracing::instrument(level = "trace", skip_all, err)]
     pub fn save_configuration(&self) -> FlowyResult<()> {
         let configuration = (&*self.configuration).clone();
         let writer = self.writer.clone();
@@ -311,13 +292,14 @@ where
     }
 }
 
-fn merge_groups(old_groups: &[GroupRevision], new_groups: Vec<GroupRevision>) -> MergeGroupResult {
+fn merge_groups(old_groups: Vec<GroupRevision>, new_groups: Vec<GroupRevision>) -> MergeGroupResult {
     let mut merge_result = MergeGroupResult::new();
-    if old_groups.is_empty() {
-        merge_result.all_group_revs = new_groups.clone();
-        merge_result.new_group_revs = new_groups;
-        return merge_result;
-    }
+    // if old_groups.is_empty() {
+    //     merge_result.all_group_revs.extend(new_groups.clone());
+    //     merge_result.all_group_revs.push(default_group);
+    //     merge_result.new_group_revs = new_groups;
+    //     return merge_result;
+    // }
 
     // group_map is a helper map is used to filter out the new groups.
     let mut new_group_map: IndexMap<String, GroupRevision> = IndexMap::new();
@@ -329,19 +311,20 @@ fn merge_groups(old_groups: &[GroupRevision], new_groups: Vec<GroupRevision>) ->
     for old in old_groups {
         if let Some(new) = new_group_map.remove(&old.id) {
             merge_result.all_group_revs.push(new.clone());
-            if is_group_changed(&new, old) {
+            if is_group_changed(&new, &old) {
                 merge_result.updated_group_revs.push(new);
             }
         } else {
-            merge_result.deleted_group_revs.push(old.clone());
+            merge_result.all_group_revs.push(old);
         }
     }
 
     // Find out the new groups
+    new_group_map.reverse();
     let new_groups = new_group_map.into_values();
     for (_, group) in new_groups.into_iter().enumerate() {
-        merge_result.all_group_revs.push(group.clone());
-        merge_result.new_group_revs.push(group);
+        merge_result.all_group_revs.insert(0, group.clone());
+        merge_result.new_group_revs.insert(0, group);
     }
     merge_result
 }

+ 30 - 24
frontend/rust-lib/flowy-grid/src/services/group/controller.rs

@@ -88,7 +88,7 @@ where
     pub async fn new(field_rev: &Arc<FieldRevision>, mut configuration: GroupContext<C>) -> FlowyResult<Self> {
         let type_option = field_rev.get_type_option::<T>(field_rev.ty);
         let groups = G::generate_groups(&field_rev.id, &configuration, &type_option);
-        let _ = configuration.init_groups(groups, true)?;
+        let _ = configuration.init_groups(groups)?;
 
         Ok(Self {
             field_id: field_rev.id.clone(),
@@ -105,8 +105,8 @@ where
         &mut self,
         row_rev: &RowRevision,
         other_group_changesets: &[GroupChangesetPB],
-    ) -> GroupChangesetPB {
-        let default_group = self.group_ctx.get_mut_default_group();
+    ) -> Option<GroupChangesetPB> {
+        let default_group = self.group_ctx.get_mut_default_group()?;
 
         // [other_group_inserted_row] contains all the inserted rows except the default group.
         let other_group_inserted_row = other_group_changesets
@@ -163,7 +163,7 @@ where
         }
         default_group.rows.retain(|row| !deleted_row_ids.contains(&row.id));
         changeset.deleted_rows.extend(deleted_row_ids);
-        changeset
+        Some(changeset)
     }
 }
 
@@ -182,11 +182,14 @@ where
 
     fn groups(&self) -> Vec<Group> {
         if self.use_default_group() {
-            let mut groups: Vec<Group> = self.group_ctx.groups().into_iter().cloned().collect();
-            groups.push(self.group_ctx.get_default_group().clone());
-            groups
-        } else {
             self.group_ctx.groups().into_iter().cloned().collect()
+        } else {
+            self.group_ctx
+                .groups()
+                .into_iter()
+                .filter(|group| group.id != self.field_id)
+                .cloned()
+                .collect::<Vec<_>>()
         }
     }
 
@@ -205,7 +208,7 @@ where
 
             if let Some(cell_rev) = cell_rev {
                 let mut grouped_rows: Vec<GroupedRow> = vec![];
-                let cell_bytes = decode_any_cell_data(cell_rev.data, field_rev);
+                let cell_bytes = decode_any_cell_data(cell_rev.data, field_rev).1;
                 let cell_data = cell_bytes.parser::<P>()?;
                 for group in self.group_ctx.groups() {
                     if self.can_group(&group.filter_content, &cell_data) {
@@ -216,17 +219,18 @@ where
                     }
                 }
 
-                if grouped_rows.is_empty() {
-                    self.group_ctx.get_mut_default_group().add_row(row_rev.into());
-                } else {
+                if !grouped_rows.is_empty() {
                     for group_row in grouped_rows {
                         if let Some(group) = self.group_ctx.get_mut_group(&group_row.group_id) {
                             group.add_row(group_row.row);
                         }
                     }
+                    continue;
                 }
-            } else {
-                self.group_ctx.get_mut_default_group().add_row(row_rev.into());
+            }
+            match self.group_ctx.get_mut_default_group() {
+                None => {}
+                Some(default_group) => default_group.add_row(row_rev.into()),
             }
         }
 
@@ -244,13 +248,14 @@ where
         field_rev: &FieldRevision,
     ) -> FlowyResult<Vec<GroupChangesetPB>> {
         if let Some(cell_rev) = row_rev.cells.get(&self.field_id) {
-            let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev);
+            let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev).1;
             let cell_data = cell_bytes.parser::<P>()?;
             let mut changesets = self.add_row_if_match(row_rev, &cell_data);
-            let default_group_changeset = self.update_default_group(row_rev, &changesets);
-            tracing::trace!("default_group_changeset: {}", default_group_changeset);
-            if !default_group_changeset.is_empty() {
-                changesets.push(default_group_changeset);
+            if let Some(default_group_changeset) = self.update_default_group(row_rev, &changesets) {
+                tracing::trace!("default_group_changeset: {}", default_group_changeset);
+                if !default_group_changeset.is_empty() {
+                    changesets.push(default_group_changeset);
+                }
             }
             Ok(changesets)
         } else {
@@ -265,15 +270,16 @@ where
     ) -> FlowyResult<Vec<GroupChangesetPB>> {
         // if the cell_rev is none, then the row must be crated from the default group.
         if let Some(cell_rev) = row_rev.cells.get(&self.field_id) {
-            let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev);
+            let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev).1;
             let cell_data = cell_bytes.parser::<P>()?;
             Ok(self.remove_row_if_match(row_rev, &cell_data))
-        } else {
-            let group = self.group_ctx.get_default_group();
+        } else if let Some(group) = self.group_ctx.get_default_group() {
             Ok(vec![GroupChangesetPB::delete(
                 group.id.clone(),
                 vec![row_rev.id.clone()],
             )])
+        } else {
+            Ok(vec![])
         }
     }
 
@@ -285,7 +291,7 @@ where
         };
 
         if let Some(cell_rev) = cell_rev {
-            let cell_bytes = decode_any_cell_data(cell_rev.data, context.field_rev);
+            let cell_bytes = decode_any_cell_data(cell_rev.data, context.field_rev).1;
             let cell_data = cell_bytes.parser::<P>()?;
             Ok(self.move_row(&cell_data, context))
         } else {
@@ -297,7 +303,7 @@ where
     fn did_update_field(&mut self, field_rev: &FieldRevision) -> FlowyResult<Option<GroupViewChangesetPB>> {
         let type_option = field_rev.get_type_option::<T>(field_rev.ty);
         let groups = G::generate_groups(&field_rev.id, &self.group_ctx, &type_option);
-        let changeset = self.group_ctx.init_groups(groups, false)?;
+        let changeset = self.group_ctx.init_groups(groups)?;
         Ok(changeset)
     }
 }

+ 3 - 2
frontend/rust-lib/flowy-grid/src/services/group/entities.rs

@@ -9,16 +9,17 @@ pub struct Group {
     pub is_visible: bool,
     pub(crate) rows: Vec<RowPB>,
 
-    /// [content] is used to determine which group the cell belongs to.
+    /// [filter_content] is used to determine which group the cell belongs to.
     pub filter_content: String,
 }
 
 impl Group {
     pub fn new(id: String, field_id: String, name: String, filter_content: String) -> Self {
+        let is_default = id == field_id;
         Self {
             id,
             field_id,
-            is_default: false,
+            is_default,
             is_visible: true,
             name,
             rows: vec![],

+ 21 - 3
frontend/rust-lib/flowy-grid/src/services/group/group_util.rs

@@ -8,8 +8,8 @@ use crate::services::group::{
 use flowy_error::FlowyResult;
 use flowy_grid_data_model::revision::{
     CheckboxGroupConfigurationRevision, DateGroupConfigurationRevision, FieldRevision, GroupConfigurationRevision,
-    LayoutRevision, NumberGroupConfigurationRevision, RowRevision, SelectOptionGroupConfigurationRevision,
-    TextGroupConfigurationRevision, UrlGroupConfigurationRevision,
+    GroupRevision, LayoutRevision, NumberGroupConfigurationRevision, RowRevision,
+    SelectOptionGroupConfigurationRevision, TextGroupConfigurationRevision, UrlGroupConfigurationRevision,
 };
 use std::sync::Arc;
 
@@ -79,7 +79,7 @@ pub fn default_group_configuration(field_rev: &FieldRevision) -> GroupConfigurat
     let field_id = field_rev.id.clone();
     let field_type_rev = field_rev.ty;
     let field_type: FieldType = field_rev.ty.into();
-    match field_type {
+    let mut group_configuration_rev = match field_type {
         FieldType::RichText => {
             GroupConfigurationRevision::new(field_id, field_type_rev, TextGroupConfigurationRevision::default())
                 .unwrap()
@@ -112,5 +112,23 @@ pub fn default_group_configuration(field_rev: &FieldRevision) -> GroupConfigurat
         FieldType::URL => {
             GroupConfigurationRevision::new(field_id, field_type_rev, UrlGroupConfigurationRevision::default()).unwrap()
         }
+    };
+
+    // Append the no `status` group
+    let default_group_rev = GroupRevision {
+        id: field_rev.id.clone(),
+        name: format!("No {}", field_rev.name),
+        visible: true,
+    };
+
+    group_configuration_rev.groups.push(default_group_rev);
+    group_configuration_rev
+}
+
+pub fn make_default_group(field_rev: &FieldRevision) -> GroupRevision {
+    GroupRevision {
+        id: field_rev.id.clone(),
+        name: format!("No {}", field_rev.name),
+        visible: true,
     }
 }

+ 23 - 1
frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs

@@ -370,6 +370,28 @@ async fn group_move_group_test() {
     test.run_scripts(scripts).await;
 }
 
+#[tokio::test]
+async fn group_default_move_group_test() {
+    let mut test = GridGroupTest::new().await;
+    let group_0 = test.group_at_index(0).await;
+    let group_3 = test.group_at_index(3).await;
+    let scripts = vec![
+        MoveGroup {
+            from_group_index: 3,
+            to_group_index: 0,
+        },
+        AssertGroup {
+            group_index: 0,
+            expected_group: group_3,
+        },
+        AssertGroup {
+            group_index: 1,
+            expected_group: group_0,
+        },
+    ];
+    test.run_scripts(scripts).await;
+}
+
 #[tokio::test]
 async fn group_insert_single_select_option_test() {
     let mut test = GridGroupTest::new().await;
@@ -402,7 +424,7 @@ async fn group_group_by_other_field() {
             group_index: 1,
             row_count: 2,
         },
-        AssertGroupCount(4),
+        AssertGroupCount(5),
     ];
     test.run_scripts(scripts).await;
 }

+ 8 - 2
frontend/rust-lib/flowy-user/src/entities/user_setting.rs

@@ -1,5 +1,6 @@
 use flowy_derive::ProtoBuf;
 use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
 
 #[derive(ProtoBuf, Default, Debug, Clone)]
 pub struct UserPreferencesPB {
@@ -21,7 +22,11 @@ pub struct AppearanceSettingsPB {
 
     #[pb(index = 3)]
     #[serde(default = "DEFAULT_RESET_VALUE")]
-    pub reset_as_default: bool,
+    pub reset_to_default: bool,
+
+    #[pb(index = 4)]
+    #[serde(default)]
+    pub setting_key_value: HashMap<String, String>,
 }
 
 const DEFAULT_RESET_VALUE: fn() -> bool = || APPEARANCE_RESET_AS_DEFAULT;
@@ -52,7 +57,8 @@ impl std::default::Default for AppearanceSettingsPB {
         AppearanceSettingsPB {
             theme: APPEARANCE_DEFAULT_THEME.to_owned(),
             locale: LocaleSettingsPB::default(),
-            reset_as_default: APPEARANCE_RESET_AS_DEFAULT,
+            reset_to_default: APPEARANCE_RESET_AS_DEFAULT,
+            setting_key_value: HashMap::default(),
         }
     }
 }

+ 4 - 4
shared-lib/Cargo.lock

@@ -650,9 +650,9 @@ dependencies = [
 
 [[package]]
 name = "hashbrown"
-version = "0.11.2"
+version = "0.12.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
 
 [[package]]
 name = "heck"
@@ -732,9 +732,9 @@ dependencies = [
 
 [[package]]
 name = "indexmap"
-version = "1.8.1"
+version = "1.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee"
+checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
 dependencies = [
  "autocfg",
  "hashbrown",

+ 1 - 1
shared-lib/flowy-grid-data-model/Cargo.toml

@@ -12,7 +12,7 @@ serde_json = {version = "1.0"}
 serde_repr = "0.1"
 nanoid = "0.4.0"
 flowy-error-code = { path = "../flowy-error-code"}
-indexmap = {version = "1.8.1", features = ["serde"]}
+indexmap = {version = "1.9.1", features = ["serde"]}
 tracing = { version = "0.1", features = ["log"] }
 
 [build-dependencies]

+ 0 - 8
shared-lib/flowy-grid-data-model/src/revision/group_rev.rs

@@ -128,14 +128,6 @@ impl GroupRevision {
         }
     }
 
-    pub fn default_group(id: String, group_name: String) -> Self {
-        Self {
-            id,
-            name: group_name,
-            visible: true,
-        }
-    }
-
     pub fn update_with_other(&mut self, other: &GroupRevision) {
         self.visible = other.visible
     }

Деякі файли не було показано, через те що забагато файлів було змінено