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 <nathan@appflowy.io>
This commit is contained in:
Mathias Mogensen 2024-07-25 14:47:08 +02:00 committed by GitHub
parent 82fffba45a
commit 23b6f94e82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 3035 additions and 431 deletions

View File

@ -12,6 +12,8 @@ env:
NODE_VERSION: "18.16.0" NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0" PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.77.2" RUST_TOOLCHAIN: "1.77.2"
CARGO_MAKE_VERSION: "0.36.6"
CI: true
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 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 if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted runs-on: self-hosted
env:
CI: true
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: install frontend dependencies - name: install frontend dependencies
@ -49,14 +48,11 @@ jobs:
tauri-build-ubuntu: tauri-build-ubuntu:
if: github.event.pull_request.head.repo.full_name != github.repository if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest runs-on: ubuntu-20.04
env:
CI: true
steps: steps:
- name: Maximize build space (ubuntu only) - uses: actions/checkout@v4
if: matrix.os == 'ubuntu-latest' - name: Maximize build space
run: | run: |
sudo rm -rf /usr/share/dotnet sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc sudo rm -rf /opt/ghc
@ -85,36 +81,27 @@ jobs:
override: true override: true
profile: minimal profile: minimal
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./frontend/appflowy_web_app/src-tauri -> target"
- name: Node_modules cache - name: Node_modules cache
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
path: frontend/appflowy_web_app/node_modules path: frontend/appflowy_web_app/node_modules
key: node-modules-${{ runner.os }} key: node-modules-${{ runner.os }}
- name: install dependencies (windows only) - name: install dependencies
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'
working-directory: frontend working-directory: frontend
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf 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 working-directory: frontend
run: | run: |
cargo install --force cargo-make
cargo make appflowy-tauri-deps-tools cargo make appflowy-tauri-deps-tools
shell: bash
- name: install frontend dependencies - name: install frontend dependencies
working-directory: frontend/appflowy_web_app working-directory: frontend/appflowy_web_app

View File

@ -42,7 +42,7 @@ void main() {
await tester.tapAnonymousSignInButton(); await tester.tapAnonymousSignInButton();
await tester.expectToSeeHomePageWithGetStartedPage(); await tester.expectToSeeHomePageWithGetStartedPage();
// reanme the name of the anon user // rename the name of the anon user
await tester.openSettings(); await tester.openSettings();
await tester.openSettingsPage(SettingsPage.account); await tester.openSettingsPage(SettingsPage.account);
await tester.pumpAndSettle(); await tester.pumpAndSettle();

View File

@ -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' import 'document_with_inline_math_equation_test.dart'
as document_with_inline_math_equation_test; as document_with_inline_math_equation_test;
import 'document_with_inline_page_test.dart' as document_with_inline_page_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_outline_block_test.dart' as document_with_outline_block;
import 'document_with_toggle_list_test.dart' as document_with_toggle_list_test; import 'document_with_toggle_list_test.dart' as document_with_toggle_list_test;
import 'edit_document_test.dart' as document_edit_test; import 'edit_document_test.dart' as document_edit_test;
@ -38,6 +40,7 @@ void startTesting() {
document_text_direction_test.main(); document_text_direction_test.main();
document_option_action_test.main(); document_option_action_test.main();
document_with_image_block_test.main(); document_with_image_block_test.main();
document_with_multi_image_block_test.main();
document_inline_page_reference_test.main(); document_inline_page_reference_test.main();
document_more_actions_test.main(); document_more_actions_test.main();
} }

View File

@ -1,21 +1,22 @@
import 'dart:io'; 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.dart';
import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/locale_keys.g.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/custom_image_block_component/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/image_placeholder.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/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/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/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart' import 'package:appflowy_editor/appflowy_editor.dart'
hide UploadImageMenu, ResizableImage; hide UploadImageMenu, ResizableImage;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -36,7 +37,7 @@ void main() {
// create a new document // create a new document
await tester.createNewPageWithNameUnderParent( await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImage.tr(), name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
); );
// tap the first line of the document // tap the first line of the document
@ -84,7 +85,7 @@ void main() {
// create a new document // create a new document
await tester.createNewPageWithNameUnderParent( await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImage.tr(), name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
); );
// tap the first line of the document // tap the first line of the document
@ -137,7 +138,7 @@ void main() {
// create a new document // create a new document
await tester.createNewPageWithNameUnderParent( await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImage.tr(), name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
); );
// tap the first line of the document // tap the first line of the document
@ -161,5 +162,67 @@ void main() {
expect(find.byType(UnsplashImageWidget), findsOneWidget); 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<KeyValueStorage>().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()]);
});
}); });
} }

View File

@ -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<KeyValueStorage>().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));
});
});
}

View File

@ -1,6 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart'; import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart';
@ -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/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/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/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/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';

View File

@ -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/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.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/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/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_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
Map<String, BlockComponentBuilder> getEditorBuilderMap({ Map<String, BlockComponentBuilder> getEditorBuilderMap({
@ -107,13 +110,33 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
ImageBlockKeys.type: CustomImageBlockComponentBuilder( ImageBlockKeys.type: CustomImageBlockComponentBuilder(
configuration: configuration, configuration: configuration,
showMenu: true, showMenu: true,
menuBuilder: (Node node, CustomImageBlockComponentState state) => menuBuilder: (node, state) => Positioned(
Positioned(
top: 10, top: 10,
right: 10, right: 10,
child: ImageMenu(node: node, state: state), child: ImageMenu(node: node, state: state),
), ),
), ),
MultiImageBlockKeys.type: MultiImageBlockComponentBuilder(
configuration: configuration,
showMenu: true,
menuBuilder: (
Node node,
MultiImageBlockComponentState state,
ValueNotifier<int> indexNotifier,
VoidCallback onImageDeleted,
) =>
Positioned(
top: 10,
right: 10,
child: MultiImageMenu(
node: node,
state: state,
indexNotifier: indexNotifier,
isLocalMode: context.read<DocumentBloc>().isLocalMode,
onImageDeleted: onImageDeleted,
),
),
),
TableBlockKeys.type: TableBlockComponentBuilder( TableBlockKeys.type: TableBlockComponentBuilder(
menuBuilder: (node, editorState, position, dir, onBuild, onClose) => menuBuilder: (node, editorState, position, dir, onBuild, onClose) =>
TableMenu( TableMenu(

View File

@ -1,5 +1,8 @@
import 'dart:ui' as ui; 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/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.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:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.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'; import 'package:flutter_bloc/flutter_bloc.dart';
final codeBlockLocalization = CodeBlockLocalizations( final codeBlockLocalization = CodeBlockLocalizations(
@ -415,6 +416,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
emojiMenuItem, emojiMenuItem,
autoGeneratorMenuItem, autoGeneratorMenuItem,
dateMenuItem, dateMenuItem,
multiImageMenuItem,
]; ];
} }

View File

@ -1,5 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.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/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/desktop_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.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/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_plugins/migration/editor_migration.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/appflowy_network_image.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:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:string_validator/string_validator.dart'; import 'package:string_validator/string_validator.dart';
@ -482,9 +483,12 @@ class DocumentCoverState extends State<DocumentCover> {
UploadImageType.url, UploadImageType.url,
UploadImageType.unsplash, UploadImageType.unsplash,
], ],
onSelectedLocalImage: (path) async { onSelectedLocalImages: (paths) async {
context.pop(); context.pop();
widget.onChangeCover(CoverType.file, path); widget.onChangeCover(
CoverType.file,
paths.first,
);
}, },
onSelectedAIImage: (_) { onSelectedAIImage: (_) {
throw UnimplementedError(); throw UnimplementedError();
@ -608,9 +612,9 @@ class DocumentCoverState extends State<DocumentCover> {
UploadImageType.url, UploadImageType.url,
UploadImageType.unsplash, UploadImageType.unsplash,
], ],
onSelectedLocalImage: (path) { onSelectedLocalImages: (paths) {
popoverController.close(); popoverController.close();
onCoverChanged(CoverType.file, path); onCoverChanged(CoverType.file, paths.first);
}, },
onSelectedAIImage: (_) { onSelectedAIImage: (_) {
throw UnimplementedError(); throw UnimplementedError();

View File

@ -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<String, dynamic> 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<String, dynamic> 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));
}
}
}

View File

@ -4,53 +4,28 @@ import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/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/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/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/image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.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/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/presentation/home/toast.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:appflowy_editor/appflowy_editor.dart' hide ResizableImage;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:string_validator/string_validator.dart'; import 'package:string_validator/string_validator.dart';
import '../common.dart';
const kImagePlaceholderKey = 'imagePlaceholderKey'; 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 { class CustomImageBlockKeys {
const CustomImageBlockKeys._(); const CustomImageBlockKeys._();
@ -84,6 +59,25 @@ class CustomImageBlockKeys {
static const String imageType = 'image_type'; 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( typedef CustomImageBlockComponentMenuBuilder = Widget Function(
Node node, Node node,
CustomImageBlockComponentState state, CustomImageBlockComponentState state,
@ -182,7 +176,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
); );
} else if (imageType != CustomImageType.internal && } else if (imageType != CustomImageType.internal &&
!_checkIfURLIsValid(src)) { !_checkIfURLIsValid(src)) {
child = const UnSupportImageWidget(); child = const UnsupportedImageWidget();
} else { } else {
child = ResizableImage( child = ResizableImage(
src: src, src: src,
@ -191,11 +185,22 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
editable: editorState.editable, editable: editorState.editable,
alignment: alignment, alignment: alignment,
type: imageType, type: imageType,
onDoubleTap: () => showDialog(
context: context,
builder: (_) => InteractiveImageViewer(
userProfile: context.read<DocumentBloc>().state.userProfilePB,
imageProvider: AFBlockImageProvider(
images: [ImageBlockData(url: src, type: imageType)],
onDeleteImage: (_) async {
final transaction = editorState.transaction..deleteNode(node);
await editorState.apply(transaction);
},
),
),
),
onResize: (width) { onResize: (width) {
final transaction = editorState.transaction final transaction = editorState.transaction
..updateNode(node, { ..updateNode(node, {CustomImageBlockKeys.width: width});
CustomImageBlockKeys.width: width,
});
editorState.apply(transaction); editorState.apply(transaction);
}, },
); );
@ -207,21 +212,11 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
delegate: this, delegate: this,
listenable: editorState.selectionNotifier, listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor, blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [ supportTypes: const [BlockSelectionType.block],
BlockSelectionType.block, child: Padding(key: imageKey, padding: padding, child: child),
],
child: Padding(
key: imageKey,
padding: padding,
child: child,
),
); );
} else { } else {
child = Padding( child = Padding(key: imageKey, padding: padding, child: child);
key: imageKey,
padding: padding,
child: child,
);
} }
if (widget.showActions && widget.actionBuilder != null) { if (widget.showActions && widget.actionBuilder != null) {
@ -246,7 +241,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
opaque: false, opaque: false,
child: ValueListenableBuilder<bool>( child: ValueListenableBuilder<bool>(
valueListenable: showActionsNotifier, valueListenable: showActionsNotifier,
builder: (context, value, child) { builder: (_, value, child) {
final url = node.attributes[CustomImageBlockKeys.url]; final url = node.attributes[CustomImageBlockKeys.url];
return Stack( return Stack(
children: [ children: [
@ -259,10 +254,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
child: child!, child: child!,
), ),
if (value && url.isNotEmpty == true) if (value && url.isNotEmpty == true)
widget.menuBuilder!( widget.menuBuilder!(widget.node, this),
widget.node,
this,
),
], ],
); );
}, },

View File

@ -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/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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/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/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/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/presentation/home/toast.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_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/ignore_parent_gesture.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'; import 'package:provider/provider.dart';
class ImageMenu extends StatefulWidget { class ImageMenu extends StatefulWidget {
const ImageMenu({ const ImageMenu({super.key, required this.node, required this.state});
super.key,
required this.node,
required this.state,
});
final Node node; final Node node;
final CustomImageBlockComponentState state; final CustomImageBlockComponentState state;
@ -30,7 +31,7 @@ class ImageMenu extends StatefulWidget {
} }
class _ImageMenuState extends State<ImageMenu> { class _ImageMenuState extends State<ImageMenu> {
late final String? url = widget.node.attributes[ImageBlockKeys.url]; late final String? url = widget.node.attributes[CustomImageBlockKeys.url];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -50,6 +51,12 @@ class _ImageMenuState extends State<ImageMenu> {
), ),
child: Row( child: Row(
children: [ children: [
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(),
iconData: FlowySvgs.full_view_s,
onTap: openFullScreen,
),
const HSpace(4), const HSpace(4),
// disable the copy link button if the image is hosted on appflowy cloud // disable the copy link button if the image is hosted on appflowy cloud
// because the url needs the verification token to be accessible // because the url needs the verification token to be accessible
@ -61,10 +68,7 @@ class _ImageMenuState extends State<ImageMenu> {
), ),
const HSpace(4), const HSpace(4),
], ],
_ImageAlignButton( _ImageAlignButton(node: widget.node, state: widget.state),
node: widget.node,
state: widget.state,
),
const _Divider(), const _Divider(),
MenuBlockButton( MenuBlockButton(
tooltip: LocaleKeys.button_delete.tr(), tooltip: LocaleKeys.button_delete.tr(),
@ -95,13 +99,34 @@ class _ImageMenuState extends State<ImageMenu> {
transaction.afterSelection = null; transaction.afterSelection = null;
await editorState.apply(transaction); await editorState.apply(transaction);
} }
void openFullScreen() {
showDialog(
context: context,
builder: (_) => InteractiveImageViewer(
userProfile: context.read<DocumentBloc>().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 { class _ImageAlignButton extends StatefulWidget {
const _ImageAlignButton({ const _ImageAlignButton({required this.node, required this.state});
required this.node,
required this.state,
});
final Node node; final Node node;
final CustomImageBlockComponentState state; final CustomImageBlockComponentState state;
@ -110,30 +135,28 @@ class _ImageAlignButton extends StatefulWidget {
State<_ImageAlignButton> createState() => _ImageAlignButtonState(); State<_ImageAlignButton> createState() => _ImageAlignButtonState();
} }
const interceptorKey = 'image-align'; const _interceptorKey = 'image-align';
class _ImageAlignButtonState extends State<_ImageAlignButton> { class _ImageAlignButtonState extends State<_ImageAlignButton> {
final gestureInterceptor = SelectionGestureInterceptor( final gestureInterceptor = SelectionGestureInterceptor(
key: interceptorKey, key: _interceptorKey,
canTap: (details) => false, canTap: (details) => false,
); );
String get align => String get align =>
widget.node.attributes[ImageBlockKeys.align] ?? centerAlignmentKey; widget.node.attributes[CustomImageBlockKeys.align] ?? centerAlignmentKey;
final popoverController = PopoverController(); final popoverController = PopoverController();
late final EditorState editorState; late final EditorState editorState;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
editorState = context.read<EditorState>(); editorState = context.read<EditorState>();
} }
@override @override
void dispose() { void dispose() {
allowMenuClose(); allowMenuClose();
super.dispose(); super.dispose();
} }
@ -153,9 +176,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
), ),
popupBuilder: (_) { popupBuilder: (_) {
preventMenuClose(); preventMenuClose();
return _AlignButtons( return _AlignButtons(onAlignChanged: onAlignChanged);
onAlignChanged: onAlignChanged,
);
}, },
), ),
); );
@ -165,9 +186,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
popoverController.close(); popoverController.close();
final transaction = editorState.transaction; final transaction = editorState.transaction;
transaction.updateNode(widget.node, { transaction.updateNode(widget.node, {CustomImageBlockKeys.align: align});
ImageBlockKeys.align: align,
});
editorState.apply(transaction); editorState.apply(transaction);
allowMenuClose(); allowMenuClose();
@ -183,7 +202,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
void allowMenuClose() { void allowMenuClose() {
widget.state.alwaysShowMenu = false; widget.state.alwaysShowMenu = false;
editorState.service.selectionService.unregisterGestureInterceptor( editorState.service.selectionService.unregisterGestureInterceptor(
interceptorKey, _interceptorKey,
); );
} }
@ -201,9 +220,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
} }
class _AlignButtons extends StatelessWidget { class _AlignButtons extends StatelessWidget {
const _AlignButtons({ const _AlignButtons({required this.onAlignChanged});
required this.onAlignChanged,
});
final Function(String align) onAlignChanged; final Function(String align) onAlignChanged;
@ -246,10 +263,7 @@ class _Divider extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Container( child: Container(width: 1, color: Colors.grey),
width: 1,
color: Colors.grey,
),
); );
} }
} }

View File

@ -1,14 +1,13 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
class UnSupportImageWidget extends StatelessWidget { class UnsupportedImageWidget extends StatelessWidget {
const UnSupportImageWidget({ const UnsupportedImageWidget({super.key});
super.key,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -18,9 +17,7 @@ class UnSupportImageWidget extends StatelessWidget {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: FlowyHover( child: FlowyHover(
style: HoverStyle( style: HoverStyle(borderRadius: BorderRadius.circular(4)),
borderRadius: BorderRadius.circular(4),
),
child: SizedBox( child: SizedBox(
height: 52, height: 52,
child: Row( child: Row(
@ -31,9 +28,7 @@ class UnSupportImageWidget extends StatelessWidget {
size: Size.square(24), size: Size.square(24),
), ),
const HSpace(10), const HSpace(10),
FlowyText( FlowyText(LocaleKeys.document_imageBlock_unableToLoadImage.tr()),
LocaleKeys.document_imageBlock_unableToLoadImage.tr(),
),
], ],
), ),
), ),

View File

@ -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<ImagePickerPage> createState() => _ImagePickerPageState();
}
class _ImagePickerPageState extends State<ImagePickerPage> {
@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: (_) {},
),
),
);
}
}

View File

@ -1,13 +1,40 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart';
import 'package:flutter/material.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 { class MobileImagePickerScreen extends StatelessWidget {
const MobileImagePickerScreen({super.key}); const MobileImagePickerScreen({super.key});
static const routeName = '/image_picker'; static const routeName = '/image_picker';
@override
Widget build(BuildContext context) => const ImagePickerPage();
}
class ImagePickerPage extends StatelessWidget {
const ImagePickerPage({super.key});
@override @override
Widget build(BuildContext context) { 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: (_) {},
),
),
);
} }
} }

View File

@ -1,24 +1,29 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/application/prelude.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/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/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/startup/startup.dart';
import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log, UploadImageMenu; import 'package:appflowy_editor/appflowy_editor.dart' hide Log, UploadImageMenu;
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
@ -26,10 +31,7 @@ import 'package:path/path.dart' as p;
import 'package:string_validator/string_validator.dart'; import 'package:string_validator/string_validator.dart';
class ImagePlaceholder extends StatefulWidget { class ImagePlaceholder extends StatefulWidget {
const ImagePlaceholder({ const ImagePlaceholder({super.key, required this.node});
super.key,
required this.node,
});
final Node node; final Node node;
@ -45,12 +47,20 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
bool showLoading = false; bool showLoading = false;
String? errorMessage; String? errorMessage;
bool isDraggingFiles = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Widget child = DecoratedBox( final Widget child = DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: isDraggingFiles
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
), ),
child: FlowyHover( child: FlowyHover(
style: HoverStyle( style: HoverStyle(
@ -85,18 +95,23 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
clickHandler: PopoverClickHandler.gestureDetector, clickHandler: PopoverClickHandler.gestureDetector,
popupBuilder: (context) { popupBuilder: (context) {
return UploadImageMenu( return UploadImageMenu(
allowMultipleImages: true,
limitMaximumImageSize: !_isLocalMode(), limitMaximumImageSize: !_isLocalMode(),
supportTypes: const [ supportTypes: const [
UploadImageType.local, UploadImageType.local,
UploadImageType.url, UploadImageType.url,
UploadImageType.unsplash, UploadImageType.unsplash,
// UploadImageType.openAI,
UploadImageType.stabilityAI, UploadImageType.stabilityAI,
], ],
onSelectedLocalImage: (path) { onSelectedLocalImages: (paths) {
controller.close(); controller.close();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
await insertLocalImage(path); final List<String> items = List.from(
paths.where((url) => url != null && url.isNotEmpty),
);
if (items.isNotEmpty) {
await insertMultipleLocalImages(items);
}
}); });
}, },
onSelectedAIImage: (url) { onSelectedAIImage: (url) {
@ -113,7 +128,27 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
}, },
); );
}, },
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 { } else {
return MobileBlockActionButtons( return MobileBlockActionButtons(
@ -133,8 +168,11 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
List<Widget> _buildTrailing(BuildContext context) { List<Widget> _buildTrailing(BuildContext context) {
if (errorMessage != null) { if (errorMessage != null) {
return [ return [
FlowyText( Flexible(
'${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}', child: FlowyText(
'${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}',
maxLines: 3,
),
), ),
]; ];
} else if (showLoading) { } else if (showLoading) {
@ -147,8 +185,14 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
]; ];
} else { } else {
return [ return [
FlowyText( Flexible(
LocaleKeys.document_plugins_image_addAnImage.tr(), 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<ImagePlaceholder> {
UploadImageType.url, UploadImageType.url,
UploadImageType.unsplash, UploadImageType.unsplash,
], ],
onSelectedLocalImage: (path) async { onSelectedLocalImages: (paths) async {
context.pop(); context.pop();
await insertLocalImage(path);
final List<String> items = List.from(
paths.where((url) => url != null && url.isNotEmpty),
);
await insertMultipleLocalImages(items);
}, },
onSelectedAIImage: (url) async { onSelectedAIImage: (url) async {
context.pop(); context.pop();
@ -198,77 +247,102 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
} }
} }
Future<void> insertLocalImage(String? url) async { Future<void> insertMultipleLocalImages(List<String> urls) async {
controller.close(); controller.close();
if (url == null || url.isEmpty) { setState(() {
return; showLoading = true;
} errorMessage = null;
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<DocumentBloc>().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(),
}); });
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<DocumentBloc>().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<void> insertAIImage(String url) async { Future<void> insertAIImage(String url) async {
if (url.isEmpty || !isURL(url)) { if (url.isEmpty || !isURL(url)) {
// show error // show error
showSnackBarMessage( return showSnackBarMessage(
context, context,
LocaleKeys.document_imageBlock_error_invalidImage.tr(), LocaleKeys.document_imageBlock_error_invalidImage.tr(),
); );
return;
} }
final path = await getIt<ApplicationDataStorage>().getPath(); final path = await getIt<ApplicationDataStorage>().getPath();
final imagePath = p.join( final imagePath = p.join(path, 'images');
path,
'images',
);
try { try {
// create the directory if not exists // create the directory if not exists
final directory = Directory(imagePath); final directory = Directory(imagePath);
@ -283,7 +357,7 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
final response = await get(uri); final response = await get(uri);
await File(copyToPath).writeAsBytes(response.bodyBytes); await File(copyToPath).writeAsBytes(response.bodyBytes);
await insertLocalImage(copyToPath); await insertMultipleLocalImages([copyToPath]);
await File(copyToPath).delete(); await File(copyToPath).delete();
} catch (e) { } catch (e) {
Log.error('cannot save image file', e); Log.error('cannot save image file', e);
@ -293,16 +367,16 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
Future<void> insertNetworkImage(String url) async { Future<void> insertNetworkImage(String url) async {
if (url.isEmpty || !isURL(url)) { if (url.isEmpty || !isURL(url)) {
// show error // show error
showSnackBarMessage( return showSnackBarMessage(
context, context,
LocaleKeys.document_imageBlock_error_invalidImage.tr(), LocaleKeys.document_imageBlock_error_invalidImage.tr(),
); );
return;
} }
final transaction = editorState.transaction; final transaction = editorState.transaction;
transaction.updateNode(widget.node, { transaction.updateNode(widget.node, {
ImageBlockKeys.url: url, CustomImageBlockKeys.url: url,
CustomImageBlockKeys.imageType: CustomImageType.external.toIntValue(),
}); });
await editorState.apply(transaction); await editorState.apply(transaction);
} }

View File

@ -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: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( final customImageMenuItem = SelectionMenuItem(
getName: () => AppFlowyEditorL10n.current.image, getName: () => AppFlowyEditorL10n.current.image,
icon: (editorState, isSelected, style) => SelectionMenuIconWidget( icon: (_, isSelected, style) => SelectionMenuIconWidget(
name: 'image', name: 'image',
isSelected: isSelected, isSelected: isSelected,
style: style, style: style,
), ),
keywords: ['image', 'picture', 'img', 'photo'], 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 // use the key to retrieve the state of the image block to show the popover automatically
final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>(); final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>();
await editorState.insertEmptyImageBlock(imagePlaceholderKey); await editorState.insertEmptyImageBlock(imagePlaceholderKey);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) { WidgetsBinding.instance.addPostFrameCallback((_) {
imagePlaceholderKey.currentState?.controller.show(); 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<ImagePlaceholderState>();
await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey);
WidgetsBinding.instance.addPostFrameCallback(
(_) => imagePlaceholderKey.currentState?.controller.show(),
);
},
);
extension InsertImage on EditorState { extension InsertImage on EditorState {
Future<void> insertEmptyImageBlock(GlobalKey key) async { Future<void> insertEmptyImageBlock(GlobalKey key) async {
final selection = this.selection; final selection = this.selection;
@ -33,31 +60,49 @@ extension InsertImage on EditorState {
return; return;
} }
final emptyImage = imageNode(url: '') final emptyImage = imageNode(url: '')
..extraInfos = { ..extraInfos = {kImagePlaceholderKey: key};
kImagePlaceholderKey: key,
};
final transaction = this.transaction; final transaction = this.transaction;
// if the current node is empty paragraph, replace it with image node // if the current node is empty paragraph, replace it with image node
if (node.type == ParagraphBlockKeys.type && if (node.type == ParagraphBlockKeys.type &&
(node.delta?.isEmpty ?? false)) { (node.delta?.isEmpty ?? false)) {
transaction transaction
..insertNode( ..insertNode(node.path, emptyImage)
node.path,
emptyImage,
)
..deleteNode(node); ..deleteNode(node);
} else { } else {
transaction.insertNode( transaction.insertNode(node.path.next, emptyImage);
node.path.next,
emptyImage,
);
} }
transaction.afterSelection = Selection.collapsed( transaction.afterSelection =
Position( Selection.collapsed(Position(path: node.path.next));
path: node.path.next, transaction.selectionExtraInfo = {};
),
); return apply(transaction);
}
Future<void> 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 = {}; transaction.selectionExtraInfo = {};
return apply(transaction); return apply(transaction);

View File

@ -1,15 +1,20 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/prelude.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/shared/custom_image_cache_manager.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/file_extension.dart'; import 'package:appflowy/util/file_extension.dart';
import 'package:appflowy/workspace/application/settings/application_data_storage.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/dispatch/error.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra/uuid.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
Future<String?> saveImageToLocalStorage(String localImagePath) async { Future<String?> saveImageToLocalStorage(String localImagePath) async {
@ -73,3 +78,49 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage(
}, },
); );
} }
Future<List<ImageBlockData>> extractAndUploadImages(
BuildContext context,
List<String?> urls,
bool isLocalMode,
) async {
final List<ImageBlockData> 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<DocumentBloc>().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;
}

View File

@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.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/image/image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
final imageMobileToolbarItem = MobileToolbarItem.action( final imageMobileToolbarItem = MobileToolbarItem.action(
itemIconBuilder: (_, __, ___) => const FlowySvg(FlowySvgs.m_toolbar_imae_lg), itemIconBuilder: (_, __, ___) => const FlowySvg(FlowySvgs.m_toolbar_imae_lg),
@ -10,7 +11,7 @@ final imageMobileToolbarItem = MobileToolbarItem.action(
final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>(); final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>();
await editorState.insertEmptyImageBlock(imagePlaceholderKey); await editorState.insertEmptyImageBlock(imagePlaceholderKey);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) { WidgetsBinding.instance.addPostFrameCallback((_) {
imagePlaceholderKey.currentState?.showUploadImageMenu(); imagePlaceholderKey.currentState?.showUploadImageMenu();
}); });
}, },

View File

@ -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<int> 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<MultiImageBlockComponent> createState() =>
MultiImageBlockComponentState();
}
class MultiImageBlockComponentState extends State<MultiImageBlockComponent>
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<EditorState>(context, listen: false);
final showActionsNotifier = ValueNotifier<bool>(false);
ValueNotifier<int> 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<DocumentBloc>().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<bool>(
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<Rect> 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<dynamic> json) {
final images = json
.map((e) => ImageBlockData.fromJson(e as Map<String, dynamic>))
.toList();
return MultiImageData(images: images);
}
MultiImageData({required this.images});
final List<ImageBlockData> images;
List<dynamic> 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();
}
}
}

View File

@ -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<int> indexNotifier;
final bool isLocalMode;
final VoidCallback onImageDeleted;
@override
State<MultiImageMenu> createState() => _MultiImageMenuState();
}
class _MultiImageMenuState extends State<MultiImageMenu> {
final gestureInterceptor = SelectionGestureInterceptor(
key: _interceptorKey,
canTap: (details) => false,
);
final PopoverController controller = PopoverController();
late List<ImageBlockData> images;
late final EditorState editorState;
@override
void initState() {
super.initState();
editorState = context.read<EditorState>();
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<void> deleteImage() async {
final node = widget.node;
final editorState = context.read<EditorState>();
final transaction = editorState.transaction;
transaction.deleteNode(node);
transaction.afterSelection = null;
await editorState.apply(transaction);
}
void openFullScreen() {
showDialog(
context: context,
builder: (_) => InteractiveImageViewer(
userProfile: context.read<DocumentBloc>().state.userProfilePB,
imageProvider: AFBlockImageProvider(
images: images,
initialIndex: widget.indexNotifier.value,
onDeleteImage: (index) async {
final transaction = editorState.transaction;
final newImages = List<ImageBlockData>.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<void> insertLocalImages(List<String?> 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<void> 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<ApplicationDataStorage>().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<void> 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),
);
}
}

View File

@ -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<MultiImagePlaceholder> createState() => MultiImagePlaceholderState();
}
class MultiImagePlaceholderState extends State<MultiImagePlaceholder> {
final controller = PopoverController();
final documentService = DocumentService();
late final editorState = context.read<EditorState>();
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<void> insertLocalImages(List<String?> 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<void> insertAIImage(String url) async {
if (url.isEmpty || !isURL(url)) {
// show error
return showSnackBarMessage(
context,
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
);
}
final path = await getIt<ApplicationDataStorage>().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<void> 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<DocumentBloc>().isLocalMode;
}
}

View File

@ -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<ImageBlockData> images;
final ValueNotifier<int> 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<ImageBrowserLayout> createState() => _ImageBrowserLayoutState();
}
class _ImageBrowserLayoutState extends State<ImageBrowserLayout> {
UserProfilePB? _userProfile;
bool isDraggingFiles = false;
@override
void initState() {
super.initState();
_userProfile = context.read<DocumentBloc>().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<void> insertLocalImages(List<String?> urls) async {
if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) {
return;
}
final isLocalMode = context.read<DocumentBloc>().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<ImageBlockData> images;
final int index;
final int selectedIndex;
final VoidCallback onDeleted;
final UserProfilePB? userProfile;
@override
State<ThumbnailItem> createState() => _ThumbnailItemState();
}
class _ThumbnailItemState extends State<ThumbnailItem> {
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,
);
}
}

View File

@ -1,14 +1,15 @@
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/application/prelude.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/shared/appflowy_network_image.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.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_bloc/flutter_bloc.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:string_validator/string_validator.dart'; import 'package:string_validator/string_validator.dart';
@ -23,6 +24,7 @@ class ResizableImage extends StatefulWidget {
required this.width, required this.width,
required this.src, required this.src,
this.height, this.height,
this.onDoubleTap,
}); });
final String src; final String src;
@ -31,6 +33,7 @@ class ResizableImage extends StatefulWidget {
final double? height; final double? height;
final Alignment alignment; final Alignment alignment;
final bool editable; final bool editable;
final VoidCallback? onDoubleTap;
final void Function(double width) onResize; final void Function(double width) onResize;
@ -41,26 +44,23 @@ class ResizableImage extends StatefulWidget {
const _kImageBlockComponentMinWidth = 30.0; const _kImageBlockComponentMinWidth = 30.0;
class _ResizableImageState extends State<ResizableImage> { class _ResizableImageState extends State<ResizableImage> {
late double imageWidth; final documentService = DocumentService();
double initialOffset = 0; double initialOffset = 0;
double moveDistance = 0; double moveDistance = 0;
Widget? _cacheImage; Widget? _cacheImage;
late double imageWidth;
@visibleForTesting @visibleForTesting
bool onFocus = false; bool onFocus = false;
final documentService = DocumentService();
UserProfilePB? _userProfilePB; UserProfilePB? _userProfilePB;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
imageWidth = widget.width; imageWidth = widget.width;
_userProfilePB = context.read<DocumentBloc>().state.userProfilePB; _userProfilePB = context.read<DocumentBloc>().state.userProfilePB;
} }
@ -72,13 +72,12 @@ class _ResizableImageState extends State<ResizableImage> {
width: max(_kImageBlockComponentMinWidth, imageWidth - moveDistance), width: max(_kImageBlockComponentMinWidth, imageWidth - moveDistance),
height: widget.height, height: widget.height,
child: MouseRegion( child: MouseRegion(
onEnter: (event) => setState(() { onEnter: (_) => setState(() => onFocus = true),
onFocus = true; onExit: (_) => setState(() => onFocus = false),
}), child: GestureDetector(
onExit: (event) => setState(() { onDoubleTap: widget.onDoubleTap,
onFocus = false; child: _buildResizableImage(context),
}), ),
child: _buildResizableImage(context),
), ),
), ),
); );
@ -97,12 +96,11 @@ class _ResizableImageState extends State<ResizableImage> {
url: widget.src, url: widget.src,
width: imageWidth - moveDistance, width: imageWidth - moveDistance,
userProfilePB: _userProfilePB, userProfilePB: _userProfilePB,
errorWidgetBuilder: (context, url, error) => _ImageLoadFailedWidget( progressIndicatorBuilder: (context, _, __) => _buildLoading(context),
errorWidgetBuilder: (_, __, error) => _ImageLoadFailedWidget(
width: imageWidth, width: imageWidth,
error: error, error: error,
), ),
progressIndicatorBuilder: (context, url, progress) =>
_buildLoading(context),
); );
child = _cacheImage!; child = _cacheImage!;
@ -121,11 +119,7 @@ class _ResizableImageState extends State<ResizableImage> {
left: 5, left: 5,
bottom: 0, bottom: 0,
width: 5, width: 5,
onUpdate: (distance) { onUpdate: (distance) => setState(() => moveDistance = distance),
setState(() {
moveDistance = distance;
});
},
), ),
_buildEdgeGesture( _buildEdgeGesture(
context, context,
@ -133,11 +127,7 @@ class _ResizableImageState extends State<ResizableImage> {
right: 5, right: 5,
bottom: 0, bottom: 0,
width: 5, width: 5,
onUpdate: (distance) { onUpdate: (distance) => setState(() => moveDistance = -distance),
setState(() {
moveDistance = -distance;
});
},
), ),
], ],
], ],
@ -154,9 +144,7 @@ class _ResizableImageState extends State<ResizableImage> {
size: const Size(18, 18), size: const Size(18, 18),
child: const CircularProgressIndicator(), child: const CircularProgressIndicator(),
), ),
SizedBox.fromSize( SizedBox.fromSize(size: const Size(10, 10)),
size: const Size(10, 10),
),
Text(AppFlowyEditorL10n.current.loading), Text(AppFlowyEditorL10n.current.loading),
], ],
), ),
@ -184,7 +172,7 @@ class _ResizableImageState extends State<ResizableImage> {
}, },
onHorizontalDragUpdate: (details) { onHorizontalDragUpdate: (details) {
if (onUpdate != null) { if (onUpdate != null) {
var offset = details.globalPosition.dx - initialOffset; double offset = details.globalPosition.dx - initialOffset;
if (widget.alignment == Alignment.center) { if (widget.alignment == Alignment.center) {
offset *= 2.0; offset *= 2.0;
} }
@ -222,10 +210,7 @@ class _ResizableImageState extends State<ResizableImage> {
} }
class _ImageLoadFailedWidget extends StatelessWidget { class _ImageLoadFailedWidget extends StatelessWidget {
const _ImageLoadFailedWidget({ const _ImageLoadFailedWidget({required this.width, required this.error});
required this.width,
required this.error,
});
final double width; final double width;
final Object error; final Object error;
@ -240,9 +225,7 @@ class _ImageLoadFailedWidget extends StatelessWidget {
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4.0)), borderRadius: const BorderRadius.all(Radius.circular(4.0)),
border: Border.all( border: Border.all(color: Colors.grey.withOpacity(0.6)),
color: Colors.grey.withOpacity(0.6),
),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -251,9 +234,7 @@ class _ImageLoadFailedWidget extends StatelessWidget {
FlowySvgs.broken_image_xl, FlowySvgs.broken_image_xl,
size: Size.square(48), size: Size.square(48),
), ),
FlowyText( FlowyText(AppFlowyEditorL10n.current.imageLoadFailed),
AppFlowyEditorL10n.current.imageLoadFailed,
),
const VSpace(6), const VSpace(6),
if (error != null) if (error != null)
FlowyText( FlowyText(

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:unsplash_client/unsplash_client.dart'; import 'package:unsplash_client/unsplash_client.dart';
const _accessKeyA = 'YyD-LbW5bVolHWZBq5fWRM_'; const _accessKeyA = 'YyD-LbW5bVolHWZBq5fWRM_';
@ -48,7 +49,6 @@ class _UnsplashImageWidgetState extends State<UnsplashImageWidget> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
randomPhotos = unsplash.photos randomPhotos = unsplash.photos
.random(count: 18, orientation: PhotoOrientation.landscape) .random(count: 18, orientation: PhotoOrientation.landscape)
.goAndGet(); .goAndGet();
@ -57,7 +57,6 @@ class _UnsplashImageWidgetState extends State<UnsplashImageWidget> {
@override @override
void dispose() { void dispose() {
unsplash.close(); unsplash.close();
super.dispose(); super.dispose();
} }
@ -132,18 +131,16 @@ class _UnsplashImagesState extends State<_UnsplashImages> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const mainAxisSpacing = 16.0;
final crossAxisCount = switch (widget.type) { final crossAxisCount = switch (widget.type) {
UnsplashImageType.halfScreen => 3, UnsplashImageType.halfScreen => 3,
UnsplashImageType.fullScreen => 2, UnsplashImageType.fullScreen => 2,
}; };
final mainAxisSpacing = switch (widget.type) {
UnsplashImageType.halfScreen => 16.0,
UnsplashImageType.fullScreen => 16.0,
};
final crossAxisSpacing = switch (widget.type) { final crossAxisSpacing = switch (widget.type) {
UnsplashImageType.halfScreen => 10.0, UnsplashImageType.halfScreen => 10.0,
UnsplashImageType.fullScreen => 16.0, UnsplashImageType.fullScreen => 16.0,
}; };
return GridView.count( return GridView.count(
crossAxisCount: crossAxisCount, crossAxisCount: crossAxisCount,
mainAxisSpacing: mainAxisSpacing, mainAxisSpacing: mainAxisSpacing,
@ -155,15 +152,11 @@ class _UnsplashImagesState extends State<_UnsplashImages> {
return _UnsplashImage( return _UnsplashImage(
type: widget.type, type: widget.type,
photo: photo, photo: photo,
onTap: () {
widget.onSelectUnsplashImage(
photo.urls.regular.toString(),
);
setState(() {
_selectedPhotoIndex = index;
});
},
isSelected: index == _selectedPhotoIndex, isSelected: index == _selectedPhotoIndex,
onTap: () {
widget.onSelectUnsplashImage(photo.urls.regular.toString());
setState(() => _selectedPhotoIndex = index);
},
); );
}).toList(), }).toList(),
); );
@ -219,10 +212,7 @@ class _UnsplashImage extends StatelessWidget {
), ),
), ),
const HSpace(2.0), const HSpace(2.0),
FlowyText( FlowyText('by ${photo.name}', fontSize: 10.0),
'by ${photo.name}',
fontSize: 10.0,
),
], ],
); );
} }
@ -233,14 +223,12 @@ class _UnsplashImage extends StatelessWidget {
child: Stack( child: Stack(
children: [ children: [
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (_, constraints) => Image.network(
return Image.network( photo.urls.thumb.toString(),
photo.urls.thumb.toString(), fit: BoxFit.cover,
fit: BoxFit.cover, width: constraints.maxWidth,
width: constraints.maxWidth, height: constraints.maxHeight,
height: constraints.maxHeight, ),
);
},
), ),
Positioned( Positioned(
bottom: 9, bottom: 9,
@ -261,13 +249,9 @@ extension on Photo {
String get name { String get name {
if (user.username.isNotEmpty) { if (user.username.isNotEmpty) {
return user.username; return user.username;
} } else if (user.name.isNotEmpty) {
if (user.name.isNotEmpty) {
return user.name; return user.name;
} } else if (user.email?.isNotEmpty == true) {
if (user.email?.isNotEmpty == true) {
return user.email!; return user.email!;
} }

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.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/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/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/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorOption; 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/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'widgets/embed_image_url_widget.dart';
enum UploadImageType { enum UploadImageType {
local, local,
@ -42,20 +44,22 @@ enum UploadImageType {
class UploadImageMenu extends StatefulWidget { class UploadImageMenu extends StatefulWidget {
const UploadImageMenu({ const UploadImageMenu({
super.key, super.key,
required this.onSelectedLocalImage, required this.onSelectedLocalImages,
required this.onSelectedAIImage, required this.onSelectedAIImage,
required this.onSelectedNetworkImage, required this.onSelectedNetworkImage,
this.onSelectedColor, this.onSelectedColor,
this.supportTypes = UploadImageType.values, this.supportTypes = UploadImageType.values,
this.limitMaximumImageSize = false, this.limitMaximumImageSize = false,
this.allowMultipleImages = false,
}); });
final void Function(String? path) onSelectedLocalImage; final void Function(List<String?>) onSelectedLocalImages;
final void Function(String url) onSelectedAIImage; final void Function(String url) onSelectedAIImage;
final void Function(String url) onSelectedNetworkImage; final void Function(String url) onSelectedNetworkImage;
final void Function(String color)? onSelectedColor; final void Function(String color)? onSelectedColor;
final List<UploadImageType> supportTypes; final List<UploadImageType> supportTypes;
final bool limitMaximumImageSize; final bool limitMaximumImageSize;
final bool allowMultipleImages;
@override @override
State<UploadImageMenu> createState() => _UploadImageMenuState(); State<UploadImageMenu> createState() => _UploadImageMenuState();
@ -133,9 +137,7 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
}, },
).toList(), ).toList(),
), ),
const Divider( const Divider(height: 2),
height: 2,
),
_buildTab(), _buildTab(),
], ],
), ),
@ -155,7 +157,8 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
child: Column( child: Column(
children: [ children: [
UploadImageFileWidget( UploadImageFileWidget(
onPickFile: widget.onSelectedLocalImage, allowMultipleImages: widget.allowMultipleImages,
onPickFiles: widget.onSelectedLocalImages,
), ),
if (widget.limitMaximumImageSize) ...[ if (widget.limitMaximumImageSize) ...[
const VSpace(6.0), const VSpace(6.0),
@ -185,30 +188,13 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
), ),
), ),
); );
// 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: case UploadImageType.stabilityAI:
return supportStabilityAI return supportStabilityAI
? Expanded( ? Expanded(
child: Container( child: Container(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: StabilityAIImageWidget( child: StabilityAIImageWidget(
onSelectImage: widget.onSelectedLocalImage, onSelectImage: (url) => widget.onSelectedLocalImages([url]),
), ),
), ),
) )

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/permission/permission_checker.dart'; import 'package:appflowy/shared/permission/permission_checker.dart';
import 'package:appflowy/startup/startup.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/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
class UploadImageFileWidget extends StatelessWidget { class UploadImageFileWidget extends StatelessWidget {
const UploadImageFileWidget({ const UploadImageFileWidget({
super.key, super.key,
required this.onPickFile, required this.onPickFiles,
this.allowedExtensions = const ['jpg', 'png', 'jpeg'], this.allowedExtensions = const ['jpg', 'png', 'jpeg'],
this.allowMultipleImages = false,
}); });
final void Function(String? path) onPickFile; final void Function(List<String?>) onPickFiles;
final List<String> allowedExtensions; final List<String> allowedExtensions;
final bool allowMultipleImages;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -35,9 +38,7 @@ class UploadImageFileWidget extends StatelessWidget {
); );
if (PlatformExtension.isDesktopOrWeb) { if (PlatformExtension.isDesktopOrWeb) {
return FlowyHover( return FlowyHover(child: child);
child: child,
);
} }
return child; return child;
@ -50,8 +51,9 @@ class UploadImageFileWidget extends StatelessWidget {
dialogTitle: '', dialogTitle: '',
type: FileType.custom, type: FileType.custom,
allowedExtensions: allowedExtensions, allowedExtensions: allowedExtensions,
allowMultiple: allowMultipleImages,
); );
onPickFile(result?.files.firstOrNull?.path); onPickFiles(result?.files.map((f) => f.path).toList() ?? const []);
} else { } else {
final photoPermission = final photoPermission =
await PermissionChecker.checkPhotoPermission(context); await PermissionChecker.checkPhotoPermission(context);
@ -60,8 +62,8 @@ class UploadImageFileWidget extends StatelessWidget {
return; return;
} }
// on mobile, the users can pick a image file from camera or image library // on mobile, the users can pick a image file from camera or image library
final result = await ImagePicker().pickImage(source: ImageSource.gallery); final result = await ImagePicker().pickMultiImage();
onPickFile(result?.path); onPickFiles(result.map((f) => f.path).toList());
} }
} }
} }

View File

@ -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/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.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:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../image/custom_image_block_component/custom_image_block_component.dart';
class LinkPreviewMenu extends StatefulWidget { class LinkPreviewMenu extends StatefulWidget {
const LinkPreviewMenu({ const LinkPreviewMenu({
super.key, super.key,
@ -72,7 +75,7 @@ class _LinkPreviewMenuState extends State<LinkPreviewMenu> {
} }
void copyImageLink() { void copyImageLink() {
final url = widget.node.attributes[ImageBlockKeys.url]; final url = widget.node.attributes[CustomImageBlockKeys.url];
if (url != null) { if (url != null) {
Clipboard.setData(ClipboardData(text: url)); Clipboard.setData(ClipboardData(text: url));
showSnackBarMessage( showSnackBarMessage(

View File

@ -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/base/type_option_menu_item.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.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/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_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_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'; 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<MultiImagePlaceholderState>();
await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey);
});
},
),
// date // date
TypeOptionMenuItemValue( TypeOptionMenuItemValue(

View File

@ -1,5 +1,7 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import '../image/custom_image_block_component/custom_image_block_component.dart';
class CustomImageNodeParser extends NodeParser { class CustomImageNodeParser extends NodeParser {
const CustomImageNodeParser(); const CustomImageNodeParser();
@ -9,7 +11,7 @@ class CustomImageNodeParser extends NodeParser {
@override @override
String transform(Node node, DocumentMarkdownEncoder? encoder) { String transform(Node node, DocumentMarkdownEncoder? encoder) {
assert(node.children.isEmpty); assert(node.children.isEmpty);
final url = node.attributes[ImageBlockKeys.url]; final url = node.attributes[CustomImageBlockKeys.url];
assert(url != null); assert(url != null);
return '![]($url)\n'; return '![]($url)\n';
} }

View File

@ -19,7 +19,8 @@ export 'font/customize_font_toolbar_item.dart';
export 'header/cover_editor_bloc.dart'; export 'header/cover_editor_bloc.dart';
export 'header/custom_cover_picker.dart'; export 'header/custom_cover_picker.dart';
export 'header/document_header_node_widget.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/image_selection_menu.dart';
export 'image/mobile_image_toolbar_item.dart'; export 'image/mobile_image_toolbar_item.dart';
export 'inline_math_equation/inline_math_equation.dart'; export 'inline_math_equation/inline_math_equation.dart';

View File

@ -13,6 +13,9 @@ const _imgUrlPattern =
r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm)(\?[^\s[",><]*)?'; r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm)(\?[^\s[",><]*)?';
final imgUrlRegex = RegExp(_imgUrlPattern); 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 /// This pattern allows for both HTTP and HTTPS Scheme
/// It allows for query parameters /// It allows for query parameters
/// It only allows the following video extensions: /// It only allows the following video extensions:

View File

@ -1,5 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
@ -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/hover.dart';
import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.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:flutter_bloc/flutter_bloc.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
@ -64,13 +65,13 @@ class SettingsManageDataView extends StatelessWidget {
label: LocaleKeys.settings_common_reset.tr(), label: LocaleKeys.settings_common_reset.tr(),
onPressed: () => showConfirmDialog( onPressed: () => showConfirmDialog(
context: context, context: context,
confirmLabel: LocaleKeys.button_confirm.tr(),
title: LocaleKeys title: LocaleKeys
.settings_manageDataPage_dataStorage_resetDialog_title .settings_manageDataPage_dataStorage_resetDialog_title
.tr(), .tr(),
description: LocaleKeys description: LocaleKeys
.settings_manageDataPage_dataStorage_resetDialog_description .settings_manageDataPage_dataStorage_resetDialog_description
.tr(), .tr(),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () async { onConfirm: () async {
final directory = final directory =
await appFlowyApplicationDataDirectory(); await appFlowyApplicationDataDirectory();

View File

@ -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<ImageBlockData> 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,
);
}
}

View File

@ -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<Widget> 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<void> _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(),
);
},
);
}
}

View File

@ -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<InteractiveImageViewer> createState() => _InteractiveImageViewerState();
}
class _InteractiveImageViewerState extends State<InteractiveImageViewer> {
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<DocumentBloc>().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();
}
}

View File

@ -836,10 +836,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: fluttertoast name: fluttertoast
sha256: dfdde255317af381bfc1c486ed968d5a43a2ded9c931e87cbecd88767d6a71c1 sha256: "7eae679e596a44fdf761853a706f74979f8dd3cd92cf4e23cae161fda091b847"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.2.4" version: "8.2.6"
freezed: freezed:
dependency: "direct dev" dependency: "direct dev"
description: description:

View File

@ -74,7 +74,7 @@ dependencies:
connectivity_plus: ^5.0.2 connectivity_plus: ^5.0.2
easy_localization: ^3.0.2 easy_localization: ^3.0.2
device_info_plus: ^10.1.0 device_info_plus: ^10.1.0
fluttertoast: ^8.2.2 fluttertoast: ^8.2.6
json_annotation: ^4.8.1 json_annotation: ^4.8.1
table_calendar: ^3.0.9 table_calendar: ^3.0.9
reorderables: ^0.6.0 reorderables: ^0.6.0

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M360-384h384L618-552l-90 120-66-88-102 136Zm-48 144q-29.7 0-50.85-21.15Q240-282.3 240-312v-480q0-29.7 21.15-50.85Q282.3-864 312-864h480q29.7 0 50.85 21.15Q864-821.7 864-792v480q0 29.7-21.15 50.85Q821.7-240 792-240H312Zm0-72h480v-480H312v480ZM168-96q-29.7 0-50.85-21.15Q96-138.3 96-168v-552h72v552h552v72H168Zm144-696v480-480Z"/></svg>

After

Width:  |  Height:  |  Size: 450 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="12" y="7.5" width="1" height="8" rx="0.5" transform="rotate(90 12 7.5)" fill="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 201 B

View File

@ -1482,11 +1482,25 @@
"depth": "Depth" "depth": "Depth"
}, },
"image": { "image": {
"copiedToPasteBoard": "The image link has been copied to the clipboard",
"addAnImage": "Add an image", "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" "errorCode": "Error code"
}, },
"photoGallery": {
"name": "Photo gallery",
"imageKeyword": "image",
"imageGalleryKeyword": "image gallery",
"photoKeyword": "photo",
"photoBrowserKeyword": "photo browser",
"galleryKeyword": "gallery",
"addImageTooltip": "Add image"
},
"math": { "math": {
"copiedToPasteBoard": "The math equation has been copied to the clipboard" "copiedToPasteBoard": "The math equation has been copied to the clipboard"
}, },
@ -1542,7 +1556,7 @@
"placeholder": "Untitled" "placeholder": "Untitled"
}, },
"imageBlock": { "imageBlock": {
"placeholder": "Click to add image", "placeholder": "Click to add image(s)",
"upload": { "upload": {
"label": "Upload", "label": "Upload",
"placeholder": "Click to upload image" "placeholder": "Click to upload image"
@ -1565,7 +1579,8 @@
"invalidImageSize": "Image size must be less than 5MB", "invalidImageSize": "Image size must be less than 5MB",
"invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG, GIF, SVG, WEBP",
"invalidImageUrl": "Invalid image URL", "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": { "embedLink": {
"label": "Embed link", "label": "Embed link",
@ -1583,7 +1598,22 @@
"unableToLoadImage": "Unable to load image", "unableToLoadImage": "Unable to load image",
"maximumImageSize": "Maximum supported upload image size is 10MB", "maximumImageSize": "Maximum supported upload image size is 10MB",
"uploadImageErrorImageSizeTooBig": "Image size must be less than 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": { "codeBlock": {
"language": { "language": {

View File

@ -5,13 +5,13 @@ run_task = { name = ["install_flutter_prerequests"] }
run_task = { name = ["install_tauri_prerequests"] } run_task = { name = ["install_tauri_prerequests"] }
[tasks.appflowy-flutter-dev-tools] [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] [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] [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] [tasks.check_visual_studio_installation.windows]
script = """ script = """
@ -101,13 +101,13 @@ rustup target add x86_64-unknown-linux-gnu
""" """
[tasks.install_tauri_prerequests] [tasks.install_tauri_prerequests]
dependencies=["install_targets", "install_web_protobuf"] dependencies = ["install_targets", "install_web_protobuf"]
[tasks.install_flutter_prerequests] [tasks.install_flutter_prerequests]
dependencies=["install_targets", "install_flutter_protobuf"] dependencies = ["install_targets", "install_flutter_protobuf"]
[tasks.install_flutter_prerequests.windows] [tasks.install_flutter_prerequests.windows]
dependencies=["install_targets", "install_windows_deps"] dependencies = ["install_targets", "install_windows_deps"]
[tasks.install_tools] [tasks.install_tools]
script = """ script = """
@ -148,7 +148,7 @@ script_runner = "@duckscript"
[tasks.enable_git_hook] [tasks.enable_git_hook]
dependencies=["download_gitlint"] dependencies = ["download_gitlint"]
script = """ script = """
git config core.hooksPath .githooks git config core.hooksPath .githooks
""" """