From ed81a0aff2101f290ec67e787e79f2c1be055318 Mon Sep 17 00:00:00 2001 From: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:49:35 +0200 Subject: [PATCH] feat: upload file in document (#5843) * feat: upload file in document * feat: add uploaded at & improvements * fix: popover onOpen not triggered by manual show * test: add basic file test * test: fix and add rename test --- .../document/document_test_runner.dart | 2 + .../document/document_with_file_test.dart | 166 ++++++ .../shared/editor_test_operations.dart | 5 +- .../lib/plugins/document/document_page.dart | 117 +++-- .../presentation/editor_configuration.dart | 1 + .../presentation/editor_drop_manager.dart | 17 + .../document/presentation/editor_page.dart | 1 + .../copy_and_paste/custom_paste_command.dart | 4 +- .../copy_and_paste/paste_from_file.dart | 40 ++ .../editor_plugins/file/file_block.dart | 2 + .../file/file_block_component.dart | 497 ++++++++++++++++++ .../editor_plugins/file/file_block_menu.dart | 204 +++++++ .../file/file_selection_menu.dart | 47 ++ .../editor_plugins/file/file_upload_menu.dart | 250 +++++++++ .../editor_plugins/file/file_util.dart | 65 +++ .../presentation/editor_plugins/plugins.dart | 1 + .../settings/settings_dialog_bloc.dart | 3 - .../menu/sidebar/space/shared_widget.dart | 37 +- .../presentation/widgets/dialogs.dart | 37 +- frontend/resources/translations/en.json | 20 + 20 files changed, 1462 insertions(+), 54 deletions(-) create mode 100644 frontend/appflowy_flutter/integration_test/desktop/document/document_with_file_test.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_selection_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart index d57714b13b..018ed1b8d4 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart @@ -12,6 +12,7 @@ import 'document_more_actions_test.dart' as document_more_actions_test; import 'document_text_direction_test.dart' as document_text_direction_test; import 'document_with_cover_image_test.dart' as document_with_cover_image_test; import 'document_with_database_test.dart' as document_with_database_test; +import 'document_with_file_test.dart' as document_with_file_test; import 'document_with_image_block_test.dart' as document_with_image_block_test; import 'document_with_inline_math_equation_test.dart' as document_with_inline_math_equation_test; @@ -43,4 +44,5 @@ void startTesting() { document_with_multi_image_block_test.main(); document_inline_page_reference_test.main(); document_more_actions_test.main(); + document_with_file_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_file_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_file_test.dart new file mode 100644 index 0000000000..b7ad7eab1a --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_file_test.dart @@ -0,0 +1,166 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +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/file/file_upload_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); + + group('file block in document', () { + testWidgets('insert a file from local file + rename file', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(name: 'Insert file test'); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName('File'); + expect(find.byType(FileBlockComponent), findsOneWidget); + + await tester.tap(find.byType(FileBlockComponent)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(find.byType(FileUploadMenu), findsOneWidget); + + final image = await rootBundle.load('assets/test/images/sample.jpeg'); + final tempDirectory = await getTemporaryDirectory(); + final filePath = p.join(tempDirectory.path, 'sample.jpeg'); + final file = File(filePath)..writeAsBytesSync(image.buffer.asUint8List()); + + mockPickFilePaths(paths: [filePath]); + + await getIt().set(KVKeys.kCloudType, '0'); + await tester.tap( + find.text(LocaleKeys.document_plugins_file_fileUploadHint.tr()), + ); + await tester.pumpAndSettle(); + + expect(find.byType(FileUploadMenu), findsNothing); + expect(find.byType(FileBlockComponent), findsOneWidget); + + final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(node.type, FileBlockKeys.type); + expect(node.attributes[FileBlockKeys.url], isNotEmpty); + expect( + node.attributes[FileBlockKeys.urlType], + FileUrlType.local.toIntValue(), + ); + + // Check the name of the file is correctly extracted + expect(node.attributes[FileBlockKeys.name], 'sample.jpeg'); + expect(find.text('sample.jpeg'), findsOneWidget); + + const newName = "Renamed file"; + + // Hover on the widget to see the three dots to open FileBlockMenu + await tester.hoverOnWidget( + find.byType(FileBlockComponent), + onHover: () async { + await tester.tap(find.byType(FileMenuTrigger)); + await tester.pumpAndSettle(); + + await tester.tap( + find.text(LocaleKeys.document_plugins_file_renameFile_title.tr()), + ); + }, + ); + await tester.pumpAndSettle(); + + expect(find.byType(FlowyTextField), findsOneWidget); + await tester.enterText(find.byType(FlowyTextField), newName); + await tester.pump(); + + await tester.tap(find.text(LocaleKeys.button_save.tr())); + await tester.pumpAndSettle(); + + final updatedNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(updatedNode.attributes[FileBlockKeys.name], newName); + expect(find.text(newName), findsOneWidget); + + // remove the temp file + file.deleteSync(); + }); + + testWidgets('insert a file from network', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(name: 'Insert file test'); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName('File'); + expect(find.byType(FileBlockComponent), findsOneWidget); + + await tester.tap(find.byType(FileBlockComponent)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.byType(FileUploadMenu), findsOneWidget); + + // Navigate to integrate link tab + await tester.tapButtonWithName( + LocaleKeys.document_plugins_file_networkTab.tr(), + ); + await tester.pumpAndSettle(); + + const url = + 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640'; + await tester.enterText( + find.descendant( + of: find.byType(FileUploadMenu), + matching: find.byType(FlowyTextField), + ), + url, + ); + await tester.tapButton( + find.descendant( + of: find.byType(FileUploadMenu), + matching: find.text( + LocaleKeys.document_plugins_file_networkAction.tr(), + findRichText: true, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(FileUploadMenu), findsNothing); + expect(find.byType(FileBlockComponent), findsOneWidget); + + final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(node.type, FileBlockKeys.type); + expect(node.attributes[FileBlockKeys.url], isNotEmpty); + expect( + node.attributes[FileBlockKeys.urlType], + FileUrlType.network.toIntValue(), + ); + + // Check the name is correctly extracted from the url + expect( + node.attributes[FileBlockKeys.name], + 'photo-1469474968028-56623f02e42e', + ); + expect(find.text('photo-1469474968028-56623f02e42e'), findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart b/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart index 0442967f15..33106c42d1 100644 --- a/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart'; @@ -14,8 +17,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/uplo 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'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 00fdd1b118..292b399731 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -4,14 +4,17 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; +import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; @@ -19,15 +22,18 @@ import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; const _excludeFromDropTarget = [ ImageBlockKeys.type, CustomImageBlockKeys.type, MultiImageBlockKeys.type, + FileBlockKeys.type, ]; class DocumentPage extends StatefulWidget { @@ -79,46 +85,65 @@ class _DocumentPageState extends State @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: getIt()), - BlocProvider.value(value: documentBloc), - ], - child: BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const Center(child: CircularProgressIndicator.adaptive()); - } + return ChangeNotifierProvider( + // Due to how DropTarget works, there is no way to differentiate if an overlay is + // blocking the target visibly, so when we have an overlay with a drop target, + // we should disable the drop target for the Editor, until it is closed. + // + // See FileBlockComponent for sample use. + // + // Relates to: + // - https://github.com/MixinNetwork/flutter-plugins/issues/2 + // - https://github.com/MixinNetwork/flutter-plugins/issues/331 + // + create: (_) => EditorDropManagerState(), + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider.value(value: documentBloc), + ], + child: BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator.adaptive()); + } - final editorState = state.editorState; - this.editorState = editorState; - final error = state.error; - if (error != null || editorState == null) { - Log.error(error); - return FlowyErrorPage.message( - error.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + final editorState = state.editorState; + this.editorState = editorState; + final error = state.error; + if (error != null || editorState == null) { + Log.error(error); + return FlowyErrorPage.message( + error.toString(), + howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + ); + } + + if (state.forceClose) { + widget.onDeleted(); + return const SizedBox.shrink(); + } + + return BlocListener( + listenWhen: (_, curr) => curr.action != null, + listener: _onNotificationAction, + child: Consumer( + builder: (context, dropState, _) => + _buildEditorPage(context, state, dropState), + ), ); - } - - if (state.forceClose) { - widget.onDeleted(); - return const SizedBox.shrink(); - } - - return BlocListener( - listenWhen: (_, curr) => curr.action != null, - listener: _onNotificationAction, - child: _buildEditorPage(context, state), - ); - }, + }, + ), ), ); } - Widget _buildEditorPage(BuildContext context, DocumentState state) { + Widget _buildEditorPage( + BuildContext context, + DocumentState state, + EditorDropManagerState dropState, + ) { final Widget child; - if (PlatformExtension.isMobile) { child = BlocBuilder( builder: (context, styleState) { @@ -136,6 +161,7 @@ class _DocumentPageState extends State ); } else { child = DropTarget( + enable: dropState.isDropEnabled, onDragExited: (_) => state.editorState!.selectionService.removeDropTarget(), onDragUpdated: (details) { @@ -163,18 +189,31 @@ class _DocumentPageState extends State if (data != null) { if (data.cursorNode != null) { - if ([ - ImageBlockKeys.type, - CustomImageBlockKeys.type, - MultiImageBlockKeys.type, - ].contains(data.cursorNode?.type)) { + if (_excludeFromDropTarget.contains(data.cursorNode?.type)) { return; } final isLocalMode = context.read().isLocalMode; + final List imageFiles = []; + final List otherfiles = []; + for (final file in details.files) { + if (file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(file.name)) { + imageFiles.add(file); + } else { + otherfiles.add(file); + } + } + await editorState!.dropImages( data.dropTarget!, - details.files, + imageFiles, + widget.view.id, + isLocalMode, + ); + await editorState!.dropFiles( + data.dropTarget!, + otherfiles, widget.view.id, isLocalMode, ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart index 765a1354f6..34f61f15ed 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -253,6 +253,7 @@ Map getEditorBuilderMap({ imageUrl: imageUrl, ), ), + FileBlockKeys.type: FileBlockComponentBuilder(configuration: configuration), errorBlockComponentBuilderKey: ErrorBlockComponentBuilder( configuration: configuration, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart new file mode 100644 index 0000000000..728dee766d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; + +class EditorDropManagerState extends ChangeNotifier { + final Set _draggedTypes = {}; + + void add(String type) { + _draggedTypes.add(type); + notifyListeners(); + } + + void remove(String type) { + _draggedTypes.remove(type); + notifyListeners(); + } + + bool get isDropEnabled => _draggedTypes.isEmpty; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 52d01aa6ac..ddd6dcf2bd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -420,6 +420,7 @@ class _AppFlowyEditorPageState extends State { autoGeneratorMenuItem, dateMenuItem, multiImageMenuItem, + fileMenuItem, ]; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart index ff0957a318..53812fbfe4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/plugins/document/application/document_bloc.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/editor_state_paste_node_extension.dart'; @@ -8,7 +10,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:string_validator/string_validator.dart'; @@ -53,7 +54,6 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) { // try to paste the content in order, if any of them is failed, then try the next one if (inAppJson != null && inAppJson.isNotEmpty) { - debugPrint('paste in app json: $inAppJson'); await editorState.deleteSelectionIfNeeded(); if (await editorState.pasteInAppJson(inAppJson)) { return; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart new file mode 100644 index 0000000000..1ca77a96d8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:cross_file/cross_file.dart'; + +extension PasteFromFile on EditorState { + Future dropFiles( + Node dropNode, + List files, + String documentId, + bool isLocalMode, + ) async { + for (final file in files) { + String? path; + FileUrlType? type; + if (isLocalMode) { + path = await saveFileToLocalStorage(file.path); + type = FileUrlType.local; + } else { + (path, _) = await saveFileToCloudStorage(file.path, documentId); + type = FileUrlType.cloud; + } + + if (path == null) { + continue; + } + + final t = transaction + ..insertNode( + dropNode.path, + fileNode( + url: path, + type: type, + name: file.name, + ), + ); + await apply(t); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block.dart new file mode 100644 index 0000000000..31ead6370c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block.dart @@ -0,0 +1,2 @@ +export './file_block_component.dart'; +export './file_selection_menu.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart new file mode 100644 index 0000000000..140506fc48 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart @@ -0,0 +1,497 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:provider/provider.dart'; +import 'package:string_validator/string_validator.dart'; + +import 'file_block_menu.dart'; +import 'file_upload_menu.dart'; + +class FileBlockKeys { + const FileBlockKeys._(); + + static const String type = 'file'; + + /// The src of the file. + /// + /// The value is a String. + /// It can be a url for a network file or a local file path. + /// + static const String url = 'url'; + + /// The name of the file. + /// + /// The value is a String. + /// + static const String name = 'name'; + + /// The type of the url. + /// + /// The value is a FileUrlType enum. + /// + static const String urlType = 'url_type'; + + /// The date of the file upload. + /// + /// The value is a timestamp in ms. + /// + static const String uploadedAt = 'uploaded_at'; + + /// The user who uploaded the file. + /// + /// The value is a String, in form of user id. + /// + static const String uploadedBy = 'uploaded_by'; +} + +enum FileUrlType { + local, + network, + cloud; + + static FileUrlType fromIntValue(int value) { + switch (value) { + case 0: + return FileUrlType.local; + case 1: + return FileUrlType.network; + case 2: + return FileUrlType.cloud; + default: + throw UnimplementedError(); + } + } + + int toIntValue() { + switch (this) { + case FileUrlType.local: + return 0; + case FileUrlType.network: + return 1; + case FileUrlType.cloud: + return 2; + } + } +} + +Node fileNode({ + required String url, + FileUrlType type = FileUrlType.local, + String? name, +}) { + return Node( + type: FileBlockKeys.type, + attributes: { + FileBlockKeys.url: url, + FileBlockKeys.urlType: type.toIntValue(), + FileBlockKeys.name: name, + FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, + }, + ); +} + +class FileBlockComponentBuilder extends BlockComponentBuilder { + FileBlockComponentBuilder({super.configuration}); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return FileBlockComponent( + key: node.key, + node: node, + showActions: showActions(node), + configuration: configuration, + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + ); + } + + @override + bool validate(Node node) => node.delta == null && node.children.isEmpty; +} + +class FileBlockComponent extends BlockComponentStatefulWidget { + const FileBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => FileBlockComponentState(); +} + +class FileBlockComponentState extends State + with SelectableMixin, BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; + + late EditorDropManagerState dropManagerState = + context.read(); + + final fileKey = GlobalKey(); + final showActionsNotifier = ValueNotifier(false); + final controller = PopoverController(); + final menuController = PopoverController(); + + late final editorState = Provider.of(context, listen: false); + + bool alwaysShowMenu = false; + bool isDragging = false; + bool isHovering = false; + + @override + void didChangeDependencies() { + dropManagerState = context.read(); + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + final url = node.attributes[FileBlockKeys.url]; + + Widget child = MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) { + setState(() => isHovering = true); + showActionsNotifier.value = true; + }, + onExit: (_) { + setState(() => isHovering = false); + if (!alwaysShowMenu) { + showActionsNotifier.value = false; + } + }, + opaque: false, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: url != null && url.isNotEmpty + ? () => afLaunchUrlString(url) + : () { + controller.show(); + dropManagerState.add(FileBlockKeys.type); + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: isHovering + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + border: isDragging + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, + ), + child: SizedBox( + height: 52, + child: Row( + children: [ + const HSpace(10), + const Icon(Icons.upload_file_outlined), + const HSpace(10), + ..._buildTrailing(context), + ], + ), + ), + ), + ), + ); + + if (PlatformExtension.isDesktopOrWeb) { + if (url == null || url.isEmpty) { + child = DropTarget( + onDragEntered: (_) { + if (dropManagerState.isDropEnabled) { + setState(() => isDragging = true); + } + }, + onDragExited: (_) { + if (dropManagerState.isDropEnabled) { + setState(() => isDragging = false); + } + }, + onDragDone: (details) { + if (dropManagerState.isDropEnabled) { + insertFileFromLocal(details.files.first.path); + } + }, + child: AppFlowyPopover( + controller: controller, + direction: PopoverDirection.bottomWithCenterAligned, + constraints: const BoxConstraints( + maxWidth: 480, + maxHeight: 340, + minHeight: 80, + ), + clickHandler: PopoverClickHandler.gestureDetector, + onOpen: () => dropManagerState.add(FileBlockKeys.type), + onClose: () => dropManagerState.remove(FileBlockKeys.type), + popupBuilder: (_) => FileUploadMenu( + onInsertLocalFile: insertFileFromLocal, + onInsertNetworkFile: insertNetworkFile, + ), + child: child, + ), + ); + } + + child = BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + blockColor: editorState.editorStyle.selectionColor, + supportTypes: const [BlockSelectionType.block], + child: Padding(key: fileKey, padding: padding, child: child), + ); + } else { + child = Padding(key: fileKey, padding: padding, child: child); + } + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + if (!PlatformExtension.isDesktopOrWeb) { + // show a fixed menu on mobile + child = MobileBlockActionButtons( + showThreeDots: false, + node: node, + editorState: editorState, + child: child, + ); + } + + return child; + } + + List _buildTrailing(BuildContext context) { + if (node.attributes[FileBlockKeys.url]?.isNotEmpty == true) { + final name = node.attributes[FileBlockKeys.name] as String; + return [ + Expanded( + child: FlowyText( + name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(8), + if (PlatformExtension.isDesktopOrWeb) ...[ + ValueListenableBuilder( + valueListenable: showActionsNotifier, + builder: (_, value, __) { + final url = node.attributes[FileBlockKeys.url]; + if (!value || url == null || url.isEmpty) { + return const SizedBox.shrink(); + } + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: menuController.show, + child: AppFlowyPopover( + controller: menuController, + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.bottomWithRightAligned, + onClose: () { + setState( + () { + alwaysShowMenu = false; + showActionsNotifier.value = false; + }, + ); + }, + popupBuilder: (_) { + alwaysShowMenu = true; + return FileBlockMenu( + controller: menuController, + node: node, + editorState: editorState, + ); + }, + child: const FileMenuTrigger(), + ), + ); + }, + ), + const HSpace(8), + ], + ]; + } else { + return [ + Flexible( + child: FlowyText( + isDragging + ? LocaleKeys.document_plugins_file_placeholderDragging.tr() + : LocaleKeys.document_plugins_file_placeholderText.tr(), + overflow: TextOverflow.ellipsis, + ), + ), + ]; + } + } + + Future insertFileFromLocal(String path) async { + final documentBloc = context.read(); + final isLocalMode = documentBloc.isLocalMode; + final urlType = isLocalMode ? FileUrlType.local : FileUrlType.cloud; + + String? url; + String? errorMsg; + if (isLocalMode) { + url = await saveFileToLocalStorage(path); + } else { + final result = + await saveFileToCloudStorage(path, documentBloc.documentId); + url = result.$1; + errorMsg = result.$2; + } + + if (errorMsg != null && mounted) { + return showSnackBarMessage(context, errorMsg); + } + + // Remove the file block from the drop state manager + dropManagerState.remove(FileBlockKeys.type); + + final name = Uri.tryParse(path)?.pathSegments.last ?? url; + final transaction = editorState.transaction; + transaction.updateNode(widget.node, { + FileBlockKeys.url: url, + FileBlockKeys.urlType: urlType.toIntValue(), + FileBlockKeys.name: name, + FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, + }); + await editorState.apply(transaction); + } + + Future insertNetworkFile(String url) async { + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + ); + } + + // Remove the file block from the drop state manager + dropManagerState.remove(FileBlockKeys.type); + + final name = Uri.tryParse(url)?.pathSegments.last ?? url; + final transaction = editorState.transaction; + transaction.updateNode(widget.node, { + FileBlockKeys.url: url, + FileBlockKeys.urlType: FileUrlType.network.toIntValue(), + FileBlockKeys.name: name, + FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, + }); + await editorState.apply(transaction); + } + + @override + Position start() => Position(path: widget.node.path); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Rect getBlockRect({bool shiftWithBaseOffset = false}) { + final renderBox = fileKey.currentContext?.findRenderObject(); + if (renderBox is RenderBox) { + return Offset.zero & renderBox.size; + } + return Rect.zero; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final rects = getRectsInSelection(Selection.collapsed(position)); + return rects.firstOrNull; + } + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + if (_renderBox == null) { + return []; + } + final parentBox = context.findRenderObject(); + final renderBox = fileKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && renderBox is RenderBox) { + return [ + renderBox.localToGlobal(Offset.zero, ancestor: parentBox) & + renderBox.size, + ]; + } + return [Offset.zero & _renderBox!.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: widget.node.path, + startOffset: 0, + endOffset: 1, + ); + + @override + Offset localToGlobal( + Offset offset, { + bool shiftWithBaseOffset = false, + }) => + _renderBox!.localToGlobal(offset); +} + +@visibleForTesting +class FileMenuTrigger extends StatelessWidget { + const FileMenuTrigger({super.key}); + + @override + Widget build(BuildContext context) { + return const FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: EdgeInsets.all(4), + child: FlowySvg( + FlowySvgs.three_dots_s, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart new file mode 100644 index 0000000000..872f0d61d0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class FileBlockMenu extends StatefulWidget { + const FileBlockMenu({ + super.key, + required this.controller, + required this.node, + required this.editorState, + }); + + final PopoverController controller; + final Node node; + final EditorState editorState; + + @override + State createState() => _FileBlockMenuState(); +} + +class _FileBlockMenuState extends State { + final nameController = TextEditingController(); + final errorMessage = ValueNotifier(null); + BuildContext? renameContext; + + @override + void initState() { + super.initState(); + nameController.text = widget.node.attributes[FileBlockKeys.name] ?? ''; + nameController.selection = TextSelection( + baseOffset: 0, + extentOffset: nameController.text.length, + ); + } + + @override + void dispose() { + errorMessage.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final uploadedAtInMS = + widget.node.attributes[FileBlockKeys.uploadedAt] as int?; + final uploadedAt = uploadedAtInMS != null + ? DateTime.fromMillisecondsSinceEpoch(uploadedAtInMS) + : null; + final dateFormat = context.read().state.dateFormat; + final urlType = + FileUrlType.fromIntValue(widget.node.attributes[FileBlockKeys.urlType]); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + HoverButton( + itemHeight: 20, + leftIcon: const FlowySvg(FlowySvgs.edit_s), + name: LocaleKeys.document_plugins_file_renameFile_title.tr(), + onTap: () { + widget.controller.close(); + showCustomConfirmDialog( + context: context, + title: LocaleKeys.document_plugins_file_renameFile_title.tr(), + description: + LocaleKeys.document_plugins_file_renameFile_description.tr(), + closeOnConfirm: false, + builder: (context) { + renameContext = context; + + return _RenameTextField( + nameController: nameController, + errorMessage: errorMessage, + onSubmitted: _saveName, + ); + }, + confirmLabel: LocaleKeys.button_save.tr(), + onConfirm: _saveName, + ); + }, + ), + const VSpace(4), + HoverButton( + itemHeight: 20, + leftIcon: const FlowySvg(FlowySvgs.delete_s), + name: LocaleKeys.button_delete.tr(), + onTap: () { + final transaction = widget.editorState.transaction + ..deleteNode(widget.node); + widget.editorState.apply(transaction); + widget.controller.close(); + }, + ), + if (uploadedAt != null) ...[ + const Divider(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: FlowyText.regular( + [FileUrlType.cloud, FileUrlType.local].contains(urlType) + ? LocaleKeys.document_plugins_file_uploadedAt.tr( + args: [dateFormat.formatDate(uploadedAt, false)], + ) + : LocaleKeys.document_plugins_file_linkedAt.tr( + args: [dateFormat.formatDate(uploadedAt, false)], + ), + fontSize: 14, + maxLines: 2, + color: Theme.of(context).hintColor, + ), + ), + const VSpace(2), + ], + ], + ); + } + + void _saveName() { + if (nameController.text.isEmpty) { + errorMessage.value = + LocaleKeys.document_plugins_file_renameFile_nameEmptyError.tr(); + return; + } + + final attributes = widget.node.attributes; + attributes[FileBlockKeys.name] = nameController.text; + + final transaction = widget.editorState.transaction + ..updateNode(widget.node, attributes); + widget.editorState.apply(transaction); + + if (renameContext != null) { + Navigator.of(renameContext!).pop(); + } + } +} + +class _RenameTextField extends StatefulWidget { + const _RenameTextField({ + required this.nameController, + required this.errorMessage, + required this.onSubmitted, + }); + + final TextEditingController nameController; + final ValueNotifier errorMessage; + final VoidCallback onSubmitted; + + @override + State<_RenameTextField> createState() => _RenameTextFieldState(); +} + +class _RenameTextFieldState extends State<_RenameTextField> { + @override + void initState() { + super.initState(); + widget.errorMessage.addListener(_setState); + } + + @override + void dispose() { + widget.errorMessage.removeListener(_setState); + widget.nameController.dispose(); + super.dispose(); + } + + void _setState() { + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyTextField( + controller: widget.nameController, + onSubmitted: (_) => widget.onSubmitted(), + ), + if (widget.errorMessage.value != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: FlowyText( + widget.errorMessage.value!, + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_selection_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_selection_menu.dart new file mode 100644 index 0000000000..16af5ba198 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_selection_menu.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +final fileMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_plugins_file_name.tr(), + icon: (_, isSelected, style) => SelectionMenuIconWidget( + icon: Icons.file_present_outlined, + isSelected: isSelected, + style: style, + ), + keywords: ['file upload', 'pdf', 'zip', 'archive', 'upload'], + handler: (editorState, _, __) async => editorState.insertEmptyFileBlock(), +); + +extension InsertFile on EditorState { + Future insertEmptyFileBlock() async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final node = getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final file = fileNode(url: ''); + final transaction = this.transaction; + + // if the current node is empty paragraph, replace it with the file node + if (node.type == ParagraphBlockKeys.type && + (node.delta?.isEmpty ?? false)) { + transaction + ..insertNode(node.path, file) + ..deleteNode(node); + } else { + transaction.insertNode(node.path.next, file); + } + + transaction.afterSelection = + Selection.collapsed(Position(path: node.path.next)); + + return apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart new file mode 100644 index 0000000000..7b45582d5b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:dotted_border/dotted_border.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; + +class FileUploadMenu extends StatefulWidget { + const FileUploadMenu({ + super.key, + required this.onInsertLocalFile, + required this.onInsertNetworkFile, + }); + + final void Function(String path) onInsertLocalFile; + final void Function(String url) onInsertNetworkFile; + + @override + State createState() => _FileUploadMenuState(); +} + +class _FileUploadMenuState extends State { + int currentTab = 0; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TabBar( + onTap: (value) => setState(() { + currentTab = value; + }), + isScrollable: true, + padding: EdgeInsets.zero, + overlayColor: WidgetStatePropertyAll( + PlatformExtension.isDesktop + ? Theme.of(context).colorScheme.secondary + : Colors.transparent, + ), + tabs: [ + _Tab( + title: LocaleKeys.document_plugins_file_uploadTab.tr(), + ), + _Tab( + title: LocaleKeys.document_plugins_file_networkTab.tr(), + ), + ], + ), + const Divider(height: 4), + if (currentTab == 0) ...[ + _FileUploadLocal( + onFilePicked: (path) { + if (path != null) { + widget.onInsertLocalFile(path); + } + }, + ), + ] else ...[ + _FileUploadNetwork(onSubmit: widget.onInsertNetworkFile), + ], + ], + ), + ); + } +} + +class _Tab extends StatelessWidget { + const _Tab({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 12.0, + right: 12.0, + bottom: 8.0, + top: PlatformExtension.isMobile ? 0 : 8.0, + ), + child: FlowyText(title), + ); + } +} + +class _FileUploadLocal extends StatefulWidget { + const _FileUploadLocal({required this.onFilePicked}); + + final void Function(String?) onFilePicked; + + @override + State<_FileUploadLocal> createState() => _FileUploadLocalState(); +} + +class _FileUploadLocalState extends State<_FileUploadLocal> { + bool isDragging = false; + + @override + Widget build(BuildContext context) { + final constraints = + PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null; + + return Padding( + padding: const EdgeInsets.all(4), + child: DropTarget( + onDragEntered: (_) => setState(() => isDragging = true), + onDragExited: (_) => setState(() => isDragging = false), + onDragDone: (details) => widget.onFilePicked(details.files.first.path), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => _uploadFile(context), + child: FlowyHover( + resetHoverOnRebuild: false, + isSelected: () => isDragging, + style: HoverStyle( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + padding: const EdgeInsets.all(8), + constraints: constraints, + child: DottedBorder( + dashPattern: const [3, 3], + radius: const Radius.circular(8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 32, + ), + borderType: BorderType.RRect, + color: isDragging + ? Theme.of(context).colorScheme.primary + : Colors.black, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isDragging) ...[ + const VSpace(13.5), + FlowyText( + LocaleKeys.document_plugins_file_dropFileToUpload + .tr(), + fontSize: 16, + color: Theme.of(context).colorScheme.primary, + ), + const VSpace(13.5), + ] else ...[ + FlowyText( + LocaleKeys.document_plugins_file_fileUploadHint + .tr(), + fontSize: 16, + maxLines: 2, + textAlign: TextAlign.center, + lineHeight: 1.5, + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Future _uploadFile(BuildContext context) async { + final result = await getIt().pickFiles(dialogTitle: ''); + widget.onFilePicked(result?.files.first.path); + } +} + +class _FileUploadNetwork extends StatefulWidget { + const _FileUploadNetwork({required this.onSubmit}); + + final void Function(String url) onSubmit; + + @override + State<_FileUploadNetwork> createState() => _FileUploadNetworkState(); +} + +class _FileUploadNetworkState extends State<_FileUploadNetwork> { + bool isUrlValid = true; + String inputText = ''; + + @override + Widget build(BuildContext context) { + final constraints = + PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null; + + return Container( + padding: const EdgeInsets.all(8), + constraints: constraints, + alignment: Alignment.center, + child: Column( + children: [ + FlowyTextField( + hintText: LocaleKeys.document_plugins_file_networkHint.tr(), + onChanged: (value) => inputText = value, + onEditingComplete: submit, + ), + if (!isUrlValid) ...[ + const VSpace(8), + FlowyText( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: Theme.of(context).colorScheme.error, + ), + ], + const VSpace(8), + SizedBox( + width: 160, + child: FlowyButton( + showDefaultBoxDecorationOnMobile: true, + margin: const EdgeInsets.all(8.0), + text: FlowyText( + LocaleKeys.document_plugins_file_networkAction.tr(), + textAlign: TextAlign.center, + ), + onTap: submit, + ), + ), + ], + ), + ); + } + + void submit() { + if (checkUrlValidity(inputText)) { + return widget.onSubmit(inputText); + } + + setState(() => isUrlValid = false); + } + + bool checkUrlValidity(String url) => hrefRegex.hasMatch(url); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart new file mode 100644 index 0000000000..cedeaec2ee --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart @@ -0,0 +1,65 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_service.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_backend/dispatch/error.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:path/path.dart' as p; + +Future saveFileToLocalStorage(String localFilePath) async { + final path = await getIt().getPath(); + final filePath = p.join(path, 'files'); + + try { + // create the directory if not exists + final directory = Directory(filePath); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + final copyToPath = p.join( + filePath, + '${uuid()}${p.extension(localFilePath)}', + ); + await File(localFilePath).copy( + copyToPath, + ); + return copyToPath; + } catch (e) { + Log.error('cannot save file', e); + return null; + } +} + +Future<(String? path, String? errorMessage)> saveFileToCloudStorage( + String localFilePath, + String documentId, +) async { + final size = localFilePath.fileSize; + if (size == null || size > 10 * 1024 * 1024) { + // 10MB + return ( + null, + LocaleKeys.document_plugins_file_fileTooBigError.tr(), + ); + } + final documentService = DocumentService(); + Log.debug("Uploading file from local path: $localFilePath"); + final result = await documentService.uploadFile( + localFilePath: localFilePath, + documentId: documentId, + ); + return result.fold( + (s) => (s.url, null), + (err) { + if (err.isStorageLimitExceeded) { + return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr()); + } + return (null, err.msg); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart index 42da0734ac..a4ba3e64aa 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -53,3 +53,4 @@ export 'table/table_option_action.dart'; export 'todo_list/todo_list_icon.dart'; export 'toggle/toggle_block_component.dart'; export 'toggle/toggle_block_shortcut_event.dart'; +export 'file/file_block.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index 874851759e..36f2603dda 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -110,9 +110,6 @@ class SettingsDialogBloc "https://beta.appflowy.cloud", "https://test.appflowy.cloud", ]; - if (kDebugMode) { - whiteList.add("http://localhost:8000"); - } return whiteList.contains(cloudSetting.serverUrl); }, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart index 0eda15ed66..21f67c56ba 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart @@ -1,3 +1,7 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/theme_extension.dart'; @@ -18,9 +22,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SpacePermissionSwitch extends StatefulWidget { @@ -295,6 +296,8 @@ class ConfirmPopup extends StatefulWidget { this.onCancel, this.confirmLabel, this.confirmButtonColor, + this.child, + this.closeOnAction = true, }); final String title; @@ -311,6 +314,18 @@ class ConfirmPopup extends StatefulWidget { /// final String? confirmLabel; + /// Allows to add a child to the popup. + /// + /// This is useful when you want to add more content to the popup. + /// The child will be placed below the description. + /// + final Widget? child; + + /// Decides whether the popup should be closed when the confirm button is clicked. + /// Defaults to true. + /// + final bool closeOnAction; + @override State createState() => _ConfirmPopupState(); } @@ -339,9 +354,13 @@ class _ConfirmPopupState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTitle(), - const VSpace(6.0), + const VSpace(6), _buildDescription(), - const VSpace(20.0), + if (widget.child != null) ...[ + const VSpace(12), + widget.child!, + ], + const VSpace(20), _buildStyledButton(context), ], ), @@ -386,7 +405,9 @@ class _ConfirmPopupState extends State { return SpaceOkButton( onConfirm: () { widget.onConfirm(); - Navigator.of(context).pop(); + if (widget.closeOnAction) { + Navigator.of(context).pop(); + } }, confirmButtonName: widget.confirmLabel ?? LocaleKeys.button_ok.tr(), confirmButtonColor: widget.confirmButtonColor ?? @@ -400,7 +421,9 @@ class _ConfirmPopupState extends State { }, onConfirm: () { widget.onConfirm(); - Navigator.of(context).pop(); + if (widget.closeOnAction) { + Navigator.of(context).pop(); + } }, confirmButtonName: widget.confirmLabel ?? LocaleKeys.space_delete.tr(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 5d004e5d98..d57f2a5442 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; @@ -11,7 +13,6 @@ import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; import 'package:toastification/toastification.dart'; export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; @@ -473,3 +474,37 @@ Future showCancelAndConfirmDialog({ }, ); } + +Future showCustomConfirmDialog({ + required BuildContext context, + required String title, + required String description, + required Widget Function(BuildContext) builder, + VoidCallback? onConfirm, + String? confirmLabel, + ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk, + bool closeOnConfirm = true, +}) { + return showDialog( + context: context, + builder: (context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 440, + child: ConfirmPopup( + title: title, + description: description, + onConfirm: () => onConfirm?.call(), + confirmLabel: confirmLabel, + style: style, + closeOnAction: closeOnConfirm, + child: builder(context), + ), + ), + ); + }, + ); +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index fe193ec950..c05adaf3e1 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -1603,6 +1603,26 @@ "invalidVideoUrl": "The source URL is not supported yet.", "invalidVideoUrlYouTube": "YouTube is not supported yet.", "supportedFormats": "Supported formats: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" + }, + "file": { + "name": "File", + "uploadTab": "Upload", + "networkTab": "Integrate link", + "placeholderText": "Click or drag and drop to upload a file", + "placeholderDragging": "Drop the file to upload", + "dropFileToUpload": "Drop the file to upload", + "fileUploadHint": "Drag and drop a file here\nor click to select a file.", + "networkHint": "Enter a link to a file", + "networkUrlInvalid": "Invalid URL, please correct the URL and try again", + "networkAction": "Embed file link", + "fileTooBigError": "File size is too big, please upload a file with size less than 10MB", + "renameFile": { + "title": "Rename file", + "description": "Enter the new name for this file", + "nameEmptyError": "File name cannot be left empty." + }, + "uploadedAt": "Uploaded on {}", + "linkedAt": "Link added on {}" } }, "outlineBlock": {