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_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_cover_image_test.dart' as document_with_cover_image_test;
|
||||||
import 'document_with_database_test.dart' as document_with_database_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_image_block_test.dart' as document_with_image_block_test;
|
||||||
import 'document_with_inline_math_equation_test.dart'
|
import 'document_with_inline_math_equation_test.dart'
|
||||||
as document_with_inline_math_equation_test;
|
as document_with_inline_math_equation_test;
|
||||||
@ -43,4 +44,5 @@ void startTesting() {
|
|||||||
document_with_multi_image_block_test.main();
|
document_with_multi_image_block_test.main();
|
||||||
document_inline_page_reference_test.main();
|
document_inline_page_reference_test.main();
|
||||||
document_more_actions_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:async';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'package:appflowy/generated/locale_keys.g.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_picker.dart';
|
||||||
import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.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/plugins/inline_actions/widgets/inline_actions_handler.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
|
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/mobile/application/page_style/document_page_style_bloc.dart';
|
||||||
import 'package:appflowy/plugins/document/application/document_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/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_notification.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_page.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/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/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/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/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_plugins/plugins.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_style.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/startup/startup.dart';
|
||||||
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
|
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
|
||||||
import 'package:appflowy/workspace/application/action_navigation/navigation_action.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/log.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
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:desktop_drop/desktop_drop.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flowy_infra_ui/widget/error_page.dart';
|
import 'package:flowy_infra_ui/widget/error_page.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
const _excludeFromDropTarget = [
|
const _excludeFromDropTarget = [
|
||||||
ImageBlockKeys.type,
|
ImageBlockKeys.type,
|
||||||
CustomImageBlockKeys.type,
|
CustomImageBlockKeys.type,
|
||||||
MultiImageBlockKeys.type,
|
MultiImageBlockKeys.type,
|
||||||
|
FileBlockKeys.type,
|
||||||
];
|
];
|
||||||
|
|
||||||
class DocumentPage extends StatefulWidget {
|
class DocumentPage extends StatefulWidget {
|
||||||
@ -79,7 +85,19 @@ class _DocumentPageState extends State<DocumentPage>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiBlocProvider(
|
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: [
|
providers: [
|
||||||
BlocProvider.value(value: getIt<ActionNavigationBloc>()),
|
BlocProvider.value(value: getIt<ActionNavigationBloc>()),
|
||||||
BlocProvider.value(value: documentBloc),
|
BlocProvider.value(value: documentBloc),
|
||||||
@ -109,16 +127,23 @@ class _DocumentPageState extends State<DocumentPage>
|
|||||||
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
|
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
|
||||||
listenWhen: (_, curr) => curr.action != null,
|
listenWhen: (_, curr) => curr.action != null,
|
||||||
listener: _onNotificationAction,
|
listener: _onNotificationAction,
|
||||||
child: _buildEditorPage(context, state),
|
child: Consumer<EditorDropManagerState>(
|
||||||
|
builder: (context, dropState, _) =>
|
||||||
|
_buildEditorPage(context, state, dropState),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEditorPage(BuildContext context, DocumentState state) {
|
Widget _buildEditorPage(
|
||||||
|
BuildContext context,
|
||||||
|
DocumentState state,
|
||||||
|
EditorDropManagerState dropState,
|
||||||
|
) {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
if (PlatformExtension.isMobile) {
|
if (PlatformExtension.isMobile) {
|
||||||
child = BlocBuilder<DocumentPageStyleBloc, DocumentPageStyleState>(
|
child = BlocBuilder<DocumentPageStyleBloc, DocumentPageStyleState>(
|
||||||
builder: (context, styleState) {
|
builder: (context, styleState) {
|
||||||
@ -136,6 +161,7 @@ class _DocumentPageState extends State<DocumentPage>
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
child = DropTarget(
|
child = DropTarget(
|
||||||
|
enable: dropState.isDropEnabled,
|
||||||
onDragExited: (_) =>
|
onDragExited: (_) =>
|
||||||
state.editorState!.selectionService.removeDropTarget(),
|
state.editorState!.selectionService.removeDropTarget(),
|
||||||
onDragUpdated: (details) {
|
onDragUpdated: (details) {
|
||||||
@ -163,18 +189,31 @@ class _DocumentPageState extends State<DocumentPage>
|
|||||||
|
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
if (data.cursorNode != null) {
|
if (data.cursorNode != null) {
|
||||||
if ([
|
if (_excludeFromDropTarget.contains(data.cursorNode?.type)) {
|
||||||
ImageBlockKeys.type,
|
|
||||||
CustomImageBlockKeys.type,
|
|
||||||
MultiImageBlockKeys.type,
|
|
||||||
].contains(data.cursorNode?.type)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
|
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(
|
await editorState!.dropImages(
|
||||||
data.dropTarget!,
|
data.dropTarget!,
|
||||||
details.files,
|
imageFiles,
|
||||||
|
widget.view.id,
|
||||||
|
isLocalMode,
|
||||||
|
);
|
||||||
|
await editorState!.dropFiles(
|
||||||
|
data.dropTarget!,
|
||||||
|
otherfiles,
|
||||||
widget.view.id,
|
widget.view.id,
|
||||||
isLocalMode,
|
isLocalMode,
|
||||||
);
|
);
|
||||||
|
@ -253,6 +253,7 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
|||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
FileBlockKeys.type: FileBlockComponentBuilder(configuration: configuration),
|
||||||
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
|
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
|
||||||
configuration: configuration,
|
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,
|
autoGeneratorMenuItem,
|
||||||
dateMenuItem,
|
dateMenuItem,
|
||||||
multiImageMenuItem,
|
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/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/clipboard_service.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.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/startup/startup.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:string_validator/string_validator.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
|
// try to paste the content in order, if any of them is failed, then try the next one
|
||||||
if (inAppJson != null && inAppJson.isNotEmpty) {
|
if (inAppJson != null && inAppJson.isNotEmpty) {
|
||||||
debugPrint('paste in app json: $inAppJson');
|
|
||||||
await editorState.deleteSelectionIfNeeded();
|
await editorState.deleteSelectionIfNeeded();
|
||||||
if (await editorState.pasteInAppJson(inAppJson)) {
|
if (await editorState.pasteInAppJson(inAppJson)) {
|
||||||
return;
|
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 'todo_list/todo_list_icon.dart';
|
||||||
export 'toggle/toggle_block_component.dart';
|
export 'toggle/toggle_block_component.dart';
|
||||||
export 'toggle/toggle_block_shortcut_event.dart';
|
export 'toggle/toggle_block_shortcut_event.dart';
|
||||||
|
export 'file/file_block.dart';
|
||||||
|
@ -110,9 +110,6 @@ class SettingsDialogBloc
|
|||||||
"https://beta.appflowy.cloud",
|
"https://beta.appflowy.cloud",
|
||||||
"https://test.appflowy.cloud",
|
"https://test.appflowy.cloud",
|
||||||
];
|
];
|
||||||
if (kDebugMode) {
|
|
||||||
whiteList.add("http://localhost:8000");
|
|
||||||
}
|
|
||||||
|
|
||||||
return whiteList.contains(cloudSetting.serverUrl);
|
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/flowy_svgs.g.dart';
|
||||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
import 'package:appflowy/util/theme_extension.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/flowy_infra_ui.dart';
|
||||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.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';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
class SpacePermissionSwitch extends StatefulWidget {
|
class SpacePermissionSwitch extends StatefulWidget {
|
||||||
@ -295,6 +296,8 @@ class ConfirmPopup extends StatefulWidget {
|
|||||||
this.onCancel,
|
this.onCancel,
|
||||||
this.confirmLabel,
|
this.confirmLabel,
|
||||||
this.confirmButtonColor,
|
this.confirmButtonColor,
|
||||||
|
this.child,
|
||||||
|
this.closeOnAction = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
@ -311,6 +314,18 @@ class ConfirmPopup extends StatefulWidget {
|
|||||||
///
|
///
|
||||||
final String? confirmLabel;
|
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
|
@override
|
||||||
State<ConfirmPopup> createState() => _ConfirmPopupState();
|
State<ConfirmPopup> createState() => _ConfirmPopupState();
|
||||||
}
|
}
|
||||||
@ -339,9 +354,13 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildTitle(),
|
_buildTitle(),
|
||||||
const VSpace(6.0),
|
const VSpace(6),
|
||||||
_buildDescription(),
|
_buildDescription(),
|
||||||
const VSpace(20.0),
|
if (widget.child != null) ...[
|
||||||
|
const VSpace(12),
|
||||||
|
widget.child!,
|
||||||
|
],
|
||||||
|
const VSpace(20),
|
||||||
_buildStyledButton(context),
|
_buildStyledButton(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -386,7 +405,9 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
|
|||||||
return SpaceOkButton(
|
return SpaceOkButton(
|
||||||
onConfirm: () {
|
onConfirm: () {
|
||||||
widget.onConfirm();
|
widget.onConfirm();
|
||||||
|
if (widget.closeOnAction) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
confirmButtonName: widget.confirmLabel ?? LocaleKeys.button_ok.tr(),
|
confirmButtonName: widget.confirmLabel ?? LocaleKeys.button_ok.tr(),
|
||||||
confirmButtonColor: widget.confirmButtonColor ??
|
confirmButtonColor: widget.confirmButtonColor ??
|
||||||
@ -400,7 +421,9 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
|
|||||||
},
|
},
|
||||||
onConfirm: () {
|
onConfirm: () {
|
||||||
widget.onConfirm();
|
widget.onConfirm();
|
||||||
|
if (widget.closeOnAction) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
confirmButtonName:
|
confirmButtonName:
|
||||||
widget.confirmLabel ?? LocaleKeys.space_delete.tr(),
|
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/flowy_svgs.g.dart';
|
||||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
import 'package:appflowy/startup/tasks/app_widget.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/buttons/secondary_button.dart';
|
||||||
import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
|
import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
|
||||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:toastification/toastification.dart';
|
import 'package:toastification/toastification.dart';
|
||||||
|
|
||||||
export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.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.",
|
"invalidVideoUrl": "The source URL is not supported yet.",
|
||||||
"invalidVideoUrlYouTube": "YouTube is not supported yet.",
|
"invalidVideoUrlYouTube": "YouTube is not supported yet.",
|
||||||
"supportedFormats": "Supported formats: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264"
|
"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": {
|
"outlineBlock": {
|
||||||
|
Loading…
Reference in New Issue
Block a user