Jelajahi Sumber

Implement cover plugin 1868 (#1897)

* implement_cover_plugin_#1868

* code cleanup

* fix: CI issue fix

* fix: cover plugin implementation finalized

* fix: localization fixes

* fix: added add cover button

* chore: optimize the cover plugin code

* feat: auto hide the add button and cover buttons when leaving

---------

Co-authored-by: Lucas.Xu <[email protected]>
Muhammad Rizwan 2 tahun lalu
induk
melakukan
f1316acfcc

TEMPAT SAMPAH
frontend/appflowy_flutter/assets/images/Local Disk (C) - Shortcut.lnk


TEMPAT SAMPAH
frontend/appflowy_flutter/assets/images/app_flowy_abstract_cover_1.jpg


TEMPAT SAMPAH
frontend/appflowy_flutter/assets/images/app_flowy_abstract_cover_2.jpg


+ 10 - 2
frontend/appflowy_flutter/assets/translations/en.json

@@ -353,7 +353,15 @@
       "smartEditFixSpelling": "Fix spelling",
       "smartEditSummarize": "Summarize",
       "smartEditCouldNotFetchResult": "Could not fetch result from OpenAI",
-      "smartEditCouldNotFetchKey": "Could not fetch OpenAI key"
+      "smartEditCouldNotFetchKey": "Could not fetch OpenAI key",
+      "cover": {
+        "changeCover": "Change Cover",
+        "colors": "Colors",
+        "images": "Images",
+        "abstract": "Abstract",
+        "addCover": "Add Cover",
+        "addLocalImage": "Add local image"
+      }
     }
   },
   "board": {
@@ -371,4 +379,4 @@
       "nextMonth": "Next Month"
     }
   }
-}
+}

+ 22 - 2
frontend/appflowy_flutter/lib/plugins/document/document_page.dart

@@ -1,5 +1,6 @@
 import 'package:appflowy/plugins/document/presentation/plugins/board/board_menu_item.dart';
 import 'package:appflowy/plugins/document/presentation/plugins/board/board_node_widget.dart';
+import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_node_widget.dart';
 import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_menu_item.dart';
 import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_node_widget.dart';
 import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart';
@@ -9,6 +10,7 @@ import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/sm
 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';
+import 'package:dartz/dartz.dart' as dartz;
 import 'package:flowy_infra_ui/widget/error_page.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
@@ -126,9 +128,11 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
   @override
   Widget build(BuildContext context) {
     final theme = Theme.of(context);
+    final autoFocusParamters = _autoFocusParamters();
     final editor = AppFlowyEditor(
       editorState: editorState,
-      autoFocus: editorState.document.isEmpty,
+      autoFocus: autoFocusParamters.value1,
+      focusedSelection: autoFocusParamters.value2,
       customBuilders: {
         // Divider
         kDividerType: DividerWidgetBuilder(),
@@ -144,6 +148,8 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
         kCalloutType: CalloutNodeWidgetBuilder(),
         // Auto Generator,
         kAutoCompletionInputType: AutoCompletionInputBuilder(),
+        // Cover
+        kCoverType: CoverNodeWidgetBuilder(),
         // Smart Edit,
         kSmartEditType: SmartEditInputBuilder(),
       },
@@ -174,7 +180,7 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
         // enable open ai features if needed.
         if (openAIKey != null && openAIKey!.isNotEmpty) ...[
           autoGeneratorMenuItem,
-        ]
+        ],
       ],
       toolbarItems: [
         if (openAIKey != null && openAIKey!.isNotEmpty) ...[
@@ -229,4 +235,18 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
       await editorState.apply(transaction, withUpdateCursor: false);
     }
   }
+
+  dartz.Tuple2<bool, Selection?> _autoFocusParamters() {
+    if (editorState.document.isEmpty) {
+      return dartz.Tuple2(true, Selection.single(path: [0], startOffset: 0));
+    }
+    final texts = editorState.document.root.children.whereType<TextNode>();
+    if (texts.every((element) => element.toPlainText().isEmpty)) {
+      return dartz.Tuple2(
+        true,
+        Selection.single(path: texts.first.path, startOffset: 0),
+      );
+    }
+    return const dartz.Tuple2(false, null);
+  }
 }

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

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

+ 376 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover.dart

@@ -0,0 +1,376 @@
+import 'dart:io';
+import 'dart:ui';
+
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_node_widget.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/util/file_picker/file_picker_service.dart';
+import 'package:appflowy/workspace/application/settings/settings_location_cubit.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:file_picker/file_picker.dart' show FileType;
+import 'package:flowy_infra/size.dart';
+import 'package:flowy_infra/theme_extension.dart';
+import 'package:flowy_infra_ui/style_widget/icon_button.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
+import 'package:flutter/material.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+const String kLocalImagesKey = 'local_images';
+
+List<String> get builtInAssetImages => [
+      "assets/images/app_flowy_abstract_cover_1.jpg",
+      "assets/images/app_flowy_abstract_cover_2.jpg"
+    ];
+
+class ChangeCoverPopover extends StatefulWidget {
+  final EditorState editorState;
+  final Node node;
+  final Function(
+    CoverSelectionType selectionType,
+    String selection,
+  ) onCoverChanged;
+
+  const ChangeCoverPopover({
+    super.key,
+    required this.editorState,
+    required this.onCoverChanged,
+    required this.node,
+  });
+
+  @override
+  State<ChangeCoverPopover> createState() => _ChangeCoverPopoverState();
+}
+
+class ColorOption {
+  final String colorHex;
+
+  final String name;
+  const ColorOption({
+    required this.colorHex,
+    required this.name,
+  });
+}
+
+class CoverColorPicker extends StatefulWidget {
+  final String? selectedBackgroundColorHex;
+
+  final Color pickerBackgroundColor;
+  final Color pickerItemHoverColor;
+  final void Function(String color) onSubmittedbackgroundColorHex;
+  final List<ColorOption> backgroundColorOptions;
+  const CoverColorPicker({
+    super.key,
+    this.selectedBackgroundColorHex,
+    required this.pickerBackgroundColor,
+    required this.backgroundColorOptions,
+    required this.pickerItemHoverColor,
+    required this.onSubmittedbackgroundColorHex,
+  });
+
+  @override
+  State<CoverColorPicker> createState() => _CoverColorPickerState();
+}
+
+class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
+  late Future<List<String>>? fileImages;
+
+  @override
+  void initState() {
+    super.initState();
+    fileImages = _getPreviouslyPickedImagePaths();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.all(15),
+      child: SingleChildScrollView(
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            FlowyText.semibold(LocaleKeys.document_plugins_cover_colors.tr()),
+            const SizedBox(height: 10),
+            _buildColorPickerList(),
+            const SizedBox(height: 10),
+            FlowyText.semibold(LocaleKeys.document_plugins_cover_images.tr()),
+            const SizedBox(height: 10),
+            _buildFileImagePicker(),
+            const SizedBox(height: 10),
+            FlowyText.semibold(LocaleKeys.document_plugins_cover_abstract.tr()),
+            const SizedBox(height: 10),
+            _buildAbstractImagePicker(),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _buildAbstractImagePicker() {
+    return GridView.builder(
+      shrinkWrap: true,
+      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+          crossAxisCount: 3,
+          childAspectRatio: 1 / 0.65,
+          crossAxisSpacing: 7,
+          mainAxisSpacing: 7),
+      itemCount: builtInAssetImages.length,
+      itemBuilder: (BuildContext ctx, index) {
+        return InkWell(
+          onTap: () {
+            widget.onCoverChanged(
+              CoverSelectionType.asset,
+              builtInAssetImages[index],
+            );
+          },
+          child: Container(
+            decoration: BoxDecoration(
+              image: DecorationImage(
+                image: AssetImage(builtInAssetImages[index]),
+                fit: BoxFit.cover,
+              ),
+              borderRadius: Corners.s8Border,
+            ),
+          ),
+        );
+      },
+    );
+  }
+
+  Widget _buildColorPickerList() {
+    return CoverColorPicker(
+      pickerBackgroundColor:
+          widget.editorState.editorStyle.selectionMenuBackgroundColor ??
+              Colors.white,
+      pickerItemHoverColor:
+          widget.editorState.editorStyle.selectionMenuItemSelectedColor ??
+              Colors.blue.withOpacity(0.3),
+      selectedBackgroundColorHex:
+          widget.node.attributes[kCoverSelectionTypeAttribute] ==
+                  CoverSelectionType.color.toString()
+              ? widget.node.attributes[kCoverSelectionAttribute]
+              : "ffffff",
+      backgroundColorOptions:
+          _generateBackgroundColorOptions(widget.editorState),
+      onSubmittedbackgroundColorHex: (color) {
+        widget.onCoverChanged(CoverSelectionType.color, color);
+        setState(() {});
+      },
+    );
+  }
+
+  Widget _buildFileImagePicker() {
+    return FutureBuilder<List<String>>(
+        future: _getPreviouslyPickedImagePaths(),
+        builder: (context, snapshot) {
+          if (snapshot.hasData) {
+            List<String> images = snapshot.data!;
+            return GridView.builder(
+              shrinkWrap: true,
+              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+                crossAxisCount: 3,
+                childAspectRatio: 1 / 0.65,
+                crossAxisSpacing: 7,
+                mainAxisSpacing: 7,
+              ),
+              itemCount: images.length + 1,
+              itemBuilder: (BuildContext ctx, index) {
+                if (index == 0) {
+                  return Container(
+                    decoration: BoxDecoration(
+                      color: Theme.of(context)
+                          .colorScheme
+                          .primary
+                          .withOpacity(0.15),
+                      border: Border.all(
+                        color: Theme.of(context).colorScheme.primary,
+                      ),
+                      borderRadius: Corners.s8Border,
+                    ),
+                    child: FlowyIconButton(
+                      iconPadding: EdgeInsets.zero,
+                      icon: Icon(
+                        Icons.add,
+                        color: Theme.of(context).colorScheme.primary,
+                      ),
+                      width: 20,
+                      onPressed: () {
+                        _pickImages();
+                      },
+                    ),
+                  );
+                }
+                return InkWell(
+                  onTap: () {
+                    widget.onCoverChanged(
+                      CoverSelectionType.file,
+                      images[index - 1],
+                    );
+                  },
+                  child: Container(
+                    decoration: BoxDecoration(
+                      image: DecorationImage(
+                        image: FileImage(File(images[index - 1])),
+                        fit: BoxFit.cover,
+                      ),
+                      borderRadius: Corners.s8Border,
+                    ),
+                  ),
+                );
+              },
+            );
+          } else {
+            return Container();
+          }
+        });
+  }
+
+  List<ColorOption> _generateBackgroundColorOptions(EditorState editorState) {
+    return FlowyTint.values
+        .map((t) => ColorOption(
+              colorHex: t.color(context).toHex(),
+              name: t.tintName(AppFlowyEditorLocalizations.current),
+            ))
+        .toList();
+  }
+
+  Future<List<String>> _getPreviouslyPickedImagePaths() async {
+    SharedPreferences prefs = await SharedPreferences.getInstance();
+    final imageNames = prefs.getStringList(kLocalImagesKey) ?? [];
+    final removeNames = [];
+    for (final name in imageNames) {
+      if (!File(name).existsSync()) {
+        removeNames.add(name);
+      }
+    }
+    imageNames.removeWhere((element) => removeNames.contains(element));
+    prefs.setStringList(kLocalImagesKey, imageNames);
+    return imageNames;
+  }
+
+  Future<void> _pickImages() async {
+    SharedPreferences prefs = await SharedPreferences.getInstance();
+    List<String> imageNames = prefs.getStringList(kLocalImagesKey) ?? [];
+    FilePickerResult? result = await getIt<FilePickerService>().pickFiles(
+      dialogTitle: LocaleKeys.document_plugins_cover_addLocalImage.tr(),
+      allowMultiple: false,
+      type: FileType.image,
+      allowedExtensions: ['jpg', 'png', 'jpeg'],
+    );
+    if (result != null && result.files.isNotEmpty) {
+      final path = result.files.first.path;
+      if (path != null) {
+        final directory = await _coverPath();
+        final newPath = await File(path).copy(
+          '$directory/${path.split('/').last}',
+        );
+        imageNames.add(newPath.path);
+      }
+    }
+    await prefs.setStringList(kLocalImagesKey, imageNames);
+    setState(() {});
+  }
+
+  Future<String> _coverPath() async {
+    final directory = await getIt<SettingsLocationCubit>().fetchLocation();
+    return Directory('$directory/covers')
+        .create(recursive: true)
+        .then((value) => value.path);
+  }
+}
+
+class _CoverColorPickerState extends State<CoverColorPicker> {
+  final scrollController = ScrollController();
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      height: 30,
+      alignment: Alignment.center,
+      child: ScrollConfiguration(
+        behavior: ScrollConfiguration.of(context).copyWith(dragDevices: {
+          PointerDeviceKind.touch,
+          PointerDeviceKind.mouse,
+        }, platform: TargetPlatform.windows),
+        child: ListView.builder(
+          controller: scrollController,
+          shrinkWrap: true,
+          itemCount: widget.backgroundColorOptions.length,
+          scrollDirection: Axis.horizontal,
+          itemBuilder: (context, index) {
+            return _buildColorItems(
+              widget.backgroundColorOptions,
+              widget.selectedBackgroundColorHex,
+            );
+          },
+        ),
+      ),
+    );
+  }
+
+  @override
+  void dispose() {
+    super.dispose();
+    scrollController.dispose();
+  }
+
+  Widget _buildColorItem(ColorOption option, bool isChecked) {
+    return InkWell(
+      customBorder: const RoundedRectangleBorder(
+        borderRadius: Corners.s6Border,
+      ),
+      hoverColor: widget.pickerItemHoverColor,
+      onTap: () {
+        widget.onSubmittedbackgroundColorHex(option.colorHex);
+      },
+      child: Padding(
+        padding: const EdgeInsets.only(right: 10.0),
+        child: SizedBox.square(
+          dimension: 25,
+          child: Container(
+            decoration: BoxDecoration(
+              color: isChecked
+                  ? Colors.transparent
+                  : Color(int.tryParse(option.colorHex) ?? 0xFFFFFFFF),
+              border: isChecked
+                  ? Border.all(
+                      color: Color(int.tryParse(option.colorHex) ?? 0xFFFFFF))
+                  : null,
+              shape: BoxShape.circle,
+            ),
+            child: isChecked
+                ? SizedBox.square(
+                    dimension: 25,
+                    child: Container(
+                      margin: const EdgeInsets.all(4),
+                      decoration: BoxDecoration(
+                        color:
+                            Color(int.tryParse(option.colorHex) ?? 0xFFFFFFFF),
+                        shape: BoxShape.circle,
+                      ),
+                    ),
+                  )
+                : null,
+          ),
+        ),
+      ),
+    );
+  }
+
+  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(),
+    );
+  }
+}
+
+extension on Color {
+  String toHex() {
+    return '0x${value.toRadixString(16)}';
+  }
+}

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

@@ -0,0 +1,302 @@
+import 'dart:io';
+
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/plugins/cover/change_cover_popover.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+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/style_widget/button.dart';
+import 'package:flowy_infra_ui/style_widget/icon_button.dart';
+import 'package:flowy_infra_ui/widget/rounded_button.dart';
+import 'package:flutter/material.dart';
+
+const String kCoverType = 'cover';
+const String kCoverSelectionTypeAttribute = 'cover_selection_type';
+const String kCoverSelectionAttribute = 'cover_selection';
+
+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[kCoverSelectionTypeAttribute],
+      );
+
+  @override
+  Widget build(BuildContext context) {
+    if (selectionType == CoverSelectionType.initial) {
+      return _AddCoverButton(
+        onTap: () {
+          _insertCover(CoverSelectionType.asset, builtInAssetImages.first);
+        },
+      );
+    } else {
+      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, {
+      kCoverSelectionTypeAttribute: type.toString(),
+      kCoverSelectionAttribute: cover,
+    });
+    return widget.editorState.apply(transaction);
+  }
+}
+
+class _AddCoverButton extends StatefulWidget {
+  const _AddCoverButton({
+    required this.onTap,
+  });
+
+  final VoidCallback onTap;
+
+  @override
+  State<_AddCoverButton> createState() => _AddCoverButtonState();
+}
+
+class _AddCoverButtonState extends State<_AddCoverButton> {
+  bool isHidden = true;
+
+  @override
+  Widget build(BuildContext context) {
+    return MouseRegion(
+      onEnter: (event) {
+        setHidden(false);
+      },
+      onExit: (event) {
+        setHidden(true);
+      },
+      child: Container(
+        height: 50.0,
+        width: double.infinity,
+        padding: const EdgeInsets.only(top: 20, bottom: 5),
+        // color: Colors.red,
+        child: isHidden
+            ? const SizedBox()
+            : Row(
+                mainAxisSize: MainAxisSize.min,
+                mainAxisAlignment: MainAxisAlignment.start,
+                children: [
+                  // Add Cover Button.
+                  FlowyButton(
+                    leftIconSize: const Size.square(18),
+                    onTap: widget.onTap,
+                    useIntrinsicWidth: true,
+                    leftIcon: svgWidget(
+                      'editor/image',
+                      color: Theme.of(context).colorScheme.onSurface,
+                    ),
+                    text: FlowyText.regular(
+                      LocaleKeys.document_plugins_cover_addCover.tr(),
+                    ),
+                  )
+                  // Add Icon Button.
+                  // ...
+                ],
+              ),
+      ),
+    );
+  }
+
+  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[kCoverSelectionTypeAttribute],
+      );
+  Color get color =>
+      Color(int.tryParse(widget.node.attributes[kCoverSelectionAttribute]) ??
+          0xFFFFFFFF);
+
+  bool isOverlayButtonsHidden = true;
+
+  @override
+  Widget build(BuildContext context) {
+    return Stack(
+      children: [
+        _buildCoverImage(context),
+        _buildCoverOverlayButtons(context),
+      ],
+    );
+  }
+
+  Widget _buildCoverOverlayButtons(BuildContext context) {
+    return Positioned(
+      bottom: 22,
+      right: 12,
+      child: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          AppFlowyPopover(
+            offset: const Offset(-125, 10),
+            controller: popoverController,
+            direction: PopoverDirection.bottomWithCenterAligned,
+            constraints: BoxConstraints.loose(const Size(380, 450)),
+            margin: EdgeInsets.zero,
+            child: RoundedTextButton(
+              onPressed: () {
+                popoverController.show();
+              },
+              hoverColor: Theme.of(context).colorScheme.surface,
+              textColor: Theme.of(context).colorScheme.onSurface,
+              fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.8),
+              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),
+          FlowyIconButton(
+            fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.8),
+            hoverColor: Theme.of(context).colorScheme.surface,
+            iconPadding: const EdgeInsets.all(5),
+            width: 28,
+            icon: svgWidget(
+              'editor/delete',
+              color: Theme.of(context).colorScheme.onSurface,
+            ),
+            onPressed: () {
+              widget.onCoverChanged(CoverSelectionType.initial, null);
+            },
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildCoverImage(BuildContext context) {
+    final screenSize = MediaQuery.of(context).size;
+    const height = 200.0;
+    final Widget coverImage;
+    switch (selectionType) {
+      case CoverSelectionType.file:
+        coverImage = Image.file(
+          File(widget.node.attributes[kCoverSelectionAttribute]),
+          fit: BoxFit.cover,
+        );
+        break;
+      case CoverSelectionType.asset:
+        coverImage = Image.asset(
+          widget.node.attributes[kCoverSelectionAttribute],
+          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(); // just an empty sizebox
+        break;
+    }
+    return UnconstrainedBox(
+      child: Container(
+        padding: const EdgeInsets.only(bottom: 10),
+        height: height,
+        width: screenSize.width,
+        child: coverImage,
+      ),
+    );
+  }
+
+  void setOverlayButtonsHidden(bool value) {
+    if (isOverlayButtonsHidden == value) return;
+    setState(() {
+      isOverlayButtonsHidden = value;
+    });
+  }
+}

+ 16 - 2
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/header/add_button.dart

@@ -1,9 +1,10 @@
 import 'package:appflowy/plugins/document/document.dart';
+import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_node_widget.dart';
 import 'package:appflowy/startup/plugin/plugin.dart';
 import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/workspace/presentation/home/menu/app/header/import/import_panel.dart';
 import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
-import 'package:appflowy_editor/appflowy_editor.dart' show Document;
+import 'package:appflowy_editor/appflowy_editor.dart' show Document, Node;
 import 'package:appflowy_popover/appflowy_popover.dart';
 import 'package:flowy_infra/image.dart';
 import 'package:flowy_infra_ui/style_widget/icon_button.dart';
@@ -60,7 +61,12 @@ class AddButton extends StatelessWidget {
       },
       onSelected: (action, controller) {
         if (action is AddButtonActionWrapper) {
-          onSelected(action.pluginBuilder, null);
+          Document? document;
+          if (action.pluginType == PluginType.editor) {
+            // initialize the document if needed.
+            document = buildInitialDocument();
+          }
+          onSelected(action.pluginBuilder, document);
         }
         if (action is ImportActionWrapper) {
           showImportPanel(context, (document) {
@@ -74,6 +80,12 @@ class AddButton extends StatelessWidget {
       },
     );
   }
+
+  Document buildInitialDocument() {
+    final document = Document.empty();
+    document.insert([0], [Node(type: kCoverType)]);
+    return document;
+  }
 }
 
 class AddButtonActionWrapper extends ActionCell {
@@ -87,6 +99,8 @@ class AddButtonActionWrapper extends ActionCell {
 
   @override
   String get name => pluginBuilder.menuName;
+
+  PluginType get pluginType => pluginBuilder.pluginType;
 }
 
 class ImportActionWrapper extends ActionCell {

+ 68 - 54
frontend/appflowy_flutter/packages/appflowy_backend/windows/appflowy_backend_plugin.cpp

@@ -1,4 +1,3 @@
-#include "include/appflowy_backend/appflowy_flutter_backend_plugin.h"
 
 // This must be included before many other Windows headers.
 #include <windows.h>
@@ -13,70 +12,85 @@
 #include <map>
 #include <memory>
 #include <sstream>
+#include "include/appflowy_backend/app_flowy_backend_plugin.h"
 
-namespace {
+namespace
+{
 
-class AppFlowyBackendPlugin : public flutter::Plugin {
- public:
-  static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar);
+  class AppFlowyBackendPlugin : public flutter::Plugin
+  {
+  public:
+    static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar);
 
-  AppFlowyBackendPlugin();
+    AppFlowyBackendPlugin();
 
-  virtual ~AppFlowyBackendPlugin();
+    virtual ~AppFlowyBackendPlugin();
 
- private:
-  // Called when a method is called on this plugin's channel from Dart.
-  void HandleMethodCall(
+  private:
+    // Called when a method is called on this plugin's channel from Dart.
+    void HandleMethodCall(
+        const flutter::MethodCall<flutter::EncodableValue> &method_call,
+        std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
+  };
+
+  // static
+  void AppFlowyBackendPlugin::RegisterWithRegistrar(
+      flutter::PluginRegistrarWindows *registrar)
+  {
+    auto channel =
+        std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
+            registrar->messenger(), "appflowy_backend",
+            &flutter::StandardMethodCodec::GetInstance());
+
+    auto plugin = std::make_unique<AppFlowyBackendPlugin>();
+
+    channel->SetMethodCallHandler(
+        [plugin_pointer = plugin.get()](const auto &call, auto result)
+        {
+          plugin_pointer->HandleMethodCall(call, std::move(result));
+        });
+
+    registrar->AddPlugin(std::move(plugin));
+  }
+
+  AppFlowyBackendPlugin::AppFlowyBackendPlugin() {}
+
+  AppFlowyBackendPlugin::~AppFlowyBackendPlugin() {}
+
+  void AppFlowyBackendPlugin::HandleMethodCall(
       const flutter::MethodCall<flutter::EncodableValue> &method_call,
-      std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
-};
-
-// static
-void AppFlowyBackendPlugin::RegisterWithRegistrar(
-    flutter::PluginRegistrarWindows *registrar) {
-  auto channel =
-      std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
-          registrar->messenger(), "appflowy_backend",
-          &flutter::StandardMethodCodec::GetInstance());
-
-  auto plugin = std::make_unique<AppFlowyBackendPlugin>();
-
-  channel->SetMethodCallHandler(
-      [plugin_pointer = plugin.get()](const auto &call, auto result) {
-        plugin_pointer->HandleMethodCall(call, std::move(result));
-      });
-
-  registrar->AddPlugin(std::move(plugin));
-}
-
-AppFlowyBackendPlugin::AppFlowyBackendPlugin() {}
-
-AppFlowyBackendPlugin::~AppFlowyBackendPlugin() {}
-
-void AppFlowyBackendPlugin::HandleMethodCall(
-    const flutter::MethodCall<flutter::EncodableValue> &method_call,
-    std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
-  if (method_call.method_name().compare("getPlatformVersion") == 0) {
-    std::ostringstream version_stream;
-    version_stream << "Windows ";
-    if (IsWindows10OrGreater()) {
-      version_stream << "10+";
-    } else if (IsWindows8OrGreater()) {
-      version_stream << "8";
-    } else if (IsWindows7OrGreater()) {
-      version_stream << "7";
+      std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result)
+  {
+    if (method_call.method_name().compare("getPlatformVersion") == 0)
+    {
+      std::ostringstream version_stream;
+      version_stream << "Windows ";
+      if (IsWindows10OrGreater())
+      {
+        version_stream << "10+";
+      }
+      else if (IsWindows8OrGreater())
+      {
+        version_stream << "8";
+      }
+      else if (IsWindows7OrGreater())
+      {
+        version_stream << "7";
+      }
+      result->Success(flutter::EncodableValue(version_stream.str()));
+    }
+    else
+    {
+      result->NotImplemented();
     }
-    result->Success(flutter::EncodableValue(version_stream.str()));
-  } else {
-    result->NotImplemented();
   }
-}
 
-}  // namespace
+} // namespace
 
 void AppFlowyBackendPluginRegisterWithRegistrar(
-    FlutterDesktopPluginRegistrarRef registrar) {
+    FlutterDesktopPluginRegistrarRef registrar)
+{
   AppFlowyBackendPlugin::RegisterWithRegistrar(
       flutter::PluginRegistrarManager::GetInstance()
           ->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
-}
+}

+ 5 - 4
frontend/appflowy_flutter/packages/appflowy_backend/windows/appflowy_backend_plugin_c_api.cpp

@@ -5,8 +5,9 @@
 #include "appflowy_flutter_backend_plugin.h"
 
 void AppFlowyBackendPluginCApiRegisterWithRegistrar(
-    FlutterDesktopPluginRegistrarRef registrar) {
-  appflowy_backend::AppFlowyBackendPlugin::RegisterWithRegistrar(
-      flutter::PluginRegistrarManager::GetInstance()
-          ->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
+    FlutterDesktopPluginRegistrarRef registrar)
+{
+    appflowy_backend::AppFlowyBackendPlugin::RegisterWithRegistrar(
+        flutter::PluginRegistrarManager::GetInstance()
+            ->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
 }

+ 4 - 1
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/editor_service.dart

@@ -32,6 +32,7 @@ class AppFlowyEditor extends StatefulWidget {
     this.toolbarItems = const [],
     this.editable = true,
     this.autoFocus = false,
+    this.focusedSelection,
     this.customActionMenuBuilder,
     ThemeData? themeData,
   }) : super(key: key) {
@@ -60,6 +61,7 @@ class AppFlowyEditor extends StatefulWidget {
 
   /// Set the value to true to focus the editor on the start of the document.
   final bool autoFocus;
+  final Selection? focusedSelection;
 
   final Positioned Function(BuildContext context, List<ActionMenuItem> items)?
       customActionMenuBuilder;
@@ -89,7 +91,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
     WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
       if (widget.editable && widget.autoFocus) {
         editorState.service.selectionService.updateSelection(
-          Selection.single(path: [0], startOffset: 0),
+          widget.focusedSelection ??
+              Selection.single(path: [0], startOffset: 0),
         );
       }
     });

+ 2 - 0
frontend/appflowy_flutter/packages/appflowy_editor_plugins/lib/appflowy_editor_plugins.dart

@@ -12,3 +12,5 @@ export 'src/divider/divider_shortcut_event.dart';
 export 'src/emoji_picker/emoji_menu_item.dart';
 // Math Equation
 export 'src/math_ equation/math_equation_node_widget.dart';
+
+export 'src/extensions/theme_extension.dart';