feat: support uploading image to cloud storage (#4413)

* feat: add object ext

* feat: integrate upload image api

* feat: support uploading local file to cloud

* feat: abstact the CachedNetworkImage as FlowyNetworkImage

* ci: fix tauri ci

* fix: integration test

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
Co-authored-by: nathan <nathan@appflowy.io>
This commit is contained in:
Zack 2024-01-20 23:16:18 +08:00 committed by GitHub
parent 4852e5c8d4
commit 0a0f2adf76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 687 additions and 122 deletions

View File

@ -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

View File

@ -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<KeyValueStorage>().set(KVKeys.kCloudType, '0');
await tester.tapButtonWithName(
LocaleKeys.document_imageBlock_upload_placeholder.tr(),
);

View File

@ -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<void> 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<TextField>(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(),
),
),
);
}

View File

@ -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,
),
),
),
);

View File

@ -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<MobileRecentView> {
switch (type) {
case CoverType.file:
if (isURL(cover)) {
return CachedNetworkImage(
imageUrl: cover,
fit: BoxFit.cover,
final userProfilePB = Provider.of<UserProfilePB?>(context);
return FlowyNetworkImage(
url: cover,
userProfilePB: userProfilePB,
);
}
final imageFile = File(cover);

View File

@ -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<DocumentEvent, DocumentState> {
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<AuthService>().getUser();
final userProfilePB = result.fold((l) => null, (r) => r);
emit(
state.copyWith(
error: null,
editorState: r,
isLoading: false,
userProfilePB: userProfilePB,
),
);
},
);
},
moveToTrash: () async {

View File

@ -100,4 +100,37 @@ class DocumentService {
final result = await DocumentEventApplyTextDeltaEvent(payload).send();
return result.swap();
}
/// Upload a file to the cloud storage.
Future<Either<FlowyError, UploadedFilePB>> 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<Either<FlowyError, Unit>> 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'));
});
}
}

View File

@ -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<DocumentHeaderNodeWidget> {
}
}
Future<void> _saveCover({(CoverType, String?)? cover, String? icon}) {
Future<void> _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<String, dynamic> 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<DocumentCover> {
switch (widget.coverType) {
case CoverType.file:
if (isURL(detail)) {
return CachedNetworkImage(
imageUrl: detail,
fit: BoxFit.cover,
final userProfilePB =
context.read<DocumentBloc>().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<DocumentCover> {
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<DocumentCover> {
),
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<void> 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(() {

View File

@ -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<CustomImageBlockComponent>
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<CustomImageBlockComponent>
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<CustomImageBlockComponent>
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<CustomImageBlockComponent>
child: ValueListenableBuilder<bool>(
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<CustomImageBlockComponent>
} 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<CustomImageBlockComponent>
// only used on mobile platform
List<Widget> _buildExtendActionWidgets(BuildContext context) {
final url = widget.node.attributes[ImageBlockKeys.url];
final url = widget.node.attributes[CustomImageBlockKeys.url];
if (!_checkIfURLIsValid(url)) {
return [];
}

View File

@ -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<ImagePlaceholder> {
final controller = PopoverController();
final documentService = DocumentService();
late final editorState = context.read<EditorState>();
@override
@ -158,33 +163,35 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
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);
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<void> insertAIImage(String url) async {

View File

@ -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<String?> saveImageToLocalStorage(String localImagePath) async {
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(localImagePath)}',
);
await File(localImagePath).copy(
copyToPath,
);
return copyToPath;
} catch (e) {
Log.error('cannot save image file', e);
return null;
}
}
Future<String?> 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;
},
);
}

View File

@ -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<ResizableImage> createState() => _ResizableImageState();
}
const _kImageBlockComponentMinWidth = 30.0;
class _ResizableImageState extends State<ResizableImage> {
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<DocumentBloc>().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,
),
),
);
}
}

View File

@ -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(

View File

@ -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<String, String> _header() {
final header = <String, String>{};
final token = userProfilePB?.token;
if (token != null) {
header['Authorization'] = 'Bearer ${jsonDecode(token)['access_token']}';
}
return header;
}
}

View File

@ -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));
}

View File

@ -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"

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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"]}

View File

@ -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<String> {
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();

View File

@ -19,8 +19,9 @@ where
fn get_object_url(&self, object_id: ObjectIdentity) -> FutureResult<String, FlowyError> {
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)
})
}

View File

@ -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"

View File

@ -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<Self, FlowyError> {
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.