mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
82fffba45a
commit
23b6f94e82
37
.github/workflows/tauri2_ci.yaml
vendored
37
.github/workflows/tauri2_ci.yaml
vendored
@ -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
|
||||||
|
@ -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();
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -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()]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
@ -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';
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
@ -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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
@ -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: (_) {},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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(
|
||||||
|
@ -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!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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(
|
||||||
|
@ -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(
|
||||||
|
@ -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';
|
||||||
}
|
}
|
||||||
|
@ -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';
|
||||||
|
@ -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:
|
||||||
|
@ -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();
|
||||||
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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 |
3
frontend/resources/flowy_icons/16x/minus.svg
Normal file
3
frontend/resources/flowy_icons/16x/minus.svg
Normal 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 |
@ -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": {
|
||||||
|
@ -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
|
||||||
"""
|
"""
|
||||||
|
Loading…
Reference in New Issue
Block a user