Jelajahi Sumber

integrate board plugin into document (#1675)

* fix: cursor doesn't blink when opening selection menu

* feat: add board plugin

* feat: integrate board plugin into document

* feat: add i10n and fix known bugs

* feat: support jump to board page on document

* feat: disable editor scroll only when the board plugin is selected

* chore: dart fix

* chore: remove unused files

* fix: dart lint
Lucas.Xu 2 tahun lalu
induk
melakukan
5de3912fe3
19 mengubah file dengan 486 tambahan dan 41 penghapusan
  1. 7 1
      frontend/app_flowy/assets/translations/en.json
  2. 4 2
      frontend/app_flowy/lib/plugins/board/board.dart
  3. 25 9
      frontend/app_flowy/lib/plugins/board/presentation/board_page.dart
  4. 8 3
      frontend/app_flowy/lib/plugins/document/document_page.dart
  5. 1 1
      frontend/app_flowy/lib/plugins/document/editor_styles.dart
  6. 195 0
      frontend/app_flowy/lib/plugins/document/presentation/plugins/board/board_menu_item.dart
  7. 175 0
      frontend/app_flowy/lib/plugins/document/presentation/plugins/board/board_node_widget.dart
  8. 49 0
      frontend/app_flowy/lib/workspace/application/app/app_service.dart
  9. 1 0
      frontend/app_flowy/packages/appflowy_editor/example/assets/example.json
  10. 1 0
      frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart
  11. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node.dart
  12. 11 3
      frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/node_extensions.dart
  13. 0 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart
  14. 0 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart
  15. 0 10
      frontend/app_flowy/packages/appflowy_editor/test/extensions/node_extension_test.dart
  16. 2 2
      frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart
  17. 4 4
      frontend/app_flowy/packages/appflowy_editor/test/service/selection_service_test.dart
  18. 1 1
      frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/math_ equation/math_equation_node_widget.dart
  19. 1 1
      frontend/app_flowy/packages/appflowy_editor_plugins/pubspec.yaml

+ 7 - 1
frontend/app_flowy/assets/translations/en.json

@@ -314,12 +314,18 @@
     "date": {
       "timeHintTextInTwelveHour": "01:00 PM",
       "timeHintTextInTwentyFourHour": "13:00"
+    },
+    "slashMenu": {
+      "board": {
+        "selectABoardToLinkTo": "Select a board to link to"
+      }
     }
   },
   "board": {
     "column": {
       "create_new_card": "New"
-    }
+    },
+    "menuName": "Board"
   },
   "calendar": {
     "menuName": "Calendar",

+ 4 - 2
frontend/app_flowy/lib/plugins/board/board.dart

@@ -1,8 +1,10 @@
+import 'package:app_flowy/generated/locale_keys.g.dart';
 import 'package:app_flowy/plugins/util.dart';
+import 'package:app_flowy/startup/plugin/plugin.dart';
 import 'package:app_flowy/workspace/presentation/home/home_stack.dart';
 import 'package:app_flowy/workspace/presentation/widgets/left_bar_item.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
-import 'package:app_flowy/startup/plugin/plugin.dart';
+import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 
 import 'presentation/board_page.dart';
@@ -18,7 +20,7 @@ class BoardPluginBuilder implements PluginBuilder {
   }
 
   @override
-  String get menuName => "Board";
+  String get menuName => LocaleKeys.board_menuName.tr();
 
   @override
   String get menuIcon => "editor/board";

+ 25 - 9
frontend/app_flowy/lib/plugins/board/presentation/board_page.dart

@@ -4,34 +4,40 @@ import 'dart:collection';
 
 import 'package:app_flowy/generated/locale_keys.g.dart';
 import 'package:app_flowy/plugins/board/application/card/card_data_controller.dart';
-import 'package:app_flowy/plugins/grid/application/row/row_cache.dart';
 import 'package:app_flowy/plugins/grid/application/field/field_controller.dart';
+import 'package:app_flowy/plugins/grid/application/row/row_cache.dart';
 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_backend/protobuf/flowy-folder/view.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-grid/field_entities.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-grid/row_entities.pb.dart';
 import 'package:appflowy_board/appflowy_board.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra/image.dart';
-import 'package:flowy_infra_ui/style_widget/text.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui_web.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
 import 'package:flowy_infra_ui/widget/error_page.dart';
-import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
-import 'package:appflowy_backend/protobuf/flowy-grid/field_entities.pb.dart';
-import 'package:appflowy_backend/protobuf/flowy-grid/row_entities.pb.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
+
 import '../application/board_bloc.dart';
 import 'card/card.dart';
 import 'card/card_cell_builder.dart';
 import 'toolbar/board_toolbar.dart';
 
 class BoardPage extends StatelessWidget {
-  final ViewPB view;
   BoardPage({
     required this.view,
     Key? key,
+    this.onEditStateChanged,
   }) : super(key: ValueKey(view.id));
 
+  final ViewPB view;
+
+  /// Called when edit state changed
+  final VoidCallback? onEditStateChanged;
+
   @override
   Widget build(BuildContext context) {
     return BlocProvider(
@@ -45,7 +51,9 @@ class BoardPage extends StatelessWidget {
                 const Center(child: CircularProgressIndicator.adaptive()),
             finish: (result) {
               return result.successOrFail.fold(
-                (_) => const BoardContent(),
+                (_) => BoardContent(
+                  onEditStateChanged: onEditStateChanged,
+                ),
                 (err) => FlowyErrorPage(err.toString()),
               );
             },
@@ -57,7 +65,12 @@ class BoardPage extends StatelessWidget {
 }
 
 class BoardContent extends StatefulWidget {
-  const BoardContent({Key? key}) : super(key: key);
+  const BoardContent({
+    Key? key,
+    this.onEditStateChanged,
+  }) : super(key: key);
+
+  final VoidCallback? onEditStateChanged;
 
   @override
   State<BoardContent> createState() => _BoardContentState();
@@ -79,7 +92,10 @@ class _BoardContentState extends State<BoardContent> {
   @override
   Widget build(BuildContext context) {
     return BlocListener<BoardBloc, BoardState>(
-      listener: (context, state) => _handleEditStateChanged(state, context),
+      listener: (context, state) {
+        _handleEditStateChanged(state, context);
+        widget.onEditStateChanged?.call();
+      },
       child: BlocBuilder<BoardBloc, BoardState>(
         buildWhen: (previous, current) => previous.groupIds != current.groupIds,
         builder: (context, state) {

+ 8 - 3
frontend/app_flowy/lib/plugins/document/document_page.dart

@@ -1,3 +1,5 @@
+import 'package:app_flowy/plugins/document/presentation/plugins/board/board_menu_item.dart';
+import 'package:app_flowy/plugins/document/presentation/plugins/board/board_node_widget.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
@@ -97,7 +99,6 @@ class _DocumentPageState extends State<DocumentPage> {
 
   Widget _renderAppFlowyEditor(EditorState editorState) {
     final theme = Theme.of(context);
-    final editorMaxWidth = MediaQuery.of(context).size.width * 0.6;
     final editor = AppFlowyEditor(
       editorState: editorState,
       autoFocus: editorState.document.isEmpty,
@@ -108,6 +109,8 @@ class _DocumentPageState extends State<DocumentPage> {
         kMathEquationType: MathEquationNodeWidgetBuidler(),
         // Code Block
         kCodeBlockType: CodeBlockNodeWidgetBuilder(),
+        // Board
+        kBoardType: BoardNodeWidgetBuilder(),
         // Card
         kCalloutType: CalloutNodeWidgetBuilder(),
       },
@@ -128,6 +131,8 @@ class _DocumentPageState extends State<DocumentPage> {
         codeBlockMenuItem,
         // Emoji
         emojiMenuItem,
+        // Board
+        boardMenuItem,
       ],
       themeData: theme.copyWith(extensions: [
         ...theme.extensions.values,
@@ -138,8 +143,8 @@ class _DocumentPageState extends State<DocumentPage> {
     return Expanded(
       child: Center(
         child: Container(
-          constraints: BoxConstraints(
-            maxWidth: editorMaxWidth,
+          constraints: const BoxConstraints(
+            maxWidth: double.infinity,
           ),
           child: editor,
         ),

+ 1 - 1
frontend/app_flowy/lib/plugins/document/editor_styles.dart

@@ -9,7 +9,7 @@ EditorStyle customEditorTheme(BuildContext context) {
       ? EditorStyle.dark
       : EditorStyle.light;
   editorStyle = editorStyle.copyWith(
-    padding: const EdgeInsets.all(0),
+    padding: const EdgeInsets.symmetric(horizontal: 40),
     textStyle: editorStyle.textStyle?.copyWith(
       fontFamily: 'poppins',
       fontSize: documentStyle.fontSize,

+ 195 - 0
frontend/app_flowy/lib/plugins/document/presentation/plugins/board/board_menu_item.dart

@@ -0,0 +1,195 @@
+import 'package:app_flowy/generated/locale_keys.g.dart';
+import 'package:app_flowy/plugins/document/presentation/plugins/board/board_node_widget.dart';
+import 'package:app_flowy/workspace/application/app/app_service.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/app.pb.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:dartz/dartz.dart' as dartz;
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/image.dart';
+import 'package:flowy_infra_ui/style_widget/button.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
+import 'package:flutter/material.dart';
+
+SelectionMenuItem boardMenuItem = SelectionMenuItem(
+  name: () => LocaleKeys.board_menuName.tr(),
+  icon: (editorState, onSelected) {
+    return svgWidget(
+      'editor/board',
+      size: const Size.square(18.0),
+      color: onSelected
+          ? editorState.editorStyle.selectionMenuItemSelectedIconColor
+          : editorState.editorStyle.selectionMenuItemIconColor,
+    );
+  },
+  keywords: ['board'],
+  handler: _showLinkToPageMenu,
+);
+
+EditorState? _editorState;
+OverlayEntry? _linkToPageMenu;
+void _dismissLinkToPageMenu() {
+  _linkToPageMenu?.remove();
+  _linkToPageMenu = null;
+
+  _editorState?.service.selectionService.currentSelection
+      .removeListener(_dismissLinkToPageMenu);
+  _editorState = null;
+}
+
+void _showLinkToPageMenu(
+  EditorState editorState,
+  SelectionMenuService menuService,
+  BuildContext context,
+) {
+  final aligment = menuService.alignment;
+  final offset = menuService.offset;
+  menuService.dismiss();
+
+  _editorState = editorState;
+
+  _linkToPageMenu?.remove();
+  _linkToPageMenu = OverlayEntry(builder: (context) {
+    return Positioned(
+      top: aligment == Alignment.bottomLeft ? offset.dy : null,
+      bottom: aligment == Alignment.topLeft ? offset.dy : null,
+      left: offset.dx,
+      child: Material(
+        color: Colors.transparent,
+        child: LinkToPageMenu(
+          editorState: editorState,
+        ),
+      ),
+    );
+  });
+
+  Overlay.of(context)?.insert(_linkToPageMenu!);
+
+  editorState.service.selectionService.currentSelection
+      .addListener(_dismissLinkToPageMenu);
+}
+
+class LinkToPageMenu extends StatefulWidget {
+  final EditorState editorState;
+
+  const LinkToPageMenu({
+    super.key,
+    required this.editorState,
+  });
+
+  @override
+  State<LinkToPageMenu> createState() => _LinkToPageMenuState();
+}
+
+class _LinkToPageMenuState extends State<LinkToPageMenu> {
+  EditorStyle get style => widget.editorState.editorStyle;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      color: Colors.transparent,
+      width: 300,
+      child: Container(
+        padding: const EdgeInsets.fromLTRB(10, 6, 10, 6),
+        decoration: BoxDecoration(
+          color: style.selectionMenuBackgroundColor,
+          boxShadow: [
+            BoxShadow(
+              blurRadius: 5,
+              spreadRadius: 1,
+              color: Colors.black.withOpacity(0.1),
+            ),
+          ],
+          borderRadius: BorderRadius.circular(6.0),
+        ),
+        child: _buildBoardListWidget(context),
+      ),
+    );
+  }
+
+  Future<List<dartz.Tuple2<AppPB, List<ViewPB>>>> fetchBoards() async {
+    return AppService().fetchViews(ViewLayoutTypePB.Board);
+  }
+
+  Widget _buildBoardListWidget(BuildContext context) {
+    return FutureBuilder<List<dartz.Tuple2<AppPB, List<ViewPB>>>>(
+      builder: (context, snapshot) {
+        if (snapshot.hasData &&
+            snapshot.connectionState == ConnectionState.done) {
+          final apps = snapshot.data;
+          final children = <Widget>[
+            Padding(
+              padding: const EdgeInsets.symmetric(vertical: 4),
+              child: FlowyText.regular(
+                LocaleKeys.document_slashMenu_board_selectABoardToLinkTo.tr(),
+                fontSize: 10,
+                color: Colors.grey,
+              ),
+            ),
+          ];
+          if (apps != null && apps.isNotEmpty) {
+            for (final app in apps) {
+              if (app.value2.isNotEmpty) {
+                children.add(
+                  Padding(
+                    padding: const EdgeInsets.symmetric(vertical: 4),
+                    child: FlowyText.regular(
+                      app.value1.name,
+                    ),
+                  ),
+                );
+                for (final board in app.value2) {
+                  children.add(
+                    FlowyButton(
+                      leftIcon: svgWidget(
+                        'editor/board',
+                        color: Theme.of(context).colorScheme.onSurface,
+                      ),
+                      text: FlowyText.regular(board.name),
+                      onTap: () => widget.editorState.insertBoard(
+                        app.value1,
+                        board,
+                      ),
+                    ),
+                  );
+                }
+              }
+            }
+          }
+          return Column(
+            crossAxisAlignment: CrossAxisAlignment.stretch,
+            children: children,
+          );
+        } else {
+          return const Center(
+            child: CircularProgressIndicator(),
+          );
+        }
+      },
+      future: fetchBoards(),
+    );
+  }
+}
+
+extension on EditorState {
+  void insertBoard(AppPB appPB, ViewPB viewPB) {
+    final selection = service.selectionService.currentSelection.value;
+    final textNodes =
+        service.selectionService.currentSelectedNodes.whereType<TextNode>();
+    if (selection == null || textNodes.isEmpty) {
+      return;
+    }
+    final transaction = this.transaction;
+    transaction.insertNode(
+      selection.end.path,
+      Node(
+        type: kBoardType,
+        attributes: {
+          kAppID: appPB.id,
+          kBoardID: viewPB.id,
+        },
+      ),
+    );
+    apply(transaction);
+  }
+}

+ 175 - 0
frontend/app_flowy/lib/plugins/document/presentation/plugins/board/board_node_widget.dart

@@ -0,0 +1,175 @@
+import 'package:app_flowy/plugins/board/presentation/board_page.dart';
+import 'package:app_flowy/startup/startup.dart';
+import 'package:app_flowy/workspace/application/app/app_service.dart';
+import 'package:app_flowy/workspace/application/view/view_ext.dart';
+import 'package:app_flowy/workspace/presentation/home/home_stack.dart';
+import 'package:app_flowy/workspace/presentation/home/menu/menu.dart';
+import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:dartz/dartz.dart' as dartz;
+import 'package:flowy_infra_ui/style_widget/button.dart';
+import 'package:flutter/material.dart';
+
+const String kBoardType = 'board';
+const String kAppID = 'app_id';
+const String kBoardID = 'board_id';
+
+class BoardNodeWidgetBuilder extends NodeWidgetBuilder<Node> {
+  @override
+  Widget build(NodeWidgetContext<Node> context) {
+    return _BoardWidget(
+      key: context.node.key,
+      node: context.node,
+      editorState: context.editorState,
+    );
+  }
+
+  @override
+  NodeValidator<Node> get nodeValidator => (node) {
+        return node.attributes[kBoardID] is String &&
+            node.attributes[kAppID] is String;
+      };
+}
+
+class _BoardWidget extends StatefulWidget {
+  const _BoardWidget({
+    Key? key,
+    required this.node,
+    required this.editorState,
+  }) : super(key: key);
+
+  final Node node;
+  final EditorState editorState;
+
+  @override
+  State<_BoardWidget> createState() => _BoardWidgetState();
+}
+
+class _BoardWidgetState extends State<_BoardWidget> with SelectableMixin {
+  RenderBox get _renderBox => context.findRenderObject() as RenderBox;
+
+  String get boardID {
+    return widget.node.attributes[kBoardID];
+  }
+
+  String get appID {
+    return widget.node.attributes[kAppID];
+  }
+
+  late Future<dartz.Either<ViewPB, FlowyError>> board;
+
+  @override
+  void initState() {
+    super.initState();
+
+    board = _fetchBoard();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return FutureBuilder<dartz.Either<ViewPB, FlowyError>>(
+      builder: (context, snapshot) {
+        if (snapshot.hasData) {
+          final board = snapshot.data?.getLeftOrNull<ViewPB>();
+          if (board != null) {
+            return _buildBoard(context, board);
+          }
+        }
+        return const Center(
+          child: CircularProgressIndicator(),
+        );
+      },
+      future: board,
+    );
+  }
+
+  Future<dartz.Either<ViewPB, FlowyError>> _fetchBoard() async {
+    return AppService().getView(appID, boardID);
+  }
+
+  Widget _buildBoard(BuildContext context, ViewPB viewPB) {
+    return MouseRegion(
+      onHover: (event) {
+        if (widget.node.isSelected(widget.editorState)) {
+          widget.editorState.service.scrollService?.disable();
+        }
+      },
+      onExit: (event) {
+        widget.editorState.service.scrollService?.enable();
+      },
+      child: SizedBox(
+        height: 400,
+        child: Stack(
+          children: [
+            Positioned(
+              top: 0,
+              left: 20,
+              child: FlowyTextButton(
+                viewPB.name,
+                onPressed: () {
+                  getIt<MenuSharedState>().latestOpenView = viewPB;
+                  getIt<HomeStackManager>().setPlugin(viewPB.plugin());
+                },
+              ),
+            ),
+            BoardPage(
+              key: ValueKey(viewPB.id),
+              view: viewPB,
+              onEditStateChanged: () {
+                /// Clear selection when the edit state changes, otherwise the editor will prevent the keyboard event when the board is in edit mode.
+                widget.editorState.service.selectionService.clearSelection();
+              },
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  @override
+  bool get shouldCursorBlink => false;
+
+  @override
+  CursorStyle get cursorStyle => CursorStyle.borderLine;
+
+  @override
+  Position start() {
+    return Position(path: widget.node.path, offset: 0);
+  }
+
+  @override
+  Position end() {
+    return Position(path: widget.node.path, offset: 0);
+  }
+
+  @override
+  Position getPositionInOffset(Offset start) {
+    return end();
+  }
+
+  @override
+  List<Rect> getRectsInSelection(Selection selection) {
+    return [Offset.zero & _renderBox.size];
+  }
+
+  @override
+  Rect? getCursorRectInPosition(Position position) {
+    final size = _renderBox.size;
+    return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
+  }
+
+  @override
+  Selection getSelectionInRange(Offset start, Offset end) {
+    return Selection.single(
+      path: widget.node.path,
+      startOffset: 0,
+      endOffset: 0,
+    );
+  }
+
+  @override
+  Offset localToGlobal(Offset offset) {
+    return _renderBox.localToGlobal(offset);
+  }
+}

+ 49 - 0
frontend/app_flowy/lib/workspace/application/app/app_service.dart

@@ -1,5 +1,6 @@
 import 'dart:async';
 
+import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
 import 'package:dartz/dartz.dart';
 import 'package:appflowy_backend/dispatch/dispatch.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@@ -77,4 +78,52 @@ class AppService {
 
     return FolderEventMoveFolderItem(payload).send();
   }
+
+  Future<List<Tuple2<AppPB, List<ViewPB>>>> fetchViews(
+      ViewLayoutTypePB layoutType) async {
+    final result = <Tuple2<AppPB, List<ViewPB>>>[];
+    return FolderEventReadCurrentWorkspace().send().then((value) async {
+      final workspaces = value.getLeftOrNull<WorkspaceSettingPB>();
+      if (workspaces != null) {
+        final apps = workspaces.workspace.apps.items;
+        for (var app in apps) {
+          final views = await getViews(appId: app.id).then(
+            (value) => value
+                .getLeftOrNull<List<ViewPB>>()
+                ?.where((e) => e.layout == layoutType)
+                .toList(),
+          );
+          if (views != null && views.isNotEmpty) {
+            result.add(Tuple2(app, views));
+          }
+        }
+      }
+      return result;
+    });
+  }
+
+  Future<Either<ViewPB, FlowyError>> getView(
+    String appID,
+    String viewID,
+  ) async {
+    final payload = AppIdPB.create()..value = appID;
+    return FolderEventReadApp(payload).send().then((result) {
+      return result.fold(
+        (app) => left(
+          app.belongings.items.firstWhere((e) => e.id == viewID),
+        ),
+        (error) => right(error),
+      );
+    });
+  }
+}
+
+extension AppFlowy on Either {
+  T? getLeftOrNull<T>() {
+    if (isLeft()) {
+      final result = fold<T?>((l) => l, (r) => null);
+      return result;
+    }
+    return null;
+  }
 }

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

@@ -23,6 +23,7 @@
         ]
       },
       { "type": "text", "delta": [] },
+      { "type": "board" },
       {
         "type": "text",
         "delta": [

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

@@ -44,3 +44,4 @@ export 'src/plugins/markdown/document_markdown.dart';
 export 'src/plugins/quill_delta/delta_document_encoder.dart';
 export 'src/commands/text/text_commands.dart';
 export 'src/render/toolbar/toolbar_item.dart';
+export 'src/extensions/node_extensions.dart';

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node.dart

@@ -71,7 +71,7 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
   Attributes _attributes;
 
   // Renderable
-  GlobalKey? key;
+  final key = GlobalKey();
   final layerLink = LayerLink();
 
   Attributes get attributes => {..._attributes};

+ 11 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/node_extensions.dart

@@ -1,17 +1,18 @@
 import 'package:appflowy_editor/src/core/document/node.dart';
 import 'package:appflowy_editor/src/core/document/path.dart';
 import 'package:appflowy_editor/src/core/location/selection.dart';
+import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/extensions/object_extensions.dart';
 import 'package:appflowy_editor/src/render/selection/selectable.dart';
 import 'package:flutter/material.dart';
 
 extension NodeExtensions on Node {
   RenderBox? get renderBox =>
-      key?.currentContext?.findRenderObject()?.unwrapOrNull<RenderBox>();
+      key.currentContext?.findRenderObject()?.unwrapOrNull<RenderBox>();
 
-  BuildContext? get context => key?.currentContext;
+  BuildContext? get context => key.currentContext;
   SelectableMixin? get selectable =>
-      key?.currentState?.unwrapOrNull<SelectableMixin>();
+      key.currentState?.unwrapOrNull<SelectableMixin>();
 
   bool inSelection(Selection selection) {
     if (selection.start.path <= selection.end.path) {
@@ -28,4 +29,11 @@ extension NodeExtensions on Node {
     }
     return Rect.zero;
   }
+
+  bool isSelected(EditorState editorState) {
+    final currentSelectedNodes =
+        editorState.service.selectionService.currentSelectedNodes;
+    return currentSelectedNodes.length == 1 &&
+        currentSelectedNodes.first == this;
+  }
 }

+ 0 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart

@@ -1,5 +1,4 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:appflowy_editor/src/extensions/node_extensions.dart';
 import 'package:flutter/material.dart';
 
 ShortcutEventHandler cursorLeftSelect = (editorState, event) {

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

@@ -74,8 +74,6 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService {
         node.subtype == null ? node.type : '${node.type}/${node.subtype!}';
     final builder = _builders[name];
     if (builder != null && builder.nodeValidator(node)) {
-      final key = GlobalKey(debugLabel: name);
-      node.key = key;
       return _autoUpdateNodeWidget(builder, context);
     } else {
       // Returns a SizeBox with 0 height if no builder found.

+ 0 - 10
frontend/app_flowy/packages/appflowy_editor/test/extensions/node_extension_test.dart

@@ -1,28 +1,18 @@
 import 'dart:collection';
-import 'dart:ui';
 
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:mockito/mockito.dart';
-import 'package:appflowy_editor/src/extensions/node_extensions.dart';
 
 class MockNode extends Mock implements Node {}
 
 void main() {
-  final mockNode = MockNode();
-
   group('NodeExtensions::', () {
     final selection = Selection(
       start: Position(path: [0]),
       end: Position(path: [1]),
     );
 
-    test('rect - renderBox is null', () {
-      when(mockNode.renderBox).thenReturn(null);
-      final result = mockNode.rect;
-      expect(result, Rect.zero);
-    });
-
     test('inSelection', () {
       // I use an empty implementation instead of mock, because the mocked
       // version throws error trying to access the path.

+ 2 - 2
frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart

@@ -43,7 +43,7 @@ void main() async {
       final selection =
           Selection.single(path: [0], startOffset: 0, endOffset: text.length);
       var node = editor.nodeAtPath([0]) as TextNode;
-      var state = node.key?.currentState as DefaultSelectable;
+      var state = node.key.currentState as DefaultSelectable;
       var checkboxWidget = find.byKey(state.iconKey!);
       await tester.tap(checkboxWidget);
       await tester.pumpAndSettle();
@@ -56,7 +56,7 @@ void main() async {
       expect(node.allSatisfyStrikethroughInSelection(selection), true);
 
       node = editor.nodeAtPath([0]) as TextNode;
-      state = node.key?.currentState as DefaultSelectable;
+      state = node.key.currentState as DefaultSelectable;
       await tester.ensureVisible(find.byKey(state.iconKey!));
       await tester.tap(find.byKey(state.iconKey!));
       await tester.pump();

+ 4 - 4
frontend/app_flowy/packages/appflowy_editor/test/service/selection_service_test.dart

@@ -21,7 +21,7 @@ void main() async {
       await editor.startTesting();
 
       final secondTextNode = editor.nodeAtPath([1]);
-      final finder = find.byKey(secondTextNode!.key!);
+      final finder = find.byKey(secondTextNode!.key);
 
       final rect = tester.getRect(finder);
       // tap at the beginning
@@ -48,7 +48,7 @@ void main() async {
       await editor.startTesting();
 
       final secondTextNode = editor.nodeAtPath([1]);
-      final finder = find.byKey(secondTextNode!.key!);
+      final finder = find.byKey(secondTextNode!.key);
 
       final rect = tester.getRect(finder);
       // double tap
@@ -70,7 +70,7 @@ void main() async {
       await editor.startTesting();
 
       final secondTextNode = editor.nodeAtPath([1]);
-      final finder = find.byKey(secondTextNode!.key!);
+      final finder = find.byKey(secondTextNode!.key);
 
       final rect = tester.getRect(finder);
       // triple tap
@@ -93,7 +93,7 @@ void main() async {
       await editor.startTesting();
 
       final secondTextNode = editor.nodeAtPath([1]) as TextNode;
-      final finder = find.byKey(secondTextNode.key!);
+      final finder = find.byKey(secondTextNode.key);
 
       final rect = tester.getRect(finder);
       // secondary tap

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/math_ equation/math_equation_node_widget.dart

@@ -47,7 +47,7 @@ SelectionMenuItem mathEquationMenuItem = SelectionMenuItem(
       final mathEquationState = editorState.document
           .nodeAtPath(mathEquationNodePath)
           ?.key
-          ?.currentState;
+          .currentState;
       if (mathEquationState != null &&
           mathEquationState is _MathEquationNodeWidgetState) {
         mathEquationState.showEditingDialog();

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

@@ -16,7 +16,7 @@ dependencies:
     path: ../appflowy_editor
   flowy_infra: 
     path: ../flowy_infra
-  flowy_infra_ui: 
+  flowy_infra_ui:
     path: ../flowy_infra_ui
   appflowy_popover: 
     path: ../appflowy_popover