From 23b6f94e82942870935958273c4a87d81df1b642 Mon Sep 17 00:00:00 2001 From: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> Date: Thu, 25 Jul 2024 14:47:08 +0200 Subject: [PATCH] feat: photo gallery block + image improvements (#5803) * feat: support multiple images in image block * feat: support drop files on image placeholder * fix: overflow in image placeholder * chore: clean code * feat: refactor to multi image block * feat: drop image on gallery to add * feat: add delete image inside interactive viewer * fix: some mobile improvements * fix: web ci * test: fix tests after dialog changes * test: add basic multi image block test * test: add to test runner * test: open interactive viewer * fix: add delete index to callback * test: add navigation next/previous * ci: fix * ci: fix * ci: fix * test: add network image + deletion tests * fix: remove duplicates after merge * test: add multi image insertion test * ci: try * ci: try --------- Co-authored-by: nathan --- .github/workflows/tauri2_ci.yaml | 37 +- .../cloud/anon_user_continue_test.dart | 2 +- .../document/document_test_runner.dart | 3 + .../document_with_image_block_test.dart | 79 ++- .../document_with_multi_image_block_test.dart | 287 +++++++++++ .../shared/editor_test_operations.dart | 7 +- .../presentation/editor_configuration.dart | 33 +- .../document/presentation/editor_page.dart | 6 +- .../header/document_header_node_widget.dart | 18 +- .../editor_plugins/image/common.dart | 64 +++ .../custom_image_block_component.dart | 100 ++-- .../image_menu.dart | 86 ++-- .../unsupport_image_widget.dart | 17 +- .../image/flowy_image_picker.dart | 40 -- .../image/image_picker_screen.dart | 31 +- .../image/image_placeholder.dart | 230 ++++++--- .../image/image_selection_menu.dart | 89 +++- .../editor_plugins/image/image_util.dart | 51 ++ .../image/mobile_image_toolbar_item.dart | 5 +- .../multi_image_block_component.dart | 331 +++++++++++++ .../multi_image_menu.dart | 332 +++++++++++++ .../multi_image_placeholder.dart | 294 ++++++++++++ .../image/multi_image_layouts.dart | 451 ++++++++++++++++++ .../image/resizeable_image.dart | 65 +-- .../image/unsplash_image_widget.dart | 50 +- .../upload_image_menu.dart | 42 +- .../widgets}/embed_image_url_widget.dart | 0 .../widgets}/open_ai_image_widget.dart | 0 .../widgets}/stability_ai_image_widget.dart | 0 .../widgets}/upload_image_file_widget.dart | 20 +- .../link_preview/link_preview_menu.dart | 9 +- .../add_block_toolbar_item.dart | 15 + .../parsers/custom_image_node_parser.dart | 4 +- .../presentation/editor_plugins/plugins.dart | 3 +- .../lib/shared/patterns/common_patterns.dart | 3 + .../pages/settings_manage_data_view.dart | 7 +- .../widgets/image_viewer/image_provider.dart | 67 +++ .../interactive_image_toolbar.dart | 339 +++++++++++++ .../interactive_image_viewer.dart | 185 +++++++ frontend/appflowy_flutter/pubspec.lock | 4 +- frontend/appflowy_flutter/pubspec.yaml | 2 +- .../16x/m_add_block_photo_gallery.svg | 1 + frontend/resources/flowy_icons/16x/minus.svg | 3 + frontend/resources/translations/en.json | 40 +- frontend/scripts/makefile/env.toml | 14 +- 45 files changed, 3035 insertions(+), 431 deletions(-) create mode 100644 frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/common.dart rename frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/{ => custom_image_block_component}/custom_image_block_component.dart (85%) rename frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/{ => custom_image_block_component}/image_menu.dart (75%) rename frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/{ => custom_image_block_component}/unsupport_image_widget.dart (75%) delete mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart rename frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/{ => upload_image_menu}/upload_image_menu.dart (86%) rename frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/{ => upload_image_menu/widgets}/embed_image_url_widget.dart (100%) rename frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/{ => upload_image_menu/widgets}/open_ai_image_widget.dart (100%) rename frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/{ => upload_image_menu/widgets}/stability_ai_image_widget.dart (100%) rename frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/{ => upload_image_menu/widgets}/upload_image_file_widget.dart (82%) create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/image_provider.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart create mode 100644 frontend/resources/flowy_icons/16x/m_add_block_photo_gallery.svg create mode 100644 frontend/resources/flowy_icons/16x/minus.svg diff --git a/.github/workflows/tauri2_ci.yaml b/.github/workflows/tauri2_ci.yaml index ac3766d4f3..5bf8cbb09f 100644 --- a/.github/workflows/tauri2_ci.yaml +++ b/.github/workflows/tauri2_ci.yaml @@ -12,6 +12,8 @@ env: NODE_VERSION: "18.16.0" PNPM_VERSION: "8.5.0" RUST_TOOLCHAIN: "1.77.2" + CARGO_MAKE_VERSION: "0.36.6" + CI: true concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -22,9 +24,6 @@ jobs: if: github.event.pull_request.head.repo.full_name == github.repository runs-on: self-hosted - env: - CI: true - steps: - uses: actions/checkout@v4 - name: install frontend dependencies @@ -49,14 +48,11 @@ jobs: tauri-build-ubuntu: if: github.event.pull_request.head.repo.full_name != github.repository - runs-on: ubuntu-latest - - env: - CI: true + runs-on: ubuntu-20.04 steps: - - name: Maximize build space (ubuntu only) - if: matrix.os == 'ubuntu-latest' + - uses: actions/checkout@v4 + - name: Maximize build space run: | sudo rm -rf /usr/share/dotnet sudo rm -rf /opt/ghc @@ -85,36 +81,27 @@ jobs: override: true profile: minimal - - name: Rust cache - uses: swatinem/rust-cache@v2 - with: - workspaces: "./frontend/appflowy_web_app/src-tauri -> target" - - name: Node_modules cache uses: actions/cache@v2 with: path: frontend/appflowy_web_app/node_modules key: node-modules-${{ runner.os }} - - name: install dependencies (windows only) - if: matrix.os == 'windows-latest' - working-directory: frontend - run: | - cargo install --force duckscript_cli - vcpkg integrate install - - - name: install dependencies (ubuntu only) - if: matrix.os == 'ubuntu-latest' + - name: install dependencies working-directory: frontend run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf - - name: install cargo-make + - uses: taiki-e/install-action@v2 + with: + tool: cargo-make@${{ env.CARGO_MAKE_VERSION }} + + - name: install tauri deps tools working-directory: frontend run: | - cargo install --force cargo-make cargo make appflowy-tauri-deps-tools + shell: bash - name: install frontend dependencies working-directory: frontend/appflowy_web_app diff --git a/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart b/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart index 0c8b96fa20..39ef5386e4 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart @@ -42,7 +42,7 @@ void main() { await tester.tapAnonymousSignInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); - // reanme the name of the anon user + // rename the name of the anon user await tester.openSettings(); await tester.openSettingsPage(SettingsPage.account); await tester.pumpAndSettle(); diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart index 239e7e09a8..d57714b13b 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner.dart @@ -16,6 +16,8 @@ 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; import 'document_with_inline_page_test.dart' as document_with_inline_page_test; +import 'document_with_multi_image_block_test.dart' + as document_with_multi_image_block_test; import 'document_with_outline_block_test.dart' as document_with_outline_block; import 'document_with_toggle_list_test.dart' as document_with_toggle_list_test; import 'edit_document_test.dart' as document_edit_test; @@ -38,6 +40,7 @@ void startTesting() { document_text_direction_test.main(); document_option_action_test.main(); document_with_image_block_test.main(); + document_with_multi_image_block_test.main(); document_inline_page_reference_test.main(); document_more_actions_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_image_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_image_block_test.dart index 976d812da1..e5ff2a4dae 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_image_block_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_image_block_test.dart @@ -1,21 +1,22 @@ import 'dart:io'; +import 'package:flutter/material.dart'; +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/image/custom_image_block_component.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.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/image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu, ResizableImage; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; @@ -36,7 +37,7 @@ void main() { // create a new document await tester.createNewPageWithNameUnderParent( - name: LocaleKeys.document_plugins_image_addAnImage.tr(), + name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(), ); // tap the first line of the document @@ -84,7 +85,7 @@ void main() { // create a new document await tester.createNewPageWithNameUnderParent( - name: LocaleKeys.document_plugins_image_addAnImage.tr(), + name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(), ); // tap the first line of the document @@ -137,7 +138,7 @@ void main() { // create a new document await tester.createNewPageWithNameUnderParent( - name: LocaleKeys.document_plugins_image_addAnImage.tr(), + name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(), ); // tap the first line of the document @@ -161,5 +162,67 @@ void main() { expect(find.byType(UnsplashImageWidget), findsOneWidget); }); }); + + testWidgets('insert two images from local file at once', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(), + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName('Image'); + expect(find.byType(CustomImageBlockComponent), findsOneWidget); + expect(find.byType(ImagePlaceholder), findsOneWidget); + expect( + find.descendant( + of: find.byType(ImagePlaceholder), + matching: find.byType(AppFlowyPopover), + ), + findsOneWidget, + ); + expect(find.byType(UploadImageMenu), findsOneWidget); + + final firstImage = + await rootBundle.load('assets/test/images/sample.jpeg'); + final secondImage = + await rootBundle.load('assets/test/images/sample.gif'); + final tempDirectory = await getTemporaryDirectory(); + + final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final firstFile = File(firstImagePath) + ..writeAsBytesSync(firstImage.buffer.asUint8List()); + + final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); + final secondFile = File(secondImagePath) + ..writeAsBytesSync(secondImage.buffer.asUint8List()); + + mockPickFilePaths(paths: [firstImagePath, secondImagePath]); + + await getIt().set(KVKeys.kCloudType, '0'); + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_upload_placeholder.tr(), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ResizableImage), findsNWidgets(2)); + + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(firstNode.type, ImageBlockKeys.type); + expect(firstNode.attributes[ImageBlockKeys.url], isNotEmpty); + + final secondNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(secondNode.type, ImageBlockKeys.type); + expect(secondNode.attributes[ImageBlockKeys.url], isNotEmpty); + + // remove the temp files + await Future.wait([firstFile.delete(), secondFile.delete()]); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart new file mode 100644 index 0000000000..6f27375c0f --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart @@ -0,0 +1,287 @@ +import 'dart:io'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.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/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_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:easy_localization/easy_localization.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'; +import '../board/board_hide_groups_test.dart'; + +void main() { + setUp(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('multi image block in document', () { + testWidgets('insert images from local and use interactive viewer', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'multi image block test', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName('Photo gallery'); + expect(find.byType(MultiImageBlockComponent), findsOneWidget); + expect(find.byType(MultiImagePlaceholder), findsOneWidget); + + await tester.tap(find.byType(MultiImagePlaceholder)); + await tester.pumpAndSettle(); + + expect(find.byType(UploadImageMenu), findsOneWidget); + + final firstImage = + await rootBundle.load('assets/test/images/sample.jpeg'); + final secondImage = + await rootBundle.load('assets/test/images/sample.gif'); + final tempDirectory = await getTemporaryDirectory(); + + final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final firstFile = File(firstImagePath) + ..writeAsBytesSync(firstImage.buffer.asUint8List()); + + final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); + final secondFile = File(secondImagePath) + ..writeAsBytesSync(secondImage.buffer.asUint8List()); + + mockPickFilePaths(paths: [firstImagePath, secondImagePath]); + + await getIt().set(KVKeys.kCloudType, '0'); + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_upload_placeholder.tr(), + ); + await tester.pumpAndSettle(); + expect(find.byType(ImageBrowserLayout), findsOneWidget); + final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(node.type, MultiImageBlockKeys.type); + + final data = MultiImageData.fromJson( + node.attributes[MultiImageBlockKeys.images], + ); + + expect(data.images.length, 2); + + // Start using the interactive viewer to view the image(s) + final imageFinder = find + .byWidgetPredicate( + (w) => + w is Image && + w.image is FileImage && + (w.image as FileImage).file.path.endsWith('.jpeg'), + ) + .first; + await tester.tap(imageFinder); + await tester.pump(kDoubleTapMinTime); + await tester.tap(imageFinder); + await tester.pumpAndSettle(); + + final ivFinder = find.byType(InteractiveImageViewer); + expect(ivFinder, findsOneWidget); + + // go to next image + await tester.tap(find.byFlowySvg(FlowySvgs.arrow_right_s)); + await tester.pumpAndSettle(); + + // Expect image to end with .gif + final gifImageFinder = find.byWidgetPredicate( + (w) => + w is Image && + w.image is FileImage && + (w.image as FileImage).file.path.endsWith('.gif'), + ); + + gifImageFinder.evaluate(); + expect(gifImageFinder.found.length, 2); + + // go to previous image + await tester.tap(find.byFlowySvg(FlowySvgs.arrow_left_s)); + await tester.pumpAndSettle(); + + gifImageFinder.evaluate(); + expect(gifImageFinder.found.length, 1); + + // remove the temp files + await Future.wait([firstFile.delete(), secondFile.delete()]); + }); + + testWidgets('insert and delete images from network', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'multi image block test', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName('Photo gallery'); + expect(find.byType(MultiImageBlockComponent), findsOneWidget); + expect(find.byType(MultiImagePlaceholder), findsOneWidget); + + await tester.tap(find.byType(MultiImagePlaceholder)); + await tester.pumpAndSettle(); + + expect(find.byType(UploadImageMenu), findsOneWidget); + + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + ); + + 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(EmbedImageUrlWidget), + matching: find.byType(TextField), + ), + url, + ); + await tester.pumpAndSettle(); + + await tester.tapButton( + find.descendant( + of: find.byType(EmbedImageUrlWidget), + matching: find.text( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + findRichText: true, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ImageBrowserLayout), findsOneWidget); + final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(node.type, MultiImageBlockKeys.type); + + final data = MultiImageData.fromJson( + node.attributes[MultiImageBlockKeys.images], + ); + + expect(data.images.length, 1); + + final imageFinder = find + .byWidgetPredicate( + (w) => w is FlowyNetworkImage && w.url == url, + ) + .first; + + // Insert two images from network + for (int i = 0; i < 2; i++) { + // Hover on the image to show the image toolbar + await tester.hoverOnWidget( + imageFinder, + onHover: () async { + // Click on the add + final addFinder = find.descendant( + of: find.byType(MultiImageMenu), + matching: find.byFlowySvg(FlowySvgs.add_s), + ); + + expect(addFinder, findsOneWidget); + await tester.tap(addFinder); + await tester.pumpAndSettle(); + + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + ); + + await tester.enterText( + find.descendant( + of: find.byType(EmbedImageUrlWidget), + matching: find.byType(TextField), + ), + url, + ); + await tester.pumpAndSettle(); + + await tester.tapButton( + find.descendant( + of: find.byType(EmbedImageUrlWidget), + matching: find.text( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + findRichText: true, + ), + ), + ); + await tester.pumpAndSettle(); + }, + ); + } + + await tester.pumpAndSettle(); + + // There should be 4 images visible now, where 2 are thumbnails + expect(find.byType(ThumbnailItem), findsNWidgets(3)); + + // And all three use ImageRender + expect(find.byType(ImageRender), findsNWidgets(4)); + + // Hover on and delete the first thumbnail image + await tester.hoverOnWidget(find.byType(ThumbnailItem).first); + + final deleteFinder = find + .descendant( + of: find.byType(ThumbnailItem), + matching: find.byFlowySvg(FlowySvgs.delete_s), + ) + .first; + + expect(deleteFinder, findsOneWidget); + await tester.tap(deleteFinder); + await tester.pumpAndSettle(); + + expect(find.byType(ImageRender), findsNWidgets(3)); + + // Delete one from interactive viewer + await tester.tap(imageFinder); + await tester.pump(kDoubleTapMinTime); + await tester.tap(imageFinder); + await tester.pumpAndSettle(); + + final ivFinder = find.byType(InteractiveImageViewer); + expect(ivFinder, findsOneWidget); + + await tester.tap( + find.descendant( + of: find.byType(InteractiveImageToolbar), + matching: find.byFlowySvg(FlowySvgs.delete_s), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(InteractiveImageViewer), findsNothing); + + // There should be 1 image and the thumbnail for said image still visible + expect(find.byType(ImageRender), findsNWidgets(2)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart b/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart index 4eff62321a..d942614402 100644 --- a/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/editor_test_operations.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart'; @@ -10,12 +13,10 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/bl import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart index 3cc466a44a..ba416c9064 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -1,17 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + 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/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/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/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra/theme_extension.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; Map getEditorBuilderMap({ @@ -107,13 +110,33 @@ Map getEditorBuilderMap({ ImageBlockKeys.type: CustomImageBlockComponentBuilder( configuration: configuration, showMenu: true, - menuBuilder: (Node node, CustomImageBlockComponentState state) => - Positioned( + menuBuilder: (node, state) => Positioned( top: 10, right: 10, child: ImageMenu(node: node, state: state), ), ), + MultiImageBlockKeys.type: MultiImageBlockComponentBuilder( + configuration: configuration, + showMenu: true, + menuBuilder: ( + Node node, + MultiImageBlockComponentState state, + ValueNotifier indexNotifier, + VoidCallback onImageDeleted, + ) => + Positioned( + top: 10, + right: 10, + child: MultiImageMenu( + node: node, + state: state, + indexNotifier: indexNotifier, + isLocalMode: context.read().isLocalMode, + onImageDeleted: onImageDeleted, + ), + ), + ), TableBlockKeys.type: TableBlockComponentBuilder( menuBuilder: (node, editorState, position, dir, onBuild, onClose) => TableMenu( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index bf2ad68e16..ed6cc04d8e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -1,5 +1,8 @@ import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/services.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_configuration.dart'; @@ -28,8 +31,6 @@ import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; final codeBlockLocalization = CodeBlockLocalizations( @@ -415,6 +416,7 @@ class _AppFlowyEditorPageState extends State { emojiMenuItem, autoGeneratorMenuItem, dateMenuItem, + multiImageMenuItem, ]; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart index 40e4d54855..b0bafb1f63 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; @@ -9,9 +11,9 @@ import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/desktop_cover.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/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/image_util.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; @@ -23,7 +25,6 @@ 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:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:string_validator/string_validator.dart'; @@ -482,9 +483,12 @@ class DocumentCoverState extends State { UploadImageType.url, UploadImageType.unsplash, ], - onSelectedLocalImage: (path) async { + onSelectedLocalImages: (paths) async { context.pop(); - widget.onChangeCover(CoverType.file, path); + widget.onChangeCover( + CoverType.file, + paths.first, + ); }, onSelectedAIImage: (_) { throw UnimplementedError(); @@ -608,9 +612,9 @@ class DocumentCoverState extends State { UploadImageType.url, UploadImageType.unsplash, ], - onSelectedLocalImage: (path) { + onSelectedLocalImages: (paths) { popoverController.close(); - onCoverChanged(CoverType.file, path); + onCoverChanged(CoverType.file, paths.first); }, onSelectedAIImage: (_) { throw UnimplementedError(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/common.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/common.dart new file mode 100644 index 0000000000..24e10f229c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/common.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; + +enum CustomImageType { + local, + internal, // the images saved in self-host cloud + external; // the images linked from network, like unsplash, https://xxx/yyy/zzz.jpg + + static CustomImageType fromIntValue(int value) { + switch (value) { + case 0: + return CustomImageType.local; + case 1: + return CustomImageType.internal; + case 2: + return CustomImageType.external; + default: + throw UnimplementedError(); + } + } + + int toIntValue() { + switch (this) { + case CustomImageType.local: + return 0; + case CustomImageType.internal: + return 1; + case CustomImageType.external: + return 2; + } + } +} + +class ImageBlockData { + factory ImageBlockData.fromJson(Map json) { + return ImageBlockData( + url: json['url'] as String? ?? '', + type: CustomImageType.fromIntValue(json['type'] as int), + ); + } + + ImageBlockData({required this.url, required this.type}); + + final String url; + final CustomImageType type; + + bool get isLocal => type == CustomImageType.local; + bool get isNotInternal => type != CustomImageType.internal; + + Map toJson() { + return {'url': url, 'type': type.toIntValue()}; + } + + ImageProvider toImageProvider() { + switch (type) { + case CustomImageType.internal: + case CustomImageType.external: + return NetworkImage(url); + case CustomImageType.local: + return FileImage(File(url)); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart similarity index 85% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart index 722a79c2f5..0aac17ce4c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart @@ -4,53 +4,28 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage; import 'package:easy_localization/easy_localization.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:string_validator/string_validator.dart'; +import '../common.dart'; + const kImagePlaceholderKey = 'imagePlaceholderKey'; -enum CustomImageType { - local, - internal, // the images saved in self-host cloud - external; // the images linked from network, like unsplash, https://xxx/yyy/zzz.jpg - - static CustomImageType fromIntValue(int value) { - switch (value) { - case 0: - return CustomImageType.local; - case 1: - return CustomImageType.internal; - case 2: - return CustomImageType.external; - default: - throw UnimplementedError(); - } - } - - int toIntValue() { - switch (this) { - case CustomImageType.local: - return 0; - case CustomImageType.internal: - return 1; - case CustomImageType.external: - return 2; - } - } -} - class CustomImageBlockKeys { const CustomImageBlockKeys._(); @@ -84,6 +59,25 @@ class CustomImageBlockKeys { static const String imageType = 'image_type'; } +Node customImageNode({ + required String url, + String align = 'center', + double? height, + double? width, + CustomImageType type = CustomImageType.local, +}) { + return Node( + type: CustomImageBlockKeys.type, + attributes: { + CustomImageBlockKeys.url: url, + CustomImageBlockKeys.align: align, + CustomImageBlockKeys.height: height, + CustomImageBlockKeys.width: width, + CustomImageBlockKeys.imageType: type.toIntValue(), + }, + ); +} + typedef CustomImageBlockComponentMenuBuilder = Widget Function( Node node, CustomImageBlockComponentState state, @@ -182,7 +176,7 @@ class CustomImageBlockComponentState extends State ); } else if (imageType != CustomImageType.internal && !_checkIfURLIsValid(src)) { - child = const UnSupportImageWidget(); + child = const UnsupportedImageWidget(); } else { child = ResizableImage( src: src, @@ -191,11 +185,22 @@ class CustomImageBlockComponentState extends State editable: editorState.editable, alignment: alignment, type: imageType, + onDoubleTap: () => showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: [ImageBlockData(url: src, type: imageType)], + onDeleteImage: (_) async { + final transaction = editorState.transaction..deleteNode(node); + await editorState.apply(transaction); + }, + ), + ), + ), onResize: (width) { final transaction = editorState.transaction - ..updateNode(node, { - CustomImageBlockKeys.width: width, - }); + ..updateNode(node, {CustomImageBlockKeys.width: width}); editorState.apply(transaction); }, ); @@ -207,21 +212,11 @@ class CustomImageBlockComponentState extends State delegate: this, listenable: editorState.selectionNotifier, blockColor: editorState.editorStyle.selectionColor, - supportTypes: const [ - BlockSelectionType.block, - ], - child: Padding( - key: imageKey, - padding: padding, - child: child, - ), + supportTypes: const [BlockSelectionType.block], + child: Padding(key: imageKey, padding: padding, child: child), ); } else { - child = Padding( - key: imageKey, - padding: padding, - child: child, - ); + child = Padding(key: imageKey, padding: padding, child: child); } if (widget.showActions && widget.actionBuilder != null) { @@ -246,7 +241,7 @@ class CustomImageBlockComponentState extends State opaque: false, child: ValueListenableBuilder( valueListenable: showActionsNotifier, - builder: (context, value, child) { + builder: (_, value, child) { final url = node.attributes[CustomImageBlockKeys.url]; return Stack( children: [ @@ -259,10 +254,7 @@ class CustomImageBlockComponentState extends State child: child!, ), if (value && url.isNotEmpty == true) - widget.menuBuilder!( - widget.node, - this, - ), + widget.menuBuilder!(widget.node, this), ], ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart similarity index 75% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart index ca765bc0ed..06028b6e63 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart @@ -1,26 +1,27 @@ +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/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.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/plugins.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.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:flowy_infra_ui/widget/ignore_parent_gesture.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; class ImageMenu extends StatefulWidget { - const ImageMenu({ - super.key, - required this.node, - required this.state, - }); + const ImageMenu({super.key, required this.node, required this.state}); final Node node; final CustomImageBlockComponentState state; @@ -30,7 +31,7 @@ class ImageMenu extends StatefulWidget { } class _ImageMenuState extends State { - late final String? url = widget.node.attributes[ImageBlockKeys.url]; + late final String? url = widget.node.attributes[CustomImageBlockKeys.url]; @override Widget build(BuildContext context) { @@ -50,6 +51,12 @@ class _ImageMenuState extends State { ), child: Row( children: [ + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(), + iconData: FlowySvgs.full_view_s, + onTap: openFullScreen, + ), const HSpace(4), // disable the copy link button if the image is hosted on appflowy cloud // because the url needs the verification token to be accessible @@ -61,10 +68,7 @@ class _ImageMenuState extends State { ), const HSpace(4), ], - _ImageAlignButton( - node: widget.node, - state: widget.state, - ), + _ImageAlignButton(node: widget.node, state: widget.state), const _Divider(), MenuBlockButton( tooltip: LocaleKeys.button_delete.tr(), @@ -95,13 +99,34 @@ class _ImageMenuState extends State { transaction.afterSelection = null; await editorState.apply(transaction); } + + void openFullScreen() { + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: url!, + type: CustomImageType.fromIntValue( + widget.node.attributes[CustomImageBlockKeys.imageType] ?? 2, + ), + ), + ], + onDeleteImage: (_) async { + final transaction = widget.state.editorState.transaction; + transaction.deleteNode(widget.node); + await widget.state.editorState.apply(transaction); + }, + ), + ), + ); + } } class _ImageAlignButton extends StatefulWidget { - const _ImageAlignButton({ - required this.node, - required this.state, - }); + const _ImageAlignButton({required this.node, required this.state}); final Node node; final CustomImageBlockComponentState state; @@ -110,30 +135,28 @@ class _ImageAlignButton extends StatefulWidget { State<_ImageAlignButton> createState() => _ImageAlignButtonState(); } -const interceptorKey = 'image-align'; +const _interceptorKey = 'image-align'; class _ImageAlignButtonState extends State<_ImageAlignButton> { final gestureInterceptor = SelectionGestureInterceptor( - key: interceptorKey, + key: _interceptorKey, canTap: (details) => false, ); String get align => - widget.node.attributes[ImageBlockKeys.align] ?? centerAlignmentKey; + widget.node.attributes[CustomImageBlockKeys.align] ?? centerAlignmentKey; final popoverController = PopoverController(); late final EditorState editorState; @override void initState() { super.initState(); - editorState = context.read(); } @override void dispose() { allowMenuClose(); - super.dispose(); } @@ -153,9 +176,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> { ), popupBuilder: (_) { preventMenuClose(); - return _AlignButtons( - onAlignChanged: onAlignChanged, - ); + return _AlignButtons(onAlignChanged: onAlignChanged); }, ), ); @@ -165,9 +186,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> { popoverController.close(); final transaction = editorState.transaction; - transaction.updateNode(widget.node, { - ImageBlockKeys.align: align, - }); + transaction.updateNode(widget.node, {CustomImageBlockKeys.align: align}); editorState.apply(transaction); allowMenuClose(); @@ -183,7 +202,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> { void allowMenuClose() { widget.state.alwaysShowMenu = false; editorState.service.selectionService.unregisterGestureInterceptor( - interceptorKey, + _interceptorKey, ); } @@ -201,9 +220,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> { } class _AlignButtons extends StatelessWidget { - const _AlignButtons({ - required this.onAlignChanged, - }); + const _AlignButtons({required this.onAlignChanged}); final Function(String align) onAlignChanged; @@ -246,10 +263,7 @@ class _Divider extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(8), - child: Container( - width: 1, - color: Colors.grey, - ), + child: Container(width: 1, color: Colors.grey), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart similarity index 75% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart index 017e5a94b2..f0310a4aa5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart @@ -1,14 +1,13 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.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:flutter/material.dart'; -class UnSupportImageWidget extends StatelessWidget { - const UnSupportImageWidget({ - super.key, - }); +class UnsupportedImageWidget extends StatelessWidget { + const UnsupportedImageWidget({super.key}); @override Widget build(BuildContext context) { @@ -18,9 +17,7 @@ class UnSupportImageWidget extends StatelessWidget { borderRadius: BorderRadius.circular(4), ), child: FlowyHover( - style: HoverStyle( - borderRadius: BorderRadius.circular(4), - ), + style: HoverStyle(borderRadius: BorderRadius.circular(4)), child: SizedBox( height: 52, child: Row( @@ -31,9 +28,7 @@ class UnSupportImageWidget extends StatelessWidget { size: Size.square(24), ), const HSpace(10), - FlowyText( - LocaleKeys.document_imageBlock_unableToLoadImage.tr(), - ), + FlowyText(LocaleKeys.document_imageBlock_unableToLoadImage.tr()), ], ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart deleted file mode 100644 index 45f5b78507..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; - -class ImagePickerPage extends StatefulWidget { - const ImagePickerPage({ - super.key, - // required this.onSelected, - }); - - // final void Function(EmojiPickerResult) onSelected; - - @override - State createState() => _ImagePickerPageState(); -} - -class _ImagePickerPageState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - titleSpacing: 0, - title: FlowyText.semibold( - LocaleKeys.titleBar_pageIcon.tr(), - fontSize: 14.0, - ), - leading: const AppBarBackButton(), - ), - body: SafeArea( - child: UploadImageMenu( - onSubmitted: (_) {}, - onUpload: (_) {}, - ), - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart index 5ea7a56c40..b1c6c94213 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart @@ -1,13 +1,40 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart'; import 'package:flutter/material.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; + class MobileImagePickerScreen extends StatelessWidget { const MobileImagePickerScreen({super.key}); static const routeName = '/image_picker'; + @override + Widget build(BuildContext context) => const ImagePickerPage(); +} + +class ImagePickerPage extends StatelessWidget { + const ImagePickerPage({super.key}); + @override Widget build(BuildContext context) { - return const ImagePickerPage(); + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + title: FlowyText.semibold( + LocaleKeys.titleBar_pageIcon.tr(), + fontSize: 14.0, + ), + leading: const AppBarBackButton(), + ), + body: SafeArea( + child: UploadImageMenu( + onSubmitted: (_) {}, + onUpload: (_) {}, + ), + ), + ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart index 6fe7822e68..27b4f2f992 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart @@ -1,24 +1,29 @@ import 'dart:io'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.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/image_util.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log, UploadImageMenu; 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/uuid.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flutter/material.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart'; @@ -26,10 +31,7 @@ import 'package:path/path.dart' as p; import 'package:string_validator/string_validator.dart'; class ImagePlaceholder extends StatefulWidget { - const ImagePlaceholder({ - super.key, - required this.node, - }); + const ImagePlaceholder({super.key, required this.node}); final Node node; @@ -45,12 +47,20 @@ class ImagePlaceholderState extends State { bool showLoading = false; String? errorMessage; + bool isDraggingFiles = false; + @override Widget build(BuildContext context) { final Widget child = DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), + border: isDraggingFiles + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, ), child: FlowyHover( style: HoverStyle( @@ -85,18 +95,23 @@ class ImagePlaceholderState extends State { clickHandler: PopoverClickHandler.gestureDetector, popupBuilder: (context) { return UploadImageMenu( + allowMultipleImages: true, limitMaximumImageSize: !_isLocalMode(), supportTypes: const [ UploadImageType.local, UploadImageType.url, UploadImageType.unsplash, - // UploadImageType.openAI, UploadImageType.stabilityAI, ], - onSelectedLocalImage: (path) { + onSelectedLocalImages: (paths) { controller.close(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - await insertLocalImage(path); + WidgetsBinding.instance.addPostFrameCallback((_) async { + final List items = List.from( + paths.where((url) => url != null && url.isNotEmpty), + ); + if (items.isNotEmpty) { + await insertMultipleLocalImages(items); + } }); }, onSelectedAIImage: (url) { @@ -113,7 +128,27 @@ class ImagePlaceholderState extends State { }, ); }, - child: child, + child: DropTarget( + onDragEntered: (_) => setState(() => isDraggingFiles = true), + onDragExited: (_) => setState(() => isDraggingFiles = false), + onDragDone: (details) { + // Only accept files where the mimetype is an image, + // otherwise we assume it's a file we cannot display. + final imageFiles = details.files + .where( + (file) => + file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(file.name), + ) + .toList(); + final paths = imageFiles.map((file) => file.path).toList(); + + WidgetsBinding.instance.addPostFrameCallback( + (_) async => insertMultipleLocalImages(paths), + ); + }, + child: child, + ), ); } else { return MobileBlockActionButtons( @@ -133,8 +168,11 @@ class ImagePlaceholderState extends State { List _buildTrailing(BuildContext context) { if (errorMessage != null) { return [ - FlowyText( - '${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}', + Flexible( + child: FlowyText( + '${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}', + maxLines: 3, + ), ), ]; } else if (showLoading) { @@ -147,8 +185,14 @@ class ImagePlaceholderState extends State { ]; } else { return [ - FlowyText( - LocaleKeys.document_plugins_image_addAnImage.tr(), + Flexible( + child: FlowyText( + PlatformExtension.isDesktop + ? isDraggingFiles + ? LocaleKeys.document_plugins_image_dropImageToInsert.tr() + : LocaleKeys.document_plugins_image_addAnImageDesktop.tr() + : LocaleKeys.document_plugins_image_addAnImageMobile.tr(), + ), ), ]; } @@ -179,9 +223,14 @@ class ImagePlaceholderState extends State { UploadImageType.url, UploadImageType.unsplash, ], - onSelectedLocalImage: (path) async { + onSelectedLocalImages: (paths) async { context.pop(); - await insertLocalImage(path); + + final List items = List.from( + paths.where((url) => url != null && url.isNotEmpty), + ); + + await insertMultipleLocalImages(items); }, onSelectedAIImage: (url) async { context.pop(); @@ -198,77 +247,102 @@ class ImagePlaceholderState extends State { } } - Future insertLocalImage(String? url) async { + Future insertMultipleLocalImages(List urls) async { controller.close(); - if (url == null || url.isEmpty) { - return; - } - - final transaction = editorState.transaction; - - String? path; - String? errorMessage; - CustomImageType imageType = CustomImageType.local; - - // if the user is using local authenticator, we need to save the image to local storage - if (_isLocalMode()) { - // don't limit the image size for local mode. - path = await saveImageToLocalStorage(url); - } else { - final documentId = context.read().documentId; - if (documentId.isEmpty) { - return; - } - // else we should save the image to cloud storage - setState(() { - showLoading = true; - this.errorMessage = null; - }); - (path, errorMessage) = await saveImageToCloudStorage(url, documentId); - setState(() { - showLoading = false; - this.errorMessage = errorMessage; - }); - imageType = CustomImageType.internal; - } - - if (mounted && path == null) { - showSnackBarMessage( - context, - errorMessage == null - ? LocaleKeys.document_imageBlock_error_invalidImage.tr() - : ': $errorMessage', - ); - setState(() { - this.errorMessage = errorMessage; - }); - return; - } - - transaction.updateNode(widget.node, { - CustomImageBlockKeys.url: path, - CustomImageBlockKeys.imageType: imageType.toIntValue(), + setState(() { + showLoading = true; + errorMessage = null; }); - await editorState.apply(transaction); + bool hasError = false; + + if (_isLocalMode()) { + final first = urls.removeAt(0); + final firstPath = await saveImageToLocalStorage(first); + final transaction = editorState.transaction; + transaction.updateNode(widget.node, { + CustomImageBlockKeys.url: firstPath, + CustomImageBlockKeys.imageType: CustomImageType.local.toIntValue(), + }); + + if (urls.isNotEmpty) { + // Create new nodes for the rest of the images: + final paths = await Future.wait(urls.map(saveImageToLocalStorage)); + paths.removeWhere((url) => url == null || url.isEmpty); + + transaction.insertNodes( + widget.node.path.next, + paths.map((url) => customImageNode(url: url!)).toList(), + ); + } + + await editorState.apply(transaction); + } else { + final transaction = editorState.transaction; + + bool isFirst = true; + for (final url in urls) { + // Upload to cloud + final (path, error) = await saveImageToCloudStorage( + url, + context.read().documentId, + ); + + if (error != null) { + hasError = true; + + if (isFirst) { + setState(() => errorMessage = error); + } + + continue; + } + + if (path != null) { + if (isFirst) { + isFirst = false; + transaction.updateNode(widget.node, { + CustomImageBlockKeys.url: path, + CustomImageBlockKeys.imageType: + CustomImageType.internal.toIntValue(), + }); + } else { + transaction.insertNode( + widget.node.path.next, + customImageNode( + url: path, + type: CustomImageType.internal, + ), + ); + } + } + } + + await editorState.apply(transaction); + } + + setState(() => showLoading = false); + + if (hasError && mounted) { + showSnapBar( + context, + LocaleKeys.document_imageBlock_error_multipleImagesFailed.tr(), + ); + } } Future insertAIImage(String url) async { if (url.isEmpty || !isURL(url)) { // show error - showSnackBarMessage( + return showSnackBarMessage( context, LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); - return; } final path = await getIt().getPath(); - final imagePath = p.join( - path, - 'images', - ); + final imagePath = p.join(path, 'images'); try { // create the directory if not exists final directory = Directory(imagePath); @@ -283,7 +357,7 @@ class ImagePlaceholderState extends State { final response = await get(uri); await File(copyToPath).writeAsBytes(response.bodyBytes); - await insertLocalImage(copyToPath); + await insertMultipleLocalImages([copyToPath]); await File(copyToPath).delete(); } catch (e) { Log.error('cannot save image file', e); @@ -293,16 +367,16 @@ class ImagePlaceholderState extends State { Future insertNetworkImage(String url) async { if (url.isEmpty || !isURL(url)) { // show error - showSnackBarMessage( + return showSnackBarMessage( context, LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); - return; } final transaction = editorState.transaction; transaction.updateNode(widget.node, { - ImageBlockKeys.url: url, + CustomImageBlockKeys.url: url, + CustomImageBlockKeys.imageType: CustomImageType.external.toIntValue(), }); await editorState.apply(transaction); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart index c42e4f8147..47c9e1fdf3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart @@ -1,27 +1,54 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:flutter/material.dart'; +import 'package:appflowy/generated/locale_keys.g.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/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +import 'package:easy_localization/easy_localization.dart'; + final customImageMenuItem = SelectionMenuItem( getName: () => AppFlowyEditorL10n.current.image, - icon: (editorState, isSelected, style) => SelectionMenuIconWidget( + icon: (_, isSelected, style) => SelectionMenuIconWidget( name: 'image', isSelected: isSelected, style: style, ), keywords: ['image', 'picture', 'img', 'photo'], - handler: (editorState, menuService, context) async { + handler: (editorState, _, __) async { // use the key to retrieve the state of the image block to show the popover automatically final imagePlaceholderKey = GlobalKey(); await editorState.insertEmptyImageBlock(imagePlaceholderKey); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + WidgetsBinding.instance.addPostFrameCallback((_) { imagePlaceholderKey.currentState?.controller.show(); }); }, ); +final multiImageMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_plugins_photoGallery_name.tr(), + icon: (_, isSelected, style) => SelectionMenuIconWidget( + icon: Icons.photo_library_outlined, + isSelected: isSelected, + style: style, + ), + keywords: [ + LocaleKeys.document_plugins_photoGallery_imageKeyword.tr(), + LocaleKeys.document_plugins_photoGallery_imageGalleryKeyword.tr(), + LocaleKeys.document_plugins_photoGallery_photoKeyword.tr(), + LocaleKeys.document_plugins_photoGallery_photoBrowserKeyword.tr(), + LocaleKeys.document_plugins_photoGallery_galleryKeyword.tr(), + ], + handler: (editorState, _, __) async { + final imagePlaceholderKey = GlobalKey(); + await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey); + WidgetsBinding.instance.addPostFrameCallback( + (_) => imagePlaceholderKey.currentState?.controller.show(), + ); + }, +); + extension InsertImage on EditorState { Future insertEmptyImageBlock(GlobalKey key) async { final selection = this.selection; @@ -33,31 +60,49 @@ extension InsertImage on EditorState { return; } final emptyImage = imageNode(url: '') - ..extraInfos = { - kImagePlaceholderKey: key, - }; + ..extraInfos = {kImagePlaceholderKey: key}; final transaction = this.transaction; // if the current node is empty paragraph, replace it with image node if (node.type == ParagraphBlockKeys.type && (node.delta?.isEmpty ?? false)) { transaction - ..insertNode( - node.path, - emptyImage, - ) + ..insertNode(node.path, emptyImage) ..deleteNode(node); } else { - transaction.insertNode( - node.path.next, - emptyImage, - ); + transaction.insertNode(node.path.next, emptyImage); } - transaction.afterSelection = Selection.collapsed( - Position( - path: node.path.next, - ), - ); + transaction.afterSelection = + Selection.collapsed(Position(path: node.path.next)); + transaction.selectionExtraInfo = {}; + + return apply(transaction); + } + + Future insertEmptyMultiImageBlock(GlobalKey key) async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final node = getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final emptyBlock = multiImageNode() + ..extraInfos = {kMultiImagePlaceholderKey: key}; + final transaction = this.transaction; + // if the current node is empty paragraph, replace it with image node + if (node.type == ParagraphBlockKeys.type && + (node.delta?.isEmpty ?? false)) { + transaction + ..insertNode(node.path, emptyBlock) + ..deleteNode(node); + } else { + transaction.insertNode(node.path.next, emptyBlock); + } + + transaction.afterSelection = + Selection.collapsed(Position(path: node.path.next)); transaction.selectionExtraInfo = {}; return apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart index 774f08cebb..6e650f1bf8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart @@ -1,15 +1,20 @@ import 'dart:io'; +import 'package:flutter/widgets.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/file_extension.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/dispatch/error.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:path/path.dart' as p; Future saveImageToLocalStorage(String localImagePath) async { @@ -73,3 +78,49 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage( }, ); } + +Future> extractAndUploadImages( + BuildContext context, + List urls, + bool isLocalMode, +) async { + final List images = []; + + bool hasError = false; + for (final url in urls) { + if (url == null || url.isEmpty) { + continue; + } + + String? path; + String? errorMsg; + CustomImageType imageType = CustomImageType.local; + + // If the user is using local authenticator, we save the image to local storage + if (isLocalMode) { + path = await saveImageToLocalStorage(url); + } else { + // Else we save the image to cloud storage + (path, errorMsg) = await saveImageToCloudStorage( + url, + context.read().documentId, + ); + imageType = CustomImageType.internal; + } + + if (path != null && errorMsg == null) { + images.add(ImageBlockData(url: path, type: imageType)); + } else { + hasError = true; + } + } + + if (context.mounted && hasError) { + showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_multipleImagesFailed.tr(), + ); + } + + return images; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart index aa8c6fe496..cb7fd457e0 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart @@ -1,8 +1,9 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; final imageMobileToolbarItem = MobileToolbarItem.action( itemIconBuilder: (_, __, ___) => const FlowySvg(FlowySvgs.m_toolbar_imae_lg), @@ -10,7 +11,7 @@ final imageMobileToolbarItem = MobileToolbarItem.action( final imagePlaceholderKey = GlobalKey(); await editorState.insertEmptyImageBlock(imagePlaceholderKey); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + WidgetsBinding.instance.addPostFrameCallback((_) { imagePlaceholderKey.currentState?.showUploadImageMenu(); }); }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart new file mode 100644 index 0000000000..a7dd9fdb2a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart @@ -0,0 +1,331 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:provider/provider.dart'; + +const kMultiImagePlaceholderKey = 'multiImagePlaceholderKey'; + +Node multiImageNode() => Node( + type: MultiImageBlockKeys.type, + attributes: { + MultiImageBlockKeys.images: MultiImageData(images: []).toJson(), + MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + }, + ); + +class MultiImageBlockKeys { + const MultiImageBlockKeys._(); + + static const String type = 'multi_image'; + + /// The image data for the block, stored as a JSON encoded list of [ImageBlockData]. + /// + static const String images = 'images'; + + /// The layout of the images. + /// + /// The value is a MultiImageLayout enum. + /// + static const String layout = 'layout'; +} + +typedef MultiImageBlockComponentMenuBuilder = Widget Function( + Node node, + MultiImageBlockComponentState state, + ValueNotifier indexNotifier, + VoidCallback onImageDeleted, +); + +class MultiImageBlockComponentBuilder extends BlockComponentBuilder { + MultiImageBlockComponentBuilder({ + super.configuration, + this.showMenu = false, + this.menuBuilder, + }); + + final bool showMenu; + final MultiImageBlockComponentMenuBuilder? menuBuilder; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return MultiImageBlockComponent( + key: node.key, + node: node, + showActions: showActions(node), + configuration: configuration, + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + showMenu: showMenu, + menuBuilder: menuBuilder, + ); + } + + @override + bool validate(Node node) => node.delta == null && node.children.isEmpty; +} + +class MultiImageBlockComponent extends BlockComponentStatefulWidget { + const MultiImageBlockComponent({ + super.key, + required super.node, + super.showActions, + this.showMenu = false, + this.menuBuilder, + super.configuration = const BlockComponentConfiguration(), + super.actionBuilder, + }); + + final bool showMenu; + + final MultiImageBlockComponentMenuBuilder? menuBuilder; + + @override + State createState() => + MultiImageBlockComponentState(); +} + +class MultiImageBlockComponentState extends State + with SelectableMixin, BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + final multiImageKey = GlobalKey(); + + RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; + + late final editorState = Provider.of(context, listen: false); + + final showActionsNotifier = ValueNotifier(false); + + ValueNotifier indexNotifier = ValueNotifier(0); + + bool alwaysShowMenu = false; + + @override + Widget build(BuildContext context) { + final data = MultiImageData.fromJson( + node.attributes[MultiImageBlockKeys.images], + ); + + Widget child; + if (data.images.isEmpty) { + final multiImagePlaceholderKey = + node.extraInfos?[kMultiImagePlaceholderKey]; + + child = MultiImagePlaceholder( + key: multiImagePlaceholderKey is GlobalKey + ? multiImagePlaceholderKey + : null, + node: node, + ); + } else { + child = ImageBrowserLayout( + node: node, + images: data.images, + editorState: editorState, + indexNotifier: indexNotifier, + isLocalMode: context.read().isLocalMode, + onIndexChanged: (index) => setState(() => indexNotifier.value = index), + ); + } + + if (PlatformExtension.isDesktopOrWeb) { + child = BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + blockColor: editorState.editorStyle.selectionColor, + supportTypes: const [BlockSelectionType.block], + child: Padding(key: multiImageKey, padding: padding, child: child), + ); + } else { + child = Padding(key: multiImageKey, padding: padding, child: child); + } + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + if (PlatformExtension.isDesktopOrWeb) { + if (widget.showMenu && widget.menuBuilder != null) { + child = MouseRegion( + onEnter: (_) => showActionsNotifier.value = true, + onExit: (_) { + if (!alwaysShowMenu) { + showActionsNotifier.value = false; + } + }, + hitTestBehavior: HitTestBehavior.opaque, + opaque: false, + child: ValueListenableBuilder( + valueListenable: showActionsNotifier, + builder: (context, value, child) { + return Stack( + children: [ + BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, + child: child!, + ), + if (value && data.images.isNotEmpty) + widget.menuBuilder!( + widget.node, + this, + indexNotifier, + () => setState( + () => indexNotifier.value = indexNotifier.value > 0 + ? indexNotifier.value - 1 + : 0, + ), + ), + ], + ); + }, + child: child, + ), + ); + } + } else { + // show a fixed menu on mobile + child = MobileBlockActionButtons( + showThreeDots: false, + node: node, + editorState: editorState, + child: child, + ); + } + + return child; + } + + @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 imageBox = multiImageKey.currentContext?.findRenderObject(); + if (imageBox is RenderBox) { + return Offset.zero & imageBox.size; + } + return Rect.zero; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final rects = getRectsInSelection(Selection.collapsed(position)); + return rects.firstOrNull; + } + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + if (_renderBox == null) { + return []; + } + final parentBox = context.findRenderObject(); + final imageBox = multiImageKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && imageBox is RenderBox) { + return [ + imageBox.localToGlobal(Offset.zero, ancestor: parentBox) & + imageBox.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); +} + +/// The data for a multi-image block, primarily used for +/// serializing and deserializing the block's images. +/// +class MultiImageData { + factory MultiImageData.fromJson(List json) { + final images = json + .map((e) => ImageBlockData.fromJson(e as Map)) + .toList(); + return MultiImageData(images: images); + } + + MultiImageData({required this.images}); + + final List images; + + List toJson() => images.map((e) => e.toJson()).toList(); +} + +enum MultiImageLayout { + browser, + masonry, + grid; + + int toIntValue() { + switch (this) { + case MultiImageLayout.browser: + return 0; + case MultiImageLayout.masonry: + return 1; + case MultiImageLayout.grid: + return 2; + } + } + + static MultiImageLayout fromIntValue(int value) { + switch (value) { + case 0: + return MultiImageLayout.browser; + case 1: + return MultiImageLayout.masonry; + case 2: + return MultiImageLayout.grid; + default: + throw UnimplementedError(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart new file mode 100644 index 0000000000..bbfb4a6462 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart @@ -0,0 +1,332 @@ +import 'dart:io'; + +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/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.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/upload_image_menu/upload_image_menu.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu, Log; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:http/http.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:string_validator/string_validator.dart'; + +const _interceptorKey = 'add-image'; + +class MultiImageMenu extends StatefulWidget { + const MultiImageMenu({ + super.key, + required this.node, + required this.state, + required this.indexNotifier, + this.isLocalMode = true, + required this.onImageDeleted, + }); + + final Node node; + final MultiImageBlockComponentState state; + final ValueNotifier indexNotifier; + final bool isLocalMode; + final VoidCallback onImageDeleted; + + @override + State createState() => _MultiImageMenuState(); +} + +class _MultiImageMenuState extends State { + final gestureInterceptor = SelectionGestureInterceptor( + key: _interceptorKey, + canTap: (details) => false, + ); + + final PopoverController controller = PopoverController(); + late List images; + late final EditorState editorState; + + @override + void initState() { + super.initState(); + editorState = context.read(); + images = MultiImageData.fromJson( + widget.node.attributes[MultiImageBlockKeys.images] ?? {}, + ).images; + } + + @override + void dispose() { + allowMenuClose(); + controller.close(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant MultiImageMenu oldWidget) { + images = MultiImageData.fromJson( + widget.node.attributes[MultiImageBlockKeys.images] ?? {}, + ).images; + + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + height: 32, + decoration: BoxDecoration( + color: theme.cardColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + borderRadius: BorderRadius.circular(4.0), + ), + child: Row( + children: [ + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(), + iconData: FlowySvgs.full_view_s, + onTap: openFullScreen, + ), + AppFlowyPopover( + controller: controller, + direction: PopoverDirection.bottomWithRightAligned, + onClose: allowMenuClose, + constraints: const BoxConstraints( + maxWidth: 540, + maxHeight: 360, + minHeight: 80, + ), + offset: const Offset(0, 10), + popupBuilder: (context) { + preventMenuClose(); + return UploadImageMenu( + allowMultipleImages: true, + supportTypes: const [ + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + UploadImageType.stabilityAI, + ], + onSelectedLocalImages: insertLocalImages, + onSelectedAIImage: insertAIImage, + onSelectedNetworkImage: insertNetworkImage, + ); + }, + child: MenuBlockButton( + tooltip: + LocaleKeys.document_plugins_photoGallery_addImageTooltip.tr(), + iconData: FlowySvgs.add_s, + onTap: () {}, + ), + ), + // disable the copy link button if the image is hosted on appflowy cloud + // because the url needs the verification token to be accessible + if (!images[widget.indexNotifier.value].url.isAppFlowyCloudUrl) ...[ + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.editor_copyLink.tr(), + iconData: FlowySvgs.copy_s, + onTap: copyImageLink, + ), + ], + const _Divider(), + MenuBlockButton( + tooltip: LocaleKeys.button_delete.tr(), + iconData: FlowySvgs.delete_s, + onTap: deleteImage, + ), + const HSpace(4), + ], + ), + ); + } + + void copyImageLink() { + Clipboard.setData( + ClipboardData(text: images[widget.indexNotifier.value].url), + ); + showSnackBarMessage( + context, + LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), + ); + } + + Future deleteImage() async { + final node = widget.node; + final editorState = context.read(); + final transaction = editorState.transaction; + transaction.deleteNode(node); + transaction.afterSelection = null; + await editorState.apply(transaction); + } + + void openFullScreen() { + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: images, + initialIndex: widget.indexNotifier.value, + onDeleteImage: (index) async { + final transaction = editorState.transaction; + final newImages = List.from(images); + newImages.removeAt(index); + + images = newImages; + widget.onImageDeleted(); + + final imagesJson = + newImages.map((image) => image.toJson()).toList(); + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + // Default to Browser layout + MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + }); + + await editorState.apply(transaction); + }, + ), + ), + ); + } + + void preventMenuClose() { + widget.state.alwaysShowMenu = true; + editorState.service.selectionService.registerGestureInterceptor( + gestureInterceptor, + ); + } + + void allowMenuClose() { + widget.state.alwaysShowMenu = false; + editorState.service.selectionService.unregisterGestureInterceptor( + _interceptorKey, + ); + } + + Future insertLocalImages(List urls) async { + controller.close(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) { + return; + } + + final transaction = editorState.transaction; + final newImages = + await extractAndUploadImages(context, urls, widget.isLocalMode); + if (newImages.isEmpty) { + return; + } + + newImages.addAll(images); + final imagesJson = newImages.map((image) => image.toJson()).toList(); + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + // Default to Browser layout + MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + }); + + await editorState.apply(transaction); + setState(() => images = newImages); + }); + } + + Future insertAIImage(String url) async { + controller.close(); + + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } + + final path = await getIt().getPath(); + final imagePath = p.join(path, 'images'); + try { + // create the directory if not exists + final directory = Directory(imagePath); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + final uri = Uri.parse(url); + final copyToPath = p.join( + imagePath, + '${uuid()}${p.extension(uri.path)}', + ); + + final response = await get(uri); + await File(copyToPath).writeAsBytes(response.bodyBytes); + await insertLocalImages([copyToPath]); + await File(copyToPath).delete(); + } catch (e) { + Log.error('cannot save image file', e); + } + } + + Future insertNetworkImage(String url) async { + controller.close(); + + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } + + final transaction = editorState.transaction; + + final newImages = [ + ...images, + ImageBlockData(url: url, type: CustomImageType.external), + ]; + + final imagesJson = newImages.map((image) => image.toJson()).toList(); + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + // Default to Browser layout + MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + }); + + await editorState.apply(transaction); + setState(() => images = newImages); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Container(width: 1, color: Colors.grey), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart new file mode 100644 index 0000000000..92c979144c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart @@ -0,0 +1,294 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.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/upload_image_menu/upload_image_menu.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu, Log; +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/uuid.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:string_validator/string_validator.dart'; + +class MultiImagePlaceholder extends StatefulWidget { + const MultiImagePlaceholder({super.key, required this.node}); + + final Node node; + + @override + State createState() => MultiImagePlaceholderState(); +} + +class MultiImagePlaceholderState extends State { + final controller = PopoverController(); + final documentService = DocumentService(); + late final editorState = context.read(); + + bool isDraggingFiles = false; + + @override + Widget build(BuildContext context) { + final child = DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + border: isDraggingFiles + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, + ), + child: FlowyHover( + style: HoverStyle( + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + child: Row( + children: [ + const Icon(Icons.photo_library_outlined, size: 24), + const HSpace(10), + FlowyText( + PlatformExtension.isDesktop + ? isDraggingFiles + ? LocaleKeys.document_plugins_image_dropImageToInsert + .tr() + : LocaleKeys.document_plugins_image_addAnImageDesktop + .tr() + : LocaleKeys.document_plugins_image_addAnImageMobile.tr(), + ), + ], + ), + ), + ), + ); + + if (PlatformExtension.isDesktopOrWeb) { + return AppFlowyPopover( + controller: controller, + direction: PopoverDirection.bottomWithCenterAligned, + constraints: const BoxConstraints( + maxWidth: 540, + maxHeight: 360, + minHeight: 80, + ), + clickHandler: PopoverClickHandler.gestureDetector, + popupBuilder: (_) { + return UploadImageMenu( + allowMultipleImages: true, + limitMaximumImageSize: !_isLocalMode(), + supportTypes: const [ + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + UploadImageType.stabilityAI, + ], + onSelectedLocalImages: (paths) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + await insertLocalImages(paths); + }); + }, + onSelectedAIImage: (url) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + await insertAIImage(url); + }); + }, + onSelectedNetworkImage: (url) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + await insertNetworkImage(url); + }); + }, + ); + }, + child: DropTarget( + onDragEntered: (_) => setState(() => isDraggingFiles = true), + onDragExited: (_) => setState(() => isDraggingFiles = false), + onDragDone: (details) { + // Only accept files where the mimetype is an image, + // or the file extension is a known image format, + // otherwise we assume it's a file we cannot display. + final imageFiles = details.files + .where( + (file) => + file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(file.name), + ) + .toList(); + final paths = imageFiles.map((file) => file.path).toList(); + WidgetsBinding.instance.addPostFrameCallback( + (_) async => insertLocalImages(paths), + ); + }, + child: child, + ), + ); + } else { + return MobileBlockActionButtons( + node: widget.node, + editorState: editorState, + child: GestureDetector( + onTap: () { + editorState.updateSelectionWithReason(null, extraInfo: {}); + showUploadImageMenu(); + }, + child: child, + ), + ); + } + } + + void showUploadImageMenu() { + if (PlatformExtension.isDesktopOrWeb) { + controller.show(); + } else { + final isLocalMode = _isLocalMode(); + showMobileBottomSheet( + context, + title: LocaleKeys.editor_image.tr(), + showHeader: true, + showCloseButton: true, + showDragHandle: true, + builder: (context) { + return Container( + margin: const EdgeInsets.only(top: 12.0), + constraints: const BoxConstraints( + maxHeight: 340, + minHeight: 80, + ), + child: UploadImageMenu( + limitMaximumImageSize: !isLocalMode, + allowMultipleImages: true, + supportTypes: const [ + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + ], + onSelectedLocalImages: (paths) async { + context.pop(); + await insertLocalImages(paths); + }, + onSelectedAIImage: (url) async { + context.pop(); + await insertAIImage(url); + }, + onSelectedNetworkImage: (url) async { + context.pop(); + await insertNetworkImage(url); + }, + ), + ); + }, + ); + } + } + + Future insertLocalImages(List urls) async { + controller.close(); + + if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) { + return; + } + + final transaction = editorState.transaction; + final images = await extractAndUploadImages(context, urls, _isLocalMode()); + if (images.isEmpty) { + return; + } + + final imagesJson = images.map((image) => image.toJson()).toList(); + + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + // Default to Browser layout + MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + }); + + await editorState.apply(transaction); + } + + Future insertAIImage(String url) async { + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } + + final path = await getIt().getPath(); + final imagePath = p.join(path, 'images'); + try { + // create the directory if not exists + final directory = Directory(imagePath); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + final uri = Uri.parse(url); + final copyToPath = p.join( + imagePath, + '${uuid()}${p.extension(uri.path)}', + ); + + final response = await get(uri); + await File(copyToPath).writeAsBytes(response.bodyBytes); + await insertLocalImages([copyToPath]); + await File(copyToPath).delete(); + } catch (e) { + Log.error('cannot save image file', e); + } + } + + Future insertNetworkImage(String url) async { + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } + + final transaction = editorState.transaction; + + final images = [ + ImageBlockData( + url: url, + type: CustomImageType.external, + ), + ]; + + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: + images.map((image) => image.toJson()).toList(), + // Default to Browser layout + MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + }); + await editorState.apply(transaction); + } + + bool _isLocalMode() { + return context.read().isLocalMode; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart new file mode 100644 index 0000000000..3b5962196a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart @@ -0,0 +1,451 @@ +import 'dart:io'; + +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/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage; +import 'package:collection/collection.dart'; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:provider/provider.dart'; + +const _thumbnailItemSize = 100.0; + +abstract class ImageBlockMultiLayout extends StatefulWidget { + const ImageBlockMultiLayout({ + super.key, + required this.node, + required this.editorState, + required this.images, + required this.indexNotifier, + required this.isLocalMode, + }); + + final Node node; + final EditorState editorState; + final List images; + final ValueNotifier indexNotifier; + final bool isLocalMode; +} + +class ImageBrowserLayout extends ImageBlockMultiLayout { + const ImageBrowserLayout({ + super.key, + required super.node, + required super.editorState, + required super.images, + required super.indexNotifier, + required super.isLocalMode, + required this.onIndexChanged, + }); + + final void Function(int) onIndexChanged; + + @override + State createState() => _ImageBrowserLayoutState(); +} + +class _ImageBrowserLayoutState extends State { + UserProfilePB? _userProfile; + bool isDraggingFiles = false; + + @override + void initState() { + super.initState(); + _userProfile = context.read().state.userProfilePB; + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 400, + width: MediaQuery.of(context).size.width, + child: GestureDetector( + onDoubleTap: () => _openInteractiveViewer(context), + child: ImageRender( + image: widget.images[widget.indexNotifier.value], + userProfile: _userProfile, + fit: BoxFit.contain, + ), + ), + ), + const VSpace(8), + LayoutBuilder( + builder: (context, constraints) { + final maxItems = + (constraints.maxWidth / (_thumbnailItemSize + 4)).floor(); + final items = widget.images.take(maxItems).toList(); + + return Wrap( + children: items.mapIndexed((index, image) { + final isLast = items.last == image; + final amountLeft = widget.images.length - items.length; + if (isLast && amountLeft > 0) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => _openInteractiveViewer( + context, + maxItems - 1, + ), + child: Container( + width: _thumbnailItemSize, + height: _thumbnailItemSize, + padding: const EdgeInsets.all(2), + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + border: Border.all( + width: 2, + color: Theme.of(context).dividerColor, + ), + ), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: Corners.s6Border, + image: image.type == CustomImageType.local + ? DecorationImage( + image: FileImage(File(image.url)), + fit: BoxFit.cover, + opacity: 0.5, + ) + : null, + ), + child: Stack( + children: [ + if (image.type != CustomImageType.local) + Positioned.fill( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: const BoxDecoration( + borderRadius: Corners.s6Border, + ), + child: FlowyNetworkImage( + url: image.url, + userProfilePB: _userProfile, + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.5), + ), + child: Center( + child: FlowyText( + '+$amountLeft', + color: AFThemeExtension.of(context) + .strongText, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => widget.onIndexChanged(index), + child: ThumbnailItem( + images: widget.images, + index: index, + selectedIndex: widget.indexNotifier.value, + userProfile: _userProfile, + onDeleted: () async { + final transaction = widget.editorState.transaction; + + final images = widget.images.toList(); + images.removeAt(index); + + transaction.updateNode( + widget.node, + { + MultiImageBlockKeys.images: + images.map((e) => e.toJson()).toList(), + MultiImageBlockKeys.layout: widget.node + .attributes[MultiImageBlockKeys.layout], + }, + ); + + await widget.editorState.apply(transaction); + + widget.onIndexChanged( + widget.indexNotifier.value > 0 + ? widget.indexNotifier.value - 1 + : 0, + ); + }, + ), + ), + ); + }).toList(), + ); + }, + ), + ], + ), + Positioned.fill( + child: DropTarget( + onDragEntered: (_) => setState(() => isDraggingFiles = true), + onDragExited: (_) => setState(() => isDraggingFiles = false), + onDragDone: (details) { + setState(() => isDraggingFiles = false); + // Only accept files where the mimetype is an image, + // or the file extension is a known image format, + // otherwise we assume it's a file we cannot display. + final imageFiles = details.files + .where( + (file) => + file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(file.name), + ) + .toList(); + final paths = imageFiles.map((file) => file.path).toList(); + WidgetsBinding.instance.addPostFrameCallback( + (_) async => insertLocalImages(paths), + ); + }, + child: !isDraggingFiles + ? const SizedBox.shrink() + : SizedBox.expand( + child: DecoratedBox( + decoration: + BoxDecoration(color: Colors.white.withOpacity(0.5)), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.import_s, + size: Size.square(28), + ), + const HSpace(12), + Flexible( + child: FlowyText( + LocaleKeys + .document_plugins_image_dropImageToInsert + .tr(), + color: AFThemeExtension.of(context).strongText, + fontSize: 22, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + } + + void _openInteractiveViewer(BuildContext context, [int? index]) => showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: _userProfile, + imageProvider: AFBlockImageProvider( + images: widget.images, + initialIndex: index ?? widget.indexNotifier.value, + onDeleteImage: (index) async { + final transaction = widget.editorState.transaction; + final newImages = widget.images.toList(); + newImages.removeAt(index); + + widget.onIndexChanged( + widget.indexNotifier.value > 0 + ? widget.indexNotifier.value - 1 + : 0, + ); + + if (newImages.isNotEmpty) { + transaction.updateNode( + widget.node, + { + MultiImageBlockKeys.images: + newImages.map((e) => e.toJson()).toList(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }, + ); + } else { + transaction.deleteNode(widget.node); + } + + await widget.editorState.apply(transaction); + }, + ), + ), + ); + + Future insertLocalImages(List urls) async { + if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) { + return; + } + + final isLocalMode = context.read().isLocalMode; + final transaction = widget.editorState.transaction; + final images = await extractAndUploadImages(context, urls, isLocalMode); + if (images.isEmpty) { + return; + } + + final newImages = [...widget.images, ...images]; + final imagesJson = newImages.map((image) => image.toJson()).toList(); + + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + }); + + await widget.editorState.apply(transaction); + } +} + +@visibleForTesting +class ThumbnailItem extends StatefulWidget { + const ThumbnailItem({ + super.key, + required this.images, + required this.index, + required this.selectedIndex, + required this.onDeleted, + this.userProfile, + }); + + final List images; + final int index; + final int selectedIndex; + final VoidCallback onDeleted; + final UserProfilePB? userProfile; + + @override + State createState() => _ThumbnailItemState(); +} + +class _ThumbnailItemState extends State { + bool isHovering = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + child: Container( + width: _thumbnailItemSize, + height: _thumbnailItemSize, + padding: const EdgeInsets.all(2), + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + border: Border.all( + width: 2, + color: widget.index == widget.selectedIndex + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + ), + child: Stack( + children: [ + Positioned.fill( + child: ImageRender( + image: widget.images[widget.index], + userProfile: widget.userProfile, + ), + ), + Positioned( + top: 4, + right: 4, + child: AnimatedOpacity( + opacity: isHovering ? 1 : 0, + duration: const Duration(milliseconds: 100), + child: FlowyTooltip( + message: LocaleKeys.button_delete.tr(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onDeleted, + child: FlowyHover( + resetHoverOnRebuild: false, + style: HoverStyle( + backgroundColor: Colors.black.withOpacity(0.6), + hoverColor: Colors.black.withOpacity(0.9), + ), + child: const Padding( + padding: EdgeInsets.all(4), + child: FlowySvg( + FlowySvgs.delete_s, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +@visibleForTesting +class ImageRender extends StatelessWidget { + const ImageRender({ + super.key, + required this.image, + this.userProfile, + this.fit = BoxFit.cover, + }); + + final ImageBlockData image; + final UserProfilePB? userProfile; + final BoxFit fit; + + @override + Widget build(BuildContext context) { + final child = switch (image.type) { + CustomImageType.internal || CustomImageType.external => FlowyNetworkImage( + url: image.url, + userProfilePB: userProfile, + fit: fit, + ), + CustomImageType.local => Image.file(File(image.url), fit: fit), + }; + + return Container( + clipBehavior: Clip.antiAlias, + decoration: const BoxDecoration(borderRadius: Corners.s6Border), + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart index 58d5454b4b..defcb8b354 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart @@ -1,14 +1,15 @@ import 'dart:io'; import 'dart:math'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:string_validator/string_validator.dart'; @@ -23,6 +24,7 @@ class ResizableImage extends StatefulWidget { required this.width, required this.src, this.height, + this.onDoubleTap, }); final String src; @@ -31,6 +33,7 @@ class ResizableImage extends StatefulWidget { final double? height; final Alignment alignment; final bool editable; + final VoidCallback? onDoubleTap; final void Function(double width) onResize; @@ -41,26 +44,23 @@ class ResizableImage extends StatefulWidget { const _kImageBlockComponentMinWidth = 30.0; class _ResizableImageState extends State { - late double imageWidth; + final documentService = DocumentService(); double initialOffset = 0; double moveDistance = 0; - Widget? _cacheImage; + late double imageWidth; + @visibleForTesting bool onFocus = false; - final documentService = DocumentService(); - UserProfilePB? _userProfilePB; @override void initState() { super.initState(); - imageWidth = widget.width; - _userProfilePB = context.read().state.userProfilePB; } @@ -72,13 +72,12 @@ class _ResizableImageState extends State { width: max(_kImageBlockComponentMinWidth, imageWidth - moveDistance), height: widget.height, child: MouseRegion( - onEnter: (event) => setState(() { - onFocus = true; - }), - onExit: (event) => setState(() { - onFocus = false; - }), - child: _buildResizableImage(context), + onEnter: (_) => setState(() => onFocus = true), + onExit: (_) => setState(() => onFocus = false), + child: GestureDetector( + onDoubleTap: widget.onDoubleTap, + child: _buildResizableImage(context), + ), ), ), ); @@ -97,12 +96,11 @@ class _ResizableImageState extends State { url: widget.src, width: imageWidth - moveDistance, userProfilePB: _userProfilePB, - errorWidgetBuilder: (context, url, error) => _ImageLoadFailedWidget( + progressIndicatorBuilder: (context, _, __) => _buildLoading(context), + errorWidgetBuilder: (_, __, error) => _ImageLoadFailedWidget( width: imageWidth, error: error, ), - progressIndicatorBuilder: (context, url, progress) => - _buildLoading(context), ); child = _cacheImage!; @@ -121,11 +119,7 @@ class _ResizableImageState extends State { left: 5, bottom: 0, width: 5, - onUpdate: (distance) { - setState(() { - moveDistance = distance; - }); - }, + onUpdate: (distance) => setState(() => moveDistance = distance), ), _buildEdgeGesture( context, @@ -133,11 +127,7 @@ class _ResizableImageState extends State { right: 5, bottom: 0, width: 5, - onUpdate: (distance) { - setState(() { - moveDistance = -distance; - }); - }, + onUpdate: (distance) => setState(() => moveDistance = -distance), ), ], ], @@ -154,9 +144,7 @@ class _ResizableImageState extends State { size: const Size(18, 18), child: const CircularProgressIndicator(), ), - SizedBox.fromSize( - size: const Size(10, 10), - ), + SizedBox.fromSize(size: const Size(10, 10)), Text(AppFlowyEditorL10n.current.loading), ], ), @@ -184,7 +172,7 @@ class _ResizableImageState extends State { }, onHorizontalDragUpdate: (details) { if (onUpdate != null) { - var offset = details.globalPosition.dx - initialOffset; + double offset = details.globalPosition.dx - initialOffset; if (widget.alignment == Alignment.center) { offset *= 2.0; } @@ -222,10 +210,7 @@ class _ResizableImageState extends State { } class _ImageLoadFailedWidget extends StatelessWidget { - const _ImageLoadFailedWidget({ - required this.width, - required this.error, - }); + const _ImageLoadFailedWidget({required this.width, required this.error}); final double width; final Object error; @@ -240,9 +225,7 @@ class _ImageLoadFailedWidget extends StatelessWidget { padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4.0)), - border: Border.all( - color: Colors.grey.withOpacity(0.6), - ), + border: Border.all(color: Colors.grey.withOpacity(0.6)), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -251,9 +234,7 @@ class _ImageLoadFailedWidget extends StatelessWidget { FlowySvgs.broken_image_xl, size: Size.square(48), ), - FlowyText( - AppFlowyEditorL10n.current.imageLoadFailed, - ), + FlowyText(AppFlowyEditorL10n.current.imageLoadFailed), const VSpace(6), if (error != null) FlowyText( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart index eda320bdb3..2a71f44f57 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart @@ -1,6 +1,7 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:unsplash_client/unsplash_client.dart'; const _accessKeyA = 'YyD-LbW5bVolHWZBq5fWRM_'; @@ -48,7 +49,6 @@ class _UnsplashImageWidgetState extends State { @override void initState() { super.initState(); - randomPhotos = unsplash.photos .random(count: 18, orientation: PhotoOrientation.landscape) .goAndGet(); @@ -57,7 +57,6 @@ class _UnsplashImageWidgetState extends State { @override void dispose() { unsplash.close(); - super.dispose(); } @@ -132,18 +131,16 @@ class _UnsplashImagesState extends State<_UnsplashImages> { @override Widget build(BuildContext context) { + const mainAxisSpacing = 16.0; final crossAxisCount = switch (widget.type) { UnsplashImageType.halfScreen => 3, UnsplashImageType.fullScreen => 2, }; - final mainAxisSpacing = switch (widget.type) { - UnsplashImageType.halfScreen => 16.0, - UnsplashImageType.fullScreen => 16.0, - }; final crossAxisSpacing = switch (widget.type) { UnsplashImageType.halfScreen => 10.0, UnsplashImageType.fullScreen => 16.0, }; + return GridView.count( crossAxisCount: crossAxisCount, mainAxisSpacing: mainAxisSpacing, @@ -155,15 +152,11 @@ class _UnsplashImagesState extends State<_UnsplashImages> { return _UnsplashImage( type: widget.type, photo: photo, - onTap: () { - widget.onSelectUnsplashImage( - photo.urls.regular.toString(), - ); - setState(() { - _selectedPhotoIndex = index; - }); - }, isSelected: index == _selectedPhotoIndex, + onTap: () { + widget.onSelectUnsplashImage(photo.urls.regular.toString()); + setState(() => _selectedPhotoIndex = index); + }, ); }).toList(), ); @@ -219,10 +212,7 @@ class _UnsplashImage extends StatelessWidget { ), ), const HSpace(2.0), - FlowyText( - 'by ${photo.name}', - fontSize: 10.0, - ), + FlowyText('by ${photo.name}', fontSize: 10.0), ], ); } @@ -233,14 +223,12 @@ class _UnsplashImage extends StatelessWidget { child: Stack( children: [ LayoutBuilder( - builder: (context, constraints) { - return Image.network( - photo.urls.thumb.toString(), - fit: BoxFit.cover, - width: constraints.maxWidth, - height: constraints.maxHeight, - ); - }, + builder: (_, constraints) => Image.network( + photo.urls.thumb.toString(), + fit: BoxFit.cover, + width: constraints.maxWidth, + height: constraints.maxHeight, + ), ), Positioned( bottom: 9, @@ -261,13 +249,9 @@ extension on Photo { String get name { if (user.username.isNotEmpty) { return user.username; - } - - if (user.name.isNotEmpty) { + } else if (user.name.isNotEmpty) { return user.name; - } - - if (user.email?.isNotEmpty == true) { + } else if (user.email?.isNotEmpty == true) { return user.email!; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart similarity index 86% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart index ee8f681ff5..6e668a165b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart @@ -1,9 +1,10 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/stability_ai_image_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ColorOption; @@ -11,7 +12,8 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flutter/material.dart'; + +import 'widgets/embed_image_url_widget.dart'; enum UploadImageType { local, @@ -42,20 +44,22 @@ enum UploadImageType { class UploadImageMenu extends StatefulWidget { const UploadImageMenu({ super.key, - required this.onSelectedLocalImage, + required this.onSelectedLocalImages, required this.onSelectedAIImage, required this.onSelectedNetworkImage, this.onSelectedColor, this.supportTypes = UploadImageType.values, this.limitMaximumImageSize = false, + this.allowMultipleImages = false, }); - final void Function(String? path) onSelectedLocalImage; + final void Function(List) onSelectedLocalImages; final void Function(String url) onSelectedAIImage; final void Function(String url) onSelectedNetworkImage; final void Function(String color)? onSelectedColor; final List supportTypes; final bool limitMaximumImageSize; + final bool allowMultipleImages; @override State createState() => _UploadImageMenuState(); @@ -133,9 +137,7 @@ class _UploadImageMenuState extends State { }, ).toList(), ), - const Divider( - height: 2, - ), + const Divider(height: 2), _buildTab(), ], ), @@ -155,7 +157,8 @@ class _UploadImageMenuState extends State { child: Column( children: [ UploadImageFileWidget( - onPickFile: widget.onSelectedLocalImage, + allowMultipleImages: widget.allowMultipleImages, + onPickFiles: widget.onSelectedLocalImages, ), if (widget.limitMaximumImageSize) ...[ const VSpace(6.0), @@ -185,30 +188,13 @@ class _UploadImageMenuState extends State { ), ), ); - // case UploadImageType.openAI: - // return supportOpenAI - // ? Expanded( - // child: Container( - // padding: const EdgeInsets.all(8.0), - // constraints: constraints, - // child: OpenAIImageWidget( - // onSelectNetworkImage: widget.onSelectedAIImage, - // ), - // ), - // ) - // : Padding( - // padding: const EdgeInsets.all(8.0), - // child: FlowyText( - // LocaleKeys.document_imageBlock_pleaseInputYourOpenAIKey.tr(), - // ), - // ); case UploadImageType.stabilityAI: return supportStabilityAI ? Expanded( child: Container( padding: const EdgeInsets.all(8.0), child: StabilityAIImageWidget( - onSelectImage: widget.onSelectedLocalImage, + onSelectImage: (url) => widget.onSelectedLocalImages([url]), ), ), ) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart similarity index 100% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/open_ai_image_widget.dart similarity index 100% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/open_ai_image_widget.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/open_ai_image_widget.dart diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/stability_ai_image_widget.dart similarity index 100% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/stability_ai_image_widget.dart diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart similarity index 82% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart index d4d94be091..e9a6ea677d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/permission/permission_checker.dart'; import 'package:appflowy/startup/startup.dart'; @@ -7,18 +9,19 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; class UploadImageFileWidget extends StatelessWidget { const UploadImageFileWidget({ super.key, - required this.onPickFile, + required this.onPickFiles, this.allowedExtensions = const ['jpg', 'png', 'jpeg'], + this.allowMultipleImages = false, }); - final void Function(String? path) onPickFile; + final void Function(List) onPickFiles; final List allowedExtensions; + final bool allowMultipleImages; @override Widget build(BuildContext context) { @@ -35,9 +38,7 @@ class UploadImageFileWidget extends StatelessWidget { ); if (PlatformExtension.isDesktopOrWeb) { - return FlowyHover( - child: child, - ); + return FlowyHover(child: child); } return child; @@ -50,8 +51,9 @@ class UploadImageFileWidget extends StatelessWidget { dialogTitle: '', type: FileType.custom, allowedExtensions: allowedExtensions, + allowMultiple: allowMultipleImages, ); - onPickFile(result?.files.firstOrNull?.path); + onPickFiles(result?.files.map((f) => f.path).toList() ?? const []); } else { final photoPermission = await PermissionChecker.checkPhotoPermission(context); @@ -60,8 +62,8 @@ class UploadImageFileWidget extends StatelessWidget { return; } // on mobile, the users can pick a image file from camera or image library - final result = await ImagePicker().pickImage(source: ImageSource.gallery); - onPickFile(result?.path); + final result = await ImagePicker().pickMultiImage(); + onPickFiles(result.map((f) => f.path).toList()); } } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart index a83fbed589..d2c84fe456 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart @@ -1,3 +1,6 @@ +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/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; @@ -7,10 +10,10 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../image/custom_image_block_component/custom_image_block_component.dart'; + class LinkPreviewMenu extends StatefulWidget { const LinkPreviewMenu({ super.key, @@ -72,7 +75,7 @@ class _LinkPreviewMenuState extends State { } void copyImageLink() { - final url = widget.node.attributes[ImageBlockKeys.url]; + final url = widget.node.attributes[CustomImageBlockKeys.url]; if (url != null) { Clipboard.setData(ClipboardData(text: url)); showSnackBarMessage( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart index d0be5af466..6974d40a24 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart @@ -7,6 +7,8 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.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_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart'; @@ -196,6 +198,19 @@ class _AddBlockMenu extends StatelessWidget { }); }, ), + TypeOptionMenuItemValue( + value: MultiImageBlockKeys.type, + backgroundColor: colorMap[ImageBlockKeys.type]!, + text: LocaleKeys.document_plugins_photoGallery_name.tr(), + icon: FlowySvgs.m_add_block_photo_gallery_s, + onTap: (_, __) async { + AppGlobals.rootNavKey.currentContext?.pop(true); + Future.delayed(const Duration(milliseconds: 400), () async { + final imagePlaceholderKey = GlobalKey(); + await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey); + }); + }, + ), // date TypeOptionMenuItemValue( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_image_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_image_node_parser.dart index a33d99fd82..91398302ed 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_image_node_parser.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_image_node_parser.dart @@ -1,5 +1,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import '../image/custom_image_block_component/custom_image_block_component.dart'; + class CustomImageNodeParser extends NodeParser { const CustomImageNodeParser(); @@ -9,7 +11,7 @@ class CustomImageNodeParser extends NodeParser { @override String transform(Node node, DocumentMarkdownEncoder? encoder) { assert(node.children.isEmpty); - final url = node.attributes[ImageBlockKeys.url]; + final url = node.attributes[CustomImageBlockKeys.url]; assert(url != null); return '![]($url)\n'; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart index 8afef3ec0f..42da0734ac 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -19,7 +19,8 @@ export 'font/customize_font_toolbar_item.dart'; export 'header/cover_editor_bloc.dart'; export 'header/custom_cover_picker.dart'; export 'header/document_header_node_widget.dart'; -export 'image/image_menu.dart'; +export 'image/custom_image_block_component/image_menu.dart'; +export 'image/multi_image_block_component/multi_image_menu.dart'; export 'image/image_selection_menu.dart'; export 'image/mobile_image_toolbar_item.dart'; export 'inline_math_equation/inline_math_equation.dart'; diff --git a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart index fb9cd9f226..baa585a88a 100644 --- a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart +++ b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart @@ -13,6 +13,9 @@ const _imgUrlPattern = r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm)(\?[^\s[",><]*)?'; final imgUrlRegex = RegExp(_imgUrlPattern); +const _imgExtensionPattern = r'\.(gif|jpe?g|tiff?|png|webp|bmp)$'; +final imgExtensionRegex = RegExp(_imgExtensionPattern); + /// This pattern allows for both HTTP and HTTPS Scheme /// It allows for query parameters /// It only allows the following video extensions: diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart index fe5afb5a46..dffad1a465 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart @@ -1,5 +1,8 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; +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'; @@ -26,8 +29,6 @@ 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/widget/spacing.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -64,13 +65,13 @@ class SettingsManageDataView extends StatelessWidget { label: LocaleKeys.settings_common_reset.tr(), onPressed: () => showConfirmDialog( context: context, + confirmLabel: LocaleKeys.button_confirm.tr(), title: LocaleKeys .settings_manageDataPage_dataStorage_resetDialog_title .tr(), description: LocaleKeys .settings_manageDataPage_dataStorage_resetDialog_description .tr(), - confirmLabel: LocaleKeys.button_confirm.tr(), onConfirm: () async { final directory = await appFlowyApplicationDataDirectory(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/image_provider.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/image_provider.dart new file mode 100644 index 0000000000..359458c6e4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/image_provider.dart @@ -0,0 +1,67 @@ +import 'package:flutter/widgets.dart'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; + +/// Abstract class for providing images to the [InteractiveImageViewer]. +/// +abstract class AFImageProvider { + const AFImageProvider({this.onDeleteImage}); + + /// Provide this callback if you want it to be possible to + /// delete the Image through the [InteractiveImageViewer]. + /// + final Function(int index)? onDeleteImage; + + int get imageCount; + int get initialIndex; + + ImageBlockData getImage(int index); + Widget renderImage( + BuildContext context, + int index, [ + UserProfilePB? userProfile, + ]); +} + +class AFBlockImageProvider implements AFImageProvider { + const AFBlockImageProvider({ + required this.images, + this.initialIndex = 0, + required this.onDeleteImage, + }); + + final List images; + + @override + final Function(int) onDeleteImage; + + @override + final int initialIndex; + + @override + int get imageCount => images.length; + + @override + ImageBlockData getImage(int index) => images[index]; + + @override + Widget renderImage( + BuildContext context, + int index, [ + UserProfilePB? userProfile, + ]) { + final image = getImage(index); + + if (image.type == CustomImageType.local) { + return Image(image: image.toImageProvider()); + } + + return FlowyNetworkImage( + url: image.url, + userProfilePB: userProfile, + fit: BoxFit.contain, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart new file mode 100644 index 0000000000..21fb54066d --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart @@ -0,0 +1,339 @@ +import 'dart:convert'; +import 'dart:io'; + +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/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.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/file_picker/file_picker_impl.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/snap_bar.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart'; + +class InteractiveImageToolbar extends StatelessWidget { + const InteractiveImageToolbar({ + super.key, + required this.currentImage, + required this.imageCount, + required this.isFirstIndex, + required this.isLastIndex, + required this.currentScale, + required this.onPrevious, + required this.onNext, + required this.onZoomIn, + required this.onZoomOut, + required this.onScaleChanged, + this.onDelete, + this.userProfile, + }); + + final ImageBlockData currentImage; + final int imageCount; + final bool isFirstIndex; + final bool isLastIndex; + final int currentScale; + + final VoidCallback onPrevious; + final VoidCallback onNext; + final VoidCallback onZoomIn; + final VoidCallback onZoomOut; + final Function(double scale) onScaleChanged; + final UserProfilePB? userProfile; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 16, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: 200, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (imageCount > 1) + _renderToolbarItems( + children: [ + _ToolbarItem( + isDisabled: isFirstIndex, + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_previousImageTooltip + .tr(), + icon: FlowySvgs.arrow_left_s, + onTap: () { + if (!isFirstIndex) { + onPrevious(); + } + }, + ), + _ToolbarItem( + isDisabled: isLastIndex, + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_nextImageTooltip + .tr(), + icon: FlowySvgs.arrow_right_s, + onTap: () { + if (!isLastIndex) { + onNext(); + } + }, + ), + ], + ), + const HSpace(10), + _renderToolbarItems( + children: [ + _ToolbarItem( + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_zoomOutTooltip + .tr(), + icon: FlowySvgs.minus_s, + onTap: onZoomOut, + ), + AppFlowyPopover( + offset: const Offset(0, -8), + decoration: const BoxDecoration(color: Colors.transparent), + direction: PopoverDirection.topWithCenterAligned, + constraints: const BoxConstraints(maxHeight: 50), + popupBuilder: (context) => _renderToolbarItems( + children: [ + _ScaleSlider( + currentScale: currentScale, + onScaleChanged: onScaleChanged, + ), + ], + ), + child: FlowyTooltip( + message: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_changeZoomLevelTooltip + .tr(), + child: FlowyHover( + resetHoverOnRebuild: false, + style: HoverStyle( + hoverColor: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.all(6), + child: SizedBox( + width: 40, + child: Center( + child: FlowyText( + LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_scalePercentage + .tr(args: [currentScale.toString()]), + color: Colors.white, + ), + ), + ), + ), + ), + ), + ), + _ToolbarItem( + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_zoomInTooltip + .tr(), + icon: FlowySvgs.add_s, + onTap: onZoomIn, + ), + ], + ), + const HSpace(10), + _renderToolbarItems( + children: [ + if (onDelete != null) + _ToolbarItem( + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_deleteImageTooltip + .tr(), + icon: FlowySvgs.delete_s, + onTap: () { + onDelete!(); + Navigator.of(context).pop(); + }, + ), + if (!PlatformExtension.isMobile) ...[ + _ToolbarItem( + tooltip: currentImage.isNotInternal + ? LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_openLocalImage + .tr() + : LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_downloadImage + .tr(), + icon: currentImage.isNotInternal + ? currentImage.isLocal + ? FlowySvgs.folder_m + : FlowySvgs.m_aa_link_s + : FlowySvgs.import_s, + onTap: () => _locateOrDownloadImage(context), + ), + ], + ], + ), + const HSpace(10), + _renderToolbarItems( + children: [ + _ToolbarItem( + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_closeViewer + .tr(), + icon: FlowySvgs.close_s, + onTap: () => Navigator.of(context).pop(), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _renderToolbarItems({required List children}) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: Colors.black.withOpacity(0.6), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: SeparatedRow( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const HSpace(4), + children: children, + ), + ), + ); + } + + Future _locateOrDownloadImage(BuildContext context) async { + if (currentImage.isLocal) { + /// If the image type is local, we simply open the image + await afLaunchUrl(Uri.file(currentImage.url)); + } else if (currentImage.isNotInternal) { + // In case of eg. Unsplash images (images without extension type in URL), + // we don't know their mimetype. In the future we can write a parser + // using the Mime package and read the image to get the proper extension. + await afLaunchUrl(Uri.parse(currentImage.url)); + } else { + if (userProfile == null) { + return showSnapBar( + context, + LocaleKeys.document_plugins_image_imageDownloadFailedToken.tr(), + ); + } + + final uri = Uri.parse(currentImage.url); + final imgFile = File(uri.pathSegments.last); + final savePath = await FilePicker().saveFile( + fileName: basename(imgFile.path), + ); + + if (savePath != null) { + final uri = Uri.parse(currentImage.url); + + final token = jsonDecode(userProfile!.token)['access_token']; + final response = await http.get( + uri, + headers: {'Authorization': 'Bearer $token'}, + ); + if (response.statusCode == 200) { + final imgFile = File(savePath); + await imgFile.writeAsBytes(response.bodyBytes); + } else if (context.mounted) { + showSnapBar( + context, + LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), + ); + } + } + } + } +} + +class _ToolbarItem extends StatelessWidget { + const _ToolbarItem({ + required this.tooltip, + required this.icon, + required this.onTap, + this.isDisabled = false, + }); + + final String tooltip; + final FlowySvgData icon; + final VoidCallback onTap; + final bool isDisabled; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: FlowyTooltip( + message: tooltip, + child: FlowyHover( + resetHoverOnRebuild: false, + style: HoverStyle( + hoverColor: + isDisabled ? Colors.transparent : Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: FlowySvg( + icon, + size: const Size.square(16), + color: isDisabled ? Colors.grey : Colors.white, + ), + ), + ), + ), + ); + } +} + +class _ScaleSlider extends StatefulWidget { + const _ScaleSlider({ + required this.currentScale, + required this.onScaleChanged, + }); + + final int currentScale; + final Function(double scale) onScaleChanged; + + @override + State<_ScaleSlider> createState() => __ScaleSliderState(); +} + +class __ScaleSliderState extends State<_ScaleSlider> { + late int _currentScale = widget.currentScale; + + @override + Widget build(BuildContext context) { + return Slider( + max: 5.0, + min: 0.5, + value: _currentScale / 100, + onChanged: (scale) { + widget.onScaleChanged(scale); + setState( + () => _currentScale = (scale * 100).toInt(), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart new file mode 100644 index 0000000000..1b1a3353c3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:provider/provider.dart'; + +const double _minScaleFactor = .5; +const double _maxScaleFactor = 5; + +class InteractiveImageViewer extends StatefulWidget { + const InteractiveImageViewer({ + super.key, + this.userProfile, + required this.imageProvider, + }); + + final UserProfilePB? userProfile; + final AFImageProvider imageProvider; + + @override + State createState() => _InteractiveImageViewerState(); +} + +class _InteractiveImageViewerState extends State { + final TransformationController controller = TransformationController(); + final focusNode = FocusNode(); + + int currentScale = 100; + late int currentIndex = widget.imageProvider.initialIndex; + + bool get isLastIndex => currentIndex == widget.imageProvider.imageCount - 1; + bool get isFirstIndex => currentIndex == 0; + + late ImageBlockData currentImage; + + UserProfilePB? userProfile; + + @override + void initState() { + super.initState(); + controller.addListener(_onControllerChanged); + currentImage = widget.imageProvider.getImage(currentIndex); + userProfile = + widget.userProfile ?? context.read().state.userProfilePB; + focusNode.requestFocus(); + } + + void _onControllerChanged() { + final scale = controller.value.getMaxScaleOnAxis(); + final percentage = (scale * 100).toInt(); + setState(() => currentScale = percentage); + } + + @override + void dispose() { + controller.removeListener(_onControllerChanged); + controller.dispose(); + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + + return KeyboardListener( + focusNode: focusNode, + onKeyEvent: (event) { + if (event is! KeyDownEvent) { + return; + } + + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + _move(-1); + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + _move(1); + } else if ([ + LogicalKeyboardKey.add, + LogicalKeyboardKey.numpadAdd, + ].contains(event.logicalKey)) { + _zoom(1.1, size); + } else if ([ + LogicalKeyboardKey.minus, + LogicalKeyboardKey.numpadSubtract, + ].contains(event.logicalKey)) { + _zoom(.9, size); + } else if ([ + LogicalKeyboardKey.numpad0, + LogicalKeyboardKey.digit0, + ].contains(event.logicalKey)) { + controller.value = Matrix4.identity(); + _onControllerChanged(); + } + }, + child: Stack( + fit: StackFit.expand, + children: [ + SizedBox.expand( + child: InteractiveViewer( + boundaryMargin: const EdgeInsets.all(double.infinity), + transformationController: controller, + constrained: false, + minScale: _minScaleFactor, + maxScale: _maxScaleFactor, + scaleFactor: 500, + child: SizedBox( + height: size.height, + width: size.width, + child: widget.imageProvider.renderImage( + context, + currentIndex, + userProfile, + ), + ), + ), + ), + InteractiveImageToolbar( + currentImage: currentImage, + imageCount: widget.imageProvider.imageCount, + isFirstIndex: isFirstIndex, + isLastIndex: isLastIndex, + currentScale: currentScale, + userProfile: userProfile, + onPrevious: () => _move(-1), + onNext: () => _move(1), + onZoomIn: () => _zoom(1.1, size), + onZoomOut: () => _zoom(.9, size), + onScaleChanged: (scale) { + final currentScale = controller.value.getMaxScaleOnAxis(); + final scaleStep = scale / currentScale; + _zoom(scaleStep, size); + }, + onDelete: () => + widget.imageProvider.onDeleteImage?.call(currentIndex), + ), + ], + ), + ); + } + + void _move(int steps) { + setState(() { + final index = currentIndex + steps; + currentIndex = index.clamp(0, widget.imageProvider.imageCount - 1); + currentImage = widget.imageProvider.getImage(currentIndex); + }); + } + + void _zoom(double scaleStep, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final scenePointBefore = controller.toScene(center); + final currentScale = controller.value.getMaxScaleOnAxis(); + final newScale = (currentScale * scaleStep).clamp( + _minScaleFactor, + _maxScaleFactor, + ); + + // Create a new transformation + final newMatrix = Matrix4.identity() + ..translate(scenePointBefore.dx, scenePointBefore.dy) + ..scale(newScale / currentScale) + ..translate(-scenePointBefore.dx, -scenePointBefore.dy); + + // Apply the new transformation + controller.value = newMatrix * controller.value; + + // Convert the center point to scene coordinates after scaling + final scenePointAfter = controller.toScene(center); + + // Compute difference to keep the same center point + final dx = scenePointAfter.dx - scenePointBefore.dx; + final dy = scenePointAfter.dy - scenePointBefore.dy; + + // Apply the translation + controller.value = Matrix4.identity() + ..translate(-dx, -dy) + ..multiply(controller.value); + + _onControllerChanged(); + } +} diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 5ae9a656c4..5f143adc84 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -836,10 +836,10 @@ packages: dependency: "direct main" description: name: fluttertoast - sha256: dfdde255317af381bfc1c486ed968d5a43a2ded9c931e87cbecd88767d6a71c1 + sha256: "7eae679e596a44fdf761853a706f74979f8dd3cd92cf4e23cae161fda091b847" url: "https://pub.dev" source: hosted - version: "8.2.4" + version: "8.2.6" freezed: dependency: "direct dev" description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 04e4cac6d8..2f7cc3cfbc 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -74,7 +74,7 @@ dependencies: connectivity_plus: ^5.0.2 easy_localization: ^3.0.2 device_info_plus: ^10.1.0 - fluttertoast: ^8.2.2 + fluttertoast: ^8.2.6 json_annotation: ^4.8.1 table_calendar: ^3.0.9 reorderables: ^0.6.0 diff --git a/frontend/resources/flowy_icons/16x/m_add_block_photo_gallery.svg b/frontend/resources/flowy_icons/16x/m_add_block_photo_gallery.svg new file mode 100644 index 0000000000..ccbfa80599 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_add_block_photo_gallery.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/minus.svg b/frontend/resources/flowy_icons/16x/minus.svg new file mode 100644 index 0000000000..8be3fe893d --- /dev/null +++ b/frontend/resources/flowy_icons/16x/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 973e3f82b7..a3958a1aff 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -1482,11 +1482,25 @@ "depth": "Depth" }, "image": { - "copiedToPasteBoard": "The image link has been copied to the clipboard", "addAnImage": "Add an image", - "imageUploadFailed": "Upload failed", + "copiedToPasteBoard": "The image link has been copied to the clipboard", + "addAnImageDesktop": "Drop image(s) or click to add image(s)", + "addAnImageMobile": "Click to add one or more images", + "dropImageToInsert": "Drop images to insert", + "imageUploadFailed": "Image upload failed", + "imageDownloadFailed": "Image upload failed, please try again", + "imageDownloadFailedToken": "Image upload failed due to missing user token, please try again", "errorCode": "Error code" }, + "photoGallery": { + "name": "Photo gallery", + "imageKeyword": "image", + "imageGalleryKeyword": "image gallery", + "photoKeyword": "photo", + "photoBrowserKeyword": "photo browser", + "galleryKeyword": "gallery", + "addImageTooltip": "Add image" + }, "math": { "copiedToPasteBoard": "The math equation has been copied to the clipboard" }, @@ -1542,7 +1556,7 @@ "placeholder": "Untitled" }, "imageBlock": { - "placeholder": "Click to add image", + "placeholder": "Click to add image(s)", "upload": { "label": "Upload", "placeholder": "Click to upload image" @@ -1565,7 +1579,8 @@ "invalidImageSize": "Image size must be less than 5MB", "invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageUrl": "Invalid image URL", - "noImage": "No such file or directory" + "noImage": "No such file or directory", + "multipleImagesFailed": "One or more images failed to upload, please try again" }, "embedLink": { "label": "Embed link", @@ -1583,7 +1598,22 @@ "unableToLoadImage": "Unable to load image", "maximumImageSize": "Maximum supported upload image size is 10MB", "uploadImageErrorImageSizeTooBig": "Image size must be less than 10MB", - "imageIsUploading": "Image is uploading" + "imageIsUploading": "Image is uploading", + "openFullScreen": "Open in full screen", + "interactiveViewer": { + "toolbar": { + "previousImageTooltip": "Previous image", + "nextImageTooltip": "Next image", + "zoomOutTooltip": "Zoom out", + "zoomInTooltip": "Zoom in", + "changeZoomLevelTooltip": "Change zoom level", + "openLocalImage": "Open image", + "downloadImage": "Download image", + "closeViewer": "Close interactive viewer", + "scalePercentage": "{}%", + "deleteImageTooltip": "Delete image" + } + } }, "codeBlock": { "language": { diff --git a/frontend/scripts/makefile/env.toml b/frontend/scripts/makefile/env.toml index 30de7e138b..a88a4874c7 100644 --- a/frontend/scripts/makefile/env.toml +++ b/frontend/scripts/makefile/env.toml @@ -5,13 +5,13 @@ run_task = { name = ["install_flutter_prerequests"] } run_task = { name = ["install_tauri_prerequests"] } [tasks.appflowy-flutter-dev-tools] -run_task = { name = ["appflowy-flutter-deps-tools","install_diesel"] } +run_task = { name = ["appflowy-flutter-deps-tools", "install_diesel"] } [tasks.appflowy-tauri-dev-tools] -run_task = { name = ["appflowy-tauri-deps-tools","install_diesel"] } +run_task = { name = ["appflowy-tauri-deps-tools", "install_diesel"] } [tasks.install_windows_deps.windows] -dependencies=["check_duckscript_installation", "check_vcpkg", "install_vcpkg_sqlite", "install_rust_vcpkg_cli"] +dependencies = ["check_duckscript_installation", "check_vcpkg", "install_vcpkg_sqlite", "install_rust_vcpkg_cli"] [tasks.check_visual_studio_installation.windows] script = """ @@ -101,13 +101,13 @@ rustup target add x86_64-unknown-linux-gnu """ [tasks.install_tauri_prerequests] -dependencies=["install_targets", "install_web_protobuf"] +dependencies = ["install_targets", "install_web_protobuf"] [tasks.install_flutter_prerequests] -dependencies=["install_targets", "install_flutter_protobuf"] +dependencies = ["install_targets", "install_flutter_protobuf"] [tasks.install_flutter_prerequests.windows] -dependencies=["install_targets", "install_windows_deps"] +dependencies = ["install_targets", "install_windows_deps"] [tasks.install_tools] script = """ @@ -148,7 +148,7 @@ script_runner = "@duckscript" [tasks.enable_git_hook] -dependencies=["download_gitlint"] +dependencies = ["download_gitlint"] script = """ git config core.hooksPath .githooks """