mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
@ -43,8 +43,6 @@ void main() {
|
|||||||
await tester.editor.hoverOnCover();
|
await tester.editor.hoverOnCover();
|
||||||
await tester.editor.tapOnChangeCover();
|
await tester.editor.tapOnChangeCover();
|
||||||
await tester.editor.addNetworkImageCover(imageUrl);
|
await tester.editor.addNetworkImageCover(imageUrl);
|
||||||
await tester.editor.switchNetworkImageCover(imageUrl);
|
|
||||||
await tester.editor.dismissCoverPicker();
|
|
||||||
tester.expectToSeeDocumentCover(CoverType.file);
|
tester.expectToSeeDocumentCover(CoverType.file);
|
||||||
|
|
||||||
// Remove the cover
|
// Remove the cover
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
import 'dart:io';
|
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/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/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/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/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/unsplash_image_widget.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.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_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:easy_localization/easy_localization.dart';
|
||||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -61,6 +66,7 @@ void main() {
|
|||||||
paths: [imagePath],
|
paths: [imagePath],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
|
||||||
await tester.tapButtonWithName(
|
await tester.tapButtonWithName(
|
||||||
LocaleKeys.document_imageBlock_upload_placeholder.tr(),
|
LocaleKeys.document_imageBlock_upload_placeholder.tr(),
|
||||||
);
|
);
|
||||||
|
@ -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/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/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/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/document_header_node_widget.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_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/plugins/inline_actions/widgets/inline_actions_handler.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
@ -121,19 +121,25 @@ class EditorOperations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addNetworkImageCover(String imageUrl) async {
|
Future<void> addNetworkImageCover(String imageUrl) async {
|
||||||
final findNewImageButton = find.byType(NewCustomCoverButton);
|
final embedLinkButton = find.findTextInFlowyText(
|
||||||
await tester.tapButton(findNewImageButton);
|
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||||
|
);
|
||||||
|
await tester.tapButton(embedLinkButton);
|
||||||
|
|
||||||
final imageUrlTextField = find.descendant(
|
final imageUrlTextField = find.descendant(
|
||||||
of: find.byType(NetworkImageUrlInput),
|
of: find.byType(EmbedImageUrlWidget),
|
||||||
matching: find.byType(TextField),
|
matching: find.byType(TextField),
|
||||||
);
|
);
|
||||||
await tester.enterText(imageUrlTextField, imageUrl);
|
final textField = tester.widget<TextField>(imageUrlTextField);
|
||||||
await tester.tapButtonWithName(
|
textField.controller?.text = imageUrl;
|
||||||
LocaleKeys.document_plugins_cover_add.tr(),
|
await tester.pumpAndSettle();
|
||||||
);
|
await tester.tapButton(
|
||||||
await tester.tapButtonWithName(
|
find.descendant(
|
||||||
LocaleKeys.document_plugins_cover_saveToGallery.tr(),
|
of: find.byType(EmbedImageUrlWidget),
|
||||||
|
matching: find.findTextInFlowyText(
|
||||||
|
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class MobileHomeScreen extends StatelessWidget {
|
class MobileHomeScreen extends StatelessWidget {
|
||||||
const MobileHomeScreen({super.key});
|
const MobileHomeScreen({super.key});
|
||||||
@ -51,11 +52,14 @@ class MobileHomeScreen extends StatelessWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
child: Provider.value(
|
||||||
|
value: userProfile,
|
||||||
child: MobileHomePage(
|
child: MobileHomePage(
|
||||||
userProfile: userProfile,
|
userProfile: userProfile,
|
||||||
workspaceSetting: workspaceSetting,
|
workspaceSetting: workspaceSetting,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -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/base/emoji/emoji_text.dart';
|
||||||
import 'package:appflowy/plugins/document/application/document_data_pb_extension.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/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/doc/doc_listener.dart';
|
||||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.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-folder/view.pb.dart';
|
||||||
|
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.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:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:string_validator/string_validator.dart';
|
import 'package:string_validator/string_validator.dart';
|
||||||
|
|
||||||
class MobileRecentView extends StatefulWidget {
|
class MobileRecentView extends StatefulWidget {
|
||||||
@ -161,9 +163,10 @@ class _MobileRecentViewState extends State<MobileRecentView> {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case CoverType.file:
|
case CoverType.file:
|
||||||
if (isURL(cover)) {
|
if (isURL(cover)) {
|
||||||
return CachedNetworkImage(
|
final userProfilePB = Provider.of<UserProfilePB?>(context);
|
||||||
imageUrl: cover,
|
return FlowyNetworkImage(
|
||||||
fit: BoxFit.cover,
|
url: cover,
|
||||||
|
userProfilePB: userProfilePB,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final imageFile = File(cover);
|
final imageFile = File(cover);
|
||||||
|
@ -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/document_data_pb_extension.dart';
|
||||||
import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
|
import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
|
||||||
import 'package:appflowy/plugins/trash/application/trash_service.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/doc_listener.dart';
|
||||||
import 'package:appflowy/workspace/application/doc/sync_state_listener.dart';
|
import 'package:appflowy/workspace/application/doc/sync_state_listener.dart';
|
||||||
import 'package:appflowy/workspace/application/view/view_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();
|
final editorState = await _fetchDocumentState();
|
||||||
_onViewChanged();
|
_onViewChanged();
|
||||||
_onDocumentChanged();
|
_onDocumentChanged();
|
||||||
editorState.fold(
|
await editorState.fold(
|
||||||
(l) => emit(
|
(l) async => emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
error: l,
|
error: l,
|
||||||
editorState: null,
|
editorState: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(r) => emit(
|
(r) async {
|
||||||
|
final result = await getIt<AuthService>().getUser();
|
||||||
|
final userProfilePB = result.fold((l) => null, (r) => r);
|
||||||
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
error: null,
|
error: null,
|
||||||
editorState: r,
|
editorState: r,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
userProfilePB: userProfilePB,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
moveToTrash: () async {
|
moveToTrash: () async {
|
||||||
|
@ -100,4 +100,37 @@ class DocumentService {
|
|||||||
final result = await DocumentEventApplyTextDeltaEvent(payload).send();
|
final result = await DocumentEventApplyTextDeltaEvent(payload).send();
|
||||||
return result.swap();
|
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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,22 +1,26 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:appflowy/env/cloud_env.dart';
|
||||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.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/emoji/emoji_picker_screen.dart';
|
||||||
import 'package:appflowy/plugins/base/icon/icon_picker.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/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_plugins/image/upload_image_menu.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_style.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/workspace/application/view/view_listener.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu;
|
import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu;
|
||||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
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:easy_localization/easy_localization.dart';
|
||||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:string_validator/string_validator.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 transaction = widget.editorState.transaction;
|
||||||
|
final coverType = widget.node.attributes[DocumentHeaderBlockKeys.coverType];
|
||||||
|
final coverDetails =
|
||||||
|
widget.node.attributes[DocumentHeaderBlockKeys.coverDetails];
|
||||||
final Map<String, dynamic> attributes = {
|
final Map<String, dynamic> attributes = {
|
||||||
DocumentHeaderBlockKeys.coverType:
|
DocumentHeaderBlockKeys.coverType: coverType,
|
||||||
widget.node.attributes[DocumentHeaderBlockKeys.coverType],
|
DocumentHeaderBlockKeys.coverDetails: coverDetails,
|
||||||
DocumentHeaderBlockKeys.coverDetails:
|
|
||||||
widget.node.attributes[DocumentHeaderBlockKeys.coverDetails],
|
|
||||||
DocumentHeaderBlockKeys.icon:
|
DocumentHeaderBlockKeys.icon:
|
||||||
widget.node.attributes[DocumentHeaderBlockKeys.icon],
|
widget.node.attributes[DocumentHeaderBlockKeys.icon],
|
||||||
};
|
};
|
||||||
@ -501,9 +506,13 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||||||
switch (widget.coverType) {
|
switch (widget.coverType) {
|
||||||
case CoverType.file:
|
case CoverType.file:
|
||||||
if (isURL(detail)) {
|
if (isURL(detail)) {
|
||||||
return CachedNetworkImage(
|
final userProfilePB =
|
||||||
imageUrl: detail,
|
context.read<DocumentBloc>().state.userProfilePB;
|
||||||
fit: BoxFit.cover,
|
return FlowyNetworkImage(
|
||||||
|
url: detail,
|
||||||
|
userProfilePB: userProfilePB,
|
||||||
|
errorWidgetBuilder: (context, url, error) =>
|
||||||
|
const SizedBox.shrink(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final imageFile = File(detail);
|
final imageFile = File(detail);
|
||||||
@ -542,7 +551,11 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||||||
triggerActions: PopoverTriggerFlags.none,
|
triggerActions: PopoverTriggerFlags.none,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
direction: PopoverDirection.bottomWithCenterAligned,
|
direction: PopoverDirection.bottomWithCenterAligned,
|
||||||
constraints: BoxConstraints.loose(const Size(380, 450)),
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: 540,
|
||||||
|
maxHeight: 360,
|
||||||
|
minHeight: 80,
|
||||||
|
),
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
onClose: () => isPopoverOpen = false,
|
onClose: () => isPopoverOpen = false,
|
||||||
child: IntrinsicWidth(
|
child: IntrinsicWidth(
|
||||||
@ -558,23 +571,55 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||||||
),
|
),
|
||||||
popupBuilder: (BuildContext popoverContext) {
|
popupBuilder: (BuildContext popoverContext) {
|
||||||
isPopoverOpen = true;
|
isPopoverOpen = true;
|
||||||
return ChangeCoverPopover(
|
|
||||||
node: widget.node,
|
return UploadImageMenu(
|
||||||
editorState: widget.editorState,
|
supportTypes: const [
|
||||||
onCoverChanged: (cover, selection) =>
|
UploadImageType.color,
|
||||||
widget.onCoverChanged(cover, selection),
|
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),
|
const HSpace(10),
|
||||||
DeleteCoverButton(
|
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) {
|
void setOverlayButtonsHidden(bool value) {
|
||||||
if (isOverlayButtonsHidden == value) return;
|
if (isOverlayButtonsHidden == value) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
@ -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/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/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/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/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart';
|
||||||
import 'package:appflowy/startup/startup.dart';
|
import 'package:appflowy/startup/startup.dart';
|
||||||
import 'package:appflowy/workspace/presentation/home/toast.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:easy_localization/easy_localization.dart';
|
||||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@ -22,6 +23,69 @@ import 'package:string_validator/string_validator.dart';
|
|||||||
|
|
||||||
const kImagePlaceholderKey = 'imagePlaceholderKey';
|
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(
|
typedef CustomImageBlockComponentMenuBuilder = Widget Function(
|
||||||
Node node,
|
Node node,
|
||||||
CustomImageBlockComponentState state,
|
CustomImageBlockComponentState state,
|
||||||
@ -103,14 +167,16 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final node = widget.node;
|
final node = widget.node;
|
||||||
final attributes = node.attributes;
|
final attributes = node.attributes;
|
||||||
final src = attributes[ImageBlockKeys.url];
|
final src = attributes[CustomImageBlockKeys.url];
|
||||||
|
|
||||||
final alignment = AlignmentExtension.fromString(
|
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;
|
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];
|
final imagePlaceholderKey = node.extraInfos?[kImagePlaceholderKey];
|
||||||
Widget child;
|
Widget child;
|
||||||
@ -119,7 +185,8 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
|||||||
key: imagePlaceholderKey is GlobalKey ? imagePlaceholderKey : null,
|
key: imagePlaceholderKey is GlobalKey ? imagePlaceholderKey : null,
|
||||||
node: node,
|
node: node,
|
||||||
);
|
);
|
||||||
} else if (!_checkIfURLIsValid(src)) {
|
} else if (imageType != CustomImageType.internal &&
|
||||||
|
!_checkIfURLIsValid(src)) {
|
||||||
child = const UnSupportImageWidget();
|
child = const UnSupportImageWidget();
|
||||||
} else {
|
} else {
|
||||||
child = ResizableImage(
|
child = ResizableImage(
|
||||||
@ -128,16 +195,18 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
|||||||
height: height,
|
height: height,
|
||||||
editable: editorState.editable,
|
editable: editorState.editable,
|
||||||
alignment: alignment,
|
alignment: alignment,
|
||||||
|
type: imageType,
|
||||||
onResize: (width) {
|
onResize: (width) {
|
||||||
final transaction = editorState.transaction
|
final transaction = editorState.transaction
|
||||||
..updateNode(node, {
|
..updateNode(node, {
|
||||||
ImageBlockKeys.width: width,
|
CustomImageBlockKeys.width: width,
|
||||||
});
|
});
|
||||||
editorState.apply(transaction);
|
editorState.apply(transaction);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (PlatformExtension.isDesktopOrWeb) {
|
||||||
child = BlockSelectionContainer(
|
child = BlockSelectionContainer(
|
||||||
node: node,
|
node: node,
|
||||||
delegate: this,
|
delegate: this,
|
||||||
@ -152,6 +221,13 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
|||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
child = Padding(
|
||||||
|
key: imageKey,
|
||||||
|
padding: padding,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (widget.showActions && widget.actionBuilder != null) {
|
if (widget.showActions && widget.actionBuilder != null) {
|
||||||
child = BlockComponentActionWrapper(
|
child = BlockComponentActionWrapper(
|
||||||
@ -176,7 +252,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
|||||||
child: ValueListenableBuilder<bool>(
|
child: ValueListenableBuilder<bool>(
|
||||||
valueListenable: showActionsNotifier,
|
valueListenable: showActionsNotifier,
|
||||||
builder: (context, value, child) {
|
builder: (context, value, child) {
|
||||||
final url = node.attributes[ImageBlockKeys.url];
|
final url = node.attributes[CustomImageBlockKeys.url];
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
BlockSelectionContainer(
|
BlockSelectionContainer(
|
||||||
@ -202,6 +278,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
|||||||
} else {
|
} else {
|
||||||
// show a fixed menu on mobile
|
// show a fixed menu on mobile
|
||||||
child = MobileBlockActionButtons(
|
child = MobileBlockActionButtons(
|
||||||
|
showThreeDots: false,
|
||||||
node: node,
|
node: node,
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
extendActionWidgets: _buildExtendActionWidgets(context),
|
extendActionWidgets: _buildExtendActionWidgets(context),
|
||||||
@ -282,7 +359,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
|||||||
|
|
||||||
// only used on mobile platform
|
// only used on mobile platform
|
||||||
List<Widget> _buildExtendActionWidgets(BuildContext context) {
|
List<Widget> _buildExtendActionWidgets(BuildContext context) {
|
||||||
final url = widget.node.attributes[ImageBlockKeys.url];
|
final url = widget.node.attributes[CustomImageBlockKeys.url];
|
||||||
if (!_checkIfURLIsValid(url)) {
|
if (!_checkIfURLIsValid(url)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:appflowy/env/cloud_env.dart';
|
||||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.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/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
|
||||||
import 'package:appflowy/startup/startup.dart';
|
import 'package:appflowy/startup/startup.dart';
|
||||||
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
|
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
|
||||||
@ -35,6 +39,7 @@ class ImagePlaceholder extends StatefulWidget {
|
|||||||
|
|
||||||
class ImagePlaceholderState extends State<ImagePlaceholder> {
|
class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||||
final controller = PopoverController();
|
final controller = PopoverController();
|
||||||
|
final documentService = DocumentService();
|
||||||
late final editorState = context.read<EditorState>();
|
late final editorState = context.read<EditorState>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -158,33 +163,35 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
|||||||
controller.close();
|
controller.close();
|
||||||
return;
|
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;
|
final transaction = editorState.transaction;
|
||||||
transaction.updateNode(widget.node, {
|
final type = await getAuthenticatorType();
|
||||||
ImageBlockKeys.url: copyToPath,
|
String? path;
|
||||||
});
|
CustomImageType imageType = CustomImageType.local;
|
||||||
await editorState.apply(transaction);
|
|
||||||
} catch (e) {
|
// if the user is using local authenticator, we need to save the image to local storage
|
||||||
Log.error('cannot copy image file', e);
|
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 {
|
Future<void> insertAIImage(String url) async {
|
||||||
|
@ -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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
@ -40,11 +40,10 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
|||||||
topLeft: Radius.circular(6.0),
|
topLeft: Radius.circular(6.0),
|
||||||
bottomLeft: Radius.circular(6.0),
|
bottomLeft: Radius.circular(6.0),
|
||||||
),
|
),
|
||||||
child: CachedNetworkImage(
|
child: FlowyNetworkImage(
|
||||||
imageUrl: imageUrl!,
|
url: imageUrl!,
|
||||||
width: 180,
|
width: 180,
|
||||||
height: 120,
|
height: 120,
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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));
|
||||||
|
}
|
@ -564,7 +564,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.3"
|
version: "8.1.3"
|
||||||
flutter_cache_manager:
|
flutter_cache_manager:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_cache_manager
|
name: flutter_cache_manager
|
||||||
sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba"
|
sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba"
|
||||||
|
@ -128,6 +128,7 @@ dependencies:
|
|||||||
leak_tracker: ^9.0.6
|
leak_tracker: ^9.0.6
|
||||||
keyboard_height_plugin: ^0.0.5
|
keyboard_height_plugin: ^0.0.5
|
||||||
scrollable_positioned_list: ^0.3.8
|
scrollable_positioned_list: ^0.3.8
|
||||||
|
flutter_cache_manager: ^3.3.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_lints: ^3.0.1
|
flutter_lints: ^3.0.1
|
||||||
|
2
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
2
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
@ -1784,7 +1784,6 @@ dependencies = [
|
|||||||
"flowy-notification",
|
"flowy-notification",
|
||||||
"flowy-storage",
|
"flowy-storage",
|
||||||
"futures",
|
"futures",
|
||||||
"fxhash",
|
|
||||||
"getrandom 0.2.10",
|
"getrandom 0.2.10",
|
||||||
"indexmap 2.1.0",
|
"indexmap 2.1.0",
|
||||||
"lib-dispatch",
|
"lib-dispatch",
|
||||||
@ -1991,6 +1990,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
"flowy-error",
|
"flowy-error",
|
||||||
|
"fxhash",
|
||||||
"lib-infra",
|
"lib-infra",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
2
frontend/rust-lib/Cargo.lock
generated
2
frontend/rust-lib/Cargo.lock
generated
@ -1777,7 +1777,6 @@ dependencies = [
|
|||||||
"flowy-notification",
|
"flowy-notification",
|
||||||
"flowy-storage",
|
"flowy-storage",
|
||||||
"futures",
|
"futures",
|
||||||
"fxhash",
|
|
||||||
"getrandom 0.2.10",
|
"getrandom 0.2.10",
|
||||||
"indexmap 2.1.0",
|
"indexmap 2.1.0",
|
||||||
"lib-dispatch",
|
"lib-dispatch",
|
||||||
@ -1993,6 +1992,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
"flowy-error",
|
"flowy-error",
|
||||||
|
"fxhash",
|
||||||
"lib-infra",
|
"lib-infra",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
@ -36,7 +36,6 @@ futures.workspace = true
|
|||||||
tokio-stream = { workspace = true, features = ["sync"] }
|
tokio-stream = { workspace = true, features = ["sync"] }
|
||||||
scraper = "0.18.0"
|
scraper = "0.18.0"
|
||||||
lru.workspace = true
|
lru.workspace = true
|
||||||
fxhash = "0.2.1"
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
getrandom = { version = "0.2", features = ["js"]}
|
getrandom = { version = "0.2", features = ["js"]}
|
||||||
|
@ -10,8 +10,7 @@ use collab_document::blocks::DocumentData;
|
|||||||
use collab_document::document::Document;
|
use collab_document::document::Document;
|
||||||
use collab_document::document_data::default_document_data;
|
use collab_document::document_data::default_document_data;
|
||||||
use collab_entity::CollabType;
|
use collab_entity::CollabType;
|
||||||
use flowy_storage::ObjectIdentity;
|
use flowy_storage::object_from_disk;
|
||||||
use flowy_storage::ObjectValue;
|
|
||||||
use lru::LruCache;
|
use lru::LruCache;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
@ -257,18 +256,9 @@ impl DocumentManager {
|
|||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
local_file_path: &str,
|
local_file_path: &str,
|
||||||
) -> FlowyResult<String> {
|
) -> 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 storage_service = self.storage_service_upgrade()?;
|
||||||
let url = {
|
let url = storage_service.get_object_url(object_identity).await?;
|
||||||
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 the upload happen in the background
|
// let the upload happen in the background
|
||||||
let clone_url = url.clone();
|
let clone_url = url.clone();
|
||||||
|
@ -19,8 +19,9 @@ where
|
|||||||
fn get_object_url(&self, object_id: ObjectIdentity) -> FutureResult<String, FlowyError> {
|
fn get_object_url(&self, object_id: ObjectIdentity) -> FutureResult<String, FlowyError> {
|
||||||
let try_get_client = self.0.try_get_client();
|
let try_get_client = self.0.try_get_client();
|
||||||
FutureResult::new(async move {
|
FutureResult::new(async move {
|
||||||
|
let file_name = format!("{}.{}", object_id.file_id, object_id.ext);
|
||||||
let client = try_get_client?;
|
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)
|
Ok(url)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -18,3 +18,4 @@ flowy-error = { workspace = true, features = ["impl_from_reqwest"] }
|
|||||||
mime = "0.3.17"
|
mime = "0.3.17"
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
fxhash = "0.2.1"
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
|
||||||
use flowy_error::FlowyError;
|
use flowy_error::FlowyError;
|
||||||
@ -9,6 +11,7 @@ use tracing::info;
|
|||||||
pub struct ObjectIdentity {
|
pub struct ObjectIdentity {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub file_id: String,
|
pub file_id: String,
|
||||||
|
pub ext: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -17,19 +20,33 @@ pub struct ObjectValue {
|
|||||||
pub mime: Mime,
|
pub mime: Mime,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ObjectValue {
|
pub async fn object_from_disk(
|
||||||
pub async fn from_file(local_file_path: &str) -> Result<Self, FlowyError> {
|
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 file = tokio::fs::File::open(local_file_path).await?;
|
||||||
let mut content = Vec::new();
|
let mut content = Vec::new();
|
||||||
let n = file.read_to_end(&mut content).await?;
|
let n = file.read_to_end(&mut content).await?;
|
||||||
info!("read {} bytes from file: {}", n, local_file_path);
|
info!("read {} bytes from file: {}", n, local_file_path);
|
||||||
let mime = mime_guess::from_path(local_file_path).first_or_octet_stream();
|
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(),
|
raw: content.into(),
|
||||||
mime,
|
mime,
|
||||||
})
|
},
|
||||||
}
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provides a service for object storage.
|
/// Provides a service for object storage.
|
||||||
|
Reference in New Issue
Block a user