From 3c66fe3164b92202b52c7623fa0c8fa67766c2fb Mon Sep 17 00:00:00 2001 From: Mathias Mogensen Date: Wed, 31 Jul 2024 01:56:31 +0200 Subject: [PATCH] feat: upload file in document --- .../lib/plugins/document/document_page.dart | 89 ++-- .../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 +- .../editor_plugins/file/file_block.dart | 2 + .../file/file_block_component.dart | 500 ++++++++++++++++++ .../editor_plugins/file/file_block_menu.dart | 165 ++++++ .../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 | 18 + 16 files changed, 1191 insertions(+), 46 deletions(-) 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/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/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 00fdd1b118..3c1edb37a3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -4,6 +4,7 @@ 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_image.dart'; @@ -23,11 +24,13 @@ 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 +82,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 +158,7 @@ class _DocumentPageState extends State ); } else { child = DropTarget( + enable: dropState.isDropEnabled, onDragExited: (_) => state.editorState!.selectionService.removeDropTarget(), onDragUpdated: (details) { 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 ba416c9064..616d9aef16 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -249,6 +249,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 78f8e20fee..94745ae691 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -421,6 +421,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/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..a2cbb4258b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart @@ -0,0 +1,500 @@ +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, +}) { + return Node( + type: FileBlockKeys.type, + attributes: { + FileBlockKeys.url: url, + FileBlockKeys.urlType: type.toIntValue(), + }, + ); +} + +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(); + final menuMutex = PopoverMutex(); + + late final editorState = Provider.of(context, listen: false); + + bool alwaysShowMenu = false; + bool isDragging = false; + bool isHovering = false; + + @override + void initState() { + super.initState(); + + final url = node.attributes[FileBlockKeys.url] as String?; + if (url != null && url.isNotEmpty) { + // If the name attribute is not set, extract the file name from the url. + final name = node.attributes[FileBlockKeys.name] as String?; + if (name == null || name.isEmpty) { + final name = Uri.tryParse(url)?.pathSegments.last ?? url; + final attributes = node.attributes; + attributes[FileBlockKeys.name] = name; + + final transaction = editorState.transaction; + transaction.updateNode(node, attributes); + editorState.apply(transaction); + } + } + } + + @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) : null, + 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, + mutex: menuMutex, + 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 FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: EdgeInsets.all(4), + child: FlowySvg( + FlowySvgs.three_dots_s, + ), + ), + ), + ), + ); + }, + ), + 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, + }); + 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, + }); + 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); +} 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..089eb8d022 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart @@ -0,0 +1,165 @@ +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/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'; + +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 + Widget build(BuildContext context) { + return Column( + 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(); + }, + ), + ], + ); + } + + 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); + 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..3e10a3d276 --- /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', '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 61e2f0ac46..e5f4ac1e5f 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 { @@ -294,6 +295,8 @@ class ConfirmPopup extends StatefulWidget { required this.onConfirm, this.confirmLabel, this.confirmButtonColor, + this.child, + this.closeOnAction = true, }); final String title; @@ -309,6 +312,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(); } @@ -337,9 +352,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), ], ), @@ -384,7 +403,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 ?? @@ -395,7 +416,9 @@ class _ConfirmPopupState extends State { onCancel: () => Navigator.of(context).pop(), 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 8a99783c11..4b3ac1da22 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'; @@ -471,3 +472,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 407456a2c5..dd352cac70 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -1555,6 +1555,24 @@ "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." + } } }, "outlineBlock": {