Browse Source

Merge branch 'main' into main

Lucas.Xu 2 years ago
parent
commit
b8cbbf7454
100 changed files with 1900 additions and 667 deletions
  1. 25 0
      CHANGELOG.md
  2. 4 3
      frontend/app_flowy/assets/translations/en.json
  3. 1 1
      frontend/app_flowy/assets/translations/es-VE.json
  4. 2 2
      frontend/app_flowy/assets/translations/fr-FR.json
  5. 1 1
      frontend/app_flowy/assets/translations/id-ID.json
  6. 1 1
      frontend/app_flowy/assets/translations/ja-JP.json
  7. 7 7
      frontend/app_flowy/assets/translations/pt-BR.json
  8. 4 4
      frontend/app_flowy/assets/translations/ru-RU.json
  9. 23 9
      frontend/app_flowy/assets/translations/zh-CN.json
  10. 1 1
      frontend/app_flowy/assets/translations/zh-TW.json
  11. 7 7
      frontend/app_flowy/lib/plugins/board/application/board_bloc.dart
  12. 5 3
      frontend/app_flowy/lib/plugins/board/board.dart
  13. 6 14
      frontend/app_flowy/lib/plugins/board/presentation/board_page.dart
  14. 5 2
      frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_setting.dart
  15. 18 10
      frontend/app_flowy/lib/plugins/doc/document.dart
  16. 1 1
      frontend/app_flowy/lib/plugins/doc/document_page.dart
  17. 35 0
      frontend/app_flowy/lib/plugins/grid/application/cell/select_option_editor_bloc.dart
  18. 5 2
      frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_format_bloc.dart
  19. 5 3
      frontend/app_flowy/lib/plugins/grid/grid.dart
  20. 2 2
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart
  21. 3 3
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/text_field.dart
  22. 5 8
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/url_cell.dart
  23. 3 1
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart
  24. 4 4
      frontend/app_flowy/lib/plugins/trash/menu.dart
  25. 4 5
      frontend/app_flowy/lib/plugins/util.dart
  26. 3 3
      frontend/app_flowy/lib/startup/plugin/plugin.dart
  27. 5 5
      frontend/app_flowy/lib/startup/tasks/app_widget.dart
  28. 5 4
      frontend/app_flowy/lib/user/application/user_settings_service.dart
  29. 79 37
      frontend/app_flowy/lib/workspace/application/appearance.dart
  30. 3 3
      frontend/app_flowy/lib/workspace/application/view/view_listener.dart
  31. 23 29
      frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart
  32. 8 5
      frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart
  33. 23 7
      frontend/app_flowy/lib/workspace/presentation/home/menu/app/header/add_button.dart
  34. 1 2
      frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart
  35. 11 6
      frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/item.dart
  36. 1 2
      frontend/app_flowy/lib/workspace/presentation/settings/settings_dialog.dart
  37. 11 4
      frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart
  38. 5 4
      frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart
  39. 7 7
      frontend/app_flowy/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart
  40. 26 22
      frontend/app_flowy/lib/workspace/presentation/widgets/pop_up_action.dart
  41. 4 3
      frontend/app_flowy/packages/appflowy_board/CHANGELOG.md
  42. 1 1
      frontend/app_flowy/packages/appflowy_board/example/lib/main.dart
  43. 7 0
      frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart
  44. 7 4
      frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart
  45. 36 52
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart
  46. 8 8
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart
  47. 8 7
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group.dart
  48. 14 8
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group_data.dart
  49. 9 25
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart
  50. 48 19
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart
  51. 6 6
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart
  52. 111 58
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart
  53. 1 1
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_mixin.dart
  54. 3 2
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart
  55. 24 20
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart
  56. 1 1
      frontend/app_flowy/packages/appflowy_board/pubspec.yaml
  57. 9 0
      frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart
  58. 277 0
      frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart
  59. 1 1
      frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock
  60. 1 0
      frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml
  61. 4 0
      frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart
  62. 35 0
      frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_nl_NL.arb
  63. 16 16
      frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_pt_BR.arb
  64. 34 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart
  65. 83 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart
  66. 67 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart
  67. 43 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart
  68. 4 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart
  69. 4 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart
  70. 5 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart
  71. 5 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart
  72. 1 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart
  73. 8 11
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart
  74. 13 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart
  75. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart
  76. 8 8
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart
  77. 37 13
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart
  78. 21 14
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart
  79. 8 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart
  80. 3 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart
  81. 5 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart
  82. 8 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart
  83. 126 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart
  84. 21 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart
  85. 10 3
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart
  86. 2 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart
  87. 17 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart
  88. 5 7
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart
  89. 0 49
      frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_item_widget_test.dart
  90. 43 29
      frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_widget_test.dart
  91. 20 7
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart
  92. 154 0
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart
  93. 45 0
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart
  94. 12 8
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart
  95. 13 7
      frontend/app_flowy/packages/flowy_infra/lib/theme.dart
  96. 2 2
      frontend/app_flowy/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart
  97. 1 1
      frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart
  98. 39 28
      frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart
  99. 4 4
      frontend/rust-lib/Cargo.lock
  100. 9 0
      frontend/rust-lib/flowy-folder/src/entities/view.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

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

@@ -181,7 +181,7 @@
       "includeTime": " Include time",
       "dateFormatFriendly": "Month Day,Year",
       "dateFormatISO": "Year-Month-Day",
-      "dateFormatLocal": "Year/Month/Day",
+      "dateFormatLocal": "Month/Day/Year",
       "dateFormatUS": "Year/Month/Day",
       "timeFormat": " Time format",
       "invalidTimeFormat": "Invalid format",
@@ -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/es-VE.json

@@ -172,7 +172,7 @@
       "includeTime": " Incluir tiempo",
       "dateFormatFriendly": "Mes Día, Año",
       "dateFormatISO": "Año-Mes-Día",
-      "dateFormatLocal": "Año/Mes/Día",
+      "dateFormatLocal": "Mes/Día/Año",
       "dateFormatUS": "Año/Mes/Día",
       "timeFormat": " Time format",
       "invalidTimeFormat": "Formato de tiempo",

+ 2 - 2
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": {
@@ -170,7 +170,7 @@
       "includeTime": " Inclure l'heure",
       "dateFormatFriendly": "Mois Jour, Année",
       "dateFormatISO": "Année-Mois-Jour",
-      "dateFormatLocal": "Année/Mois/Jour",
+      "dateFormatLocal": "Mois/Jour/Année",
       "dateFormatUS": "Année/Mois/Jour",
       "timeFormat": " Format du temps",
       "invalidTimeFormat": "Format invalide",

+ 1 - 1
frontend/app_flowy/assets/translations/id-ID.json

@@ -173,7 +173,7 @@
       "includeTime": " Sertakan waktu",
       "dateFormatFriendly": "Bulan Hari,Tahun",
       "dateFormatISO": "Tahun-Bulan-Hari",
-      "dateFormatLocal": "Tahun/Bulan/Hari",
+      "dateFormatLocal": "Bulan/Hari/Tahun",
       "dateFormatUS": "Tahun/Bulan/Hari",
       "timeFormat": " Format waktu",
       "invalidTimeFormat": "Format yang tidak valid",

+ 1 - 1
frontend/app_flowy/assets/translations/ja-JP.json

@@ -165,7 +165,7 @@
       "includeTime": " 時刻を含める",
       "dateFormatFriendly": "月 日,年",
       "dateFormatISO": "年-月-日",
-      "dateFormatLocal": "年/月/日",
+      "dateFormatLocal": "月/日/年",
       "dateFormatUS": "年/月/日",
       "timeFormat": " 時刻書式",
       "timeFormatTwelveHour": "12 時間表記",

+ 7 - 7
frontend/app_flowy/assets/translations/pt-BR.json

@@ -63,13 +63,13 @@
     "deletePermanent": "Apagar permanentemente"
   },
   "dialogCreatePageNameHint": "Nome da página",
-    "questionBubble": {
+  "questionBubble": {
     "whatsNew": "O que há de novo?",
     "help": "Ajuda e Suporte",
     "debug": {
-    "name": "Informação de depuração",
-    "success": "Informação de depuração copiada para a área de transferência!",
-    "fail": "Falha ao copiar a informação de depuração para a área de transferência"
+      "name": "Informação de depuração",
+      "success": "Informação de depuração copiada para a área de transferência!",
+      "fail": "Falha ao copiar a informação de depuração para a área de transferência"
     }
   },
   "menuAppHeader": {
@@ -148,7 +148,7 @@
     "menu": {
       "appearance": "Aparência",
       "language": "Idioma",
-      "user":"Usuário",
+      "user": "Usuário",
       "open": "Abrir as Configurações"
     },
     "appearance": {
@@ -181,7 +181,7 @@
       "includeTime": "Incluir horário",
       "dateFormatFriendly": "Mês/Dia/Ano",
       "dateFormatISO": "Ano/Mês/Dia",
-      "dateFormatLocal": "Ano/Mês/Dia",
+      "dateFormatLocal": "Mês/Dia/Ano",
       "dateFormatUS": "Ano/Mês/Dia",
       "timeFormat": "Formato de hora",
       "invalidTimeFormat": "Formato Inválido",
@@ -231,4 +231,4 @@
       "create_new_card": "Novo"
     }
   }
-}
+}

+ 4 - 4
frontend/app_flowy/assets/translations/ru-RU.json

@@ -94,9 +94,9 @@
   },
   "tooltip": {
     "darkMode": "Переключиться в тёмную тему",
-     "openAsPage": "Открыть как страницу",
-     "addNewRow": "Добавить новую строку",
-     "openMenu": "Открыть меню"
+    "openAsPage": "Открыть как страницу",
+    "addNewRow": "Добавить новую строку",
+    "openMenu": "Открыть меню"
   },
   "sideBar": {
     "closeSidebar": "Закрыть боковое меню",
@@ -180,7 +180,7 @@
       "includeTime": " Время",
       "dateFormatFriendly": "День Месяц, Год",
       "dateFormatISO": "Год-Месяц-День",
-      "dateFormatLocal": "Год/Месяц/День",
+      "dateFormatLocal": "Месяц/День/Год",
       "dateFormatUS": "Год/Месяц/День",
       "timeFormat": " Форматировать время",
       "invalidTimeFormat": "Неверный формат",

+ 23 - 9
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": "隐藏",
@@ -177,7 +181,7 @@
       "includeTime": " 包含时间",
       "dateFormatFriendly": "月 日,年",
       "dateFormatISO": "年-月-日",
-      "dateFormatLocal": "年/月/日",
+      "dateFormatLocal": "月/日/年",
       "dateFormatUS": "年/月/日",
       "timeFormat": " 时间格式",
       "invalidTimeFormat": "时间格式错误",
@@ -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": "看板"
   }
 }

+ 1 - 1
frontend/app_flowy/assets/translations/zh-TW.json

@@ -173,7 +173,7 @@
       "includeTime": " 包含時間",
       "dateFormatFriendly": "月 日,年",
       "dateFormatISO": "年-月-日",
-      "dateFormatLocal": "年/月/日",
+      "dateFormatLocal": "月/日/年",
       "dateFormatUS": "年/月/日",
       "timeFormat": " 時間格式",
       "invalidTimeFormat": "格式無效",

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

+ 5 - 2
frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_setting.dart

@@ -106,8 +106,11 @@ class _SettingItem extends StatelessWidget {
       height: 30,
       child: FlowyButton(
         isSelected: isSelected,
-        text: FlowyText.medium(action.title(),
-            fontSize: 12, color: theme.textColor),
+        text: FlowyText.medium(
+          action.title(),
+          fontSize: 12,
+          color: theme.textColor,
+        ),
         hoverColor: theme.hover,
         onTap: () {
           context

+ 18 - 10
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(
@@ -195,9 +206,6 @@ class ShareActions with ActionList<ShareActionWrapper>, FlowyOverlayDelegate {
 
   ShareActions({required this.onSelected});
 
-  @override
-  double get maxWidth => 130;
-
   @override
   double get itemHeight => 22;
 

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

+ 35 - 0
frontend/app_flowy/lib/plugins/grid/application/cell/select_option_editor_bloc.dart

@@ -54,6 +54,9 @@ class SelectOptionCellEditorBloc
           selectOption: (_SelectOption value) {
             _onSelectOption(value.optionId);
           },
+          trySelectOption: (_TrySelectOption value) {
+            _trySelectOption(value.optionName, emit);
+          },
           filterOption: (_SelectOptionFilter value) {
             _filterOption(value.optionName, emit);
           },
@@ -100,6 +103,36 @@ class SelectOptionCellEditorBloc
     }
   }
 
+  void _trySelectOption(
+      String optionName, Emitter<SelectOptionEditorState> emit) async {
+    SelectOptionPB? matchingOption;
+    bool optionExistsButSelected = false;
+
+    for (final option in state.options) {
+      if (option.name.toLowerCase() == optionName.toLowerCase()) {
+        if (!state.selectedOptions.contains(option)) {
+          matchingOption = option;
+          break;
+        } else {
+          optionExistsButSelected = true;
+        }
+      }
+    }
+
+    // if there isn't a matching option at all, then create it
+    if (matchingOption == null && !optionExistsButSelected) {
+      _createOption(optionName);
+    }
+
+    // if there is an unselected matching option, select it
+    if (matchingOption != null) {
+      _selectOptionService.select(optionId: matchingOption.id);
+    }
+
+    // clear the filter
+    emit(state.copyWith(filter: none()));
+  }
+
   void _filterOption(String optionName, Emitter<SelectOptionEditorState> emit) {
     final _MakeOptionResult result =
         _makeOptions(Some(optionName), state.allOptions);
@@ -187,6 +220,8 @@ class SelectOptionEditorEvent with _$SelectOptionEditorEvent {
       _DeleteOption;
   const factory SelectOptionEditorEvent.filterOption(String optionName) =
       _SelectOptionFilter;
+  const factory SelectOptionEditorEvent.trySelectOption(String optionName) =
+      _TrySelectOption;
 }
 
 @freezed

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

+ 2 - 2
frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart

@@ -154,10 +154,10 @@ class _TextField extends StatelessWidget {
                   .read<SelectOptionCellEditorBloc>()
                   .add(SelectOptionEditorEvent.filterOption(text));
             },
-            onNewTag: (tagName) {
+            onSubmitted: (tagName) {
               context
                   .read<SelectOptionCellEditorBloc>()
-                  .add(SelectOptionEditorEvent.newOption(tagName));
+                  .add(SelectOptionEditorEvent.trySelectOption(tagName));
             },
           ),
         );

+ 3 - 3
frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/text_field.dart

@@ -17,7 +17,7 @@ class SelectOptionTextField extends StatefulWidget {
   final LinkedHashMap<String, SelectOptionPB> selectedOptionMap;
   final double distanceToText;
 
-  final Function(String) onNewTag;
+  final Function(String) onSubmitted;
   final Function(String) newText;
   final VoidCallback? onClick;
 
@@ -26,7 +26,7 @@ class SelectOptionTextField extends StatefulWidget {
     required this.selectedOptionMap,
     required this.distanceToText,
     required this.tagController,
-    required this.onNewTag,
+    required this.onSubmitted,
     required this.newText,
     this.onClick,
     TextEditingController? textController,
@@ -88,7 +88,7 @@ class _SelectOptionTextFieldState extends State<SelectOptionTextField> {
               }
 
               if (text.isNotEmpty) {
-                widget.onNewTag(text);
+                widget.onSubmitted(text);
                 focusNode.requestFocus();
               }
             },

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

+ 23 - 7
frontend/app_flowy/lib/workspace/presentation/home/menu/app/header/add_button.dart

@@ -61,13 +61,20 @@ class ActionList {
       itemBuilder: (context, index) => items[index],
       anchorContext: anchorContext,
       anchorDirection: AnchorDirection.bottomRight,
-      width: 120,
-      height: 80,
+      constraints: BoxConstraints(
+        minWidth: 120,
+        maxWidth: 280,
+        minHeight: items.length * (CreateItem.height),
+        maxHeight: items.length * (CreateItem.height),
+      ),
     );
   }
 }
 
 class CreateItem extends StatelessWidget {
+  static const double height = 30;
+  static const double verticalPadding = 6;
+
   final PluginBuilder pluginBuilder;
   final Function(PluginBuilder) onSelected;
   const CreateItem({
@@ -86,11 +93,20 @@ class CreateItem extends StatelessWidget {
       child: GestureDetector(
         behavior: HitTestBehavior.opaque,
         onTap: () => onSelected(pluginBuilder),
-        child: FlowyText.medium(
-          pluginBuilder.menuName,
-          color: theme.textColor,
-          fontSize: 12,
-        ).padding(horizontal: 10, vertical: 6),
+        child: ConstrainedBox(
+          constraints: const BoxConstraints(
+            minWidth: 120,
+            minHeight: CreateItem.height,
+          ),
+          child: Align(
+            alignment: Alignment.centerLeft,
+            child: FlowyText.medium(
+              pluginBuilder.menuName,
+              color: theme.textColor,
+              fontSize: 12,
+            ).padding(horizontal: 10),
+          ),
+        ),
       ),
     );
   }

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

+ 11 - 6
frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/item.dart

@@ -53,7 +53,7 @@ class ViewSectionItem extends StatelessWidget {
                 _handleAction(context, action);
               },
               child: Padding(
-                padding: const EdgeInsets.symmetric(vertical: 4),
+                padding: const EdgeInsets.symmetric(vertical: 2),
                 child: InkWell(
                   onTap: () => onSelected(context.read<ViewBloc>().state.view),
                   child: FlowyHover(
@@ -73,13 +73,18 @@ class ViewSectionItem extends StatelessWidget {
       BuildContext context, bool onHover, ViewState state, Color iconColor) {
     List<Widget> children = [
       SizedBox(
-          width: 16,
-          height: 16,
-          child: state.view.renderThumbnail(iconColor: iconColor)),
+        width: 16,
+        height: 16,
+        child: state.view.renderThumbnail(iconColor: iconColor),
+      ),
       const HSpace(2),
       Expanded(
-          child: FlowyText.regular(state.view.name,
-              fontSize: 12, overflow: TextOverflow.clip)),
+        child: FlowyText.regular(
+          state.view.name,
+          fontSize: 12,
+          overflow: TextOverflow.clip,
+        ),
+      ),
     ];
 
     if (onHover || state.isEditing) {

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

+ 7 - 7
frontend/app_flowy/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart

@@ -111,9 +111,6 @@ class QuestionBubbleActionSheet
     required this.onSelected,
   });
 
-  @override
-  double get maxWidth => 170;
-
   @override
   double get itemHeight => 22;
 
@@ -142,7 +139,7 @@ class QuestionBubbleActionSheet
   @override
   ListOverlayFooter? get footer => ListOverlayFooter(
         widget: const FlowyVersionDescription(),
-        height: 30,
+        height: 40,
         padding: const EdgeInsets.only(top: 6),
       );
 }
@@ -174,11 +171,14 @@ class FlowyVersionDescription extends StatelessWidget {
             children: [
               Divider(height: 1, color: theme.shader6, thickness: 1.0),
               const VSpace(6),
-              FlowyText("$appName $version.$buildNumber",
-                  fontSize: 12, color: theme.shader4),
+              FlowyText(
+                "$appName $version.$buildNumber",
+                fontSize: 12,
+                color: theme.shader4,
+              ),
             ],
           ).padding(
-            horizontal: ActionListSizes.itemHPadding + ActionListSizes.padding,
+            horizontal: ActionListSizes.itemHPadding + ActionListSizes.hPadding,
           );
         } else {
           return const CircularProgressIndicator();

+ 26 - 22
frontend/app_flowy/lib/workspace/presentation/widgets/pop_up_action.dart

@@ -13,7 +13,9 @@ abstract class ActionList<T extends ActionItem> {
 
   String get identifier => toString();
 
-  double get maxWidth => 162;
+  double get maxWidth => 300;
+
+  double get minWidth => 120;
 
   double get itemHeight => ActionListSizes.itemHeight;
 
@@ -29,28 +31,29 @@ abstract class ActionList<T extends ActionItem> {
     AnchorDirection anchorDirection = AnchorDirection.bottomRight,
     Offset? anchorOffset,
   }) {
-    final widgets = items
-        .map(
-          (action) => ActionCell<T>(
-            action: action,
-            itemHeight: itemHeight,
-            onSelected: (action) {
-              FlowyOverlay.of(buildContext).remove(identifier);
-              selectCallback(dartz.some(action));
-            },
-          ),
-        )
-        .toList();
-
     ListOverlay.showWithAnchor(
       buildContext,
       identifier: identifier,
-      itemCount: widgets.length,
-      itemBuilder: (context, index) => widgets[index],
+      itemCount: items.length,
+      itemBuilder: (context, index) {
+        final action = items[index];
+        return ActionCell<T>(
+          action: action,
+          itemHeight: itemHeight,
+          onSelected: (action) {
+            FlowyOverlay.of(buildContext).remove(identifier);
+            selectCallback(dartz.some(action));
+          },
+        );
+      },
       anchorContext: anchorContext ?? buildContext,
       anchorDirection: anchorDirection,
-      width: maxWidth,
-      height: widgets.length * (itemHeight + ActionListSizes.padding * 2),
+      constraints: BoxConstraints(
+        minHeight: items.length * (itemHeight + ActionListSizes.vPadding * 2),
+        maxHeight: items.length * (itemHeight + ActionListSizes.vPadding * 2),
+        maxWidth: maxWidth,
+        minWidth: minWidth,
+      ),
       delegate: delegate,
       anchorOffset: anchorOffset,
       footer: footer,
@@ -66,7 +69,8 @@ abstract class ActionItem {
 class ActionListSizes {
   static double itemHPadding = 10;
   static double itemHeight = 20;
-  static double padding = 6;
+  static double vPadding = 6;
+  static double hPadding = 10;
 }
 
 class ActionCell<T extends ActionItem> extends StatelessWidget {
@@ -93,7 +97,7 @@ class ActionCell<T extends ActionItem> extends StatelessWidget {
         child: SizedBox(
           height: itemHeight,
           child: Row(
-            crossAxisAlignment: CrossAxisAlignment.center,
+            crossAxisAlignment: CrossAxisAlignment.start,
             children: [
               if (icon != null) icon,
               HSpace(ActionListSizes.itemHPadding),
@@ -101,8 +105,8 @@ class ActionCell<T extends ActionItem> extends StatelessWidget {
             ],
           ),
         ).padding(
-          horizontal: ActionListSizes.padding,
-          vertical: ActionListSizes.padding,
+          horizontal: ActionListSizes.hPadding,
+          vertical: ActionListSizes.vPadding,
         ),
       ),
     );

+ 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 - 1
frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock

@@ -24,7 +24,7 @@ EXTERNAL SOURCES:
     :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
 
 SPEC CHECKSUMS:
-  FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424
+  FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811
   path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19
   rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c
   url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3

+ 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": {}
+}

+ 16 - 16
frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_pt_BR.arb

@@ -1,35 +1,35 @@
 {
   "@@locale": "pt-BR",
-  "bold": "",
+  "bold": "Negrito",
   "@bold": {},
-  "bulletedList": "",
+  "bulletedList": "Lista de marcadores",
   "@bulletedList": {},
-  "checkbox": "",
+  "checkbox": "Caixa de seleção",
   "@checkbox": {},
-  "embedCode": "",
+  "embedCode": "Código incorporado",
   "@embedCode": {},
-  "heading1": "",
+  "heading1": "H1",
   "@heading1": {},
-  "heading2": "",
+  "heading2": "H2",
   "@heading2": {},
-  "heading3": "",
+  "heading3": "H3",
   "@heading3": {},
-  "highlight": "",
+  "highlight": "Destacar",
   "@highlight": {},
-  "image": "",
+  "image": "Imagem",
   "@image": {},
-  "italic": "",
+  "italic": "Itálico",
   "@italic": {},
-  "link": "",
+  "link": "Link",
   "@link": {},
-  "numberedList": "",
+  "numberedList": "Lista numerada",
   "@numberedList": {},
-  "quote": "",
+  "quote": "Citar",
   "@quote": {},
-  "strikethrough": "",
+  "strikethrough": "Rasurar",
   "@strikethrough": {},
-  "text": "",
+  "text": "Texto",
   "@text": {},
-  "underline": "",
+  "underline": "Sublinhar",
   "@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);

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

@@ -54,6 +54,11 @@ extension TextNodeExtension on TextNode {
         return value == true;
       });
 
+  bool allSatisfyCodeInSelection(Selection selection) =>
+      allSatisfyInSelection(selection, BuiltInAttributeKey.code, (value) {
+        return value == true;
+      });
+
   bool allSatisfyInSelection(
     Selection selection,
     String styleKey,

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

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart

@@ -37,7 +37,7 @@ class SelectionMenuItemWidget extends StatelessWidget {
                 : MaterialStateProperty.all(Colors.transparent),
           ),
           label: Text(
-            item.name,
+            item.name(),
             textAlign: TextAlign.left,
             style: const TextStyle(
               color: Colors.black,

+ 8 - 8
frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart

@@ -124,7 +124,7 @@ List<SelectionMenuItem> get defaultSelectionMenuItems =>
     _defaultSelectionMenuItems;
 final List<SelectionMenuItem> _defaultSelectionMenuItems = [
   SelectionMenuItem(
-    name: AppFlowyEditorLocalizations.current.text,
+    name: () => AppFlowyEditorLocalizations.current.text,
     icon: _selectionMenuIcon('text'),
     keywords: ['text'],
     handler: (editorState, _, __) {
@@ -132,7 +132,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     },
   ),
   SelectionMenuItem(
-    name: AppFlowyEditorLocalizations.current.heading1,
+    name: () => AppFlowyEditorLocalizations.current.heading1,
     icon: _selectionMenuIcon('h1'),
     keywords: ['heading 1, h1'],
     handler: (editorState, _, __) {
@@ -140,7 +140,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     },
   ),
   SelectionMenuItem(
-    name: AppFlowyEditorLocalizations.current.heading2,
+    name: () => AppFlowyEditorLocalizations.current.heading2,
     icon: _selectionMenuIcon('h2'),
     keywords: ['heading 2, h2'],
     handler: (editorState, _, __) {
@@ -148,7 +148,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     },
   ),
   SelectionMenuItem(
-    name: AppFlowyEditorLocalizations.current.heading3,
+    name: () => AppFlowyEditorLocalizations.current.heading3,
     icon: _selectionMenuIcon('h3'),
     keywords: ['heading 3, h3'],
     handler: (editorState, _, __) {
@@ -156,13 +156,13 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     },
   ),
   SelectionMenuItem(
-    name: AppFlowyEditorLocalizations.current.image,
+    name: () => AppFlowyEditorLocalizations.current.image,
     icon: _selectionMenuIcon('image'),
     keywords: ['image'],
     handler: showImageUploadMenu,
   ),
   SelectionMenuItem(
-    name: AppFlowyEditorLocalizations.current.bulletedList,
+    name: () => AppFlowyEditorLocalizations.current.bulletedList,
     icon: _selectionMenuIcon('bulleted_list'),
     keywords: ['bulleted list', 'list', 'unordered list'],
     handler: (editorState, _, __) {
@@ -170,7 +170,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     },
   ),
   SelectionMenuItem(
-    name: AppFlowyEditorLocalizations.current.checkbox,
+    name: () => AppFlowyEditorLocalizations.current.checkbox,
     icon: _selectionMenuIcon('checkbox'),
     keywords: ['todo list', 'list', 'checkbox list'],
     handler: (editorState, _, __) {
@@ -178,7 +178,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     },
   ),
   SelectionMenuItem(
-    name: AppFlowyEditorLocalizations.current.quote,
+    name: () => AppFlowyEditorLocalizations.current.quote,
     icon: _selectionMenuIcon('quote'),
     keywords: ['quote', 'refer'],
     handler: (editorState, _, __) {

+ 37 - 13
frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart

@@ -6,27 +6,54 @@ import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
+typedef SelectionMenuItemHandler = void Function(
+    EditorState editorState,
+    SelectionMenuService menuService,
+    BuildContext context,
+    );
+
 /// Selection Menu Item
 class SelectionMenuItem {
   SelectionMenuItem({
     required this.name,
     required this.icon,
     required this.keywords,
-    required this.handler,
-  });
+    required SelectionMenuItemHandler handler,
+  }) {
+    this.handler = (editorState, menuService, context) {
+      _deleteToSlash(editorState);
+      WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+            handler(editorState, menuService, context);
+      });
+    };
+  }
 
-  final String name;
+  final String Function() name;
   final Widget icon;
 
   /// Customizes keywords for item.
   ///
   /// The keywords are used to quickly retrieve items.
   final List<String> keywords;
-  final void Function(
-    EditorState editorState,
-    SelectionMenuService menuService,
-    BuildContext context,
-  ) handler;
+  late final SelectionMenuItemHandler handler;
+
+  void _deleteToSlash(EditorState editorState) {
+    final selectionService = editorState.service.selectionService;
+    final selection = selectionService.currentSelection.value;
+    final nodes = selectionService.currentSelectedNodes;
+    if (selection != null && nodes.length == 1) {
+      final node = nodes.first as TextNode;
+      final end = selection.start.offset;
+      final start = node.toRawString().substring(0, end).lastIndexOf('/');
+      TransactionBuilder(editorState)
+        ..deleteText(
+          node,
+          start,
+          selection.start.offset - start,
+        )
+        ..commit();
+    }
+  }
 }
 
 class SelectionMenuWidget extends StatefulWidget {
@@ -204,11 +231,8 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
 
     if (event.logicalKey == LogicalKeyboardKey.enter) {
       if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) {
-        _deleteLastCharacters(length: keyword.length + 1);
-        WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
-          _showingItems[_selectedIndex]
-              .handler(widget.editorState, widget.menuService, context);
-        });
+        _showingItems[_selectedIndex]
+            .handler(widget.editorState, widget.menuService, context);
         return KeyEventResult.handled;
       }
     } else if (event.logicalKey == LogicalKeyboardKey.escape) {

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

+ 126 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart

@@ -0,0 +1,126 @@
+import "dart:math";
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
+import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
+import 'package:flutter/material.dart';
+
+bool _isCodeStyle(TextNode textNode, int index) {
+  return textNode.allSatisfyCodeInSelection(Selection.single(
+      path: textNode.path, startOffset: index, endOffset: index + 1));
+}
+
+// enter escape mode when start two backquote
+bool _isEscapeBackquote(String text, List<int> backquoteIndexes) {
+  if (backquoteIndexes.length >= 2) {
+    final firstBackquoteIndex = backquoteIndexes[0];
+    final secondBackquoteIndex = backquoteIndexes[1];
+    return firstBackquoteIndex == secondBackquoteIndex - 1;
+  }
+  return false;
+}
+
+// find all the index of `, exclusion in code style.
+List<int> _findBackquoteIndexes(String text, TextNode textNode) {
+  final backquoteIndexes = <int>[];
+  for (var i = 0; i < text.length; i++) {
+    if (text[i] == '`' && _isCodeStyle(textNode, i) == false) {
+      backquoteIndexes.add(i);
+    }
+  }
+  return backquoteIndexes;
+}
+
+/// To denote a word or phrase as code, enclose it in backticks (`).
+/// If the word or phrase you want to denote as code includes one or more
+/// backticks, you can escape it by enclosing the word or phrase in double
+/// backticks (``).
+ShortcutEventHandler backquoteToCodeHandler = (editorState, event) {
+  final selectionService = editorState.service.selectionService;
+  final selection = selectionService.currentSelection.value;
+  final textNodes = selectionService.currentSelectedNodes.whereType<TextNode>();
+
+  if (selection == null || !selection.isSingle || textNodes.length != 1) {
+    return KeyEventResult.ignored;
+  }
+
+  final textNode = textNodes.first;
+  final selectionText = textNode
+      .toRawString()
+      .substring(selection.start.offset, selection.end.offset);
+
+  // toggle code style when selected some text
+  if (selectionText.length > 0) {
+    formatEmbedCode(editorState);
+    return KeyEventResult.handled;
+  }
+
+  final text = textNode.toRawString().substring(0, selection.end.offset);
+  final backquoteIndexes = _findBackquoteIndexes(text, textNode);
+  if (backquoteIndexes.isEmpty) {
+    return KeyEventResult.ignored;
+  }
+
+  final endIndex = selection.end.offset;
+
+  if (_isEscapeBackquote(text, backquoteIndexes)) {
+    final firstBackquoteIndex = backquoteIndexes[0];
+    final secondBackquoteIndex = backquoteIndexes[1];
+    final lastBackquoteIndex = backquoteIndexes[backquoteIndexes.length - 1];
+    if (secondBackquoteIndex == lastBackquoteIndex ||
+        secondBackquoteIndex == lastBackquoteIndex - 1 ||
+        lastBackquoteIndex != endIndex - 1) {
+      // ``(`),```(`),``...`...(`) should ignored
+      return KeyEventResult.ignored;
+    }
+
+    TransactionBuilder(editorState)
+      ..deleteText(textNode, lastBackquoteIndex, 1)
+      ..deleteText(textNode, firstBackquoteIndex, 2)
+      ..formatText(
+        textNode,
+        firstBackquoteIndex,
+        endIndex - firstBackquoteIndex - 3,
+        {
+          BuiltInAttributeKey.code: true,
+        },
+      )
+      ..afterSelection = Selection.collapsed(
+        Position(
+          path: textNode.path,
+          offset: endIndex - 3,
+        ),
+      )
+      ..commit();
+
+    return KeyEventResult.handled;
+  }
+
+  // handle single backquote
+  final startIndex = backquoteIndexes[0];
+  if (startIndex == endIndex - 1) {
+    return KeyEventResult.ignored;
+  }
+
+  // delete the backquote.
+  // update the style of the text surround by ` ` to code.
+  // and update the cursor position.
+  TransactionBuilder(editorState)
+    ..deleteText(textNode, startIndex, 1)
+    ..formatText(
+      textNode,
+      startIndex,
+      endIndex - startIndex - 1,
+      {
+        BuiltInAttributeKey.code: true,
+      },
+    )
+    ..afterSelection = Selection.collapsed(
+      Position(
+        path: textNode.path,
+        offset: endIndex - 1,
+      ),
+    )
+    ..commit();
+
+  return KeyEventResult.handled;
+};

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

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

@@ -5,14 +5,17 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspac
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/page_up_down_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart';
 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 = [
@@ -260,4 +263,18 @@ List<ShortcutEvent> builtInShortcutEvents = [
     command: 'shift+underscore',
     handler: doubleUnderscoresToBold,
   ),
+    key: 'Backquote to code',
+    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,
-  // ) {}
 }

+ 0 - 49
frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_item_widget_test.dart

@@ -1,49 +0,0 @@
-import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart';
-import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
-import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter_test/flutter_test.dart';
-import '../../infra/test_editor.dart';
-
-void main() async {
-  setUpAll(() {
-    TestWidgetsFlutterBinding.ensureInitialized();
-  });
-
-  group('selection_menu_item_widget.dart', () {
-    testWidgets('test selection menu item widget', (tester) async {
-      bool flag = false;
-      final editorState = tester.editor.editorState;
-      final menuService = _TestSelectionMenuService();
-      const icon = Icon(Icons.abc);
-      final item = SelectionMenuItem(
-        name: 'example',
-        icon: icon,
-        keywords: ['example A', 'example B'],
-        handler: (editorState, menuService, context) {
-          flag = true;
-        },
-      );
-      final widget = SelectionMenuItemWidget(
-        editorState: editorState,
-        menuService: menuService,
-        item: item,
-        isSelected: true,
-      );
-      await tester.pumpWidget(MaterialApp(home: widget));
-      await tester.tap(find.byType(SelectionMenuItemWidget));
-      expect(flag, true);
-    });
-  });
-}
-
-class _TestSelectionMenuService implements SelectionMenuService {
-  @override
-  void dismiss() {}
-
-  @override
-  void show() {}
-
-  @override
-  Offset get topLeft => throw UnimplementedError();
-}

+ 43 - 29
frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_widget_test.dart

@@ -1,5 +1,4 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
@@ -13,29 +12,40 @@ void main() async {
   });
 
   group('selection_menu_widget.dart', () {
-    // const i = defaultSelectionMenuItems.length;
-    //
-    // Because the `defaultSelectionMenuItems` uses localization,
-    // and the MaterialApp has not been initialized at the time of getting the value,
-    // it will crash.
-    //
-    // Use const value temporarily instead.
-    const i = 7;
-    testWidgets('Selects number.$i item in selection menu', (tester) async {
-      final editor = await _prepare(tester);
-      for (var j = 0; j < i; j++) {
-        await editor.pressLogicKey(LogicalKeyboardKey.arrowDown);
-      }
+    for (var i = 0; i < defaultSelectionMenuItems.length; i += 1) {
+      testWidgets('Selects number.$i item in selection menu with enter', (
+          tester) async {
+        final editor = await _prepare(tester);
+        for (var j = 0; j < i; j++) {
+          await editor.pressLogicKey(LogicalKeyboardKey.arrowDown);
+        }
 
-      await editor.pressLogicKey(LogicalKeyboardKey.enter);
-      expect(
-        find.byType(SelectionMenuWidget, skipOffstage: false),
-        findsNothing,
-      );
-      if (defaultSelectionMenuItems[i].name != 'Image') {
-        await _testDefaultSelectionMenuItems(i, editor);
-      }
-    });
+        await editor.pressLogicKey(LogicalKeyboardKey.enter);
+        expect(
+          find.byType(SelectionMenuWidget, skipOffstage: false),
+          findsNothing,
+        );
+        if (defaultSelectionMenuItems[i].name() != 'Image') {
+          await _testDefaultSelectionMenuItems(i, editor);
+        }
+      });
+
+      testWidgets('Selects number.$i item in selection menu with click', (
+          tester) async {
+        final editor = await _prepare(tester);
+
+        await tester.tap(find.byType(SelectionMenuItemWidget).at(i));
+        await tester.pumpAndSettle();
+
+        expect(
+          find.byType(SelectionMenuWidget, skipOffstage: false),
+          findsNothing,
+        );
+        if (defaultSelectionMenuItems[i].name() != 'Image') {
+          await _testDefaultSelectionMenuItems(i, editor);
+        }
+      });
+    }
 
     testWidgets('Search item in selection menu util no results',
         (tester) async {
@@ -138,23 +148,27 @@ Future<void> _testDefaultSelectionMenuItems(
     int index, EditorWidgetTester editor) async {
   expect(editor.documentLength, 4);
   expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0));
+  expect((editor.nodeAtPath([1]) as TextNode).toRawString(), 'Welcome to Appflowy 😁');
   final node = editor.nodeAtPath([2]);
   final item = defaultSelectionMenuItems[index];
-  if (item.name == 'Text') {
+  final itemName = item.name();
+  if (itemName == 'Text') {
     expect(node?.subtype == null, true);
-  } else if (item.name == 'Heading 1') {
+  } else if (itemName == 'Heading 1') {
     expect(node?.subtype, BuiltInAttributeKey.heading);
     expect(node?.attributes.heading, BuiltInAttributeKey.h1);
-  } else if (item.name == 'Heading 2') {
+  } else if (itemName == 'Heading 2') {
     expect(node?.subtype, BuiltInAttributeKey.heading);
     expect(node?.attributes.heading, BuiltInAttributeKey.h2);
-  } else if (item.name == 'Heading 3') {
+  } else if (itemName == 'Heading 3') {
     expect(node?.subtype, BuiltInAttributeKey.heading);
     expect(node?.attributes.heading, BuiltInAttributeKey.h3);
-  } else if (item.name == 'Bulleted list') {
+  } else if (itemName == 'Bulleted list') {
     expect(node?.subtype, BuiltInAttributeKey.bulletedList);
-  } else if (item.name == 'Checkbox') {
+  } else if (itemName == 'Checkbox') {
     expect(node?.subtype, BuiltInAttributeKey.checkbox);
     expect(node?.attributes.check, false);
+  } else if (itemName == 'Quote') {
+    expect(node?.subtype, BuiltInAttributeKey.quote);
   }
 }

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

+ 154 - 0
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart

@@ -0,0 +1,154 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/extensions/text_node_extensions.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('markdown_syntax_to_styled_text.dart', () {
+    group('convert single backquote to code', () {
+      Future<void> insertBackquote(
+        EditorWidgetTester editor, {
+        int repeat = 1,
+      }) async {
+        for (var i = 0; i < repeat; i++) {
+          await editor.pressLogicKey(
+            LogicalKeyboardKey.backquote,
+          );
+        }
+      }
+
+      testWidgets('`AppFlowy` to code AppFlowy', (tester) async {
+        const text = '`AppFlowy';
+        final editor = tester.editor..insertTextNode('');
+        await editor.startTesting();
+        await editor.updateSelection(
+          Selection.single(path: [0], startOffset: 0),
+        );
+        final textNode = editor.nodeAtPath([0]) as TextNode;
+        for (var i = 0; i < text.length; i++) {
+          await editor.insertText(textNode, text[i], i);
+        }
+        await insertBackquote(editor);
+        final allCode = textNode.allSatisfyCodeInSelection(
+          Selection.single(
+            path: [0],
+            startOffset: 0,
+            endOffset: textNode.toRawString().length,
+          ),
+        );
+        expect(allCode, true);
+        expect(textNode.toRawString(), 'AppFlowy');
+      });
+
+      testWidgets('App`Flowy` to code AppFlowy', (tester) async {
+        const text = 'App`Flowy';
+        final editor = tester.editor..insertTextNode('');
+        await editor.startTesting();
+        await editor.updateSelection(
+          Selection.single(path: [0], startOffset: 0),
+        );
+        final textNode = editor.nodeAtPath([0]) as TextNode;
+        for (var i = 0; i < text.length; i++) {
+          await editor.insertText(textNode, text[i], i);
+        }
+        await insertBackquote(editor);
+        final allCode = textNode.allSatisfyCodeInSelection(
+          Selection.single(
+            path: [0],
+            startOffset: 3,
+            endOffset: textNode.toRawString().length,
+          ),
+        );
+        expect(allCode, true);
+        expect(textNode.toRawString(), 'AppFlowy');
+      });
+
+      testWidgets('`` nothing changes', (tester) async {
+        const text = '`';
+        final editor = tester.editor..insertTextNode('');
+        await editor.startTesting();
+        await editor.updateSelection(
+          Selection.single(path: [0], startOffset: 0),
+        );
+        final textNode = editor.nodeAtPath([0]) as TextNode;
+        for (var i = 0; i < text.length; i++) {
+          await editor.insertText(textNode, text[i], i);
+        }
+        await insertBackquote(editor);
+        final allCode = textNode.allSatisfyCodeInSelection(
+          Selection.single(
+            path: [0],
+            startOffset: 0,
+            endOffset: textNode.toRawString().length,
+          ),
+        );
+        expect(allCode, false);
+        expect(textNode.toRawString(), text);
+      });
+    });
+
+    group('convert double backquote to code', () {
+      Future<void> insertBackquote(
+        EditorWidgetTester editor, {
+        int repeat = 1,
+      }) async {
+        for (var i = 0; i < repeat; i++) {
+          await editor.pressLogicKey(
+            LogicalKeyboardKey.backquote,
+          );
+        }
+      }
+
+      testWidgets('```AppFlowy`` to code `AppFlowy', (tester) async {
+        const text = '```AppFlowy`';
+        final editor = tester.editor..insertTextNode('');
+        await editor.startTesting();
+        await editor.updateSelection(
+          Selection.single(path: [0], startOffset: 0),
+        );
+        final textNode = editor.nodeAtPath([0]) as TextNode;
+        for (var i = 0; i < text.length; i++) {
+          await editor.insertText(textNode, text[i], i);
+        }
+        await insertBackquote(editor);
+        final allCode = textNode.allSatisfyCodeInSelection(
+          Selection.single(
+            path: [0],
+            startOffset: 1,
+            endOffset: textNode.toRawString().length,
+          ),
+        );
+        expect(allCode, true);
+        expect(textNode.toRawString(), '`AppFlowy');
+      });
+
+      testWidgets('```` nothing changes', (tester) async {
+        const text = '```';
+        final editor = tester.editor..insertTextNode('');
+        await editor.startTesting();
+        await editor.updateSelection(
+          Selection.single(path: [0], startOffset: 0),
+        );
+        final textNode = editor.nodeAtPath([0]) as TextNode;
+        for (var i = 0; i < text.length; i++) {
+          await editor.insertText(textNode, text[i], i);
+        }
+        await insertBackquote(editor);
+        final allCode = textNode.allSatisfyCodeInSelection(
+          Selection.single(
+            path: [0],
+            startOffset: 0,
+            endOffset: textNode.toRawString().length,
+          ),
+        );
+        expect(allCode, false);
+        expect(textNode.toRawString(), text);
+      });
+    });
+  });
+}

+ 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

+ 13 - 7
frontend/app_flowy/packages/flowy_infra/lib/theme.dart

@@ -104,7 +104,7 @@ class AppTheme {
           ..tint6 = const Color(0xfff5ffdc)
           ..tint7 = const Color(0xffddffd6)
           ..tint8 = const Color(0xffdefff1)
-          ..tint9 = const Color(0xffdefff1)
+          ..tint9 = const Color(0xffe1fbff)
           ..main1 = const Color(0xff00bcf0)
           ..main2 = const Color(0xff00b7ea)
           ..textColor = _black
@@ -152,7 +152,8 @@ class AppTheme {
   ThemeData get themeData {
     var t = ThemeData(
       textTheme: TextTheme(bodyText2: TextStyle(color: textColor)),
-      textSelectionTheme: TextSelectionThemeData(cursorColor: main2, selectionHandleColor: main2),
+      textSelectionTheme: TextSelectionThemeData(
+          cursorColor: main2, selectionHandleColor: main2),
       primaryIconTheme: IconThemeData(color: hover),
       iconTheme: IconThemeData(color: shader1),
       canvasColor: shader6,
@@ -179,7 +180,8 @@ class AppTheme {
         toggleableActiveColor: main1);
   }
 
-  Color shift(Color c, double d) => ColorUtils.shiftHsl(c, d * (isDark ? -1 : 1));
+  Color shift(Color c, double d) =>
+      ColorUtils.shiftHsl(c, d * (isDark ? -1 : 1));
 }
 
 class ColorUtils {
@@ -188,14 +190,18 @@ class ColorUtils {
     return hslc.withLightness((hslc.lightness + amt).clamp(0.0, 1.0)).toColor();
   }
 
-  static Color parseHex(String value) => Color(int.parse(value.substring(1, 7), radix: 16) + 0xFF000000);
+  static Color parseHex(String value) =>
+      Color(int.parse(value.substring(1, 7), radix: 16) + 0xFF000000);
 
   static Color blend(Color dst, Color src, double opacity) {
     return Color.fromARGB(
       255,
-      (dst.red.toDouble() * (1.0 - opacity) + src.red.toDouble() * opacity).toInt(),
-      (dst.green.toDouble() * (1.0 - opacity) + src.green.toDouble() * opacity).toInt(),
-      (dst.blue.toDouble() * (1.0 - opacity) + src.blue.toDouble() * opacity).toInt(),
+      (dst.red.toDouble() * (1.0 - opacity) + src.red.toDouble() * opacity)
+          .toInt(),
+      (dst.green.toDouble() * (1.0 - opacity) + src.green.toDouble() * opacity)
+          .toInt(),
+      (dst.blue.toDouble() * (1.0 - opacity) + src.blue.toDouble() * opacity)
+          .toInt(),
     );
   }
 }

+ 2 - 2
frontend/app_flowy/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart

@@ -218,8 +218,8 @@ class OverlayScreen extends StatelessWidget {
                           overlapBehaviour: providerContext
                               .read<OverlayDemoConfiguration>()
                               .overlapBehaviour,
-                          width: 200.0,
-                          height: 200.0,
+                          constraints:
+                              BoxConstraints.tight(const Size(200, 200)),
                         );
                       },
                       child: const Text('Show List Overlay'),

+ 1 - 1
frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart

@@ -30,7 +30,7 @@ class AppFlowyPopover extends StatelessWidget {
     this.offset,
     this.controller,
     this.asBarrier = false,
-    this.margin = const EdgeInsets.all(12),
+    this.margin = const EdgeInsets.all(6),
   }) : super(key: key);
 
   @override

+ 39 - 28
frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart

@@ -1,3 +1,5 @@
+import 'dart:math';
+
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flutter/material.dart';
 import 'package:flowy_infra/theme.dart';
@@ -19,46 +21,55 @@ class ListOverlay extends StatelessWidget {
   const ListOverlay({
     Key? key,
     required this.itemBuilder,
-    this.itemCount,
+    this.itemCount = 0,
     this.controller,
-    this.width = double.infinity,
-    this.height = double.infinity,
+    this.constraints = const BoxConstraints(),
     this.footer,
   }) : super(key: key);
 
   final IndexedWidgetBuilder itemBuilder;
-  final int? itemCount;
+  final int itemCount;
   final ScrollController? controller;
-  final double width;
-  final double height;
+  final BoxConstraints constraints;
   final ListOverlayFooter? footer;
 
   @override
   Widget build(BuildContext context) {
     const padding = EdgeInsets.symmetric(horizontal: 6, vertical: 6);
-    double totalHeight = height + padding.vertical;
+    double totalHeight = constraints.minHeight + padding.vertical;
     if (footer != null) {
       totalHeight = totalHeight + footer!.height + footer!.padding.vertical;
     }
 
+    final innerConstraints = BoxConstraints(
+      minHeight: totalHeight,
+      maxHeight: max(constraints.maxHeight, totalHeight),
+      minWidth: constraints.minWidth,
+      maxWidth: constraints.maxWidth,
+    );
+
+    List<Widget> children = [];
+    for (var i = 0; i < itemCount; i++) {
+      children.add(itemBuilder(context, i));
+    }
+
     return OverlayContainer(
-      constraints: BoxConstraints.tight(Size(width, totalHeight)),
+      constraints: innerConstraints,
       padding: padding,
       child: SingleChildScrollView(
-        child: Column(
-          children: [
-            ListView.builder(
-              shrinkWrap: true,
-              itemBuilder: itemBuilder,
-              itemCount: itemCount,
-              controller: controller,
-            ),
-            if (footer != null)
-              Padding(
-                padding: footer!.padding,
-                child: footer!.widget,
-              ),
-          ],
+        scrollDirection: Axis.horizontal,
+        child: IntrinsicWidth(
+          child: Column(
+            mainAxisSize: MainAxisSize.max,
+            children: [
+              ...children,
+              if (footer != null)
+                Padding(
+                  padding: footer!.padding,
+                  child: footer!.widget,
+                ),
+            ],
+          ),
         ),
       ),
     );
@@ -68,10 +79,9 @@ class ListOverlay extends StatelessWidget {
     BuildContext context, {
     required String identifier,
     required IndexedWidgetBuilder itemBuilder,
-    int? itemCount,
+    int itemCount = 0,
     ScrollController? controller,
-    double width = double.infinity,
-    double height = double.infinity,
+    BoxConstraints constraints = const BoxConstraints(),
     required BuildContext anchorContext,
     AnchorDirection? anchorDirection,
     FlowyOverlayDelegate? delegate,
@@ -85,8 +95,7 @@ class ListOverlay extends StatelessWidget {
         itemBuilder: itemBuilder,
         itemCount: itemCount,
         controller: controller,
-        width: width,
-        height: height,
+        constraints: constraints,
         footer: footer,
       ),
       identifier: identifier,
@@ -122,7 +131,9 @@ class OverlayContainer extends StatelessWidget {
       child: Container(
         padding: padding,
         decoration: FlowyDecoration.decoration(
-            theme.surface, theme.shadowColor.withOpacity(0.15)),
+          theme.surface,
+          theme.shadowColor.withOpacity(0.15),
+        ),
         constraints: constraints,
         child: child,
       ),

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

Some files were not shown because too many files changed in this diff