Compare commits

...
23 Commits
Author SHA1 Message Date
Zack Fu Zi Xiang e739f56d17 Merge branch 'main' into update-client-api 2024-07-30 12:33:43 +08:00
Lucas.Xu 115ae27ab1 fix: missing system font display (#5825)
* fix: missing system font display

* fix: integration test

* fix: cloud integration test
2024-07-29 14:30:11 +08:00
Chen ☁️☁️ e908892b4a chore: update simplified Chinese translate (#5798) 2024-07-29 10:00:05 +08:00
hasanbeder 948d61a9e0 chore: update tr-TR.json (#5822)
chore: Update Turkish translations
2024-07-29 09:59:25 +08:00
Lucas.Xu b5c4786896 fix: 0.6.6 launch review issues (#5824)
* fix: only rebuild the sidebar header when needed

* fix: skip the null or empty awareness data

* fix: ai writer setState error

* chore: add arrow down icon when hovering on workspace button

* chore: update workspace button tooltip sytle

* chore: only show collapse all subpages button when then page is expanded

* chore: update pin color

* chore: fix integration test

* Revert "chore: fix integration test"

This reverts commit e02b237423.
2024-07-29 09:57:58 +08:00
Zack Fu Zi Xiang af1769164a Merge branch 'main' into update-client-api 2024-07-29 09:51:21 +08:00
Nathan.fooo d24f1c566a fix: chat bugs (#5823)
* chore: implement slash menu

* chore: show popup

* chore: single column

* chore: update appflowy editor

* chore: fix warns
2024-07-27 23:47:08 +08:00
Lucas.Xu ddf68b010d chore: improve UI design on mobile (#5816) 2024-07-27 21:05:51 +08:00
Zack Fu Zi Xiang d890c8b93e chore: test use dotenvy 2024-07-26 21:27:19 +08:00
Zack Fu Zi Xiang 5efdd6e8b9 chore: update to latest client api 2024-07-26 14:06:18 +08:00
Zack Fu Zi Xiang d1c867d089 chore: update client api 2024-07-26 13:46:06 +08:00
Nathan.fooo 22b108df70 chore: enable billing (#5818) 2024-07-26 12:03:29 +08:00
Nathan.fooo aff9a61919 chore: notify in release build (#5817) 2024-07-26 10:28:18 +08:00
Lucas.Xu 043cd3f3bb fix: chat messaage color doesn't update when switching theme (#5815) 2024-07-26 10:08:00 +08:00
Lucas.Xu 29fb4af40a chore: improve UI design on Desktop (#5792)
* fix: only show collapse page button when the children of the page is not emtpy

* chore: set minimum sidebar width to 268

* chore: replace space lock icon and pin & unpin icon

* chore: change divider color

* chore: update divider color

* chore: improve create space color

* feat: highlight delete button when hovering

* chore: update translations

* fix: icon align issue

* feat: highlight sidebar resizer when hovering

* feat: add border to popover

* feat: optimize scroll bar

* feat: improve scrollbar hover color

* feat: support creating a new page via cmd+n

* chore: improve scrollbar color

* feat: improve tooltip style

* chore: fix unit test

* chore: bump version 0.6.6
2024-07-26 09:49:13 +08:00
Nathan.fooo a2e211555e chore: chat with pdf ui (#5811)
* chore: chat with pdf ui

* chore: only enable local ai on macos

* chore: add todo

* chore: adjust UI

* chore: clippy
2024-07-26 07:58:54 +08:00
Mathias Mogensen d1af172fb7 feat: drop images into editor (#5813) 2024-07-26 00:08:55 +02:00
Mathias Mogensenandnathan 23b6f94e82 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>
2024-07-25 14:47:08 +02:00
Nathan.fooo 82fffba45a chore: document local ai (#5810)
* chore: document local ai

* chore: update ui

* chore: clippy
2024-07-25 19:41:16 +08:00
charles 0373088fb8 fix: defaultWindowSize is out of scope (#5795) (#5796) 2024-07-25 13:53:46 +08:00
Lucas.Xu 0da0f320b7 fix: the action menu is blocked by the navigation bar on Android (#5806) 2024-07-25 13:52:20 +08:00
Lucas.Xu 4abdcd9478 fix: integration test (reset to default location) (#5804)
* fix: integration test (reset to default location)

* fix: unit test (appflowy cloud anon user and then sign in)

* Revert "fix: unit test (appflowy cloud anon user and then sign in)"

This reverts commit 2635aa42f0.

* fix: unit test (appflowy cloud anon user and then sign in)
2024-07-25 12:02:18 +08:00
Nathan.fooo 1e54c2dc7c chore: only run appflowy cloud if it's not exist (#5799)
* chore: only run appflowy cloud if it's not exist

* chore: update

* chore: log error

* chore: fix sign out test

* chore: update test
2024-07-25 07:59:03 +08:00
199 changed files with 6778 additions and 1991 deletions

No files matched your search

+7 -4
View File
@@ -248,10 +248,13 @@ jobs:
env:
BACKEND_VERSION: 0.3.24-amd64
run: |
docker compose down -v --remove-orphans
docker compose pull
docker compose up -d
sleep 10
if [ "$(docker ps --filter name=appflowy-cloud -q)" == "" ]; then
docker compose pull
docker compose up -d
sleep 10
else
echo "Docker container 'appflowy-cloud' is already running."
fi
- name: Checkout source code
uses: actions/checkout@v4
+18
View File
@@ -479,6 +479,24 @@ jobs:
cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/af_build_cache:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/af_build_cache:buildcache,mode=max
notify-failure:
runs-on: ubuntu-latest
needs:
- build-for-macOS-x86_64
- build-for-windows
- build-for-linux
if: failure()
steps:
- uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: |
🔴🔴🔴Workflow ${{ github.workflow }} in repository ${{ github.repository }} was failed 🔴🔴🔴.
fields: repo,message,author,eventName,ref,workflow
env:
SLACK_WEBHOOK_URL: ${{ secrets.RELEASE_SLACK_WEBHOOK }}
if: always()
notify-discord:
runs-on: ubuntu-latest
needs:
+7 -4
View File
@@ -106,11 +106,14 @@ jobs:
- name: Run Docker-Compose
working-directory: AppFlowy-Cloud
env:
BACKEND_VERSION: 0.3.24-amd64
run: |
docker pull appflowyinc/appflowy_cloud:latest
docker compose up -d
if [ "$(docker ps --filter name=appflowy-cloud -q)" == "" ]; then
docker compose pull
docker compose up -d
sleep 10
else
echo "Docker container 'appflowy-cloud' is already running."
fi
- name: Run rust-lib tests
working-directory: frontend/rust-lib
+12 -25
View File
@@ -12,6 +12,8 @@ env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.77.2"
CARGO_MAKE_VERSION: "0.36.6"
CI: true
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -22,9 +24,6 @@ jobs:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
env:
CI: true
steps:
- uses: actions/checkout@v4
- name: install frontend dependencies
@@ -49,14 +48,11 @@ jobs:
tauri-build-ubuntu:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
env:
CI: true
runs-on: ubuntu-20.04
steps:
- name: Maximize build space (ubuntu only)
if: matrix.os == 'ubuntu-latest'
- uses: actions/checkout@v4
- name: Maximize build space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
@@ -85,36 +81,27 @@ jobs:
override: true
profile: minimal
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./frontend/appflowy_web_app/src-tauri -> target"
- name: Node_modules cache
uses: actions/cache@v2
with:
path: frontend/appflowy_web_app/node_modules
key: node-modules-${{ runner.os }}
- name: install dependencies (windows only)
if: matrix.os == 'windows-latest'
working-directory: frontend
run: |
cargo install --force duckscript_cli
vcpkg integrate install
- name: install dependencies (ubuntu only)
if: matrix.os == 'ubuntu-latest'
- name: install dependencies
working-directory: frontend
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: install cargo-make
- uses: taiki-e/install-action@v2
with:
tool: cargo-make@${{ env.CARGO_MAKE_VERSION }}
- name: install tauri deps tools
working-directory: frontend
run: |
cargo install --force cargo-make
cargo make appflowy-tauri-deps-tools
shell: bash
- name: install frontend dependencies
working-directory: frontend/appflowy_web_app
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.6.5"
APPFLOWY_VERSION = "0.6.6"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -42,7 +42,7 @@ void main() {
await tester.tapAnonymousSignInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
// reanme the name of the anon user
// rename the name of the anon user
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.account);
await tester.pumpAndSettle();
@@ -47,11 +47,11 @@ void main() {
);
await tester.tapButton(find.byType(SignInOutButton));
tester.expectToSeeText(LocaleKeys.button_confirm.tr());
await tester.tapButtonWithName(LocaleKeys.button_confirm.tr());
tester.expectToSeeText(LocaleKeys.button_ok.tr());
await tester.tapButtonWithName(LocaleKeys.button_ok.tr());
// Go to the sign in page again
await tester.pumpAndSettle(const Duration(seconds: 1));
await tester.pumpAndSettle(const Duration(seconds: 5));
tester.expectToSeeGoogleLoginButton();
});
@@ -50,26 +50,28 @@ void main() {
await tester.tapEscButton();
// wait 2 seconds for the sync to finish
await tester.pumpAndSettle(const Duration(seconds: 2));
await tester.pumpAndSettle(const Duration(seconds: 6));
});
testWidgets('get user icon and name from server', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
email: email,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
await tester.pumpAndSettle();
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.account);
// Verify name
final profileSetting =
tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting;
expect(profileSetting.name, name);
});
});
testWidgets('get user icon and name from server', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
email: email,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
await tester.pumpAndSettle();
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.account);
// Verify name
final profileSetting =
tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting;
expect(profileSetting.name, name);
});
}
@@ -16,6 +16,8 @@ import 'document_with_image_block_test.dart' as document_with_image_block_test;
import 'document_with_inline_math_equation_test.dart'
as document_with_inline_math_equation_test;
import 'document_with_inline_page_test.dart' as document_with_inline_page_test;
import 'document_with_multi_image_block_test.dart'
as document_with_multi_image_block_test;
import 'document_with_outline_block_test.dart' as document_with_outline_block;
import 'document_with_toggle_list_test.dart' as document_with_toggle_list_test;
import 'edit_document_test.dart' as document_edit_test;
@@ -38,6 +40,7 @@ void startTesting() {
document_text_direction_test.main();
document_option_action_test.main();
document_with_image_block_test.main();
document_with_multi_image_block_test.main();
document_inline_page_reference_test.main();
document_more_actions_test.main();
}
@@ -1,21 +1,22 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
hide UploadImageMenu, ResizableImage;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
@@ -36,7 +37,7 @@ void main() {
// create a new document
await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImage.tr(),
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
);
// tap the first line of the document
@@ -84,7 +85,7 @@ void main() {
// create a new document
await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImage.tr(),
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
);
// tap the first line of the document
@@ -137,7 +138,7 @@ void main() {
// create a new document
await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImage.tr(),
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
);
// tap the first line of the document
@@ -161,5 +162,67 @@ void main() {
expect(find.byType(UnsplashImageWidget), findsOneWidget);
});
});
testWidgets('insert two images from local file at once', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName('Image');
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
find.descendant(
of: find.byType(ImagePlaceholder),
matching: find.byType(AppFlowyPopover),
),
findsOneWidget,
);
expect(find.byType(UploadImageMenu), findsOneWidget);
final firstImage =
await rootBundle.load('assets/test/images/sample.jpeg');
final secondImage =
await rootBundle.load('assets/test/images/sample.gif');
final tempDirectory = await getTemporaryDirectory();
final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg');
final firstFile = File(firstImagePath)
..writeAsBytesSync(firstImage.buffer.asUint8List());
final secondImagePath = p.join(tempDirectory.path, 'sample.gif');
final secondFile = File(secondImagePath)
..writeAsBytesSync(secondImage.buffer.asUint8List());
mockPickFilePaths(paths: [firstImagePath, secondImagePath]);
await getIt<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,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
@@ -7,6 +5,7 @@ import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflow
import 'package:appflowy/workspace/presentation/settings/widgets/setting_supabase_cloud.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'util.dart';
@@ -29,8 +28,8 @@ extension AppFlowyAuthTest on WidgetTester {
await tapButton(find.byType(SignInOutButton));
expectToSeeText(LocaleKeys.button_confirm.tr());
await tapButtonWithName(LocaleKeys.button_confirm.tr());
expectToSeeText(LocaleKeys.button_ok.tr());
await tapButtonWithName(LocaleKeys.button_ok.tr());
}
Future<void> tapSignInAsGuest() async {
@@ -10,7 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/bl
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
@@ -171,7 +171,17 @@ class EditorOperations {
///
/// Must call [showSlashMenu] first.
Future<void> tapSlashMenuItemWithName(String name) async {
final slashMenu = find
.ancestor(
of: find.byType(SelectionMenuItemWidget),
matching: find.byWidgetPredicate(
(widget) => widget is Scrollable,
),
)
.first;
final slashMenuItem = find.text(name, findRichText: true);
await tester.scrollUntilVisible(slashMenuItem, 200, scrollable: slashMenu);
// await tester.ensureVisible(slashMenuItem);
await tester.tapButton(slashMenuItem);
}
@@ -12,17 +12,23 @@ import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flutter_test/flutter_test.dart';
import '../desktop/board/board_hide_groups_test.dart';
import 'base.dart';
import 'common_operations.dart';
extension AppFlowySettings on WidgetTester {
/// Open settings page
Future<void> openSettings() async {
final settingsDialog = find.byType(SettingsDialog);
// tap empty area to close the settings page
while (settingsDialog.evaluate().isNotEmpty) {
await tapAt(Offset.zero);
await pumpAndSettle();
}
final settingsButton = find.byType(UserSettingButton);
expect(settingsButton, findsOneWidget);
await tapButton(settingsButton);
final settingsDialog = find.byType(SettingsDialog);
expect(settingsDialog, findsOneWidget);
return;
}
+1 -1
View File
@@ -175,7 +175,7 @@ SPEC CHECKSUMS:
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb
image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
@@ -1,16 +1,14 @@
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/shared/window_title_bar.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CocoaWindowChannel {
@@ -104,19 +102,17 @@ class MoveWindowDetectorState extends State<MoveWindowDetector> {
return const SizedBox.shrink();
}
final color = Theme.of(context).isLightMode ? Colors.white : Colors.black;
final textSpan = TextSpan(
children: [
TextSpan(
text: '${LocaleKeys.sideBar_openSidebar.tr()}\n',
style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: color),
style: context.tooltipTextStyle(),
),
TextSpan(
text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\',
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(color: Theme.of(context).hintColor),
style: context
.tooltipTextStyle()
?.copyWith(color: Theme.of(context).hintColor),
),
],
);
@@ -23,6 +23,9 @@ class DocumentPageStyleBloc
await event.when(
initial: () async {
try {
if (view.id.isEmpty) {
return;
}
final layoutObject =
await ViewBackendService.getView(view.id).fold(
(s) => jsonDecode(s.extra),
@@ -0,0 +1,54 @@
import 'package:appflowy/shared/feedback_gesture_detector.dart';
import 'package:flutter/material.dart';
class AnimatedGestureDetector extends StatefulWidget {
const AnimatedGestureDetector({
super.key,
this.scaleFactor = 0.98,
this.feedback = true,
this.duration = const Duration(milliseconds: 100),
this.alignment = Alignment.center,
this.behavior = HitTestBehavior.opaque,
required this.onTapUp,
required this.child,
});
final Widget child;
final double scaleFactor;
final Duration duration;
final Alignment alignment;
final bool feedback;
final HitTestBehavior behavior;
final VoidCallback onTapUp;
@override
State<AnimatedGestureDetector> createState() =>
_AnimatedGestureDetectorState();
}
class _AnimatedGestureDetectorState extends State<AnimatedGestureDetector> {
double scale = 1.0;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: widget.behavior,
onTapUp: (details) {
setState(() => scale = 1.0);
HapticFeedbackType.vibrate.call();
widget.onTapUp();
},
onTapDown: (details) {
setState(() => scale = widget.scaleFactor);
},
child: AnimatedScale(
scale: scale,
alignment: widget.alignment,
duration: widget.duration,
child: widget.child,
),
);
}
}
@@ -125,7 +125,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
return child;
},
)
: child;
: SafeArea(child: child);
return Scaffold(
extendBodyBehindAppBar: isDocument,
appBar: appBar,
@@ -24,9 +24,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.icon_document_s,
size: Size.square(18),
size: Size.square(20),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(ViewLayoutPB.Document),
),
FlowyOptionTile.text(
@@ -34,9 +35,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.icon_grid_s,
size: Size.square(18),
size: Size.square(20),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(ViewLayoutPB.Grid),
),
FlowyOptionTile.text(
@@ -44,9 +46,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.icon_board_s,
size: Size.square(18),
size: Size.square(20),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(ViewLayoutPB.Board),
),
FlowyOptionTile.text(
@@ -54,9 +57,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.icon_calendar_s,
size: Size.square(18),
size: Size.square(20),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(ViewLayoutPB.Calendar),
),
FlowyOptionTile.text(
@@ -64,9 +68,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget {
height: 52.0,
leftIcon: const FlowySvg(
FlowySvgs.chat_ai_page_s,
size: Size.square(18),
size: Size.square(20),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(ViewLayoutPB.Chat),
),
],
@@ -1,4 +1,3 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
@@ -6,6 +5,7 @@ import 'package:appflowy/startup/tasks/app_widget.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -109,16 +109,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
await _showConfirmDialog(
onDelete: () {
recentViewsBloc.add(RecentViewsEvent.removeRecentViews([viewId]));
fToast.showToast(
child: const _RemoveToast(),
positionedToastBuilder: (context, child) {
return Positioned.fill(
top: 450,
child: child,
);
},
);
},
);
}
@@ -136,38 +126,14 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
),
onRightButtonPressed: (context) {
onDelete();
Navigator.pop(context);
showToastNotification(
context,
message: LocaleKeys.sideBar_removeSuccess.tr(),
);
},
);
}
}
class _RemoveToast extends StatelessWidget {
const _RemoveToast();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 13.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.0),
color: const Color(0xE5171717),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FlowySvg(
FlowySvgs.success_s,
blendMode: null,
),
const HSpace(8.0),
FlowyText.regular(
LocaleKeys.sideBar_removeSuccess.tr(),
fontSize: 16.0,
color: Colors.white,
),
],
),
);
}
}
@@ -7,6 +7,7 @@ import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -40,18 +41,32 @@ enum MobilePaneActionType {
backgroundColor: const Color(0xFFFA217F),
svg: FlowySvgs.favorite_section_remove_from_favorite_s,
size: 24.0,
onPressed: (context) => context
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(context.read<ViewBloc>().view)),
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
);
context
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(context.read<ViewBloc>().view));
},
);
case MobilePaneActionType.addToFavorites:
return MobileSlideActionButton(
backgroundColor: const Color(0xFF00C8FF),
svg: FlowySvgs.favorite_s,
size: 24.0,
onPressed: (context) => context
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(context.read<ViewBloc>().view)),
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(),
);
context
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(context.read<ViewBloc>().view));
},
);
case MobilePaneActionType.add:
return MobileSlideActionButton(
@@ -69,6 +84,7 @@ enum MobilePaneActionType {
showDragHandle: true,
showCloseButton: true,
useRootNavigator: true,
showDivider: false,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (sheetContext) {
return AddNewPageWidgetBottomSheet(
@@ -145,8 +161,6 @@ enum MobilePaneActionType {
? MobileViewItemBottomSheetBodyAction.removeFromFavorites
: MobileViewItemBottomSheetBodyAction.addToFavorites,
MobileViewItemBottomSheetBodyAction.divider,
if (view.layout != ViewLayoutPB.Chat)
MobileViewItemBottomSheetBodyAction.duplicate,
MobileViewItemBottomSheetBodyAction.divider,
MobileViewItemBottomSheetBodyAction.removeFromRecent,
];
@@ -156,7 +170,6 @@ enum MobilePaneActionType {
? MobileViewItemBottomSheetBodyAction.removeFromFavorites
: MobileViewItemBottomSheetBodyAction.addToFavorites,
MobileViewItemBottomSheetBodyAction.divider,
MobileViewItemBottomSheetBodyAction.duplicate,
];
}
}
@@ -181,12 +194,13 @@ ActionPane buildEndActionPane(
bool needSpace = true,
MobilePageCardType? cardType,
FolderSpaceType? spaceType,
required double spaceRatio,
}) {
return ActionPane(
motion: const ScrollMotion(),
extentRatio: actions.length / 5,
extentRatio: actions.length / spaceRatio,
children: [
if (needSpace) const HSpace(20),
if (needSpace) const HSpace(60),
...actions.map(
(action) => action.actionButton(
context,
@@ -70,6 +70,7 @@ Future<T?> showMobileBottomSheet<T>(
backgroundColor ??= Theme.of(context).brightness == Brightness.light
? const Color(0xFFF7F8FB)
: const Color(0xFF23262B);
barrierColor ??= Colors.black.withOpacity(0.3);
return showModalBottomSheet<T>(
context: context,
@@ -226,10 +227,14 @@ class BottomSheetHeader extends StatelessWidget {
),
),
Align(
child: FlowyText(
title,
fontSize: 16.0,
fontWeight: FontWeight.w500,
child: Container(
constraints: const BoxConstraints(maxWidth: 250),
child: FlowyText(
title,
fontSize: 17.0,
fontWeight: FontWeight.w500,
overflow: TextOverflow.ellipsis,
),
),
),
if (showDoneButton)
@@ -100,8 +100,6 @@ class _FavoriteViews extends StatelessWidget {
child: ListView.separated(
key: const PageStorageKey('favorite_views_page_storage_key'),
padding: EdgeInsets.only(
left: HomeSpaceViewSizes.mHorizontalPadding,
right: HomeSpaceViewSizes.mHorizontalPadding,
bottom: HomeSpaceViewSizes.mVerticalPadding +
MediaQuery.of(context).padding.bottom,
),
@@ -72,6 +72,7 @@ class MobileFavoriteFolder extends StatelessWidget {
MobilePaneActionType.more,
],
spaceType: FolderSpaceType.favorite,
spaceRatio: 5,
),
),
),
@@ -29,8 +29,6 @@ class _MobileHomeSpaceState extends State<MobileHomeSpace>
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.only(
left: HomeSpaceViewSizes.mHorizontalPadding,
right: HomeSpaceViewSizes.mHorizontalPadding,
top: HomeSpaceViewSizes.mVerticalPadding,
bottom: HomeSpaceViewSizes.mVerticalPadding +
MediaQuery.of(context).padding.bottom,
@@ -9,6 +9,7 @@ import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -91,7 +92,12 @@ class MobileFolders extends StatelessWidget {
children: [
..._buildSpaceOrSection(context, state),
const VSpace(4.0),
const _TrashButton(),
const Padding(
padding: EdgeInsets.symmetric(
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
),
child: _TrashButton(),
),
],
),
);
@@ -1,5 +1,6 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/gesture.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/mobile_home_setting_page.dart';
import 'package:appflowy/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart';
@@ -113,8 +114,9 @@ class _MobileWorkspace extends StatelessWidget {
if (currentWorkspace == null) {
return const SizedBox.shrink();
}
return GestureDetector(
onTap: () {
return AnimatedGestureDetector(
alignment: Alignment.centerLeft,
onTapUp: () {
context.read<UserWorkspaceBloc>().add(
const UserWorkspaceEvent.fetchWorkspaces(),
);
@@ -143,7 +145,7 @@ class _MobileWorkspace extends StatelessWidget {
: const HSpace(8),
FlowyText.semibold(
currentWorkspace.name,
fontSize: 16.0,
fontSize: 20.0,
overflow: TextOverflow.ellipsis,
),
],
@@ -162,9 +164,10 @@ class _MobileWorkspace extends StatelessWidget {
showHeader: true,
showDragHandle: true,
showCloseButton: true,
useRootNavigator: true,
title: LocaleKeys.workspace_menuTitle.tr(),
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (_) {
builder: (sheetContext) {
return BlocProvider.value(
value: context.read<UserWorkspaceBloc>(),
child: BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
@@ -179,7 +182,7 @@ class _MobileWorkspace extends StatelessWidget {
currentWorkspace: currentWorkspace,
workspaces: workspaces,
onWorkspaceSelected: (workspace) {
context.pop();
Navigator.of(sheetContext).pop();
if (workspace == currentWorkspace) {
return;
@@ -72,8 +72,6 @@ class _RecentViews extends StatelessWidget {
child: ListView.separated(
key: const PageStorageKey('recent_views_page_storage_key'),
padding: EdgeInsets.only(
left: HomeSpaceViewSizes.mHorizontalPadding,
right: HomeSpaceViewSizes.mHorizontalPadding,
bottom: HomeSpaceViewSizes.mVerticalPadding +
MediaQuery.of(context).padding.bottom,
),
@@ -82,6 +82,7 @@ class MobileSectionFolder extends StatelessWidget {
],
spaceType: spaceType,
needSpace: false,
spaceRatio: 5,
);
},
),
@@ -5,6 +5,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/application/recent/recent_view_bloc.dart';
import 'package:appflowy/mobile/presentation/base/gesture.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
@@ -15,6 +16,7 @@ import 'package:appflowy/workspace/application/settings/appearance/appearance_cu
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -76,13 +78,14 @@ class MobileViewPage extends StatelessWidget {
: MobilePaneActionType.addToFavorites,
],
cardType: type,
spaceRatio: 4,
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (_) => context.pushView(view),
child: AnimatedGestureDetector(
onTapUp: () => context.pushView(view),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const HSpace(HomeSpaceViewSizes.mHorizontalPadding),
Expanded(child: _buildDescription(context, state)),
const HSpace(20.0),
SizedBox(
@@ -90,6 +93,7 @@ class MobileViewPage extends StatelessWidget {
height: 60,
child: _buildCover(context, state),
),
const HSpace(HomeSpaceViewSizes.mHorizontalPadding),
],
),
),
@@ -211,7 +215,7 @@ class MobileViewPage extends StatelessWidget {
Widget _buildLastViewed(BuildContext context) {
final textColor = Theme.of(context).isLightMode
? const Color(0xFF171717)
? const Color(0x7F171717)
: Colors.white.withOpacity(0.45);
if (timestamp == null) {
return const SizedBox.shrink();
@@ -50,23 +50,17 @@ class _MobileSpaceState extends State<MobileSpace> {
MobileSpaceHeader(
isExpanded: state.isExpanded,
space: currentSpace,
onAdded: () {
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
layout: ViewLayoutPB.Document,
index: 0,
),
);
context.read<SpaceBloc>().add(
SpaceEvent.expand(currentSpace, true),
);
},
onAdded: () => _showCreatePageMenu(currentSpace),
onPressed: () => _showSpaceMenu(context),
),
_Pages(
key: ValueKey(currentSpace.id),
space: currentSpace,
Padding(
padding: const EdgeInsets.only(
left: HomeSpaceViewSizes.mHorizontalPadding,
),
child: _Pages(
key: ValueKey(currentSpace.id),
space: currentSpace,
),
),
],
);
@@ -82,6 +76,7 @@ class _MobileSpaceState extends State<MobileSpace> {
showDragHandle: true,
showCloseButton: true,
showDoneButton: true,
useRootNavigator: true,
title: LocaleKeys.space_title.tr(),
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (_) {
@@ -104,6 +99,38 @@ class _MobileSpaceState extends State<MobileSpace> {
),
);
}
void _showCreatePageMenu(ViewPB space) {
final title = space.name;
showMobileBottomSheet(
context,
showHeader: true,
title: title,
showDragHandle: true,
showCloseButton: true,
useRootNavigator: true,
showDivider: false,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (sheetContext) {
return AddNewPageWidgetBottomSheet(
view: space,
onAction: (layout) {
Navigator.of(sheetContext).pop();
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
layout: layout,
index: 0,
),
);
context.read<SpaceBloc>().add(
SpaceEvent.expand(space, true),
);
},
);
},
);
}
}
class _Pages extends StatelessWidget {
@@ -148,7 +175,7 @@ class _Pages extends StatelessWidget {
MobilePaneActionType.add,
],
spaceType: spaceType,
needSpace: false,
spaceRatio: 4,
);
},
),
@@ -1,4 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -30,6 +31,7 @@ class MobileSpaceHeader extends StatelessWidget {
height: 48,
child: Row(
children: [
const HSpace(HomeSpaceViewSizes.mHorizontalPadding),
SpaceIcon(
dimension: 24,
space: space,
@@ -49,8 +51,15 @@ class MobileSpaceHeader extends StatelessWidget {
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onAdded,
child: const FlowySvg(
FlowySvgs.m_space_add_s,
child: Container(
// expand the touch area
margin: const EdgeInsets.symmetric(
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
vertical: 8.0,
),
child: const FlowySvg(
FlowySvgs.m_space_add_s,
),
),
),
],
@@ -59,6 +59,7 @@ class _SidebarSpaceMenuItem extends StatelessWidget {
children: [
FlowyText.medium(
space.name,
fontSize: 16.0,
),
const HSpace(6.0),
if (space.spacePermission == SpacePermission.private)
@@ -68,16 +69,17 @@ class _SidebarSpaceMenuItem extends StatelessWidget {
),
],
),
margin: const EdgeInsets.symmetric(horizontal: 12.0),
iconPadding: 10,
leftIcon: SpaceIcon(
dimension: 24,
space: space,
cornerRadius: 6.0,
),
leftIconSize: const Size.square(20),
leftIconSize: const Size.square(24),
rightIcon: isSelected
? const FlowySvg(
FlowySvgs.workspace_selected_s,
FlowySvgs.m_blue_check_s,
blendMode: null,
)
: null,
@@ -138,17 +138,20 @@ class _WorkspaceMenuItem extends StatelessWidget {
height: 60,
showTopBorder: showTopBorder,
showBottomBorder: false,
leftIcon: WorkspaceIcon(
enableEdit: false,
iconSize: 26,
fontSize: 16.0,
workspace: workspace,
onSelected: (result) => context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.updateWorkspaceIcon(
workspace.workspaceId,
result.emoji,
leftIcon: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: WorkspaceIcon(
enableEdit: false,
iconSize: 26,
fontSize: 16.0,
workspace: workspace,
onSelected: (result) => context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.updateWorkspaceIcon(
workspace.workspaceId,
result.emoji,
),
),
),
),
),
trailing: workspace.workspaceId == currentWorkspace.workspaceId
? const FlowySvg(
@@ -50,6 +50,9 @@ class MobileBottomNavigationBar extends StatelessWidget {
final backgroundColor = isLightMode
? Colors.white.withOpacity(0.95)
: const Color(0xFF23262B).withOpacity(0.95);
final borderColor = isLightMode
? const Color(0x141F2329)
: const Color(0xFF23262B).withOpacity(0.5);
return Scaffold(
body: navigationShell,
extendBody: true,
@@ -62,9 +65,7 @@ class MobileBottomNavigationBar extends StatelessWidget {
child: DecoratedBox(
decoration: BoxDecoration(
border: isLightMode
? Border(
top: BorderSide(color: Theme.of(context).dividerColor),
)
? Border(top: BorderSide(color: borderColor))
: null,
color: backgroundColor,
),
@@ -300,7 +300,7 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
)
: Opacity(
opacity: 0.7,
child: widget.view.defaultIcon(),
child: widget.view.defaultIcon(size: const Size.square(18)),
);
return SizedBox(width: 18.0, child: icon);
}
@@ -17,7 +17,7 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
required this.questionId,
}) : super(ChatAIMessageState.initial(message)) {
if (state.stream != null) {
_subscription = state.stream!.listen(
state.stream!.listen(
onData: (text) {
if (!isClosed) {
add(ChatAIMessageEvent.updateText(text));
@@ -108,13 +108,6 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
);
}
@override
Future<void> close() {
_subscription?.cancel();
return super.close();
}
StreamSubscription<String>? _subscription;
final String chatId;
final Int64? questionId;
}
@@ -586,7 +586,7 @@ class AnswerStream {
_port.close();
}
StreamSubscription<String> listen({
void listen({
void Function(String text)? onData,
void Function()? onStart,
void Function()? onEnd,
@@ -602,7 +602,5 @@ class AnswerStream {
if (_onStart != null) {
_onStart!();
}
return _subscription;
}
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
@@ -13,6 +15,11 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
}) : listener = LocalLLMListener(),
super(const ChatFileState()) {
listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(ChatFileEvent.updatePluginState(pluginState));
}
},
chatStateCallback: (chatState) {
if (!isClosed) {
add(ChatFileEvent.updateChatState(chatState));
@@ -38,18 +45,55 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
},
);
},
newFile: (String filePath) {
newFile: (String filePath, String fileName) async {
emit(
state.copyWith(
indexFileIndicator: IndexFileIndicator.indexing(fileName),
),
);
final payload = ChatFilePB(filePath: filePath, chatId: chatId);
ChatEventChatWithFile(payload).send();
unawaited(
ChatEventChatWithFile(payload).send().then((result) {
if (!isClosed) {
result.fold((_) {
add(
ChatFileEvent.updateIndexFile(
IndexFileIndicator.finish(fileName),
),
);
}, (err) {
add(
ChatFileEvent.updateIndexFile(
IndexFileIndicator.error(err.msg),
),
);
});
}
}),
);
},
updateChatState: (LocalAIChatPB chatState) {
// Only user enable chat with file and the plugin is already running
final supportChatWithFile = chatState.fileEnabled &&
chatState.pluginState.state == RunningStatePB.Running;
emit(
state.copyWith(supportChatWithFile: supportChatWithFile),
state.copyWith(
supportChatWithFile: supportChatWithFile,
chatState: chatState,
),
);
},
updateIndexFile: (IndexFileIndicator indicator) {
emit(
state.copyWith(indexFileIndicator: indicator),
);
},
updatePluginState: (LocalAIPluginStatePB chatState) {
final fileEnabled = state.chatState?.fileEnabled ?? false;
final supportChatWithFile =
fileEnabled && chatState.state == RunningStatePB.Running;
emit(state.copyWith(supportChatWithFile: supportChatWithFile));
},
);
},
);
@@ -67,20 +111,29 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
@freezed
class ChatFileEvent with _$ChatFileEvent {
const factory ChatFileEvent.initial() = Initial;
const factory ChatFileEvent.newFile(String filePath) = _NewFile;
const factory ChatFileEvent.newFile(String filePath, String fileName) =
_NewFile;
const factory ChatFileEvent.updateChatState(LocalAIChatPB chatState) =
_UpdateChatState;
const factory ChatFileEvent.updatePluginState(
LocalAIPluginStatePB chatState,
) = _UpdatePluginState;
const factory ChatFileEvent.updateIndexFile(IndexFileIndicator indicator) =
_UpdateIndexFile;
}
@freezed
class ChatFileState with _$ChatFileState {
const factory ChatFileState({
@Default(false) bool supportChatWithFile,
IndexFileIndicator? indexFileIndicator,
LocalAIChatPB? chatState,
}) = _ChatFileState;
}
@freezed
class LocalAIChatFileIndicator with _$LocalAIChatFileIndicator {
const factory LocalAIChatFileIndicator.ready(bool isEnabled) = _Ready;
const factory LocalAIChatFileIndicator.loading() = _Loading;
class IndexFileIndicator with _$IndexFileIndicator {
const factory IndexFileIndicator.finish(String fileName) = _Finish;
const factory IndexFileIndicator.indexing(String fileName) = _Indexing;
const factory IndexFileIndicator.error(String error) = _Error;
}
@@ -13,9 +13,9 @@ class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
: listener = LocalLLMListener(),
super(const ChatInputState(aiType: _AppFlowyAI())) {
listener.start(
stateCallback: (pluginState) {
chatStateCallback: (aiState) {
if (!isClosed) {
add(ChatInputEvent.updateState(pluginState));
add(ChatInputEvent.updateState(aiState));
}
},
);
@@ -37,18 +37,26 @@ class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
) async {
await event.when(
started: () async {
final result = await ChatEventGetLocalAIPluginState().send();
final result = await ChatEventGetLocalAIChatState().send();
result.fold(
(pluginState) {
(aiState) {
if (!isClosed) {
add(ChatInputEvent.updateState(pluginState));
add(
ChatInputEvent.updateState(aiState),
);
}
},
(err) => Log.error(err.toString()),
(err) {
Log.error(err.toString());
},
);
},
updateState: (LocalAIPluginStatePB aiPluginState) {
emit(const ChatInputState(aiType: _AppFlowyAI()));
updateState: (aiState) {
if (aiState.enabled) {
emit(const ChatInputState(aiType: _LocalAI()));
} else {
emit(const ChatInputState(aiType: _AppFlowyAI()));
}
},
);
}
@@ -57,8 +65,8 @@ class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
@freezed
class ChatInputEvent with _$ChatInputEvent {
const factory ChatInputEvent.started() = _Started;
const factory ChatInputEvent.updateState(LocalAIPluginStatePB aiPluginState) =
_UpdatePluginState;
const factory ChatInputEvent.updateState(LocalAIChatPB aiState) =
_UpdateAIState;
}
@freezed
@@ -1,5 +1,7 @@
import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -21,7 +23,7 @@ import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat;
import 'presentation/chat_input.dart';
import 'presentation/chat_input/chat_input.dart';
import 'presentation/chat_popmenu.dart';
import 'presentation/chat_theme.dart';
import 'presentation/chat_user_invalid_message.dart';
@@ -71,7 +73,8 @@ class AIChatPage extends StatelessWidget {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (_) => ChatFileBloc(chatId: view.id.toString()),
create: (_) => ChatFileBloc(chatId: view.id.toString())
..add(const ChatFileEvent.initial()),
),
BlocProvider(
create: (_) => ChatBloc(
@@ -79,30 +82,44 @@ class AIChatPage extends StatelessWidget {
userProfile: userProfile,
)..add(const ChatEvent.initialLoad()),
),
BlocProvider(create: (_) => ChatInputBloc()),
BlocProvider(
create: (_) => ChatInputBloc()..add(const ChatInputEvent.started()),
),
],
child: BlocBuilder<ChatFileBloc, ChatFileState>(
builder: (context, state) {
Widget child = _ChatContentPage(
view: view,
userProfile: userProfile,
);
// If the chat supports file upload, wrap the chat content with a drop target
if (state.supportChatWithFile) {
child = DropTarget(
child: BlocListener<ChatFileBloc, ChatFileState>(
listenWhen: (previous, current) =>
previous.indexFileIndicator != current.indexFileIndicator,
listener: (context, state) {
_handleIndexIndicator(state.indexFileIndicator, context);
},
child: BlocBuilder<ChatFileBloc, ChatFileState>(
builder: (context, state) {
return DropTarget(
onDragDone: (DropDoneDetails detail) async {
for (final file in detail.files) {
context
.read<ChatFileBloc>()
.add(ChatFileEvent.newFile(file.path));
if (state.supportChatWithFile) {
await showConfirmDialog(
context: context,
style: ConfirmPopupStyle.cancelAndOk,
title: LocaleKeys.chat_chatWithFilePrompt.tr(),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () {
for (final file in detail.files) {
context
.read<ChatFileBloc>()
.add(ChatFileEvent.newFile(file.path, file.name));
}
},
description: '',
);
}
},
child: child,
child: _ChatContentPage(
view: view,
userProfile: userProfile,
),
);
}
return child;
},
},
),
),
);
}
@@ -114,6 +131,35 @@ class AIChatPage extends StatelessWidget {
),
);
}
void _handleIndexIndicator(
IndexFileIndicator? indicator,
BuildContext context,
) {
if (indicator != null) {
indicator.when(
finish: (fileName) {
showSnackBarMessage(
context,
LocaleKeys.chat_indexFileSuccess.tr(args: [fileName]),
);
},
indexing: (fileName) {
showSnackBarMessage(
context,
LocaleKeys.chat_indexingFile.tr(args: [fileName]),
duration: const Duration(seconds: 2),
);
},
error: (err) {
showSnackBarMessage(
context,
err,
);
},
);
}
}
}
class _ChatContentPage extends StatefulWidget {
@@ -347,36 +393,39 @@ class _ChatContentPageState extends State<_ChatContentPage> {
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocBuilder<ChatInputBloc, ChatInputState>(
builder: (context, state) {
return state.aiType.when(
appflowyAI: () => Column(
children: [
BlocSelector<ChatBloc, ChatState, LoadingState>(
selector: (state) => state.streamingStatus,
builder: (context, state) {
return ChatInput(
chatId: widget.view.id,
onSendPressed: (message) =>
onSendPressed(context, message.text),
isStreaming: state != const LoadingState.finish(),
onStopStreaming: () {
context
.read<ChatBloc>()
.add(const ChatEvent.stopStream());
},
);
},
final hintText = state.aiType.when(
appflowyAI: () => LocaleKeys.chat_inputMessageHint.tr(),
localAI: () => LocaleKeys.chat_inputLocalAIMessageHint.tr(),
);
return Column(
children: [
BlocSelector<ChatBloc, ChatState, LoadingState>(
selector: (state) => state.streamingStatus,
builder: (context, state) {
return ChatInput(
chatId: widget.view.id,
onSendPressed: (message) =>
onSendPressed(context, message.text),
isStreaming: state != const LoadingState.finish(),
onStopStreaming: () {
context
.read<ChatBloc>()
.add(const ChatEvent.stopStream());
},
hintText: hintText,
);
},
),
const VSpace(6),
Opacity(
opacity: 0.6,
child: FlowyText(
LocaleKeys.chat_aiMistakePrompt.tr(),
fontSize: 12,
),
const VSpace(6),
Opacity(
opacity: 0.6,
child: FlowyText(
LocaleKeys.chat_aiMistakePrompt.tr(),
fontSize: 12,
),
),
],
),
localAI: () => const SizedBox.shrink(),
),
],
);
},
),
@@ -4,7 +4,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_popmenu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
@@ -0,0 +1,237 @@
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
abstract class ChatActionMenuItem {
String get title;
}
abstract class ChatActionHandler {
List<ChatActionMenuItem> get items;
void onEnter();
void onSelected(ChatActionMenuItem item);
void onExit();
}
abstract class ChatAnchor {
GlobalKey get anchorKey;
LayerLink get layerLink;
}
const int _itemHeight = 34;
const int _itemVerticalPadding = 4;
class ChatActionsMenu {
ChatActionsMenu({
required this.anchor,
required this.context,
required this.handler,
required this.style,
});
final BuildContext context;
final ChatAnchor anchor;
final ChatActionsMenuStyle style;
final ChatActionHandler handler;
OverlayEntry? _overlayEntry;
void dismiss() {
_overlayEntry?.remove();
_overlayEntry = null;
handler.onExit();
}
void show() {
WidgetsBinding.instance.addPostFrameCallback((_) => _show());
}
void _show() {
if (_overlayEntry != null) {
dismiss();
}
if (anchor.anchorKey.currentContext == null) {
return;
}
handler.onEnter();
final height = handler.items.length * (_itemHeight + _itemVerticalPadding);
_overlayEntry = OverlayEntry(
builder: (context) => Stack(
children: [
CompositedTransformFollower(
link: anchor.layerLink,
showWhenUnlinked: false,
offset: Offset(0, -height - 4),
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 200,
maxWidth: 200,
maxHeight: 200,
),
child: DecoratedBox(
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6.0),
),
child: ActionList(
handler: handler,
onDismiss: () => dismiss(),
),
),
),
),
),
],
),
);
Overlay.of(context).insert(_overlayEntry!);
}
}
class _ActionItem extends StatelessWidget {
const _ActionItem({
required this.item,
required this.onTap,
required this.isSelected,
});
final ChatActionMenuItem item;
final VoidCallback? onTap;
final bool isSelected;
@override
Widget build(BuildContext context) {
return Container(
height: _itemHeight.toDouble(),
padding: const EdgeInsets.symmetric(vertical: _itemVerticalPadding / 2.0),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(4.0),
),
child: FlowyButton(
margin: const EdgeInsets.symmetric(horizontal: 6),
iconPadding: 10.0,
text: FlowyText.regular(
item.title,
),
onTap: onTap,
),
);
}
}
class ActionList extends StatefulWidget {
const ActionList({super.key, required this.handler, required this.onDismiss});
final ChatActionHandler handler;
final VoidCallback? onDismiss;
@override
State<ActionList> createState() => _ActionListState();
}
class _ActionListState extends State<ActionList> {
final FocusScopeNode _focusNode =
FocusScopeNode(debugLabel: 'ChatActionsMenu');
int _selectedIndex = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
void _handleKeyPress(event) {
setState(() {
// ignore: deprecated_member_use
if (event is KeyDownEvent || event is RawKeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
_selectedIndex = (_selectedIndex + 1) % widget.handler.items.length;
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
_selectedIndex = (_selectedIndex - 1 + widget.handler.items.length) %
widget.handler.items.length;
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
widget.handler.onSelected(widget.handler.items[_selectedIndex]);
widget.onDismiss?.call();
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
widget.onDismiss?.call();
}
}
});
}
@override
Widget build(BuildContext context) {
return FocusScope(
node: _focusNode,
onKey: (node, event) {
_handleKeyPress(event);
return KeyEventResult.handled;
},
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.all(8),
children: widget.handler.items.asMap().entries.map((entry) {
final index = entry.key;
final ChatActionMenuItem item = entry.value;
return _ActionItem(
item: item,
onTap: () {
widget.handler.onSelected(item);
widget.onDismiss?.call();
},
isSelected: _selectedIndex == index,
);
}).toList(),
),
);
}
}
class ChatActionsMenuStyle {
ChatActionsMenuStyle({
required this.backgroundColor,
required this.groupTextColor,
required this.menuItemTextColor,
required this.menuItemSelectedColor,
required this.menuItemSelectedTextColor,
});
const ChatActionsMenuStyle.light()
: backgroundColor = Colors.white,
groupTextColor = const Color(0xFF555555),
menuItemTextColor = const Color(0xFF333333),
menuItemSelectedColor = const Color(0xFFE0F8FF),
menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247);
const ChatActionsMenuStyle.dark()
: backgroundColor = const Color(0xFF282E3A),
groupTextColor = const Color(0xFFBBC3CD),
menuItemTextColor = const Color(0xFFBBC3CD),
menuItemSelectedColor = const Color(0xFF00BCF0),
menuItemSelectedTextColor = const Color(0xFF131720);
final Color backgroundColor;
final Color groupTextColor;
final Color menuItemTextColor;
final Color menuItemSelectedColor;
final Color menuItemSelectedTextColor;
}
@@ -0,0 +1,48 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/material.dart';
class ChatInputAccessoryButton extends StatelessWidget {
const ChatInputAccessoryButton({
required this.onSendPressed,
required this.onStopStreaming,
required this.isStreaming,
super.key,
});
final void Function() onSendPressed;
final void Function() onStopStreaming;
final bool isStreaming;
@override
Widget build(BuildContext context) {
if (isStreaming) {
return FlowyIconButton(
width: 36,
icon: FlowySvg(
FlowySvgs.ai_stream_stop_s,
size: const Size.square(28),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onStopStreaming,
radius: BorderRadius.circular(18),
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
);
} else {
return FlowyIconButton(
width: 36,
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(18),
icon: FlowySvg(
FlowySvgs.send_s,
size: const Size.square(24),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onSendPressed,
);
}
}
}
@@ -0,0 +1,76 @@
import 'package:appflowy/plugins/ai_chat/presentation/chat_inline_action_menu.dart';
import 'package:flutter/material.dart';
class ChatTextFieldInterceptor {
String previosText = "";
ChatActionHandler? onTextChanged(
String text,
TextEditingController textController,
FocusNode textFieldFocusNode,
) {
if (previosText == "/" && text == "/ ") {
final handler = IndexActionHandler(
textController: textController,
textFieldFocusNode: textFieldFocusNode,
) as ChatActionHandler;
return handler;
}
previosText = text;
return null;
}
}
class FixGrammarMenuItem extends ChatActionMenuItem {
@override
String get title => "Fix Grammar";
}
class ImproveWritingMenuItem extends ChatActionMenuItem {
@override
String get title => "Improve Writing";
}
class ChatWithFileMenuItem extends ChatActionMenuItem {
@override
String get title => "Chat With PDF";
}
class IndexActionHandler extends ChatActionHandler {
IndexActionHandler({
required this.textController,
required this.textFieldFocusNode,
});
final TextEditingController textController;
final FocusNode textFieldFocusNode;
@override
List<ChatActionMenuItem> get items => [
ChatWithFileMenuItem(),
FixGrammarMenuItem(),
ImproveWritingMenuItem(),
];
@override
void onSelected(ChatActionMenuItem item) {
textController.clear();
WidgetsBinding.instance.addPostFrameCallback(
(_) => textFieldFocusNode.requestFocus(),
);
}
@override
void onExit() {
if (!textFieldFocusNode.hasFocus) {
textFieldFocusNode.requestFocus();
}
}
@override
void onEnter() {
if (textFieldFocusNode.hasFocus) {
textFieldFocusNode.unfocus();
}
}
}
@@ -1,14 +1,13 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_inline_action_menu.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'chat_accessory_button.dart';
class ChatInput extends StatefulWidget {
/// Creates [ChatInput] widget.
const ChatInput({
@@ -20,6 +19,7 @@ class ChatInput extends StatefulWidget {
this.options = const InputOptions(),
required this.isStreaming,
required this.onStopStreaming,
required this.hintText,
});
final bool? isAttachmentUploading;
@@ -29,6 +29,7 @@ class ChatInput extends StatefulWidget {
final InputOptions options;
final String chatId;
final bool isStreaming;
final String hintText;
@override
State<ChatInput> createState() => _ChatInputState();
@@ -36,6 +37,11 @@ class ChatInput extends StatefulWidget {
/// [ChatInput] widget state.
class _ChatInputState extends State<ChatInput> {
final GlobalKey _textFieldKey = GlobalKey();
final LayerLink _layerLink = LayerLink();
// final ChatTextFieldInterceptor _textFieldInterceptor =
// ChatTextFieldInterceptor();
late final _inputFocusNode = FocusNode(
onKeyEvent: (node, event) {
if (event.physicalKey == PhysicalKeyboardKey.enter &&
@@ -59,54 +65,32 @@ class _ChatInputState extends State<ChatInput> {
}
},
);
late TextEditingController _textController;
bool _sendButtonVisible = false;
late TextEditingController _textController;
@override
void initState() {
super.initState();
_textController =
widget.options.textEditingController ?? InputTextFieldController();
_textController = InputTextFieldController();
_handleSendButtonVisibilityModeChange();
}
void _handleSendButtonVisibilityModeChange() {
_textController.removeListener(_handleTextControllerChange);
_sendButtonVisible =
_textController.text.trim() != '' || widget.isStreaming;
_textController.addListener(_handleTextControllerChange);
@override
void dispose() {
_inputFocusNode.dispose();
_textController.dispose();
super.dispose();
}
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
if (trimmedText != '') {
final partialText = types.PartialText(text: trimmedText);
widget.onSendPressed(partialText);
if (widget.options.inputClearMode == InputClearMode.always) {
_textController.clear();
}
}
}
void _handleTextControllerChange() {
if (_textController.value.isComposingRangeValid) {
return;
}
setState(() {
_sendButtonVisible = _textController.text.trim() != '';
});
}
Widget _inputBuilder() {
@override
Widget build(BuildContext context) {
const textPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
const buttonPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
const inputPadding = EdgeInsets.all(6);
return Focus(
autofocus: !widget.options.autofocus,
child: Padding(
padding: inputPadding,
child: Material(
@@ -132,31 +116,78 @@ class _ChatInputState extends State<ChatInput> {
);
}
Padding _inputTextField(EdgeInsets textPadding) {
return Padding(
padding: textPadding,
child: TextField(
controller: _textController,
focusNode: _inputFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
hintStyle: TextStyle(
color: AFThemeExtension.of(context).textColor.withOpacity(0.5),
void _handleSendButtonVisibilityModeChange() {
_textController.removeListener(_handleTextControllerChange);
_sendButtonVisible =
_textController.text.trim() != '' || widget.isStreaming;
_textController.addListener(_handleTextControllerChange);
}
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
if (trimmedText != '') {
final partialText = types.PartialText(text: trimmedText);
widget.onSendPressed(partialText);
_textController.clear();
}
}
void _handleTextControllerChange() {
if (_textController.value.isComposingRangeValid) {
return;
}
setState(() {
_sendButtonVisible = _textController.text.trim() != '';
});
}
Widget _inputTextField(EdgeInsets textPadding) {
return CompositedTransformTarget(
link: _layerLink,
child: Padding(
padding: textPadding,
child: TextField(
key: _textFieldKey,
controller: _textController,
focusNode: _inputFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
hintText: widget.hintText,
hintStyle: TextStyle(
color: AFThemeExtension.of(context).textColor.withOpacity(0.5),
),
),
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
maxLines: 10,
minLines: 1,
// onChanged: (text) {
// final handler = _textFieldInterceptor.onTextChanged(
// text,
// _textController,
// _inputFocusNode,
// );
// // If the handler is not null, it means that the text has been
// // recognized as a command.
// if (handler != null) {
// ChatActionsMenu(
// anchor: ChatInputAnchor(
// anchorKey: _textFieldKey,
// layerLink: _layerLink,
// ),
// handler: handler,
// context: context,
// style: Theme.of(context).brightness == Brightness.dark
// ? const ChatActionsMenuStyle.dark()
// : const ChatActionsMenuStyle.light(),
// ).show();
// }
// },
),
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
),
autocorrect: widget.options.autocorrect,
autofocus: widget.options.autofocus,
enableSuggestions: widget.options.enableSuggestions,
keyboardType: widget.options.keyboardType,
textCapitalization: TextCapitalization.sentences,
maxLines: 10,
minLines: 1,
onChanged: widget.options.onTextChanged,
onTap: widget.options.onTextFieldTap,
),
);
}
@@ -170,16 +201,14 @@ class _ChatInputState extends State<ChatInput> {
visible: _sendButtonVisible,
child: Padding(
padding: buttonPadding,
child: AccessoryButton(
child: ChatInputAccessoryButton(
onSendPressed: () {
if (!widget.isStreaming) {
widget.onStopStreaming();
_handleSendPressed();
}
},
onStopStreaming: () {
widget.onStopStreaming();
},
onStopStreaming: () => widget.onStopStreaming(),
isStreaming: widget.isStreaming,
),
),
@@ -192,111 +221,20 @@ class _ChatInputState extends State<ChatInput> {
super.didUpdateWidget(oldWidget);
_handleSendButtonVisibilityModeChange();
}
@override
void dispose() {
_inputFocusNode.dispose();
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => GestureDetector(
onTap: () => _inputFocusNode.requestFocus(),
child: _inputBuilder(),
);
}
@immutable
class InputOptions {
const InputOptions({
this.inputClearMode = InputClearMode.always,
this.keyboardType = TextInputType.multiline,
this.onTextChanged,
this.onTextFieldTap,
this.textEditingController,
this.autocorrect = true,
this.autofocus = false,
this.enableSuggestions = true,
this.enabled = true,
});
/// Controls the [ChatInput] clear behavior. Defaults to [InputClearMode.always].
final InputClearMode inputClearMode;
/// Controls the [ChatInput] keyboard type. Defaults to [TextInputType.multiline].
final TextInputType keyboardType;
/// Will be called whenever the text inside [TextField] changes.
final void Function(String)? onTextChanged;
/// Will be called on [TextField] tap.
final VoidCallback? onTextFieldTap;
/// Custom [TextEditingController]. If not provided, defaults to the
/// [InputTextFieldController], which extends [TextEditingController] and has
/// additional fatures like markdown support. If you want to keep additional
/// features but still need some methods from the default [TextEditingController],
/// you can create your own [InputTextFieldController] (imported from this lib)
/// and pass it here.
final TextEditingController? textEditingController;
/// Controls the [TextInput] autocorrect behavior. Defaults to [true].
final bool autocorrect;
/// Whether [TextInput] should have focus. Defaults to [false].
final bool autofocus;
/// Controls the [TextInput] enableSuggestions behavior. Defaults to [true].
final bool enableSuggestions;
/// Controls the [TextInput] enabled behavior. Defaults to [true].
final bool enabled;
}
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
class AccessoryButton extends StatelessWidget {
const AccessoryButton({
required this.onSendPressed,
required this.onStopStreaming,
required this.isStreaming,
super.key,
class ChatInputAnchor extends ChatAnchor {
ChatInputAnchor({
required this.anchorKey,
required this.layerLink,
});
final void Function() onSendPressed;
final void Function() onStopStreaming;
final bool isStreaming;
@override
final GlobalKey<State<StatefulWidget>> anchorKey;
@override
Widget build(BuildContext context) {
if (isStreaming) {
return FlowyIconButton(
width: 36,
icon: FlowySvg(
FlowySvgs.ai_stream_stop_s,
size: const Size.square(28),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onStopStreaming,
radius: BorderRadius.circular(18),
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
);
} else {
return FlowyIconButton(
width: 36,
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(18),
icon: FlowySvg(
FlowySvgs.send_s,
size: const Size.square(24),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onSendPressed,
);
}
}
final LayerLink layerLink;
}
@@ -6,7 +6,6 @@ import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class RelatedQuestionList extends StatelessWidget {
const RelatedQuestionList({
required this.chatId,
@@ -97,6 +96,7 @@ class _RelatedQuestionItemState extends State<RelatedQuestionItem> {
style: TextStyle(
color: _isHovered ? Theme.of(context).colorScheme.primary : null,
fontSize: 14,
height: 1.5,
),
),
onTap: () {
@@ -5,7 +5,7 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'chat_input.dart';
import 'chat_input/chat_input.dart';
class ChatWelcomePage extends StatelessWidget {
ChatWelcomePage({required this.onSelectedQuestion, super.key});
@@ -1,11 +1,14 @@
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:markdown_widget/markdown_widget.dart';
import 'selectable_highlight.dart';
@@ -30,7 +33,11 @@ class AIMarkdownText extends StatelessWidget {
Widget build(BuildContext context) {
switch (type) {
case AIMarkdownType.appflowyEditor:
return _AppFlowyEditorMarkdown(markdown: markdown);
return BlocProvider(
create: (context) => DocumentPageStyleBloc(view: ViewPB())
..add(const DocumentPageStyleEvent.initial()),
child: _AppFlowyEditorMarkdown(markdown: markdown),
);
case AIMarkdownType.markdownWidget:
return _ThirdPartyMarkdown(markdown: markdown);
}
@@ -52,15 +59,6 @@ class _AppFlowyEditorMarkdown extends StatefulWidget {
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
late EditorState editorState;
late final styleCustomizer = EditorStyleCustomizer(
context: context,
padding: EdgeInsets.zero,
);
late final editorStyle = styleCustomizer.style().copyWith(
// hide the cursor
cursorColor: Colors.transparent,
cursorWidth: 0,
);
late EditorScrollController scrollController;
@override
@@ -99,6 +97,17 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
@override
Widget build(BuildContext context) {
// don't lazy load the styleCustomizer and blockBuilders,
// it needs the context to get the theme.
final styleCustomizer = EditorStyleCustomizer(
context: context,
padding: EdgeInsets.zero,
);
final editorStyle = styleCustomizer.style().copyWith(
// hide the cursor
cursorColor: Colors.transparent,
cursorWidth: 0,
);
final blockBuilders = getEditorBuilderMap(
context: context,
editorState: editorState,
@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart';
@@ -16,6 +14,7 @@ import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CalculateCell extends StatefulWidget {
@@ -141,11 +140,14 @@ class _CalculateCellState extends State<CalculateCell> {
TextSpan(
text: widget.calculation!.calculationType.shortLabel
.toUpperCase(),
style: context.tooltipTextStyle(),
),
const TextSpan(text: ' '),
TextSpan(
text: calculateValue,
style: const TextStyle(fontWeight: FontWeight.w500),
style: context
.tooltipTextStyle()
?.copyWith(fontWeight: FontWeight.w500),
),
],
),
@@ -6,7 +6,10 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/startup/startup.dart';
@@ -16,10 +19,17 @@ import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
const _excludeFromDropTarget = [
ImageBlockKeys.type,
CustomImageBlockKeys.type,
MultiImageBlockKeys.type,
];
class DocumentPage extends StatefulWidget {
const DocumentPage({
super.key,
@@ -125,15 +135,62 @@ class _DocumentPageState extends State<DocumentPage>
},
);
} else {
child = AppFlowyEditorPage(
editorState: state.editorState!,
styleCustomizer: EditorStyleCustomizer(
context: context,
// the 44 is the width of the left action list
padding: EditorStyleCustomizer.documentPadding,
child = DropTarget(
onDragExited: (_) =>
state.editorState!.selectionService.removeDropTarget(),
onDragUpdated: (details) {
final data = state.editorState!.selectionService
.getDropTargetRenderData(details.globalPosition);
if (data != null &&
data.dropTarget != null &&
// We implement custom Drop logic for image blocks, this is
// how we can exclude them from the Drop Target
!_excludeFromDropTarget.contains(data.cursorNode?.type)) {
// Render the drop target
state.editorState!.selectionService
.renderDropTargetForOffset(details.globalPosition);
} else {
state.editorState!.selectionService.removeDropTarget();
}
},
onDragDone: (details) async {
state.editorState!.selectionService.removeDropTarget();
final data = state.editorState!.selectionService
.getDropTargetRenderData(details.globalPosition);
if (data != null) {
if (data.cursorNode != null) {
if ([
ImageBlockKeys.type,
CustomImageBlockKeys.type,
MultiImageBlockKeys.type,
].contains(data.cursorNode?.type)) {
return;
}
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
await editorState!.dropImages(
data.dropTarget!,
details.files,
widget.view.id,
isLocalMode,
);
}
}
},
child: AppFlowyEditorPage(
editorState: state.editorState!,
styleCustomizer: EditorStyleCustomizer(
context: context,
// the 44 is the width of the left action list
padding: EditorStyleCustomizer.documentPadding,
),
header: _buildCoverAndIcon(context, state),
initialSelection: widget.initialSelection,
),
header: _buildCoverAndIcon(context, state),
initialSelection: widget.initialSelection,
);
}
@@ -1,17 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
Map<String, BlockComponentBuilder> getEditorBuilderMap({
@@ -107,13 +110,33 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
ImageBlockKeys.type: CustomImageBlockComponentBuilder(
configuration: configuration,
showMenu: true,
menuBuilder: (Node node, CustomImageBlockComponentState state) =>
Positioned(
menuBuilder: (node, state) => Positioned(
top: 10,
right: 10,
child: ImageMenu(node: node, state: state),
),
),
MultiImageBlockKeys.type: MultiImageBlockComponentBuilder(
configuration: configuration,
showMenu: true,
menuBuilder: (
Node node,
MultiImageBlockComponentState state,
ValueNotifier<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(
menuBuilder: (node, editorState, position, dir, onBuild, onClose) =>
TableMenu(
@@ -1,5 +1,8 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
@@ -28,8 +31,6 @@ import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
final codeBlockLocalization = CodeBlockLocalizations(
@@ -352,6 +353,10 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
},
child: VSpace(PlatformExtension.isDesktopOrWeb ? 200 : 400),
),
dropTargetStyle: AppFlowyDropTargetStyle(
color: Theme.of(context).colorScheme.primary.withOpacity(0.8),
margin: const EdgeInsets.only(left: 44),
),
),
);
@@ -415,6 +420,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
emojiMenuItem,
autoGeneratorMenuItem,
dateMenuItem,
multiImageMenuItem,
];
}
@@ -91,10 +91,8 @@ class _AlignmentButtonsState extends State<_AlignmentButtons> {
margin: const EdgeInsets.all(4),
direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onTertiary,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
decorationColor: Theme.of(context).colorScheme.onTertiary,
borderRadius: const BorderRadius.all(Radius.circular(4)),
popupBuilder: (_) {
keepEditorFocusNotifier.increase();
return _AlignButtons(onAlignChanged: widget.onAlignChanged);
@@ -3,12 +3,16 @@ import 'dart:typed_data';
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/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:cross_file/cross_file.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:path/path.dart' as p;
@@ -21,6 +25,42 @@ extension PasteFromImage on EditorState {
'gif',
];
Future<void> dropImages(
Node dropNode,
List<XFile> files,
String documentId,
bool isLocalMode,
) async {
final imageFiles = files.where(
(file) =>
file.mimeType?.startsWith('image/') ??
false || imgExtensionRegex.hasMatch(file.name),
);
for (final file in imageFiles) {
String? path;
CustomImageType? type;
if (isLocalMode) {
path = await saveImageToLocalStorage(file.path);
type = CustomImageType.local;
} else {
(path, _) = await saveImageToCloudStorage(file.path, documentId);
type = CustomImageType.internal;
}
if (path == null) {
continue;
}
final t = transaction
..insertNode(
dropNode.path,
customImageNode(url: path, type: type),
);
await apply(t);
}
}
Future<bool> pasteImage(
String format,
Uint8List imageBytes,
@@ -90,19 +90,22 @@ class _DocumentImmersiveCoverState extends State<DocumentImmersiveCover> {
);
}
return Stack(
children: [
_buildCover(context, state),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: iconAndTitle,
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Stack(
children: [
_buildCover(context, state),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: iconAndTitle,
),
),
),
],
],
),
);
},
),
@@ -1,5 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
@@ -9,9 +11,9 @@ import 'package:appflowy/plugins/base/icon/icon_picker.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/desktop_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
@@ -23,7 +25,6 @@ import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:string_validator/string_validator.dart';
@@ -482,9 +483,12 @@ class DocumentCoverState extends State<DocumentCover> {
UploadImageType.url,
UploadImageType.unsplash,
],
onSelectedLocalImage: (path) async {
onSelectedLocalImages: (paths) async {
context.pop();
widget.onChangeCover(CoverType.file, path);
widget.onChangeCover(
CoverType.file,
paths.first,
);
},
onSelectedAIImage: (_) {
throw UnimplementedError();
@@ -608,9 +612,9 @@ class DocumentCoverState extends State<DocumentCover> {
UploadImageType.url,
UploadImageType.unsplash,
],
onSelectedLocalImage: (path) {
onSelectedLocalImages: (paths) {
popoverController.close();
onCoverChanged(CoverType.file, path);
onCoverChanged(CoverType.file, paths.first);
},
onSelectedAIImage: (_) {
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/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage;
import 'package:easy_localization/easy_localization.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:string_validator/string_validator.dart';
import '../common.dart';
const kImagePlaceholderKey = 'imagePlaceholderKey';
enum CustomImageType {
local,
internal, // the images saved in self-host cloud
external; // the images linked from network, like unsplash, https://xxx/yyy/zzz.jpg
static CustomImageType fromIntValue(int value) {
switch (value) {
case 0:
return CustomImageType.local;
case 1:
return CustomImageType.internal;
case 2:
return CustomImageType.external;
default:
throw UnimplementedError();
}
}
int toIntValue() {
switch (this) {
case CustomImageType.local:
return 0;
case CustomImageType.internal:
return 1;
case CustomImageType.external:
return 2;
}
}
}
class CustomImageBlockKeys {
const CustomImageBlockKeys._();
@@ -84,6 +59,25 @@ class CustomImageBlockKeys {
static const String imageType = 'image_type';
}
Node customImageNode({
required String url,
String align = 'center',
double? height,
double? width,
CustomImageType type = CustomImageType.local,
}) {
return Node(
type: CustomImageBlockKeys.type,
attributes: {
CustomImageBlockKeys.url: url,
CustomImageBlockKeys.align: align,
CustomImageBlockKeys.height: height,
CustomImageBlockKeys.width: width,
CustomImageBlockKeys.imageType: type.toIntValue(),
},
);
}
typedef CustomImageBlockComponentMenuBuilder = Widget Function(
Node node,
CustomImageBlockComponentState state,
@@ -182,7 +176,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
);
} else if (imageType != CustomImageType.internal &&
!_checkIfURLIsValid(src)) {
child = const UnSupportImageWidget();
child = const UnsupportedImageWidget();
} else {
child = ResizableImage(
src: src,
@@ -191,11 +185,22 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
editable: editorState.editable,
alignment: alignment,
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) {
final transaction = editorState.transaction
..updateNode(node, {
CustomImageBlockKeys.width: width,
});
..updateNode(node, {CustomImageBlockKeys.width: width});
editorState.apply(transaction);
},
);
@@ -207,21 +212,11 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [
BlockSelectionType.block,
],
child: Padding(
key: imageKey,
padding: padding,
child: child,
),
supportTypes: const [BlockSelectionType.block],
child: Padding(key: imageKey, padding: padding, child: child),
);
} else {
child = Padding(
key: imageKey,
padding: padding,
child: child,
);
child = Padding(key: imageKey, padding: padding, child: child);
}
if (widget.showActions && widget.actionBuilder != null) {
@@ -246,7 +241,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
opaque: false,
child: ValueListenableBuilder<bool>(
valueListenable: showActionsNotifier,
builder: (context, value, child) {
builder: (_, value, child) {
final url = node.attributes[CustomImageBlockKeys.url];
return Stack(
children: [
@@ -259,10 +254,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
child: child!,
),
if (value && url.isNotEmpty == true)
widget.menuBuilder!(
widget.node,
this,
),
widget.menuBuilder!(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/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
class ImageMenu extends StatefulWidget {
const ImageMenu({
super.key,
required this.node,
required this.state,
});
const ImageMenu({super.key, required this.node, required this.state});
final Node node;
final CustomImageBlockComponentState state;
@@ -30,7 +31,7 @@ class ImageMenu extends StatefulWidget {
}
class _ImageMenuState extends State<ImageMenu> {
late final String? url = widget.node.attributes[ImageBlockKeys.url];
late final String? url = widget.node.attributes[CustomImageBlockKeys.url];
@override
Widget build(BuildContext context) {
@@ -50,6 +51,12 @@ class _ImageMenuState extends State<ImageMenu> {
),
child: Row(
children: [
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(),
iconData: FlowySvgs.full_view_s,
onTap: openFullScreen,
),
const HSpace(4),
// disable the copy link button if the image is hosted on appflowy cloud
// because the url needs the verification token to be accessible
@@ -61,10 +68,7 @@ class _ImageMenuState extends State<ImageMenu> {
),
const HSpace(4),
],
_ImageAlignButton(
node: widget.node,
state: widget.state,
),
_ImageAlignButton(node: widget.node, state: widget.state),
const _Divider(),
MenuBlockButton(
tooltip: LocaleKeys.button_delete.tr(),
@@ -95,13 +99,34 @@ class _ImageMenuState extends State<ImageMenu> {
transaction.afterSelection = null;
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 {
const _ImageAlignButton({
required this.node,
required this.state,
});
const _ImageAlignButton({required this.node, required this.state});
final Node node;
final CustomImageBlockComponentState state;
@@ -110,30 +135,28 @@ class _ImageAlignButton extends StatefulWidget {
State<_ImageAlignButton> createState() => _ImageAlignButtonState();
}
const interceptorKey = 'image-align';
const _interceptorKey = 'image-align';
class _ImageAlignButtonState extends State<_ImageAlignButton> {
final gestureInterceptor = SelectionGestureInterceptor(
key: interceptorKey,
key: _interceptorKey,
canTap: (details) => false,
);
String get align =>
widget.node.attributes[ImageBlockKeys.align] ?? centerAlignmentKey;
widget.node.attributes[CustomImageBlockKeys.align] ?? centerAlignmentKey;
final popoverController = PopoverController();
late final EditorState editorState;
@override
void initState() {
super.initState();
editorState = context.read<EditorState>();
}
@override
void dispose() {
allowMenuClose();
super.dispose();
}
@@ -153,9 +176,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
),
popupBuilder: (_) {
preventMenuClose();
return _AlignButtons(
onAlignChanged: onAlignChanged,
);
return _AlignButtons(onAlignChanged: onAlignChanged);
},
),
);
@@ -165,9 +186,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
popoverController.close();
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
ImageBlockKeys.align: align,
});
transaction.updateNode(widget.node, {CustomImageBlockKeys.align: align});
editorState.apply(transaction);
allowMenuClose();
@@ -183,7 +202,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
void allowMenuClose() {
widget.state.alwaysShowMenu = false;
editorState.service.selectionService.unregisterGestureInterceptor(
interceptorKey,
_interceptorKey,
);
}
@@ -201,9 +220,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
}
class _AlignButtons extends StatelessWidget {
const _AlignButtons({
required this.onAlignChanged,
});
const _AlignButtons({required this.onAlignChanged});
final Function(String align) onAlignChanged;
@@ -246,10 +263,7 @@ class _Divider extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Container(
width: 1,
color: Colors.grey,
),
child: Container(width: 1, color: Colors.grey),
);
}
}
@@ -1,14 +1,13 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
class UnSupportImageWidget extends StatelessWidget {
const UnSupportImageWidget({
super.key,
});
class UnsupportedImageWidget extends StatelessWidget {
const UnsupportedImageWidget({super.key});
@override
Widget build(BuildContext context) {
@@ -18,9 +17,7 @@ class UnSupportImageWidget extends StatelessWidget {
borderRadius: BorderRadius.circular(4),
),
child: FlowyHover(
style: HoverStyle(
borderRadius: BorderRadius.circular(4),
),
style: HoverStyle(borderRadius: BorderRadius.circular(4)),
child: SizedBox(
height: 52,
child: Row(
@@ -31,9 +28,7 @@ class UnSupportImageWidget extends StatelessWidget {
size: Size.square(24),
),
const HSpace(10),
FlowyText(
LocaleKeys.document_imageBlock_unableToLoadImage.tr(),
),
FlowyText(LocaleKeys.document_imageBlock_unableToLoadImage.tr()),
],
),
),
@@ -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:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
class MobileImagePickerScreen extends StatelessWidget {
const MobileImagePickerScreen({super.key});
static const routeName = '/image_picker';
@override
Widget build(BuildContext context) => const ImagePickerPage();
}
class ImagePickerPage extends StatelessWidget {
const ImagePickerPage({super.key});
@override
Widget build(BuildContext context) {
return const ImagePickerPage();
return Scaffold(
appBar: AppBar(
titleSpacing: 0,
title: FlowyText.semibold(
LocaleKeys.titleBar_pageIcon.tr(),
fontSize: 14.0,
),
leading: const AppBarBackButton(),
),
body: SafeArea(
child: UploadImageMenu(
onSubmitted: (_) {},
onUpload: (_) {},
),
),
);
}
}
@@ -1,24 +1,29 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log, UploadImageMenu;
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart';
@@ -26,10 +31,7 @@ import 'package:path/path.dart' as p;
import 'package:string_validator/string_validator.dart';
class ImagePlaceholder extends StatefulWidget {
const ImagePlaceholder({
super.key,
required this.node,
});
const ImagePlaceholder({super.key, required this.node});
final Node node;
@@ -45,12 +47,20 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
bool showLoading = false;
String? errorMessage;
bool isDraggingFiles = false;
@override
Widget build(BuildContext context) {
final Widget child = DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
border: isDraggingFiles
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: FlowyHover(
style: HoverStyle(
@@ -85,18 +95,23 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
clickHandler: PopoverClickHandler.gestureDetector,
popupBuilder: (context) {
return UploadImageMenu(
allowMultipleImages: true,
limitMaximumImageSize: !_isLocalMode(),
supportTypes: const [
UploadImageType.local,
UploadImageType.url,
UploadImageType.unsplash,
// UploadImageType.openAI,
UploadImageType.stabilityAI,
],
onSelectedLocalImage: (path) {
onSelectedLocalImages: (paths) {
controller.close();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
await insertLocalImage(path);
WidgetsBinding.instance.addPostFrameCallback((_) async {
final List<String> items = List.from(
paths.where((url) => url != null && url.isNotEmpty),
);
if (items.isNotEmpty) {
await insertMultipleLocalImages(items);
}
});
},
onSelectedAIImage: (url) {
@@ -113,7 +128,27 @@ class ImagePlaceholderState extends State<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 {
return MobileBlockActionButtons(
@@ -133,8 +168,11 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
List<Widget> _buildTrailing(BuildContext context) {
if (errorMessage != null) {
return [
FlowyText(
'${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}',
Flexible(
child: FlowyText(
'${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}',
maxLines: 3,
),
),
];
} else if (showLoading) {
@@ -147,8 +185,14 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
];
} else {
return [
FlowyText(
LocaleKeys.document_plugins_image_addAnImage.tr(),
Flexible(
child: FlowyText(
PlatformExtension.isDesktop
? isDraggingFiles
? LocaleKeys.document_plugins_image_dropImageToInsert.tr()
: LocaleKeys.document_plugins_image_addAnImageDesktop.tr()
: LocaleKeys.document_plugins_image_addAnImageMobile.tr(),
),
),
];
}
@@ -179,9 +223,14 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
UploadImageType.url,
UploadImageType.unsplash,
],
onSelectedLocalImage: (path) async {
onSelectedLocalImages: (paths) async {
context.pop();
await insertLocalImage(path);
final List<String> items = List.from(
paths.where((url) => url != null && url.isNotEmpty),
);
await insertMultipleLocalImages(items);
},
onSelectedAIImage: (url) async {
context.pop();
@@ -198,77 +247,102 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
}
}
Future<void> insertLocalImage(String? url) async {
Future<void> insertMultipleLocalImages(List<String> urls) async {
controller.close();
if (url == null || url.isEmpty) {
return;
}
final transaction = editorState.transaction;
String? path;
String? errorMessage;
CustomImageType imageType = CustomImageType.local;
// if the user is using local authenticator, we need to save the image to local storage
if (_isLocalMode()) {
// don't limit the image size for local mode.
path = await saveImageToLocalStorage(url);
} else {
final documentId = context.read<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(),
setState(() {
showLoading = true;
errorMessage = null;
});
await editorState.apply(transaction);
bool hasError = false;
if (_isLocalMode()) {
final first = urls.removeAt(0);
final firstPath = await saveImageToLocalStorage(first);
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
CustomImageBlockKeys.url: firstPath,
CustomImageBlockKeys.imageType: CustomImageType.local.toIntValue(),
});
if (urls.isNotEmpty) {
// Create new nodes for the rest of the images:
final paths = await Future.wait(urls.map(saveImageToLocalStorage));
paths.removeWhere((url) => url == null || url.isEmpty);
transaction.insertNodes(
widget.node.path.next,
paths.map((url) => customImageNode(url: url!)).toList(),
);
}
await editorState.apply(transaction);
} else {
final transaction = editorState.transaction;
bool isFirst = true;
for (final url in urls) {
// Upload to cloud
final (path, error) = await saveImageToCloudStorage(
url,
context.read<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 {
if (url.isEmpty || !isURL(url)) {
// show error
showSnackBarMessage(
return showSnackBarMessage(
context,
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
);
return;
}
final path = await getIt<ApplicationDataStorage>().getPath();
final imagePath = p.join(
path,
'images',
);
final imagePath = p.join(path, 'images');
try {
// create the directory if not exists
final directory = Directory(imagePath);
@@ -283,7 +357,7 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
final response = await get(uri);
await File(copyToPath).writeAsBytes(response.bodyBytes);
await insertLocalImage(copyToPath);
await insertMultipleLocalImages([copyToPath]);
await File(copyToPath).delete();
} catch (e) {
Log.error('cannot save image file', e);
@@ -293,16 +367,16 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
Future<void> insertNetworkImage(String url) async {
if (url.isEmpty || !isURL(url)) {
// show error
showSnackBarMessage(
return showSnackBarMessage(
context,
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
);
return;
}
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
ImageBlockKeys.url: url,
CustomImageBlockKeys.url: url,
CustomImageBlockKeys.imageType: CustomImageType.external.toIntValue(),
});
await editorState.apply(transaction);
}
@@ -1,27 +1,54 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
final customImageMenuItem = SelectionMenuItem(
getName: () => AppFlowyEditorL10n.current.image,
icon: (editorState, isSelected, style) => SelectionMenuIconWidget(
icon: (_, isSelected, style) => SelectionMenuIconWidget(
name: 'image',
isSelected: isSelected,
style: style,
),
keywords: ['image', 'picture', 'img', 'photo'],
handler: (editorState, menuService, context) async {
handler: (editorState, _, __) async {
// use the key to retrieve the state of the image block to show the popover automatically
final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>();
await editorState.insertEmptyImageBlock(imagePlaceholderKey);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
WidgetsBinding.instance.addPostFrameCallback((_) {
imagePlaceholderKey.currentState?.controller.show();
});
},
);
final multiImageMenuItem = SelectionMenuItem(
getName: () => LocaleKeys.document_plugins_photoGallery_name.tr(),
icon: (_, isSelected, style) => SelectionMenuIconWidget(
icon: Icons.photo_library_outlined,
isSelected: isSelected,
style: style,
),
keywords: [
LocaleKeys.document_plugins_photoGallery_imageKeyword.tr(),
LocaleKeys.document_plugins_photoGallery_imageGalleryKeyword.tr(),
LocaleKeys.document_plugins_photoGallery_photoKeyword.tr(),
LocaleKeys.document_plugins_photoGallery_photoBrowserKeyword.tr(),
LocaleKeys.document_plugins_photoGallery_galleryKeyword.tr(),
],
handler: (editorState, _, __) async {
final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>();
await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey);
WidgetsBinding.instance.addPostFrameCallback(
(_) => imagePlaceholderKey.currentState?.controller.show(),
);
},
);
extension InsertImage on EditorState {
Future<void> insertEmptyImageBlock(GlobalKey key) async {
final selection = this.selection;
@@ -33,31 +60,49 @@ extension InsertImage on EditorState {
return;
}
final emptyImage = imageNode(url: '')
..extraInfos = {
kImagePlaceholderKey: key,
};
..extraInfos = {kImagePlaceholderKey: key};
final transaction = this.transaction;
// if the current node is empty paragraph, replace it with image node
if (node.type == ParagraphBlockKeys.type &&
(node.delta?.isEmpty ?? false)) {
transaction
..insertNode(
node.path,
emptyImage,
)
..insertNode(node.path, emptyImage)
..deleteNode(node);
} else {
transaction.insertNode(
node.path.next,
emptyImage,
);
transaction.insertNode(node.path.next, emptyImage);
}
transaction.afterSelection = Selection.collapsed(
Position(
path: node.path.next,
),
);
transaction.afterSelection =
Selection.collapsed(Position(path: node.path.next));
transaction.selectionExtraInfo = {};
return apply(transaction);
}
Future<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 = {};
return apply(transaction);
@@ -1,15 +1,20 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/shared/custom_image_cache_manager.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/file_extension.dart';
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/dispatch/error.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:path/path.dart' as p;
Future<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/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
final imageMobileToolbarItem = MobileToolbarItem.action(
itemIconBuilder: (_, __, ___) => const FlowySvg(FlowySvgs.m_toolbar_imae_lg),
@@ -10,7 +11,7 @@ final imageMobileToolbarItem = MobileToolbarItem.action(
final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>();
await editorState.insertEmptyImageBlock(imagePlaceholderKey);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
WidgetsBinding.instance.addPostFrameCallback((_) {
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:math';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:string_validator/string_validator.dart';
@@ -23,6 +24,7 @@ class ResizableImage extends StatefulWidget {
required this.width,
required this.src,
this.height,
this.onDoubleTap,
});
final String src;
@@ -31,6 +33,7 @@ class ResizableImage extends StatefulWidget {
final double? height;
final Alignment alignment;
final bool editable;
final VoidCallback? onDoubleTap;
final void Function(double width) onResize;
@@ -41,26 +44,23 @@ class ResizableImage extends StatefulWidget {
const _kImageBlockComponentMinWidth = 30.0;
class _ResizableImageState extends State<ResizableImage> {
late double imageWidth;
final documentService = DocumentService();
double initialOffset = 0;
double moveDistance = 0;
Widget? _cacheImage;
late double imageWidth;
@visibleForTesting
bool onFocus = false;
final documentService = DocumentService();
UserProfilePB? _userProfilePB;
@override
void initState() {
super.initState();
imageWidth = widget.width;
_userProfilePB = context.read<DocumentBloc>().state.userProfilePB;
}
@@ -72,13 +72,12 @@ class _ResizableImageState extends State<ResizableImage> {
width: max(_kImageBlockComponentMinWidth, imageWidth - moveDistance),
height: widget.height,
child: MouseRegion(
onEnter: (event) => setState(() {
onFocus = true;
}),
onExit: (event) => setState(() {
onFocus = false;
}),
child: _buildResizableImage(context),
onEnter: (_) => setState(() => onFocus = true),
onExit: (_) => setState(() => onFocus = false),
child: GestureDetector(
onDoubleTap: widget.onDoubleTap,
child: _buildResizableImage(context),
),
),
),
);
@@ -97,12 +96,11 @@ class _ResizableImageState extends State<ResizableImage> {
url: widget.src,
width: imageWidth - moveDistance,
userProfilePB: _userProfilePB,
errorWidgetBuilder: (context, url, error) => _ImageLoadFailedWidget(
progressIndicatorBuilder: (context, _, __) => _buildLoading(context),
errorWidgetBuilder: (_, __, error) => _ImageLoadFailedWidget(
width: imageWidth,
error: error,
),
progressIndicatorBuilder: (context, url, progress) =>
_buildLoading(context),
);
child = _cacheImage!;
@@ -121,11 +119,7 @@ class _ResizableImageState extends State<ResizableImage> {
left: 5,
bottom: 0,
width: 5,
onUpdate: (distance) {
setState(() {
moveDistance = distance;
});
},
onUpdate: (distance) => setState(() => moveDistance = distance),
),
_buildEdgeGesture(
context,
@@ -133,11 +127,7 @@ class _ResizableImageState extends State<ResizableImage> {
right: 5,
bottom: 0,
width: 5,
onUpdate: (distance) {
setState(() {
moveDistance = -distance;
});
},
onUpdate: (distance) => setState(() => moveDistance = -distance),
),
],
],
@@ -154,9 +144,7 @@ class _ResizableImageState extends State<ResizableImage> {
size: const Size(18, 18),
child: const CircularProgressIndicator(),
),
SizedBox.fromSize(
size: const Size(10, 10),
),
SizedBox.fromSize(size: const Size(10, 10)),
Text(AppFlowyEditorL10n.current.loading),
],
),
@@ -184,7 +172,7 @@ class _ResizableImageState extends State<ResizableImage> {
},
onHorizontalDragUpdate: (details) {
if (onUpdate != null) {
var offset = details.globalPosition.dx - initialOffset;
double offset = details.globalPosition.dx - initialOffset;
if (widget.alignment == Alignment.center) {
offset *= 2.0;
}
@@ -222,10 +210,7 @@ class _ResizableImageState extends State<ResizableImage> {
}
class _ImageLoadFailedWidget extends StatelessWidget {
const _ImageLoadFailedWidget({
required this.width,
required this.error,
});
const _ImageLoadFailedWidget({required this.width, required this.error});
final double width;
final Object error;
@@ -240,9 +225,7 @@ class _ImageLoadFailedWidget extends StatelessWidget {
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
border: Border.all(
color: Colors.grey.withOpacity(0.6),
),
border: Border.all(color: Colors.grey.withOpacity(0.6)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -251,9 +234,7 @@ class _ImageLoadFailedWidget extends StatelessWidget {
FlowySvgs.broken_image_xl,
size: Size.square(48),
),
FlowyText(
AppFlowyEditorL10n.current.imageLoadFailed,
),
FlowyText(AppFlowyEditorL10n.current.imageLoadFailed),
const VSpace(6),
if (error != null)
FlowyText(
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:unsplash_client/unsplash_client.dart';
const _accessKeyA = 'YyD-LbW5bVolHWZBq5fWRM_';
@@ -48,7 +49,6 @@ class _UnsplashImageWidgetState extends State<UnsplashImageWidget> {
@override
void initState() {
super.initState();
randomPhotos = unsplash.photos
.random(count: 18, orientation: PhotoOrientation.landscape)
.goAndGet();
@@ -57,7 +57,6 @@ class _UnsplashImageWidgetState extends State<UnsplashImageWidget> {
@override
void dispose() {
unsplash.close();
super.dispose();
}
@@ -132,18 +131,16 @@ class _UnsplashImagesState extends State<_UnsplashImages> {
@override
Widget build(BuildContext context) {
const mainAxisSpacing = 16.0;
final crossAxisCount = switch (widget.type) {
UnsplashImageType.halfScreen => 3,
UnsplashImageType.fullScreen => 2,
};
final mainAxisSpacing = switch (widget.type) {
UnsplashImageType.halfScreen => 16.0,
UnsplashImageType.fullScreen => 16.0,
};
final crossAxisSpacing = switch (widget.type) {
UnsplashImageType.halfScreen => 10.0,
UnsplashImageType.fullScreen => 16.0,
};
return GridView.count(
crossAxisCount: crossAxisCount,
mainAxisSpacing: mainAxisSpacing,
@@ -155,15 +152,11 @@ class _UnsplashImagesState extends State<_UnsplashImages> {
return _UnsplashImage(
type: widget.type,
photo: photo,
onTap: () {
widget.onSelectUnsplashImage(
photo.urls.regular.toString(),
);
setState(() {
_selectedPhotoIndex = index;
});
},
isSelected: index == _selectedPhotoIndex,
onTap: () {
widget.onSelectUnsplashImage(photo.urls.regular.toString());
setState(() => _selectedPhotoIndex = index);
},
);
}).toList(),
);
@@ -219,10 +212,7 @@ class _UnsplashImage extends StatelessWidget {
),
),
const HSpace(2.0),
FlowyText(
'by ${photo.name}',
fontSize: 10.0,
),
FlowyText('by ${photo.name}', fontSize: 10.0),
],
);
}
@@ -233,14 +223,12 @@ class _UnsplashImage extends StatelessWidget {
child: Stack(
children: [
LayoutBuilder(
builder: (context, constraints) {
return Image.network(
photo.urls.thumb.toString(),
fit: BoxFit.cover,
width: constraints.maxWidth,
height: constraints.maxHeight,
);
},
builder: (_, constraints) => Image.network(
photo.urls.thumb.toString(),
fit: BoxFit.cover,
width: constraints.maxWidth,
height: constraints.maxHeight,
),
),
Positioned(
bottom: 9,
@@ -261,13 +249,9 @@ extension on Photo {
String get name {
if (user.username.isNotEmpty) {
return user.username;
}
if (user.name.isNotEmpty) {
} else if (user.name.isNotEmpty) {
return user.name;
}
if (user.email?.isNotEmpty == true) {
} else if (user.email?.isNotEmpty == true) {
return user.email!;
}
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/stability_ai_image_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorOption;
@@ -11,7 +12,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'widgets/embed_image_url_widget.dart';
enum UploadImageType {
local,
@@ -42,20 +44,22 @@ enum UploadImageType {
class UploadImageMenu extends StatefulWidget {
const UploadImageMenu({
super.key,
required this.onSelectedLocalImage,
required this.onSelectedLocalImages,
required this.onSelectedAIImage,
required this.onSelectedNetworkImage,
this.onSelectedColor,
this.supportTypes = UploadImageType.values,
this.limitMaximumImageSize = false,
this.allowMultipleImages = false,
});
final void Function(String? path) onSelectedLocalImage;
final void Function(List<String?>) onSelectedLocalImages;
final void Function(String url) onSelectedAIImage;
final void Function(String url) onSelectedNetworkImage;
final void Function(String color)? onSelectedColor;
final List<UploadImageType> supportTypes;
final bool limitMaximumImageSize;
final bool allowMultipleImages;
@override
State<UploadImageMenu> createState() => _UploadImageMenuState();
@@ -133,9 +137,7 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
},
).toList(),
),
const Divider(
height: 2,
),
const Divider(height: 2),
_buildTab(),
],
),
@@ -155,7 +157,8 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
child: Column(
children: [
UploadImageFileWidget(
onPickFile: widget.onSelectedLocalImage,
allowMultipleImages: widget.allowMultipleImages,
onPickFiles: widget.onSelectedLocalImages,
),
if (widget.limitMaximumImageSize) ...[
const VSpace(6.0),
@@ -185,30 +188,13 @@ class _UploadImageMenuState extends State<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:
return supportStabilityAI
? Expanded(
child: Container(
padding: const EdgeInsets.all(8.0),
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/shared/permission/permission_checker.dart';
import 'package:appflowy/startup/startup.dart';
@@ -7,18 +9,19 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
class UploadImageFileWidget extends StatelessWidget {
const UploadImageFileWidget({
super.key,
required this.onPickFile,
required this.onPickFiles,
this.allowedExtensions = const ['jpg', 'png', 'jpeg'],
this.allowMultipleImages = false,
});
final void Function(String? path) onPickFile;
final void Function(List<String?>) onPickFiles;
final List<String> allowedExtensions;
final bool allowMultipleImages;
@override
Widget build(BuildContext context) {
@@ -35,9 +38,7 @@ class UploadImageFileWidget extends StatelessWidget {
);
if (PlatformExtension.isDesktopOrWeb) {
return FlowyHover(
child: child,
);
return FlowyHover(child: child);
}
return child;
@@ -50,8 +51,9 @@ class UploadImageFileWidget extends StatelessWidget {
dialogTitle: '',
type: FileType.custom,
allowedExtensions: allowedExtensions,
allowMultiple: allowMultipleImages,
);
onPickFile(result?.files.firstOrNull?.path);
onPickFiles(result?.files.map((f) => f.path).toList() ?? const []);
} else {
final photoPermission =
await PermissionChecker.checkPhotoPermission(context);
@@ -60,8 +62,8 @@ class UploadImageFileWidget extends StatelessWidget {
return;
}
// on mobile, the users can pick a image file from camera or image library
final result = await ImagePicker().pickImage(source: ImageSource.gallery);
onPickFile(result?.path);
final result = await ImagePicker().pickMultiImage();
onPickFiles(result.map((f) => f.path).toList());
}
}
}
@@ -1,3 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
@@ -7,10 +10,10 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../image/custom_image_block_component/custom_image_block_component.dart';
class LinkPreviewMenu extends StatefulWidget {
const LinkPreviewMenu({
super.key,
@@ -72,7 +75,7 @@ class _LinkPreviewMenuState extends State<LinkPreviewMenu> {
}
void copyImageLink() {
final url = widget.node.attributes[ImageBlockKeys.url];
final url = widget.node.attributes[CustomImageBlockKeys.url];
if (url != null) {
Clipboard.setData(ClipboardData(text: url));
showSnackBarMessage(
@@ -75,7 +75,7 @@ class FontFamilyItem extends StatelessWidget {
}
});
},
text: (fontFamily ?? systemFonFamily).parseFontFamilyName(),
text: (fontFamily ?? systemFonFamily).fontFamilyDisplayName,
fontFamily: fontFamily ?? systemFonFamily,
backgroundColor: theme.toolbarMenuItemBackgroundColor,
isSelected: false,
@@ -7,6 +7,8 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart';
@@ -196,6 +198,19 @@ class _AddBlockMenu extends StatelessWidget {
});
},
),
TypeOptionMenuItemValue(
value: MultiImageBlockKeys.type,
backgroundColor: colorMap[ImageBlockKeys.type]!,
text: LocaleKeys.document_plugins_photoGallery_name.tr(),
icon: FlowySvgs.m_add_block_photo_gallery_s,
onTap: (_, __) async {
AppGlobals.rootNavKey.currentContext?.pop(true);
Future.delayed(const Duration(milliseconds: 400), () async {
final imagePlaceholderKey = GlobalKey<MultiImagePlaceholderState>();
await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey);
});
},
),
// date
TypeOptionMenuItemValue(
@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart';
@@ -13,6 +11,7 @@ import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'ai_limit_dialog.dart';
@@ -178,6 +177,7 @@ class _AutoCompletionBlockComponentState
await editorState.apply(
transaction,
options: const ApplyOptions(recordUndo: false),
withUpdateSelection: false,
);
}
@@ -12,7 +12,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/decoration.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
@@ -118,10 +117,6 @@ class _SmartEditBlockComponentWidgetState
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
constraints: BoxConstraints(maxWidth: width),
decoration: FlowyDecoration.decoration(
Colors.transparent,
Colors.transparent,
),
child: const SizedBox(
width: double.infinity,
),
@@ -1,5 +1,7 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import '../image/custom_image_block_component/custom_image_block_component.dart';
class CustomImageNodeParser extends NodeParser {
const CustomImageNodeParser();
@@ -9,7 +11,7 @@ class CustomImageNodeParser extends NodeParser {
@override
String transform(Node node, DocumentMarkdownEncoder? encoder) {
assert(node.children.isEmpty);
final url = node.attributes[ImageBlockKeys.url];
final url = node.attributes[CustomImageBlockKeys.url];
assert(url != null);
return '![]($url)\n';
}
@@ -19,7 +19,8 @@ export 'font/customize_font_toolbar_item.dart';
export 'header/cover_editor_bloc.dart';
export 'header/custom_cover_picker.dart';
export 'header/document_header_node_widget.dart';
export 'image/image_menu.dart';
export 'image/custom_image_block_component/image_menu.dart';
export 'image/multi_image_block_component/multi_image_menu.dart';
export 'image/image_selection_menu.dart';
export 'image/mobile_image_toolbar_item.dart';
export 'inline_math_equation/inline_math_equation.dart';
@@ -91,7 +91,7 @@ enum FeatureFlag {
bool get isOn {
if ([
// FeatureFlag.planBilling,
FeatureFlag.planBilling,
// release this feature in version 0.6.1
FeatureFlag.spaceDesign,
// release this feature in version 0.5.9
@@ -13,6 +13,9 @@ const _imgUrlPattern =
r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm)(\?[^\s[",><]*)?';
final imgUrlRegex = RegExp(_imgUrlPattern);
const _imgExtensionPattern = r'\.(gif|jpe?g|tiff?|png|webp|bmp)$';
final imgExtensionRegex = RegExp(_imgExtensionPattern);
/// This pattern allows for both HTTP and HTTPS Scheme
/// It allows for query parameters
/// It only allows the following video extensions:
@@ -1,8 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/shared/feature_flags.dart';
@@ -24,8 +21,11 @@ import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme.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:go_router/go_router.dart';
import 'package:toastification/toastification.dart';
import 'prelude.dart';
@@ -197,31 +197,33 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
builder: (context, state) {
_setSystemOverlayStyle(state);
return MaterialApp.router(
builder: (context, child) => MediaQuery(
// use the 1.0 as the textScaleFactor to avoid the text size
// affected by the system setting.
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(state.textScaleFactor),
),
child: overlayManagerBuilder(
context,
!PlatformExtension.isMobile && FeatureFlag.search.isOn
? CommandPalette(
notifier: _commandPaletteNotifier,
child: child,
)
: child,
return ToastificationWrapper(
child: MaterialApp.router(
builder: (context, child) => MediaQuery(
// use the 1.0 as the textScaleFactor to avoid the text size
// affected by the system setting.
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(state.textScaleFactor),
),
child: overlayManagerBuilder(
context,
!PlatformExtension.isMobile && FeatureFlag.search.isOn
? CommandPalette(
notifier: _commandPaletteNotifier,
child: child,
)
: child,
),
),
debugShowCheckedModeBanner: false,
theme: state.lightTheme,
darkTheme: state.darkTheme,
themeMode: state.themeMode,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: state.locale,
routerConfig: routerConfig,
),
debugShowCheckedModeBanner: false,
theme: state.lightTheme,
darkTheme: state.darkTheme,
themeMode: state.themeMode,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: state.locale,
routerConfig: routerConfig,
);
},
),
@@ -35,7 +35,7 @@ class WindowSizeManager {
Future<Size> getSize() async {
final defaultWindowSize = jsonEncode(
{WindowSizeManager.height: 600.0, WindowSizeManager.width: 800.0},
{WindowSizeManager.height: minWindowHeight, WindowSizeManager.width: minWindowWidth},
);
final windowSize = await getIt<KeyValueStorage>().get(KVKeys.windowSize);
final size = json.decode(
@@ -10,6 +10,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
/// Only used for testing.
class AppFlowyCloudMockAuthService implements AuthService {
@@ -64,12 +65,18 @@ class AppFlowyCloudMockAuthService implements AuthService {
);
Log.info("UserEventOauthSignIn with payload: $payload");
return UserEventOauthSignIn(payload).send().then((value) {
value.fold((l) => null, (err) => Log.error(err));
value.fold(
(l) => null,
(err) {
debugPrint("Error: $err");
Log.error(err);
},
);
return value;
});
},
(r) {
Log.error(r);
debugPrint("Error: $r");
return FlowyResult.failure(r);
},
);
@@ -0,0 +1,69 @@
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'local_ai_on_boarding_bloc.freezed.dart';
class LocalAIOnBoardingBloc
extends Bloc<LocalAIOnBoardingEvent, LocalAIOnBoardingState> {
LocalAIOnBoardingBloc(this.workspaceId)
: super(const LocalAIOnBoardingState()) {
_dispatch();
}
final String workspaceId;
void _dispatch() {
on<LocalAIOnBoardingEvent>((event, emit) {
event.when(
started: () {
_loadSubscriptionPlans();
},
didGetSubscriptionPlans: (result) {
result.fold(
(workspaceSubInfo) {
final isPurchaseAILocal = workspaceSubInfo.addOns.any((addOn) {
return addOn.type == WorkspaceAddOnPBType.AddOnAiLocal;
});
emit(
state.copyWith(isPurchaseAILocal: isPurchaseAILocal),
);
},
(err) {
Log.error("Failed to get subscription plans: $err");
},
);
},
);
});
}
void _loadSubscriptionPlans() {
final payload = UserWorkspaceIdPB()..workspaceId = workspaceId;
UserEventGetWorkspaceSubscriptionInfo(payload).send().then((result) {
if (!isClosed) {
add(LocalAIOnBoardingEvent.didGetSubscriptionPlans(result));
}
});
}
}
@freezed
class LocalAIOnBoardingEvent with _$LocalAIOnBoardingEvent {
const factory LocalAIOnBoardingEvent.started() = _Started;
const factory LocalAIOnBoardingEvent.didGetSubscriptionPlans(
FlowyResult<WorkspaceSubscriptionInfoPB, FlowyError> result,
) = _LoadSubscriptionPlans;
}
@freezed
class LocalAIOnBoardingState with _$LocalAIOnBoardingState {
const factory LocalAIOnBoardingState({
@Default(false) bool isPurchaseAILocal,
}) = _LocalAIOnBoardingState;
}
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
@@ -67,8 +68,20 @@ class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
break;
}
},
restartLocalAI: () {
ChatEventRestartLocalAIChat().send();
restartLocalAI: () async {
emit(
const PluginStateState(action: PluginStateAction.loadingPlugin()),
);
unawaited(ChatEventRestartLocalAIChat().send());
},
openModelDirectory: () async {
final result = await ChatEventGetModelStorageDirectory().send();
result.fold(
(data) {
afLaunchUrl(Uri.file(data.filePath));
},
(err) => Log.error(err.toString()),
);
},
);
}
@@ -80,12 +93,15 @@ class PluginStateEvent with _$PluginStateEvent {
const factory PluginStateEvent.updateState(LocalAIPluginStatePB pluginState) =
_UpdatePluginState;
const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI;
const factory PluginStateEvent.openModelDirectory() =
_OpenModelStorageDirectory;
}
@freezed
class PluginStateState with _$PluginStateState {
const factory PluginStateState({required PluginStateAction action}) =
_PluginStateState;
const factory PluginStateState({
required PluginStateAction action,
}) = _PluginStateState;
}
@freezed
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
class DesktopAppearance extends BaseAppearance {
@override
@@ -75,18 +74,12 @@ class DesktopAppearance extends BaseAppearance {
contentTextStyle: TextStyle(color: colorScheme.onSurface),
),
scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.any(scrollbarInteractiveStates.contains)) {
return theme.shader3;
}
return theme.shader5;
}),
thickness: WidgetStateProperty.resolveWith((states) {
if (states.any(scrollbarInteractiveStates.contains)) {
return 4;
}
return 3.0;
}),
thumbColor: WidgetStateProperty.resolveWith(
(states) => states.any(scrollbarInteractiveStates.contains)
? theme.scrollbarHoverColor
: theme.scrollbarColor,
),
thickness: WidgetStateProperty.resolveWith((_) => 4.0),
crossAxisMargin: 0.0,
mainAxisMargin: 6.0,
radius: Corners.s10Radius,
@@ -147,6 +140,9 @@ class DesktopAppearance extends BaseAppearance {
),
onBackground: theme.text,
background: theme.surface,
borderColor: theme.borderColor,
scrollbarColor: theme.scrollbarColor,
scrollbarHoverColor: theme.scrollbarHoverColor,
),
],
);
@@ -1,11 +1,10 @@
import 'package:flutter/material.dart';
// ThemeData in mobile
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart';
import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
class MobileAppearance extends BaseAppearance {
static const _primaryColor = Color(0xFF00BCF0); //primary 100
@@ -276,6 +275,9 @@ class MobileAppearance extends BaseAppearance {
),
onBackground: onBackground,
background: background,
borderColor: theme.borderColor,
scrollbarColor: theme.scrollbarColor,
scrollbarHoverColor: theme.scrollbarHoverColor,
),
ToolbarColorExtension.fromBrightness(brightness),
],
@@ -48,7 +48,7 @@ class ViewExtKeys {
}
extension ViewExtension on ViewPB {
Widget defaultIcon() => FlowySvg(
Widget defaultIcon({Size? size}) => FlowySvg(
switch (layout) {
ViewLayoutPB.Board => FlowySvgs.icon_board_s,
ViewLayoutPB.Calendar => FlowySvgs.icon_calendar_s,
@@ -57,6 +57,7 @@ extension ViewExtension on ViewPB {
ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s,
_ => FlowySvgs.document_s,
},
size: size,
);
PluginType get pluginType => switch (layout) {
@@ -1,6 +1,3 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/blank/blank.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
@@ -27,12 +24,13 @@ import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
show UserProfilePB;
import 'package:flowy_infra_ui/style_widget/container.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:sized_context/sized_context.dart';
import 'package:styled_widget/styled_widget.dart';
import '../widgets/edit_panel/edit_panel.dart';
import '../widgets/sidebar_resizer.dart';
import 'home_layout.dart';
import 'home_stack.dart';
@@ -164,7 +162,9 @@ class DesktopHomeScreen extends StatelessWidget {
userProfile: userProfile,
workspaceSetting: workspaceSetting,
);
final homeMenuResizer = _buildHomeMenuResizer(context, layout: layout);
final homeMenuResizer =
layout.showMenu ? const SidebarResizer() : const SizedBox.shrink();
final editPanel = _buildEditPanel(context, layout: layout);
return _layoutWidgets(
@@ -218,39 +218,6 @@ class DesktopHomeScreen extends StatelessWidget {
);
}
Widget _buildHomeMenuResizer(
BuildContext context, {
required HomeLayout layout,
}) {
if (!layout.showMenu) {
return const SizedBox.shrink();
}
return MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
child: GestureDetector(
dragStartBehavior: DragStartBehavior.down,
onHorizontalDragStart: (details) => context
.read<HomeSettingBloc>()
.add(const HomeSettingEvent.editPanelResizeStart()),
onHorizontalDragUpdate: (details) => context
.read<HomeSettingBloc>()
.add(HomeSettingEvent.editPanelResized(details.localPosition.dx)),
onHorizontalDragEnd: (details) => context
.read<HomeSettingBloc>()
.add(const HomeSettingEvent.editPanelResizeEnd()),
onHorizontalDragCancel: () => context
.read<HomeSettingBloc>()
.add(const HomeSettingEvent.editPanelResizeEnd()),
behavior: HitTestBehavior.translucent,
child: SizedBox(
width: 10,
height: MediaQuery.of(context).size.height,
),
),
);
}
Widget _layoutWidgets({
required HomeLayout layout,
required Widget sidebar,
@@ -296,7 +263,7 @@ class DesktopHomeScreen extends StatelessWidget {
)
.positioned(left: 0, top: 0, width: layout.menuWidth, bottom: 0),
homeMenuResizer
.positioned(left: layout.menuWidth - 5)
.positioned(left: layout.menuWidth)
.animate(layout.animDuration, Curves.easeOutQuad),
],
);
@@ -1,4 +1,5 @@
import 'dart:io' show Platform;
import 'dart:math';
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
import 'package:flowy_infra/size.dart';
@@ -16,6 +17,8 @@ class HomeLayout {
menuWidth = Sizes.sideBarWidth;
menuWidth += homeSetting.resizeOffset;
menuWidth = max(menuWidth, HomeSizes.minimumSidebarWidth);
final screenWidthPx = context.widthPx;
context
.read<HomeSettingBloc>()
@@ -8,6 +8,7 @@ class HomeSizes {
static const double workspaceSectionHeight = 32;
static const double searchSectionHeight = 30;
static const double newPageSectionHeight = 30;
static const double minimumSidebarWidth = 268;
}
class HomeInsets {
@@ -1,7 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/app_window_size_manager.dart';
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
@@ -11,6 +9,7 @@ import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:flutter/material.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:provider/provider.dart';
import 'package:scaled_app/scaled_app.dart';
@@ -18,6 +17,7 @@ import 'package:scaled_app/scaled_app.dart';
typedef KeyDownHandler = void Function(HotKey hotKey);
ValueNotifier<int> switchToTheNextSpace = ValueNotifier(0);
ValueNotifier<int> createNewPageNotifier = ValueNotifier(0);
/// Helper class that utilizes the global [HotKeyManager] to easily
/// add a [HotKey] with different handlers.
@@ -180,6 +180,16 @@ class _HomeHotKeysState extends State<HomeHotKeys> {
keyDownHandler: (_) => switchToTheNextSpace.value++,
),
// Create a new page
HotKeyItem(
hotKey: HotKey(
KeyCode.keyN,
modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control],
scope: HotKeyScope.inapp,
),
keyDownHandler: (_) => createNewPageNotifier.value++,
),
// Open settings dialog
openSettingsHotKey(context, widget.userProfile),
];
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff. Show more