Browse Source

refactor: cover node widget code (#2899)

* chore: change initial cover type's name to none

* chore: refactor cover node widget

* chore: use a constant instead of magic value

* fix: make the size of icon hover effect smaller

* chore: improve appearance of selected color

* test: add cover integration tests

* fix: inner ring of selected color in dark mode

* refactor: cover node to document header node

* test: simplify tests

* chore: rename files
Richard Shiue 1 year ago
parent
commit
7f74fd6149
17 changed files with 863 additions and 670 deletions
  1. 1 0
      frontend/appflowy_flutter/integration_test/database_row_page_test.dart
  2. 100 4
      frontend/appflowy_flutter/integration_test/document/cover_image_test.dart
  3. 0 11
      frontend/appflowy_flutter/integration_test/util/database_test_op.dart
  4. 79 6
      frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart
  5. 17 0
      frontend/appflowy_flutter/integration_test/util/emoji.dart
  6. 51 1
      frontend/appflowy_flutter/integration_test/util/expectation.dart
  7. 1 1
      frontend/appflowy_flutter/lib/plugins/document/document_page.dart
  8. 0 555
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/cover_node_widget.dart
  9. 54 37
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart
  10. 8 8
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor_bloc.dart
  11. 1 1
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart
  12. 3 2
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart
  13. 507 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart
  14. 2 5
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart
  15. 35 35
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_popover.dart
  16. 3 3
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart
  17. 1 1
      frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart

+ 1 - 0
frontend/appflowy_flutter/integration_test/database_row_page_test.dart

@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
 import 'package:integration_test/integration_test.dart';
 
 import 'util/database_test_op.dart';
+import 'util/emoji.dart';
 import 'util/ime.dart';
 import 'util/util.dart';
 

+ 100 - 4
frontend/appflowy_flutter/integration_test/document/cover_image_test.dart

@@ -1,6 +1,8 @@
+import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:integration_test/integration_test.dart';
 
+import '../util/emoji.dart';
 import '../util/util.dart';
 
 void main() {
@@ -22,15 +24,109 @@ void main() {
       await TestFolder.cleanTestLocation(null);
     });
 
-    testWidgets(
-        'hovering on cover image will display change and delete cover image buttons',
-        (tester) async {
+    testWidgets('document cover tests', (tester) async {
       await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      tester.expectToSeeNoDocumentCover();
+
+      // Hover over cover toolbar to show 'Add Cover' and 'Add Icon' buttons
+      await tester.editor.hoverOnCoverToolbar();
+      tester.expectToSeePluginAddCoverAndIconButton();
+
+      // Insert a document cover
+      await tester.editor.tapOnAddCover();
+      tester.expectToSeeDocumentCover(
+        CoverType.asset,
+        "assets/images/app_flowy_abstract_cover_1.jpg",
+      );
+
+      // Hover over the cover to show the 'Change Cover' and delete buttons
+      await tester.editor.hoverOnCover();
+      tester.expectChangeCoverAndDeleteButton();
+
+      // Change cover to a solid color background
+      await tester.editor.hoverOnCover();
+      await tester.editor.tapOnChangeCover();
+      await tester.editor.switchSolidColorBackground();
+      await tester.editor.dismissCoverPicker();
+      tester.expectToSeeDocumentCover(CoverType.color, "ffe8e0ff");
 
+      // Remove the cover
+      await tester.editor.hoverOnCover();
+      await tester.editor.tapOnRemoveCover();
+      tester.expectToSeeNoDocumentCover();
+    });
+
+    testWidgets('document icon tests', (tester) async {
+      await tester.initializeAppFlowy();
       await tester.tapGoButton();
-      await tester.editor.hoverOnCoverPluginAddButton();
 
+      tester.expectToSeeDocumentIcon(null);
+
+      // Hover over cover toolbar to show the 'Add Cover' and 'Add Icon' buttons
+      await tester.editor.hoverOnCoverToolbar();
       tester.expectToSeePluginAddCoverAndIconButton();
+
+      // Insert a document icon
+      await tester.editor.tapAddIconButton();
+      await tester.switchToEmojiList();
+      await tester.tapEmoji('😀');
+      tester.expectToSeeDocumentIcon('😀');
+
+      // Remove the document icon from the cover toolbar
+      await tester.editor.hoverOnCoverToolbar();
+      await tester.editor.tapRemoveIconButton();
+      tester.expectToSeeDocumentIcon(null);
+
+      // Add the icon back for further testing
+      await tester.editor.hoverOnCoverToolbar();
+      await tester.editor.tapAddIconButton();
+      await tester.switchToEmojiList();
+      await tester.tapEmoji('😀');
+      tester.expectToSeeDocumentIcon('😀');
+
+      // Change the document icon
+      await tester.editor.tapOnIconWidget();
+      await tester.switchToEmojiList();
+      await tester.tapEmoji('😅');
+      tester.expectToSeeDocumentIcon('😅');
+
+      // Remove the document icon from the icon picker
+      await tester.editor.tapOnIconWidget();
+      await tester.editor.tapRemoveIconButton(isInPicker: true);
+      tester.expectToSeeDocumentIcon(null);
+    });
+
+    testWidgets('icon and cover at the same time', (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      tester.expectToSeeDocumentIcon(null);
+      tester.expectToSeeNoDocumentCover();
+
+      // Hover over cover toolbar to show the 'Add Cover' and 'Add Icon' buttons
+      await tester.editor.hoverOnCoverToolbar();
+      tester.expectToSeePluginAddCoverAndIconButton();
+
+      // Insert a document icon
+      await tester.editor.tapAddIconButton();
+      await tester.switchToEmojiList();
+      await tester.tapEmoji('😀');
+
+      // Insert a document cover
+      await tester.editor.tapOnAddCover();
+
+      // Expect to see the icon and cover at the same time
+      tester.expectToSeeDocumentIcon('😀');
+      tester.expectToSeeDocumentCover(
+        CoverType.asset,
+        "assets/images/app_flowy_abstract_cover_1.jpg",
+      );
+
+      // Hover over the cover toolbar and see that neither icons are shown
+      await tester.editor.hoverOnCoverToolbar();
+      tester.expectToSeeEmptyDocumentHeaderToolbar();
     });
   });
 }

+ 0 - 11
frontend/appflowy_flutter/integration_test/util/database_test_op.dart

@@ -461,17 +461,6 @@ extension AppFlowyDatabaseTest on WidgetTester {
     await tapButton(find.byType(EmojiSelectionMenu));
   }
 
-  /// Must call [openEmojiPicker] first
-  Future<void> switchToEmojiList() async {
-    final icon = find.byIcon(Icons.tag_faces);
-    await tapButton(icon);
-  }
-
-  Future<void> tapEmoji(String emoji) async {
-    final emojiWidget = find.text(emoji);
-    await tapButton(emojiWidget);
-  }
-
   Future<void> tapDateCellInRowDetailPage() async {
     final findDateCell = find.byType(GridDateCell);
     await tapButton(findDateCell);

+ 79 - 6
frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart

@@ -1,4 +1,13 @@
+import 'dart:ui';
+
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_popover.dart';
 import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 import 'ime.dart';
@@ -26,14 +35,78 @@ class EditorOperations {
   }
 
   /// Hover on cover plugin button above the document
-  Future<void> hoverOnCoverPluginAddButton() async {
-    final editor = find.byWidgetPredicate(
-      (widget) => widget is AppFlowyEditor,
+  Future<void> hoverOnCoverToolbar() async {
+    final coverToolbar = find.byType(DocumentHeaderToolbar);
+    await tester.startGesture(
+      tester.getBottomLeft(coverToolbar).translate(5, -5),
+      kind: PointerDeviceKind.mouse,
+    );
+    await tester.pumpAndSettle();
+  }
+
+  /// Taps on the 'Add Icon' button in the cover toolbar
+  Future<void> tapAddIconButton() async {
+    await tester.tapButtonWithName(
+      LocaleKeys.document_plugins_cover_addIcon.tr(),
+    );
+    expect(find.byType(EmojiPopover), findsOneWidget);
+  }
+
+  /// Taps the 'Remove Icon' button in the cover toolbar and the icon popover
+  Future<void> tapRemoveIconButton({bool isInPicker = false}) async {
+    Finder button =
+        find.text(LocaleKeys.document_plugins_cover_removeIcon.tr());
+    if (isInPicker) {
+      button = find.descendant(of: find.byType(EmojiPopover), matching: button);
+    }
+
+    await tester.tapButton(button);
+  }
+
+  /// Requires that the document must already have an icon. This opens the icon
+  /// picker
+  Future<void> tapOnIconWidget() async {
+    final iconWidget = find.byType(EmojiIconWidget);
+    await tester.tapButton(iconWidget);
+  }
+
+  Future<void> tapOnAddCover() async {
+    await tester.tapButtonWithName(
+      LocaleKeys.document_plugins_cover_addCover.tr(),
     );
-    await tester.hoverOnWidget(
-      editor,
-      offset: tester.getTopLeft(editor).translate(20, 20),
+  }
+
+  Future<void> tapOnChangeCover() async {
+    await tester.tapButtonWithName(
+      LocaleKeys.document_plugins_cover_changeCover.tr(),
+    );
+  }
+
+  Future<void> switchSolidColorBackground() async {
+    final findPurpleButton = find.byWidgetPredicate(
+      (widget) => widget is ColorItem && widget.option.colorHex == "ffe8e0ff",
+    );
+    await tester.tapButton(findPurpleButton);
+  }
+
+  Future<void> tapOnRemoveCover() async {
+    await tester.tapButton(find.byType(DeleteCoverButton));
+  }
+
+  /// A cover must be present in the document to function properly since this
+  /// catches all cover types collectively
+  Future<void> hoverOnCover() async {
+    final cover = find.byType(DocumentCover);
+    await tester.startGesture(
+      tester.getCenter(cover),
+      kind: PointerDeviceKind.mouse,
     );
+    await tester.pumpAndSettle();
+  }
+
+  Future<void> dismissCoverPicker() async {
+    await tester.sendKeyEvent(LogicalKeyboardKey.escape);
+    await tester.pumpAndSettle();
   }
 
   /// trigger the slash command (selection menu)

+ 17 - 0
frontend/appflowy_flutter/integration_test/util/emoji.dart

@@ -0,0 +1,17 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'base.dart';
+
+extension EmojiTestExtension on WidgetTester {
+  /// Must call [openEmojiPicker] first
+  Future<void> switchToEmojiList() async {
+    final icon = find.byIcon(Icons.tag_faces);
+    await tapButton(icon);
+  }
+
+  Future<void> tapEmoji(String emoji) async {
+    final emojiWidget = find.text(emoji);
+    await tapButton(emojiWidget);
+  }
+}

+ 51 - 1
frontend/appflowy_flutter/integration_test/util/expectation.dart

@@ -1,5 +1,7 @@
 import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:appflowy/plugins/document/presentation/banner.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
 import 'package:appflowy/workspace/presentation/home/home_stack.dart';
 import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
 import 'package:easy_localization/easy_localization.dart';
@@ -47,7 +49,7 @@ extension Expectation on WidgetTester {
     expect(exportSuccess, findsOneWidget);
   }
 
-  /// Expect to see the add button and icon button inside the document.
+  /// Expect to see the add button and icon button in the cover toolbar
   void expectToSeePluginAddCoverAndIconButton() {
     final addCover = find.textContaining(
       LocaleKeys.document_plugins_cover_addCover.tr(),
@@ -59,6 +61,54 @@ extension Expectation on WidgetTester {
     expect(addIcon, findsOneWidget);
   }
 
+  /// Expect to see the document header toolbar empty
+  void expectToSeeEmptyDocumentHeaderToolbar() {
+    final addCover = find.textContaining(
+      LocaleKeys.document_plugins_cover_addCover.tr(),
+    );
+    final addIcon = find.textContaining(
+      LocaleKeys.document_plugins_cover_addIcon.tr(),
+    );
+    expect(addCover, findsNothing);
+    expect(addIcon, findsNothing);
+  }
+
+  void expectToSeeDocumentIcon(String? emoji) {
+    if (emoji == null) {
+      final iconWidget = find.byType(EmojiIconWidget);
+      expect(iconWidget, findsNothing);
+      return;
+    }
+    final iconWidget = find.byWidgetPredicate(
+      (widget) => widget is EmojiIconWidget && widget.emoji == emoji,
+    );
+    expect(iconWidget, findsOneWidget);
+  }
+
+  void expectToSeeDocumentCover(CoverType type, String details) {
+    final findCover = find.byWidgetPredicate(
+      (widget) =>
+          widget is DocumentCover &&
+          widget.coverType == type &&
+          widget.coverDetails == details,
+    );
+    expect(findCover, findsOneWidget);
+  }
+
+  void expectToSeeNoDocumentCover() {
+    final findCover = find.byType(DocumentCover);
+    expect(findCover, findsNothing);
+  }
+
+  void expectChangeCoverAndDeleteButton() {
+    final findChangeCover = find.text(
+      LocaleKeys.document_plugins_cover_changeCover.tr(),
+    );
+    final findRemoveIcon = find.byType(DeleteCoverButton);
+    expect(findChangeCover, findsOneWidget);
+    expect(findRemoveIcon, findsOneWidget);
+  }
+
   /// Expect to see the user name on the home page
   void expectToSeeUserName(String name) {
     final userName = find.byWidgetPredicate(

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/document/document_page.dart

@@ -123,7 +123,7 @@ class _DocumentPageState extends State<DocumentPage> {
       return const Placeholder();
     }
     final page = editorState!.document.root;
-    return CoverImageNodeWidget(
+    return DocumentHeaderNodeWidget(
       node: page,
       editorState: editorState!,
     );

+ 0 - 555
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/cover_node_widget.dart

@@ -1,555 +0,0 @@
-import 'dart:io';
-
-import 'package:appflowy/generated/locale_keys.g.dart';
-import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/change_cover_popover.dart';
-import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/emoji_popover.dart';
-import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/emoji_icon_widget.dart';
-import 'package:appflowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart';
-import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg;
-import 'package:appflowy_popover/appflowy_popover.dart';
-import 'package:easy_localization/easy_localization.dart';
-import 'package:flowy_infra/image.dart';
-import 'package:flowy_infra/size.dart';
-import 'package:flowy_infra_ui/flowy_infra_ui.dart';
-import 'package:flowy_infra_ui/widget/rounded_button.dart';
-import 'package:flutter/material.dart';
-
-class CoverBlockKeys {
-  const CoverBlockKeys._();
-
-  static const String selectionType = 'cover_selection_type';
-  static const String selection = 'cover_selection';
-  static const String iconSelection = 'selected_icon';
-}
-
-enum CoverSelectionType {
-  initial,
-  color,
-  file,
-  asset;
-
-  static CoverSelectionType fromString(String? value) {
-    if (value == null) {
-      return CoverSelectionType.initial;
-    }
-    return CoverSelectionType.values.firstWhere(
-      (e) => e.toString() == value,
-      orElse: () => CoverSelectionType.initial,
-    );
-  }
-}
-
-class CoverNodeWidgetBuilder implements NodeWidgetBuilder {
-  @override
-  Widget build(NodeWidgetContext<Node> context) {
-    return CoverImageNodeWidget(
-      key: context.node.key,
-      node: context.node,
-      editorState: context.editorState,
-    );
-  }
-
-  @override
-  NodeValidator<Node> get nodeValidator => (node) {
-        return true;
-      };
-}
-
-class CoverImageNodeWidget extends StatefulWidget {
-  const CoverImageNodeWidget({
-    Key? key,
-    required this.node,
-    required this.editorState,
-  }) : super(key: key);
-
-  final Node node;
-  final EditorState editorState;
-
-  @override
-  State<CoverImageNodeWidget> createState() => _CoverImageNodeWidgetState();
-}
-
-class _CoverImageNodeWidgetState extends State<CoverImageNodeWidget> {
-  CoverSelectionType get selectionType => CoverSelectionType.fromString(
-        widget.node.attributes[CoverBlockKeys.selectionType],
-      );
-
-  @override
-  void initState() {
-    super.initState();
-
-    widget.node.addListener(_reload);
-  }
-
-  @override
-  void dispose() {
-    widget.node.removeListener(_reload);
-
-    super.dispose();
-  }
-
-  void _reload() {
-    setState(() {});
-  }
-
-  PopoverController iconPopoverController = PopoverController();
-  @override
-  Widget build(BuildContext context) {
-    return _CoverImage(
-      editorState: widget.editorState,
-      node: widget.node,
-      onCoverChanged: (type, value) {
-        _insertCover(type, value);
-      },
-    );
-  }
-
-  Future<void> _insertCover(CoverSelectionType type, dynamic cover) async {
-    final transaction = widget.editorState.transaction;
-    transaction.updateNode(widget.node, {
-      CoverBlockKeys.selectionType: type.toString(),
-      CoverBlockKeys.selection: cover,
-      CoverBlockKeys.iconSelection:
-          widget.node.attributes[CoverBlockKeys.iconSelection]
-    });
-    return widget.editorState.apply(transaction);
-  }
-}
-
-class _AddCoverButton extends StatefulWidget {
-  final Node node;
-  final EditorState editorState;
-  final bool hasIcon;
-  final CoverSelectionType selectionType;
-
-  final PopoverController iconPopoverController;
-  const _AddCoverButton({
-    required this.onTap,
-    required this.node,
-    required this.editorState,
-    required this.hasIcon,
-    required this.selectionType,
-    required this.iconPopoverController,
-  });
-
-  final VoidCallback onTap;
-
-  @override
-  State<_AddCoverButton> createState() => _AddCoverButtonState();
-}
-
-bool isPopoverOpen = false;
-
-class _AddCoverButtonState extends State<_AddCoverButton> {
-  bool isHidden = true;
-  PopoverMutex mutex = PopoverMutex();
-  bool isPopoverOpen = false;
-  @override
-  void initState() {
-    super.initState();
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return MouseRegion(
-      onEnter: (event) {
-        setHidden(false);
-      },
-      onExit: (event) {
-        setHidden(isPopoverOpen ? false : true);
-      },
-      opaque: false,
-      child: Container(
-        height: widget.hasIcon ? 180 : 50.0,
-        alignment: Alignment.bottomLeft,
-        width: double.infinity,
-        padding: const EdgeInsets.only(
-          left: 80,
-          top: 20,
-          bottom: 5,
-        ),
-        child: isHidden
-            ? Container()
-            : Row(
-                mainAxisSize: MainAxisSize.min,
-                mainAxisAlignment: MainAxisAlignment.start,
-                children: [
-                  // Add Cover Button.
-                  widget.selectionType != CoverSelectionType.initial
-                      ? Container()
-                      : FlowyButton(
-                          key: UniqueKey(),
-                          leftIconSize: const Size.square(18),
-                          onTap: widget.onTap,
-                          useIntrinsicWidth: true,
-                          leftIcon: const FlowySvg(name: 'editor/image'),
-                          text: FlowyText.regular(
-                            LocaleKeys.document_plugins_cover_addCover.tr(),
-                          ),
-                        ),
-                  // Add Icon Button.
-                  widget.hasIcon
-                      ? FlowyButton(
-                          leftIconSize: const Size.square(18),
-                          onTap: () {
-                            _removeIcon();
-                          },
-                          useIntrinsicWidth: true,
-                          leftIcon: const Icon(
-                            Icons.emoji_emotions_outlined,
-                            size: 18,
-                          ),
-                          text: FlowyText.regular(
-                            LocaleKeys.document_plugins_cover_removeIcon.tr(),
-                          ),
-                        )
-                      : AppFlowyPopover(
-                          mutex: mutex,
-                          asBarrier: true,
-                          onClose: () {
-                            isPopoverOpen = false;
-                            setHidden(true);
-                          },
-                          offset: const Offset(120, 10),
-                          controller: widget.iconPopoverController,
-                          direction: PopoverDirection.bottomWithCenterAligned,
-                          constraints:
-                              BoxConstraints.loose(const Size(320, 380)),
-                          margin: EdgeInsets.zero,
-                          child: FlowyButton(
-                            leftIconSize: const Size.square(18),
-                            useIntrinsicWidth: true,
-                            leftIcon: const Icon(
-                              Icons.emoji_emotions_outlined,
-                              size: 18,
-                            ),
-                            text: FlowyText.regular(
-                              LocaleKeys.document_plugins_cover_addIcon.tr(),
-                            ),
-                          ),
-                          popupBuilder: (BuildContext popoverContext) {
-                            isPopoverOpen = true;
-                            return EmojiPopover(
-                              showRemoveButton: widget.hasIcon,
-                              removeIcon: _removeIcon,
-                              node: widget.node,
-                              editorState: widget.editorState,
-                              onEmojiChanged: (Emoji emoji) {
-                                _insertIcon(emoji);
-                                widget.iconPopoverController.close();
-                              },
-                            );
-                          },
-                        )
-                ],
-              ),
-      ),
-    );
-  }
-
-  Future<void> _insertIcon(Emoji emoji) async {
-    final transaction = widget.editorState.transaction;
-    transaction.updateNode(widget.node, {
-      CoverBlockKeys.selectionType:
-          widget.node.attributes[CoverBlockKeys.selectionType],
-      CoverBlockKeys.selection:
-          widget.node.attributes[CoverBlockKeys.selection],
-      CoverBlockKeys.iconSelection: emoji.emoji,
-    });
-    return widget.editorState.apply(transaction);
-  }
-
-  Future<void> _removeIcon() async {
-    final transaction = widget.editorState.transaction;
-    transaction.updateNode(widget.node, {
-      CoverBlockKeys.iconSelection: "",
-      CoverBlockKeys.selectionType:
-          widget.node.attributes[CoverBlockKeys.selectionType],
-      CoverBlockKeys.selection:
-          widget.node.attributes[CoverBlockKeys.selection],
-    });
-    return widget.editorState.apply(transaction);
-  }
-
-  void setHidden(bool value) {
-    if (isHidden == value) return;
-    setState(() {
-      isHidden = value;
-    });
-  }
-}
-
-class _CoverImage extends StatefulWidget {
-  const _CoverImage({
-    required this.editorState,
-    required this.node,
-    required this.onCoverChanged,
-  });
-
-  final Node node;
-  final EditorState editorState;
-  final Function(
-    CoverSelectionType selectionType,
-    dynamic selection,
-  ) onCoverChanged;
-  @override
-  State<_CoverImage> createState() => _CoverImageState();
-}
-
-class _CoverImageState extends State<_CoverImage> {
-  final popoverController = PopoverController();
-
-  CoverSelectionType get selectionType => CoverSelectionType.fromString(
-        widget.node.attributes[CoverBlockKeys.selectionType],
-      );
-  Color get color {
-    final hex = widget.node.attributes[CoverBlockKeys.selection] as String?;
-    return hex?.toColor() ?? Colors.white;
-  }
-
-  bool get hasIcon =>
-      widget.node.attributes[CoverBlockKeys.iconSelection] == null
-          ? false
-          : widget.node.attributes[CoverBlockKeys.iconSelection].isNotEmpty;
-  bool isOverlayButtonsHidden = true;
-  PopoverController iconPopoverController = PopoverController();
-  bool get hasCover =>
-      selectionType == CoverSelectionType.initial ? false : true;
-
-  @override
-  Widget build(BuildContext context) {
-    return Stack(
-      alignment: Alignment.bottomLeft,
-      children: [
-        Container(
-          alignment: Alignment.topCenter,
-          height: !hasCover
-              ? 0
-              : hasIcon
-                  ? 320
-                  : 280,
-          child: _buildCoverImage(context, widget.editorState),
-        ),
-        hasIcon
-            ? Positioned(
-                left: 80,
-                bottom: !hasCover ? 30 : 40,
-                child: AppFlowyPopover(
-                  offset: const Offset(100, 0),
-                  controller: iconPopoverController,
-                  direction: PopoverDirection.bottomWithCenterAligned,
-                  constraints: BoxConstraints.loose(const Size(320, 380)),
-                  margin: EdgeInsets.zero,
-                  child: EmojiIconWidget(
-                    emoji: widget.node.attributes[CoverBlockKeys.iconSelection],
-                  ),
-                  popupBuilder: (BuildContext popoverContext) {
-                    return EmojiPopover(
-                      node: widget.node,
-                      showRemoveButton: hasIcon,
-                      removeIcon: _removeIcon,
-                      editorState: widget.editorState,
-                      onEmojiChanged: (Emoji emoji) {
-                        _insertIcon(emoji);
-                        iconPopoverController.close();
-                      },
-                    );
-                  },
-                ),
-              )
-            : Container(),
-        hasIcon && selectionType != CoverSelectionType.initial
-            ? Container()
-            : _AddCoverButton(
-                onTap: () {
-                  _insertCover(
-                    CoverSelectionType.asset,
-                    builtInAssetImages.first,
-                  );
-                },
-                node: widget.node,
-                editorState: widget.editorState,
-                hasIcon: hasIcon,
-                selectionType: selectionType,
-                iconPopoverController: iconPopoverController,
-              ),
-      ],
-    );
-  }
-
-  Future<void> _insertCover(CoverSelectionType type, dynamic cover) async {
-    final transaction = widget.editorState.transaction;
-    transaction.updateNode(widget.node, {
-      CoverBlockKeys.selectionType: type.toString(),
-      CoverBlockKeys.selection: cover,
-      CoverBlockKeys.iconSelection:
-          widget.node.attributes[CoverBlockKeys.iconSelection]
-    });
-    return widget.editorState.apply(transaction);
-  }
-
-  Future<void> _insertIcon(Emoji emoji) async {
-    final transaction = widget.editorState.transaction;
-    transaction.updateNode(widget.node, {
-      CoverBlockKeys.selectionType:
-          widget.node.attributes[CoverBlockKeys.selectionType],
-      CoverBlockKeys.selection:
-          widget.node.attributes[CoverBlockKeys.selection],
-      CoverBlockKeys.iconSelection: emoji.emoji,
-    });
-    return widget.editorState.apply(transaction);
-  }
-
-  Future<void> _removeIcon() async {
-    final transaction = widget.editorState.transaction;
-    transaction.updateNode(widget.node, {
-      CoverBlockKeys.iconSelection: "",
-      CoverBlockKeys.selectionType:
-          widget.node.attributes[CoverBlockKeys.selectionType],
-      CoverBlockKeys.selection:
-          widget.node.attributes[CoverBlockKeys.selection],
-    });
-    return widget.editorState.apply(transaction);
-  }
-
-  Widget _buildCoverOverlayButtons(BuildContext context) {
-    return Positioned(
-      bottom: 20,
-      right: 50,
-      child: Row(
-        mainAxisSize: MainAxisSize.min,
-        children: [
-          AppFlowyPopover(
-            onClose: () {
-              setOverlayButtonsHidden(true);
-            },
-            offset: const Offset(-125, 10),
-            controller: popoverController,
-            direction: PopoverDirection.bottomWithCenterAligned,
-            constraints: BoxConstraints.loose(const Size(380, 450)),
-            margin: EdgeInsets.zero,
-            child: Visibility(
-              maintainState: true,
-              maintainAnimation: true,
-              maintainSize: true,
-              visible: !isOverlayButtonsHidden,
-              child: RoundedTextButton(
-                onPressed: () {
-                  popoverController.show();
-                  setOverlayButtonsHidden(true);
-                },
-                hoverColor: Theme.of(context).colorScheme.surface,
-                textColor: Theme.of(context).colorScheme.tertiary,
-                fillColor:
-                    Theme.of(context).colorScheme.surface.withOpacity(0.5),
-                width: 120,
-                height: 28,
-                title: LocaleKeys.document_plugins_cover_changeCover.tr(),
-              ),
-            ),
-            popupBuilder: (BuildContext popoverContext) {
-              return ChangeCoverPopover(
-                node: widget.node,
-                editorState: widget.editorState,
-                onCoverChanged: widget.onCoverChanged,
-              );
-            },
-          ),
-          const SizedBox(width: 10),
-          Visibility(
-            maintainAnimation: true,
-            maintainSize: true,
-            maintainState: true,
-            visible: !isOverlayButtonsHidden,
-            child: FlowyIconButton(
-              hoverColor: Theme.of(context).colorScheme.surface,
-              fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
-              iconPadding: const EdgeInsets.all(5),
-              width: 28,
-              icon: svgWidget(
-                'editor/delete',
-                color: Theme.of(context).colorScheme.tertiary,
-              ),
-              onPressed: () {
-                widget.onCoverChanged(CoverSelectionType.initial, null);
-              },
-            ),
-          ),
-        ],
-      ),
-    );
-  }
-
-  Widget _buildCoverImage(BuildContext context, EditorState editorState) {
-    const height = 250.0;
-    final Widget coverImage;
-    switch (selectionType) {
-      case CoverSelectionType.file:
-        final imageFile =
-            File(widget.node.attributes[CoverBlockKeys.selection]);
-        if (!imageFile.existsSync()) {
-          // reset cover state
-          WidgetsBinding.instance.addPostFrameCallback((_) {
-            widget.onCoverChanged(CoverSelectionType.initial, null);
-          });
-          coverImage = const SizedBox();
-          break;
-        }
-        coverImage = Image.file(
-          imageFile,
-          fit: BoxFit.cover,
-        );
-        break;
-      case CoverSelectionType.asset:
-        coverImage = Image.asset(
-          widget.node.attributes[CoverBlockKeys.selection],
-          fit: BoxFit.cover,
-        );
-        break;
-      case CoverSelectionType.color:
-        coverImage = Container(
-          decoration: BoxDecoration(
-            color: color,
-            borderRadius: Corners.s6Border,
-          ),
-          alignment: Alignment.center,
-        );
-        break;
-      case CoverSelectionType.initial:
-        coverImage = const SizedBox();
-        break;
-    }
-// OverflowBox needs to be wraped by a widget with constraints(or from its parent) first,otherwise it will occur an error
-    return MouseRegion(
-      onEnter: (event) {
-        setOverlayButtonsHidden(false);
-      },
-      onExit: (event) {
-        setOverlayButtonsHidden(true);
-      },
-      child: SizedBox(
-        height: height,
-        child: Stack(
-          children: [
-            Container(
-              padding: const EdgeInsets.only(bottom: 10),
-              height: double.infinity,
-              width: double.infinity,
-              child: coverImage,
-            ),
-            hasCover
-                ? _buildCoverOverlayButtons(context)
-                : const SizedBox.shrink()
-          ],
-        ),
-      ),
-    );
-  }
-
-  void setOverlayButtonsHidden(bool value) {
-    if (isOverlayButtonsHidden == value) return;
-    setState(() {
-      isOverlayButtonsHidden = value;
-    });
-  }
-}

+ 54 - 37
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/change_cover_popover.dart → frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart

@@ -25,7 +25,7 @@ class ChangeCoverPopover extends StatefulWidget {
   final EditorState editorState;
   final Node node;
   final Function(
-    CoverSelectionType selectionType,
+    CoverType selectionType,
     String selection,
   ) onCoverChanged;
 
@@ -149,10 +149,10 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
               LocaleKeys.document_plugins_cover_clearAll.tr(),
               fontColor: Theme.of(context).colorScheme.tertiary,
               onPressed: () async {
-                final hasFileImageCover = CoverSelectionType.fromString(
-                      widget.node.attributes[CoverBlockKeys.selectionType],
+                final hasFileImageCover = CoverType.fromString(
+                      widget.node.attributes[DocumentHeaderBlockKeys.coverType],
                     ) ==
-                    CoverSelectionType.file;
+                    CoverType.file;
                 final changeCoverBloc = context.read<ChangeCoverPopoverBloc>();
                 if (hasFileImageCover) {
                   await showDialog(
@@ -196,7 +196,7 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
         return InkWell(
           onTap: () {
             widget.onCoverChanged(
-              CoverSelectionType.asset,
+              CoverType.asset,
               builtInAssetImages[index],
             );
           },
@@ -220,14 +220,14 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
       pickerBackgroundColor: theme.cardColor,
       pickerItemHoverColor: theme.hoverColor,
       selectedBackgroundColorHex:
-          widget.node.attributes[CoverBlockKeys.selectionType] ==
-                  CoverSelectionType.color.toString()
-              ? widget.node.attributes[CoverBlockKeys.selection]
+          widget.node.attributes[DocumentHeaderBlockKeys.coverType] ==
+                  CoverType.color.toString()
+              ? widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]
               : 'ffffff',
       backgroundColorOptions:
           _generateBackgroundColorOptions(widget.editorState),
       onSubmittedBackgroundColorHex: (color) {
-        widget.onCoverChanged(CoverSelectionType.color, color);
+        widget.onCoverChanged(CoverType.color, color);
         setState(() {});
       },
     );
@@ -276,16 +276,16 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
               return ImageGridItem(
                 onImageSelect: () {
                   widget.onCoverChanged(
-                    CoverSelectionType.file,
+                    CoverType.file,
                     images[index - 1],
                   );
                 },
                 onImageDelete: () async {
                   final changeCoverBloc =
                       context.read<ChangeCoverPopoverBloc>();
-                  final deletingCurrentCover =
-                      widget.node.attributes[CoverBlockKeys.selection] ==
-                          images[index - 1];
+                  final deletingCurrentCover = widget.node
+                          .attributes[DocumentHeaderBlockKeys.coverDetails] ==
+                      images[index - 1];
                   if (deletingCurrentCover) {
                     await showDialog(
                       context: context,
@@ -481,36 +481,63 @@ class _CoverColorPickerState extends State<CoverColorPicker> {
     scrollController.dispose();
   }
 
-  Widget _buildColorItem(ColorOption option, bool isChecked) {
+  Widget _buildColorItems(List<ColorOption> options, String? selectedColor) {
+    return Row(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: options
+          .map(
+            (e) => ColorItem(
+              option: e,
+              isChecked: e.colorHex == selectedColor,
+              hoverColor: widget.pickerItemHoverColor,
+              onTap: widget.onSubmittedBackgroundColorHex,
+            ),
+          )
+          .toList(),
+    );
+  }
+}
+
+@visibleForTesting
+class ColorItem extends StatelessWidget {
+  final ColorOption option;
+  final bool isChecked;
+  final Color hoverColor;
+  final void Function(String) onTap;
+  const ColorItem({
+    required this.option,
+    required this.isChecked,
+    required this.hoverColor,
+    required this.onTap,
+    super.key,
+  });
+
+  @override
+  Widget build(BuildContext context) {
     return InkWell(
       customBorder: const RoundedRectangleBorder(
         borderRadius: Corners.s6Border,
       ),
-      hoverColor: widget.pickerItemHoverColor,
-      onTap: () {
-        widget.onSubmittedBackgroundColorHex(option.colorHex);
-      },
+      hoverColor: hoverColor,
+      onTap: () => onTap(option.colorHex),
       child: Padding(
         padding: const EdgeInsets.only(right: 10.0),
         child: SizedBox.square(
-          dimension: isChecked ? 24 : 25,
+          dimension: 25,
           child: Container(
             decoration: BoxDecoration(
               color: option.colorHex.toColor(),
-              border: isChecked
-                  ? Border.all(
-                      color: const Color(0xFFFFFFFF),
-                      width: 2.0,
-                    )
-                  : null,
               shape: BoxShape.circle,
             ),
             child: isChecked
                 ? SizedBox.square(
-                    dimension: 24,
                     child: Container(
-                      margin: const EdgeInsets.all(4),
+                      margin: const EdgeInsets.all(1),
                       decoration: BoxDecoration(
+                        border: Border.all(
+                          color: Theme.of(context).cardColor,
+                          width: 3.0,
+                        ),
                         color: option.colorHex.toColor(),
                         shape: BoxShape.circle,
                       ),
@@ -522,14 +549,4 @@ class _CoverColorPickerState extends State<CoverColorPicker> {
       ),
     );
   }
-
-  Widget _buildColorItems(List<ColorOption> options, String? selectedColor) {
-    return Row(
-      crossAxisAlignment: CrossAxisAlignment.start,
-      mainAxisAlignment: MainAxisAlignment.start,
-      children: options
-          .map((e) => _buildColorItem(e, e.colorHex == selectedColor))
-          .toList(),
-    );
-  }
 }

+ 8 - 8
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/change_cover_popover_bloc.dart → frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor_bloc.dart

@@ -1,14 +1,14 @@
 import 'dart:async';
 import 'dart:io';
 
-import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/change_cover_popover.dart';
-import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/cover_node_widget.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:freezed_annotation/freezed_annotation.dart';
 import 'package:shared_preferences/shared_preferences.dart';
 
-part 'change_cover_popover_bloc.freezed.dart';
+part 'cover_editor_bloc.freezed.dart';
 
 class ChangeCoverPopoverBloc
     extends Bloc<ChangeCoverPopoverEvent, ChangeCoverPopoverState> {
@@ -32,7 +32,7 @@ class ChangeCoverPopoverBloc
         deleteImage: (DeleteImage deleteImage) async {
           final currentState = state;
           final currentlySelectedImage =
-              node.attributes[CoverBlockKeys.selection];
+              node.attributes[DocumentHeaderBlockKeys.coverDetails];
           if (currentState is Loaded) {
             await _deleteImageInStorage(deleteImage.path);
             if (currentlySelectedImage == deleteImage.path) {
@@ -48,7 +48,7 @@ class ChangeCoverPopoverBloc
         clearAllImages: (ClearAllImages clearAllImages) async {
           final currentState = state;
           final currentlySelectedImage =
-              node.attributes[CoverBlockKeys.selection];
+              node.attributes[DocumentHeaderBlockKeys.coverDetails];
 
           if (currentState is Loaded) {
             for (final image in currentState.imageNames) {
@@ -90,9 +90,9 @@ class ChangeCoverPopoverBloc
   Future<void> _removeCoverImageFromNode() async {
     final transaction = editorState.transaction;
     transaction.updateNode(node, {
-      CoverBlockKeys.selectionType: CoverSelectionType.initial.toString(),
-      CoverBlockKeys.iconSelection:
-          node.attributes[CoverBlockKeys.iconSelection]
+      DocumentHeaderBlockKeys.coverType: CoverType.none.toString(),
+      DocumentHeaderBlockKeys.icon:
+          node.attributes[DocumentHeaderBlockKeys.icon]
     });
     return editorState.apply(transaction);
   }

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/cover_image_picker.dart → frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart

@@ -1,6 +1,6 @@
 import 'dart:io';
 import 'package:appflowy/generated/locale_keys.g.dart';
-import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/cover_image_picker_bloc.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';

+ 3 - 2
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/cover_image_picker_bloc.dart → frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart

@@ -13,9 +13,10 @@ import 'package:dartz/dartz.dart';
 import 'package:http/http.dart' as http;
 import 'package:shared_preferences/shared_preferences.dart';
 import 'package:path/path.dart' as p;
-import 'change_cover_popover.dart';
 
-part 'cover_image_picker_bloc.freezed.dart';
+import 'cover_editor.dart';
+
+part 'custom_cover_picker_bloc.freezed.dart';
 
 class CoverImagePickerBloc
     extends Bloc<CoverImagePickerEvent, CoverImagePickerState> {

+ 507 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart

@@ -0,0 +1,507 @@
+import 'dart:io';
+
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart';
+import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg;
+import 'package:appflowy_popover/appflowy_popover.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/image.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/widget/rounded_button.dart';
+import 'package:flutter/material.dart';
+
+import 'cover_editor.dart';
+import 'emoji_icon_widget.dart';
+import 'emoji_popover.dart';
+
+const double kCoverHeight = 250.0;
+const double kIconHeight = 60.0;
+const double kToolbarHeight = 40.0; // with padding to the top
+
+class DocumentHeaderBlockKeys {
+  const DocumentHeaderBlockKeys._();
+
+  static const String coverType = 'cover_selection_type';
+  static const String coverDetails = 'cover_selection';
+  static const String icon = 'selected_icon';
+}
+
+enum CoverType {
+  none,
+  color,
+  file,
+  asset;
+
+  static CoverType fromString(String? value) {
+    if (value == null) {
+      return CoverType.none;
+    }
+    return CoverType.values.firstWhere(
+      (e) => e.toString() == value,
+      orElse: () => CoverType.none,
+    );
+  }
+}
+
+class DocumentHeaderNodeWidgetBuilder implements NodeWidgetBuilder {
+  @override
+  Widget build(NodeWidgetContext<Node> context) {
+    return DocumentHeaderNodeWidget(
+      key: context.node.key,
+      node: context.node,
+      editorState: context.editorState,
+    );
+  }
+
+  @override
+  NodeValidator<Node> get nodeValidator => (_) => true;
+}
+
+class DocumentHeaderNodeWidget extends StatefulWidget {
+  const DocumentHeaderNodeWidget({
+    required this.node,
+    required this.editorState,
+    super.key,
+  });
+
+  final Node node;
+  final EditorState editorState;
+
+  @override
+  State<DocumentHeaderNodeWidget> createState() =>
+      _DocumentHeaderNodeWidgetState();
+}
+
+class _DocumentHeaderNodeWidgetState extends State<DocumentHeaderNodeWidget> {
+  CoverType get coverType => CoverType.fromString(
+        widget.node.attributes[DocumentHeaderBlockKeys.coverType],
+      );
+  String? get coverDetails =>
+      widget.node.attributes[DocumentHeaderBlockKeys.coverDetails];
+  String get icon => widget.node.attributes[DocumentHeaderBlockKeys.icon];
+  bool get hasIcon =>
+      widget.node.attributes[DocumentHeaderBlockKeys.icon]?.isNotEmpty ?? false;
+  bool get hasCover => coverType != CoverType.none;
+
+  @override
+  void initState() {
+    super.initState();
+    widget.node.addListener(_reload);
+  }
+
+  @override
+  void dispose() {
+    widget.node.removeListener(_reload);
+    super.dispose();
+  }
+
+  void _reload() => setState(() {});
+
+  @override
+  Widget build(BuildContext context) {
+    return Stack(
+      children: [
+        SizedBox(
+          height: _calculateOverallHeight(),
+          child: DocumentHeaderToolbar(
+            onCoverChanged: _saveCover,
+            node: widget.node,
+            editorState: widget.editorState,
+            hasCover: hasCover,
+            hasIcon: hasIcon,
+          ),
+        ),
+        if (hasCover)
+          DocumentCover(
+            editorState: widget.editorState,
+            node: widget.node,
+            coverType: coverType,
+            coverDetails: coverDetails,
+            onCoverChanged: (type, details) =>
+                _saveCover(cover: (type, details)),
+          ),
+        if (hasIcon)
+          Positioned(
+            left: 80,
+            // if hasCover, there shouldn't be icons present so the icon can
+            // be closer to the bottom.
+            bottom:
+                hasCover ? kToolbarHeight - kIconHeight / 2 : kToolbarHeight,
+            child: DocumentIcon(
+              editorState: widget.editorState,
+              node: widget.node,
+              icon: icon,
+              onIconChanged: (icon) => _saveCover(icon: icon),
+            ),
+          ),
+      ],
+    );
+  }
+
+  double _calculateOverallHeight() {
+    switch ((hasIcon, hasCover)) {
+      case (true, true):
+        return kCoverHeight + kToolbarHeight;
+      case (true, false):
+        return 50 + kIconHeight + kToolbarHeight;
+      case (false, true):
+        return kCoverHeight + kToolbarHeight;
+      case (false, false):
+        return kToolbarHeight;
+    }
+  }
+
+  Future<void> _saveCover({(CoverType, String?)? cover, String? icon}) {
+    final transaction = widget.editorState.transaction;
+    final Map<String, dynamic> attributes = {
+      DocumentHeaderBlockKeys.coverType:
+          widget.node.attributes[DocumentHeaderBlockKeys.coverType],
+      DocumentHeaderBlockKeys.coverDetails:
+          widget.node.attributes[DocumentHeaderBlockKeys.coverDetails],
+      DocumentHeaderBlockKeys.icon:
+          widget.node.attributes[DocumentHeaderBlockKeys.icon]
+    };
+    if (cover != null) {
+      attributes[DocumentHeaderBlockKeys.coverType] = cover.$1.toString();
+      attributes[DocumentHeaderBlockKeys.coverDetails] = cover.$2;
+    }
+    if (icon != null) {
+      attributes[DocumentHeaderBlockKeys.icon] = icon;
+    }
+
+    transaction.updateNode(widget.node, attributes);
+    return widget.editorState.apply(transaction);
+  }
+}
+
+@visibleForTesting
+class DocumentHeaderToolbar extends StatefulWidget {
+  final Node node;
+  final EditorState editorState;
+  final bool hasCover;
+  final bool hasIcon;
+  final Future<void> Function({(CoverType, String?)? cover, String? icon})
+      onCoverChanged;
+
+  const DocumentHeaderToolbar({
+    required this.node,
+    required this.editorState,
+    required this.hasCover,
+    required this.hasIcon,
+    required this.onCoverChanged,
+    super.key,
+  });
+
+  @override
+  State<DocumentHeaderToolbar> createState() => _DocumentHeaderToolbarState();
+}
+
+class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
+  bool isHidden = true;
+  bool isPopoverOpen = false;
+
+  final PopoverController _popoverController = PopoverController();
+
+  @override
+  Widget build(BuildContext context) {
+    return MouseRegion(
+      onEnter: (event) => setHidden(false),
+      onExit: (event) {
+        if (!isPopoverOpen) {
+          setHidden(true);
+        }
+      },
+      opaque: false,
+      child: Container(
+        alignment: Alignment.bottomLeft,
+        width: double.infinity,
+        padding: const EdgeInsets.symmetric(horizontal: 80),
+        child: SizedBox(
+          height: 28,
+          child: Row(
+            crossAxisAlignment: CrossAxisAlignment.stretch,
+            children: buildRowChildren(),
+          ),
+        ),
+      ),
+    );
+  }
+
+  List<Widget> buildRowChildren() {
+    if (isHidden || widget.hasCover && widget.hasIcon) {
+      return [];
+    }
+    final List<Widget> children = [];
+
+    if (!widget.hasCover) {
+      children.add(
+        FlowyButton(
+          leftIconSize: const Size.square(18),
+          onTap: () => widget.onCoverChanged(
+            cover: (CoverType.asset, builtInAssetImages.first),
+          ),
+          useIntrinsicWidth: true,
+          leftIcon: const FlowySvg(name: 'editor/image'),
+          text: FlowyText.regular(
+            LocaleKeys.document_plugins_cover_addCover.tr(),
+          ),
+        ),
+      );
+    }
+
+    if (widget.hasIcon) {
+      children.add(
+        FlowyButton(
+          leftIconSize: const Size.square(18),
+          onTap: () => widget.onCoverChanged(icon: ""),
+          useIntrinsicWidth: true,
+          leftIcon: const Icon(
+            Icons.emoji_emotions_outlined,
+            size: 18,
+          ),
+          text: FlowyText.regular(
+            LocaleKeys.document_plugins_cover_removeIcon.tr(),
+          ),
+        ),
+      );
+    } else {
+      children.add(
+        AppFlowyPopover(
+          onClose: () => isPopoverOpen = false,
+          controller: _popoverController,
+          offset: const Offset(0, 8),
+          direction: PopoverDirection.bottomWithCenterAligned,
+          constraints: BoxConstraints.loose(const Size(320, 380)),
+          child: FlowyButton(
+            leftIconSize: const Size.square(18),
+            useIntrinsicWidth: true,
+            leftIcon: const Icon(
+              Icons.emoji_emotions_outlined,
+              size: 18,
+            ),
+            text: FlowyText.regular(
+              LocaleKeys.document_plugins_cover_addIcon.tr(),
+            ),
+          ),
+          popupBuilder: (BuildContext popoverContext) {
+            isPopoverOpen = true;
+            return EmojiPopover(
+              showRemoveButton: widget.hasIcon,
+              removeIcon: () {
+                widget.onCoverChanged(icon: "");
+                _popoverController.close();
+              },
+              node: widget.node,
+              editorState: widget.editorState,
+              onEmojiChanged: (Emoji emoji) {
+                widget.onCoverChanged(icon: emoji.emoji);
+                _popoverController.close();
+              },
+            );
+          },
+        ),
+      );
+    }
+
+    return children;
+  }
+
+  void setHidden(bool value) {
+    if (isHidden == value) return;
+    setState(() {
+      isHidden = value;
+    });
+  }
+}
+
+@visibleForTesting
+class DocumentCover extends StatefulWidget {
+  final Node node;
+  final EditorState editorState;
+  final CoverType coverType;
+  final String? coverDetails;
+  final Future<void> Function(CoverType type, String? details) onCoverChanged;
+
+  const DocumentCover({
+    required this.editorState,
+    required this.node,
+    required this.coverType,
+    required this.onCoverChanged,
+    this.coverDetails,
+    super.key,
+  });
+
+  @override
+  State<DocumentCover> createState() => DocumentCoverState();
+}
+
+class DocumentCoverState extends State<DocumentCover> {
+  bool isOverlayButtonsHidden = true;
+  bool isPopoverOpen = false;
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      height: kCoverHeight,
+      child: MouseRegion(
+        onEnter: (event) => setOverlayButtonsHidden(false),
+        onExit: (event) =>
+            setOverlayButtonsHidden(isPopoverOpen ? false : true),
+        child: Stack(
+          children: [
+            SizedBox(
+              height: double.infinity,
+              width: double.infinity,
+              child: _buildCoverImage(),
+            ),
+            if (!isOverlayButtonsHidden) _buildCoverOverlayButtons(context)
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _buildCoverImage() {
+    switch (widget.coverType) {
+      case CoverType.file:
+        final imageFile = File(widget.coverDetails ?? "");
+        if (!imageFile.existsSync()) {
+          WidgetsBinding.instance.addPostFrameCallback((_) {
+            widget.onCoverChanged(CoverType.none, null);
+          });
+          return const SizedBox.shrink();
+        }
+        return Image.file(
+          imageFile,
+          fit: BoxFit.cover,
+        );
+      case CoverType.asset:
+        return Image.asset(
+          widget.coverDetails!,
+          fit: BoxFit.cover,
+        );
+      case CoverType.color:
+        final color = widget.coverDetails?.toColor() ?? Colors.white;
+        return Container(color: color);
+      case CoverType.none:
+        return const SizedBox.shrink();
+    }
+  }
+
+  Widget _buildCoverOverlayButtons(BuildContext context) {
+    return Positioned(
+      bottom: 20,
+      right: 50,
+      child: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          AppFlowyPopover(
+            offset: const Offset(0, 8),
+            direction: PopoverDirection.bottomWithCenterAligned,
+            constraints: BoxConstraints.loose(const Size(380, 450)),
+            margin: EdgeInsets.zero,
+            onClose: () => isPopoverOpen = false,
+            child: RoundedTextButton(
+              hoverColor: Theme.of(context).colorScheme.surface,
+              textColor: Theme.of(context).colorScheme.tertiary,
+              fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
+              width: 120,
+              height: 28,
+              title: LocaleKeys.document_plugins_cover_changeCover.tr(),
+            ),
+            popupBuilder: (BuildContext popoverContext) {
+              isPopoverOpen = true;
+              return ChangeCoverPopover(
+                node: widget.node,
+                editorState: widget.editorState,
+                onCoverChanged: (cover, selection) =>
+                    widget.onCoverChanged(cover, selection),
+              );
+            },
+          ),
+          const HSpace(10),
+          DeleteCoverButton(
+            onTap: () => widget.onCoverChanged(CoverType.none, null),
+          ),
+        ],
+      ),
+    );
+  }
+
+  void setOverlayButtonsHidden(bool value) {
+    if (isOverlayButtonsHidden == value) return;
+    setState(() {
+      isOverlayButtonsHidden = value;
+    });
+  }
+}
+
+@visibleForTesting
+class DeleteCoverButton extends StatelessWidget {
+  final VoidCallback onTap;
+  const DeleteCoverButton({required this.onTap, super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return FlowyIconButton(
+      hoverColor: Theme.of(context).colorScheme.surface,
+      fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
+      iconPadding: const EdgeInsets.all(5),
+      width: 28,
+      icon: svgWidget(
+        'editor/delete',
+        color: Theme.of(context).colorScheme.tertiary,
+      ),
+      onPressed: onTap,
+    );
+  }
+}
+
+@visibleForTesting
+class DocumentIcon extends StatefulWidget {
+  final Node node;
+  final EditorState editorState;
+  final String icon;
+  final Future<void> Function(String icon) onIconChanged;
+
+  const DocumentIcon({
+    required this.node,
+    required this.editorState,
+    required this.icon,
+    required this.onIconChanged,
+    super.key,
+  });
+
+  @override
+  State<DocumentIcon> createState() => _DocumentIconState();
+}
+
+class _DocumentIconState extends State<DocumentIcon> {
+  final PopoverController _popoverController = PopoverController();
+
+  @override
+  Widget build(BuildContext context) {
+    return AppFlowyPopover(
+      direction: PopoverDirection.bottomWithCenterAligned,
+      controller: _popoverController,
+      offset: const Offset(0, 8),
+      constraints: BoxConstraints.loose(const Size(320, 380)),
+      child: EmojiIconWidget(emoji: widget.icon),
+      popupBuilder: (BuildContext popoverContext) {
+        return EmojiPopover(
+          node: widget.node,
+          showRemoveButton: true,
+          removeIcon: () {
+            widget.onIconChanged("");
+            _popoverController.close();
+          },
+          editorState: widget.editorState,
+          onEmojiChanged: (Emoji emoji) {
+            widget.onIconChanged(emoji.emoji);
+            _popoverController.close();
+          },
+        );
+      },
+    );
+  }
+}

+ 2 - 5
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/emoji_icon_widget.dart → frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart

@@ -5,12 +5,10 @@ class EmojiIconWidget extends StatefulWidget {
   const EmojiIconWidget({
     super.key,
     required this.emoji,
-    this.size = 80,
     this.emojiSize = 60,
   });
 
   final String emoji;
-  final double size;
   final double emojiSize;
 
   @override
@@ -25,12 +23,11 @@ class _EmojiIconWidgetState extends State<EmojiIconWidget> {
     return MouseRegion(
       onEnter: (_) => setHidden(false),
       onExit: (_) => setHidden(true),
+      cursor: SystemMouseCursors.click,
       child: Container(
-        height: widget.size,
-        width: widget.size,
         decoration: BoxDecoration(
           color: !hover
-              ? Theme.of(context).colorScheme.inverseSurface
+              ? Theme.of(context).colorScheme.inverseSurface.withOpacity(0.5)
               : Colors.transparent,
           borderRadius: BorderRadius.circular(8),
         ),

+ 35 - 35
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/emoji_popover.dart → frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_popover.dart

@@ -30,38 +30,35 @@ class EmojiPopover extends StatefulWidget {
 class _EmojiPopoverState extends State<EmojiPopover> {
   @override
   Widget build(BuildContext context) {
-    return Padding(
-      padding: const EdgeInsets.all(15),
-      child: Column(
-        children: [
-          if (widget.showRemoveButton)
-            Padding(
-              padding: const EdgeInsets.only(bottom: 4.0),
-              child: Align(
-                alignment: Alignment.centerRight,
-                child: DeleteButton(onTap: widget.removeIcon),
-              ),
+    return Column(
+      children: [
+        if (widget.showRemoveButton)
+          Padding(
+            padding: const EdgeInsets.only(bottom: 4.0),
+            child: Align(
+              alignment: Alignment.centerRight,
+              child: DeleteButton(onTap: widget.removeIcon),
             ),
-          Expanded(
-            child: EmojiPicker(
-              onEmojiSelected: (category, emoji) {
-                widget.onEmojiChanged(emoji);
-              },
-              config: Config(
-                columns: 8,
-                emojiSizeMax: 28,
-                bgColor: Colors.transparent,
-                iconColor: Theme.of(context).iconTheme.color!,
-                iconColorSelected: Theme.of(context).colorScheme.onSurface,
-                selectedHoverColor: Theme.of(context).colorScheme.secondary,
-                progressIndicatorColor: Theme.of(context).iconTheme.color!,
-                buttonMode: ButtonMode.CUPERTINO,
-                initCategory: Category.RECENT,
-              ),
+          ),
+        Expanded(
+          child: EmojiPicker(
+            onEmojiSelected: (category, emoji) {
+              widget.onEmojiChanged(emoji);
+            },
+            config: Config(
+              columns: 8,
+              emojiSizeMax: 28,
+              bgColor: Colors.transparent,
+              iconColor: Theme.of(context).iconTheme.color!,
+              iconColorSelected: Theme.of(context).colorScheme.onSurface,
+              selectedHoverColor: Theme.of(context).colorScheme.secondary,
+              progressIndicatorColor: Theme.of(context).iconTheme.color!,
+              buttonMode: ButtonMode.CUPERTINO,
+              initCategory: Category.RECENT,
             ),
           ),
-        ],
-      ),
+        ),
+      ],
     );
   }
 }
@@ -72,13 +69,16 @@ class DeleteButton extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return FlowyButton(
-      onTap: onTap,
-      useIntrinsicWidth: true,
-      text: FlowyText(
-        LocaleKeys.document_plugins_cover_removeIcon.tr(),
+    return SizedBox(
+      height: 28,
+      child: FlowyButton(
+        onTap: onTap,
+        useIntrinsicWidth: true,
+        text: FlowyText(
+          LocaleKeys.document_plugins_cover_removeIcon.tr(),
+        ),
+        leftIcon: const FlowySvg(name: 'editor/delete'),
       ),
-      leftIcon: const FlowySvg(name: 'editor/delete'),
     );
   }
 }

+ 3 - 3
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart

@@ -1,9 +1,9 @@
 export 'callout/callout_block_component.dart';
 export 'code_block/code_block_component.dart';
 export 'code_block/code_block_shortcut_event.dart';
-export 'cover/change_cover_popover_bloc.dart';
-export 'cover/cover_node_widget.dart';
-export 'cover/cover_image_picker.dart';
+export 'header/cover_editor_bloc.dart';
+export 'header/document_header_node_widget.dart';
+export 'header/custom_cover_picker.dart';
 export 'emoji_picker/emoji_menu_item.dart';
 export 'extensions/flowy_tint_extension.dart';
 export 'database/inline_database_menu_item.dart';

+ 1 - 1
frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart

@@ -81,7 +81,7 @@ class FlowyIconButton extends StatelessWidget {
               hoverColor: hoverColor,
               foregroundColorOnHover:
                   iconColorOnHover ?? Theme.of(context).iconTheme.color,
-              backgroundColor: fillColor ?? Colors.transparent,
+              backgroundColor: Colors.transparent,
             ),
             child: Padding(
               padding: iconPadding,