Explorar o código

Merge branch 'main' into android_vscode

Sean Riley Hawkins %!s(int64=2) %!d(string=hai) anos
pai
achega
09777d926e
Modificáronse 100 ficheiros con 2740 adicións e 687 borrados
  1. 11 0
      CHANGELOG.md
  2. 1 1
      frontend/Makefile.toml
  3. 31 15
      frontend/app_flowy/lib/plugins/board/application/board_bloc.dart
  4. 34 4
      frontend/app_flowy/lib/plugins/board/application/board_data_controller.dart
  5. 50 0
      frontend/app_flowy/lib/plugins/board/application/board_listener.dart
  6. 0 1
      frontend/app_flowy/lib/plugins/board/application/card/board_select_option_cell_bloc.dart
  7. 6 6
      frontend/app_flowy/lib/plugins/board/application/group_controller.dart
  8. 2 2
      frontend/app_flowy/lib/plugins/board/application/group_listener.dart
  9. 1 1
      frontend/app_flowy/lib/plugins/board/board.dart
  10. 65 16
      frontend/app_flowy/lib/plugins/board/presentation/board_page.dart
  11. 11 6
      frontend/app_flowy/lib/plugins/board/presentation/card/board_checkbox_cell.dart
  12. 14 3
      frontend/app_flowy/lib/plugins/board/presentation/card/board_date_cell.dart
  13. 13 5
      frontend/app_flowy/lib/plugins/board/presentation/card/board_number_cell.dart
  14. 24 13
      frontend/app_flowy/lib/plugins/board/presentation/card/board_select_option_cell.dart
  15. 14 8
      frontend/app_flowy/lib/plugins/board/presentation/card/board_text_cell.dart
  16. 18 10
      frontend/app_flowy/lib/plugins/board/presentation/card/board_url_cell.dart
  17. 12 4
      frontend/app_flowy/lib/plugins/board/presentation/card/card.dart
  18. 8 1
      frontend/app_flowy/lib/plugins/board/presentation/card/card_cell_builder.dart
  19. 19 1
      frontend/app_flowy/lib/plugins/board/presentation/card/card_container.dart
  20. 3 0
      frontend/app_flowy/lib/plugins/board/presentation/card/define.dart
  21. 16 12
      frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_data_loader.dart
  22. 5 2
      frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/context_builder.dart
  23. 5 2
      frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart
  24. 20 7
      frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart
  25. 13 8
      frontend/app_flowy/packages/appflowy_board/example/lib/single_board_list_example.dart
  26. 22 3
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart
  27. 6 5
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column.dart
  28. 33 3
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column_data.dart
  29. 17 15
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart
  30. 7 9
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/card.dart
  31. 3 2
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/footer.dart
  32. 14 6
      frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/header.dart
  33. 5 0
      frontend/app_flowy/packages/appflowy_editor/assets/images/clear.svg
  34. 6 0
      frontend/app_flowy/packages/appflowy_editor/assets/images/delete.svg
  35. 5 0
      frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_center.svg
  36. 5 0
      frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_left.svg
  37. 5 0
      frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_right.svg
  38. 4 0
      frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/copy.svg
  39. 6 0
      frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/delete.svg
  40. 3 0
      frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/divider.svg
  41. 4 0
      frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/share.svg
  42. 4 0
      frontend/app_flowy/packages/appflowy_editor/assets/images/link.svg
  43. 5 0
      frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/image.svg
  44. 2 2
      frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/link.svg
  45. 10 1
      frontend/app_flowy/packages/appflowy_editor/example/assets/example.json
  46. 2 2
      frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart
  47. 48 9
      frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart
  48. 5 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/infra/log.dart
  49. 11 11
      frontend/app_flowy/packages/appflowy_editor/lib/src/operation/operation.dart
  50. 7 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart
  51. 72 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart
  52. 340 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart
  53. 202 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart
  54. 151 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart
  55. 1 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart
  56. 1 5
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart
  57. 62 35
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart
  58. 1 3
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart
  59. 24 31
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart
  60. 4 14
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart
  61. 0 217
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/toolbar_widget.dart
  62. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart
  63. 18 8
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart
  64. 9 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart
  65. 231 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart
  66. 35 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart
  67. 79 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart
  68. 2 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart
  69. 16 9
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart
  70. 5 6
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/delete_text_handler.dart
  71. 6 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart
  72. 3 3
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/service.dart
  73. 39 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart
  74. 2 0
      frontend/app_flowy/packages/appflowy_editor/pubspec.yaml
  75. 13 0
      frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart
  76. 3 0
      frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart
  77. 2 2
      frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart
  78. 131 0
      frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart
  79. 81 0
      frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart
  80. 41 0
      frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart
  81. 1 1
      frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_item_widget_test.dart
  82. 3 1
      frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_widget_test.dart
  83. 46 0
      frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_item_widget_test.dart
  84. 11 0
      frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_widget_test.dart
  85. 102 4
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/update_text_style_by_command_x_handler_test.dart
  86. 36 0
      frontend/app_flowy/packages/appflowy_editor/test/service/toolbar_service_test.dart
  87. 18 18
      frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text.dart
  88. 1 1
      frontend/app_flowy/pubspec.lock
  89. 7 0
      frontend/rust-lib/Cargo.lock
  90. 1 1
      frontend/rust-lib/flowy-grid/src/entities/block_entities.rs
  91. 4 4
      frontend/rust-lib/flowy-grid/src/entities/group_entities/configuration.rs
  92. 12 0
      frontend/rust-lib/flowy-grid/src/entities/group_entities/group.rs
  93. 37 8
      frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs
  94. 1 1
      frontend/rust-lib/flowy-grid/src/event_handler.rs
  95. 9 0
      frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/select_option.rs
  96. 74 50
      frontend/rust-lib/flowy-grid/src/services/grid_editor.rs
  97. 24 12
      frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs
  98. 9 3
      frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs
  99. 4 8
      frontend/rust-lib/flowy-grid/src/services/group/action.rs
  100. 130 32
      frontend/rust-lib/flowy-grid/src/services/group/configuration.rs

+ 11 - 0
CHANGELOG.md

@@ -1,5 +1,16 @@
 # Release Notes
 
+## Version 0.0.5 - beta.1 - 2022-08-25
+
+New features
+- Board-view database  
+  - Group by single select
+  - drag and drop cards
+  - insert / delete cards
+
+![Aug-25-2022 16-22-38](https://user-images.githubusercontent.com/86001920/186614248-23186dfe-410e-427a-8cc6-865b1f79e074.gif)
+
+
 ## Version 0.0.4 - 2022-06-06
 - Drag to adjust the width of a column
 - Upgrade to Flutter 3.0

+ 1 - 1
frontend/Makefile.toml

@@ -22,7 +22,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
 CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
 CARGO_MAKE_CRATE_NAME = "dart-ffi"
 LIB_NAME = "dart_ffi"
-CURRENT_APP_VERSION = "0.0.4"
+CURRENT_APP_VERSION = "0.0.5"
 FEATURES = "flutter"
 PRODUCT_NAME = "AppFlowy"
 # CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html

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

@@ -20,19 +20,19 @@ import 'group_controller.dart';
 part 'board_bloc.freezed.dart';
 
 class BoardBloc extends Bloc<BoardEvent, BoardState> {
-  final BoardDataController _dataController;
-  late final AFBoardDataController afBoardDataController;
+  final BoardDataController _gridDataController;
+  late final AFBoardDataController boardController;
   final MoveRowFFIService _rowService;
   LinkedHashMap<String, GroupController> groupControllers = LinkedHashMap.new();
 
-  GridFieldCache get fieldCache => _dataController.fieldCache;
-  String get gridId => _dataController.gridId;
+  GridFieldCache get fieldCache => _gridDataController.fieldCache;
+  String get gridId => _gridDataController.gridId;
 
   BoardBloc({required ViewPB view})
       : _rowService = MoveRowFFIService(gridId: view.id),
-        _dataController = BoardDataController(view: view),
+        _gridDataController = BoardDataController(view: view),
         super(BoardState.initial(view.id)) {
-    afBoardDataController = AFBoardDataController(
+    boardController = AFBoardDataController(
       onMoveColumn: (
         fromColumnId,
         fromIndex,
@@ -70,7 +70,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
             await _loadGrid(emit);
           },
           createRow: (groupId) async {
-            final result = await _dataController.createBoardCard(groupId);
+            final result = await _gridDataController.createBoardCard(groupId);
             result.fold(
               (rowPB) {
                 emit(state.copyWith(editingRow: some(rowPB)));
@@ -126,7 +126,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
 
   @override
   Future<void> close() async {
-    await _dataController.dispose();
+    await _gridDataController.dispose();
     for (final controller in groupControllers.values) {
       controller.dispose();
     }
@@ -135,7 +135,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
 
   void initializeGroups(List<GroupPB> groups) {
     for (final group in groups) {
-      final delegate = GroupControllerDelegateImpl(afBoardDataController);
+      final delegate = GroupControllerDelegateImpl(boardController);
       final controller = GroupController(
         gridId: state.gridId,
         group: group,
@@ -147,12 +147,12 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
   }
 
   GridRowCache? getRowCache(String blockId) {
-    final GridBlockCache? blockCache = _dataController.blocks[blockId];
+    final GridBlockCache? blockCache = _gridDataController.blocks[blockId];
     return blockCache?.rowCache;
   }
 
   void _startListening() {
-    _dataController.addListener(
+    _gridDataController.addListener(
       onGridChanged: (grid) {
         if (!isClosed) {
           add(BoardEvent.didReceiveGridUpdate(grid));
@@ -162,18 +162,34 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
         List<AFBoardColumnData> columns = groups.map((group) {
           return AFBoardColumnData(
             id: group.groupId,
-            desc: group.desc,
+            name: group.desc,
             items: _buildRows(group.rows),
             customData: group,
           );
         }).toList();
 
-        afBoardDataController.addColumns(columns);
+        boardController.addColumns(columns);
         initializeGroups(groups);
       },
       onRowsChanged: (List<RowInfo> rowInfos, RowsChangedReason reason) {
         add(BoardEvent.didReceiveRows(rowInfos));
       },
+      onDeletedGroup: (groupIds) {
+        //
+      },
+      onInsertedGroup: (insertedGroups) {
+        //
+      },
+      onUpdatedGroup: (updatedGroups) {
+        //
+        for (final group in updatedGroups) {
+          final columnController =
+              boardController.getColumnController(group.groupId);
+          if (columnController != null) {
+            columnController.updateColumnName(group.desc);
+          }
+        }
+      },
       onError: (err) {
         Log.error(err);
       },
@@ -189,7 +205,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
   }
 
   Future<void> _loadGrid(Emitter<BoardState> emit) async {
-    final result = await _dataController.loadData();
+    final result = await _gridDataController.loadData();
     result.fold(
       (grid) => emit(
         state.copyWith(loadingState: GridLoadingState.finish(left(unit))),
@@ -301,6 +317,6 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
 
   @override
   void updateRow(String groupId, RowPB row) {
-    //
+    controller.updateColumnItem(groupId, BoardColumnItem(row: row));
   }
 }

+ 34 - 4
frontend/app_flowy/lib/plugins/board/application/board_data_controller.dart

@@ -10,9 +10,15 @@ import 'dart:async';
 import 'package:dartz/dartz.dart';
 import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart';
 
+import 'board_listener.dart';
+
 typedef OnFieldsChanged = void Function(UnmodifiableListView<FieldPB>);
 typedef OnGridChanged = void Function(GridPB);
 typedef DidLoadGroups = void Function(List<GroupPB>);
+typedef OnUpdatedGroup = void Function(List<GroupPB>);
+typedef OnDeletedGroup = void Function(List<String>);
+typedef OnInsertedGroup = void Function(List<InsertedGroupPB>);
+
 typedef OnRowsChanged = void Function(
   List<RowInfo>,
   RowsChangedReason,
@@ -23,6 +29,7 @@ class BoardDataController {
   final String gridId;
   final GridFFIService _gridFFIService;
   final GridFieldCache fieldCache;
+  final BoardListener _listener;
 
   // key: the block id
   final LinkedHashMap<String, GridBlockCache> _blocks;
@@ -44,16 +51,20 @@ class BoardDataController {
 
   BoardDataController({required ViewPB view})
       : gridId = view.id,
+        _listener = BoardListener(view.id),
         _blocks = LinkedHashMap.new(),
         _gridFFIService = GridFFIService(gridId: view.id),
         fieldCache = GridFieldCache(gridId: view.id);
 
   void addListener({
-    OnGridChanged? onGridChanged,
+    required OnGridChanged onGridChanged,
     OnFieldsChanged? onFieldsChanged,
-    DidLoadGroups? didLoadGroups,
-    OnRowsChanged? onRowsChanged,
-    OnError? onError,
+    required DidLoadGroups didLoadGroups,
+    required OnRowsChanged onRowsChanged,
+    required OnUpdatedGroup onUpdatedGroup,
+    required OnDeletedGroup onDeletedGroup,
+    required OnInsertedGroup onInsertedGroup,
+    required OnError? onError,
   }) {
     _onGridChanged = onGridChanged;
     _onFieldsChanged = onFieldsChanged;
@@ -64,6 +75,25 @@ class BoardDataController {
     fieldCache.addListener(onFields: (fields) {
       _onFieldsChanged?.call(UnmodifiableListView(fields));
     });
+
+    _listener.start(onBoardChanged: (result) {
+      result.fold(
+        (changeset) {
+          if (changeset.updateGroups.isNotEmpty) {
+            onUpdatedGroup.call(changeset.updateGroups);
+          }
+
+          if (changeset.insertedGroups.isNotEmpty) {
+            onInsertedGroup.call(changeset.insertedGroups);
+          }
+
+          if (changeset.deletedGroups.isNotEmpty) {
+            onDeletedGroup.call(changeset.deletedGroups);
+          }
+        },
+        (e) => _onError?.call(e),
+      );
+    });
   }
 
   Future<Either<Unit, FlowyError>> loadData() async {

+ 50 - 0
frontend/app_flowy/lib/plugins/board/application/board_listener.dart

@@ -0,0 +1,50 @@
+import 'dart:typed_data';
+
+import 'package:app_flowy/core/grid_notification.dart';
+import 'package:flowy_infra/notifier.dart';
+import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/dart_notification.pb.dart';
+import 'package:dartz/dartz.dart';
+import 'package:flowy_sdk/protobuf/flowy-grid/group_changeset.pb.dart';
+
+typedef UpdateBoardNotifiedValue = Either<GroupViewChangesetPB, FlowyError>;
+
+class BoardListener {
+  final String viewId;
+  PublishNotifier<UpdateBoardNotifiedValue>? _groupNotifier = PublishNotifier();
+  GridNotificationListener? _listener;
+  BoardListener(this.viewId);
+
+  void start({
+    required void Function(UpdateBoardNotifiedValue) onBoardChanged,
+  }) {
+    _groupNotifier?.addPublishListener(onBoardChanged);
+    _listener = GridNotificationListener(
+      objectId: viewId,
+      handler: _handler,
+    );
+  }
+
+  void _handler(
+    GridNotification ty,
+    Either<Uint8List, FlowyError> result,
+  ) {
+    switch (ty) {
+      case GridNotification.DidUpdateGroupView:
+        result.fold(
+          (payload) => _groupNotifier?.value =
+              left(GroupViewChangesetPB.fromBuffer(payload)),
+          (error) => _groupNotifier?.value = right(error),
+        );
+        break;
+      default:
+        break;
+    }
+  }
+
+  Future<void> stop() async {
+    await _listener?.stop();
+    _groupNotifier?.dispose();
+    _groupNotifier = null;
+  }
+}

+ 0 - 1
frontend/app_flowy/lib/plugins/board/application/card/board_select_option_cell_bloc.dart

@@ -68,7 +68,6 @@ class BoardSelectOptionCellState with _$BoardSelectOptionCellState {
   factory BoardSelectOptionCellState.initial(
       GridSelectOptionCellController context) {
     final data = context.getCellData();
-
     return BoardSelectOptionCellState(
       selectedOptions: data?.selectOptions ?? [],
     );

+ 6 - 6
frontend/app_flowy/lib/plugins/board/application/group_controller.dart

@@ -34,7 +34,12 @@ class GroupController {
   void startListening() {
     _listener.start(onGroupChanged: (result) {
       result.fold(
-        (GroupRowsChangesetPB changeset) {
+        (GroupChangesetPB changeset) {
+          for (final deletedRow in changeset.deletedRows) {
+            group.rows.removeWhere((rowPB) => rowPB.id == deletedRow);
+            delegate.removeRow(group.groupId, deletedRow);
+          }
+
           for (final insertedRow in changeset.insertedRows) {
             final index = insertedRow.hasIndex() ? insertedRow.index : null;
 
@@ -52,11 +57,6 @@ class GroupController {
             );
           }
 
-          for (final deletedRow in changeset.deletedRows) {
-            group.rows.removeWhere((rowPB) => rowPB.id == deletedRow);
-            delegate.removeRow(group.groupId, deletedRow);
-          }
-
           for (final updatedRow in changeset.updatedRows) {
             final index = group.rows.indexWhere(
               (rowPB) => rowPB.id == updatedRow.id,

+ 2 - 2
frontend/app_flowy/lib/plugins/board/application/group_listener.dart

@@ -8,7 +8,7 @@ import 'package:flowy_sdk/protobuf/flowy-grid/group.pb.dart';
 import 'package:dartz/dartz.dart';
 import 'package:flowy_sdk/protobuf/flowy-grid/group_changeset.pb.dart';
 
-typedef UpdateGroupNotifiedValue = Either<GroupRowsChangesetPB, FlowyError>;
+typedef UpdateGroupNotifiedValue = Either<GroupChangesetPB, FlowyError>;
 
 class GroupListener {
   final GroupPB group;
@@ -34,7 +34,7 @@ class GroupListener {
       case GridNotification.DidUpdateGroup:
         result.fold(
           (payload) => _groupNotifier?.value =
-              left(GroupRowsChangesetPB.fromBuffer(payload)),
+              left(GroupChangesetPB.fromBuffer(payload)),
           (error) => _groupNotifier?.value = right(error),
         );
         break;

+ 1 - 1
frontend/app_flowy/lib/plugins/board/board.dart

@@ -31,7 +31,7 @@ class BoardPluginBuilder implements PluginBuilder {
 
 class BoardPluginConfig implements PluginConfig {
   @override
-  bool get creatable => false;
+  bool get creatable => true;
 }
 
 class BoardPlugin extends Plugin {

+ 65 - 16
frontend/app_flowy/lib/plugins/board/presentation/board_page.dart

@@ -9,6 +9,9 @@ import 'package:app_flowy/plugins/grid/application/row/row_data_controller.dart'
 import 'package:app_flowy/plugins/grid/presentation/widgets/cell/cell_builder.dart';
 import 'package:app_flowy/plugins/grid/presentation/widgets/row/row_detail.dart';
 import 'package:appflowy_board/appflowy_board.dart';
+import 'package:flowy_infra/image.dart';
+import 'package:flowy_infra/theme.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
 import 'package:flowy_infra_ui/widget/error_page.dart';
 import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
 import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart';
@@ -62,12 +65,15 @@ class BoardContent extends StatelessWidget {
           child: Padding(
             padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
             child: AFBoard(
-              // key: UniqueKey(),
               scrollController: ScrollController(),
-              dataController: context.read<BoardBloc>().afBoardDataController,
+              dataController: context.read<BoardBloc>().boardController,
               headerBuilder: _buildHeader,
               footBuilder: _buildFooter,
-              cardBuilder: (_, data) => _buildCard(context, data),
+              cardBuilder: (_, column, columnItem) => _buildCard(
+                context,
+                column,
+                columnItem,
+              ),
               columnConstraints: const BoxConstraints.tightFor(width: 240),
               config: AFBoardConfig(
                 columnBackgroundColor: HexColor.fromHex('#F7F8FC'),
@@ -79,34 +85,64 @@ class BoardContent extends StatelessWidget {
     );
   }
 
-  Widget _buildHeader(BuildContext context, AFBoardColumnData columnData) {
+  Widget _buildHeader(
+      BuildContext context, AFBoardColumnHeaderData headerData) {
     return AppFlowyColumnHeader(
-      icon: const Icon(Icons.lightbulb_circle),
-      title: Text(columnData.desc),
-      addIcon: const Icon(Icons.add, size: 20),
-      moreIcon: const Icon(Icons.more_horiz, size: 20),
+      title: Flexible(
+        fit: FlexFit.tight,
+        child: FlowyText.medium(
+          headerData.columnName,
+          fontSize: 14,
+          overflow: TextOverflow.clip,
+          color: context.read<AppTheme>().textColor,
+        ),
+      ),
+      // addIcon: const Icon(Icons.add, size: 20),
+      // moreIcon: SizedBox(
+      //   width: 20,
+      //   height: 20,
+      //   child: svgWidget(
+      //     'grid/details',
+      //     color: context.read<AppTheme>().iconColor,
+      //   ),
+      // ),
       height: 50,
-      margin: config.columnItemPadding,
+      margin: config.headerPadding,
     );
   }
 
   Widget _buildFooter(BuildContext context, AFBoardColumnData columnData) {
     return AppFlowyColumnFooter(
-        icon: const Icon(Icons.add, size: 20),
-        title: const Text('New'),
+        icon: SizedBox(
+          height: 20,
+          width: 20,
+          child: svgWidget(
+            "home/add",
+            color: context.read<AppTheme>().iconColor,
+          ),
+        ),
+        title: FlowyText.medium(
+          "New",
+          fontSize: 14,
+          color: context.read<AppTheme>().textColor,
+        ),
         height: 50,
-        margin: config.columnItemPadding,
+        margin: config.footerPadding,
         onAddButtonClick: () {
           context.read<BoardBloc>().add(BoardEvent.createRow(columnData.id));
         });
   }
 
-  Widget _buildCard(BuildContext context, AFColumnItem item) {
-    final rowPB = (item as BoardColumnItem).row;
+  Widget _buildCard(
+    BuildContext context,
+    AFBoardColumnData column,
+    AFColumnItem columnItem,
+  ) {
+    final rowPB = (columnItem as BoardColumnItem).row;
     final rowCache = context.read<BoardBloc>().getRowCache(rowPB.blockId);
 
     /// Return placeholder widget if the rowCache is null.
-    if (rowCache == null) return SizedBox(key: ObjectKey(item));
+    if (rowCache == null) return SizedBox(key: ObjectKey(columnItem));
 
     final fieldCache = context.read<BoardBloc>().fieldCache;
     final gridId = context.read<BoardBloc>().gridId;
@@ -123,9 +159,12 @@ class BoardContent extends StatelessWidget {
         );
 
     return AppFlowyColumnItemCard(
-      key: ObjectKey(item),
+      key: ObjectKey(columnItem),
+      margin: config.cardPadding,
+      decoration: _makeBoxDecoration(context),
       child: BoardCard(
         gridId: gridId,
+        groupId: column.id,
         isEditing: isEditing,
         cellBuilder: cellBuilder,
         dataController: cardController,
@@ -143,6 +182,16 @@ class BoardContent extends StatelessWidget {
     );
   }
 
+  BoxDecoration _makeBoxDecoration(BuildContext context) {
+    final theme = context.read<AppTheme>();
+    final borderSide = BorderSide(color: theme.shader6, width: 1.0);
+    return BoxDecoration(
+      color: theme.surface,
+      border: Border.fromBorderSide(borderSide),
+      borderRadius: const BorderRadius.all(Radius.circular(6)),
+    );
+  }
+
   void _openCard(String gridId, GridFieldCache fieldCache, RowPB rowPB,
       GridRowCache rowCache, BuildContext context) {
     final rowInfo = RowInfo(

+ 11 - 6
frontend/app_flowy/lib/plugins/board/presentation/card/board_checkbox_cell.dart

@@ -6,9 +6,11 @@ import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
 class BoardCheckboxCell extends StatefulWidget {
+  final String groupId;
   final GridCellControllerBuilder cellControllerBuilder;
 
   const BoardCheckboxCell({
+    required this.groupId,
     required this.cellControllerBuilder,
     Key? key,
   }) : super(key: key);
@@ -38,12 +40,15 @@ class _BoardCheckboxCellState extends State<BoardCheckboxCell> {
           final icon = state.isSelected
               ? svgWidget('editor/editor_check')
               : svgWidget('editor/editor_uncheck');
-          return Align(
-            alignment: Alignment.centerLeft,
-            child: FlowyIconButton(
-              iconPadding: EdgeInsets.zero,
-              icon: icon,
-              width: 20,
+          return Padding(
+            padding: EdgeInsets.zero,
+            child: Align(
+              alignment: Alignment.centerLeft,
+              child: FlowyIconButton(
+                iconPadding: EdgeInsets.zero,
+                icon: icon,
+                width: 20,
+              ),
             ),
           );
         },

+ 14 - 3
frontend/app_flowy/lib/plugins/board/presentation/card/board_date_cell.dart

@@ -1,13 +1,18 @@
 import 'package:app_flowy/plugins/board/application/card/board_date_cell_bloc.dart';
 import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart';
+import 'package:flowy_infra/theme.dart';
 import 'package:flowy_infra_ui/style_widget/text.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
+import 'define.dart';
+
 class BoardDateCell extends StatefulWidget {
+  final String groupId;
   final GridCellControllerBuilder cellControllerBuilder;
 
   const BoardDateCell({
+    required this.groupId,
     required this.cellControllerBuilder,
     Key? key,
   }) : super(key: key);
@@ -40,9 +45,15 @@ class _BoardDateCellState extends State<BoardDateCell> {
           } else {
             return Align(
               alignment: Alignment.centerLeft,
-              child: FlowyText.regular(
-                state.dateStr,
-                fontSize: 14,
+              child: Padding(
+                padding: EdgeInsets.symmetric(
+                  vertical: BoardSizes.cardCellVPadding,
+                ),
+                child: FlowyText.regular(
+                  state.dateStr,
+                  fontSize: 13,
+                  color: context.read<AppTheme>().shader3,
+                ),
               ),
             );
           }

+ 13 - 5
frontend/app_flowy/lib/plugins/board/presentation/card/board_number_cell.dart

@@ -4,10 +4,14 @@ import 'package:flowy_infra_ui/style_widget/text.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
+import 'define.dart';
+
 class BoardNumberCell extends StatefulWidget {
+  final String groupId;
   final GridCellControllerBuilder cellControllerBuilder;
 
   const BoardNumberCell({
+    required this.groupId,
     required this.cellControllerBuilder,
     Key? key,
   }) : super(key: key);
@@ -38,11 +42,15 @@ class _BoardNumberCellState extends State<BoardNumberCell> {
           if (state.content.isEmpty) {
             return const SizedBox();
           } else {
-            return Align(
-              alignment: Alignment.centerLeft,
-              child: FlowyText.regular(
-                state.content,
-                fontSize: 14,
+            return Padding(
+              padding:
+                  EdgeInsets.symmetric(vertical: BoardSizes.cardCellVPadding),
+              child: Align(
+                alignment: Alignment.centerLeft,
+                child: FlowyText.medium(
+                  state.content,
+                  fontSize: 14,
+                ),
               ),
             );
           }

+ 24 - 13
frontend/app_flowy/lib/plugins/board/presentation/card/board_select_option_cell.dart

@@ -4,10 +4,14 @@ import 'package:app_flowy/plugins/grid/presentation/widgets/cell/select_option_c
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
+import 'define.dart';
+
 class BoardSelectOptionCell extends StatefulWidget {
+  final String groupId;
   final GridCellControllerBuilder cellControllerBuilder;
 
   const BoardSelectOptionCell({
+    required this.groupId,
     required this.cellControllerBuilder,
     Key? key,
   }) : super(key: key);
@@ -34,22 +38,29 @@ class _BoardSelectOptionCellState extends State<BoardSelectOptionCell> {
       value: _cellBloc,
       child: BlocBuilder<BoardSelectOptionCellBloc, BoardSelectOptionCellState>(
         builder: (context, state) {
-          final children = state.selectedOptions
-              .map((option) => SelectOptionTag.fromOption(
+          if (state.selectedOptions
+              .where((element) => element.id == widget.groupId)
+              .isNotEmpty) {
+            return const SizedBox();
+          } else {
+            final children = state.selectedOptions
+                .map(
+                  (option) => SelectOptionTag.fromOption(
                     context: context,
                     option: option,
-                  ))
-              .toList();
-          return Align(
-            alignment: Alignment.centerLeft,
-            child: AbsorbPointer(
-              child: Wrap(
-                children: children,
-                spacing: 4,
-                runSpacing: 2,
+                  ),
+                )
+                .toList();
+            return Padding(
+              padding: EdgeInsets.only(top: BoardSizes.cardCellVPadding),
+              child: Align(
+                alignment: Alignment.centerLeft,
+                child: AbsorbPointer(
+                  child: Wrap(children: children, spacing: 4, runSpacing: 2),
+                ),
               ),
-            ),
-          );
+            );
+          }
         },
       ),
     );

+ 14 - 8
frontend/app_flowy/lib/plugins/board/presentation/card/board_text_cell.dart

@@ -4,10 +4,16 @@ import 'package:flowy_infra_ui/style_widget/text.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
+import 'define.dart';
+
 class BoardTextCell extends StatefulWidget {
+  final String groupId;
   final GridCellControllerBuilder cellControllerBuilder;
-  const BoardTextCell({required this.cellControllerBuilder, Key? key})
-      : super(key: key);
+  const BoardTextCell({
+    required this.groupId,
+    required this.cellControllerBuilder,
+    Key? key,
+  }) : super(key: key);
 
   @override
   State<BoardTextCell> createState() => _BoardTextCellState();
@@ -37,13 +43,13 @@ class _BoardTextCellState extends State<BoardTextCell> {
           } else {
             return Align(
               alignment: Alignment.centerLeft,
-              child: ConstrainedBox(
-                constraints: BoxConstraints.loose(
-                  const Size(double.infinity, 100),
+              child: Padding(
+                padding: EdgeInsets.symmetric(
+                  vertical: BoardSizes.cardCellVPadding,
                 ),
-                child: FlowyText.regular(
-                  state.content,
-                  fontSize: 14,
+                child: ConstrainedBox(
+                  constraints: const BoxConstraints(maxHeight: 120),
+                  child: FlowyText.medium(state.content, fontSize: 14),
                 ),
               ),
             );

+ 18 - 10
frontend/app_flowy/lib/plugins/board/presentation/card/board_url_cell.dart

@@ -4,10 +4,14 @@ import 'package:flowy_infra/theme.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 
+import 'define.dart';
+
 class BoardUrlCell extends StatefulWidget {
+  final String groupId;
   final GridCellControllerBuilder cellControllerBuilder;
 
   const BoardUrlCell({
+    required this.groupId,
     required this.cellControllerBuilder,
     Key? key,
   }) : super(key: key);
@@ -38,16 +42,20 @@ class _BoardUrlCellState extends State<BoardUrlCell> {
           if (state.content.isEmpty) {
             return const SizedBox();
           } else {
-            return Align(
-              alignment: Alignment.centerLeft,
-              child: RichText(
-                textAlign: TextAlign.left,
-                text: TextSpan(
-                  text: state.content,
-                  style: TextStyle(
-                    color: theme.main2,
-                    fontSize: 14,
-                    decoration: TextDecoration.underline,
+            return Padding(
+              padding:
+                  EdgeInsets.symmetric(vertical: BoardSizes.cardCellVPadding),
+              child: Align(
+                alignment: Alignment.centerLeft,
+                child: RichText(
+                  textAlign: TextAlign.left,
+                  text: TextSpan(
+                    text: state.content,
+                    style: TextStyle(
+                      color: theme.main2,
+                      fontSize: 14,
+                      decoration: TextDecoration.underline,
+                    ),
                   ),
                 ),
               ),

+ 12 - 4
frontend/app_flowy/lib/plugins/board/presentation/card/card.dart

@@ -14,6 +14,7 @@ typedef OnEndEditing = void Function(String rowId);
 
 class BoardCard extends StatefulWidget {
   final String gridId;
+  final String groupId;
   final bool isEditing;
   final CardDataController dataController;
   final BoardCellBuilder cellBuilder;
@@ -22,6 +23,7 @@ class BoardCard extends StatefulWidget {
 
   const BoardCard({
     required this.gridId,
+    required this.groupId,
     required this.isEditing,
     required this.dataController,
     required this.cellBuilder,
@@ -42,7 +44,7 @@ class _BoardCardState extends State<BoardCard> {
     _cardBloc = BoardCardBloc(
       gridId: widget.gridId,
       dataController: widget.dataController,
-    );
+    )..add(const BoardCardEvent.initial());
     super.initState();
   }
 
@@ -71,14 +73,20 @@ class _BoardCardState extends State<BoardCard> {
   List<Widget> _makeCells(BuildContext context, GridCellMap cellMap) {
     return cellMap.values.map(
       (cellId) {
-        final child = widget.cellBuilder.buildCell(cellId);
+        final child = widget.cellBuilder.buildCell(widget.groupId, cellId);
         return Padding(
-          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
+          padding: const EdgeInsets.symmetric(horizontal: 6),
           child: child,
         );
       },
     ).toList();
   }
+
+  @override
+  Future<void> dispose() async {
+    _cardBloc.close();
+    super.dispose();
+  }
 }
 
 class _CardMoreOption extends StatelessWidget with CardAccessory {
@@ -86,7 +94,7 @@ class _CardMoreOption extends StatelessWidget with CardAccessory {
 
   @override
   Widget build(BuildContext context) {
-    return svgWidget('home/details', color: context.read<AppTheme>().iconColor);
+    return svgWidget('grid/details', color: context.read<AppTheme>().iconColor);
   }
 
   @override

+ 8 - 1
frontend/app_flowy/lib/plugins/board/presentation/card/card_cell_builder.dart

@@ -19,7 +19,7 @@ class BoardCellBuilder {
 
   BoardCellBuilder(this.delegate);
 
-  Widget buildCell(GridCellIdentifier cellId) {
+  Widget buildCell(String groupId, GridCellIdentifier cellId) {
     final cellControllerBuilder = GridCellControllerBuilder(
       delegate: delegate,
       cellId: cellId,
@@ -30,36 +30,43 @@ class BoardCellBuilder {
     switch (cellId.fieldType) {
       case FieldType.Checkbox:
         return BoardCheckboxCell(
+          groupId: groupId,
           cellControllerBuilder: cellControllerBuilder,
           key: key,
         );
       case FieldType.DateTime:
         return BoardDateCell(
+          groupId: groupId,
           cellControllerBuilder: cellControllerBuilder,
           key: key,
         );
       case FieldType.SingleSelect:
         return BoardSelectOptionCell(
+          groupId: groupId,
           cellControllerBuilder: cellControllerBuilder,
           key: key,
         );
       case FieldType.MultiSelect:
         return BoardSelectOptionCell(
+          groupId: groupId,
           cellControllerBuilder: cellControllerBuilder,
           key: key,
         );
       case FieldType.Number:
         return BoardNumberCell(
+          groupId: groupId,
           cellControllerBuilder: cellControllerBuilder,
           key: key,
         );
       case FieldType.RichText:
         return BoardTextCell(
+          groupId: groupId,
           cellControllerBuilder: cellControllerBuilder,
           key: key,
         );
       case FieldType.URL:
         return BoardUrlCell(
+          groupId: groupId,
           cellControllerBuilder: cellControllerBuilder,
           key: key,
         );

+ 19 - 1
frontend/app_flowy/lib/plugins/board/presentation/card/card_container.dart

@@ -74,6 +74,7 @@ class CardAccessoryContainer extends StatelessWidget {
           width: 26,
           height: 26,
           padding: const EdgeInsets.all(3),
+          decoration: _makeBoxDecoration(context),
           child: accessory,
         ),
       );
@@ -88,6 +89,23 @@ class CardAccessoryContainer extends StatelessWidget {
   }
 }
 
+BoxDecoration _makeBoxDecoration(BuildContext context) {
+  final theme = context.read<AppTheme>();
+  final borderSide = BorderSide(color: theme.shader6, width: 1.0);
+  return BoxDecoration(
+    color: theme.surface,
+    border: Border.fromBorderSide(borderSide),
+    boxShadow: [
+      BoxShadow(
+          color: theme.shader6,
+          spreadRadius: 0,
+          blurRadius: 2,
+          offset: Offset.zero)
+    ],
+    borderRadius: const BorderRadius.all(Radius.circular(6)),
+  );
+}
+
 class _CardEnterRegion extends StatelessWidget {
   final Widget child;
   final List<CardAccessory> accessories;
@@ -116,7 +134,7 @@ class _CardEnterRegion extends StatelessWidget {
                   .onEnter = false,
           child: IntrinsicHeight(
               child: Stack(
-            alignment: AlignmentDirectional.center,
+            alignment: AlignmentDirectional.topEnd,
             fit: StackFit.expand,
             children: children,
           )),

+ 3 - 0
frontend/app_flowy/lib/plugins/board/presentation/card/define.dart

@@ -0,0 +1,3 @@
+class BoardSizes {
+  static double get cardCellVPadding => 6;
+}

+ 16 - 12
frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_data_loader.dart

@@ -24,18 +24,21 @@ class GridCellDataLoader<T> {
   Future<T?> loadData() {
     final fut = service.getCell(cellId: cellId);
     return fut.then(
-      (result) => result.fold((GridCellPB cell) {
-        try {
-          return parser.parserData(cell.data);
-        } catch (e, s) {
-          Log.error('$parser parser cellData failed, $e');
-          Log.error('Stack trace \n $s');
+      (result) => result.fold(
+        (GridCellPB cell) {
+          try {
+            return parser.parserData(cell.data);
+          } catch (e, s) {
+            Log.error('$parser parser cellData failed, $e');
+            Log.error('Stack trace \n $s');
+            return null;
+          }
+        },
+        (err) {
+          Log.error(err);
           return null;
-        }
-      }, (err) {
-        Log.error(err);
-        return null;
-      }),
+        },
+      ),
     );
   }
 }
@@ -58,7 +61,8 @@ class DateCellDataParser implements IGridCellDataParser<DateCellDataPB> {
   }
 }
 
-class SelectOptionCellDataParser implements IGridCellDataParser<SelectOptionCellDataPB> {
+class SelectOptionCellDataParser
+    implements IGridCellDataParser<SelectOptionCellDataPB> {
   @override
   SelectOptionCellDataPB? parserData(List<int> data) {
     if (data.isEmpty) {

+ 5 - 2
frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/context_builder.dart

@@ -190,7 +190,10 @@ class IGridCellController<T, D> extends Equatable {
     ///  cell display: $12
     _cellListener?.start(onCellChanged: (result) {
       result.fold(
-        (_) => _loadData(),
+        (_) {
+          _cellsCache.remove(fieldId);
+          _loadData();
+        },
         (err) => Log.error(err),
       );
     });
@@ -279,8 +282,8 @@ class IGridCellController<T, D> extends Equatable {
     _loadDataOperation?.cancel();
     _loadDataOperation = Timer(const Duration(milliseconds: 10), () {
       _cellDataLoader.loadData().then((data) {
-        _cellDataNotifier?.value = data;
         _cellsCache.insert(_cacheKey, GridCell(object: data));
+        _cellDataNotifier?.value = data;
       });
     });
   }

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

@@ -91,8 +91,11 @@ class SelectOptionTag extends StatelessWidget {
   Widget build(BuildContext context) {
     return ChoiceChip(
       pressElevation: 1,
-      label:
-          FlowyText.medium(name, fontSize: 12, overflow: TextOverflow.ellipsis),
+      label: FlowyText.medium(
+        name,
+        fontSize: 12,
+        overflow: TextOverflow.clip,
+      ),
       selectedColor: color,
       backgroundColor: color,
       labelPadding: const EdgeInsets.symmetric(horizontal: 6),

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

@@ -34,13 +34,15 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
       RichTextItem(title: "Card 8", subtitle: 'Aug 1, 2020 4:05 PM'),
       TextItem("Card 9"),
     ];
+
     final column1 = AFBoardColumnData(id: "To Do", items: a);
     final column2 = AFBoardColumnData(id: "In Progress", items: <AFColumnItem>[
       RichTextItem(title: "Card 10", subtitle: 'Aug 1, 2020 4:05 PM'),
       TextItem("Card 11"),
     ]);
 
-    final column3 = AFBoardColumnData(id: "Done", items: <AFColumnItem>[]);
+    final column3 =
+        AFBoardColumnData(id: "Done", name: "Done", items: <AFColumnItem>[]);
 
     boardDataController.addColumn(column1);
     boardDataController.addColumn(column2);
@@ -68,20 +70,31 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
               margin: config.columnItemPadding,
             );
           },
-          headerBuilder: (context, columnData) {
+          headerBuilder: (context, headerData) {
             return AppFlowyColumnHeader(
               icon: const Icon(Icons.lightbulb_circle),
-              title: Text(columnData.id),
+              title: SizedBox(
+                width: 60,
+                child: TextField(
+                  controller: TextEditingController()
+                    ..text = headerData.columnName,
+                  onSubmitted: (val) {
+                    boardDataController
+                        .getColumnController(headerData.columnId)!
+                        .updateColumnName(val);
+                  },
+                ),
+              ),
               addIcon: const Icon(Icons.add, size: 20),
               moreIcon: const Icon(Icons.more_horiz, size: 20),
               height: 50,
               margin: config.columnItemPadding,
             );
           },
-          cardBuilder: (context, item) {
+          cardBuilder: (context, column, columnItem) {
             return AppFlowyColumnItemCard(
-              key: ObjectKey(item),
-              child: _buildCard(item),
+              key: ObjectKey(columnItem),
+              child: _buildCard(columnItem),
             );
           },
           columnConstraints: const BoxConstraints.tightFor(width: 240),
@@ -108,7 +121,7 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
       return Align(
         alignment: Alignment.centerLeft,
         child: Padding(
-          padding: const EdgeInsets.all(20),
+          padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 60),
           child: Column(
             crossAxisAlignment: CrossAxisAlignment.start,
             children: [

+ 13 - 8
frontend/app_flowy/packages/appflowy_board/example/lib/single_board_list_example.dart

@@ -13,12 +13,16 @@ class _SingleBoardListExampleState extends State<SingleBoardListExample> {
 
   @override
   void initState() {
-    final column = AFBoardColumnData(id: "1", items: [
-      TextItem("a"),
-      TextItem("b"),
-      TextItem("c"),
-      TextItem("d"),
-    ]);
+    final column = AFBoardColumnData(
+      id: "1",
+      name: "1",
+      items: [
+        TextItem("a"),
+        TextItem("b"),
+        TextItem("c"),
+        TextItem("d"),
+      ],
+    );
 
     boardData.addColumn(column);
     super.initState();
@@ -28,8 +32,9 @@ class _SingleBoardListExampleState extends State<SingleBoardListExample> {
   Widget build(BuildContext context) {
     return AFBoard(
       dataController: boardData,
-      cardBuilder: (context, item) {
-        return _RowWidget(item: item as TextItem, key: ObjectKey(item));
+      cardBuilder: (context, column, columnItem) {
+        return _RowWidget(
+            item: columnItem as TextItem, key: ObjectKey(columnItem));
       },
     );
   }

+ 22 - 3
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart

@@ -12,12 +12,18 @@ class AFBoardConfig {
   final double cornerRadius;
   final EdgeInsets columnPadding;
   final EdgeInsets columnItemPadding;
+  final EdgeInsets footerPadding;
+  final EdgeInsets headerPadding;
+  final EdgeInsets cardPadding;
   final Color columnBackgroundColor;
 
   const AFBoardConfig({
     this.cornerRadius = 6.0,
     this.columnPadding = const EdgeInsets.symmetric(horizontal: 8),
-    this.columnItemPadding = const EdgeInsets.symmetric(horizontal: 10),
+    this.columnItemPadding = const EdgeInsets.symmetric(horizontal: 12),
+    this.footerPadding = const EdgeInsets.symmetric(horizontal: 12),
+    this.headerPadding = const EdgeInsets.symmetric(horizontal: 16),
+    this.cardPadding = const EdgeInsets.symmetric(horizontal: 3, vertical: 4),
     this.columnBackgroundColor = Colors.transparent,
   });
 }
@@ -205,7 +211,7 @@ class _BoardContentState extends State<BoardContent> {
 
         return ChangeNotifierProvider.value(
           key: ValueKey(columnData.id),
-          value: widget.dataController.columnController(columnData.id),
+          value: widget.dataController.getColumnController(columnData.id),
           child: Consumer<AFBoardColumnDataController>(
             builder: (context, value, child) {
               final boardColumn = AFBoardColumnWidget(
@@ -245,6 +251,19 @@ class _BoardContentState extends State<BoardContent> {
     return children;
   }
 
+  Widget? _buildHeader(
+      BuildContext context, AFBoardColumnHeaderData headerData) {
+    if (widget.headerBuilder == null) {
+      return null;
+    }
+    return Selector<AFBoardColumnDataController, AFBoardColumnHeaderData>(
+      selector: (context, controller) => controller.columnData.headerData,
+      builder: (context, headerData, _) {
+        return widget.headerBuilder!(context, headerData)!;
+      },
+    );
+  }
+
   EdgeInsets _marginFromIndex(int index) {
     if (widget.dataController.columnDatas.isEmpty) {
       return widget.config.columnPadding;
@@ -273,7 +292,7 @@ class _BoardColumnDataSourceImpl extends AFBoardColumnDataDataSource {
 
   @override
   AFBoardColumnData get columnData =>
-      dataController.columnController(columnId).columnData;
+      dataController.getColumnController(columnId)!.columnData;
 
   @override
   List<String> get acceptedColumnIds => dataController.columnIds;

+ 6 - 5
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column.dart

@@ -24,12 +24,13 @@ typedef OnColumnInserted = void Function(String listId, int insertedIndex);
 
 typedef AFBoardColumnCardBuilder = Widget Function(
   BuildContext context,
+  AFBoardColumnData columnData,
   AFColumnItem item,
 );
 
-typedef AFBoardColumnHeaderBuilder = Widget Function(
+typedef AFBoardColumnHeaderBuilder = Widget? Function(
   BuildContext context,
-  AFBoardColumnData columnData,
+  AFBoardColumnHeaderData headerData,
 );
 
 typedef AFBoardColumnFooterBuilder = Widget Function(
@@ -125,8 +126,8 @@ class _AFBoardColumnWidgetState extends State<AFBoardColumnWidget> {
             .map((item) => _buildWidget(context, item))
             .toList();
 
-        final header =
-            widget.headerBuilder?.call(context, widget.dataSource.columnData);
+        final header = widget.headerBuilder
+            ?.call(context, widget.dataSource.columnData.headerData);
 
         final footer =
             widget.footBuilder?.call(context, widget.dataSource.columnData);
@@ -207,7 +208,7 @@ class _AFBoardColumnWidgetState extends State<AFBoardColumnWidget> {
         passthroughPhantomContext: item.phantomContext,
       );
     } else {
-      return widget.cardBuilder(context, item);
+      return widget.cardBuilder(context, widget.dataSource.columnData, item);
     }
   }
 }

+ 33 - 3
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column_data.dart

@@ -34,6 +34,13 @@ class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin {
   UnmodifiableListView<AFColumnItem> get items =>
       UnmodifiableListView(columnData.items);
 
+  void updateColumnName(String newName) {
+    if (columnData.headerData.columnName != newName) {
+      columnData.headerData.columnName = newName;
+      notifyListeners();
+    }
+  }
+
   /// Remove the item at [index].
   /// * [index] the index of the item you want to remove
   /// * [notify] the default value of [notify] is true, it will notify the
@@ -123,6 +130,18 @@ class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin {
     notifyListeners();
   }
 
+  void replaceOrInsertItem(AFColumnItem newItem) {
+    final index = columnData._items.indexWhere((item) => item.id == newItem.id);
+    if (index != -1) {
+      columnData._items.removeAt(index);
+      columnData._items.insert(index, newItem);
+      notifyListeners();
+    } else {
+      columnData._items.add(newItem);
+      notifyListeners();
+    }
+  }
+
   bool _containsItem(AFColumnItem item) {
     return columnData._items.indexWhere((element) => element.id == item.id) !=
         -1;
@@ -133,16 +152,20 @@ class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin {
 class AFBoardColumnData<CustomData> extends ReoderFlexItem with EquatableMixin {
   @override
   final String id;
-  final String desc;
+  AFBoardColumnHeaderData headerData;
   final List<AFColumnItem> _items;
   final CustomData? customData;
 
   AFBoardColumnData({
     this.customData,
     required this.id,
-    this.desc = "",
+    required String name,
     List<AFColumnItem> items = const [],
-  }) : _items = items;
+  })  : _items = items,
+        headerData = AFBoardColumnHeaderData(
+          columnId: id,
+          columnName: name,
+        );
 
   /// Returns the readonly List<ColumnItem>
   UnmodifiableListView<AFColumnItem> get items =>
@@ -156,3 +179,10 @@ class AFBoardColumnData<CustomData> extends ReoderFlexItem with EquatableMixin {
     return 'Column:[$id]';
   }
 }
+
+class AFBoardColumnHeaderData {
+  String columnId;
+  String columnName;
+
+  AFBoardColumnHeaderData({required this.columnId, required this.columnName});
+}

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

@@ -89,10 +89,6 @@ class AFBoardDataController extends ChangeNotifier
     if (columnIds.isNotEmpty && notify) notifyListeners();
   }
 
-  AFBoardColumnDataController columnController(String columnId) {
-    return _columnControllers[columnId]!;
-  }
-
   AFBoardColumnDataController? getColumnController(String columnId) {
     final columnController = _columnControllers[columnId];
     if (columnController == null) {
@@ -129,6 +125,10 @@ class AFBoardDataController extends ChangeNotifier
     getColumnController(columnId)?.removeWhere((item) => item.id == itemId);
   }
 
+  void updateColumnItem(String columnId, AFColumnItem item) {
+    getColumnController(columnId)?.replaceOrInsertItem(item);
+  }
+
   @override
   @protected
   void swapColumnItem(
@@ -137,15 +137,14 @@ class AFBoardDataController extends ChangeNotifier
     String toColumnId,
     int toColumnIndex,
   ) {
-    final item = columnController(fromColumnId).removeAt(fromColumnIndex);
-
-    if (columnController(toColumnId).items.length > toColumnIndex) {
-      assert(columnController(toColumnId).items[toColumnIndex]
-          is PhantomColumnItem);
+    final fromColumnController = getColumnController(fromColumnId)!;
+    final toColumnController = getColumnController(toColumnId)!;
+    final item = fromColumnController.removeAt(fromColumnIndex);
+    if (toColumnController.items.length > toColumnIndex) {
+      assert(toColumnController.items[toColumnIndex] is PhantomColumnItem);
     }
 
-    columnController(toColumnId).replace(toColumnIndex, item);
-
+    toColumnController.replace(toColumnIndex, item);
     onMoveColumnItemToColumn?.call(
       fromColumnId,
       fromColumnIndex,
@@ -174,9 +173,12 @@ class AFBoardDataController extends ChangeNotifier
   @override
   @protected
   bool removePhantom(String columnId) {
-    final columnController = this.columnController(columnId);
+    final columnController = getColumnController(columnId);
+    if (columnController == null) {
+      Log.warn('Can not find the column controller with columnId: $columnId');
+      return false;
+    }
     final index = columnController.items.indexWhere((item) => item.isPhantom);
-
     final isExist = index != -1;
     if (isExist) {
       columnController.removeAt(index);
@@ -190,7 +192,7 @@ class AFBoardDataController extends ChangeNotifier
   @override
   @protected
   void updatePhantom(String columnId, int newIndex) {
-    final columnDataController = columnController(columnId);
+    final columnDataController = getColumnController(columnId)!;
     final index =
         columnDataController.items.indexWhere((item) => item.isPhantom);
 
@@ -208,6 +210,6 @@ class AFBoardDataController extends ChangeNotifier
   @override
   @protected
   void insertPhantom(String columnId, int index, PhantomColumnItem item) {
-    columnController(columnId).insert(index, item);
+    getColumnController(columnId)!.insert(index, item);
   }
 }

+ 7 - 9
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/card.dart

@@ -2,16 +2,17 @@ import 'package:flutter/material.dart';
 
 class AppFlowyColumnItemCard extends StatefulWidget {
   final Widget? child;
-  final Color backgroundColor;
-  final double cornerRadius;
   final EdgeInsets margin;
   final BoxConstraints boxConstraints;
+  final BoxDecoration decoration;
 
   const AppFlowyColumnItemCard({
     this.child,
-    this.cornerRadius = 0.0,
     this.margin = const EdgeInsets.all(4),
-    this.backgroundColor = Colors.white,
+    this.decoration = const BoxDecoration(
+      color: Colors.white,
+      borderRadius: BorderRadius.zero,
+    ),
     this.boxConstraints = const BoxConstraints(minHeight: 40),
     Key? key,
   }) : super(key: key);
@@ -24,14 +25,11 @@ class _AppFlowyColumnItemCardState extends State<AppFlowyColumnItemCard> {
   @override
   Widget build(BuildContext context) {
     return Padding(
-      padding: const EdgeInsets.all(4),
+      padding: widget.margin,
       child: Container(
         clipBehavior: Clip.hardEdge,
         constraints: widget.boxConstraints,
-        decoration: BoxDecoration(
-          color: widget.backgroundColor,
-          borderRadius: BorderRadius.circular(widget.cornerRadius),
-        ),
+        decoration: widget.decoration,
         child: widget.child,
       ),
     );

+ 3 - 2
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/footer.dart

@@ -12,7 +12,7 @@ class AppFlowyColumnFooter extends StatefulWidget {
   const AppFlowyColumnFooter({
     this.icon,
     this.title,
-    this.margin = EdgeInsets.zero,
+    this.margin = const EdgeInsets.symmetric(horizontal: 12),
     required this.height,
     this.onAddButtonClick,
     Key? key,
@@ -30,12 +30,13 @@ class _AppFlowyColumnFooterState extends State<AppFlowyColumnFooter> {
       child: SizedBox(
         height: widget.height,
         child: Padding(
-          padding: const EdgeInsets.symmetric(horizontal: 10),
+          padding: widget.margin,
           child: Row(
             mainAxisAlignment: MainAxisAlignment.start,
             crossAxisAlignment: CrossAxisAlignment.center,
             children: [
               if (widget.icon != null) widget.icon!,
+              const SizedBox(width: 8),
               if (widget.title != null) widget.title!,
             ],
           ),

+ 14 - 6
frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/header.dart

@@ -45,15 +45,25 @@ class _AppFlowyColumnHeaderState extends State<AppFlowyColumnHeader> {
     }
 
     if (widget.moreIcon != null) {
-      children.add(const Spacer());
+      // children.add(const Spacer());
       children.add(
-        IconButton(onPressed: widget.onMoreButtonClick, icon: widget.moreIcon!),
+        IconButton(
+          onPressed: widget.onMoreButtonClick,
+          icon: widget.moreIcon!,
+          padding: const EdgeInsets.all(4),
+          constraints: const BoxConstraints(),
+        ),
       );
     }
 
     if (widget.addIcon != null) {
       children.add(
-        IconButton(onPressed: widget.onAddButtonClick, icon: widget.addIcon!),
+        IconButton(
+          onPressed: widget.onAddButtonClick,
+          icon: widget.addIcon!,
+          padding: const EdgeInsets.all(4),
+          constraints: const BoxConstraints(),
+        ),
       );
     }
 
@@ -61,9 +71,7 @@ class _AppFlowyColumnHeaderState extends State<AppFlowyColumnHeader> {
       height: widget.height,
       child: Padding(
         padding: widget.margin,
-        child: Row(
-          children: children,
-        ),
+        child: Row(children: children),
       ),
     );
   }

+ 5 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/clear.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="8" cy="8" r="7" fill="#F2F2F2"/>
+<path d="M6 6L10 10" stroke="#BDBDBD" stroke-linecap="round"/>
+<path d="M10 6L6 10" stroke="#BDBDBD" stroke-linecap="round"/>
+</svg>

+ 6 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/delete.svg

@@ -0,0 +1,6 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3 4.3999H4.11111H13" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.77799 4.4V3.2C5.77799 2.88174 5.89506 2.57652 6.10343 2.35147C6.31181 2.12643 6.59442 2 6.88911 2H9.11133C9.40601 2 9.68863 2.12643 9.897 2.35147C10.1054 2.57652 10.2224 2.88174 10.2224 3.2V4.4M11.8891 4.4V12.8C11.8891 13.1183 11.772 13.4235 11.5637 13.6485C11.3553 13.8736 11.0727 14 10.778 14H5.22244C4.92775 14 4.64514 13.8736 4.43676 13.6485C4.22839 13.4235 4.11133 13.1183 4.11133 12.8V4.4H11.8891Z" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.88867 7.3999V10.9999" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.11133 7.3999V10.9999" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 5 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_center.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4 4L12 4" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 8H11" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 12L12 12" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 5 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_left.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4 4L12 4" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 8H10" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 12L12 12" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 5 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_right.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4 4L12 4" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6 8H12" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 12L12 12" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/copy.svg

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.974 6.33301H7.35865C6.7922 6.33301 6.33301 6.7922 6.33301 7.35865V11.974C6.33301 12.5405 6.7922 12.9997 7.35865 12.9997H11.974C12.5405 12.9997 12.9997 12.5405 12.9997 11.974V7.35865C12.9997 6.7922 12.5405 6.33301 11.974 6.33301Z" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.53846 9.66667H4.02564C3.75362 9.66667 3.49275 9.55861 3.3004 9.36626C3.10806 9.17392 3 8.91304 3 8.64103V4.02564C3 3.75362 3.10806 3.49275 3.3004 3.3004C3.49275 3.10806 3.75362 3 4.02564 3H8.64103C8.91304 3 9.17392 3.10806 9.36626 3.3004C9.55861 3.49275 9.66667 3.75362 9.66667 4.02564V4.53846" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 6 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/delete.svg

@@ -0,0 +1,6 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3 4.3999H4.11111H13" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.77799 4.4V3.2C5.77799 2.88174 5.89506 2.57652 6.10343 2.35147C6.31181 2.12643 6.59442 2 6.88911 2H9.11133C9.40601 2 9.68863 2.12643 9.897 2.35147C10.1054 2.57652 10.2224 2.88174 10.2224 3.2V4.4M11.8891 4.4V12.8C11.8891 13.1183 11.772 13.4235 11.5637 13.6485C11.3553 13.8736 11.0727 14 10.778 14H5.22244C4.92775 14 4.64514 13.8736 4.43676 13.6485C4.22839 13.4235 4.11133 13.1183 4.11133 12.8V4.4H11.8891Z" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.88867 7.3999V10.9999" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.11133 7.3999V10.9999" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/divider.svg

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

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/share.svg

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/link.svg

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 5 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/image.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="#333333"/>
+<circle cx="5.5" cy="6.5" r="1" stroke="#333333"/>
+<path d="M5 13L10.112 8.45603C10.4211 8.18126 10.8674 8.12513 11.235 8.31482L14.5 10" stroke="#333333"/>
+</svg>

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/link.svg

@@ -1,4 +1,4 @@
 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="#00BCF0" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="#00BCF0" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
 </svg>

+ 10 - 1
frontend/app_flowy/packages/appflowy_editor/example/assets/example.json

@@ -6,7 +6,8 @@
       {
         "type": "image",
         "attributes": {
-          "image_src": "https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png"
+          "image_src": "https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png",
+          "align": "center"
         }
       },
       {
@@ -121,6 +122,14 @@
           "heading": "h3"
         }
       },
+      {
+        "type": "image",
+        "attributes": {
+          "image_src": "https://s1.ax1x.com/2022/08/24/vgAJED.png",
+          "align": "left",
+          "width": 300
+        }
+      },
       {
         "type": "text",
         "delta": [

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

@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
 import 'expandable_floating_action_button.dart';
-import 'plugin/image_node_widget.dart';
+// import 'plugin/image_node_widget.dart';
 import 'plugin/youtube_link_node_widget.dart';
 
 import 'package:appflowy_editor/appflowy_editor.dart';
@@ -139,7 +139,7 @@ class _MyHomePageState extends State<MyHomePage> {
         editorState: editorState,
         keyEventHandlers: const [],
         customBuilders: {
-          'image': ImageNodeBuilder(),
+          // 'image': ImageNodeBuilder(),
           'youtube_link': YouTubeLinkNodeBuilder()
         },
       ),

+ 48 - 9
frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart

@@ -6,22 +6,57 @@ import 'package:appflowy_editor/src/document/text_delta.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
 
 extension TextNodeExtension on TextNode {
+  dynamic getAttributeInSelection(Selection selection, String styleKey) {
+    final ops = delta.whereType<TextInsert>();
+    final startOffset =
+        selection.isBackward ? selection.start.offset : selection.end.offset;
+    final endOffset =
+        selection.isBackward ? selection.end.offset : selection.start.offset;
+    var start = 0;
+    for (final op in ops) {
+      if (start >= endOffset) {
+        break;
+      }
+      final length = op.length;
+      if (start < endOffset && start + length > startOffset) {
+        if (op.attributes?.containsKey(styleKey) == true) {
+          return op.attributes![styleKey];
+        }
+      }
+      start += length;
+    }
+    return null;
+  }
+
+  bool allSatisfyLinkInSelection(Selection selection) =>
+      allSatisfyInSelection(StyleKey.href, selection, (value) {
+        return value != null;
+      });
+
   bool allSatisfyBoldInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.bold, true, selection);
+      allSatisfyInSelection(StyleKey.bold, selection, (value) {
+        return value == true;
+      });
 
   bool allSatisfyItalicInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.italic, true, selection);
+      allSatisfyInSelection(StyleKey.italic, selection, (value) {
+        return value == true;
+      });
 
   bool allSatisfyUnderlineInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.underline, true, selection);
+      allSatisfyInSelection(StyleKey.underline, selection, (value) {
+        return value == true;
+      });
 
   bool allSatisfyStrikethroughInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.strikethrough, true, selection);
+      allSatisfyInSelection(StyleKey.strikethrough, selection, (value) {
+        return value == true;
+      });
 
   bool allSatisfyInSelection(
     String styleKey,
-    dynamic value,
     Selection selection,
+    bool Function(dynamic value) compare,
   ) {
     final ops = delta.whereType<TextInsert>();
     final startOffset =
@@ -37,7 +72,7 @@ extension TextNodeExtension on TextNode {
       if (start < endOffset && start + length > startOffset) {
         if (op.attributes == null ||
             !op.attributes!.containsKey(styleKey) ||
-            op.attributes![styleKey] != value) {
+            !compare(op.attributes![styleKey])) {
           return false;
         }
       }
@@ -91,13 +126,15 @@ extension TextNodesExtension on List<TextNode> {
   bool allSatisfyInSelection(
     String styleKey,
     Selection selection,
-    dynamic value,
+    dynamic matchValue,
   ) {
     if (isEmpty) {
       return false;
     }
     if (length == 1) {
-      return first.allSatisfyInSelection(styleKey, value, selection);
+      return first.allSatisfyInSelection(styleKey, selection, (value) {
+        return value == matchValue;
+      });
     } else {
       for (var i = 0; i < length; i++) {
         final node = this[i];
@@ -117,7 +154,9 @@ extension TextNodesExtension on List<TextNode> {
             end: Position(path: node.path, offset: node.toRawString().length),
           );
         }
-        if (!node.allSatisfyInSelection(styleKey, value, newSelection)) {
+        if (!node.allSatisfyInSelection(styleKey, newSelection, (value) {
+          return value == matchValue;
+        })) {
           return false;
         }
       }

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

@@ -75,6 +75,11 @@ class Log {
   /// For example, uses the logger when processing scroll events.
   static Log scroll = Log._(name: 'scroll');
 
+  /// For logging message related to [AppFlowyToolbarService].
+  ///
+  /// For example, uses the logger when processing toolbar events.
+  static Log toolbar = Log._(name: 'toolbar');
+
   /// For logging message related to UI.
   ///
   /// For example, uses the logger when building the widget.

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

@@ -2,14 +2,14 @@ import 'package:appflowy_editor/appflowy_editor.dart';
 
 abstract class Operation {
   factory Operation.fromJson(Map<String, dynamic> map) {
-    String t = map["type"] as String;
-    if (t == "insert-operation") {
+    String t = map["op"] as String;
+    if (t == "insert") {
       return InsertOperation.fromJson(map);
-    } else if (t == "update-operation") {
+    } else if (t == "update") {
       return UpdateOperation.fromJson(map);
-    } else if (t == "delete-operation") {
+    } else if (t == "delete") {
       return DeleteOperation.fromJson(map);
-    } else if (t == "text-edit-operation") {
+    } else if (t == "text-edit") {
       return TextEditOperation.fromJson(map);
     }
 
@@ -51,7 +51,7 @@ class InsertOperation extends Operation {
   @override
   Map<String, dynamic> toJson() {
     return {
-      "type": "insert-operation",
+      "op": "insert",
       "path": path.toList(),
       "nodes": nodes.map((n) => n.toJson()),
     };
@@ -95,7 +95,7 @@ class UpdateOperation extends Operation {
   @override
   Map<String, dynamic> toJson() {
     return {
-      "type": "update-operation",
+      "op": "update",
       "path": path.toList(),
       "attributes": {...attributes},
       "oldAttributes": {...oldAttributes},
@@ -132,7 +132,7 @@ class DeleteOperation extends Operation {
   @override
   Map<String, dynamic> toJson() {
     return {
-      "type": "delete-operation",
+      "op": "delete",
       "path": path.toList(),
       "nodes": nodes.map((n) => n.toJson()),
     };
@@ -171,7 +171,7 @@ class TextEditOperation extends Operation {
   @override
   Map<String, dynamic> toJson() {
     return {
-      "type": "text-edit-operation",
+      "op": "text-edit",
       "path": path.toList(),
       "delta": delta.toJson(),
       "invert": inverted.toJson(),
@@ -207,10 +207,10 @@ Path transformPath(Path preInsertPath, Path b, [int delta = 1]) {
 
 Operation transformOperation(Operation a, Operation b) {
   if (a is InsertOperation) {
-    final newPath = transformPath(a.path, b.path);
+    final newPath = transformPath(a.path, b.path, a.nodes.length);
     return b.copyWithPath(newPath);
   } else if (a is DeleteOperation) {
-    final newPath = transformPath(a.path, b.path, -1);
+    final newPath = transformPath(a.path, b.path, -1 * a.nodes.length);
     return b.copyWithPath(newPath);
   }
   // TODO: transform update and textedit

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

@@ -116,11 +116,17 @@ class TransactionBuilder {
   /// Optionally, you may specify formatting attributes that are applied to the inserted string.
   /// By default, the formatting attributes before the insert position will be used.
   insertText(TextNode node, int index, String content,
-      [Attributes? attributes]) {
+      {Attributes? attributes, Attributes? removedAttributes}) {
     var newAttributes = attributes;
     if (index != 0 && attributes == null) {
       newAttributes =
           node.delta.slice(max(index - 1, 0), index).first.attributes;
+      if (newAttributes != null) {
+        newAttributes = Attributes.from(newAttributes);
+        if (removedAttributes != null) {
+          newAttributes.addAll(removedAttributes);
+        }
+      }
     }
     textEdit(
       node,

+ 72 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart

@@ -0,0 +1,72 @@
+import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:appflowy_editor/src/service/render_plugin_service.dart';
+import 'package:flutter/material.dart';
+import 'package:rich_clipboard/rich_clipboard.dart';
+
+import 'image_node_widget.dart';
+
+class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
+  @override
+  Widget build(NodeWidgetContext<Node> context) {
+    final src = context.node.attributes['image_src'];
+    final align = context.node.attributes['align'];
+    double? width;
+    if (context.node.attributes.containsKey('width')) {
+      width = context.node.attributes['width'].toDouble();
+    }
+    return ImageNodeWidget(
+      key: context.node.key,
+      src: src,
+      width: width,
+      alignment: _textToAlignment(align),
+      onCopy: () {
+        RichClipboard.setData(RichClipboardData(text: src));
+      },
+      onDelete: () {
+        TransactionBuilder(context.editorState)
+          ..deleteNode(context.node)
+          ..commit();
+      },
+      onAlign: (alignment) {
+        TransactionBuilder(context.editorState)
+          ..updateNode(context.node, {
+            'align': _alignmentToText(alignment),
+          })
+          ..commit();
+      },
+      onResize: (width) {
+        TransactionBuilder(context.editorState)
+          ..updateNode(context.node, {
+            'width': width,
+          })
+          ..commit();
+      },
+    );
+  }
+
+  @override
+  NodeValidator<Node> get nodeValidator => ((node) {
+        return node.type == 'image' &&
+            node.attributes.containsKey('image_src') &&
+            node.attributes.containsKey('align');
+      });
+
+  Alignment _textToAlignment(String text) {
+    if (text == 'left') {
+      return Alignment.centerLeft;
+    } else if (text == 'right') {
+      return Alignment.centerRight;
+    }
+    return Alignment.center;
+  }
+
+  String _alignmentToText(Alignment alignment) {
+    if (alignment == Alignment.centerLeft) {
+      return 'left';
+    } else if (alignment == Alignment.centerRight) {
+      return 'right';
+    }
+    return 'center';
+  }
+}

+ 340 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart

@@ -0,0 +1,340 @@
+import 'package:appflowy_editor/src/infra/flowy_svg.dart';
+import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
+import 'package:flutter/material.dart';
+
+class ImageNodeWidget extends StatefulWidget {
+  const ImageNodeWidget({
+    Key? key,
+    required this.src,
+    this.width,
+    required this.alignment,
+    required this.onCopy,
+    required this.onDelete,
+    required this.onAlign,
+    required this.onResize,
+  }) : super(key: key);
+
+  final String src;
+  final double? width;
+  final Alignment alignment;
+  final VoidCallback onCopy;
+  final VoidCallback onDelete;
+  final void Function(Alignment alignment) onAlign;
+  final void Function(double width) onResize;
+
+  @override
+  State<ImageNodeWidget> createState() => _ImageNodeWidgetState();
+}
+
+class _ImageNodeWidgetState extends State<ImageNodeWidget> {
+  double? _imageWidth;
+  double _initial = 0;
+  double _distance = 0;
+  bool _onFocus = false;
+
+  ImageStream? _imageStream;
+  late ImageStreamListener _imageStreamListener;
+
+  @override
+  void initState() {
+    super.initState();
+
+    _imageWidth = widget.width;
+    _imageStreamListener = ImageStreamListener(
+      (image, _) {
+        _imageWidth = image.image.width.toDouble();
+      },
+    );
+  }
+
+  @override
+  void dispose() {
+    _imageStream?.removeListener(_imageStreamListener);
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    // only support network image.
+
+    return Container(
+      width: defaultMaxTextNodeWidth,
+      padding: const EdgeInsets.only(top: 8, bottom: 8),
+      child: _buildNetworkImage(context),
+    );
+  }
+
+  Widget _buildNetworkImage(BuildContext context) {
+    return Align(
+      alignment: widget.alignment,
+      child: MouseRegion(
+        onEnter: (event) => setState(() {
+          _onFocus = true;
+        }),
+        onExit: (event) => setState(() {
+          _onFocus = false;
+        }),
+        child: _buildResizableImage(context),
+      ),
+    );
+  }
+
+  Widget _buildResizableImage(BuildContext context) {
+    final networkImage = Image.network(
+      widget.src,
+      width: _imageWidth == null ? null : _imageWidth! - _distance,
+      gaplessPlayback: true,
+      loadingBuilder: (context, child, loadingProgress) =>
+          loadingProgress == null ? child : _buildLoading(context),
+      errorBuilder: (context, error, stackTrace) {
+        _imageWidth ??= defaultMaxTextNodeWidth;
+        return _buildError(context);
+      },
+    );
+    if (_imageWidth == null) {
+      _imageStream = networkImage.image.resolve(const ImageConfiguration())
+        ..addListener(_imageStreamListener);
+    }
+    return Stack(
+      children: [
+        networkImage,
+        _buildEdgeGesture(
+          context,
+          top: 0,
+          left: 0,
+          bottom: 0,
+          width: 5,
+          onUpdate: (distance) {
+            setState(() {
+              _distance = distance;
+            });
+          },
+        ),
+        _buildEdgeGesture(
+          context,
+          top: 0,
+          right: 0,
+          bottom: 0,
+          width: 5,
+          onUpdate: (distance) {
+            setState(() {
+              _distance = -distance;
+            });
+          },
+        ),
+        if (_onFocus)
+          ImageToolbar(
+            top: 8,
+            right: 8,
+            height: 30,
+            alignment: widget.alignment,
+            onAlign: widget.onAlign,
+            onCopy: widget.onCopy,
+            onDelete: widget.onDelete,
+          )
+      ],
+    );
+  }
+
+  Widget _buildLoading(BuildContext context) {
+    return SizedBox(
+      height: 150,
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          SizedBox.fromSize(
+            size: const Size(18, 18),
+            child: const CircularProgressIndicator(),
+          ),
+          SizedBox.fromSize(
+            size: const Size(10, 10),
+          ),
+          const Text('Loading'),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildError(BuildContext context) {
+    return Container(
+      height: 100,
+      width: _imageWidth,
+      alignment: Alignment.center,
+      padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
+      decoration: BoxDecoration(
+        borderRadius: const BorderRadius.all(Radius.circular(4.0)),
+        border: Border.all(width: 1, color: Colors.black),
+      ),
+      child: const Text('Could not load the image'),
+    );
+  }
+
+  Widget _buildEdgeGesture(
+    BuildContext context, {
+    double? top,
+    double? left,
+    double? right,
+    double? bottom,
+    double? width,
+    void Function(double distance)? onUpdate,
+  }) {
+    return Positioned(
+      top: top,
+      left: left,
+      right: right,
+      bottom: bottom,
+      width: width,
+      child: GestureDetector(
+        onHorizontalDragStart: (details) {
+          _initial = details.globalPosition.dx;
+        },
+        onHorizontalDragUpdate: (details) {
+          if (onUpdate != null) {
+            onUpdate(details.globalPosition.dx - _initial);
+          }
+        },
+        onHorizontalDragEnd: (details) {
+          _imageWidth = _imageWidth! - _distance;
+          _initial = 0;
+          _distance = 0;
+
+          widget.onResize(_imageWidth!);
+        },
+        child: MouseRegion(
+          cursor: SystemMouseCursors.resizeLeftRight,
+          child: _onFocus
+              ? Center(
+                  child: Container(
+                    height: 40,
+                    decoration: BoxDecoration(
+                      color: Colors.black.withOpacity(0.2),
+                      borderRadius: const BorderRadius.all(
+                        Radius.circular(5.0),
+                      ),
+                    ),
+                  ),
+                )
+              : null,
+        ),
+      ),
+    );
+  }
+}
+
+@visibleForTesting
+class ImageToolbar extends StatelessWidget {
+  const ImageToolbar({
+    Key? key,
+    required this.top,
+    required this.right,
+    required this.height,
+    required this.alignment,
+    required this.onCopy,
+    required this.onDelete,
+    required this.onAlign,
+  }) : super(key: key);
+
+  final double top;
+  final double right;
+  final double height;
+  final Alignment alignment;
+  final VoidCallback onCopy;
+  final VoidCallback onDelete;
+  final void Function(Alignment alignment) onAlign;
+
+  @override
+  Widget build(BuildContext context) {
+    return Positioned(
+      top: top,
+      right: right,
+      height: height,
+      child: Container(
+        decoration: BoxDecoration(
+          color: const Color(0xFF333333),
+          boxShadow: [
+            BoxShadow(
+              blurRadius: 5,
+              spreadRadius: 1,
+              color: Colors.black.withOpacity(0.1),
+            ),
+          ],
+          borderRadius: BorderRadius.circular(8.0),
+        ),
+        child: Row(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            IconButton(
+              hoverColor: Colors.transparent,
+              constraints: const BoxConstraints(),
+              padding: const EdgeInsets.fromLTRB(6.0, 4.0, 0.0, 4.0),
+              icon: FlowySvg(
+                name: 'image_toolbar/align_left',
+                color: alignment == Alignment.centerLeft
+                    ? const Color(0xFF00BCF0)
+                    : null,
+              ),
+              onPressed: () {
+                onAlign(Alignment.centerLeft);
+              },
+            ),
+            IconButton(
+              hoverColor: Colors.transparent,
+              constraints: const BoxConstraints(),
+              padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 4.0),
+              icon: FlowySvg(
+                name: 'image_toolbar/align_center',
+                color: alignment == Alignment.center
+                    ? const Color(0xFF00BCF0)
+                    : null,
+              ),
+              onPressed: () {
+                onAlign(Alignment.center);
+              },
+            ),
+            IconButton(
+              hoverColor: Colors.transparent,
+              constraints: const BoxConstraints(),
+              padding: const EdgeInsets.fromLTRB(0.0, 4.0, 4.0, 4.0),
+              icon: FlowySvg(
+                name: 'image_toolbar/align_right',
+                color: alignment == Alignment.centerRight
+                    ? const Color(0xFF00BCF0)
+                    : null,
+              ),
+              onPressed: () {
+                onAlign(Alignment.centerRight);
+              },
+            ),
+            const Center(
+              child: FlowySvg(
+                name: 'image_toolbar/divider',
+              ),
+            ),
+            IconButton(
+              hoverColor: Colors.transparent,
+              constraints: const BoxConstraints(),
+              padding: const EdgeInsets.fromLTRB(4.0, 4.0, 0.0, 4.0),
+              icon: const FlowySvg(
+                name: 'image_toolbar/copy',
+              ),
+              onPressed: () {
+                onCopy();
+              },
+            ),
+            IconButton(
+              hoverColor: Colors.transparent,
+              constraints: const BoxConstraints(),
+              padding: const EdgeInsets.fromLTRB(0.0, 4.0, 6.0, 4.0),
+              icon: const FlowySvg(
+                name: 'image_toolbar/delete',
+              ),
+              onPressed: () {
+                onDelete();
+              },
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 202 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart

@@ -0,0 +1,202 @@
+import 'dart:collection';
+
+import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/editor_state.dart';
+import 'package:appflowy_editor/src/infra/flowy_svg.dart';
+import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
+import 'package:flutter/material.dart';
+
+OverlayEntry? _imageUploadMenu;
+EditorState? _editorState;
+void showImageUploadMenu(
+  EditorState editorState,
+  SelectionMenuService menuService,
+  BuildContext context,
+) {
+  menuService.dismiss();
+
+  _imageUploadMenu?.remove();
+  _imageUploadMenu = OverlayEntry(builder: (context) {
+    return Positioned(
+      top: menuService.topLeft.dy,
+      left: menuService.topLeft.dx,
+      child: Material(
+        child: ImageUploadMenu(
+          onSubmitted: (text) {
+            // _dismissImageUploadMenu();
+            editorState.insertImageNode(text);
+          },
+          onUpload: (text) {
+            // _dismissImageUploadMenu();
+            editorState.insertImageNode(text);
+          },
+        ),
+      ),
+    );
+  });
+
+  Overlay.of(context)?.insert(_imageUploadMenu!);
+
+  editorState.service.selectionService.currentSelection
+      .addListener(_dismissImageUploadMenu);
+}
+
+void _dismissImageUploadMenu() {
+  _imageUploadMenu?.remove();
+  _imageUploadMenu = null;
+
+  _editorState?.service.selectionService.currentSelection
+      .removeListener(_dismissImageUploadMenu);
+  _editorState = null;
+}
+
+class ImageUploadMenu extends StatefulWidget {
+  const ImageUploadMenu({
+    Key? key,
+    required this.onSubmitted,
+    required this.onUpload,
+  }) : super(key: key);
+
+  final void Function(String text) onSubmitted;
+  final void Function(String text) onUpload;
+
+  @override
+  State<ImageUploadMenu> createState() => _ImageUploadMenuState();
+}
+
+class _ImageUploadMenuState extends State<ImageUploadMenu> {
+  final _textEditingController = TextEditingController();
+  final _focusNode = FocusNode();
+
+  @override
+  void initState() {
+    super.initState();
+    _focusNode.requestFocus();
+  }
+
+  @override
+  void dispose() {
+    _focusNode.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      width: 300,
+      padding: const EdgeInsets.all(24.0),
+      decoration: BoxDecoration(
+        color: Colors.white,
+        boxShadow: [
+          BoxShadow(
+            blurRadius: 5,
+            spreadRadius: 1,
+            color: Colors.black.withOpacity(0.1),
+          ),
+        ],
+        borderRadius: BorderRadius.circular(6.0),
+      ),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          _buildHeader(context),
+          const SizedBox(height: 16.0),
+          _buildInput(),
+          const SizedBox(height: 18.0),
+          _buildUploadButton(context),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildHeader(BuildContext context) {
+    return const Text(
+      'URL Image',
+      textAlign: TextAlign.left,
+      style: TextStyle(
+        fontSize: 14.0,
+        color: Colors.black,
+        fontWeight: FontWeight.w500,
+      ),
+    );
+  }
+
+  Widget _buildInput() {
+    return TextField(
+      focusNode: _focusNode,
+      style: const TextStyle(fontSize: 14.0),
+      textAlign: TextAlign.left,
+      controller: _textEditingController,
+      onSubmitted: widget.onSubmitted,
+      decoration: InputDecoration(
+        hintText: 'URL',
+        hintStyle: const TextStyle(fontSize: 14.0),
+        contentPadding: const EdgeInsets.all(16.0),
+        isDense: true,
+        suffixIcon: IconButton(
+          padding: const EdgeInsets.all(4.0),
+          icon: const FlowySvg(
+            name: 'clear',
+            width: 24,
+            height: 24,
+          ),
+          onPressed: () {
+            _textEditingController.clear();
+          },
+        ),
+        border: const OutlineInputBorder(
+          borderRadius: BorderRadius.all(Radius.circular(12.0)),
+          borderSide: BorderSide(color: Color(0xFFBDBDBD)),
+        ),
+      ),
+    );
+  }
+
+  Widget _buildUploadButton(BuildContext context) {
+    return SizedBox(
+      width: 170,
+      height: 48,
+      child: TextButton(
+        style: ButtonStyle(
+          backgroundColor: MaterialStateProperty.all(const Color(0xFF00BCF0)),
+          shape: MaterialStateProperty.all<RoundedRectangleBorder>(
+            RoundedRectangleBorder(
+              borderRadius: BorderRadius.circular(12.0),
+            ),
+          ),
+        ),
+        onPressed: () {
+          widget.onUpload(_textEditingController.text);
+        },
+        child: const Text(
+          'Upload',
+          style: TextStyle(color: Colors.white, fontSize: 14.0),
+        ),
+      ),
+    );
+  }
+}
+
+extension on EditorState {
+  void insertImageNode(String src) {
+    final selection = service.selectionService.currentSelection.value;
+    if (selection == null) {
+      return;
+    }
+    final imageNode = Node(
+      type: 'image',
+      children: LinkedList(),
+      attributes: {
+        'image_src': src,
+        'align': 'center',
+      },
+    );
+    TransactionBuilder(this)
+      ..insertNode(
+        selection.start.path,
+        imageNode,
+      )
+      ..commit();
+  }
+}

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

@@ -0,0 +1,151 @@
+import 'package:appflowy_editor/src/infra/flowy_svg.dart';
+import 'package:flutter/material.dart';
+
+class LinkMenu extends StatefulWidget {
+  const LinkMenu({
+    Key? key,
+    this.linkText,
+    required this.onSubmitted,
+    required this.onCopyLink,
+    required this.onRemoveLink,
+  }) : super(key: key);
+
+  final String? linkText;
+  final void Function(String text) onSubmitted;
+  final VoidCallback onCopyLink;
+  final VoidCallback onRemoveLink;
+
+  @override
+  State<LinkMenu> createState() => _LinkMenuState();
+}
+
+class _LinkMenuState extends State<LinkMenu> {
+  final _textEditingController = TextEditingController();
+  final _focusNode = FocusNode();
+
+  @override
+  void initState() {
+    super.initState();
+
+    _textEditingController.text = widget.linkText ?? '';
+    _focusNode.requestFocus();
+  }
+
+  @override
+  void dispose() {
+    _focusNode.dispose();
+
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      width: 350,
+      child: Container(
+        decoration: BoxDecoration(
+          color: Colors.white,
+          boxShadow: [
+            BoxShadow(
+              blurRadius: 5,
+              spreadRadius: 1,
+              color: Colors.black.withOpacity(0.1),
+            ),
+          ],
+          borderRadius: BorderRadius.circular(6.0),
+        ),
+        child: Padding(
+          padding: const EdgeInsets.all(20.0),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              _buildHeader(),
+              const SizedBox(height: 16.0),
+              _buildInput(),
+              const SizedBox(height: 16.0),
+              if (widget.linkText != null) ...[
+                _buildIconButton(
+                  iconName: 'link',
+                  text: 'Copy link',
+                  onPressed: widget.onCopyLink,
+                ),
+                _buildIconButton(
+                  iconName: 'delete',
+                  text: 'Remove link',
+                  onPressed: widget.onRemoveLink,
+                ),
+              ]
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+
+  Widget _buildHeader() {
+    return const Text(
+      'Add your link',
+      style: TextStyle(
+        color: Colors.grey,
+        fontWeight: FontWeight.bold,
+      ),
+    );
+  }
+
+  Widget _buildInput() {
+    return TextField(
+      focusNode: _focusNode,
+      style: const TextStyle(fontSize: 14.0),
+      textAlign: TextAlign.left,
+      controller: _textEditingController,
+      onSubmitted: widget.onSubmitted,
+      decoration: InputDecoration(
+        hintText: 'URL',
+        hintStyle: const TextStyle(fontSize: 14.0),
+        contentPadding: const EdgeInsets.all(16.0),
+        isDense: true,
+        suffixIcon: IconButton(
+          padding: const EdgeInsets.all(4.0),
+          icon: const FlowySvg(
+            name: 'clear',
+            width: 24,
+            height: 24,
+          ),
+          onPressed: () {
+            _textEditingController.clear();
+          },
+        ),
+        border: const OutlineInputBorder(
+          borderRadius: BorderRadius.all(Radius.circular(12.0)),
+          borderSide: BorderSide(color: Color(0xFFBDBDBD)),
+        ),
+      ),
+    );
+  }
+
+  Widget _buildIconButton({
+    required String iconName,
+    required String text,
+    required VoidCallback onPressed,
+  }) {
+    return TextButton.icon(
+      icon: FlowySvg(name: iconName),
+      style: TextButton.styleFrom(
+        minimumSize: const Size.fromHeight(40),
+        padding: EdgeInsets.zero,
+        tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+        alignment: Alignment.centerLeft,
+      ),
+      label: Text(
+        text,
+        textAlign: TextAlign.left,
+        style: const TextStyle(
+          color: Colors.black,
+          fontSize: 14.0,
+        ),
+      ),
+      onPressed: onPressed,
+    );
+  }
+}

+ 1 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart

@@ -56,8 +56,6 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
 
   @override
   Widget build(BuildContext context) {
-    final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
-
     return SizedBox(
       width: defaultMaxTextNodeWidth,
       child: Padding(
@@ -69,8 +67,7 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
               key: iconKey,
               width: _iconWidth,
               height: _iconWidth,
-              padding:
-                  EdgeInsets.only(top: topPadding, right: _iconRightPadding),
+              padding: EdgeInsets.only(right: _iconRightPadding),
               name: 'point',
             ),
             Expanded(

+ 1 - 5
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart

@@ -63,7 +63,6 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
 
   Widget _buildWithSingle(BuildContext context) {
     final check = widget.textNode.attributes.check;
-    final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
     return SizedBox(
       width: defaultMaxTextNodeWidth,
       child: Padding(
@@ -76,10 +75,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
               child: FlowySvg(
                 width: _iconWidth,
                 height: _iconWidth,
-                padding: EdgeInsets.only(
-                  top: topPadding,
-                  right: _iconRightPadding,
-                ),
+                padding: EdgeInsets.only(right: _iconRightPadding),
                 name: check ? 'check' : 'uncheck',
               ),
               onTap: () {

+ 62 - 35
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart

@@ -1,5 +1,7 @@
+import 'dart:async';
 import 'dart:ui';
 
+import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
 
@@ -11,6 +13,7 @@ import 'package:appflowy_editor/src/document/text_delta.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
 import 'package:appflowy_editor/src/render/selection/selectable.dart';
+import 'package:url_launcher/url_launcher_string.dart';
 
 typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
 
@@ -143,6 +146,11 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
     );
   }
 
+  @override
+  Offset localToGlobal(Offset offset) {
+    return _renderParagraph.localToGlobal(offset);
+  }
+
   Widget _buildRichText(BuildContext context) {
     return MouseRegion(
       cursor: SystemMouseCursors.text,
@@ -181,43 +189,62 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
     );
   }
 
-  // unused now.
-  // Widget _buildRichTextWithChildren(BuildContext context) {
-  //   return Column(
-  //     crossAxisAlignment: CrossAxisAlignment.start,
-  //     children: [
-  //       _buildSingleRichText(context),
-  //       ...widget.textNode.children
-  //           .map(
-  //             (child) => widget.editorState.service.renderPluginService
-  //                 .buildPluginWidget(
-  //               NodeWidgetContext(
-  //                 context: context,
-  //                 node: child,
-  //                 editorState: widget.editorState,
-  //               ),
-  //             ),
-  //           )
-  //           .toList()
-  //     ],
-  //   );
-  // }
+  TextSpan get _textSpan {
+    var offset = 0;
+    return TextSpan(
+      children: widget.textNode.delta.whereType<TextInsert>().map((insert) {
+        GestureRecognizer? gestureDetector;
+        if (insert.attributes?[StyleKey.href] != null) {
+          final startOffset = offset;
+          Timer? timer;
+          var tapCount = 0;
+          gestureDetector = TapGestureRecognizer()
+            ..onTap = () async {
+              // implement a simple double tap logic
+              tapCount += 1;
+              timer?.cancel();
 
-  @override
-  Offset localToGlobal(Offset offset) {
-    return _renderParagraph.localToGlobal(offset);
-  }
+              if (tapCount == 2) {
+                tapCount = 0;
+                final href = insert.attributes![StyleKey.href];
+                final uri = Uri.parse(href);
+                // url_launcher cannot open a link without scheme.
+                final newHref =
+                    (uri.scheme.isNotEmpty ? href : 'http://$href').trim();
+                if (await canLaunchUrlString(newHref)) {
+                  await launchUrlString(newHref);
+                }
+                return;
+              }
 
-  TextSpan get _textSpan => TextSpan(
-        children: widget.textNode.delta
-            .whereType<TextInsert>()
-            .map((insert) => RichTextStyle(
-                  attributes: insert.attributes ?? {},
-                  text: insert.content,
-                  height: _lineHeight,
-                ).toTextSpan())
-            .toList(growable: false),
-      );
+              timer = Timer(const Duration(milliseconds: 200), () {
+                tapCount = 0;
+                // update selection
+                final selection = Selection.single(
+                  path: widget.textNode.path,
+                  startOffset: startOffset,
+                  endOffset: startOffset + insert.length,
+                );
+                widget.editorState.service.selectionService
+                    .updateSelection(selection);
+                WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+                  widget.editorState.service.toolbarService
+                      ?.triggerHandler('appflowy.toolbar.link');
+                });
+              });
+            };
+        }
+        offset += insert.length;
+        final textSpan = RichTextStyle(
+          attributes: insert.attributes ?? {},
+          text: insert.content,
+          height: _lineHeight,
+          gestureRecognizer: gestureDetector,
+        ).toTextSpan();
+        return textSpan;
+      }).toList(growable: false),
+    );
+  }
 
   TextSpan get _placeholderTextSpan => TextSpan(children: [
         RichTextStyle(

+ 1 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart

@@ -56,7 +56,6 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
 
   @override
   Widget build(BuildContext context) {
-    final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
     return Padding(
         padding: EdgeInsets.only(bottom: defaultLinePadding),
         child: SizedBox(
@@ -68,8 +67,7 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
                 key: iconKey,
                 width: _iconWidth,
                 height: _iconWidth,
-                padding:
-                    EdgeInsets.only(top: topPadding, right: _iconRightPadding),
+                padding: EdgeInsets.only(right: _iconRightPadding),
                 number: widget.textNode.attributes.number,
               ),
               Expanded(

+ 24 - 31
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart

@@ -55,39 +55,32 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
 
   @override
   Widget build(BuildContext context) {
-    final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
     return SizedBox(
-        width: defaultMaxTextNodeWidth,
-        child: Padding(
-          padding: EdgeInsets.only(bottom: defaultLinePadding),
-          child: IntrinsicHeight(
-            child: Row(
-              crossAxisAlignment: CrossAxisAlignment.stretch,
-              children: [
-                FlowySvg(
-                  key: iconKey,
-                  width: _iconWidth,
-                  padding: EdgeInsets.only(
-                      top: topPadding, right: _iconRightPadding),
-                  name: 'quote',
+      width: defaultMaxTextNodeWidth,
+      child: Padding(
+        padding: EdgeInsets.only(bottom: defaultLinePadding),
+        child: IntrinsicHeight(
+          child: Row(
+            crossAxisAlignment: CrossAxisAlignment.stretch,
+            children: [
+              FlowySvg(
+                key: iconKey,
+                width: _iconWidth,
+                padding: EdgeInsets.only(right: _iconRightPadding),
+                name: 'quote',
+              ),
+              Expanded(
+                child: FlowyRichText(
+                  key: _richTextKey,
+                  placeholderText: 'Quote',
+                  textNode: widget.textNode,
+                  editorState: widget.editorState,
                 ),
-                Expanded(
-                  child: FlowyRichText(
-                    key: _richTextKey,
-                    placeholderText: 'Quote',
-                    textNode: widget.textNode,
-                    editorState: widget.editorState,
-                  ),
-                ),
-              ],
-            ),
+              ),
+            ],
           ),
-        ));
-  }
-
-  double get _quoteHeight {
-    final lines =
-        widget.textNode.toRawString().characters.where((c) => c == '\n').length;
-    return (lines + 1) * _iconWidth;
+        ),
+      ),
+    );
   }
 }

+ 4 - 14
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart

@@ -1,8 +1,6 @@
 import 'package:appflowy_editor/src/document/attributes.dart';
-import 'package:appflowy_editor/src/document/node.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
-import 'package:url_launcher/url_launcher_string.dart';
 
 ///
 /// Supported partial rendering types:
@@ -182,14 +180,13 @@ class RichTextStyle {
   RichTextStyle({
     required this.attributes,
     required this.text,
+    this.gestureRecognizer,
     this.height = 1.5,
   });
 
-  RichTextStyle.fromTextNode(TextNode textNode)
-      : this(attributes: textNode.attributes, text: textNode.toRawString());
-
   final Attributes attributes;
   final String text;
+  final GestureRecognizer? gestureRecognizer;
   final double height;
 
   TextSpan toTextSpan() => _toTextSpan(height);
@@ -201,6 +198,7 @@ class RichTextStyle {
   TextSpan _toTextSpan(double? height) {
     return TextSpan(
       text: text,
+      recognizer: _recognizer,
       style: TextStyle(
         fontWeight: _fontWeight,
         fontStyle: _fontStyle,
@@ -210,7 +208,6 @@ class RichTextStyle {
         background: _background,
         height: height,
       ),
-      recognizer: _recognizer,
     );
   }
 
@@ -273,13 +270,6 @@ class RichTextStyle {
 
   // recognizer
   GestureRecognizer? get _recognizer {
-    final href = attributes.href;
-    if (href != null) {
-      return TapGestureRecognizer()
-        ..onTap = () async {
-          await launchUrlString(href);
-        };
-    }
-    return null;
+    return gestureRecognizer;
   }
 }

+ 0 - 217
frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/toolbar_widget.dart

@@ -1,217 +0,0 @@
-import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
-import 'package:flutter/material.dart';
-
-import 'package:appflowy_editor/src/editor_state.dart';
-import 'package:appflowy_editor/src/infra/flowy_svg.dart';
-import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
-
-typedef ToolbarEventHandler = void Function(EditorState editorState);
-
-typedef ToolbarEventHandlers = Map<String, ToolbarEventHandler>;
-
-ToolbarEventHandlers defaultToolbarEventHandlers = {
-  'bold': (editorState) => formatBold(editorState),
-  'italic': (editorState) => formatItalic(editorState),
-  'strikethrough': (editorState) => formatStrikethrough(editorState),
-  'underline': (editorState) => formatUnderline(editorState),
-  'quote': (editorState) => formatQuote(editorState),
-  'bulleted_list': (editorState) => formatBulletedList(editorState),
-  'highlight': (editorState) => formatHighlight(editorState),
-  'Text': (editorState) => formatText(editorState),
-  'h1': (editorState) => formatHeading(editorState, StyleKey.h1),
-  'h2': (editorState) => formatHeading(editorState, StyleKey.h2),
-  'h3': (editorState) => formatHeading(editorState, StyleKey.h3),
-};
-
-List<String> defaultListToolbarEventNames = [
-  'Text',
-  'H1',
-  'H2',
-  'H3',
-];
-
-mixin ToolbarMixin<T extends StatefulWidget> on State<T> {
-  void hide();
-}
-
-class ToolbarWidget extends StatefulWidget {
-  const ToolbarWidget({
-    Key? key,
-    required this.editorState,
-    required this.layerLink,
-    required this.offset,
-    required this.handlers,
-  }) : super(key: key);
-
-  final EditorState editorState;
-  final LayerLink layerLink;
-  final Offset offset;
-  final ToolbarEventHandlers handlers;
-
-  @override
-  State<ToolbarWidget> createState() => _ToolbarWidgetState();
-}
-
-class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
-  // final GlobalKey _listToolbarKey = GlobalKey();
-
-  final toolbarHeight = 32.0;
-  final topPadding = 5.0;
-
-  final listToolbarWidth = 60.0;
-  final listToolbarHeight = 120.0;
-
-  final cornerRadius = 8.0;
-
-  OverlayEntry? _listToolbarOverlay;
-
-  @override
-  Widget build(BuildContext context) {
-    return Positioned(
-      top: widget.offset.dx,
-      left: widget.offset.dy,
-      child: CompositedTransformFollower(
-        link: widget.layerLink,
-        showWhenUnlinked: true,
-        offset: widget.offset,
-        child: _buildToolbar(context),
-      ),
-    );
-  }
-
-  @override
-  void hide() {
-    _listToolbarOverlay?.remove();
-    _listToolbarOverlay = null;
-  }
-
-  Widget _buildToolbar(BuildContext context) {
-    return Material(
-      borderRadius: BorderRadius.circular(cornerRadius),
-      color: const Color(0xFF333333),
-      child: SizedBox(
-        height: toolbarHeight,
-        child: Row(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            // _listToolbar(context),
-            _centerToolbarIcon('h1', tooltipMessage: 'Heading 1'),
-            _centerToolbarIcon('h2', tooltipMessage: 'Heading 2'),
-            _centerToolbarIcon('h3', tooltipMessage: 'Heading 3'),
-            _centerToolbarIcon('divider', width: 2),
-            _centerToolbarIcon('bold', tooltipMessage: 'Bold'),
-            _centerToolbarIcon('italic', tooltipMessage: 'Italic'),
-            _centerToolbarIcon('strikethrough',
-                tooltipMessage: 'Strikethrough'),
-            _centerToolbarIcon('underline', tooltipMessage: 'Underline'),
-            _centerToolbarIcon('divider', width: 2),
-            _centerToolbarIcon('quote', tooltipMessage: 'Quote'),
-            // _centerToolbarIcon('number_list'),
-            _centerToolbarIcon('bulleted_list',
-                tooltipMessage: 'Bulleted List'),
-            _centerToolbarIcon('divider', width: 2),
-            _centerToolbarIcon('highlight', tooltipMessage: 'Highlight'),
-          ],
-        ),
-      ),
-    );
-  }
-
-  // Widget _listToolbar(BuildContext context) {
-  //   return _centerToolbarIcon(
-  //     'quote',
-  //     key: _listToolbarKey,
-  //     width: listToolbarWidth,
-  //     onTap: () => _onTapListToolbar(context),
-  //   );
-  // }
-
-  Widget _centerToolbarIcon(String name,
-      {Key? key, String? tooltipMessage, double? width, VoidCallback? onTap}) {
-    return Tooltip(
-        key: key,
-        preferBelow: false,
-        message: tooltipMessage ?? '',
-        child: MouseRegion(
-          cursor: SystemMouseCursors.click,
-          child: GestureDetector(
-            onTap: onTap ?? () => _onTap(name),
-            child: SizedBox.fromSize(
-              size:
-                  Size(toolbarHeight - (width != null ? 20 : 0), toolbarHeight),
-              child: Center(
-                child: FlowySvg(
-                  width: width ?? 20,
-                  name: 'toolbar/$name',
-                ),
-              ),
-            ),
-          ),
-        ));
-  }
-
-  // void _onTapListToolbar(BuildContext context) {
-  //   // TODO: implement more detailed UI.
-  //   final items = defaultListToolbarEventNames;
-  //   final renderBox =
-  //       _listToolbarKey.currentContext?.findRenderObject() as RenderBox;
-  //   final offset = renderBox
-  //       .localToGlobal(Offset.zero)
-  //       .translate(0, toolbarHeight - cornerRadius);
-  //   final rect = offset & Size(listToolbarWidth, listToolbarHeight);
-
-  //   _listToolbarOverlay?.remove();
-  //   _listToolbarOverlay = OverlayEntry(builder: (context) {
-  //     return Positioned.fromRect(
-  //       rect: rect,
-  //       child: Material(
-  //         borderRadius: BorderRadius.only(
-  //           bottomLeft: Radius.circular(cornerRadius),
-  //           bottomRight: Radius.circular(cornerRadius),
-  //         ),
-  //         color: const Color(0xFF333333),
-  //         child: SingleChildScrollView(
-  //           child: ListView.builder(
-  //             itemExtent: toolbarHeight,
-  //             padding: const EdgeInsets.only(bottom: 10.0),
-  //             shrinkWrap: true,
-  //             itemCount: items.length,
-  //             itemBuilder: ((context, index) {
-  //               return ListTile(
-  //                 contentPadding: const EdgeInsets.only(
-  //                   left: 3.0,
-  //                   right: 3.0,
-  //                 ),
-  //                 minVerticalPadding: 0.0,
-  //                 title: FittedBox(
-  //                   fit: BoxFit.scaleDown,
-  //                   child: Text(
-  //                     items[index],
-  //                     textAlign: TextAlign.center,
-  //                     style: const TextStyle(
-  //                       color: Colors.white,
-  //                     ),
-  //                   ),
-  //                 ),
-  //                 onTap: () {
-  //                   _onTap(items[index]);
-  //                 },
-  //               );
-  //             }),
-  //           ),
-  //         ),
-  //       ),
-  //     );
-  //   });
-  //   // TODO: disable scrolling.
-  //   Overlay.of(context)?.insert(_listToolbarOverlay!);
-  // }
-
-  void _onTap(String eventName) {
-    if (defaultToolbarEventHandlers.containsKey(eventName)) {
-      defaultToolbarEventHandlers[eventName]!(widget.editorState);
-      return;
-    }
-    assert(false, 'Could not find the event handler for $eventName');
-  }
-}

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

@@ -45,7 +45,7 @@ class SelectionMenuItemWidget extends StatelessWidget {
             ),
           ),
           onPressed: () {
-            item.handler(editorState, menuService);
+            item.handler(editorState, menuService, context);
           },
         ),
       ),

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

@@ -1,5 +1,6 @@
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
+import 'package:appflowy_editor/src/render/image/image_upload_widget.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
 import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
@@ -23,6 +24,7 @@ class SelectionMenu implements SelectionMenuService {
 
   OverlayEntry? _selectionMenuEntry;
   bool _selectionUpdateByInner = false;
+  Offset? _topLeft;
 
   @override
   void dismiss() {
@@ -53,6 +55,7 @@ class SelectionMenu implements SelectionMenuService {
       return;
     }
     final offset = selectionRects.first.bottomRight + const Offset(10, 10);
+    _topLeft = offset;
 
     _selectionMenuEntry = OverlayEntry(builder: (context) {
       return Positioned(
@@ -84,8 +87,9 @@ class SelectionMenu implements SelectionMenuService {
   }
 
   @override
-  // TODO: implement topLeft
-  Offset get topLeft => throw UnimplementedError();
+  Offset get topLeft {
+    return _topLeft ?? Offset.zero;
+  }
 
   void _onSelectionChange() {
     // workaround: SelectionService has been released after hot reload.
@@ -115,7 +119,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     name: 'Text',
     icon: _selectionMenuIcon('text'),
     keywords: ['text'],
-    handler: (editorState, menuService) {
+    handler: (editorState, _, __) {
       insertTextNodeAfterSelection(editorState, {});
     },
   ),
@@ -123,7 +127,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     name: 'Heading 1',
     icon: _selectionMenuIcon('h1'),
     keywords: ['heading 1, h1'],
-    handler: (editorState, menuService) {
+    handler: (editorState, _, __) {
       insertHeadingAfterSelection(editorState, StyleKey.h1);
     },
   ),
@@ -131,7 +135,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     name: 'Heading 2',
     icon: _selectionMenuIcon('h2'),
     keywords: ['heading 2, h2'],
-    handler: (editorState, menuService) {
+    handler: (editorState, _, __) {
       insertHeadingAfterSelection(editorState, StyleKey.h2);
     },
   ),
@@ -139,15 +143,21 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     name: 'Heading 3',
     icon: _selectionMenuIcon('h3'),
     keywords: ['heading 3, h3'],
-    handler: (editorState, menuService) {
+    handler: (editorState, _, __) {
       insertHeadingAfterSelection(editorState, StyleKey.h3);
     },
   ),
+  SelectionMenuItem(
+    name: 'Image',
+    icon: _selectionMenuIcon('image'),
+    keywords: ['image'],
+    handler: showImageUploadMenu,
+  ),
   SelectionMenuItem(
     name: 'Bulleted list',
     icon: _selectionMenuIcon('bulleted_list'),
     keywords: ['bulleted list', 'list', 'unordered list'],
-    handler: (editorState, menuService) {
+    handler: (editorState, _, __) {
       insertBulletedListAfterSelection(editorState);
     },
   ),
@@ -155,7 +165,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
     name: 'Checkbox',
     icon: _selectionMenuIcon('checkbox'),
     keywords: ['todo list', 'list', 'checkbox list'],
-    handler: (editorState, menuService) {
+    handler: (editorState, _, __) {
       insertCheckboxAfterSelection(editorState);
     },
   ),

+ 9 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart

@@ -22,8 +22,11 @@ class SelectionMenuItem {
   ///
   /// The keywords are used to quickly retrieve items.
   final List<String> keywords;
-  final void Function(EditorState editorState, SelectionMenuService menuService)
-      handler;
+  final void Function(
+    EditorState editorState,
+    SelectionMenuService menuService,
+    BuildContext context,
+  ) handler;
 }
 
 class SelectionMenuWidget extends StatefulWidget {
@@ -202,8 +205,10 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
     if (event.logicalKey == LogicalKeyboardKey.enter) {
       if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) {
         _deleteLastCharacters(length: keyword.length + 1);
-        _showingItems[_selectedIndex]
-            .handler(widget.editorState, widget.menuService);
+        WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+          _showingItems[_selectedIndex]
+              .handler(widget.editorState, widget.menuService, context);
+        });
         return KeyEventResult.handled;
       }
     } else if (event.logicalKey == LogicalKeyboardKey.escape) {

+ 231 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart

@@ -0,0 +1,231 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/infra/flowy_svg.dart';
+import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
+import 'package:appflowy_editor/src/render/rich_text/rich_text_style.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';
+import 'package:rich_clipboard/rich_clipboard.dart';
+
+typedef ToolbarEventHandler = void Function(
+    EditorState editorState, BuildContext context);
+typedef ToolbarShowValidator = bool Function(EditorState editorState);
+
+class ToolbarItem {
+  ToolbarItem({
+    required this.id,
+    required this.type,
+    required this.icon,
+    this.tooltipsMessage = '',
+    required this.validator,
+    required this.handler,
+  });
+
+  final String id;
+  final int type;
+  final Widget icon;
+  final String tooltipsMessage;
+  final ToolbarShowValidator validator;
+  final ToolbarEventHandler handler;
+
+  factory ToolbarItem.divider() {
+    return ToolbarItem(
+      id: 'divider',
+      type: -1,
+      icon: const FlowySvg(name: 'toolbar/divider'),
+      validator: (editorState) => true,
+      handler: (editorState, context) {},
+    );
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! ToolbarItem) {
+      return false;
+    }
+    if (identical(this, other)) {
+      return true;
+    }
+    return id == other.id;
+  }
+
+  @override
+  int get hashCode => id.hashCode;
+}
+
+List<ToolbarItem> defaultToolbarItems = [
+  ToolbarItem(
+    id: 'appflowy.toolbar.h1',
+    type: 1,
+    tooltipsMessage: 'Heading 1',
+    icon: const FlowySvg(name: 'toolbar/h1'),
+    validator: _onlyShowInSingleTextSelection,
+    handler: (editorState, context) => formatHeading(editorState, StyleKey.h1),
+  ),
+  ToolbarItem(
+    id: 'appflowy.toolbar.h2',
+    type: 1,
+    tooltipsMessage: 'Heading 2',
+    icon: const FlowySvg(name: 'toolbar/h2'),
+    validator: _onlyShowInSingleTextSelection,
+    handler: (editorState, context) => formatHeading(editorState, StyleKey.h2),
+  ),
+  ToolbarItem(
+    id: 'appflowy.toolbar.h3',
+    type: 1,
+    tooltipsMessage: 'Heading 3',
+    icon: const FlowySvg(name: 'toolbar/h3'),
+    validator: _onlyShowInSingleTextSelection,
+    handler: (editorState, context) => formatHeading(editorState, StyleKey.h3),
+  ),
+  ToolbarItem(
+    id: 'appflowy.toolbar.bold',
+    type: 2,
+    tooltipsMessage: 'Bold',
+    icon: const FlowySvg(name: 'toolbar/bold'),
+    validator: _showInTextSelection,
+    handler: (editorState, context) => formatBold(editorState),
+  ),
+  ToolbarItem(
+    id: 'appflowy.toolbar.italic',
+    type: 2,
+    tooltipsMessage: 'Italic',
+    icon: const FlowySvg(name: 'toolbar/italic'),
+    validator: _showInTextSelection,
+    handler: (editorState, context) => formatItalic(editorState),
+  ),
+  ToolbarItem(
+    id: 'appflowy.toolbar.underline',
+    type: 2,
+    tooltipsMessage: 'Underline',
+    icon: const FlowySvg(name: 'toolbar/underline'),
+    validator: _showInTextSelection,
+    handler: (editorState, context) => formatUnderline(editorState),
+  ),
+  ToolbarItem(
+    id: 'appflowy.toolbar.strikethrough',
+    type: 2,
+    tooltipsMessage: 'Strikethrough',
+    icon: const FlowySvg(name: 'toolbar/strikethrough'),
+    validator: _showInTextSelection,
+    handler: (editorState, context) => formatStrikethrough(editorState),
+  ),
+  ToolbarItem(
+    id: 'appflowy.toolbar.quote',
+    type: 3,
+    tooltipsMessage: 'Quote',
+    icon: const FlowySvg(name: 'toolbar/quote'),
+    validator: _onlyShowInSingleTextSelection,
+    handler: (editorState, context) => formatQuote(editorState),
+  ),
+  ToolbarItem(
+    id: 'appflowy.toolbar.bulleted_list',
+    type: 3,
+    tooltipsMessage: 'Bulleted list',
+    icon: const FlowySvg(name: 'toolbar/bulleted_list'),
+    validator: _onlyShowInSingleTextSelection,
+    handler: (editorState, context) => formatBulletedList(editorState),
+  ),
+  ToolbarItem(
+    id: 'appflowy.toolbar.link',
+    type: 4,
+    tooltipsMessage: 'Link',
+    icon: const FlowySvg(name: 'toolbar/link'),
+    validator: _onlyShowInSingleTextSelection,
+    handler: (editorState, context) => _showLinkMenu(editorState, context),
+  ),
+  ToolbarItem(
+    id: 'appflowy.toolbar.highlight',
+    type: 4,
+    tooltipsMessage: 'Highlight',
+    icon: const FlowySvg(name: 'toolbar/highlight'),
+    validator: _showInTextSelection,
+    handler: (editorState, context) => formatHighlight(editorState),
+  ),
+];
+
+ToolbarShowValidator _onlyShowInSingleTextSelection = (editorState) {
+  final nodes = editorState.service.selectionService.currentSelectedNodes;
+  return (nodes.length == 1 && nodes.first is TextNode);
+};
+
+ToolbarShowValidator _showInTextSelection = (editorState) {
+  final nodes = editorState.service.selectionService.currentSelectedNodes
+      .whereType<TextNode>();
+  return nodes.isNotEmpty;
+};
+
+OverlayEntry? _linkMenuOverlay;
+EditorState? _editorState;
+void _showLinkMenu(EditorState editorState, BuildContext context) {
+  final rects = editorState.service.selectionService.selectionRects;
+  var maxBottom = 0.0;
+  late Rect matchRect;
+  for (final rect in rects) {
+    if (rect.bottom > maxBottom) {
+      maxBottom = rect.bottom;
+      matchRect = rect;
+    }
+  }
+
+  _dismissLinkMenu();
+  _editorState = editorState;
+
+  // Since the link menu will only show in single text selection,
+  // We get the text node directly instead of judging details again.
+  final selection =
+      editorState.service.selectionService.currentSelection.value!;
+  final index =
+      selection.isBackward ? selection.start.offset : selection.end.offset;
+  final length = (selection.start.offset - selection.end.offset).abs();
+  final node = editorState.service.selectionService.currentSelectedNodes.first
+      as TextNode;
+  String? linkText;
+  if (node.allSatisfyLinkInSelection(selection)) {
+    linkText = node.getAttributeInSelection(selection, StyleKey.href);
+  }
+  _linkMenuOverlay = OverlayEntry(builder: (context) {
+    return Positioned(
+      top: matchRect.bottom + 5.0,
+      left: matchRect.left,
+      child: Material(
+        child: LinkMenu(
+          linkText: linkText,
+          onSubmitted: (text) {
+            TransactionBuilder(editorState)
+              ..formatText(node, index, length, {StyleKey.href: text})
+              ..commit();
+            _dismissLinkMenu();
+          },
+          onCopyLink: () {
+            RichClipboard.setData(RichClipboardData(text: linkText));
+            _dismissLinkMenu();
+          },
+          onRemoveLink: () {
+            TransactionBuilder(editorState)
+              ..formatText(node, index, length, {StyleKey.href: null})
+              ..commit();
+            _dismissLinkMenu();
+          },
+        ),
+      ),
+    );
+  });
+  Overlay.of(context)?.insert(_linkMenuOverlay!);
+
+  editorState.service.scrollService?.disable();
+  editorState.service.keyboardService?.disable();
+  editorState.service.selectionService.currentSelection
+      .addListener(_dismissLinkMenu);
+}
+
+void _dismissLinkMenu() {
+  _linkMenuOverlay?.remove();
+  _linkMenuOverlay = null;
+
+  _editorState?.service.scrollService?.enable();
+  _editorState?.service.keyboardService?.enable();
+  _editorState?.service.selectionService.currentSelection
+      .removeListener(_dismissLinkMenu);
+  _editorState = null;
+}

+ 35 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart

@@ -0,0 +1,35 @@
+import 'package:flutter/material.dart';
+
+import 'toolbar_item.dart';
+
+class ToolbarItemWidget extends StatelessWidget {
+  const ToolbarItemWidget({
+    Key? key,
+    required this.item,
+    required this.onPressed,
+  }) : super(key: key);
+
+  final ToolbarItem item;
+  final VoidCallback onPressed;
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      width: 28,
+      height: 28,
+      child: Tooltip(
+        preferBelow: false,
+        message: item.tooltipsMessage,
+        child: MouseRegion(
+          cursor: SystemMouseCursors.click,
+          child: IconButton(
+            padding: EdgeInsets.zero,
+            icon: item.icon,
+            iconSize: 28,
+            onPressed: onPressed,
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 79 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart

@@ -0,0 +1,79 @@
+import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
+import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart';
+import 'package:flutter/material.dart';
+
+import 'package:appflowy_editor/src/editor_state.dart';
+
+mixin ToolbarMixin<T extends StatefulWidget> on State<T> {
+  void hide();
+}
+
+class ToolbarWidget extends StatefulWidget {
+  const ToolbarWidget({
+    Key? key,
+    required this.editorState,
+    required this.layerLink,
+    required this.offset,
+    required this.items,
+  }) : super(key: key);
+
+  final EditorState editorState;
+  final LayerLink layerLink;
+  final Offset offset;
+  final List<ToolbarItem> items;
+
+  @override
+  State<ToolbarWidget> createState() => _ToolbarWidgetState();
+}
+
+class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
+  OverlayEntry? _listToolbarOverlay;
+
+  @override
+  Widget build(BuildContext context) {
+    return Positioned(
+      top: widget.offset.dx,
+      left: widget.offset.dy,
+      child: CompositedTransformFollower(
+        link: widget.layerLink,
+        showWhenUnlinked: true,
+        offset: widget.offset,
+        child: _buildToolbar(context),
+      ),
+    );
+  }
+
+  @override
+  void hide() {
+    _listToolbarOverlay?.remove();
+    _listToolbarOverlay = null;
+  }
+
+  Widget _buildToolbar(BuildContext context) {
+    return Material(
+      borderRadius: BorderRadius.circular(8.0),
+      color: const Color(0xFF333333),
+      child: Padding(
+        padding: const EdgeInsets.only(left: 8.0, right: 8.0),
+        child: SizedBox(
+          height: 32.0,
+          child: Row(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: widget.items
+                .map(
+                  (item) => Center(
+                    child: ToolbarItemWidget(
+                      item: item,
+                      onPressed: () {
+                        item.handler(widget.editorState, context);
+                      },
+                    ),
+                  ),
+                )
+                .toList(growable: false),
+          ),
+        ),
+      ),
+    );
+  }
+}

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

@@ -1,3 +1,4 @@
+import 'package:appflowy_editor/src/render/image/image_node_builder.dart';
 import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart';
 import 'package:flutter/material.dart';
@@ -25,6 +26,7 @@ NodeWidgetBuilders defaultBuilders = {
   'text/bulleted-list': BulletedListTextNodeWidgetBuilder(),
   'text/number-list': NumberListTextNodeWidgetBuilder(),
   'text/quote': QuotedTextNodeWidgetBuilder(),
+  'image': ImageNodeBuilder(),
 };
 
 class AppFlowyEditor extends StatefulWidget {

+ 16 - 9
frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy_editor/src/infra/log.dart';
+import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
@@ -87,15 +88,18 @@ class _AppFlowyInputState extends State<AppFlowyInput>
 
   @override
   void attach(TextEditingValue textEditingValue) {
-    _textInputConnection ??= TextInput.attach(
-      this,
-      const TextInputConfiguration(
-        // TODO: customize
-        enableDeltaModel: true,
-        inputType: TextInputType.multiline,
-        textCapitalization: TextCapitalization.sentences,
-      ),
-    );
+    if (_textInputConnection == null ||
+        _textInputConnection!.attached == false) {
+      _textInputConnection = TextInput.attach(
+        this,
+        const TextInputConfiguration(
+          // TODO: customize
+          enableDeltaModel: true,
+          inputType: TextInputType.multiline,
+          textCapitalization: TextCapitalization.sentences,
+        ),
+      );
+    }
 
     _textInputConnection!
       ..setEditingState(textEditingValue)
@@ -146,6 +150,9 @@ class _AppFlowyInputState extends State<AppFlowyInput>
           textNode,
           delta.insertionOffset,
           delta.textInserted,
+          removedAttributes: {
+            StyleKey.href: null,
+          },
         )
         ..commit();
     } else {

+ 5 - 6
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/delete_text_handler.dart

@@ -13,9 +13,6 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
   selection = selection.isBackward ? selection : selection.reversed;
   // make sure all nodes is [TextNode].
   final textNodes = nodes.whereType<TextNode>().toList();
-  if (textNodes.length != nodes.length) {
-    return KeyEventResult.ignored;
-  }
 
   final transactionBuilder = TransactionBuilder(editorState);
   if (textNodes.length == 1) {
@@ -37,9 +34,9 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
       } else {
         // 2. non-style
         // find previous text node.
-        while (textNode.previous != null) {
-          if (textNode.previous is TextNode) {
-            final previous = textNode.previous as TextNode;
+        var previous = textNode.previous;
+        while (previous != null) {
+          if (previous is TextNode) {
             transactionBuilder
               ..mergeText(previous, textNode)
               ..deleteNode(textNode)
@@ -50,6 +47,8 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
                 ),
               );
             break;
+          } else {
+            previous = previous.previous;
           }
         }
       }

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

@@ -36,6 +36,12 @@ AppFlowyKeyEventHandler updateTextStyleByCommandXHandler =
       event.isShiftPressed) {
     formatHighlight(editorState);
     return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.keyK) {
+    if (editorState.service.toolbarService
+            ?.triggerHandler('appflowy.toolbar.link') ==
+        true) {
+      return KeyEventResult.handled;
+    }
   }
 
   return KeyEventResult.ignored;

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

@@ -36,10 +36,10 @@ class FlowyService {
 
   // toolbar service
   final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service');
-  FlowyToolbarService? get toolbarService {
+  AppFlowyToolbarService? get toolbarService {
     if (toolbarServiceKey.currentState != null &&
-        toolbarServiceKey.currentState is FlowyToolbarService) {
-      return toolbarServiceKey.currentState! as FlowyToolbarService;
+        toolbarServiceKey.currentState is AppFlowyToolbarService) {
+      return toolbarServiceKey.currentState! as AppFlowyToolbarService;
     }
     return null;
   }

+ 39 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart

@@ -1,15 +1,19 @@
+import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
 import 'package:flutter/material.dart';
 
 import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:appflowy_editor/src/render/selection/toolbar_widget.dart';
+import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
 import 'package:appflowy_editor/src/extensions/object_extensions.dart';
 
-abstract class FlowyToolbarService {
+abstract class AppFlowyToolbarService {
   /// Show the toolbar widget beside the offset.
   void showInOffset(Offset offset, LayerLink layerLink);
 
   /// Hide the toolbar widget.
   void hide();
+
+  /// Trigger the specified handler.
+  bool triggerHandler(String id);
 }
 
 class FlowyToolbar extends StatefulWidget {
@@ -27,7 +31,7 @@ class FlowyToolbar extends StatefulWidget {
 }
 
 class _FlowyToolbarState extends State<FlowyToolbar>
-    implements FlowyToolbarService {
+    implements AppFlowyToolbarService {
   OverlayEntry? _toolbarOverlay;
   final _toolbarWidgetKey = GlobalKey(debugLabel: '_toolbar_widget');
 
@@ -41,7 +45,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
         editorState: widget.editorState,
         layerLink: layerLink,
         offset: offset.translate(0, -37.0),
-        handlers: const {},
+        items: _filterItems(defaultToolbarItems),
       ),
     );
     Overlay.of(context)?.insert(_toolbarOverlay!);
@@ -54,6 +58,17 @@ class _FlowyToolbarState extends State<FlowyToolbar>
     _toolbarOverlay = null;
   }
 
+  @override
+  bool triggerHandler(String id) {
+    final items = defaultToolbarItems.where((item) => item.id == id);
+    if (items.length != 1) {
+      assert(items.length == 1, 'The toolbar item\'s id must be unique');
+      return false;
+    }
+    items.first.handler(widget.editorState, context);
+    return true;
+  }
+
   @override
   Widget build(BuildContext context) {
     return Container(
@@ -67,4 +82,24 @@ class _FlowyToolbarState extends State<FlowyToolbar>
 
     super.dispose();
   }
+
+  // Filter items that should not be displayed, sort according to type,
+  // and insert dividers between different types.
+  List<ToolbarItem> _filterItems(List<ToolbarItem> items) {
+    final filterItems = items
+        .where((item) => item.validator(widget.editorState))
+        .toList(growable: false)
+      ..sort((a, b) => a.type.compareTo(b.type));
+    if (items.isEmpty) {
+      return [];
+    }
+    final List<ToolbarItem> dividedItems = [filterItems.first];
+    for (var i = 1; i < filterItems.length; i++) {
+      if (filterItems[i].type != filterItems[i - 1].type) {
+        dividedItems.add(ToolbarItem.divider());
+      }
+      dividedItems.add(filterItems[i]);
+    }
+    return dividedItems;
+  }
 }

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

@@ -22,6 +22,7 @@ dev_dependencies:
   flutter_test:
     sdk: flutter
   flutter_lints: ^2.0.0
+  network_image_mock: ^2.1.1
 
 # For information on the generic Dart part of this file, see the
 # following page: https://dart.dev/tools/pub/pubspec
@@ -32,6 +33,7 @@ flutter:
   assets:
     - assets/images/toolbar/
     - assets/images/selection_menu/
+    - assets/images/image_toolbar/
     - assets/images/
   #
   # For details regarding assets in packages, see

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

@@ -57,6 +57,19 @@ class EditorWidgetTester {
     );
   }
 
+  void insertImageNode(String src, {String? align}) {
+    insert(
+      Node(
+        type: 'image',
+        children: LinkedList(),
+        attributes: {
+          'image_src': src,
+          'align': align ?? 'center',
+        },
+      ),
+    );
+  }
+
   Node? nodeAtPath(Path path) {
     return root.childAtPath(path);
   }

+ 3 - 0
frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart

@@ -115,6 +115,9 @@ extension on LogicalKeyboardKey {
     if (this == LogicalKeyboardKey.keyI) {
       return PhysicalKeyboardKey.keyI;
     }
+    if (this == LogicalKeyboardKey.keyK) {
+      return PhysicalKeyboardKey.keyK;
+    }
     if (this == LogicalKeyboardKey.keyS) {
       return PhysicalKeyboardKey.keyS;
     }

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart

@@ -84,7 +84,7 @@ void main() {
       expect(transaction.toJson(), {
         "operations": [
           {
-            "type": "insert-operation",
+            "op": "insert",
             "path": [0],
             "nodes": [item1.toJson()],
           }
@@ -107,7 +107,7 @@ void main() {
       expect(transaction.toJson(), {
         "operations": [
           {
-            "type": "delete-operation",
+            "op": "delete",
             "path": [0],
             "nodes": [item1.toJson()],
           }

+ 131 - 0
frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart

@@ -0,0 +1,131 @@
+import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
+import 'package:appflowy_editor/src/service/editor_service.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:network_image_mock/network_image_mock.dart';
+
+import '../../infra/test_editor.dart';
+
+void main() async {
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  group('image_node_builder.dart', () {
+    testWidgets('render image node', (tester) async {
+      mockNetworkImagesFor(() async {
+        const text = 'Welcome to Appflowy 😁';
+        const src =
+            'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
+        final editor = tester.editor
+          ..insertTextNode(text)
+          ..insertImageNode(src)
+          ..insertTextNode(text);
+        await editor.startTesting();
+
+        expect(editor.documentLength, 3);
+        expect(find.byType(Image), findsOneWidget);
+      });
+    });
+
+    testWidgets('render image align', (tester) async {
+      mockNetworkImagesFor(() async {
+        const text = 'Welcome to Appflowy 😁';
+        const src =
+            'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
+        final editor = tester.editor
+          ..insertTextNode(text)
+          ..insertImageNode(src, align: 'left')
+          ..insertImageNode(src, align: 'center')
+          ..insertImageNode(src, align: 'right')
+          ..insertTextNode(text);
+        await editor.startTesting();
+
+        expect(editor.documentLength, 5);
+        final imageFinder = find.byType(Image);
+        expect(imageFinder, findsNWidgets(3));
+
+        final editorFinder = find.byType(AppFlowyEditor);
+        final editorRect = tester.getRect(editorFinder);
+
+        final leftImageRect = tester.getRect(imageFinder.at(0));
+        expect(leftImageRect.left, editorRect.left);
+        final rightImageRect = tester.getRect(imageFinder.at(2));
+        expect(rightImageRect.right, editorRect.right);
+        final centerImageRect = tester.getRect(imageFinder.at(1));
+        expect(centerImageRect.left,
+            (leftImageRect.left + rightImageRect.left) / 2.0);
+        expect(leftImageRect.size, centerImageRect.size);
+        expect(rightImageRect.size, centerImageRect.size);
+
+        final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
+
+        final leftImage =
+            tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
+
+        leftImage.onAlign(Alignment.center);
+        await tester.pump(const Duration(milliseconds: 100));
+        expect(
+          tester.getRect(imageFinder.at(0)).left,
+          centerImageRect.left,
+        );
+
+        leftImage.onAlign(Alignment.centerRight);
+        await tester.pump(const Duration(milliseconds: 100));
+        expect(
+          tester.getRect(imageFinder.at(0)).left,
+          rightImageRect.left,
+        );
+      });
+    });
+
+    testWidgets('render image copy', (tester) async {
+      mockNetworkImagesFor(() async {
+        const text = 'Welcome to Appflowy 😁';
+        const src =
+            'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
+        final editor = tester.editor
+          ..insertTextNode(text)
+          ..insertImageNode(src)
+          ..insertTextNode(text);
+        await editor.startTesting();
+
+        expect(editor.documentLength, 3);
+        final imageFinder = find.byType(Image);
+        expect(imageFinder, findsOneWidget);
+
+        final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
+        final image =
+            tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
+        image.onCopy();
+      });
+    });
+
+    testWidgets('render image delete', (tester) async {
+      mockNetworkImagesFor(() async {
+        const text = 'Welcome to Appflowy 😁';
+        const src =
+            'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
+        final editor = tester.editor
+          ..insertTextNode(text)
+          ..insertImageNode(src)
+          ..insertImageNode(src)
+          ..insertTextNode(text);
+        await editor.startTesting();
+
+        expect(editor.documentLength, 4);
+        final imageFinder = find.byType(Image);
+        expect(imageFinder, findsNWidgets(2));
+
+        final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
+        final image =
+            tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
+        image.onDelete();
+
+        await tester.pump(const Duration(milliseconds: 100));
+        expect(editor.documentLength, 3);
+        expect(find.byType(Image), findsNWidgets(1));
+      });
+    });
+  });
+}

+ 81 - 0
frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart

@@ -0,0 +1,81 @@
+import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:network_image_mock/network_image_mock.dart';
+
+void main() async {
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  group('image_node_widget.dart', () {
+    testWidgets('build the image node widget', (tester) async {
+      mockNetworkImagesFor(() async {
+        var onCopyHit = false;
+        var onDeleteHit = false;
+        var onAlignHit = false;
+        const src =
+            'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
+
+        final widget = ImageNodeWidget(
+          src: src,
+          alignment: Alignment.center,
+          onCopy: () {
+            onCopyHit = true;
+          },
+          onDelete: () {
+            onDeleteHit = true;
+          },
+          onAlign: (alignment) {
+            onAlignHit = true;
+          },
+          onResize: (width) {},
+        );
+
+        await tester.pumpWidget(
+          MaterialApp(
+            home: Material(
+              child: widget,
+            ),
+          ),
+        );
+        expect(find.byType(ImageNodeWidget), findsOneWidget);
+
+        final gesture =
+            await tester.createGesture(kind: PointerDeviceKind.mouse);
+        await gesture.addPointer(location: Offset.zero);
+
+        expect(find.byType(ImageToolbar), findsNothing);
+
+        addTearDown(gesture.removePointer);
+        await tester.pump();
+        await gesture.moveTo(tester.getCenter(find.byType(ImageNodeWidget)));
+        await tester.pump();
+
+        expect(find.byType(ImageToolbar), findsOneWidget);
+
+        final iconFinder = find.byType(IconButton);
+        expect(iconFinder, findsNWidgets(5));
+
+        await tester.tap(iconFinder.at(0));
+        expect(onAlignHit, true);
+        onAlignHit = false;
+
+        await tester.tap(iconFinder.at(1));
+        expect(onAlignHit, true);
+        onAlignHit = false;
+
+        await tester.tap(iconFinder.at(2));
+        expect(onAlignHit, true);
+        onAlignHit = false;
+
+        await tester.tap(iconFinder.at(3));
+        expect(onCopyHit, true);
+
+        await tester.tap(iconFinder.at(4));
+        expect(onDeleteHit, true);
+      });
+    });
+  });
+}

+ 41 - 0
frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart

@@ -0,0 +1,41 @@
+import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  group('link_menu.dart', () {
+    testWidgets('test empty link menu actions', (tester) async {
+      const link = 'appflowy.io';
+      var submittedText = '';
+      final linkMenu = LinkMenu(
+        onCopyLink: () {},
+        onRemoveLink: () {},
+        onSubmitted: (text) {
+          submittedText = text;
+        },
+      );
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Material(
+            child: linkMenu,
+          ),
+        ),
+      );
+
+      expect(find.byType(TextButton), findsNothing);
+      expect(find.byType(TextField), findsOneWidget);
+
+      await tester.tap(find.byType(TextField));
+      await tester.enterText(find.byType(TextField), link);
+      await tester.pumpAndSettle();
+      await tester.testTextInput.receiveAction(TextInputAction.done);
+      await tester.pumpAndSettle();
+
+      expect(submittedText, link);
+    });
+  });
+}

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

@@ -20,7 +20,7 @@ void main() async {
         name: 'example',
         icon: icon,
         keywords: ['example A', 'example B'],
-        handler: (editorState, menuService) {
+        handler: (editorState, menuService, context) {
           flag = true;
         },
       );

+ 3 - 1
frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_widget_test.dart

@@ -25,7 +25,9 @@ void main() async {
           find.byType(SelectionMenuWidget, skipOffstage: false),
           findsNothing,
         );
-        await _testDefaultSelectionMenuItems(i, editor);
+        if (defaultSelectionMenuItems[i].name != 'Image') {
+          await _testDefaultSelectionMenuItems(i, editor);
+        }
       });
     }
   });

+ 46 - 0
frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_item_widget_test.dart

@@ -0,0 +1,46 @@
+import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
+import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  group('toolbar_item_widget.dart', () {
+    testWidgets('test single toolbar item widget', (tester) async {
+      final key = GlobalKey();
+      var hit = false;
+      final item = ToolbarItem(
+        id: 'appflowy.toolbar.test',
+        type: 1,
+        icon: const Icon(Icons.abc),
+        validator: (editorState) => true,
+        handler: (editorState, context) {},
+      );
+      final widget = ToolbarItemWidget(
+        key: key,
+        item: item,
+        onPressed: (() {
+          hit = true;
+        }),
+      );
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Material(
+            child: widget,
+          ),
+        ),
+      );
+
+      expect(find.byKey(key), findsOneWidget);
+
+      await tester.tap(find.byKey(key));
+      await tester.pumpAndSettle();
+
+      expect(hit, true);
+    });
+  });
+}

+ 11 - 0
frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_widget_test.dart

@@ -0,0 +1,11 @@
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  group('toolbar_widget.dart', () {
+    testWidgets('test toolbar widget', (tester) async {});
+  });
+}

+ 102 - 4
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/update_text_style_by_command_x_handler_test.dart

@@ -1,6 +1,10 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
 import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
+import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
+import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
+import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
 import '../../infra/test_editor.dart';
@@ -54,6 +58,10 @@ void main() async {
         LogicalKeyboardKey.keyH,
       );
     });
+
+    testWidgets('Presses Command + K to trigger link menu', (tester) async {
+      await _testLinkMenuInSingleTextSelection(tester);
+    });
   });
 }
 
@@ -82,7 +90,14 @@ Future<void> _testUpdateTextStyleByCommandX(
   );
   var textNode = editor.nodeAtPath([1]) as TextNode;
   expect(
-      textNode.allSatisfyInSelection(matchStyle, matchValue, selection), true);
+      textNode.allSatisfyInSelection(
+        matchStyle,
+        selection,
+        (value) {
+          return value == matchValue;
+        },
+      ),
+      true);
 
   selection =
       Selection.single(path: [1], startOffset: 0, endOffset: text.length);
@@ -94,7 +109,14 @@ Future<void> _testUpdateTextStyleByCommandX(
   );
   textNode = editor.nodeAtPath([1]) as TextNode;
   expect(
-      textNode.allSatisfyInSelection(matchStyle, matchValue, selection), true);
+      textNode.allSatisfyInSelection(
+        matchStyle,
+        selection,
+        (value) {
+          return value == matchValue;
+        },
+      ),
+      true);
 
   await editor.updateSelection(selection);
   await editor.pressLogicKey(
@@ -123,9 +145,14 @@ Future<void> _testUpdateTextStyleByCommandX(
     expect(
       node.allSatisfyInSelection(
         matchStyle,
-        matchValue,
         Selection.single(
-            path: node.path, startOffset: 0, endOffset: text.length),
+          path: node.path,
+          startOffset: 0,
+          endOffset: text.length,
+        ),
+        (value) {
+          return value == matchValue;
+        },
       ),
       true,
     );
@@ -152,3 +179,74 @@ Future<void> _testUpdateTextStyleByCommandX(
     );
   }
 }
+
+Future<void> _testLinkMenuInSingleTextSelection(WidgetTester tester) async {
+  const link = 'appflowy.io';
+  const text = 'Welcome to Appflowy 😁';
+  final editor = tester.editor
+    ..insertTextNode(text)
+    ..insertTextNode(text)
+    ..insertTextNode(text);
+  await editor.startTesting();
+
+  final selection =
+      Selection.single(path: [1], startOffset: 0, endOffset: text.length);
+  await editor.updateSelection(selection);
+
+  // show toolbar
+  expect(find.byType(ToolbarWidget), findsOneWidget);
+
+  final item = defaultToolbarItems
+      .where((item) => item.id == 'appflowy.toolbar.link')
+      .first;
+  expect(find.byWidget(item.icon), findsOneWidget);
+
+  // trigger the link menu
+  await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
+
+  expect(find.byType(LinkMenu), findsOneWidget);
+
+  await tester.enterText(find.byType(TextField), link);
+  await tester.testTextInput.receiveAction(TextInputAction.done);
+  await tester.pumpAndSettle();
+
+  expect(find.byType(LinkMenu), findsNothing);
+
+  final node = editor.nodeAtPath([1]) as TextNode;
+  expect(
+      node.allSatisfyInSelection(
+        StyleKey.href,
+        selection,
+        (value) => value == link,
+      ),
+      true);
+
+  await editor.updateSelection(selection);
+  await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
+  expect(find.byType(LinkMenu), findsOneWidget);
+  expect(
+      find.text(link, findRichText: true, skipOffstage: false), findsOneWidget);
+
+  // Copy link
+  final copyLink = find.text('Copy link');
+  expect(copyLink, findsOneWidget);
+  await tester.tap(copyLink);
+  await tester.pumpAndSettle();
+  expect(find.byType(LinkMenu), findsNothing);
+
+  // Remove link
+  await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
+  final removeLink = find.text('Remove link');
+  expect(removeLink, findsOneWidget);
+  await tester.tap(removeLink);
+  await tester.pumpAndSettle();
+  expect(find.byType(LinkMenu), findsNothing);
+
+  expect(
+      node.allSatisfyInSelection(
+        StyleKey.href,
+        selection,
+        (value) => value == link,
+      ),
+      false);
+}

+ 36 - 0
frontend/app_flowy/packages/appflowy_editor/test/service/toolbar_service_test.dart

@@ -0,0 +1,36 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
+import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
+import 'package:flutter_test/flutter_test.dart';
+import '../infra/test_editor.dart';
+
+void main() async {
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  group('toolbar_service.dart', () {
+    testWidgets('Test toolbar service in multi text selection', (tester) async {
+      const text = 'Welcome to Appflowy 😁';
+      final editor = tester.editor
+        ..insertTextNode(text)
+        ..insertTextNode(text)
+        ..insertTextNode(text);
+      await editor.startTesting();
+
+      final selection = Selection(
+        start: Position(path: [0], offset: 0),
+        end: Position(path: [1], offset: text.length),
+      );
+      await editor.updateSelection(selection);
+
+      expect(find.byType(ToolbarWidget), findsOneWidget);
+
+      // no link item
+      final item = defaultToolbarItems
+          .where((item) => item.id == 'appflowy.toolbar.link')
+          .first;
+      expect(find.byWidget(item.icon), findsNothing);
+    });
+  });
+}

+ 18 - 18
frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text.dart

@@ -4,15 +4,16 @@ import 'package:provider/provider.dart';
 
 class FlowyText extends StatelessWidget {
   final String title;
-  final TextOverflow overflow;
+  final TextOverflow? overflow;
   final double fontSize;
   final FontWeight fontWeight;
   final TextAlign? textAlign;
   final Color? color;
+
   const FlowyText(
     this.title, {
     Key? key,
-    this.overflow = TextOverflow.ellipsis,
+    this.overflow = TextOverflow.clip,
     this.fontSize = 16,
     this.fontWeight = FontWeight.w400,
     this.textAlign,
@@ -20,34 +21,33 @@ class FlowyText extends StatelessWidget {
   }) : super(key: key);
 
   const FlowyText.semibold(this.title,
-      {Key? key, this.fontSize = 16, TextOverflow? overflow, this.color, this.textAlign})
+      {Key? key, this.fontSize = 16, this.overflow, this.color, this.textAlign})
       : fontWeight = FontWeight.w600,
-        overflow = overflow ?? TextOverflow.ellipsis,
         super(key: key);
 
-  const FlowyText.medium(this.title, {Key? key, this.fontSize = 16, TextOverflow? overflow, this.color, this.textAlign})
+  const FlowyText.medium(this.title,
+      {Key? key, this.fontSize = 16, this.overflow, this.color, this.textAlign})
       : fontWeight = FontWeight.w500,
-        overflow = overflow ?? TextOverflow.ellipsis,
         super(key: key);
 
   const FlowyText.regular(this.title,
-      {Key? key, this.fontSize = 16, TextOverflow? overflow, this.color, this.textAlign})
+      {Key? key, this.fontSize = 16, this.overflow, this.color, this.textAlign})
       : fontWeight = FontWeight.w400,
-        overflow = overflow ?? TextOverflow.ellipsis,
         super(key: key);
 
   @override
   Widget build(BuildContext context) {
     final theme = context.watch<AppTheme>();
-    return Text(title,
-        overflow: overflow,
-        softWrap: false,
-        textAlign: textAlign,
-        style: TextStyle(
-          color: color ?? theme.textColor,
-          fontWeight: fontWeight,
-          fontSize: fontSize,
-          fontFamily: 'Mulish',
-        ));
+    return Text(
+      title,
+      textAlign: textAlign,
+      overflow: overflow ?? TextOverflow.clip,
+      style: TextStyle(
+        color: color ?? theme.textColor,
+        fontWeight: fontWeight,
+        fontSize: fontSize,
+        fontFamily: 'Mulish',
+      ),
+    );
   }
 }

+ 1 - 1
frontend/app_flowy/pubspec.lock

@@ -28,7 +28,7 @@ packages:
       path: "packages/appflowy_board"
       relative: true
     source: path
-    version: "0.0.4"
+    version: "0.0.5"
   appflowy_editor:
     dependency: "direct main"
     description:

+ 7 - 0
frontend/rust-lib/Cargo.lock

@@ -1618,6 +1618,12 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "indextree"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b4b46b3311ebd8e5cd44f6b03b36e0f48a70552cf6b036afcebc5626794066"
+
 [[package]]
 name = "instant"
 version = "0.1.12"
@@ -1766,6 +1772,7 @@ dependencies = [
  "bytes",
  "dashmap",
  "derive_more",
+ "indextree",
  "lazy_static",
  "log",
  "md5",

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

@@ -30,7 +30,7 @@ impl BlockPB {
 }
 
 /// [RowPB] Describes a row. Has the id of the parent Block. Has the metadata of the row.
-#[derive(Debug, Default, Clone, ProtoBuf)]
+#[derive(Debug, Default, Clone, ProtoBuf, Eq, PartialEq)]
 pub struct RowPB {
     #[pb(index = 1)]
     pub block_id: String,

+ 4 - 4
frontend/rust-lib/flowy-grid/src/entities/group_entities/configuration.rs

@@ -1,5 +1,5 @@
 use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
-use flowy_grid_data_model::revision::{GroupRecordRevision, SelectOptionGroupConfigurationRevision};
+use flowy_grid_data_model::revision::{GroupRevision, SelectOptionGroupConfigurationRevision};
 
 #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)]
 pub struct UrlGroupConfigurationPB {
@@ -36,10 +36,10 @@ pub struct GroupRecordPB {
     visible: bool,
 }
 
-impl std::convert::From<GroupRecordRevision> for GroupRecordPB {
-    fn from(rev: GroupRecordRevision) -> Self {
+impl std::convert::From<GroupRevision> for GroupRecordPB {
+    fn from(rev: GroupRevision) -> Self {
         Self {
-            group_id: rev.group_id,
+            group_id: rev.id,
             visible: rev.visible,
         }
     }

+ 12 - 0
frontend/rust-lib/flowy-grid/src/entities/group_entities/group.rs

@@ -1,4 +1,5 @@
 use crate::entities::{CreateRowParams, FieldType, GridLayout, RowPB};
+use crate::services::group::Group;
 use flowy_derive::ProtoBuf;
 use flowy_error::ErrorCode;
 use flowy_grid_data_model::parser::NotEmptyStr;
@@ -82,6 +83,17 @@ pub struct GroupPB {
     pub rows: Vec<RowPB>,
 }
 
+impl std::convert::From<Group> for GroupPB {
+    fn from(group: Group) -> Self {
+        Self {
+            field_id: group.field_id,
+            group_id: group.id,
+            desc: group.name,
+            rows: group.rows,
+        }
+    }
+}
+
 #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)]
 pub struct RepeatedGridGroupConfigurationPB {
     #[pb(index = 1)]

+ 37 - 8
frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs

@@ -5,21 +5,24 @@ use flowy_grid_data_model::parser::NotEmptyStr;
 use std::fmt::Formatter;
 
 #[derive(Debug, Default, ProtoBuf)]
-pub struct GroupRowsChangesetPB {
+pub struct GroupChangesetPB {
     #[pb(index = 1)]
     pub group_id: String,
 
-    #[pb(index = 2)]
-    pub inserted_rows: Vec<InsertedRowPB>,
+    #[pb(index = 2, one_of)]
+    pub group_name: Option<String>,
 
     #[pb(index = 3)]
-    pub deleted_rows: Vec<String>,
+    pub inserted_rows: Vec<InsertedRowPB>,
 
     #[pb(index = 4)]
+    pub deleted_rows: Vec<String>,
+
+    #[pb(index = 5)]
     pub updated_rows: Vec<RowPB>,
 }
 
-impl std::fmt::Display for GroupRowsChangesetPB {
+impl std::fmt::Display for GroupChangesetPB {
     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
         for inserted_row in &self.inserted_rows {
             let _ = f.write_fmt(format_args!(
@@ -36,10 +39,29 @@ impl std::fmt::Display for GroupRowsChangesetPB {
     }
 }
 
-impl GroupRowsChangesetPB {
+impl GroupChangesetPB {
     pub fn is_empty(&self) -> bool {
-        self.inserted_rows.is_empty() && self.deleted_rows.is_empty() && self.updated_rows.is_empty()
+        self.group_name.is_none()
+            && self.inserted_rows.is_empty()
+            && self.deleted_rows.is_empty()
+            && self.updated_rows.is_empty()
+    }
+
+    pub fn new(group_id: String) -> Self {
+        Self {
+            group_id,
+            ..Default::default()
+        }
     }
+
+    pub fn name(group_id: String, name: &str) -> Self {
+        Self {
+            group_id,
+            group_name: Some(name.to_owned()),
+            ..Default::default()
+        }
+    }
+
     pub fn insert(group_id: String, inserted_rows: Vec<InsertedRowPB>) -> Self {
         Self {
             group_id,
@@ -113,9 +135,16 @@ pub struct GroupViewChangesetPB {
 
     #[pb(index = 3)]
     pub deleted_groups: Vec<String>,
+
+    #[pb(index = 4)]
+    pub update_groups: Vec<GroupPB>,
 }
 
-impl GroupViewChangesetPB {}
+impl GroupViewChangesetPB {
+    pub fn is_empty(&self) -> bool {
+        self.inserted_groups.is_empty() && self.deleted_groups.is_empty() && self.update_groups.is_empty()
+    }
+}
 
 #[derive(Debug, Default, ProtoBuf)]
 pub struct InsertedGroupPB {

+ 1 - 1
frontend/rust-lib/flowy-grid/src/event_handler.rs

@@ -269,7 +269,7 @@ pub(crate) async fn create_table_row_handler(
     data_result(row)
 }
 
-// #[tracing::instrument(level = "debug", skip_all, err)]
+#[tracing::instrument(level = "trace", skip_all, err)]
 pub(crate) async fn get_cell_handler(
     data: Data<GridCellIdPB>,
     manager: AppData<Arc<GridManager>>,

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

@@ -157,6 +157,9 @@ pub fn select_option_color_from_index(index: usize) -> SelectOptionColorPB {
 pub struct SelectOptionIds(Vec<String>);
 
 impl SelectOptionIds {
+    pub fn new() -> Self {
+        Self(vec![])
+    }
     pub fn into_inner(self) -> Vec<String> {
         self.0
     }
@@ -181,6 +184,12 @@ impl std::convert::From<String> for SelectOptionIds {
     }
 }
 
+impl ToString for SelectOptionIds {
+    fn to_string(&self) -> String {
+        self.0.join(SELECTION_IDS_SEPARATOR)
+    }
+}
+
 impl std::convert::From<Option<String>> for SelectOptionIds {
     fn from(s: Option<String>) -> Self {
         match s {

+ 74 - 50
frontend/rust-lib/flowy-grid/src/services/grid_editor.rs

@@ -188,8 +188,13 @@ impl GridRevisionEditor {
     pub async fn replace_field(&self, field_rev: Arc<FieldRevision>) -> FlowyResult<()> {
         let field_id = field_rev.id.clone();
         let _ = self
-            .modify(|grid_pad| Ok(grid_pad.replace_field_rev(field_rev)?))
+            .modify(|grid_pad| Ok(grid_pad.replace_field_rev(field_rev.clone())?))
             .await?;
+
+        match self.view_manager.did_update_field(&field_rev.id).await {
+            Ok(_) => {}
+            Err(e) => tracing::error!("View manager update field failed: {:?}", e),
+        }
         let _ = self.notify_did_update_grid_field(&field_id).await?;
         Ok(())
     }
@@ -263,59 +268,65 @@ impl GridRevisionEditor {
     }
 
     async fn update_field_rev(&self, params: FieldChangesetParams, field_type: FieldType) -> FlowyResult<()> {
-        self.modify(|grid| {
-            let deserializer = TypeOptionJsonDeserializer(field_type);
-
-            let changeset = grid.modify_field(&params.field_id, |field| {
-                let mut is_changed = None;
-                if let Some(name) = params.name {
-                    field.name = name;
-                    is_changed = Some(())
-                }
+        let _ = self
+            .modify(|grid| {
+                let deserializer = TypeOptionJsonDeserializer(field_type);
+                let changeset = grid.modify_field(&params.field_id, |field| {
+                    let mut is_changed = None;
+                    if let Some(name) = params.name {
+                        field.name = name;
+                        is_changed = Some(())
+                    }
 
-                if let Some(desc) = params.desc {
-                    field.desc = desc;
-                    is_changed = Some(())
-                }
+                    if let Some(desc) = params.desc {
+                        field.desc = desc;
+                        is_changed = Some(())
+                    }
 
-                if let Some(field_type) = params.field_type {
-                    field.ty = field_type;
-                    is_changed = Some(())
-                }
+                    if let Some(field_type) = params.field_type {
+                        field.ty = field_type;
+                        is_changed = Some(())
+                    }
 
-                if let Some(frozen) = params.frozen {
-                    field.frozen = frozen;
-                    is_changed = Some(())
-                }
+                    if let Some(frozen) = params.frozen {
+                        field.frozen = frozen;
+                        is_changed = Some(())
+                    }
 
-                if let Some(visibility) = params.visibility {
-                    field.visibility = visibility;
-                    is_changed = Some(())
-                }
+                    if let Some(visibility) = params.visibility {
+                        field.visibility = visibility;
+                        is_changed = Some(())
+                    }
 
-                if let Some(width) = params.width {
-                    field.width = width;
-                    is_changed = Some(())
-                }
+                    if let Some(width) = params.width {
+                        field.width = width;
+                        is_changed = Some(())
+                    }
 
-                if let Some(type_option_data) = params.type_option_data {
-                    match deserializer.deserialize(type_option_data) {
-                        Ok(json_str) => {
-                            let field_type = field.ty;
-                            field.insert_type_option_str(&field_type, json_str);
-                            is_changed = Some(())
-                        }
-                        Err(err) => {
-                            tracing::error!("Deserialize data to type option json failed: {}", err);
+                    if let Some(type_option_data) = params.type_option_data {
+                        match deserializer.deserialize(type_option_data) {
+                            Ok(json_str) => {
+                                let field_type = field.ty;
+                                field.insert_type_option_str(&field_type, json_str);
+                                is_changed = Some(())
+                            }
+                            Err(err) => {
+                                tracing::error!("Deserialize data to type option json failed: {}", err);
+                            }
                         }
                     }
-                }
 
-                Ok(is_changed)
-            })?;
-            Ok(changeset)
-        })
-        .await
+                    Ok(is_changed)
+                })?;
+                Ok(changeset)
+            })
+            .await?;
+
+        match self.view_manager.did_update_field(&params.field_id).await {
+            Ok(_) => {}
+            Err(e) => tracing::error!("View manager update field failed: {:?}", e),
+        }
+        Ok(())
     }
 
     pub async fn create_block(&self, block_meta_rev: GridBlockMetaRevision) -> FlowyResult<()> {
@@ -571,7 +582,7 @@ impl GridRevisionEditor {
 
     pub async fn move_group_row(&self, params: MoveGroupRowParams) -> FlowyResult<()> {
         let MoveGroupRowParams {
-            view_id: _,
+            view_id,
             from_row_id,
             to_group_id,
             to_row_id,
@@ -585,10 +596,23 @@ impl GridRevisionEditor {
                     .move_group_row(row_rev, to_group_id, to_row_id.clone())
                     .await
                 {
-                    match self.block_manager.update_row(row_changeset).await {
-                        Ok(_) => {}
-                        Err(e) => {
-                            tracing::error!("Apply row changeset error:{:?}", e);
+                    tracing::trace!("Move group row cause row data changed: {:?}", row_changeset);
+
+                    let cell_changesets = row_changeset
+                        .cell_by_field_id
+                        .into_iter()
+                        .map(|(field_id, cell_rev)| CellChangesetPB {
+                            grid_id: view_id.clone(),
+                            row_id: row_changeset.row_id.clone(),
+                            field_id,
+                            content: cell_rev.data,
+                        })
+                        .collect::<Vec<CellChangesetPB>>();
+
+                    for cell_changeset in cell_changesets {
+                        match self.block_manager.update_cell(cell_changeset).await {
+                            Ok(_) => {}
+                            Err(e) => tracing::error!("Apply cell changeset error:{:?}", e),
                         }
                     }
                 }

+ 24 - 12
frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs

@@ -1,8 +1,8 @@
 use crate::dart_notification::{send_dart_notification, GridNotification};
 use crate::entities::{
     CreateFilterParams, CreateRowParams, DeleteFilterParams, GridFilterConfiguration, GridLayout, GridLayoutPB,
-    GridSettingPB, GroupPB, GroupRowsChangesetPB, GroupViewChangesetPB, InsertedGroupPB, InsertedRowPB,
-    MoveGroupParams, RepeatedGridConfigurationFilterPB, RepeatedGridGroupConfigurationPB, RowPB,
+    GridSettingPB, GroupChangesetPB, GroupPB, GroupViewChangesetPB, InsertedGroupPB, InsertedRowPB, MoveGroupParams,
+    RepeatedGridConfigurationFilterPB, RepeatedGridGroupConfigurationPB, RowPB,
 };
 use crate::services::grid_editor_task::GridServiceTaskScheduler;
 use crate::services::grid_view_manager::{GridViewFieldDelegate, GridViewRowDelegate};
@@ -59,7 +59,7 @@ impl GridViewRevisionEditor {
             rev_manager: rev_manager.clone(),
             view_pad: pad.clone(),
         };
-        let group_service = GroupService::new(configuration_reader, configuration_writer).await;
+        let group_service = GroupService::new(view_id.clone(), configuration_reader, configuration_writer).await;
         let user_id = user_id.to_owned();
         let did_load_group = AtomicBool::new(false);
         Ok(Self {
@@ -99,8 +99,8 @@ impl GridViewRevisionEditor {
                     row: row_pb.clone(),
                     index: None,
                 };
-                let changeset = GroupRowsChangesetPB::insert(group_id.clone(), vec![inserted_row]);
-                self.notify_did_update_group_rows(changeset).await;
+                let changeset = GroupChangesetPB::insert(group_id.clone(), vec![inserted_row]);
+                self.notify_did_update_group(changeset).await;
             }
         }
     }
@@ -115,7 +115,7 @@ impl GridViewRevisionEditor {
             .await
         {
             for changeset in changesets {
-                self.notify_did_update_group_rows(changeset).await;
+                self.notify_did_update_group(changeset).await;
             }
         }
     }
@@ -129,7 +129,7 @@ impl GridViewRevisionEditor {
             .await
         {
             for changeset in changesets {
-                self.notify_did_update_group_rows(changeset).await;
+                self.notify_did_update_group(changeset).await;
             }
         }
     }
@@ -151,11 +151,11 @@ impl GridViewRevisionEditor {
             .await
         {
             for changeset in changesets {
-                self.notify_did_update_group_rows(changeset).await;
+                self.notify_did_update_group(changeset).await;
             }
         }
     }
-
+    /// Only call once after grid view editor initialized
     #[tracing::instrument(level = "trace", skip(self))]
     pub(crate) async fn load_groups(&self) -> FlowyResult<Vec<GroupPB>> {
         let groups = if !self.did_load_group.load(Ordering::SeqCst) {
@@ -198,9 +198,10 @@ impl GridViewRevisionEditor {
                 };
 
                 let changeset = GroupViewChangesetPB {
-                    view_id: "".to_string(),
+                    view_id: self.view_id.clone(),
                     inserted_groups: vec![inserted_group],
                     deleted_groups: vec![params.from_group_id.clone()],
+                    update_groups: vec![],
                 };
 
                 self.notify_did_update_view(changeset).await;
@@ -252,8 +253,20 @@ impl GridViewRevisionEditor {
         })
         .await
     }
+    #[tracing::instrument(level = "trace", skip_all, err)]
+    pub(crate) async fn did_update_field(&self, field_id: &str) -> FlowyResult<()> {
+        if let Some(field_rev) = self.field_delegate.get_field_rev(field_id).await {
+            match self.group_service.write().await.did_update_field(&field_rev).await? {
+                None => {}
+                Some(changeset) => {
+                    self.notify_did_update_view(changeset).await;
+                }
+            }
+        }
+        Ok(())
+    }
 
-    async fn notify_did_update_group_rows(&self, changeset: GroupRowsChangesetPB) {
+    async fn notify_did_update_group(&self, changeset: GroupChangesetPB) {
         send_dart_notification(&changeset.group_id, GridNotification::DidUpdateGroup)
             .payload(changeset)
             .send();
@@ -265,7 +278,6 @@ impl GridViewRevisionEditor {
             .send();
     }
 
-    #[allow(dead_code)]
     async fn modify<F>(&self, f: F) -> FlowyResult<()>
     where
         F: for<'a> FnOnce(&'a mut GridViewRevisionPad) -> FlowyResult<Option<GridViewRevisionChangeset>>,

+ 9 - 3
frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs

@@ -142,13 +142,19 @@ impl GridViewManager {
                 .await;
         }
 
-        if row_changeset.has_changed() {
-            Some(row_changeset)
-        } else {
+        if row_changeset.is_empty() {
             None
+        } else {
+            Some(row_changeset)
         }
     }
 
+    pub(crate) async fn did_update_field(&self, field_id: &str) -> FlowyResult<()> {
+        let view_editor = self.get_default_view_editor().await?;
+        let _ = view_editor.did_update_field(field_id).await?;
+        Ok(())
+    }
+
     pub(crate) async fn get_view_editor(&self, view_id: &str) -> FlowyResult<Arc<GridViewRevisionEditor>> {
         debug_assert!(!view_id.is_empty());
         match self.view_editors.get(view_id) {

+ 4 - 8
frontend/rust-lib/flowy-grid/src/services/group/action.rs

@@ -1,4 +1,4 @@
-use crate::entities::GroupRowsChangesetPB;
+use crate::entities::GroupChangesetPB;
 
 use crate::services::group::controller::MoveGroupRowContext;
 use flowy_grid_data_model::revision::RowRevision;
@@ -6,12 +6,8 @@ use flowy_grid_data_model::revision::RowRevision;
 pub trait GroupAction: Send + Sync {
     type CellDataType;
     fn can_group(&self, content: &str, cell_data: &Self::CellDataType) -> bool;
-    fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupRowsChangesetPB>;
-    fn remove_row_if_match(
-        &mut self,
-        row_rev: &RowRevision,
-        cell_data: &Self::CellDataType,
-    ) -> Vec<GroupRowsChangesetPB>;
+    fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB>;
+    fn remove_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec<GroupChangesetPB>;
 
-    fn move_row(&mut self, cell_data: &Self::CellDataType, context: MoveGroupRowContext) -> Vec<GroupRowsChangesetPB>;
+    fn move_row(&mut self, cell_data: &Self::CellDataType, context: MoveGroupRowContext) -> Vec<GroupChangesetPB>;
 }

+ 130 - 32
frontend/rust-lib/flowy-grid/src/services/group/configuration.rs

@@ -1,12 +1,13 @@
+use crate::entities::{GroupPB, GroupViewChangesetPB, InsertedGroupPB};
 use crate::services::group::{default_group_configuration, Group};
 use flowy_error::{FlowyError, FlowyResult};
 use flowy_grid_data_model::revision::{
-    FieldRevision, FieldTypeRevision, GroupConfigurationContentSerde, GroupConfigurationRevision, GroupRecordRevision,
+    FieldRevision, FieldTypeRevision, GroupConfigurationContentSerde, GroupConfigurationRevision, GroupRevision,
 };
-use std::marker::PhantomData;
-
 use indexmap::IndexMap;
 use lib_infra::future::AFFuture;
+use std::fmt::Formatter;
+use std::marker::PhantomData;
 use std::sync::Arc;
 
 pub trait GroupConfigurationReader: Send + Sync + 'static {
@@ -25,7 +26,17 @@ pub trait GroupConfigurationWriter: Send + Sync + 'static {
     ) -> AFFuture<FlowyResult<()>>;
 }
 
+impl<T> std::fmt::Display for GenericGroupConfiguration<T> {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        self.groups_map.iter().for_each(|(_, group)| {
+            let _ = f.write_fmt(format_args!("Group:{} has {} rows \n", group.id, group.rows.len()));
+        });
+        Ok(())
+    }
+}
+
 pub struct GenericGroupConfiguration<C> {
+    view_id: String,
     pub configuration: Arc<GroupConfigurationRevision>,
     configuration_content: PhantomData<C>,
     field_rev: Arc<FieldRevision>,
@@ -39,6 +50,7 @@ where
 {
     #[tracing::instrument(level = "trace", skip_all, err)]
     pub async fn new(
+        view_id: String,
         field_rev: Arc<FieldRevision>,
         reader: Arc<dyn GroupConfigurationReader>,
         writer: Arc<dyn GroupConfigurationWriter>,
@@ -56,6 +68,7 @@ where
 
         // let configuration = C::from_configuration_content(&configuration_rev.content)?;
         Ok(Self {
+            view_id,
             field_rev,
             groups_map: IndexMap::new(),
             writer,
@@ -72,17 +85,53 @@ where
         self.groups_map.values().cloned().collect()
     }
 
-    pub(crate) async fn merge_groups(&mut self, groups: Vec<Group>) -> FlowyResult<()> {
-        let (group_revs, groups) = merge_groups(&self.configuration.groups, groups);
+    pub(crate) fn merge_groups(&mut self, groups: Vec<Group>) -> FlowyResult<Option<GroupViewChangesetPB>> {
+        let MergeGroupResult {
+            groups,
+            inserted_groups,
+            updated_groups,
+        } = merge_groups(&self.configuration.groups, groups);
+
+        let group_revs = groups
+            .iter()
+            .map(|group| GroupRevision::new(group.id.clone(), group.name.clone()))
+            .collect::<Vec<GroupRevision>>();
+
         self.mut_configuration(move |configuration| {
-            configuration.groups = group_revs;
-            true
+            let mut is_changed = false;
+            for new_group_rev in group_revs {
+                match configuration
+                    .groups
+                    .iter()
+                    .position(|group_rev| group_rev.id == new_group_rev.id)
+                {
+                    None => {
+                        configuration.groups.push(new_group_rev);
+                        is_changed = true;
+                    }
+                    Some(pos) => {
+                        let removed_group = configuration.groups.remove(pos);
+                        if removed_group != new_group_rev {
+                            is_changed = true;
+                        }
+                        configuration.groups.insert(pos, new_group_rev);
+                    }
+                }
+            }
+            is_changed
         })?;
 
         groups.into_iter().for_each(|group| {
             self.groups_map.insert(group.id.clone(), group);
         });
-        Ok(())
+
+        let changeset = make_group_view_changeset(self.view_id.clone(), inserted_groups, updated_groups);
+        tracing::trace!("Group changeset: {:?}", changeset);
+        if changeset.is_empty() {
+            Ok(None)
+        } else {
+            Ok(Some(changeset))
+        }
     }
 
     #[allow(dead_code)]
@@ -101,7 +150,7 @@ where
         Ok(())
     }
 
-    pub(crate) fn with_mut_groups(&mut self, mut each: impl FnMut(&mut Group)) {
+    pub(crate) fn iter_mut_groups(&mut self, mut each: impl FnMut(&mut Group)) {
         self.groups_map.iter_mut().for_each(|(_, group)| {
             each(group);
         })
@@ -119,8 +168,8 @@ where
                 self.groups_map.swap_indices(from_index, to_index);
 
                 self.mut_configuration(|configuration| {
-                    let from_index = configuration.groups.iter().position(|group| group.group_id == from_id);
-                    let to_index = configuration.groups.iter().position(|group| group.group_id == to_id);
+                    let from_index = configuration.groups.iter().position(|group| group.id == from_id);
+                    let to_index = configuration.groups.iter().position(|group| group.id == to_id);
                     if let (Some(from), Some(to)) = (from_index, to_index) {
                         configuration.groups.swap(from, to);
                     }
@@ -163,10 +212,10 @@ where
     fn mut_configuration_group(
         &mut self,
         group_id: &str,
-        mut_groups_fn: impl Fn(&mut GroupRecordRevision),
+        mut_groups_fn: impl Fn(&mut GroupRevision),
     ) -> FlowyResult<()> {
         self.mut_configuration(|configuration| {
-            match configuration.groups.iter_mut().find(|group| group.group_id == group_id) {
+            match configuration.groups.iter_mut().find(|group| group.id == group_id) {
                 None => false,
                 Some(group_rev) => {
                     mut_groups_fn(group_rev);
@@ -189,33 +238,82 @@ where
     }
 }
 
-fn merge_groups(old_group_revs: &[GroupRecordRevision], groups: Vec<Group>) -> (Vec<GroupRecordRevision>, Vec<Group>) {
-    if old_group_revs.is_empty() {
-        let new_groups = groups
-            .iter()
-            .map(|group| GroupRecordRevision::new(group.id.clone()))
-            .collect();
-        return (new_groups, groups);
+fn merge_groups(old_groups: &[GroupRevision], groups: Vec<Group>) -> MergeGroupResult {
+    let mut merge_result = MergeGroupResult::new();
+    if old_groups.is_empty() {
+        merge_result.groups = groups;
+        return merge_result;
     }
 
+    // group_map is a helper map is used to filter out the new groups.
     let mut group_map: IndexMap<String, Group> = IndexMap::new();
     groups.into_iter().for_each(|group| {
         group_map.insert(group.id.clone(), group);
     });
 
-    // Inert
-    let mut sorted_groups: Vec<Group> = vec![];
-    for group_rev in old_group_revs {
-        if let Some(group) = group_map.remove(&group_rev.group_id) {
-            sorted_groups.push(group);
+    // The group is ordered in old groups. Add them before adding the new groups
+    for group_rev in old_groups {
+        if let Some(group) = group_map.remove(&group_rev.id) {
+            if group.name == group_rev.name {
+                merge_result.add_group(group);
+            } else {
+                merge_result.add_updated_group(group);
+            }
+        }
+    }
+
+    // Find out the new groups
+    let new_groups = group_map.into_values().collect::<Vec<Group>>();
+    for (index, group) in new_groups.into_iter().enumerate() {
+        merge_result.add_insert_group(index, group);
+    }
+    merge_result
+}
+
+struct MergeGroupResult {
+    groups: Vec<Group>,
+    inserted_groups: Vec<InsertedGroupPB>,
+    updated_groups: Vec<Group>,
+}
+
+impl MergeGroupResult {
+    fn new() -> Self {
+        Self {
+            groups: vec![],
+            inserted_groups: vec![],
+            updated_groups: vec![],
         }
     }
-    sorted_groups.extend(group_map.into_values().collect::<Vec<Group>>());
-    let new_group_revs = sorted_groups
-        .iter()
-        .map(|group| GroupRecordRevision::new(group.id.clone()))
-        .collect::<Vec<GroupRecordRevision>>();
 
-    tracing::trace!("group revs: {}, groups: {}", new_group_revs.len(), sorted_groups.len());
-    (new_group_revs, sorted_groups)
+    fn add_updated_group(&mut self, group: Group) {
+        self.groups.push(group.clone());
+        self.updated_groups.push(group);
+    }
+
+    fn add_group(&mut self, group: Group) {
+        self.groups.push(group.clone());
+    }
+
+    fn add_insert_group(&mut self, index: usize, group: Group) {
+        self.groups.push(group.clone());
+        let inserted_group = InsertedGroupPB {
+            group: GroupPB::from(group),
+            index: index as i32,
+        };
+        self.inserted_groups.push(inserted_group);
+    }
+}
+
+fn make_group_view_changeset(
+    view_id: String,
+    inserted_groups: Vec<InsertedGroupPB>,
+    updated_group: Vec<Group>,
+) -> GroupViewChangesetPB {
+    let changeset = GroupViewChangesetPB {
+        view_id,
+        inserted_groups,
+        deleted_groups: vec![],
+        update_groups: updated_group.into_iter().map(GroupPB::from).collect(),
+    };
+    changeset
 }

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio