Lucas.Xu 1 рік тому
батько
коміт
36f47f3636
18 змінених файлів з 794 додано та 63 видалено
  1. 4 2
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart
  2. 1 1
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart
  3. 248 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart
  4. 46 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart
  5. 3 2
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart
  6. 134 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart
  7. 2 47
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart
  8. 150 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart
  9. 49 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart
  10. 122 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart
  11. 2 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart
  12. 1 1
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_language_view.dart
  13. 3 2
      frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart
  14. 2 2
      frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart
  15. 11 3
      frontend/appflowy_flutter/pubspec.lock
  16. 3 1
      frontend/appflowy_flutter/pubspec.yaml
  17. 5 0
      frontend/resources/flowy_icons/16x/image_placeholder.svg
  18. 8 2
      frontend/resources/translations/en.json

+ 4 - 2
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy/plugins/document/application/doc_bloc.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
 import 'package:appflowy/plugins/document/presentation/editor_style.dart';
 import 'package:appflowy/plugins/inline_actions/handlers/date_reference.dart';
@@ -267,10 +268,11 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
         ),
         textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level),
       ),
-      ImageBlockKeys.type: ImageBlockComponentBuilder(
+      ImageBlockKeys.type: CustomImageBlockComponentBuilder(
         configuration: configuration,
         showMenu: true,
-        menuBuilder: (node, state) => Positioned(
+        menuBuilder: (Node node, CustomImageBlockComponentState state) =>
+            Positioned(
           top: 0,
           right: 10,
           child: ImageMenu(

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart

@@ -22,7 +22,7 @@ final customizeFontToolbarItem = ToolbarItem(
         onClose: () => keepEditorFocusNotifier.value -= 1,
         showResetButton: true,
         onFontFamilyChanged: (fontFamily) async {
-          await popoverController.close();
+          popoverController.close();
           try {
             await editorState.formatDelta(selection, {
               AppFlowyRichTextKeys.fontFamily: fontFamily,

+ 248 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart

@@ -0,0 +1,248 @@
+import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+typedef CustomImageBlockComponentMenuBuilder = Widget Function(
+  Node node,
+  CustomImageBlockComponentState state,
+);
+
+class CustomImageBlockComponentBuilder extends BlockComponentBuilder {
+  CustomImageBlockComponentBuilder({
+    super.configuration,
+    this.showMenu = false,
+    this.menuBuilder,
+  });
+
+  /// Whether to show the menu of this block component.
+  final bool showMenu;
+
+  ///
+  final CustomImageBlockComponentMenuBuilder? menuBuilder;
+
+  @override
+  BlockComponentWidget build(BlockComponentContext blockComponentContext) {
+    final node = blockComponentContext.node;
+    return CustomImageBlockComponent(
+      key: node.key,
+      node: node,
+      showActions: showActions(node),
+      configuration: configuration,
+      actionBuilder: (context, state) => actionBuilder(
+        blockComponentContext,
+        state,
+      ),
+      showMenu: showMenu,
+      menuBuilder: menuBuilder,
+    );
+  }
+
+  @override
+  bool validate(Node node) => node.delta == null && node.children.isEmpty;
+}
+
+class CustomImageBlockComponent extends BlockComponentStatefulWidget {
+  const CustomImageBlockComponent({
+    super.key,
+    required super.node,
+    super.showActions,
+    super.actionBuilder,
+    super.configuration = const BlockComponentConfiguration(),
+    this.showMenu = false,
+    this.menuBuilder,
+  });
+
+  /// Whether to show the menu of this block component.
+  final bool showMenu;
+
+  final CustomImageBlockComponentMenuBuilder? menuBuilder;
+
+  @override
+  State<CustomImageBlockComponent> createState() =>
+      CustomImageBlockComponentState();
+}
+
+class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
+    with SelectableMixin, BlockComponentConfigurable {
+  @override
+  BlockComponentConfiguration get configuration => widget.configuration;
+
+  @override
+  Node get node => widget.node;
+
+  final imageKey = GlobalKey();
+  RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
+
+  late final editorState = Provider.of<EditorState>(context, listen: false);
+
+  final showActionsNotifier = ValueNotifier<bool>(false);
+
+  bool alwaysShowMenu = false;
+
+  @override
+  Widget build(BuildContext context) {
+    final node = widget.node;
+    final attributes = node.attributes;
+    final src = attributes[ImageBlockKeys.url];
+
+    final alignment = AlignmentExtension.fromString(
+      attributes[ImageBlockKeys.align] ?? 'center',
+    );
+    final width = attributes[ImageBlockKeys.width]?.toDouble() ??
+        MediaQuery.of(context).size.width;
+    final height = attributes[ImageBlockKeys.height]?.toDouble();
+
+    Widget child = src.isEmpty
+        ? ImagePlaceholder(
+            node: node,
+          )
+        : ResizableImage(
+            src: src,
+            width: width,
+            height: height,
+            editable: editorState.editable,
+            alignment: alignment,
+            onResize: (width) {
+              final transaction = editorState.transaction
+                ..updateNode(node, {
+                  ImageBlockKeys.width: width,
+                });
+              editorState.apply(transaction);
+            },
+          );
+
+    child = BlockSelectionContainer(
+      node: node,
+      delegate: this,
+      listenable: editorState.selectionNotifier,
+      blockColor: editorState.editorStyle.selectionColor,
+      supportTypes: const [
+        BlockSelectionType.block,
+      ],
+      child: Padding(
+        key: imageKey,
+        padding: padding,
+        child: child,
+      ),
+    );
+
+    if (widget.showActions && widget.actionBuilder != null) {
+      child = BlockComponentActionWrapper(
+        node: node,
+        actionBuilder: widget.actionBuilder!,
+        child: child,
+      );
+    }
+
+    if (widget.showMenu && widget.menuBuilder != null) {
+      child = MouseRegion(
+        onEnter: (_) => showActionsNotifier.value = true,
+        onExit: (_) {
+          if (!alwaysShowMenu) {
+            showActionsNotifier.value = false;
+          }
+        },
+        hitTestBehavior: HitTestBehavior.opaque,
+        opaque: false,
+        child: ValueListenableBuilder<bool>(
+          valueListenable: showActionsNotifier,
+          builder: (context, value, child) {
+            final url = node.attributes[ImageBlockKeys.url];
+            return Stack(
+              children: [
+                BlockSelectionContainer(
+                  node: node,
+                  delegate: this,
+                  listenable: editorState.selectionNotifier,
+                  cursorColor: editorState.editorStyle.cursorColor,
+                  selectionColor: editorState.editorStyle.selectionColor,
+                  child: child!,
+                ),
+                if (value && url.isNotEmpty == true)
+                  widget.menuBuilder!(
+                    widget.node,
+                    this,
+                  ),
+              ],
+            );
+          },
+          child: child,
+        ),
+      );
+    }
+
+    return child;
+  }
+
+  @override
+  Position start() => Position(path: widget.node.path, offset: 0);
+
+  @override
+  Position end() => Position(path: widget.node.path, offset: 1);
+
+  @override
+  Position getPositionInOffset(Offset start) => end();
+
+  @override
+  bool get shouldCursorBlink => false;
+
+  @override
+  CursorStyle get cursorStyle => CursorStyle.cover;
+
+  @override
+  Rect getBlockRect({
+    bool shiftWithBaseOffset = false,
+  }) {
+    final imageBox = imageKey.currentContext?.findRenderObject();
+    if (imageBox is RenderBox) {
+      return Offset.zero & imageBox.size;
+    }
+    return Rect.zero;
+  }
+
+  @override
+  Rect? getCursorRectInPosition(
+    Position position, {
+    bool shiftWithBaseOffset = false,
+  }) {
+    if (_renderBox == null) {
+      return null;
+    }
+    final size = _renderBox!.size;
+    return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
+  }
+
+  @override
+  List<Rect> getRectsInSelection(
+    Selection selection, {
+    bool shiftWithBaseOffset = false,
+  }) {
+    if (_renderBox == null) {
+      return [];
+    }
+    final parentBox = context.findRenderObject();
+    final imageBox = imageKey.currentContext?.findRenderObject();
+    if (parentBox is RenderBox && imageBox is RenderBox) {
+      return [
+        imageBox.localToGlobal(Offset.zero, ancestor: parentBox) &
+            imageBox.size,
+      ];
+    }
+    return [Offset.zero & _renderBox!.size];
+  }
+
+  @override
+  Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
+        path: widget.node.path,
+        startOffset: 0,
+        endOffset: 1,
+      );
+
+  @override
+  Offset localToGlobal(
+    Offset offset, {
+    bool shiftWithBaseOffset = false,
+  }) =>
+      _renderBox!.localToGlobal(offset);
+}

+ 46 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart

@@ -0,0 +1,46 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+
+class EmbedImageUrlWidget extends StatefulWidget {
+  const EmbedImageUrlWidget({
+    super.key,
+    required this.onSubmit,
+  });
+
+  final void Function(String url) onSubmit;
+
+  @override
+  State<EmbedImageUrlWidget> createState() => _EmbedImageUrlWidgetState();
+}
+
+class _EmbedImageUrlWidgetState extends State<EmbedImageUrlWidget> {
+  String inputText = '';
+
+  @override
+  Widget build(BuildContext context) {
+    return Column(
+      children: [
+        FlowyTextField(
+          autoFocus: true,
+          hintText: LocaleKeys.document_imageBlock_embedLink_placeholder.tr(),
+          onChanged: (value) => inputText = value,
+          onEditingComplete: () => widget.onSubmit(inputText),
+        ),
+        const VSpace(5),
+        SizedBox(
+          width: 160,
+          child: FlowyButton(
+            margin: const EdgeInsets.all(8.0),
+            text: FlowyText(
+              LocaleKeys.document_imageBlock_embedLink_label.tr(),
+              textAlign: TextAlign.center,
+            ),
+            onTap: () => widget.onSubmit(inputText),
+          ),
+        ),
+      ],
+    );
+  }
+}

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

@@ -1,5 +1,6 @@
 import 'package:appflowy/generated/flowy_svgs.g.dart';
 import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
 import 'package:appflowy/workspace/presentation/home/toast.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_popover/appflowy_popover.dart';
@@ -18,7 +19,7 @@ class ImageMenu extends StatefulWidget {
   });
 
   final Node node;
-  final ImageBlockComponentWidgetState state;
+  final CustomImageBlockComponentState state;
 
   @override
   State<ImageMenu> createState() => _ImageMenuState();
@@ -109,7 +110,7 @@ class _ImageAlignButton extends StatefulWidget {
   });
 
   final Node node;
-  final ImageBlockComponentWidgetState state;
+  final CustomImageBlockComponentState state;
 
   @override
   State<_ImageAlignButton> createState() => _ImageAlignButtonState();

+ 134 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart

@@ -0,0 +1,134 @@
+import 'dart:io';
+
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
+import 'package:appflowy/workspace/presentation/home/toast.dart';
+import 'package:appflowy_backend/log.dart';
+import 'package:appflowy_editor/appflowy_editor.dart' hide Log, UploadImageMenu;
+import 'package:appflowy_popover/appflowy_popover.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/uuid.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/style_widget/hover.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:path/path.dart' as p;
+import 'package:string_validator/string_validator.dart';
+
+class ImagePlaceholder extends StatefulWidget {
+  const ImagePlaceholder({
+    super.key,
+    required this.node,
+  });
+
+  final Node node;
+
+  @override
+  State<ImagePlaceholder> createState() => _ImagePlaceholderState();
+}
+
+class _ImagePlaceholderState extends State<ImagePlaceholder> {
+  final controller = PopoverController();
+  late final editorState = context.read<EditorState>();
+
+  @override
+  Widget build(BuildContext context) {
+    return AppFlowyPopover(
+      controller: controller,
+      direction: PopoverDirection.bottomWithCenterAligned,
+      constraints: const BoxConstraints(
+        maxWidth: 540,
+        maxHeight: 260,
+        minHeight: 80,
+      ),
+      popupBuilder: (context) {
+        return UploadImageMenu(
+          onPickFile: insertLocalImage,
+          onSubmit: insertNetworkImage,
+        );
+      },
+      child: DecoratedBox(
+        decoration: BoxDecoration(
+          color: Theme.of(context).colorScheme.surfaceVariant,
+          borderRadius: BorderRadius.circular(4),
+        ),
+        child: FlowyHover(
+          style: HoverStyle(
+            borderRadius: BorderRadius.circular(4),
+          ),
+          child: SizedBox(
+            height: 48,
+            child: Row(
+              children: [
+                const HSpace(10),
+                const FlowySvg(
+                  FlowySvgs.image_placeholder_s,
+                  size: Size.square(24),
+                ),
+                const HSpace(10),
+                FlowyText(
+                  LocaleKeys.document_plugins_image_addAnImage.tr(),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  Future<void> insertLocalImage(String? url) async {
+    if (url == null || url.isEmpty) {
+      controller.close();
+      return;
+    }
+    final path = await getIt<ApplicationDataStorage>().getPath();
+    final imagePath = p.join(
+      path,
+      'images',
+    );
+    try {
+      // create the directory if not exists
+      final directory = Directory(imagePath);
+      if (!directory.existsSync()) {
+        await directory.create(recursive: true);
+      }
+      final copyToPath = p.join(
+        imagePath,
+        '${uuid()}${p.extension(url)}',
+      );
+      await File(url).copy(
+        copyToPath,
+      );
+
+      final transaction = editorState.transaction;
+      transaction.updateNode(widget.node, {
+        ImageBlockKeys.url: copyToPath,
+      });
+      await editorState.apply(transaction);
+    } catch (e) {
+      Log.error('cannot copy image file', e);
+    }
+    controller.close();
+  }
+
+  Future<void> insertNetworkImage(String url) async {
+    if (url.isEmpty || !isURL(url)) {
+      // show error
+      showSnackBarMessage(
+        context,
+        LocaleKeys.document_imageBlock_error_invalidImage.tr(),
+      );
+      return;
+    }
+
+    final transaction = editorState.transaction;
+    transaction.updateNode(widget.node, {
+      ImageBlockKeys.url: url,
+    });
+    await editorState.apply(transaction);
+  }
+}

+ 2 - 47
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart

@@ -1,12 +1,4 @@
-import 'dart:io';
-
-import 'package:appflowy/startup/startup.dart';
-import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
-import 'package:appflowy_backend/log.dart';
 import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
-import 'package:flowy_infra/uuid.dart';
-import 'package:flutter/material.dart';
-import 'package:path/path.dart' as p;
 
 final customImageMenuItem = SelectionMenuItem(
   name: AppFlowyEditorLocalizations.current.image,
@@ -16,44 +8,7 @@ final customImageMenuItem = SelectionMenuItem(
     style: style,
   ),
   keywords: ['image', 'picture', 'img', 'photo'],
-  handler: (editorState, menuService, context) {
-    final container = Overlay.of(context);
-    showImageMenu(
-      container,
-      editorState,
-      menuService,
-      onInsertImage: (url) async {
-        // if the url is http, we can insert it directly
-        // otherwise, if it's a file url, we need to copy the file to the app's document directory
-
-        final regex = RegExp('^(http|https)://');
-        if (regex.hasMatch(url)) {
-          await editorState.insertImageNode(url);
-        } else {
-          final path = await getIt<ApplicationDataStorage>().getPath();
-          final imagePath = p.join(
-            path,
-            'images',
-          );
-          try {
-            // create the directory if not exists
-            final directory = Directory(imagePath);
-            if (!directory.existsSync()) {
-              await directory.create(recursive: true);
-            }
-            final copyToPath = p.join(
-              imagePath,
-              '${uuid()}${p.extension(url)}',
-            );
-            await File(url).copy(
-              copyToPath,
-            );
-            await editorState.insertImageNode(copyToPath);
-          } catch (e) {
-            Log.error('cannot copy image file', e);
-          }
-        }
-      },
-    );
+  handler: (editorState, menuService, context) async {
+    return await editorState.insertImageNode('');
   },
 );

+ 150 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart

@@ -0,0 +1,150 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+import 'package:unsplash_client/unsplash_client.dart';
+
+class UnsplashImageWidget extends StatefulWidget {
+  const UnsplashImageWidget({
+    super.key,
+    required this.onSelectUnsplashImage,
+  });
+
+  final void Function(String url) onSelectUnsplashImage;
+
+  @override
+  State<UnsplashImageWidget> createState() => _UnsplashImageWidgetState();
+}
+
+class _UnsplashImageWidgetState extends State<UnsplashImageWidget> {
+  final client = UnsplashClient(
+    settings: const ClientSettings(
+      credentials: AppCredentials(
+        // TODO: there're the demo keys, we should replace them with the production keys when releasing and inject them with env file.
+        accessKey: 'YyD-LbW5bVolHWZBq5fWRM_3ezkG2XchRFjhNTnK9TE',
+        secretKey: '5z4EnxaXjWjWMnuBhc0Ku0uYW2bsYCZlO-REZaqmV6A',
+      ),
+    ),
+  );
+
+  late Future<List<Photo>> randomPhotos;
+
+  String query = '';
+
+  @override
+  void initState() {
+    super.initState();
+
+    randomPhotos = client.photos
+        .random(count: 18, orientation: PhotoOrientation.landscape)
+        .goAndGet();
+  }
+
+  @override
+  void dispose() {
+    client.close();
+
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Column(
+      children: [
+        FlowyTextField(
+          autoFocus: true,
+          hintText: LocaleKeys.document_imageBlock_searchForAnImage.tr(),
+          // textAlign: TextAlign.left,
+          onChanged: (value) => query = value,
+          onEditingComplete: () => setState(() {
+            randomPhotos = client.photos
+                .random(
+                  count: 18,
+                  orientation: PhotoOrientation.landscape,
+                  query: query,
+                )
+                .goAndGet();
+          }),
+        ),
+        const HSpace(12.0),
+        Expanded(
+          child: FutureBuilder(
+            future: randomPhotos,
+            builder: (context, value) {
+              final data = value.data;
+              if (!value.hasData || data == null || data.isEmpty) {
+                return const CircularProgressIndicator.adaptive();
+              }
+              return GridView.count(
+                crossAxisCount: 3,
+                mainAxisSpacing: 16.0,
+                crossAxisSpacing: 10.0,
+                childAspectRatio: 4 / 3,
+                children: data
+                    .map(
+                      (photo) => _UnsplashImage(
+                        photo: photo,
+                        onTap: () => widget.onSelectUnsplashImage(
+                          photo.urls.regular.toString(),
+                        ),
+                      ),
+                    )
+                    .toList(),
+              );
+            },
+          ),
+        ),
+      ],
+    );
+  }
+}
+
+class _UnsplashImage extends StatelessWidget {
+  const _UnsplashImage({
+    required this.photo,
+    required this.onTap,
+  });
+
+  final Photo photo;
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(
+      onTap: onTap,
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.stretch,
+        children: [
+          Expanded(
+            child: Image.network(
+              photo.urls.thumb.toString(),
+              fit: BoxFit.cover,
+            ),
+          ),
+          FlowyText(
+            'by ${photo.name}',
+            fontSize: 10.0,
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+extension on Photo {
+  String get name {
+    if (user.username.isNotEmpty) {
+      return user.username;
+    }
+
+    if (user.name.isNotEmpty) {
+      return user.name;
+    }
+
+    if (user.email?.isNotEmpty == true) {
+      return user.email!;
+    }
+
+    return user.id;
+  }
+}

+ 49 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart

@@ -0,0 +1,49 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/file_picker/file_picker_service.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/style_widget/hover.dart';
+import 'package:flutter/material.dart';
+
+class UploadImageFileWidget extends StatelessWidget {
+  const UploadImageFileWidget({
+    super.key,
+    required this.onPickFile,
+    this.allowedExtensions = const ['jpg', 'png', 'jpeg'],
+  });
+
+  final void Function(String? path) onPickFile;
+  final List<String> allowedExtensions;
+
+  @override
+  Widget build(BuildContext context) {
+    return FlowyHover(
+      child: GestureDetector(
+        behavior: HitTestBehavior.translucent,
+        onTapDown: (_) async {
+          final result = await getIt<FilePickerService>().pickFiles(
+            dialogTitle: '',
+            allowMultiple: false,
+            type: FileType.image,
+            allowedExtensions: allowedExtensions,
+          );
+          onPickFile(result?.files.firstOrNull?.path);
+        },
+        child: Container(
+          alignment: Alignment.center,
+          padding: const EdgeInsets.symmetric(vertical: 8.0),
+          decoration: BoxDecoration(
+            border: Border.all(
+              color: Theme.of(context).colorScheme.surfaceVariant,
+              width: 1.0,
+            ),
+          ),
+          child: FlowyText(
+            LocaleKeys.document_imageBlock_upload_placeholder.tr(),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 122 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart

@@ -0,0 +1,122 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/style_widget/hover.dart';
+import 'package:flutter/material.dart';
+
+enum UploadImageType {
+  local,
+  url,
+  unsplash,
+  ai;
+
+  String get description {
+    switch (this) {
+      case UploadImageType.local:
+        return LocaleKeys.document_imageBlock_upload_label.tr();
+      case UploadImageType.url:
+        return LocaleKeys.document_imageBlock_embedLink_label.tr();
+      case UploadImageType.unsplash:
+        return 'Unsplash';
+      case UploadImageType.ai:
+        return 'Generate from AI';
+    }
+  }
+}
+
+class UploadImageMenu extends StatefulWidget {
+  const UploadImageMenu({
+    super.key,
+    required this.onPickFile,
+    required this.onSubmit,
+  });
+
+  final void Function(String? path) onPickFile;
+  final void Function(String url) onSubmit;
+
+  @override
+  State<UploadImageMenu> createState() => _UploadImageMenuState();
+}
+
+class _UploadImageMenuState extends State<UploadImageMenu> {
+  int currentTabIndex = 0;
+
+  @override
+  Widget build(BuildContext context) {
+    return DefaultTabController(
+      length: 3, // UploadImageType.values.length, // ai is not implemented yet
+      child: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          TabBar(
+            onTap: (value) => setState(() {
+              currentTabIndex = value;
+            }),
+            indicatorSize: TabBarIndicatorSize.label,
+            isScrollable: true,
+            overlayColor: MaterialStatePropertyAll(
+              Theme.of(context).colorScheme.secondary,
+            ),
+            padding: EdgeInsets.zero,
+            // splashBorderRadius: BorderRadius.circular(4),
+            tabs: UploadImageType.values
+                .where(
+                  (element) => element != UploadImageType.ai,
+                ) // ai is not implemented yet
+                .map(
+                  (e) => FlowyHover(
+                    style: const HoverStyle(borderRadius: BorderRadius.zero),
+                    child: Padding(
+                      padding: const EdgeInsets.symmetric(
+                        horizontal: 12.0,
+                        vertical: 8.0,
+                      ),
+                      child: FlowyText(e.description),
+                    ),
+                  ),
+                )
+                .toList(),
+          ),
+          const Divider(
+            height: 2,
+          ),
+          _buildTab(),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildTab() {
+    final type = UploadImageType.values[currentTabIndex];
+    switch (type) {
+      case UploadImageType.local:
+        return Padding(
+          padding: const EdgeInsets.all(8.0),
+          child: UploadImageFileWidget(
+            onPickFile: widget.onPickFile,
+          ),
+        );
+      case UploadImageType.url:
+        return Padding(
+          padding: const EdgeInsets.all(8.0),
+          child: EmbedImageUrlWidget(
+            onSubmit: widget.onSubmit,
+          ),
+        );
+      case UploadImageType.unsplash:
+        return Expanded(
+          child: Padding(
+            padding: const EdgeInsets.all(8.0),
+            child: UnsplashImageWidget(
+              onSelectUnsplashImage: widget.onSubmit,
+            ),
+          ),
+        );
+      case UploadImageType.ai:
+        return const FlowyText.medium('ai');
+    }
+  }
+}

+ 2 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart

@@ -55,7 +55,9 @@ class _SmartEditActionListState extends State<SmartEditActionList> {
       actions: SmartEditAction.values
           .map((action) => SmartEditActionWrapper(action))
           .toList(),
+      onClosed: () => keepEditorFocusNotifier.value -= 1,
       buildChild: (controller) {
+        keepEditorFocusNotifier.value += 1;
         return FlowyIconButton(
           hoverColor: Colors.transparent,
           tooltipText: isOpenAIEnabled

+ 1 - 1
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_language_view.dart

@@ -32,7 +32,7 @@ class SettingsLanguageView extends StatelessWidget {
 
 class LanguageSelector extends StatelessWidget {
   final Locale currentLocale;
-  
+
   const LanguageSelector({
     super.key,
     required this.currentLocale,

+ 3 - 2
frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart

@@ -1,17 +1,18 @@
 import 'package:appflowy_popover/src/layout.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
+
 import 'mask.dart';
 import 'mutex.dart';
 
 class PopoverController {
   PopoverState? _state;
 
-  close() {
+  void close() {
     _state?.close();
   }
 
-  show() {
+  void show() {
     _state?.showOverlay();
   }
 }

+ 2 - 2
frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart

@@ -153,8 +153,8 @@ class AFThemeExtension extends ThemeExtension<AFThemeExtension> {
           Color.lerp(toggleButtonBGColor, other.toggleButtonBGColor, t)!,
       calendarWeekendBGColor:
           Color.lerp(calendarWeekendBGColor, other.calendarWeekendBGColor, t)!,
-      gridRowCountColor: Color.lerp(
-          gridRowCountColor, other.gridRowCountColor, t)!,
+      gridRowCountColor:
+          Color.lerp(gridRowCountColor, other.gridRowCountColor, t)!,
       code: other.code,
       callout: other.callout,
       caption: other.caption,

+ 11 - 3
frontend/appflowy_flutter/pubspec.lock

@@ -54,8 +54,8 @@ packages:
     dependency: "direct main"
     description:
       path: "."
-      ref: "0fdca2f"
-      resolved-ref: "0fdca2f702485eeec1bfbe50127c06f2a8fd8b1e"
+      ref: e996c92
+      resolved-ref: e996c9279d873f55a1b6aa919144763a60f83d32
       url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
     source: git
     version: "1.4.3"
@@ -1447,7 +1447,7 @@ packages:
     source: hosted
     version: "1.2.0"
   string_validator:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: string_validator
       sha256: b419cf5d21d608522e6e7cafed4deb34b6f268c43df866e63c320bab98a08cf6
@@ -1615,6 +1615,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.0.0+1"
+  unsplash_client:
+    dependency: "direct main"
+    description:
+      name: unsplash_client
+      sha256: "832011981ef358ef4f816f356375620791d24dc6e1afb33a37f066df9aaea537"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.1"
   url_launcher:
     dependency: "direct main"
     description:

+ 3 - 1
frontend/appflowy_flutter/pubspec.yaml

@@ -47,7 +47,7 @@ dependencies:
   appflowy_editor:
     git:
       url: https://github.com/AppFlowy-IO/appflowy-editor.git
-      ref: '0fdca2f'
+      ref: 'e996c92'
   appflowy_popover:
     path: packages/appflowy_popover
 
@@ -108,6 +108,8 @@ dependencies:
   hive_flutter: ^1.1.0
   super_clipboard: ^0.6.3
   go_router: ^10.1.2
+  string_validator: ^1.0.0
+  unsplash_client: ^2.1.1
 
   # Notifications
   # TODO: Consider implementing custom package

+ 5 - 0
frontend/resources/flowy_icons/16x/image_placeholder.svg

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

+ 8 - 2
frontend/resources/translations/en.json

@@ -616,7 +616,8 @@
         "defaultColor": "Default"
       },
       "image": {
-        "copiedToPasteBoard": "The image link has been copied to the clipboard"
+        "copiedToPasteBoard": "The image link has been copied to the clipboard",
+        "addAnImage": "Add an image"
       },
       "outline": {
         "addHeadingToCreateOutline": "Add headings to create a table of contents."
@@ -657,7 +658,12 @@
         "invalidImageSize": "Image size must be less than 5MB",
         "invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, GIF, SVG",
         "invalidImageUrl": "Invalid image URL"
-      }
+      },
+      "embedLink": {
+        "label": "Embed link",
+        "placeholder": "Paste or type an image link"
+      },
+      "searchForAnImage": "Search for an image"
     },
     "codeBlock": {
       "language": {