From 792573f46d33610e23d1726bd679c6165015ebfc Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Tue, 30 Jan 2024 22:39:42 +0800 Subject: [PATCH] feat: optimize image block (#4553) * feat: add tooltip for maximum image size * feat: add maximum upload image size tooltip * feat: limit image size to 10MB * fix: disable copy link option for cloud image * fix: disable copy link option for cloud image * feat: use regex to match the appflowy.cloud image --- frontend/appflowy_flutter/ios/Podfile.lock | 33 +++++------------- .../header/document_header_node_widget.dart | 14 +++++--- .../image/custom_image_block_component.dart | 34 +++++++++++-------- .../editor_plugins/image/image_menu.dart | 15 +++++--- .../image/image_placeholder.dart | 25 ++++++++++++-- .../image/upload_image_menu.dart | 18 ++++++++-- .../lib/shared/appflowy_network_image.dart | 3 +- .../lib/util/file_extension.dart | 11 ++++++ .../lib/util/string_extension.dart | 20 ++++++++++- frontend/resources/translations/en.json | 4 ++- 10 files changed, 120 insertions(+), 57 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/util/file_extension.dart diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index 9057a91ab5..c707a32587 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -48,9 +48,6 @@ PODS: - fluttertoast (0.0.2): - Flutter - Toast - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - image_gallery_saver (2.0.2): - Flutter - image_picker_ios (0.0.1): @@ -75,19 +72,15 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sign_in_with_apple (0.0.1): - - Flutter - sqflite (0.0.3): - Flutter - - FMDB (>= 2.7.5) + - FlutterMacOS - super_native_extensions (0.0.1): - Flutter - SwiftyGif (5.4.3) - Toast (4.0.0) - url_launcher_ios (0.0.1): - Flutter - - webview_flutter_wkwebview (0.0.1): - - Flutter DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) @@ -107,17 +100,14 @@ DEPENDENCIES: - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - rich_clipboard_ios (from `.symlinks/plugins/rich_clipboard_ios/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) - - sqflite (from `.symlinks/plugins/sqflite/ios`) + - sqflite (from `.symlinks/plugins/sqflite/darwin`) - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`) SPEC REPOS: trunk: - DKImagePickerController - DKPhotoGallery - - FMDB - ReachabilitySwift - SDWebImage - SwiftyGif @@ -158,16 +148,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/rich_clipboard_ios/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sign_in_with_apple: - :path: ".symlinks/plugins/sign_in_with_apple/ios" sqflite: - :path: ".symlinks/plugins/sqflite/ios" + :path: ".symlinks/plugins/sqflite/darwin" super_native_extensions: :path: ".symlinks/plugins/super_native_extensions/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" - webview_flutter_wkwebview: - :path: ".symlinks/plugins/webview_flutter_wkwebview/ios" SPEC CHECKSUMS: app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 @@ -180,25 +166,22 @@ SPEC CHECKSUMS: flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb - image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 + image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425 integration_test: 13825b8a9334a850581300559b8839134b124670 irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9 keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86 package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 rich_clipboard_ios: 7588abe18f881a6d0e9ec0b12e51cae2761e8942 SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440 - sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7 SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: bf5ce03e0e2088bad9cc378ea97fa0ed5b49673b - webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a + url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812 PODFILE CHECKSUM: 8c681999c7764593c94846b2a64b44d86f7a27ac diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart index 924dea10f3..49a85406db 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart @@ -1,6 +1,5 @@ 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'; @@ -14,6 +13,7 @@ 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_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -449,6 +449,7 @@ class DocumentCoverState extends State { minHeight: 80, ), child: UploadImageMenu( + limitMaximumImageSize: !_isLocalMode(), supportTypes: const [ UploadImageType.color, UploadImageType.local, @@ -574,6 +575,7 @@ class DocumentCoverState extends State { isPopoverOpen = true; return UploadImageMenu( + limitMaximumImageSize: !_isLocalMode(), supportTypes: const [ UploadImageType.color, UploadImageType.local, @@ -609,9 +611,7 @@ class DocumentCoverState extends State { Future onCoverChanged(CoverType type, String? details) async { if (type == CoverType.file && details != null && !isURL(details)) { - final type = await getAuthenticatorType(); - // if the user is using local authenticator, we need to save the image to local storage - if (type == AuthenticatorType.local) { + if (_isLocalMode()) { details = await saveImageToLocalStorage(details); } else { // else we should save the image to cloud storage @@ -627,6 +627,12 @@ class DocumentCoverState extends State { isOverlayButtonsHidden = value; }); } + + bool _isLocalMode() { + final userProfilePB = context.read().state.userProfilePB; + final type = userProfilePB?.authenticator ?? AuthenticatorPB.Local; + return type == AuthenticatorPB.Local; + } } @visibleForTesting diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart index 06a73aaf5b..c389977333 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart @@ -9,6 +9,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/imag 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/util/string_extension.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage; import 'package:easy_localization/easy_localization.dart'; @@ -355,27 +356,30 @@ class CustomImageBlockComponentState extends State // only used on mobile platform List _buildExtendActionWidgets(BuildContext context) { - final url = widget.node.attributes[CustomImageBlockKeys.url]; + final String url = widget.node.attributes[CustomImageBlockKeys.url]; if (!_checkIfURLIsValid(url)) { return []; } return [ - FlowyOptionTile.text( - showTopBorder: false, - text: LocaleKeys.editor_copyLink.tr(), - leftIcon: const FlowySvg( - FlowySvgs.m_field_copy_s, + // disable the copy link button if the image is hosted on appflowy cloud + // because the url needs the verification token to be accessible + if (!url.isAppFlowyCloudUrl) + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.editor_copyLink.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_field_copy_s, + ), + onTap: () async { + context.pop(); + showSnackBarMessage( + context, + LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), + ); + await getIt().setPlainText(url); + }, ), - onTap: () async { - context.pop(); - showSnackBarMessage( - context, - LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), - ); - await getIt().setPlainText(url); - }, - ), FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.document_imageBlock_saveImageToGallery.tr(), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart index 2ab8d6b5b3..3e8ef2f32d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart @@ -1,6 +1,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; +import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; @@ -26,6 +27,8 @@ class ImageMenu extends StatefulWidget { } class _ImageMenuState extends State { + late final String? url = widget.node.attributes[ImageBlockKeys.url]; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -45,9 +48,12 @@ class _ImageMenuState extends State { child: Row( children: [ const HSpace(4), - _ImageCopyLinkButton( - onTap: copyImageLink, - ), + // disable the copy link button if the image is hosted on appflowy cloud + // because the url needs the verification token to be accessible + if (!(url?.isAppFlowyCloudUrl ?? false)) + _ImageCopyLinkButton( + onTap: copyImageLink, + ), const HSpace(4), _ImageAlignButton( node: widget.node, @@ -64,9 +70,8 @@ class _ImageMenuState extends State { } void copyImageLink() { - final url = widget.node.attributes[ImageBlockKeys.url]; if (url != null) { - Clipboard.setData(ClipboardData(text: url)); + Clipboard.setData(ClipboardData(text: url!)); showSnackBarMessage( context, LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart index c6e9f50914..8eddfdd179 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart @@ -9,6 +9,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/cust 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/util/file_extension.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/log.dart'; @@ -85,6 +86,7 @@ class ImagePlaceholderState extends State { clickHandler: PopoverClickHandler.gestureDetector, popupBuilder: (context) { return UploadImageMenu( + limitMaximumImageSize: !_isLocalMode(), onSelectedLocalImage: (path) { controller.close(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { @@ -126,6 +128,7 @@ class ImagePlaceholderState extends State { if (PlatformExtension.isDesktopOrWeb) { controller.show(); } else { + final isLocalMode = _isLocalMode(); showMobileBottomSheet( context, title: LocaleKeys.editor_image.tr(), @@ -140,6 +143,7 @@ class ImagePlaceholderState extends State { minHeight: 80, ), child: UploadImageMenu( + limitMaximumImageSize: !isLocalMode, supportTypes: const [ UploadImageType.local, UploadImageType.url, @@ -170,15 +174,24 @@ class ImagePlaceholderState extends State { return; } - final userProfilePB = context.read().state.userProfilePB; + final size = url.fileSize; + if (size == null || size > 10 * 1024 * 1024) { + // show error + controller.close(); + showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_uploadImageErrorImageSizeTooBig.tr(), + ); + return; + } final transaction = editorState.transaction; - final type = userProfilePB?.authenticator ?? AuthenticatorPB.Local; + String? path; CustomImageType imageType = CustomImageType.local; // if the user is using local authenticator, we need to save the image to local storage - if (type == AuthenticatorPB.Local) { + if (_isLocalMode()) { path = await saveImageToLocalStorage(url); } else { // else we should save the image to cloud storage @@ -258,4 +271,10 @@ class ImagePlaceholderState extends State { }); await editorState.apply(transaction); } + + bool _isLocalMode() { + final userProfilePB = context.read().state.userProfilePB; + final type = userProfilePB?.authenticator ?? AuthenticatorPB.Local; + return type == AuthenticatorPB.Local; + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart index 7e63f3b4da..fab96db524 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart @@ -48,6 +48,7 @@ class UploadImageMenu extends StatefulWidget { required this.onSelectedNetworkImage, this.onSelectedColor, this.supportTypes = UploadImageType.values, + this.limitMaximumImageSize = false, }); final void Function(String? path) onSelectedLocalImage; @@ -55,6 +56,7 @@ class UploadImageMenu extends StatefulWidget { final void Function(String url) onSelectedNetworkImage; final void Function(String color)? onSelectedColor; final List supportTypes; + final bool limitMaximumImageSize; @override State createState() => _UploadImageMenuState(); @@ -151,8 +153,20 @@ class _UploadImageMenuState extends State { padding: const EdgeInsets.all(8.0), alignment: Alignment.center, constraints: constraints, - child: UploadImageFileWidget( - onPickFile: widget.onSelectedLocalImage, + child: Column( + children: [ + UploadImageFileWidget( + onPickFile: widget.onSelectedLocalImage, + ), + if (widget.limitMaximumImageSize) ...[ + const VSpace(6.0), + FlowyText( + LocaleKeys.document_imageBlock_maximumImageSize.tr(), + fontSize: 12.0, + color: Theme.of(context).hintColor, + ), + ], + ], ), ); case UploadImageType.url: diff --git a/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart index 1e858970e9..4d89e4eef3 100644 --- a/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart +++ b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:appflowy/shared/custom_image_cache_manager.dart'; +import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; @@ -33,7 +34,7 @@ class FlowyNetworkImage extends StatelessWidget { Widget build(BuildContext context) { assert(isURL(url)); - if (url.contains('beta.appflowy')) { + if (url.isAppFlowyCloudUrl) { assert(userProfilePB != null && userProfilePB!.token.isNotEmpty); } diff --git a/frontend/appflowy_flutter/lib/util/file_extension.dart b/frontend/appflowy_flutter/lib/util/file_extension.dart new file mode 100644 index 0000000000..69c20d1dcb --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/file_extension.dart @@ -0,0 +1,11 @@ +import 'dart:io'; + +extension FileSizeExtension on String { + int? get fileSize { + final file = File(this); + if (file.existsSync()) { + return file.lengthSync(); + } + return null; + } +} diff --git a/frontend/appflowy_flutter/lib/util/string_extension.dart b/frontend/appflowy_flutter/lib/util/string_extension.dart index 66c32db7ff..0730a9f76d 100644 --- a/frontend/appflowy_flutter/lib/util/string_extension.dart +++ b/frontend/appflowy_flutter/lib/util/string_extension.dart @@ -1,6 +1,8 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; -extension EncodeString on String { +extension StringExtension on String { static const _specialCharacters = r'\/:*?"<>| '; /// Encode a string to a file name. @@ -17,4 +19,20 @@ extension EncodeString on String { } return buffer.toString(); } + + /// Returns the file size of the file at the given path. + /// + /// Returns null if the file does not exist. + int? get fileSize { + final file = File(this); + if (file.existsSync()) { + return file.lengthSync(); + } + return null; + } + + /// Returns if the string is a appflowy cloud url. + bool get isAppFlowyCloudUrl { + return RegExp(r'^(https:\/\/)(.*)(\.appflowy\.cloud\/)(.*)').hasMatch(this); + } } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index a060b98c18..89d3c3176b 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -835,7 +835,9 @@ "saveImageToGallery": "Save image", "failedToAddImageToGallery": "Failed to add image to gallery", "successToAddImageToGallery": "Image added to gallery successfully", - "unableToLoadImage": "Unable to load image" + "unableToLoadImage": "Unable to load image", + "maximumImageSize": "Maximum supported upload image size is 10MB", + "uploadImageErrorImageSizeTooBig": "Image size must be less than 10MB" }, "codeBlock": { "language": {