diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart b/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart index fbd4312489..5c8f4cf184 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/document_with_cover_image_test.dart @@ -43,8 +43,6 @@ void main() { await tester.editor.hoverOnCover(); await tester.editor.tapOnChangeCover(); await tester.editor.addNetworkImageCover(imageUrl); - await tester.editor.switchNetworkImageCover(imageUrl); - await tester.editor.dismissCoverPicker(); tester.expectToSeeDocumentCover(CoverType.file); // Remove the cover diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_image_block_test.dart b/frontend/appflowy_flutter/integration_test/document/document_with_image_block_test.dart index 5516cead47..3b52759a92 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_image_block_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/document_with_image_block_test.dart @@ -1,13 +1,18 @@ import 'dart:io'; +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.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/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.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_menu.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; +import 'package:appflowy_editor/appflowy_editor.dart' + hide UploadImageMenu, ResizableImage; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -61,6 +66,7 @@ void main() { paths: [imagePath], ); + getIt().set(KVKeys.kCloudType, '0'); await tester.tapButtonWithName( LocaleKeys.document_imageBlock_upload_placeholder.tr(), ); diff --git a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart b/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart index 73dd494922..44171d4f37 100644 --- a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart @@ -6,9 +6,9 @@ import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart'; import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker.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/image/embed_image_url_widget.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:easy_localization/easy_localization.dart'; @@ -121,19 +121,25 @@ class EditorOperations { } Future addNetworkImageCover(String imageUrl) async { - final findNewImageButton = find.byType(NewCustomCoverButton); - await tester.tapButton(findNewImageButton); + final embedLinkButton = find.findTextInFlowyText( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + ); + await tester.tapButton(embedLinkButton); final imageUrlTextField = find.descendant( - of: find.byType(NetworkImageUrlInput), + of: find.byType(EmbedImageUrlWidget), matching: find.byType(TextField), ); - await tester.enterText(imageUrlTextField, imageUrl); - await tester.tapButtonWithName( - LocaleKeys.document_plugins_cover_add.tr(), - ); - await tester.tapButtonWithName( - LocaleKeys.document_plugins_cover_saveToGallery.tr(), + final textField = tester.widget(imageUrlTextField); + textField.controller?.text = imageUrl; + await tester.pumpAndSettle(); + await tester.tapButton( + find.descendant( + of: find.byType(EmbedImageUrlWidget), + matching: find.findTextInFlowyText( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + ), + ), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart index 8502f768c1..ba0ce2c86d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart @@ -14,6 +14,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; class MobileHomeScreen extends StatelessWidget { const MobileHomeScreen({super.key}); @@ -51,9 +52,12 @@ class MobileHomeScreen extends StatelessWidget { return Scaffold( body: SafeArea( - child: MobileHomePage( - userProfile: userProfile, - workspaceSetting: workspaceSetting, + child: Provider.value( + value: userProfile, + child: MobileHomePage( + userProfile: userProfile, + workspaceSetting: workspaceSetting, + ), ), ), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart index 2e91b577b5..e3bd929ead 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart @@ -4,16 +4,18 @@ import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/workspace/application/doc/doc_listener.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:string_validator/string_validator.dart'; class MobileRecentView extends StatefulWidget { @@ -161,9 +163,10 @@ class _MobileRecentViewState extends State { switch (type) { case CoverType.file: if (isURL(cover)) { - return CachedNetworkImage( - imageUrl: cover, - fit: BoxFit.cover, + final userProfilePB = Provider.of(context); + return FlowyNetworkImage( + url: cover, + userProfilePB: userProfilePB, ); } final imageFile = File(cover); diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart index b39f5c01a1..7a478ea169 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart @@ -4,6 +4,8 @@ import 'package:appflowy/plugins/document/application/doc_service.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/application/doc/doc_listener.dart'; import 'package:appflowy/workspace/application/doc/sync_state_listener.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; @@ -73,21 +75,26 @@ class DocumentBloc extends Bloc { final editorState = await _fetchDocumentState(); _onViewChanged(); _onDocumentChanged(); - editorState.fold( - (l) => emit( + await editorState.fold( + (l) async => emit( state.copyWith( error: l, editorState: null, isLoading: false, ), ), - (r) => emit( - state.copyWith( - error: null, - editorState: r, - isLoading: false, - ), - ), + (r) async { + final result = await getIt().getUser(); + final userProfilePB = result.fold((l) => null, (r) => r); + emit( + state.copyWith( + error: null, + editorState: r, + isLoading: false, + userProfilePB: userProfilePB, + ), + ); + }, ); }, moveToTrash: () async { diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart index 5e17a1c1e1..74684a19d5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart @@ -100,4 +100,37 @@ class DocumentService { final result = await DocumentEventApplyTextDeltaEvent(payload).send(); return result.swap(); } + + /// Upload a file to the cloud storage. + Future> uploadFile({ + required String localFilePath, + }) async { + final workspace = await FolderEventReadCurrentWorkspace().send(); + return workspace.fold((l) async { + final payload = UploadFileParamsPB( + workspaceId: l.id, + localFilePath: localFilePath, + ); + final result = await DocumentEventUploadFile(payload).send(); + return result.swap(); + }, (r) async { + return left(FlowyError(msg: 'Workspace not found')); + }); + } + + /// Download a file from the cloud storage. + Future> downloadFile({ + required String url, + }) async { + final workspace = await FolderEventReadCurrentWorkspace().send(); + return workspace.fold((l) async { + final payload = UploadedFilePB( + url: url, + ); + final result = await DocumentEventDownloadFile(payload).send(); + return result.swap(); + }, (r) async { + return left(FlowyError(msg: 'Workspace not found')); + }); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart index 53f680da6a..e207a685a7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart @@ -1,22 +1,26 @@ import 'dart:io'; +import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; import 'package:appflowy/plugins/base/icon/icon_picker.dart'; +import 'package:appflowy/plugins/document/application/doc_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.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 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:string_validator/string_validator.dart'; @@ -165,13 +169,14 @@ class _DocumentHeaderNodeWidgetState extends State { } } - Future _saveCover({(CoverType, String?)? cover, String? icon}) { + Future _saveCover({(CoverType, String?)? cover, String? icon}) async { final transaction = widget.editorState.transaction; + final coverType = widget.node.attributes[DocumentHeaderBlockKeys.coverType]; + final coverDetails = + widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]; final Map attributes = { - DocumentHeaderBlockKeys.coverType: - widget.node.attributes[DocumentHeaderBlockKeys.coverType], - DocumentHeaderBlockKeys.coverDetails: - widget.node.attributes[DocumentHeaderBlockKeys.coverDetails], + DocumentHeaderBlockKeys.coverType: coverType, + DocumentHeaderBlockKeys.coverDetails: coverDetails, DocumentHeaderBlockKeys.icon: widget.node.attributes[DocumentHeaderBlockKeys.icon], }; @@ -501,9 +506,13 @@ class DocumentCoverState extends State { switch (widget.coverType) { case CoverType.file: if (isURL(detail)) { - return CachedNetworkImage( - imageUrl: detail, - fit: BoxFit.cover, + final userProfilePB = + context.read().state.userProfilePB; + return FlowyNetworkImage( + url: detail, + userProfilePB: userProfilePB, + errorWidgetBuilder: (context, url, error) => + const SizedBox.shrink(), ); } final imageFile = File(detail); @@ -542,7 +551,11 @@ class DocumentCoverState extends State { triggerActions: PopoverTriggerFlags.none, offset: const Offset(0, 8), direction: PopoverDirection.bottomWithCenterAligned, - constraints: BoxConstraints.loose(const Size(380, 450)), + constraints: const BoxConstraints( + maxWidth: 540, + maxHeight: 360, + minHeight: 80, + ), margin: EdgeInsets.zero, onClose: () => isPopoverOpen = false, child: IntrinsicWidth( @@ -558,23 +571,55 @@ class DocumentCoverState extends State { ), popupBuilder: (BuildContext popoverContext) { isPopoverOpen = true; - return ChangeCoverPopover( - node: widget.node, - editorState: widget.editorState, - onCoverChanged: (cover, selection) => - widget.onCoverChanged(cover, selection), + + return UploadImageMenu( + supportTypes: const [ + UploadImageType.color, + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + ], + onSelectedLocalImage: (path) async { + popoverController.close(); + onCoverChanged(CoverType.file, path); + }, + onSelectedAIImage: (_) { + throw UnimplementedError(); + }, + onSelectedNetworkImage: (url) async { + popoverController.close(); + onCoverChanged(CoverType.file, url); + }, + onSelectedColor: (color) { + popoverController.close(); + onCoverChanged(CoverType.color, color); + }, ); }, ), const HSpace(10), DeleteCoverButton( - onTap: () => widget.onCoverChanged(CoverType.none, null), + onTap: () => onCoverChanged(CoverType.none, null), ), ], ), ); } + Future onCoverChanged(CoverType type, String? details) async { + if (type == CoverType.file && details != null && !isURL(details)) { + final type = await getAuthenticatorType(); + // if the user is using local authenticator, we need to save the image to local storage + if (type == AuthenticatorType.local) { + details = await saveImageToLocalStorage(details); + } else { + // else we should save the image to cloud storage + details = await saveImageToCloudStorage(details); + } + } + widget.onCoverChanged(type, details); + } + void setOverlayButtonsHidden(bool value) { if (isOverlayButtonsHidden == value) return; setState(() { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart index 1112343de8..be1ad90039 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart @@ -6,10 +6,11 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; @@ -22,6 +23,69 @@ import 'package:string_validator/string_validator.dart'; const kImagePlaceholderKey = 'imagePlaceholderKey'; +enum CustomImageType { + local, + internal, // the images saved in self-host cloud + external; // the images linked from network, like unsplash, https://xxx/yyy/zzz.jpg + + static CustomImageType fromIntValue(int value) { + switch (value) { + case 0: + return CustomImageType.local; + case 1: + return CustomImageType.internal; + case 2: + return CustomImageType.external; + default: + throw UnimplementedError(); + } + } + + int toIntValue() { + switch (this) { + case CustomImageType.local: + return 0; + case CustomImageType.internal: + return 1; + case CustomImageType.external: + return 2; + } + } +} + +class CustomImageBlockKeys { + const CustomImageBlockKeys._(); + + static const String type = 'image'; + + /// The align data of a image block. + /// + /// The value is a String. + /// left, center, right + static const String align = 'align'; + + /// The image src of a image block. + /// + /// The value is a String. + /// It can be a url or a base64 string(web). + static const String url = 'url'; + + /// The height of a image block. + /// + /// The value is a double. + static const String width = 'width'; + + /// The width of a image block. + /// + /// The value is a double. + static const String height = 'height'; + + /// The image type of a image block. + /// + /// The value is a CustomImageType enum. + static const String imageType = 'image_type'; +} + typedef CustomImageBlockComponentMenuBuilder = Widget Function( Node node, CustomImageBlockComponentState state, @@ -103,14 +167,16 @@ class CustomImageBlockComponentState extends State Widget build(BuildContext context) { final node = widget.node; final attributes = node.attributes; - final src = attributes[ImageBlockKeys.url]; + final src = attributes[CustomImageBlockKeys.url]; final alignment = AlignmentExtension.fromString( - attributes[ImageBlockKeys.align] ?? 'center', + attributes[CustomImageBlockKeys.align] ?? 'center', ); - final width = attributes[ImageBlockKeys.width]?.toDouble() ?? + final width = attributes[CustomImageBlockKeys.width]?.toDouble() ?? MediaQuery.of(context).size.width; - final height = attributes[ImageBlockKeys.height]?.toDouble(); + final height = attributes[CustomImageBlockKeys.height]?.toDouble(); + final rawImageType = attributes[CustomImageBlockKeys.imageType] ?? 0; + final imageType = CustomImageType.fromIntValue(rawImageType); final imagePlaceholderKey = node.extraInfos?[kImagePlaceholderKey]; Widget child; @@ -119,7 +185,8 @@ class CustomImageBlockComponentState extends State key: imagePlaceholderKey is GlobalKey ? imagePlaceholderKey : null, node: node, ); - } else if (!_checkIfURLIsValid(src)) { + } else if (imageType != CustomImageType.internal && + !_checkIfURLIsValid(src)) { child = const UnSupportImageWidget(); } else { child = ResizableImage( @@ -128,30 +195,39 @@ class CustomImageBlockComponentState extends State height: height, editable: editorState.editable, alignment: alignment, + type: imageType, onResize: (width) { final transaction = editorState.transaction ..updateNode(node, { - ImageBlockKeys.width: width, + CustomImageBlockKeys.width: width, }); editorState.apply(transaction); }, ); } - child = BlockSelectionContainer( - node: node, - delegate: this, - listenable: editorState.selectionNotifier, - blockColor: editorState.editorStyle.selectionColor, - supportTypes: const [ - BlockSelectionType.block, - ], - child: Padding( + if (PlatformExtension.isDesktopOrWeb) { + 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, + ), + ); + } else { + child = Padding( key: imageKey, padding: padding, child: child, - ), - ); + ); + } if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( @@ -176,7 +252,7 @@ class CustomImageBlockComponentState extends State child: ValueListenableBuilder( valueListenable: showActionsNotifier, builder: (context, value, child) { - final url = node.attributes[ImageBlockKeys.url]; + final url = node.attributes[CustomImageBlockKeys.url]; return Stack( children: [ BlockSelectionContainer( @@ -202,6 +278,7 @@ class CustomImageBlockComponentState extends State } else { // show a fixed menu on mobile child = MobileBlockActionButtons( + showThreeDots: false, node: node, editorState: editorState, extendActionWidgets: _buildExtendActionWidgets(context), @@ -282,7 +359,7 @@ class CustomImageBlockComponentState extends State // only used on mobile platform List _buildExtendActionWidgets(BuildContext context) { - final url = widget.node.attributes[ImageBlockKeys.url]; + final url = widget.node.attributes[CustomImageBlockKeys.url]; if (!_checkIfURLIsValid(url)) { return []; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart index d8a9f43151..8dd7a47d6b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart @@ -1,8 +1,12 @@ import 'dart:io'; +import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.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'; @@ -35,6 +39,7 @@ class ImagePlaceholder extends StatefulWidget { class ImagePlaceholderState extends State { final controller = PopoverController(); + final documentService = DocumentService(); late final editorState = context.read(); @override @@ -158,33 +163,35 @@ class ImagePlaceholderState extends State { controller.close(); return; } - final path = await getIt().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); + final transaction = editorState.transaction; + final type = await getAuthenticatorType(); + String? path; + CustomImageType imageType = CustomImageType.local; + + // if the user is using local authenticator, we need to save the image to local storage + if (type == AuthenticatorType.local) { + path = await saveImageToLocalStorage(url); + } else { + // else we should save the image to cloud storage + path = await saveImageToCloudStorage(url); + imageType = CustomImageType.internal; } + + if (path == null && context.mounted) { + showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + return; + } + + transaction.updateNode(widget.node, { + CustomImageBlockKeys.url: path, + CustomImageBlockKeys.imageType: imageType.toIntValue(), + }); + + await editorState.apply(transaction); } Future insertAIImage(String url) async { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart new file mode 100644 index 0000000000..12fbf93b07 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/shared/custom_image_cache_manager.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:path/path.dart' as p; + +Future saveImageToLocalStorage(String localImagePath) async { + final path = await getIt().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(localImagePath)}', + ); + await File(localImagePath).copy( + copyToPath, + ); + return copyToPath; + } catch (e) { + Log.error('cannot save image file', e); + return null; + } +} + +Future saveImageToCloudStorage(String localImagePath) async { + final documentService = DocumentService(); + final result = await documentService.uploadFile( + localFilePath: localImagePath, + ); + return result.fold( + (l) => null, + (r) async { + await CustomImageCacheManager().putFile( + r.url, + File(localImagePath).readAsBytesSync(), + ); + return r.url; + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart new file mode 100644 index 0000000000..2de8449d38 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart @@ -0,0 +1,244 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:string_validator/string_validator.dart'; + +class ResizableImage extends StatefulWidget { + const ResizableImage({ + super.key, + required this.type, + required this.alignment, + required this.editable, + required this.onResize, + required this.width, + required this.src, + this.height, + }); + + final String src; + final CustomImageType type; + final double width; + final double? height; + final Alignment alignment; + final bool editable; + + final void Function(double width) onResize; + + @override + State createState() => _ResizableImageState(); +} + +const _kImageBlockComponentMinWidth = 30.0; + +class _ResizableImageState extends State { + late double imageWidth; + + double initialOffset = 0; + double moveDistance = 0; + + Widget? _cacheImage; + + @visibleForTesting + bool onFocus = false; + + final documentService = DocumentService(); + + UserProfilePB? _userProfilePB; + + @override + void initState() { + super.initState(); + + imageWidth = widget.width; + + if (widget.type == CustomImageType.internal) { + _userProfilePB = context.read().state.userProfilePB; + } + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: widget.alignment, + child: SizedBox( + width: max(_kImageBlockComponentMinWidth, imageWidth - moveDistance), + height: widget.height, + child: MouseRegion( + onEnter: (event) => setState(() { + onFocus = true; + }), + onExit: (event) => setState(() { + onFocus = false; + }), + child: _buildResizableImage(context), + ), + ), + ); + } + + Widget _buildResizableImage(BuildContext context) { + Widget child; + final src = widget.src; + if (isURL(src)) { + // load network image + if (widget.type == CustomImageType.internal && _userProfilePB == null) { + return _buildLoading(context); + } + + _cacheImage ??= FlowyNetworkImage( + url: widget.src, + width: widget.width, + userProfilePB: _userProfilePB, + errorWidgetBuilder: (context, url, error) => + _buildError(context, error), + progressIndicatorBuilder: (context, url, progress) => + _buildLoading(context), + ); + child = _cacheImage!; + } else { + // load local file + _cacheImage ??= Image.file(File(src)); + child = _cacheImage!; + } + return Stack( + children: [ + child, + if (widget.editable) ...[ + _buildEdgeGesture( + context, + top: 0, + left: 5, + bottom: 0, + width: 5, + onUpdate: (distance) { + setState(() { + moveDistance = distance; + }); + }, + ), + _buildEdgeGesture( + context, + top: 0, + right: 5, + bottom: 0, + width: 5, + onUpdate: (distance) { + setState(() { + moveDistance = -distance; + }); + }, + ), + ], + ], + ); + } + + Widget _buildLoading(BuildContext context) { + return SizedBox( + height: 150, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.fromSize( + size: const Size(18, 18), + child: const CircularProgressIndicator(), + ), + SizedBox.fromSize( + size: const Size(10, 10), + ), + Text(AppFlowyEditorL10n.current.loading), + ], + ), + ); + } + + Widget _buildError(BuildContext context, Object error) { + return Container( + height: 100, + width: imageWidth, + alignment: Alignment.center, + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + border: Border.all(width: 1, color: Colors.black), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText(AppFlowyEditorL10n.current.imageLoadFailed), + const VSpace(4), + FlowyText.small( + error.toString(), + textAlign: TextAlign.center, + maxLines: 2, + ), + ], + ), + ); + } + + Widget _buildEdgeGesture( + BuildContext context, { + double? top, + double? left, + double? right, + double? bottom, + double? width, + void Function(double distance)? onUpdate, + }) { + return Positioned( + top: top, + left: left, + right: right, + bottom: bottom, + width: width, + child: GestureDetector( + onHorizontalDragStart: (details) { + initialOffset = details.globalPosition.dx; + }, + onHorizontalDragUpdate: (details) { + if (onUpdate != null) { + var offset = (details.globalPosition.dx - initialOffset); + if (widget.alignment == Alignment.center) { + offset *= 2.0; + } + onUpdate(offset); + } + }, + onHorizontalDragEnd: (details) { + imageWidth = + max(_kImageBlockComponentMinWidth, imageWidth - moveDistance); + initialOffset = 0; + moveDistance = 0; + + widget.onResize(imageWidth); + }, + child: MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: onFocus + ? Center( + child: Container( + height: 40, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: const BorderRadius.all( + Radius.circular(5.0), + ), + border: Border.all(width: 1, color: Colors.white), + ), + ), + ) + : null, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart index 767e80d679..b57226c442 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart @@ -1,4 +1,4 @@ -import 'package:cached_network_image/cached_network_image.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -40,11 +40,10 @@ class CustomLinkPreviewWidget extends StatelessWidget { topLeft: Radius.circular(6.0), bottomLeft: Radius.circular(6.0), ), - child: CachedNetworkImage( - imageUrl: imageUrl!, + child: FlowyNetworkImage( + url: imageUrl!, width: 180, height: 120, - fit: BoxFit.cover, ), ), Expanded( diff --git a/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart new file mode 100644 index 0000000000..1e858970e9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:appflowy/shared/custom_image_cache_manager.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:string_validator/string_validator.dart'; + +/// This widget handles the downloading and caching of either internal or network images. +/// +/// It will append the access token to the URL if the URL is internal. +class FlowyNetworkImage extends StatelessWidget { + const FlowyNetworkImage({ + super.key, + this.userProfilePB, + this.width, + this.height, + this.fit = BoxFit.cover, + this.progressIndicatorBuilder, + this.errorWidgetBuilder, + required this.url, + }); + + final UserProfilePB? userProfilePB; + final String url; + final double? width; + final double? height; + final BoxFit fit; + final ProgressIndicatorBuilder? progressIndicatorBuilder; + final LoadingErrorWidgetBuilder? errorWidgetBuilder; + + @override + Widget build(BuildContext context) { + assert(isURL(url)); + + if (url.contains('beta.appflowy')) { + assert(userProfilePB != null && userProfilePB!.token.isNotEmpty); + } + + return CachedNetworkImage( + cacheManager: CustomImageCacheManager(), + httpHeaders: _header(), + imageUrl: url, + fit: fit, + width: width, + height: height, + progressIndicatorBuilder: progressIndicatorBuilder, + errorWidget: (context, url, error) => + errorWidgetBuilder?.call(context, url, error) ?? + const SizedBox.shrink(), + ); + } + + Map _header() { + final header = {}; + final token = userProfilePB?.token; + if (token != null) { + header['Authorization'] = 'Bearer ${jsonDecode(token)['access_token']}'; + } + return header; + } +} diff --git a/frontend/appflowy_flutter/lib/shared/custom_image_cache_manager.dart b/frontend/appflowy_flutter/lib/shared/custom_image_cache_manager.dart new file mode 100644 index 0000000000..eebf316542 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/custom_image_cache_manager.dart @@ -0,0 +1,13 @@ +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +class CustomImageCacheManager extends CacheManager with ImageCacheManager { + static const key = 'appflowy_image_cache'; + + static final CustomImageCacheManager _instance = CustomImageCacheManager._(); + + factory CustomImageCacheManager() { + return _instance; + } + + CustomImageCacheManager._() : super(Config(key)); +} diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 47c4cb03ec..a81a424f8d 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -564,7 +564,7 @@ packages: source: hosted version: "8.1.3" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 80c179a9b6..55cfc83b0e 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -128,6 +128,7 @@ dependencies: leak_tracker: ^9.0.6 keyboard_height_plugin: ^0.0.5 scrollable_positioned_list: ^0.3.8 + flutter_cache_manager: ^3.3.1 dev_dependencies: flutter_lints: ^3.0.1 diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index f89e00a8be..91a22843fc 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -1784,7 +1784,6 @@ dependencies = [ "flowy-notification", "flowy-storage", "futures", - "fxhash", "getrandom 0.2.10", "indexmap 2.1.0", "lib-dispatch", @@ -1991,6 +1990,7 @@ dependencies = [ "async-trait", "bytes", "flowy-error", + "fxhash", "lib-infra", "mime", "mime_guess", diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 111a79d88c..f2f1c81857 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -1777,7 +1777,6 @@ dependencies = [ "flowy-notification", "flowy-storage", "futures", - "fxhash", "getrandom 0.2.10", "indexmap 2.1.0", "lib-dispatch", @@ -1993,6 +1992,7 @@ dependencies = [ "async-trait", "bytes", "flowy-error", + "fxhash", "lib-infra", "mime", "mime_guess", diff --git a/frontend/rust-lib/flowy-document/Cargo.toml b/frontend/rust-lib/flowy-document/Cargo.toml index 02fb73b83a..6a684ef9e0 100644 --- a/frontend/rust-lib/flowy-document/Cargo.toml +++ b/frontend/rust-lib/flowy-document/Cargo.toml @@ -36,7 +36,6 @@ futures.workspace = true tokio-stream = { workspace = true, features = ["sync"] } scraper = "0.18.0" lru.workspace = true -fxhash = "0.2.1" [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { version = "0.2", features = ["js"]} diff --git a/frontend/rust-lib/flowy-document/src/manager.rs b/frontend/rust-lib/flowy-document/src/manager.rs index 809a5819d9..a94fdbdfad 100644 --- a/frontend/rust-lib/flowy-document/src/manager.rs +++ b/frontend/rust-lib/flowy-document/src/manager.rs @@ -10,8 +10,7 @@ use collab_document::blocks::DocumentData; use collab_document::document::Document; use collab_document::document_data::default_document_data; use collab_entity::CollabType; -use flowy_storage::ObjectIdentity; -use flowy_storage::ObjectValue; +use flowy_storage::object_from_disk; use lru::LruCache; use parking_lot::Mutex; use tokio::io::AsyncWriteExt; @@ -257,18 +256,9 @@ impl DocumentManager { workspace_id: String, local_file_path: &str, ) -> FlowyResult { - let object_value = ObjectValue::from_file(local_file_path).await?; - + let (object_identity, object_value) = object_from_disk(&workspace_id, local_file_path).await?; let storage_service = self.storage_service_upgrade()?; - let url = { - let hash = fxhash::hash(object_value.raw.as_ref()); - storage_service - .get_object_url(ObjectIdentity { - workspace_id: workspace_id.to_owned(), - file_id: hash.to_string(), - }) - .await? - }; + let url = storage_service.get_object_url(object_identity).await?; // let the upload happen in the background let clone_url = url.clone(); diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs index 874949b21b..839a8b5ed1 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs @@ -19,8 +19,9 @@ where fn get_object_url(&self, object_id: ObjectIdentity) -> FutureResult { let try_get_client = self.0.try_get_client(); FutureResult::new(async move { + let file_name = format!("{}.{}", object_id.file_id, object_id.ext); let client = try_get_client?; - let url = client.get_blob_url(&object_id.workspace_id, &object_id.file_id); + let url = client.get_blob_url(&object_id.workspace_id, &file_name); Ok(url) }) } diff --git a/frontend/rust-lib/flowy-storage/Cargo.toml b/frontend/rust-lib/flowy-storage/Cargo.toml index 67bad269be..db3f0a330c 100644 --- a/frontend/rust-lib/flowy-storage/Cargo.toml +++ b/frontend/rust-lib/flowy-storage/Cargo.toml @@ -18,3 +18,4 @@ flowy-error = { workspace = true, features = ["impl_from_reqwest"] } mime = "0.3.17" tokio.workspace = true tracing.workspace = true +fxhash = "0.2.1" diff --git a/frontend/rust-lib/flowy-storage/src/lib.rs b/frontend/rust-lib/flowy-storage/src/lib.rs index 7fe91a3455..448cedbbcb 100644 --- a/frontend/rust-lib/flowy-storage/src/lib.rs +++ b/frontend/rust-lib/flowy-storage/src/lib.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use bytes::Bytes; use flowy_error::FlowyError; @@ -9,6 +11,7 @@ use tracing::info; pub struct ObjectIdentity { pub workspace_id: String, pub file_id: String, + pub ext: String, } #[derive(Clone)] @@ -17,19 +20,33 @@ pub struct ObjectValue { pub mime: Mime, } -impl ObjectValue { - pub async fn from_file(local_file_path: &str) -> Result { - let mut file = tokio::fs::File::open(local_file_path).await?; - let mut content = Vec::new(); - let n = file.read_to_end(&mut content).await?; - info!("read {} bytes from file: {}", n, local_file_path); - let mime = mime_guess::from_path(local_file_path).first_or_octet_stream(); +pub async fn object_from_disk( + workspace_id: &str, + local_file_path: &str, +) -> Result<(ObjectIdentity, ObjectValue), FlowyError> { + let ext = Path::new(local_file_path) + .extension() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or("") + .to_owned(); + let mut file = tokio::fs::File::open(local_file_path).await?; + let mut content = Vec::new(); + let n = file.read_to_end(&mut content).await?; + info!("read {} bytes from file: {}", n, local_file_path); + let mime = mime_guess::from_path(local_file_path).first_or_octet_stream(); + let hash = fxhash::hash(&content); - Ok(ObjectValue { + Ok(( + ObjectIdentity { + workspace_id: workspace_id.to_owned(), + file_id: hash.to_string(), + ext, + }, + ObjectValue { raw: content.into(), mime, - }) - } + }, + )) } /// Provides a service for object storage.