mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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
This commit is contained in:
parent
dce9231118
commit
ed81a0aff2
@ -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();
|
||||
}
|
||||
|
@ -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<KeyValueStorage>().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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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';
|
||||
|
||||
|
@ -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<DocumentPage>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: getIt<ActionNavigationBloc>()),
|
||||
BlocProvider.value(value: documentBloc),
|
||||
],
|
||||
child: BlocBuilder<DocumentBloc, DocumentState>(
|
||||
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<ActionNavigationBloc>()),
|
||||
BlocProvider.value(value: documentBloc),
|
||||
],
|
||||
child: BlocBuilder<DocumentBloc, DocumentState>(
|
||||
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<ActionNavigationBloc, ActionNavigationState>(
|
||||
listenWhen: (_, curr) => curr.action != null,
|
||||
listener: _onNotificationAction,
|
||||
child: Consumer<EditorDropManagerState>(
|
||||
builder: (context, dropState, _) =>
|
||||
_buildEditorPage(context, state, dropState),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.forceClose) {
|
||||
widget.onDeleted();
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
|
||||
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<DocumentPageStyleBloc, DocumentPageStyleState>(
|
||||
builder: (context, styleState) {
|
||||
@ -136,6 +161,7 @@ class _DocumentPageState extends State<DocumentPage>
|
||||
);
|
||||
} else {
|
||||
child = DropTarget(
|
||||
enable: dropState.isDropEnabled,
|
||||
onDragExited: (_) =>
|
||||
state.editorState!.selectionService.removeDropTarget(),
|
||||
onDragUpdated: (details) {
|
||||
@ -163,18 +189,31 @@ class _DocumentPageState extends State<DocumentPage>
|
||||
|
||||
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<DocumentBloc>().isLocalMode;
|
||||
final List<XFile> imageFiles = [];
|
||||
final List<XFile> 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,
|
||||
);
|
||||
|
@ -253,6 +253,7 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
||||
imageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
FileBlockKeys.type: FileBlockComponentBuilder(configuration: configuration),
|
||||
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
),
|
||||
|
@ -0,0 +1,17 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class EditorDropManagerState extends ChangeNotifier {
|
||||
final Set<String> _draggedTypes = {};
|
||||
|
||||
void add(String type) {
|
||||
_draggedTypes.add(type);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void remove(String type) {
|
||||
_draggedTypes.remove(type);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get isDropEnabled => _draggedTypes.isEmpty;
|
||||
}
|
@ -420,6 +420,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
autoGeneratorMenuItem,
|
||||
dateMenuItem,
|
||||
multiImageMenuItem,
|
||||
fileMenuItem,
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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<void> dropFiles(
|
||||
Node dropNode,
|
||||
List<XFile> 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export './file_block_component.dart';
|
||||
export './file_selection_menu.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<FileBlockComponent> createState() => FileBlockComponentState();
|
||||
}
|
||||
|
||||
class FileBlockComponentState extends State<FileBlockComponent>
|
||||
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<EditorDropManagerState>();
|
||||
|
||||
final fileKey = GlobalKey();
|
||||
final showActionsNotifier = ValueNotifier<bool>(false);
|
||||
final controller = PopoverController();
|
||||
final menuController = PopoverController();
|
||||
|
||||
late final editorState = Provider.of<EditorState>(context, listen: false);
|
||||
|
||||
bool alwaysShowMenu = false;
|
||||
bool isDragging = false;
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
dropManagerState = context.read<EditorDropManagerState>();
|
||||
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<Widget> _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<bool>(
|
||||
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<void> insertFileFromLocal(String path) async {
|
||||
final documentBloc = context.read<DocumentBloc>();
|
||||
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<void> 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<Rect> 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<FileBlockMenu> createState() => _FileBlockMenuState();
|
||||
}
|
||||
|
||||
class _FileBlockMenuState extends State<FileBlockMenu> {
|
||||
final nameController = TextEditingController();
|
||||
final errorMessage = ValueNotifier<String?>(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<AppearanceSettingsCubit>().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<String?> 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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<void> 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);
|
||||
}
|
||||
}
|
@ -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<FileUploadMenu> createState() => _FileUploadMenuState();
|
||||
}
|
||||
|
||||
class _FileUploadMenuState extends State<FileUploadMenu> {
|
||||
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<void> _uploadFile(BuildContext context) async {
|
||||
final result = await getIt<FilePickerService>().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);
|
||||
}
|
@ -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<String?> saveFileToLocalStorage(String localFilePath) async {
|
||||
final path = await getIt<ApplicationDataStorage>().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);
|
||||
},
|
||||
);
|
||||
}
|
@ -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';
|
||||
|
@ -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);
|
||||
},
|
||||
|
@ -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<ConfirmPopup> createState() => _ConfirmPopupState();
|
||||
}
|
||||
@ -339,9 +354,13 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
|
||||
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<ConfirmPopup> {
|
||||
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<ConfirmPopup> {
|
||||
},
|
||||
onConfirm: () {
|
||||
widget.onConfirm();
|
||||
Navigator.of(context).pop();
|
||||
if (widget.closeOnAction) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
confirmButtonName:
|
||||
widget.confirmLabel ?? LocaleKeys.space_delete.tr(),
|
||||
|
@ -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<void> showCancelAndConfirmDialog({
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -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": {
|
||||
|
Loading…
Reference in New Issue
Block a user