Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca195887e0 | ||
|
|
f9a5458b94 | ||
|
|
244d072a65 | ||
|
|
d9bc97e012 | ||
|
|
e7491e5182 | ||
|
|
550b8835c6 | ||
|
|
1851721d9a | ||
|
|
603d65a790 | ||
|
|
0cba3f9e3f | ||
|
|
81960a7f05 | ||
|
|
62c4a8c541 | ||
|
|
510d8357ee | ||
|
|
4c6f6f14f3 | ||
|
|
27af57289f | ||
|
|
17aa8c9036 | ||
|
|
c910bda534 | ||
|
|
8d01b96281 | ||
|
|
55f12d5358 | ||
|
|
018c146d72 | ||
|
|
068ac0e992 | ||
|
|
b036129efb | ||
|
|
b3c8eb151a | ||
|
|
e86d584ea7 | ||
|
|
e0226e54a5 | ||
|
|
bde1457524 | ||
|
|
2ad2a79bd0 | ||
|
|
f013bb9d6e | ||
|
|
1b4a723500 | ||
|
|
b5d2af3371 | ||
|
|
4205a34f04 | ||
|
|
e86a9d697c | ||
|
|
f82dabcc75 | ||
|
|
09717d92c5 |
No files matched your search
+9
-1
@@ -1,5 +1,13 @@
|
||||
# Release Notes
|
||||
# Release Notes
|
||||
## Version 0.7.5 - 25/11/2024
|
||||
### Bug Fixes
|
||||
- Improved chat response parsing
|
||||
- Fixed toggle list icon direction for RTL mode
|
||||
- Fixed cross blocks formatting not reflecting in float toolbar
|
||||
- Fixed unable to click inside the toggle list to create a new paragraph
|
||||
- Fixed open file error 50 on macOS
|
||||
- Fixed upload file exceed limit error
|
||||
|
||||
## Version 0.7.4 - 19/11/2024
|
||||
### New Features
|
||||
- Support uploading WebP and BMP images
|
||||
|
||||
@@ -42,10 +42,12 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
|
||||
## User Installation
|
||||
|
||||
- [Download AppFlowy Desktop (macOS, Windows, and Linux)](https://github.com/AppFlowy-IO/AppFlowy/releases)
|
||||
- Other channels: [FlatHub](https://flathub.org/apps/io.appflowy.AppFlowy), [Snapcraft](https://snapcraft.io/appflowy), [Sourceforge](https://sourceforge.net/projects/appflowy/)
|
||||
- Other
|
||||
channels: [FlatHub](https://flathub.org/apps/io.appflowy.AppFlowy), [Snapcraft](https://snapcraft.io/appflowy), [Sourceforge](https://sourceforge.net/projects/appflowy/)
|
||||
- Available on
|
||||
- [App Store](https://apps.apple.com/app/appflowy/id6457261352): iPhone
|
||||
- [Play Store](https://play.google.com/store/apps/details?id=io.appflowy.appflowy): Android 10 or above; ARMv7 is not supported
|
||||
- [App Store](https://apps.apple.com/app/appflowy/id6457261352): iPhone
|
||||
- [Play Store](https://play.google.com/store/apps/details?id=io.appflowy.appflowy): Android 10 or above; ARMv7 is
|
||||
not supported
|
||||
- [Self-hosting AppFlowy](https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy)
|
||||
- [Source](https://docs.appflowy.io/docs/documentation/appflowy/from-source)
|
||||
|
||||
@@ -61,15 +63,18 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
|
||||
|
||||
## Getting Started with development
|
||||
|
||||
Please view the [documentation](https://docs.appflowy.io/docs/documentation/appflowy/from-source) for OS specific development instructions
|
||||
Please view the [documentation](https://docs.appflowy.io/docs/documentation/appflowy/from-source) for OS specific
|
||||
development instructions
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [AppFlowy Roadmap ReadMe](https://docs.appflowy.io/docs/appflowy/roadmap)
|
||||
- [AppFlowy Public Roadmap](https://github.com/orgs/AppFlowy-IO/projects/5/views/12)
|
||||
|
||||
If you'd like to propose a feature, submit a feature request [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=feature_request.yaml&title=%5BFR%5D+) <br/>
|
||||
If you'd like to report a bug, submit a bug report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=bug_report.yaml&title=%5BBug%5D+)
|
||||
If you'd like to propose a feature, submit a feature
|
||||
request [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=feature_request.yaml&title=%5BFR%5D+) <br/>
|
||||
If you'd like to report a bug, submit a bug
|
||||
report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=bug_report.yaml&title=%5BBug%5D+)
|
||||
|
||||
## **Releases**
|
||||
|
||||
@@ -77,16 +82,24 @@ Please see the [changelog](https://www.appflowy.io/whatsnew) for more details ab
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. Please look at [Contributing to AppFlowy](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy) for details.
|
||||
Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make
|
||||
are **greatly appreciated**. Please look
|
||||
at [Contributing to AppFlowy](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy)
|
||||
for details.
|
||||
|
||||
If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly easier to use or understand, **Congratulations!** If your administrative and managerial work behind the scenes sustains the community, **Congratulations!** You are now an official contributor to AppFlowy. Get in touch with us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt!
|
||||
If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly
|
||||
easier to use or understand, **Congratulations!** If your administrative and managerial work behind the scenes sustains
|
||||
the community, **Congratulations!** You are now an official contributor to AppFlowy. Get in touch with
|
||||
us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt!
|
||||
Proudly wear your T-shirt and show it to us by tagging [@appflowy](https://twitter.com/appflowy) on Twitter.
|
||||
|
||||
## Translations 🌎🗺
|
||||
|
||||
[](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy?ref=badge)
|
||||
|
||||
To add translations, you can manually edit the JSON translation files in `/frontend/resources/translations`, use the [inlang online editor](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy), or run `npx inlang machine translate` to add missing translations.
|
||||
To add translations, you can manually edit the JSON translation files in `/frontend/resources/translations`, use
|
||||
the [inlang online editor](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy), or
|
||||
run `npx inlang machine translate` to add missing translations.
|
||||
|
||||
## Join the community to build AppFlowy together
|
||||
|
||||
@@ -96,16 +109,30 @@ To add translations, you can manually edit the JSON translation files in `/front
|
||||
|
||||
## Why Are We Building This?
|
||||
|
||||
Notion has been our favourite project and knowledge management tool in recent years because of its aesthetic appeal and functionality. Our team uses it daily, and we are on its paid plan. However, as we all know, Notion has its limitations. These include weak data security and poor compatibility with mobile devices. Likewise, alternative collaborative workplace management tools also have their constraints.
|
||||
Notion has been our favourite project and knowledge management tool in recent years because of its aesthetic appeal and
|
||||
functionality. Our team uses it daily, and we are on its paid plan. However, as we all know, Notion has its limitations.
|
||||
These include weak data security and poor compatibility with mobile devices. Likewise, alternative collaborative
|
||||
workplace management tools also have their constraints.
|
||||
|
||||
The limitations we encountered using these tools and our past work experience with collaborative productivity tools have led to our firm belief that there is a glass ceiling on what's possible for these tools in the future. This emanates from the fact that these tools will probably struggle to scale horizontally at some point and be forced to prioritize a proportion of customers whose needs differ from the rest. While decision-makers want a workplace OS, it is impossible to come up with a one-size fits all solution in such a fragmented market.
|
||||
The limitations we encountered using these tools and our past work experience with collaborative productivity tools have
|
||||
led to our firm belief that there is a glass ceiling on what's possible for these tools in the future. This emanates
|
||||
from the fact that these tools will probably struggle to scale horizontally at some point and be forced to prioritize a
|
||||
proportion of customers whose needs differ from the rest. While decision-makers want a workplace OS, it is impossible to
|
||||
come up with a one-size fits all solution in such a fragmented market.
|
||||
|
||||
When a customer's evolving core needs are not satisfied, they either switch to another or build one from the ground up, in-house. Consequently, they either go under another ceiling or buy an expensive ticket to learn a hard lesson. This is a requirement for many resources and expertise, building a reliable and easy-to-use collaborative tool, not to mention the speed and native experience. The same may apply to individual users as well.
|
||||
When a customer's evolving core needs are not satisfied, they either switch to another or build one from the ground up,
|
||||
in-house. Consequently, they either go under another ceiling or buy an expensive ticket to learn a hard lesson. This is
|
||||
a requirement for many resources and expertise, building a reliable and easy-to-use collaborative tool, not to mention
|
||||
the speed and native experience. The same may apply to individual users as well.
|
||||
|
||||
All these restrictions necessitate our mission - to make it possible for anyone to create apps that suit their needs well.
|
||||
All these restrictions necessitate our mission - to make it possible for anyone to create apps that suit their needs
|
||||
well.
|
||||
|
||||
- To individuals, we would like to offer Notion's functionality, data security, and cross-platform native experience.
|
||||
- To enterprises and hackers, AppFlowy is dedicated to offering building blocks and collaboration infra services to enable you to make apps on your own. Moreover, you have 100% control of your data. You can design and modify AppFlowy your way, with a single codebase written in Flutter and Rust supporting multiple platforms armed with long-term maintainability.
|
||||
- To enterprises and hackers, AppFlowy is dedicated to offering building blocks and collaboration infra services to
|
||||
enable you to make apps on your own. Moreover, you have 100% control of your data. You can design and modify AppFlowy
|
||||
your way, with a single codebase written in Flutter and Rust supporting multiple platforms armed with long-term
|
||||
maintainability.
|
||||
|
||||
We decided to achieve this mission by upholding the three most fundamental values:
|
||||
|
||||
@@ -113,16 +140,20 @@ We decided to achieve this mission by upholding the three most fundamental value
|
||||
- Reliable native experience
|
||||
- Community-driven extensibility
|
||||
|
||||
We do not claim to outperform Notion in terms of functionality and design, at least for now. Besides, our priority doesn't lie in more functionality at the moment. Instead, we would like to cultivate a community to democratize the knowledge and wheels of making complex workplace management tools while enabling people and businesses to create beautiful things on their own by equipping them with a versatile toolbox of building blocks.
|
||||
We do not claim to outperform Notion in terms of functionality and design, at least for now. Besides, our priority
|
||||
doesn't lie in more functionality at the moment. Instead, we would like to cultivate a community to democratize the
|
||||
knowledge and wheels of making complex workplace management tools while enabling people and businesses to create
|
||||
beautiful things on their own by equipping them with a versatile toolbox of building blocks.
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the AGPLv3 License. See [`LICENSE.md`](https://github.com/AppFlowy-IO/AppFlowy/blob/main/LICENSE) for more information.
|
||||
Distributed under the AGPLv3 License. See [`LICENSE.md`](https://github.com/AppFlowy-IO/AppFlowy/blob/main/LICENSE) for
|
||||
more information.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Special thanks to these amazing projects which help power AppFlowy.IO:
|
||||
|
||||
- [flutter-quill](https://github.com/singerdmx/flutter-quill)
|
||||
- [cargo-make](https://github.com/sagiegurari/cargo-make)
|
||||
- [contrib.rocks](https://contrib.rocks)
|
||||
- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui)
|
||||
@@ -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.7.4"
|
||||
APPFLOWY_VERSION = "0.7.6"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
MACOSX_DEPLOYMENT_TARGET = "11.0"
|
||||
|
||||
@@ -65,4 +65,5 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
</manifest>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../../shared/constants.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('AI Writer:', () {
|
||||
testWidgets('the ai writer transaction should only apply in memory',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
const pageName = 'Document';
|
||||
await tester.createNewPageInSpace(
|
||||
spaceName: Constants.generalSpaceName,
|
||||
layout: ViewLayoutPB.Document,
|
||||
pageName: pageName,
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_aiWriter.tr(),
|
||||
);
|
||||
expect(find.byType(AIWriterBlockComponent), findsOneWidget);
|
||||
|
||||
// switch to another page
|
||||
await tester.openPage(Constants.gettingStartedPageName);
|
||||
// switch back to the page
|
||||
await tester.openPage(pageName);
|
||||
|
||||
// expect the ai writer block is not in the document
|
||||
expect(find.byType(AIWriterBlockComponent), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'document_ai_writer_test.dart' as document_ai_writer_test;
|
||||
import 'document_copy_link_to_block_test.dart'
|
||||
as document_copy_link_to_block_test;
|
||||
import 'document_option_actions_test.dart' as document_option_actions_test;
|
||||
@@ -11,4 +12,5 @@ void main() {
|
||||
document_option_actions_test.main();
|
||||
document_copy_link_to_block_test.main();
|
||||
document_publish_test.main();
|
||||
document_ai_writer_test.main();
|
||||
}
|
||||
-57
@@ -4,12 +4,10 @@ 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/database/widgets/card/card.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/row/row_banner.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/shared/af_image.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -26,61 +24,6 @@ void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('database row cover', () {
|
||||
testWidgets('add image to media field and check if cover is set (grid)',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
|
||||
|
||||
// Invoke the field editor
|
||||
await tester.tapGridFieldWithName('Type');
|
||||
await tester.tapEditFieldButton();
|
||||
|
||||
// Change to media type
|
||||
await tester.tapSwitchFieldTypeButton();
|
||||
await tester.selectFieldType(FieldType.Media);
|
||||
await tester.dismissFieldEditor();
|
||||
|
||||
// Prepare file for upload from local
|
||||
final image = await rootBundle.load('assets/test/images/sample.jpeg');
|
||||
final tempDirectory = await getTemporaryDirectory();
|
||||
|
||||
final imagePath = p.join(tempDirectory.path, 'sample.jpeg');
|
||||
final file = File(imagePath)
|
||||
..writeAsBytesSync(image.buffer.asUint8List());
|
||||
|
||||
mockPickFilePaths(paths: [imagePath]);
|
||||
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
|
||||
|
||||
// Open media cell editor
|
||||
await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media);
|
||||
await tester.findMediaCellEditor(findsOneWidget);
|
||||
|
||||
// Click on add file button in the Media Cell Editor
|
||||
await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap on the upload interaction
|
||||
await tester.tapFileUploadHint();
|
||||
|
||||
// Expect one file
|
||||
expect(find.byType(RenderMedia), findsOneWidget);
|
||||
|
||||
// Close cell editor
|
||||
await tester.dismissCellEditor();
|
||||
|
||||
// Open first row in row detail view
|
||||
await tester.openFirstRowDetailPage();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expect a cover to be shown
|
||||
expect(find.byType(RowCover), findsOneWidget);
|
||||
|
||||
// Remove the temp file
|
||||
await Future.wait([file.delete()]);
|
||||
});
|
||||
|
||||
testWidgets('add and remove cover from Row Detail Card', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
+3
@@ -11,6 +11,8 @@ import 'document_with_file_test.dart' as document_with_file_test;
|
||||
import 'document_with_image_block_test.dart' as document_with_image_block_test;
|
||||
import 'document_with_multi_image_block_test.dart'
|
||||
as document_with_multi_image_block_test;
|
||||
import 'document_with_simple_table_test.dart'
|
||||
as document_with_simple_table_test;
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -25,4 +27,5 @@ void main() {
|
||||
document_block_option_test.main();
|
||||
document_find_menu_test.main();
|
||||
document_toolbar_test.main();
|
||||
document_with_simple_table_test.main();
|
||||
}
|
||||
-1
@@ -26,7 +26,6 @@ import '../../shared/util.dart';
|
||||
|
||||
const _testImageUrls = [
|
||||
'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',
|
||||
'https://mathiasbynens.be/demo/animated-webp-supported.webp',
|
||||
'https://www.easygifanimator.net/images/samples/eglite.gif',
|
||||
'https://people.math.sc.edu/Burkardt/data/bmp/snail.bmp',
|
||||
'https://file-examples.com/storage/fe9566cb7d67345489a5a97/2017/10/file_example_JPG_100kB.jpg',
|
||||
|
||||
+2
-2
@@ -48,7 +48,7 @@ void main() {
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
|
||||
offset: 100,
|
||||
offset: 80,
|
||||
);
|
||||
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
|
||||
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
|
||||
@@ -146,7 +146,7 @@ void main() {
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
|
||||
offset: 100,
|
||||
offset: 80,
|
||||
);
|
||||
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
|
||||
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
|
||||
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../../shared/util.dart';
|
||||
|
||||
const String heading1 = "Heading 1";
|
||||
const String heading2 = "Heading 2";
|
||||
const String heading3 = "Heading 3";
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('simple table block test:', () {
|
||||
testWidgets('insert a simple table block', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// validate the table is inserted
|
||||
expect(find.byType(SimpleTableBlockWidget), findsOneWidget);
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
expect(
|
||||
editorState.selection,
|
||||
// table -> row -> cell -> paragraph
|
||||
Selection.collapsed(Position(path: [0, 0, 0, 0])),
|
||||
);
|
||||
|
||||
final firstCell = find.byType(SimpleTableCellBlockWidget).first;
|
||||
expect(
|
||||
tester
|
||||
.state<SimpleTableCellBlockWidgetState>(firstCell)
|
||||
.isEditingCellNotifier
|
||||
.value,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('select all in table cell', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
const cell1Content = 'Cell 1';
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.ime.insertText('New Table');
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.editor.tapLineOfEditorAt(1);
|
||||
await tester.insertTableInDocument();
|
||||
await tester.ime.insertText(cell1Content);
|
||||
await tester.pumpAndSettle();
|
||||
// Select all in the cell
|
||||
await tester.simulateKeyEvent(
|
||||
LogicalKeyboardKey.keyA,
|
||||
isControlPressed: !UniversalPlatform.isMacOS,
|
||||
isMetaPressed: UniversalPlatform.isMacOS,
|
||||
);
|
||||
|
||||
expect(
|
||||
tester.editor.getCurrentEditorState().selection,
|
||||
Selection(
|
||||
start: Position(path: [1, 0, 0, 0]),
|
||||
end: Position(path: [1, 0, 0, 0], offset: cell1Content.length),
|
||||
),
|
||||
);
|
||||
|
||||
// Press select all again, the selection should be the entire document
|
||||
await tester.simulateKeyEvent(
|
||||
LogicalKeyboardKey.keyA,
|
||||
isControlPressed: !UniversalPlatform.isMacOS,
|
||||
isMetaPressed: UniversalPlatform.isMacOS,
|
||||
);
|
||||
|
||||
expect(
|
||||
tester.editor.getCurrentEditorState().selection,
|
||||
Selection(
|
||||
start: Position(path: [0]),
|
||||
end: Position(path: [1, 1, 1, 0]),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('''
|
||||
1. hover on the table
|
||||
1.1 click the add row button
|
||||
1.2 click the add column button
|
||||
1.3 click the add row and column button
|
||||
2. validate the table is updated
|
||||
3. delete the last column
|
||||
4. delete the last row
|
||||
5. validate the table is updated
|
||||
''', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// hover on the table
|
||||
final tableBlock = find.byType(SimpleTableBlockWidget).first;
|
||||
await tester.hoverOnWidget(
|
||||
tableBlock,
|
||||
onHover: () async {
|
||||
// click the add row button
|
||||
final addRowButton = find.byType(SimpleTableAddRowButton).first;
|
||||
await tester.tap(addRowButton);
|
||||
|
||||
// click the add column button
|
||||
final addColumnButton = find.byType(SimpleTableAddColumnButton).first;
|
||||
await tester.tap(addColumnButton);
|
||||
|
||||
// click the add row and column button
|
||||
final addRowAndColumnButton =
|
||||
find.byType(SimpleTableAddColumnAndRowButton).first;
|
||||
await tester.tap(addRowAndColumnButton);
|
||||
},
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tableNode =
|
||||
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
|
||||
expect(tableNode.columnLength, 4);
|
||||
expect(tableNode.rowLength, 4);
|
||||
|
||||
// delete the last row
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.row,
|
||||
index: tableNode.rowLength - 1,
|
||||
action: SimpleTableMoreAction.delete,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tableNode.rowLength, 3);
|
||||
expect(tableNode.columnLength, 4);
|
||||
|
||||
// delete the last column
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.column,
|
||||
index: tableNode.columnLength - 1,
|
||||
action: SimpleTableMoreAction.delete,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tableNode.columnLength, 3);
|
||||
expect(tableNode.rowLength, 3);
|
||||
});
|
||||
|
||||
testWidgets('enable header column and header row', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// enable the header row
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.row,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.enableHeaderRow,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
// enable the header column
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.column,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.enableHeaderColumn,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tableNode =
|
||||
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
|
||||
|
||||
expect(tableNode.isHeaderColumnEnabled, isTrue);
|
||||
expect(tableNode.isHeaderRowEnabled, isTrue);
|
||||
|
||||
// disable the header row
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.row,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.enableHeaderRow,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tableNode.isHeaderColumnEnabled, isTrue);
|
||||
expect(tableNode.isHeaderRowEnabled, isFalse);
|
||||
|
||||
// disable the header column
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.column,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.enableHeaderColumn,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tableNode.isHeaderColumnEnabled, isFalse);
|
||||
expect(tableNode.isHeaderRowEnabled, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('duplicate a column / row', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// duplicate the row
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.row,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.duplicate,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// duplicate the column
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.column,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.duplicate,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tableNode =
|
||||
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
|
||||
expect(tableNode.columnLength, 3);
|
||||
expect(tableNode.rowLength, 3);
|
||||
});
|
||||
|
||||
testWidgets('insert left / insert right', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// insert left
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.column,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.insertLeft,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// insert right
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.column,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.insertRight,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tableNode =
|
||||
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
|
||||
expect(tableNode.columnLength, 4);
|
||||
expect(tableNode.rowLength, 2);
|
||||
});
|
||||
|
||||
testWidgets('insert above / insert below', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
// insert above
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.row,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.insertAbove,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// insert below
|
||||
await tester.clickMoreActionItemInTableMenu(
|
||||
type: SimpleTableMoreActionType.row,
|
||||
index: 0,
|
||||
action: SimpleTableMoreAction.insertBelow,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tableNode =
|
||||
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
|
||||
expect(tableNode.rowLength, 4);
|
||||
expect(tableNode.columnLength, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
extension on WidgetTester {
|
||||
/// Insert a table in the document
|
||||
Future<void> insertTableInDocument() async {
|
||||
// open the actions menu and insert the outline block
|
||||
await editor.showSlashMenu();
|
||||
await editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_table.tr(),
|
||||
);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> clickMoreActionItemInTableMenu({
|
||||
required SimpleTableMoreActionType type,
|
||||
required int index,
|
||||
required SimpleTableMoreAction action,
|
||||
}) async {
|
||||
if (type == SimpleTableMoreActionType.row) {
|
||||
final row = find.byWidgetPredicate((w) {
|
||||
return w is SimpleTableRowBlockWidget && w.node.rowIndex == index;
|
||||
});
|
||||
await hoverOnWidget(
|
||||
row,
|
||||
onHover: () async {
|
||||
final moreActionButton = find.byWidgetPredicate((w) {
|
||||
return w is SimpleTableMoreActionMenu &&
|
||||
w.type == SimpleTableMoreActionType.row &&
|
||||
w.index == index;
|
||||
});
|
||||
await tapButton(moreActionButton);
|
||||
await tapButton(find.text(action.name));
|
||||
},
|
||||
);
|
||||
await pumpAndSettle();
|
||||
} else if (type == SimpleTableMoreActionType.column) {
|
||||
final column = find.byWidgetPredicate((w) {
|
||||
return w is SimpleTableCellBlockWidget && w.node.columnIndex == index;
|
||||
}).first;
|
||||
await hoverOnWidget(
|
||||
column,
|
||||
onHover: () async {
|
||||
final moreActionButton = find.byWidgetPredicate((w) {
|
||||
return w is SimpleTableMoreActionMenu &&
|
||||
w.type == SimpleTableMoreActionType.column &&
|
||||
w.index == index;
|
||||
});
|
||||
await tapButton(moreActionButton);
|
||||
await tapButton(find.text(action.name));
|
||||
},
|
||||
);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
await tapAt(Offset.zero);
|
||||
}
|
||||
}
|
||||
+21
@@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -263,5 +265,24 @@ void main() {
|
||||
expect(node.attributes[ToggleListBlockKeys.level], 3);
|
||||
expect(node.delta!.toPlainText(), 'Hello');
|
||||
});
|
||||
|
||||
testWidgets('click the toggle list to create a new paragraph',
|
||||
(tester) async {
|
||||
await prepareToggleHeadingBlock(tester, '> # Hello');
|
||||
final emptyHintText = find.text(
|
||||
LocaleKeys.document_plugins_emptyToggleHeading.tr(
|
||||
args: ['1'],
|
||||
),
|
||||
);
|
||||
expect(emptyHintText, findsOneWidget);
|
||||
|
||||
await tester.tapButton(emptyHintText);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// check the new paragraph is created
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
final node = editorState.getNodeAtPath([0, 0])!;
|
||||
expect(node.type, ParagraphBlockKeys.type);
|
||||
});
|
||||
});
|
||||
}
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -87,7 +88,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
importedPageEditorState.getNodeAtPath([2])!.type,
|
||||
TableBlockKeys.type,
|
||||
SimpleTableBlockKeys.type,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
@@ -145,5 +146,189 @@ void main() {
|
||||
// and in this case view name in sidebar)
|
||||
expect(find.text(gettingStarted), findsNWidgets(3));
|
||||
});
|
||||
|
||||
testWidgets('cannot close pinned tabs', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(TabBar),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentName);
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
|
||||
|
||||
// Open second menu item in a new tab
|
||||
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
|
||||
|
||||
// Open third menu item in a new tab
|
||||
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
findsNWidgets(3),
|
||||
);
|
||||
|
||||
const firstTab = _documentTwoName;
|
||||
const secondTab = gettingStarted;
|
||||
const thirdTab = _documentName;
|
||||
|
||||
expect(tester.isTabAtIndex(firstTab, 0), isTrue);
|
||||
expect(tester.isTabAtIndex(secondTab, 1), isTrue);
|
||||
expect(tester.isTabAtIndex(thirdTab, 2), isTrue);
|
||||
|
||||
expect(tester.isTabPinned(gettingStarted), isFalse);
|
||||
|
||||
// Right click on second tab
|
||||
await tester.openTabMenu(gettingStarted);
|
||||
expect(find.byType(TabMenu), findsOneWidget);
|
||||
|
||||
// Pin second tab
|
||||
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.isTabPinned(gettingStarted), isTrue);
|
||||
|
||||
/// Right click on first unpinned tab (second tab)
|
||||
await tester.openTabMenu(_documentTwoName);
|
||||
|
||||
// Close others
|
||||
await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// We expect to find 2 tabs, the first pinned tab and the second tab
|
||||
expect(find.byType(FlowyTab), findsNWidgets(2));
|
||||
expect(tester.isTabAtIndex(gettingStarted, 0), isTrue);
|
||||
expect(tester.isTabAtIndex(_documentTwoName, 1), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('pin/unpin tabs proper order', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(TabBar),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentName);
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
|
||||
|
||||
// Open second menu item in a new tab
|
||||
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
|
||||
|
||||
// Open third menu item in a new tab
|
||||
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
findsNWidgets(3),
|
||||
);
|
||||
|
||||
const firstTabName = _documentTwoName;
|
||||
const secondTabName = gettingStarted;
|
||||
const thirdTabName = _documentName;
|
||||
|
||||
// Expect correct order
|
||||
expect(tester.isTabAtIndex(firstTabName, 0), isTrue);
|
||||
expect(tester.isTabAtIndex(secondTabName, 1), isTrue);
|
||||
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
|
||||
|
||||
// Pin second tab
|
||||
await tester.openTabMenu(secondTabName);
|
||||
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.isTabPinned(secondTabName), isTrue);
|
||||
|
||||
// Expect correct order
|
||||
expect(tester.isTabAtIndex(secondTabName, 0), isTrue);
|
||||
expect(tester.isTabAtIndex(firstTabName, 1), isTrue);
|
||||
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
|
||||
|
||||
// Pin new second tab (first tab)
|
||||
await tester.openTabMenu(firstTabName);
|
||||
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.isTabPinned(firstTabName), isTrue);
|
||||
expect(tester.isTabPinned(secondTabName), isTrue);
|
||||
expect(tester.isTabPinned(thirdTabName), isFalse);
|
||||
|
||||
expect(tester.isTabAtIndex(secondTabName, 0), isTrue);
|
||||
expect(tester.isTabAtIndex(firstTabName, 1), isTrue);
|
||||
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
|
||||
|
||||
// Unpin second tab
|
||||
await tester.openTabMenu(secondTabName);
|
||||
await tester.tap(find.text(LocaleKeys.tabMenu_unpinTab.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.isTabPinned(firstTabName), isTrue);
|
||||
expect(tester.isTabPinned(secondTabName), isFalse);
|
||||
expect(tester.isTabPinned(thirdTabName), isFalse);
|
||||
|
||||
expect(tester.isTabAtIndex(firstTabName, 0), isTrue);
|
||||
expect(tester.isTabAtIndex(secondTabName, 1), isTrue);
|
||||
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
extension _TabsTester on WidgetTester {
|
||||
bool isTabPinned(String tabName) {
|
||||
final tabFinder = find.ancestor(
|
||||
of: find.byWidgetPredicate(
|
||||
(w) => w is ViewTabBarItem && w.view.name == tabName,
|
||||
),
|
||||
matching: find.byType(FlowyTab),
|
||||
);
|
||||
|
||||
final FlowyTab tabWidget = widget(tabFinder);
|
||||
return tabWidget.pageManager.isPinned;
|
||||
}
|
||||
|
||||
bool isTabAtIndex(String tabName, int index) {
|
||||
final tabFinder = find.ancestor(
|
||||
of: find.byWidgetPredicate(
|
||||
(w) => w is ViewTabBarItem && w.view.name == tabName,
|
||||
),
|
||||
matching: find.byType(FlowyTab),
|
||||
);
|
||||
|
||||
final pluginId = (widget(tabFinder) as FlowyTab).pageManager.plugin.id;
|
||||
|
||||
final pluginIds = find
|
||||
.byType(FlowyTab)
|
||||
.evaluate()
|
||||
.map((e) => (e.widget as FlowyTab).pageManager.plugin.id);
|
||||
|
||||
return pluginIds.elementAt(index) == pluginId;
|
||||
}
|
||||
|
||||
Future<void> openTabMenu(String tabName) async {
|
||||
await tap(
|
||||
buttons: kSecondaryButton,
|
||||
find.ancestor(
|
||||
of: find.byWidgetPredicate(
|
||||
(w) => w is ViewTabBarItem && w.view.name == tabName,
|
||||
),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@
|
||||
<string>AppFlowy needs access to your photos to let you add images to your documents</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>AppFlowy needs access to your camera to let you add images to your documents from camera</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -65,9 +67,9 @@
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>UISupportsDocumentBrowser</key>
|
||||
<true/>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -49,6 +49,7 @@ class KVKeys {
|
||||
/// {'SidebarFolderCategoryType.value': true}
|
||||
static const String expandedFolders = 'expandedFolders';
|
||||
|
||||
/// @deprecated in version 0.7.6
|
||||
/// The key for saving if showing the rename dialog when creating a new file
|
||||
///
|
||||
/// The value is a boolean string.
|
||||
|
||||
+4
-2
@@ -9,10 +9,10 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/widgets.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/plugins/base/drag_handler.dart';
|
||||
import 'package:appflowy/plugins/database/domain/field_service.dart';
|
||||
import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart';
|
||||
import 'package:appflowy/plugins/database/domain/field_service.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart';
|
||||
import 'package:appflowy/util/field_type_extension.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -863,6 +863,8 @@ class _SelectOptionListState extends State<_SelectOptionList> {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
// disable the inner scroll physics, so the outer ListView can scroll
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: widget.selectOptions
|
||||
.mapIndexed(
|
||||
(index, option) => _SelectOptionTile(
|
||||
|
||||
+48
-40
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
@@ -22,42 +20,26 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
}) : super(
|
||||
ChatAIMessageState.initial(
|
||||
message,
|
||||
messageReferenceSource(refSourceJsonString),
|
||||
parseMetadata(refSourceJsonString),
|
||||
),
|
||||
) {
|
||||
if (state.stream != null) {
|
||||
state.stream!.listen(
|
||||
onData: (text) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.updateText(text));
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveError(error.toString()));
|
||||
}
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
if (!isClosed) {
|
||||
add(const ChatAIMessageEvent.onAIResponseLimit());
|
||||
}
|
||||
},
|
||||
onMetadata: (sources) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveSources(sources));
|
||||
}
|
||||
},
|
||||
);
|
||||
_dispatch();
|
||||
|
||||
if (state.stream!.error != null) {
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
|
||||
}
|
||||
});
|
||||
if (state.stream != null) {
|
||||
_startListening();
|
||||
|
||||
if (state.stream!.aiLimitReached) {
|
||||
add(const ChatAIMessageEvent.onAIResponseLimit());
|
||||
} else if (state.stream!.error != null) {
|
||||
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
final Int64? questionId;
|
||||
|
||||
void _dispatch() {
|
||||
on<ChatAIMessageEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
@@ -116,10 +98,12 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
receiveSources: (List<ChatMessageRefSource> sources) {
|
||||
receiveMetadata: (metadata) {
|
||||
Log.debug("AI Steps: ${metadata.progress?.step}");
|
||||
emit(
|
||||
state.copyWith(
|
||||
sources: sources,
|
||||
sources: metadata.sources,
|
||||
progress: metadata.progress,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -128,8 +112,30 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
);
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
final Int64? questionId;
|
||||
void _startListening() {
|
||||
state.stream!.listen(
|
||||
onData: (text) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.updateText(text));
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveError(error.toString()));
|
||||
}
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
if (!isClosed) {
|
||||
add(const ChatAIMessageEvent.onAIResponseLimit());
|
||||
}
|
||||
},
|
||||
onMetadata: (metadata) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveMetadata(metadata));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -139,8 +145,8 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
|
||||
const factory ChatAIMessageEvent.retry() = _Retry;
|
||||
const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult;
|
||||
const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit;
|
||||
const factory ChatAIMessageEvent.receiveSources(
|
||||
List<ChatMessageRefSource> sources,
|
||||
const factory ChatAIMessageEvent.receiveMetadata(
|
||||
MetadataCollection metadata,
|
||||
) = _ReceiveMetadata;
|
||||
}
|
||||
|
||||
@@ -151,17 +157,19 @@ class ChatAIMessageState with _$ChatAIMessageState {
|
||||
required String text,
|
||||
required MessageState messageState,
|
||||
required List<ChatMessageRefSource> sources,
|
||||
required AIChatProgress? progress,
|
||||
}) = _ChatAIMessageState;
|
||||
|
||||
factory ChatAIMessageState.initial(
|
||||
dynamic text,
|
||||
List<ChatMessageRefSource> sources,
|
||||
MetadataCollection metadata,
|
||||
) {
|
||||
return ChatAIMessageState(
|
||||
text: text is String ? text : "",
|
||||
stream: text is AnswerStream ? text : null,
|
||||
messageState: const MessageState.ready(),
|
||||
sources: sources,
|
||||
sources: metadata.sources,
|
||||
progress: metadata.progress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,50 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy/util/int64_extension.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
import 'chat_entity.dart';
|
||||
import 'chat_message_listener.dart';
|
||||
import 'chat_message_service.dart';
|
||||
import 'chat_message_stream.dart';
|
||||
|
||||
part 'chat_bloc.freezed.dart';
|
||||
|
||||
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
ChatBloc({
|
||||
required ViewPB view,
|
||||
required UserProfilePB userProfile,
|
||||
}) : listener = ChatMessageListener(chatId: view.id),
|
||||
chatId = view.id,
|
||||
super(
|
||||
ChatState.initial(view, userProfile),
|
||||
) {
|
||||
required this.chatId,
|
||||
required this.userId,
|
||||
}) : chatController = InMemoryChatController(),
|
||||
listener = ChatMessageListener(chatId: chatId),
|
||||
super(ChatState.initial()) {
|
||||
_startListening();
|
||||
_dispatch();
|
||||
_init();
|
||||
}
|
||||
|
||||
final ChatMessageListener listener;
|
||||
final String chatId;
|
||||
final String userId;
|
||||
final ChatMessageListener listener;
|
||||
|
||||
final ChatController chatController;
|
||||
|
||||
/// The last streaming message id
|
||||
String answerStreamMessageId = '';
|
||||
String questionStreamMessageId = '';
|
||||
|
||||
ChatMessagePB? lastSentMessage;
|
||||
|
||||
/// Using a temporary map to associate the real message ID with the last streaming message ID.
|
||||
///
|
||||
/// When a message is streaming, it does not have a real message ID. To maintain the relationship
|
||||
@@ -51,11 +54,14 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
/// is 3 (AI response).
|
||||
final HashMap<String, String> temporaryMessageIDMap = HashMap();
|
||||
|
||||
bool isLoadingPreviousMessages = false;
|
||||
bool hasMorePreviousMessages = true;
|
||||
AnswerStream? answerStream;
|
||||
int numSendMessage = 0;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (state.answerStream != null) {
|
||||
await state.answerStream?.dispose();
|
||||
}
|
||||
await answerStream?.dispose();
|
||||
await listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
@@ -64,195 +70,166 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
on<ChatEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
initialLoad: () {
|
||||
final payload = LoadNextChatMessagePB(
|
||||
chatId: state.view.id,
|
||||
limit: Int64(10),
|
||||
);
|
||||
AIEventLoadNextMessage(payload).send().then(
|
||||
(result) {
|
||||
result.fold((list) {
|
||||
if (!isClosed) {
|
||||
final messages =
|
||||
list.messages.map(_createTextMessage).toList();
|
||||
add(ChatEvent.didLoadLatestMessages(messages));
|
||||
}
|
||||
}, (err) {
|
||||
Log.error("Failed to load messages: $err");
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
// Loading messages
|
||||
startLoadingPrevMessage: () async {
|
||||
Int64? beforeMessageId;
|
||||
final oldestMessage = _getOldestMessage();
|
||||
if (oldestMessage != null) {
|
||||
try {
|
||||
beforeMessageId = Int64.parseInt(oldestMessage.id);
|
||||
} catch (e) {
|
||||
Log.error(
|
||||
"Failed to parse message id: $e, messaeg_id: ${oldestMessage.id}",
|
||||
);
|
||||
}
|
||||
}
|
||||
_loadPrevMessage(beforeMessageId);
|
||||
emit(
|
||||
state.copyWith(
|
||||
loadingPreviousStatus: const ChatLoadingState.loading(),
|
||||
),
|
||||
);
|
||||
},
|
||||
didLoadPreviousMessages: (List<Message> messages, bool hasMore) {
|
||||
Log.debug("did load previous messages: ${messages.length}");
|
||||
final onetimeMessages = _getOnetimeMessages();
|
||||
final allMessages = _permanentMessages();
|
||||
final uniqueMessages = {...allMessages, ...messages}.toList();
|
||||
|
||||
uniqueMessages.insertAll(0, onetimeMessages);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: uniqueMessages,
|
||||
loadingPreviousStatus: const ChatLoadingState.finish(),
|
||||
hasMorePrevMessage: hasMore,
|
||||
),
|
||||
);
|
||||
},
|
||||
didLoadLatestMessages: (List<Message> messages) {
|
||||
final onetimeMessages = _getOnetimeMessages();
|
||||
final allMessages = _permanentMessages();
|
||||
final uniqueMessages = {...allMessages, ...messages}.toList();
|
||||
uniqueMessages.insertAll(0, onetimeMessages);
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: uniqueMessages,
|
||||
initialLoadingStatus: const ChatLoadingState.finish(),
|
||||
),
|
||||
);
|
||||
},
|
||||
// streaming message
|
||||
finishAnswerStreaming: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
streamingState: const StreamingState.done(),
|
||||
acceptRelatedQuestion: true,
|
||||
canSendMessage:
|
||||
state.sendingState == const SendMessageState.done(),
|
||||
),
|
||||
);
|
||||
},
|
||||
didUpdateAnswerStream: (AnswerStream stream) {
|
||||
emit(state.copyWith(answerStream: stream));
|
||||
},
|
||||
stopStream: () async {
|
||||
if (state.answerStream == null) {
|
||||
return;
|
||||
didLoadLatestMessages: (List<Message> messages) async {
|
||||
for (final message in messages) {
|
||||
await chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
final payload = StopStreamPB(chatId: chatId);
|
||||
await AIEventStopStream(payload).send();
|
||||
final allMessages = _permanentMessages();
|
||||
if (state.streamingState != const StreamingState.done()) {
|
||||
// If the streaming is not started, remove the message from the list
|
||||
if (!state.answerStream!.hasStarted) {
|
||||
allMessages.removeWhere(
|
||||
(element) => element.id == answerStreamMessageId,
|
||||
);
|
||||
answerStreamMessageId = "";
|
||||
}
|
||||
|
||||
// when stop stream, we will set the answer stream to null. Which means the streaming
|
||||
// is finished or canceled.
|
||||
if (state.loadingState.isLoading) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
answerStream: null,
|
||||
streamingState: const StreamingState.done(),
|
||||
),
|
||||
state.copyWith(loadingState: const ChatLoadingState.finish()),
|
||||
);
|
||||
}
|
||||
},
|
||||
receiveMessage: (Message message) {
|
||||
final allMessages = _permanentMessages();
|
||||
// remove message with the same id
|
||||
allMessages.removeWhere((element) => element.id == message.id);
|
||||
allMessages.insert(0, message);
|
||||
loadPreviousMessages: () {
|
||||
if (isLoadingPreviousMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
final oldestMessage = _getOldestMessage();
|
||||
|
||||
if (oldestMessage != null) {
|
||||
final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
|
||||
if (oldestMessageId == null) {
|
||||
Log.error("Failed to parse message_id: ${oldestMessage.id}");
|
||||
return;
|
||||
}
|
||||
isLoadingPreviousMessages = true;
|
||||
_loadPreviousMessages(oldestMessageId);
|
||||
}
|
||||
},
|
||||
didLoadPreviousMessages: (messages, hasMore) {
|
||||
Log.debug("did load previous messages: ${messages.length}");
|
||||
|
||||
for (final message in messages) {
|
||||
chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
isLoadingPreviousMessages = false;
|
||||
hasMorePreviousMessages = hasMore;
|
||||
},
|
||||
didFinishAnswerStream: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
),
|
||||
state.copyWith(promptResponseState: PromptResponseState.ready),
|
||||
);
|
||||
},
|
||||
startAnswerStreaming: (Message message) {
|
||||
final allMessages = _permanentMessages();
|
||||
allMessages.insert(0, message);
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
streamingState: const StreamingState.streaming(),
|
||||
canSendMessage: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
sendMessage: (String message, Map<String, dynamic>? metadata) async {
|
||||
unawaited(_startStreamingMessage(message, metadata, emit));
|
||||
final allMessages = _permanentMessages();
|
||||
emit(
|
||||
state.copyWith(
|
||||
lastSentMessage: null,
|
||||
messages: allMessages,
|
||||
relatedQuestions: [],
|
||||
acceptRelatedQuestion: false,
|
||||
sendingState: const SendMessageState.sending(),
|
||||
canSendMessage: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
finishSending: (ChatMessagePB message) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
lastSentMessage: message,
|
||||
sendingState: const SendMessageState.done(),
|
||||
canSendMessage:
|
||||
state.streamingState == const StreamingState.done(),
|
||||
),
|
||||
);
|
||||
},
|
||||
failedSending: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: _permanentMessages()..removeAt(0),
|
||||
sendingState: const SendMessageState.done(),
|
||||
canSendMessage: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
// related question
|
||||
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
|
||||
didReceiveRelatedQuestions: (List<String> questions) {
|
||||
if (questions.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final allMessages = _permanentMessages();
|
||||
final message = CustomMessage(
|
||||
metadata: OnetimeShotType.relatedQuestion.toMap(),
|
||||
final metadata = {
|
||||
onetimeShotType: OnetimeShotType.relatedQuestion,
|
||||
'questions': questions,
|
||||
};
|
||||
|
||||
final createdAt = DateTime.now();
|
||||
|
||||
final message = TextMessage(
|
||||
id: "related_question_$createdAt",
|
||||
text: '',
|
||||
metadata: metadata,
|
||||
author: const User(id: systemUserId),
|
||||
showStatus: false,
|
||||
id: systemUserId,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
allMessages.insert(0, message);
|
||||
|
||||
chatController.insert(message);
|
||||
},
|
||||
receiveMessage: (Message message) {
|
||||
final oldMessage = chatController.messages
|
||||
.firstWhereOrNull((m) => m.id == message.id);
|
||||
if (oldMessage == null) {
|
||||
chatController.insert(message);
|
||||
} else {
|
||||
chatController.update(oldMessage, message);
|
||||
}
|
||||
},
|
||||
sendMessage: (
|
||||
String message,
|
||||
Map<String, dynamic>? metadata,
|
||||
) {
|
||||
numSendMessage += 1;
|
||||
|
||||
final relatedQuestionMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.relatedQuestion,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in relatedQuestionMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
|
||||
_startStreamingMessage(message, metadata);
|
||||
lastSentMessage = null;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
relatedQuestions: questions,
|
||||
promptResponseState: PromptResponseState.sendingQuestion,
|
||||
),
|
||||
);
|
||||
},
|
||||
clearRelatedQuestions: () {
|
||||
finishSending: (ChatMessagePB message) {
|
||||
lastSentMessage = message;
|
||||
emit(
|
||||
state.copyWith(
|
||||
relatedQuestions: [],
|
||||
promptResponseState: PromptResponseState.awaitingAnswer,
|
||||
),
|
||||
);
|
||||
},
|
||||
stopStream: () async {
|
||||
if (answerStream == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// tell backend to stop
|
||||
final payload = StopStreamPB(chatId: chatId);
|
||||
await AIEventStopStream(payload).send();
|
||||
|
||||
// allow user input
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
),
|
||||
);
|
||||
|
||||
// no need to remove old message if stream has started already
|
||||
if (answerStream!.hasStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove the non-started message from the list
|
||||
final message = chatController.messages.lastWhereOrNull(
|
||||
(e) => e.id == answerStreamMessageId,
|
||||
);
|
||||
if (message != null) {
|
||||
await chatController.remove(message);
|
||||
}
|
||||
|
||||
// set answer stream to null
|
||||
await answerStream?.dispose();
|
||||
answerStream = null;
|
||||
answerStreamMessageId = '';
|
||||
},
|
||||
startAnswerStreaming: (Message message) {
|
||||
chatController.insert(message);
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.streamingAnswer,
|
||||
),
|
||||
);
|
||||
},
|
||||
failedSending: () {
|
||||
final lastMessage = chatController.messages.lastOrNull;
|
||||
if (lastMessage != null) {
|
||||
chatController.remove(lastMessage);
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -264,29 +241,31 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
void _startListening() {
|
||||
listener.start(
|
||||
chatMessageCallback: (pb) {
|
||||
if (!isClosed) {
|
||||
// 3 mean message response from AI
|
||||
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
answerStreamMessageId;
|
||||
answerStreamMessageId = "";
|
||||
}
|
||||
|
||||
// 1 mean message response from User
|
||||
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
questionStreamMessageId;
|
||||
questionStreamMessageId = "";
|
||||
}
|
||||
|
||||
final message = _createTextMessage(pb);
|
||||
add(ChatEvent.receiveMessage(message));
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3 mean message response from AI
|
||||
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
answerStreamMessageId;
|
||||
answerStreamMessageId = '';
|
||||
}
|
||||
|
||||
// 1 mean message response from User
|
||||
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
questionStreamMessageId;
|
||||
questionStreamMessageId = '';
|
||||
}
|
||||
|
||||
final message = _createTextMessage(pb);
|
||||
add(ChatEvent.receiveMessage(message));
|
||||
},
|
||||
chatErrorMessageCallback: (err) {
|
||||
if (!isClosed) {
|
||||
Log.error("chat error: ${err.errorMessage}");
|
||||
add(const ChatEvent.finishAnswerStreaming());
|
||||
add(const ChatEvent.didFinishAnswerStream());
|
||||
}
|
||||
},
|
||||
latestMessageCallback: (list) {
|
||||
@@ -301,65 +280,74 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
|
||||
}
|
||||
},
|
||||
finishStreamingCallback: () {
|
||||
if (!isClosed) {
|
||||
add(const ChatEvent.finishAnswerStreaming());
|
||||
// The answer strema will bet set to null after the streaming is finished or canceled.
|
||||
// so if the answer stream is null, we will not get related question.
|
||||
if (state.lastSentMessage != null && state.answerStream != null) {
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: state.lastSentMessage!.messageId,
|
||||
);
|
||||
// When user message was sent to the server, we start gettting related question
|
||||
AIEventGetRelatedQuestion(payload).send().then((result) {
|
||||
if (!isClosed) {
|
||||
result.fold(
|
||||
(list) {
|
||||
if (state.acceptRelatedQuestion) {
|
||||
add(ChatEvent.didReceiveRelatedQuestion(list.items));
|
||||
}
|
||||
},
|
||||
(err) {
|
||||
Log.error("Failed to get related question: $err");
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
finishStreamingCallback: () async {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
add(const ChatEvent.didFinishAnswerStream());
|
||||
|
||||
// The answer stream will bet set to null after the streaming has
|
||||
// finished, got cancelled, or errored. In this case, don't retrieve
|
||||
// related questions.
|
||||
if (answerStream == null || lastSentMessage == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: lastSentMessage!.messageId,
|
||||
);
|
||||
|
||||
// when previous numSendMessage is not equal to current numSendMessage, it means that the user
|
||||
// has sent a new message. So we don't need to get related questions.
|
||||
final preNumSendMessage = numSendMessage;
|
||||
await AIEventGetRelatedQuestion(payload).send().fold(
|
||||
(list) {
|
||||
if (!isClosed && preNumSendMessage == numSendMessage) {
|
||||
add(
|
||||
ChatEvent.didReceiveRelatedQuestions(
|
||||
list.items.map((e) => e.content).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to get related questions: $err"),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Returns the list of messages that are not include one-time messages.
|
||||
List<Message> _permanentMessages() {
|
||||
final allMessages = state.messages.where((element) {
|
||||
return !(element.metadata?.containsKey(onetimeShotType) == true);
|
||||
}).toList();
|
||||
|
||||
return allMessages;
|
||||
void _init() async {
|
||||
final payload = LoadNextChatMessagePB(
|
||||
chatId: chatId,
|
||||
limit: Int64(10),
|
||||
);
|
||||
await AIEventLoadNextMessage(payload).send().fold(
|
||||
(list) {
|
||||
if (!isClosed) {
|
||||
final messages = list.messages.map(_createTextMessage).toList();
|
||||
add(ChatEvent.didLoadLatestMessages(messages));
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to load messages: $err"),
|
||||
);
|
||||
}
|
||||
|
||||
List<Message> _getOnetimeMessages() {
|
||||
final messages = state.messages.where((element) {
|
||||
return (element.metadata?.containsKey(onetimeShotType) == true);
|
||||
}).toList();
|
||||
|
||||
return messages;
|
||||
bool _isOneTimeMessage(Message message) {
|
||||
return message.metadata != null &&
|
||||
message.metadata!.containsKey(onetimeShotType);
|
||||
}
|
||||
|
||||
/// get the last message that is not a one-time message
|
||||
Message? _getOldestMessage() {
|
||||
// get the last message that is not a one-time message
|
||||
final message = state.messages.lastWhereOrNull((element) {
|
||||
return !(element.metadata?.containsKey(onetimeShotType) == true);
|
||||
});
|
||||
return message;
|
||||
return chatController.messages
|
||||
.firstWhereOrNull((message) => !_isOneTimeMessage(message));
|
||||
}
|
||||
|
||||
void _loadPrevMessage(Int64? beforeMessageId) {
|
||||
void _loadPreviousMessages(Int64? beforeMessageId) {
|
||||
final payload = LoadPrevChatMessagePB(
|
||||
chatId: state.view.id,
|
||||
chatId: chatId,
|
||||
limit: Int64(10),
|
||||
beforeMessageId: beforeMessageId,
|
||||
);
|
||||
@@ -369,59 +357,56 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
Future<void> _startStreamingMessage(
|
||||
String message,
|
||||
Map<String, dynamic>? metadata,
|
||||
Emitter<ChatState> emit,
|
||||
) async {
|
||||
if (state.answerStream != null) {
|
||||
await state.answerStream?.dispose();
|
||||
}
|
||||
await answerStream?.dispose();
|
||||
|
||||
final answerStream = AnswerStream();
|
||||
answerStream = AnswerStream();
|
||||
final questionStream = QuestionStream();
|
||||
add(ChatEvent.didUpdateAnswerStream(answerStream));
|
||||
|
||||
final payload = StreamChatPayloadPB(
|
||||
chatId: state.view.id,
|
||||
message: message,
|
||||
messageType: ChatMessageTypePB.User,
|
||||
questionStreamPort: Int64(questionStream.nativePort),
|
||||
answerStreamPort: Int64(answerStream.nativePort),
|
||||
metadata: await metadataPBFromMetadata(metadata),
|
||||
);
|
||||
|
||||
// add a streaming question message
|
||||
final questionStreamMessage = _createQuestionStreamMessage(
|
||||
questionStream,
|
||||
metadata,
|
||||
);
|
||||
add(ChatEvent.receiveMessage(questionStreamMessage));
|
||||
|
||||
// Stream message to the server
|
||||
final result = await AIEventStreamMessage(payload).send();
|
||||
result.fold(
|
||||
(ChatMessagePB question) {
|
||||
final payload = StreamChatPayloadPB(
|
||||
chatId: chatId,
|
||||
message: message,
|
||||
messageType: ChatMessageTypePB.User,
|
||||
questionStreamPort: Int64(questionStream.nativePort),
|
||||
answerStreamPort: Int64(answerStream!.nativePort),
|
||||
metadata: await metadataPBFromMetadata(metadata),
|
||||
);
|
||||
|
||||
// stream the question to the server
|
||||
await AIEventStreamMessage(payload).send().fold(
|
||||
(question) {
|
||||
if (!isClosed) {
|
||||
final streamAnswer = _createAnswerStreamMessage(
|
||||
answerStream!,
|
||||
question.messageId,
|
||||
);
|
||||
|
||||
add(ChatEvent.finishSending(question));
|
||||
|
||||
// final message = _createTextMessage(question);
|
||||
// add(ChatEvent.receiveMessage(message));
|
||||
|
||||
final streamAnswer =
|
||||
_createAnswerStreamMessage(answerStream, question.messageId);
|
||||
add(ChatEvent.startAnswerStreaming(streamAnswer));
|
||||
}
|
||||
},
|
||||
(err) {
|
||||
if (!isClosed) {
|
||||
Log.error("Failed to send message: ${err.msg}");
|
||||
final metadata = OnetimeShotType.invalidSendMesssage.toMap();
|
||||
if (err.code != ErrorCode.Internal) {
|
||||
metadata[sendMessageErrorKey] = err.msg;
|
||||
}
|
||||
|
||||
final error = CustomMessage(
|
||||
final metadata = {
|
||||
onetimeShotType: OnetimeShotType.error,
|
||||
if (err.code != ErrorCode.Internal) errorMessageTextKey: err.msg,
|
||||
};
|
||||
|
||||
final error = TextMessage(
|
||||
text: '',
|
||||
metadata: metadata,
|
||||
author: const User(id: systemUserId),
|
||||
showStatus: false,
|
||||
id: systemUserId,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
add(const ChatEvent.failedSending());
|
||||
@@ -435,20 +420,18 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
AnswerStream stream,
|
||||
Int64 questionMessageId,
|
||||
) {
|
||||
final streamMessageId = (questionMessageId + 1).toString();
|
||||
answerStreamMessageId = streamMessageId;
|
||||
answerStreamMessageId = (questionMessageId + 1).toString();
|
||||
|
||||
return TextMessage(
|
||||
id: answerStreamMessageId,
|
||||
text: '',
|
||||
author: User(id: "streamId:${nanoid()}"),
|
||||
metadata: {
|
||||
"$AnswerStream": stream,
|
||||
messageQuestionIdKey: questionMessageId,
|
||||
"chatId": chatId,
|
||||
},
|
||||
id: streamMessageId,
|
||||
showStatus: false,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
text: '',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -457,24 +440,18 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
Map<String, dynamic>? sentMetadata,
|
||||
) {
|
||||
final now = DateTime.now();
|
||||
final timestamp = now.millisecondsSinceEpoch;
|
||||
questionStreamMessageId = timestamp.toString();
|
||||
final Map<String, dynamic> metadata = {};
|
||||
|
||||
// if (sentMetadata != null) {
|
||||
// metadata[messageMetadataJsonStringKey] = sentMetadata;
|
||||
// }
|
||||
questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
|
||||
|
||||
metadata["$QuestionStream"] = stream;
|
||||
metadata["chatId"] = chatId;
|
||||
metadata[messageChatFileListKey] =
|
||||
chatFilesFromMessageMetadata(sentMetadata);
|
||||
return TextMessage(
|
||||
author: User(id: state.userProfile.id.toString()),
|
||||
metadata: metadata,
|
||||
author: User(id: userId),
|
||||
metadata: {
|
||||
"$QuestionStream": stream,
|
||||
"chatId": chatId,
|
||||
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
|
||||
},
|
||||
id: questionStreamMessageId,
|
||||
showStatus: false,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
createdAt: now,
|
||||
text: '',
|
||||
);
|
||||
}
|
||||
@@ -491,8 +468,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
author: User(id: message.authorId),
|
||||
id: messageId,
|
||||
text: message.content,
|
||||
createdAt: message.createdAt.toInt() * 1000,
|
||||
showStatus: false,
|
||||
createdAt: message.createdAt.toDateTime(),
|
||||
metadata: {
|
||||
messageRefSourceJsonStringKey: message.metadata,
|
||||
},
|
||||
@@ -502,8 +478,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
|
||||
@freezed
|
||||
class ChatEvent with _$ChatEvent {
|
||||
const factory ChatEvent.initialLoad() = _InitialLoadMessage;
|
||||
|
||||
// send message
|
||||
const factory ChatEvent.sendMessage({
|
||||
required String message,
|
||||
@@ -513,71 +487,40 @@ class ChatEvent with _$ChatEvent {
|
||||
_FinishSendMessage;
|
||||
const factory ChatEvent.failedSending() = _FailSendMessage;
|
||||
|
||||
// receive message
|
||||
// streaming answer
|
||||
const factory ChatEvent.startAnswerStreaming(Message message) =
|
||||
_StartAnswerStreaming;
|
||||
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
|
||||
const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming;
|
||||
const factory ChatEvent.stopStream() = _StopStream;
|
||||
const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
|
||||
|
||||
// loading messages
|
||||
const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage;
|
||||
// receive message
|
||||
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
|
||||
|
||||
// loading messages
|
||||
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
|
||||
_DidLoadMessages;
|
||||
const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages;
|
||||
const factory ChatEvent.didLoadPreviousMessages(
|
||||
List<Message> messages,
|
||||
bool hasMore,
|
||||
) = _DidLoadPreviousMessages;
|
||||
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
|
||||
_DidLoadMessages;
|
||||
|
||||
// related questions
|
||||
const factory ChatEvent.didReceiveRelatedQuestion(
|
||||
List<RelatedQuestionPB> questions,
|
||||
// related questions
|
||||
const factory ChatEvent.didReceiveRelatedQuestions(
|
||||
List<String> questions,
|
||||
) = _DidReceiveRelatedQueston;
|
||||
const factory ChatEvent.clearRelatedQuestions() = _ClearRelatedQuestions;
|
||||
|
||||
const factory ChatEvent.didUpdateAnswerStream(
|
||||
AnswerStream stream,
|
||||
) = _DidUpdateAnswerStream;
|
||||
const factory ChatEvent.stopStream() = _StopStream;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatState with _$ChatState {
|
||||
const factory ChatState({
|
||||
required ViewPB view,
|
||||
required List<Message> messages,
|
||||
required UserProfilePB userProfile,
|
||||
// When opening the chat, the initial loading status will be set as loading.
|
||||
//After the initial loading is done, the status will be set as finished.
|
||||
required ChatLoadingState initialLoadingStatus,
|
||||
// When loading previous messages, the status will be set as loading.
|
||||
// After the loading is done, the status will be set as finished.
|
||||
required ChatLoadingState loadingPreviousStatus,
|
||||
// When sending a user message, the status will be set as loading.
|
||||
// After the message is sent, the status will be set as finished.
|
||||
required StreamingState streamingState,
|
||||
required SendMessageState sendingState,
|
||||
// Indicate whether there are more previous messages to load.
|
||||
required bool hasMorePrevMessage,
|
||||
// The related questions that are received after the user message is sent.
|
||||
required List<RelatedQuestionPB> relatedQuestions,
|
||||
@Default(false) bool acceptRelatedQuestion,
|
||||
// The last user message that is sent to the server.
|
||||
ChatMessagePB? lastSentMessage,
|
||||
AnswerStream? answerStream,
|
||||
@Default(true) bool canSendMessage,
|
||||
required ChatLoadingState loadingState,
|
||||
required PromptResponseState promptResponseState,
|
||||
}) = _ChatState;
|
||||
|
||||
factory ChatState.initial(ViewPB view, UserProfilePB userProfile) =>
|
||||
ChatState(
|
||||
view: view,
|
||||
messages: [],
|
||||
userProfile: userProfile,
|
||||
initialLoadingStatus: const ChatLoadingState.finish(),
|
||||
loadingPreviousStatus: const ChatLoadingState.finish(),
|
||||
streamingState: const StreamingState.done(),
|
||||
sendingState: const SendMessageState.done(),
|
||||
hasMorePrevMessage: true,
|
||||
relatedQuestions: [],
|
||||
factory ChatState.initial() => const ChatState(
|
||||
loadingState: ChatLoadingState.loading(),
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
@@ -10,7 +9,7 @@ import 'package:path/path.dart' as path;
|
||||
part 'chat_entity.g.dart';
|
||||
part 'chat_entity.freezed.dart';
|
||||
|
||||
const sendMessageErrorKey = "sendMessageError";
|
||||
const errorMessageTextKey = "errorMessageText";
|
||||
const systemUserId = "system";
|
||||
const aiResponseUserId = "0";
|
||||
|
||||
@@ -40,16 +39,25 @@ class ChatMessageRefSource {
|
||||
Map<String, dynamic> toJson() => _$ChatMessageRefSourceToJson(this);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class StreamingState with _$StreamingState {
|
||||
const factory StreamingState.streaming() = _Streaming;
|
||||
const factory StreamingState.done({FlowyError? error}) = _StreamDone;
|
||||
@JsonSerializable()
|
||||
class AIChatProgress {
|
||||
AIChatProgress({
|
||||
required this.step,
|
||||
});
|
||||
|
||||
factory AIChatProgress.fromJson(Map<String, dynamic> json) =>
|
||||
_$AIChatProgressFromJson(json);
|
||||
|
||||
final String step;
|
||||
|
||||
Map<String, dynamic> toJson() => _$AIChatProgressToJson(this);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SendMessageState with _$SendMessageState {
|
||||
const factory SendMessageState.sending() = _Sending;
|
||||
const factory SendMessageState.done({FlowyError? error}) = _SendDone;
|
||||
enum PromptResponseState {
|
||||
ready,
|
||||
sendingQuestion,
|
||||
awaitingAnswer,
|
||||
streamingAnswer,
|
||||
}
|
||||
|
||||
class ChatFile extends Equatable {
|
||||
@@ -112,45 +120,13 @@ extension ChatLoadingStateExtension on ChatLoadingState {
|
||||
}
|
||||
|
||||
enum OnetimeShotType {
|
||||
unknown,
|
||||
sendingMessage,
|
||||
relatedQuestion,
|
||||
invalidSendMesssage,
|
||||
error,
|
||||
}
|
||||
|
||||
const onetimeShotType = "OnetimeShotType";
|
||||
|
||||
extension OnetimeMessageTypeExtension on OnetimeShotType {
|
||||
static OnetimeShotType fromString(String value) {
|
||||
switch (value) {
|
||||
case 'OnetimeShotType.sendingMessage':
|
||||
return OnetimeShotType.sendingMessage;
|
||||
case 'OnetimeShotType.relatedQuestion':
|
||||
return OnetimeShotType.relatedQuestion;
|
||||
case 'OnetimeShotType.invalidSendMesssage':
|
||||
return OnetimeShotType.invalidSendMesssage;
|
||||
default:
|
||||
Log.error('Unknown OnetimeShotType: $value');
|
||||
return OnetimeShotType.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
onetimeShotType: toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
|
||||
if (metadata == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (final entry in metadata.entries) {
|
||||
if (entry.key == onetimeShotType) {
|
||||
return OnetimeMessageTypeExtension.fromString(entry.value as String);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return metadata?[onetimeShotType];
|
||||
}
|
||||
+78
-55
@@ -7,7 +7,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
/// Indicate file source from appflowy document
|
||||
@@ -66,78 +66,101 @@ ChatFile? chatFileFromMap(Map<String, dynamic>? map) {
|
||||
return ChatFile.fromFilePath(filePath);
|
||||
}
|
||||
|
||||
List<ChatMessageRefSource> messageReferenceSource(String? s) {
|
||||
if (s == null || s.isEmpty || s == "null") {
|
||||
return [];
|
||||
class MetadataCollection {
|
||||
MetadataCollection({
|
||||
required this.sources,
|
||||
this.progress,
|
||||
});
|
||||
final List<ChatMessageRefSource> sources;
|
||||
final AIChatProgress? progress;
|
||||
}
|
||||
|
||||
MetadataCollection parseMetadata(String? s) {
|
||||
if (s == null || s.trim().isEmpty || s.toLowerCase() == "null") {
|
||||
return MetadataCollection(sources: []);
|
||||
}
|
||||
|
||||
final List<ChatMessageRefSource> metadata = [];
|
||||
try {
|
||||
final metadataJson = jsonDecode(s);
|
||||
if (metadataJson == null) {
|
||||
Log.warn("metadata is null");
|
||||
return [];
|
||||
}
|
||||
// [{"id":null,"name":"The Five Dysfunctions of a Team.pdf","source":"/Users/weidongfu/Desktop/The Five Dysfunctions of a Team.pdf"}]
|
||||
AIChatProgress? progress;
|
||||
|
||||
if (metadataJson is Map<String, dynamic>) {
|
||||
if (metadataJson.isNotEmpty) {
|
||||
metadata.add(ChatMessageRefSource.fromJson(metadataJson));
|
||||
}
|
||||
} else if (metadataJson is List) {
|
||||
metadata.addAll(
|
||||
metadataJson.map(
|
||||
(e) => ChatMessageRefSource.fromJson(e as Map<String, dynamic>),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Log.error("Invalid metadata: $metadataJson");
|
||||
try {
|
||||
final dynamic decodedJson = jsonDecode(s);
|
||||
if (decodedJson == null) {
|
||||
return MetadataCollection(sources: []);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error("Failed to parse metadata: $e");
|
||||
|
||||
void processMap(Map<String, dynamic> map) {
|
||||
if (map.containsKey("step") && map["step"] != null) {
|
||||
progress = AIChatProgress.fromJson(map);
|
||||
} else if (map.containsKey("id") && map["id"] != null) {
|
||||
metadata.add(ChatMessageRefSource.fromJson(map));
|
||||
} else {
|
||||
Log.info("Unsupported metadata format: $map");
|
||||
}
|
||||
}
|
||||
|
||||
if (decodedJson is Map<String, dynamic>) {
|
||||
processMap(decodedJson);
|
||||
} else if (decodedJson is List) {
|
||||
for (final element in decodedJson) {
|
||||
if (element is Map<String, dynamic>) {
|
||||
processMap(element);
|
||||
} else {
|
||||
Log.error("Invalid metadata element: $element");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.error("Invalid metadata format: $decodedJson");
|
||||
}
|
||||
} catch (e, stacktrace) {
|
||||
Log.error("Failed to parse metadata: $e, input: $s");
|
||||
Log.debug(stacktrace.toString());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
return MetadataCollection(sources: metadata, progress: progress);
|
||||
}
|
||||
|
||||
Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
|
||||
Map<String, dynamic>? map,
|
||||
) async {
|
||||
if (map == null) return [];
|
||||
|
||||
final List<ChatMessageMetaPB> metadata = [];
|
||||
if (map != null) {
|
||||
for (final entry in map.entries) {
|
||||
if (entry.value is ViewActionPage) {
|
||||
if (entry.value.page is ViewPB) {
|
||||
final view = entry.value.page as ViewPB;
|
||||
if (view.layout.isDocumentView) {
|
||||
final payload = OpenDocumentPayloadPB(documentId: view.id);
|
||||
final result = await DocumentEventGetDocumentText(payload).send();
|
||||
result.fold((pb) {
|
||||
metadata.add(
|
||||
ChatMessageMetaPB(
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
data: pb.text,
|
||||
dataType: ChatMessageMetaTypePB.Txt,
|
||||
source: appflowySource,
|
||||
),
|
||||
);
|
||||
}, (err) {
|
||||
Log.error('Failed to get document text: $err');
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (entry.value is ChatFile) {
|
||||
|
||||
for (final value in map.values) {
|
||||
switch (value) {
|
||||
case ViewActionPage(view: final view) when view.layout.isDocumentView:
|
||||
final payload = OpenDocumentPayloadPB(documentId: view.id);
|
||||
await DocumentEventGetDocumentText(payload).send().fold(
|
||||
(pb) {
|
||||
metadata.add(
|
||||
ChatMessageMetaPB(
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
data: pb.text,
|
||||
dataType: ChatMessageMetaTypePB.Txt,
|
||||
source: appflowySource,
|
||||
),
|
||||
);
|
||||
},
|
||||
(err) => Log.error('Failed to get document text: $err'),
|
||||
);
|
||||
break;
|
||||
case ChatFile(
|
||||
filePath: final filePath,
|
||||
fileName: final fileName,
|
||||
fileType: final fileType,
|
||||
):
|
||||
metadata.add(
|
||||
ChatMessageMetaPB(
|
||||
id: nanoid(8),
|
||||
name: entry.value.fileName,
|
||||
data: entry.value.filePath,
|
||||
dataType: entry.value.fileType,
|
||||
source: entry.value.filePath,
|
||||
name: fileName,
|
||||
data: filePath,
|
||||
dataType: fileType,
|
||||
source: filePath,
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-22
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
|
||||
|
||||
class AnswerStream {
|
||||
@@ -14,34 +13,25 @@ class AnswerStream {
|
||||
_hasStarted = true;
|
||||
final newText = event.substring(5);
|
||||
_text += newText;
|
||||
if (_onData != null) {
|
||||
_onData!(_text);
|
||||
}
|
||||
_onData?.call(_text);
|
||||
} else if (event.startsWith("error:")) {
|
||||
_error = event.substring(5);
|
||||
if (_onError != null) {
|
||||
_onError!(_error!);
|
||||
}
|
||||
_onError?.call(_error!);
|
||||
} else if (event.startsWith("metadata:")) {
|
||||
if (_onMetadata != null) {
|
||||
final s = event.substring(9);
|
||||
_onMetadata!(messageReferenceSource(s));
|
||||
_onMetadata!(parseMetadata(s));
|
||||
}
|
||||
} else if (event == "AI_RESPONSE_LIMIT") {
|
||||
if (_onAIResponseLimit != null) {
|
||||
_onAIResponseLimit!();
|
||||
}
|
||||
_aiLimitReached = true;
|
||||
_onAIResponseLimit?.call();
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (_onEnd != null) {
|
||||
_onEnd!();
|
||||
}
|
||||
_onEnd?.call();
|
||||
},
|
||||
onError: (error) {
|
||||
if (_onError != null) {
|
||||
_onError!(error.toString());
|
||||
}
|
||||
_onError?.call(error.toString());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -50,6 +40,7 @@ class AnswerStream {
|
||||
final StreamController<String> _controller = StreamController.broadcast();
|
||||
late StreamSubscription<String> _subscription;
|
||||
bool _hasStarted = false;
|
||||
bool _aiLimitReached = false;
|
||||
String? _error;
|
||||
String _text = "";
|
||||
|
||||
@@ -59,10 +50,11 @@ class AnswerStream {
|
||||
void Function()? _onEnd;
|
||||
void Function(String error)? _onError;
|
||||
void Function()? _onAIResponseLimit;
|
||||
void Function(List<ChatMessageRefSource> metadata)? _onMetadata;
|
||||
void Function(MetadataCollection metadataCollection)? _onMetadata;
|
||||
|
||||
int get nativePort => _port.sendPort.nativePort;
|
||||
bool get hasStarted => _hasStarted;
|
||||
bool get aiLimitReached => _aiLimitReached;
|
||||
String? get error => _error;
|
||||
String get text => _text;
|
||||
|
||||
@@ -78,7 +70,7 @@ class AnswerStream {
|
||||
void Function()? onEnd,
|
||||
void Function(String error)? onError,
|
||||
void Function()? onAIResponseLimit,
|
||||
void Function(List<ChatMessageRefSource> metadata)? onMetadata,
|
||||
void Function(MetadataCollection metadata)? onMetadata,
|
||||
}) {
|
||||
_onData = onData;
|
||||
_onStart = onStart;
|
||||
@@ -87,9 +79,7 @@ class AnswerStream {
|
||||
_onAIResponseLimit = onAIResponseLimit;
|
||||
_onMetadata = onMetadata;
|
||||
|
||||
if (_onStart != null) {
|
||||
_onStart!();
|
||||
}
|
||||
_onStart?.call();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+72
-77
@@ -8,76 +8,19 @@ part 'chat_user_message_bloc.freezed.dart';
|
||||
class ChatUserMessageBloc
|
||||
extends Bloc<ChatUserMessageEvent, ChatUserMessageState> {
|
||||
ChatUserMessageBloc({
|
||||
required dynamic message,
|
||||
}) : super(
|
||||
ChatUserMessageState.initial(
|
||||
message,
|
||||
),
|
||||
) {
|
||||
required this.questionStream,
|
||||
required String text,
|
||||
}) : super(ChatUserMessageState.initial(text)) {
|
||||
_dispatch();
|
||||
_startListening();
|
||||
}
|
||||
|
||||
final QuestionStream? questionStream;
|
||||
|
||||
void _dispatch() {
|
||||
on<ChatUserMessageEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
initial: () {
|
||||
if (state.stream != null) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.updateText(state.stream!.text));
|
||||
}
|
||||
}
|
||||
|
||||
state.stream?.listen(
|
||||
onData: (text) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.updateText(text));
|
||||
}
|
||||
},
|
||||
onMessageId: (messageId) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.updateMessageId(messageId));
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.receiveError(error.toString()));
|
||||
}
|
||||
},
|
||||
onFileIndexStart: (indexName) {
|
||||
Log.debug("index start: $indexName");
|
||||
},
|
||||
onFileIndexEnd: (indexName) {
|
||||
Log.info("index end: $indexName");
|
||||
},
|
||||
onFileIndexFail: (indexName) {
|
||||
Log.debug("index fail: $indexName");
|
||||
},
|
||||
onIndexStart: () {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
const ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState.indexStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onIndexEnd: () {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
const ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState.indexEnd(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
const ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState.finish(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
updateText: (String text) {
|
||||
emit(state.copyWith(text: text));
|
||||
},
|
||||
@@ -92,11 +35,66 @@ class ChatUserMessageBloc
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _startListening() {
|
||||
questionStream?.listen(
|
||||
onData: (text) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.updateText(text));
|
||||
}
|
||||
},
|
||||
onMessageId: (messageId) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.updateMessageId(messageId));
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (!isClosed) {
|
||||
add(ChatUserMessageEvent.receiveError(error.toString()));
|
||||
}
|
||||
},
|
||||
onFileIndexStart: (indexName) {
|
||||
Log.debug("index start: $indexName");
|
||||
},
|
||||
onFileIndexEnd: (indexName) {
|
||||
Log.info("index end: $indexName");
|
||||
},
|
||||
onFileIndexFail: (indexName) {
|
||||
Log.debug("index fail: $indexName");
|
||||
},
|
||||
onIndexStart: () {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
const ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState.indexStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onIndexEnd: () {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
const ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState.indexEnd(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
const ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState.finish(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatUserMessageEvent with _$ChatUserMessageEvent {
|
||||
const factory ChatUserMessageEvent.initial() = Initial;
|
||||
const factory ChatUserMessageEvent.updateText(String text) = _UpdateText;
|
||||
const factory ChatUserMessageEvent.updateQuestionState(
|
||||
QuestionMessageState newState,
|
||||
@@ -110,17 +108,14 @@ class ChatUserMessageEvent with _$ChatUserMessageEvent {
|
||||
class ChatUserMessageState with _$ChatUserMessageState {
|
||||
const factory ChatUserMessageState({
|
||||
required String text,
|
||||
QuestionStream? stream,
|
||||
String? messageId,
|
||||
@Default(QuestionMessageState.finish()) QuestionMessageState messageState,
|
||||
required String? messageId,
|
||||
required QuestionMessageState messageState,
|
||||
}) = _ChatUserMessageState;
|
||||
|
||||
factory ChatUserMessageState.initial(
|
||||
dynamic message,
|
||||
) =>
|
||||
ChatUserMessageState(
|
||||
text: message is String ? message : "",
|
||||
stream: message is QuestionStream ? message : null,
|
||||
factory ChatUserMessageState.initial(String message) => ChatUserMessageState(
|
||||
text: message,
|
||||
messageId: null,
|
||||
messageState: const QuestionMessageState.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'chat_message_service.dart';
|
||||
|
||||
part 'chat_user_message_bubble_bloc.freezed.dart';
|
||||
|
||||
class ChatUserMessageBubbleBloc
|
||||
extends Bloc<ChatUserMessageBubbleEvent, ChatUserMessageBubbleState> {
|
||||
ChatUserMessageBubbleBloc({
|
||||
required Message message,
|
||||
}) : super(
|
||||
ChatUserMessageBubbleState.initial(
|
||||
message,
|
||||
_getFiles(message.metadata),
|
||||
),
|
||||
) {
|
||||
on<ChatUserMessageBubbleEvent>(
|
||||
(event, emit) async {
|
||||
event.when(
|
||||
initial: () {},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<ChatFile> _getFiles(Map<String, dynamic>? metadata) {
|
||||
if (metadata == null) {
|
||||
return [];
|
||||
}
|
||||
final refSourceMetadata = metadata[messageRefSourceJsonStringKey] as String?;
|
||||
final files = metadata[messageChatFileListKey] as List<ChatFile>?;
|
||||
|
||||
if (refSourceMetadata != null) {
|
||||
return chatFilesFromMetadataString(refSourceMetadata);
|
||||
}
|
||||
return files ?? [];
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatUserMessageBubbleEvent with _$ChatUserMessageBubbleEvent {
|
||||
const factory ChatUserMessageBubbleEvent.initial() = Initial;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatUserMessageBubbleState with _$ChatUserMessageBubbleState {
|
||||
const factory ChatUserMessageBubbleState({
|
||||
required Message message,
|
||||
required List<ChatFile> files,
|
||||
}) = _ChatUserMessageBubbleState;
|
||||
|
||||
factory ChatUserMessageBubbleState.initial(
|
||||
Message message,
|
||||
List<ChatFile> files,
|
||||
) =>
|
||||
ChatUserMessageBubbleState(message: message, files: files);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/chat_page.dart';
|
||||
import 'package:appflowy/plugins/util.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
|
||||
@@ -85,12 +86,16 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
|
||||
final ViewPluginNotifier notifier;
|
||||
int? deletedViewIndex;
|
||||
|
||||
@override
|
||||
String? get viewName => notifier.view.nameOrDefault;
|
||||
|
||||
@override
|
||||
Widget get leftBarItem =>
|
||||
ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view);
|
||||
|
||||
@override
|
||||
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
|
||||
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
|
||||
ViewTabBarItem(view: notifier.view, shortForm: shortForm);
|
||||
|
||||
@override
|
||||
Widget buildWidget({
|
||||
|
||||
@@ -4,32 +4,31 @@ import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/user_message_bubble.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:desktop_drop/desktop_drop.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_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat;
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
|
||||
hide ChatAnimatedListReversed;
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'application/chat_member_bloc.dart';
|
||||
import 'application/chat_side_panel_bloc.dart';
|
||||
import 'presentation/animated_chat_list.dart';
|
||||
import 'presentation/chat_input/desktop_ai_prompt_input.dart';
|
||||
import 'presentation/chat_input/mobile_ai_prompt_input.dart';
|
||||
import 'presentation/chat_side_panel.dart';
|
||||
import 'presentation/chat_theme.dart';
|
||||
import 'presentation/chat_user_invalid_message.dart';
|
||||
import 'presentation/chat_welcome_page.dart';
|
||||
import 'presentation/layout_define.dart';
|
||||
import 'presentation/message/ai_text_message.dart';
|
||||
import 'presentation/message/error_text_message.dart';
|
||||
import 'presentation/message/user_text_message.dart';
|
||||
import 'presentation/scroll_to_bottom.dart';
|
||||
|
||||
class AIChatPage extends StatelessWidget {
|
||||
const AIChatPage({
|
||||
@@ -59,9 +58,9 @@ class AIChatPage extends StatelessWidget {
|
||||
/// [ChatBloc] is used to handle chat messages including send/receive message
|
||||
BlocProvider(
|
||||
create: (_) => ChatBloc(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
)..add(const ChatEvent.initialLoad()),
|
||||
chatId: view.id,
|
||||
userId: userProfile.id.toString(),
|
||||
),
|
||||
),
|
||||
|
||||
/// [AIPromptInputBloc] is used to handle the user prompt
|
||||
@@ -69,20 +68,24 @@ class AIChatPage extends StatelessWidget {
|
||||
BlocProvider(create: (_) => ChatSidePanelBloc(chatId: view.id)),
|
||||
BlocProvider(create: (_) => ChatMemberBloc()),
|
||||
],
|
||||
child: DropTarget(
|
||||
onDragDone: (DropDoneDetails detail) async {
|
||||
if (context.read<AIPromptInputBloc>().state.supportChatWithFile) {
|
||||
for (final file in detail.files) {
|
||||
context
|
||||
.read<AIPromptInputBloc>()
|
||||
.add(AIPromptInputEvent.newFile(file.path, file.name));
|
||||
}
|
||||
}
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return DropTarget(
|
||||
onDragDone: (DropDoneDetails detail) async {
|
||||
if (context.read<AIPromptInputBloc>().state.supportChatWithFile) {
|
||||
for (final file in detail.files) {
|
||||
context
|
||||
.read<AIPromptInputBloc>()
|
||||
.add(AIPromptInputEvent.newFile(file.path, file.name));
|
||||
}
|
||||
}
|
||||
},
|
||||
child: _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -113,11 +116,11 @@ class _ChatContentPage extends StatelessWidget {
|
||||
return Row(
|
||||
children: [
|
||||
Center(
|
||||
child: buildChatWidget()
|
||||
child: buildChatWidget(context)
|
||||
.constrained(
|
||||
maxWidth: 784,
|
||||
)
|
||||
.padding(horizontal: 32)
|
||||
.padding(horizontal: 60)
|
||||
.animate(
|
||||
const Duration(milliseconds: 200),
|
||||
Curves.easeOut,
|
||||
@@ -148,7 +151,7 @@ class _ChatContentPage extends StatelessWidget {
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 784),
|
||||
child: buildChatWidget(),
|
||||
child: buildChatWidget(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -167,187 +170,187 @@ class _ChatContentPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildChatWidget() {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (_, state) => state.initialLoadingStatus.isFinish
|
||||
? Chat(
|
||||
messages: state.messages,
|
||||
dateHeaderBuilder: (_) => const SizedBox.shrink(),
|
||||
onSendPressed: (_) {
|
||||
// We use custom bottom widget for chat input, so
|
||||
// do not need to handle this event.
|
||||
},
|
||||
customBottomWidget: _buildBottom(context),
|
||||
user: types.User(id: userProfile.id.toString()),
|
||||
theme: _buildTheme(context),
|
||||
onEndReached: () async {
|
||||
if (state.hasMorePrevMessage &&
|
||||
state.loadingPreviousStatus.isFinish) {
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.startLoadingPrevMessage());
|
||||
}
|
||||
},
|
||||
emptyState: TextFieldTapRegion(
|
||||
child: ChatWelcomePage(
|
||||
userProfile: userProfile,
|
||||
onSelectedQuestion: (question) => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(message: question)),
|
||||
Widget buildChatWidget(BuildContext context) {
|
||||
return ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return state.loadingState.when(
|
||||
loading: () {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
},
|
||||
finish: (_) {
|
||||
final chatController = context.read<ChatBloc>().chatController;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Chat(
|
||||
chatController: chatController,
|
||||
user: User(id: userProfile.id.toString()),
|
||||
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||
theme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||
builders: Builders(
|
||||
inputBuilder: (_) => const SizedBox.shrink(),
|
||||
textMessageBuilder: _buildTextMessage,
|
||||
chatMessageBuilder: _buildChatMessage,
|
||||
scrollToBottomBuilder: _buildScrollToBottom,
|
||||
chatAnimatedListBuilder: _buildChatAnimatedList,
|
||||
),
|
||||
),
|
||||
messageWidthRatio: AIChatUILayout.messageWidthRatio,
|
||||
textMessageBuilder: (
|
||||
textMessage, {
|
||||
required messageWidth,
|
||||
required showName,
|
||||
}) =>
|
||||
_buildTextMessage(context, textMessage, state),
|
||||
customMessageBuilder: (message, {required messageWidth}) {
|
||||
final messageType = onetimeMessageTypeFromMeta(
|
||||
message.metadata,
|
||||
);
|
||||
|
||||
if (messageType == OnetimeShotType.invalidSendMesssage) {
|
||||
return ChatInvalidUserMessage(
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
if (messageType == OnetimeShotType.relatedQuestion) {
|
||||
return RelatedQuestionList(
|
||||
relatedQuestions: state.relatedQuestions,
|
||||
onQuestionSelected: (question) {
|
||||
final bloc = context.read<ChatBloc>();
|
||||
bloc
|
||||
..add(ChatEvent.sendMessage(message: question))
|
||||
..add(const ChatEvent.clearRelatedQuestions());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
bubbleBuilder: (
|
||||
child, {
|
||||
required message,
|
||||
required nextMessageInGroup,
|
||||
}) =>
|
||||
_buildBubble(context, message, child),
|
||||
)
|
||||
: const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
_buildInput(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextMessage(
|
||||
BuildContext context,
|
||||
TextMessage message,
|
||||
ChatState state,
|
||||
) {
|
||||
if (message.author.id == userProfile.id.toString()) {
|
||||
final stream = message.metadata?["$QuestionStream"];
|
||||
return ChatUserMessageWidget(
|
||||
key: ValueKey(message.id),
|
||||
user: message.author,
|
||||
message: stream is QuestionStream ? stream : message.text,
|
||||
);
|
||||
} else if (isOtherUserMessage(message)) {
|
||||
final stream = message.metadata?["$QuestionStream"];
|
||||
return ChatUserMessageWidget(
|
||||
key: ValueKey(message.id),
|
||||
user: message.author,
|
||||
message: stream is QuestionStream ? stream : message.text,
|
||||
);
|
||||
} else {
|
||||
final stream = message.metadata?["$AnswerStream"];
|
||||
final questionId = message.metadata?[messageQuestionIdKey];
|
||||
final refSourceJsonString =
|
||||
message.metadata?[messageRefSourceJsonStringKey] as String?;
|
||||
final messageType = onetimeMessageTypeFromMeta(
|
||||
message.metadata,
|
||||
);
|
||||
|
||||
return BlocSelector<ChatBloc, ChatState, bool>(
|
||||
key: ValueKey(message.id),
|
||||
selector: (state) {
|
||||
final messages = state.messages.where((e) {
|
||||
final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata);
|
||||
if (oneTimeMessageType == null) {
|
||||
return true;
|
||||
}
|
||||
if (oneTimeMessageType
|
||||
case OnetimeShotType.relatedQuestion ||
|
||||
OnetimeShotType.sendingMessage ||
|
||||
OnetimeShotType.invalidSendMesssage) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return messages.isEmpty ? false : messages.first.id == message.id;
|
||||
},
|
||||
builder: (context, isLastMessage) {
|
||||
return ChatAIMessageWidget(
|
||||
user: message.author,
|
||||
messageUserId: message.id,
|
||||
message: message,
|
||||
stream: stream is AnswerStream ? stream : null,
|
||||
questionId: questionId,
|
||||
chatId: view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
isLastMessage: isLastMessage,
|
||||
onSelectedMetadata: (metadata) {
|
||||
context
|
||||
.read<ChatSidePanelBloc>()
|
||||
.add(ChatSidePanelEvent.selectedMetadata(metadata));
|
||||
},
|
||||
);
|
||||
if (messageType == OnetimeShotType.error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: message.metadata?[errorMessageTextKey] ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
if (messageType == OnetimeShotType.relatedQuestion) {
|
||||
return RelatedQuestionList(
|
||||
relatedQuestions: message.metadata!['questions'],
|
||||
onQuestionSelected: (question) {
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(message: question));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBubble(
|
||||
BuildContext context,
|
||||
Message message,
|
||||
Widget child,
|
||||
) {
|
||||
if (message.author.id == userProfile.id.toString()) {
|
||||
return ChatUserMessageBubble(
|
||||
return ChatUserMessageWidget(
|
||||
user: message.author,
|
||||
message: message,
|
||||
child: child,
|
||||
isCurrentUser: true,
|
||||
);
|
||||
} else if (isOtherUserMessage(message)) {
|
||||
return ChatUserMessageBubble(
|
||||
}
|
||||
|
||||
if (isOtherUserMessage(message)) {
|
||||
return ChatUserMessageWidget(
|
||||
user: message.author,
|
||||
message: message,
|
||||
isCurrentUser: false,
|
||||
child: child,
|
||||
);
|
||||
} else {
|
||||
// The bubble is rendered in the child already
|
||||
return child;
|
||||
}
|
||||
|
||||
final stream = message.metadata?["$AnswerStream"];
|
||||
final questionId = message.metadata?[messageQuestionIdKey];
|
||||
final refSourceJsonString =
|
||||
message.metadata?[messageRefSourceJsonStringKey] as String?;
|
||||
|
||||
return BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) {
|
||||
final chatController = context.read<ChatBloc>().chatController;
|
||||
final messages = chatController.messages
|
||||
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
|
||||
return messages.isEmpty ? false : messages.last.id == message.id;
|
||||
},
|
||||
builder: (context, isLastMessage) {
|
||||
return ChatAIMessageWidget(
|
||||
user: message.author,
|
||||
messageUserId: message.id,
|
||||
message: message,
|
||||
stream: stream is AnswerStream ? stream : null,
|
||||
questionId: questionId,
|
||||
chatId: view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
isLastMessage: isLastMessage,
|
||||
onSelectedMetadata: (metadata) {
|
||||
context
|
||||
.read<ChatSidePanelBloc>()
|
||||
.add(ChatSidePanelEvent.selectedMetadata(metadata));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottom(BuildContext context) {
|
||||
Widget _buildChatMessage(
|
||||
BuildContext context,
|
||||
Message message,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) {
|
||||
return ChatMessage(
|
||||
message: message,
|
||||
animation: animation,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||
receivedMessageScaleAnimationAlignment: Alignment.center,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScrollToBottom(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
VoidCallback onPressed,
|
||||
) {
|
||||
return CustomScrollToBottom(
|
||||
animation: animation,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatAnimatedList(
|
||||
BuildContext context,
|
||||
ScrollController scrollController,
|
||||
ChatItem itemBuilder,
|
||||
) {
|
||||
final bloc = context.read<ChatBloc>();
|
||||
|
||||
if (bloc.chatController.messages.isEmpty) {
|
||||
return ChatWelcomePage(
|
||||
userProfile: userProfile,
|
||||
onSelectedQuestion: (question) {
|
||||
bloc.add(ChatEvent.sendMessage(message: question));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return ChatAnimatedListReversed(
|
||||
scrollController: scrollController,
|
||||
itemBuilder: itemBuilder,
|
||||
onLoadPreviousMessages: () {
|
||||
bloc.add(const ChatEvent.loadPreviousMessages());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInput(BuildContext context) {
|
||||
return Padding(
|
||||
padding: AIChatUILayout.safeAreaInsets(context),
|
||||
child: BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) => state.canSendMessage,
|
||||
selector: (state) {
|
||||
return state.promptResponseState == PromptResponseState.ready;
|
||||
},
|
||||
builder: (context, canSendMessage) {
|
||||
return UniversalPlatform.isDesktop
|
||||
? DesktopAIPromptInput(
|
||||
chatId: view.id,
|
||||
indicateFocus: true,
|
||||
onSubmitted: (message) {
|
||||
onSubmitted: (text, metadata) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: message.text,
|
||||
metadata: message.metadata,
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -358,11 +361,11 @@ class _ChatContentPage extends StatelessWidget {
|
||||
)
|
||||
: MobileAIPromptInput(
|
||||
chatId: view.id,
|
||||
onSubmitted: (message) {
|
||||
onSubmitted: (text, metadata) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: message.text,
|
||||
metadata: message.metadata,
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -375,11 +378,4 @@ class _ChatContentPage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AFDefaultChatTheme _buildTheme(BuildContext context) {
|
||||
return AFDefaultChatTheme(
|
||||
primaryColor: Theme.of(context).colorScheme.primary,
|
||||
secondaryColor: AFThemeExtension.of(context).tint1,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// ignore_for_file: implementation_imports
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:diffutil_dart/diffutil.dart' as diffutil;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
|
||||
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
|
||||
|
||||
class ChatAnimatedListReversed extends StatefulWidget {
|
||||
const ChatAnimatedListReversed({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
required this.itemBuilder,
|
||||
this.insertAnimationDuration = const Duration(milliseconds: 250),
|
||||
this.removeAnimationDuration = const Duration(milliseconds: 250),
|
||||
this.scrollToEndAnimationDuration = const Duration(milliseconds: 250),
|
||||
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
|
||||
this.bottomPadding = 8,
|
||||
this.onLoadPreviousMessages,
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
final ChatItem itemBuilder;
|
||||
final Duration insertAnimationDuration;
|
||||
final Duration removeAnimationDuration;
|
||||
final Duration scrollToEndAnimationDuration;
|
||||
final Duration scrollToBottomAppearanceDelay;
|
||||
final double? bottomPadding;
|
||||
final VoidCallback? onLoadPreviousMessages;
|
||||
|
||||
@override
|
||||
ChatAnimatedListReversedState createState() =>
|
||||
ChatAnimatedListReversedState();
|
||||
}
|
||||
|
||||
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
|
||||
late ChatController _chatController;
|
||||
late List<Message> _oldList;
|
||||
late StreamSubscription<ChatOperation> _operationsSubscription;
|
||||
|
||||
late final AnimationController _scrollToBottomController;
|
||||
late final Animation<double> _scrollToBottomAnimation;
|
||||
Timer? _scrollToBottomShowTimer;
|
||||
|
||||
bool _userHasScrolled = false;
|
||||
bool _isScrollingToBottom = false;
|
||||
String _lastInsertedMessageId = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatController = Provider.of<ChatController>(context, listen: false);
|
||||
// TODO: Add assert for messages having same id
|
||||
_oldList = List.from(_chatController.messages);
|
||||
_operationsSubscription = _chatController.operationsStream.listen((event) {
|
||||
switch (event.type) {
|
||||
case ChatOperationType.insert:
|
||||
assert(
|
||||
event.index != null,
|
||||
'Index must be provided when inserting a message.',
|
||||
);
|
||||
assert(
|
||||
event.message != null,
|
||||
'Message must be provided when inserting a message.',
|
||||
);
|
||||
_onInserted(0, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
break;
|
||||
case ChatOperationType.remove:
|
||||
assert(
|
||||
event.index != null,
|
||||
'Index must be provided when removing a message.',
|
||||
);
|
||||
assert(
|
||||
event.message != null,
|
||||
'Message must be provided when removing a message.',
|
||||
);
|
||||
_onRemoved(event.index!, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
break;
|
||||
case ChatOperationType.set:
|
||||
final newList = _chatController.messages;
|
||||
|
||||
final updates = diffutil
|
||||
.calculateDiff<Message>(
|
||||
MessageListDiff(_oldList, newList),
|
||||
)
|
||||
.getUpdatesWithData();
|
||||
|
||||
for (var i = updates.length - 1; i >= 0; i--) {
|
||||
_onDiffUpdate(updates.elementAt(i));
|
||||
}
|
||||
|
||||
_oldList = List.from(newList);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
_scrollToBottomController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
_scrollToBottomAnimation = CurvedAnimation(
|
||||
parent: _scrollToBottomController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
widget.scrollController.addListener(_handleLoadPreviousMessages);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleLoadPreviousMessages();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomController.dispose();
|
||||
_operationsSubscription.cancel();
|
||||
widget.scrollController.removeListener(_handleLoadPreviousMessages);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final builders = context.watch<Builders>();
|
||||
|
||||
return NotificationListener<Notification>(
|
||||
onNotification: (notification) {
|
||||
if (notification is UserScrollNotification) {
|
||||
// When user scrolls up, save it to `_userHasScrolled`
|
||||
if (notification.direction == ScrollDirection.reverse) {
|
||||
_userHasScrolled = true;
|
||||
} else {
|
||||
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
|
||||
if (notification.metrics.pixels ==
|
||||
notification.metrics.minScrollExtent) {
|
||||
_userHasScrolled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_handleToggleScrollToBottom();
|
||||
}
|
||||
|
||||
// Allow other listeners to get the notification
|
||||
return false;
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
reverse: true,
|
||||
controller: widget.scrollController,
|
||||
slivers: <Widget>[
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
top: widget.bottomPadding ?? 0,
|
||||
),
|
||||
),
|
||||
SliverAnimatedList(
|
||||
key: _listKey,
|
||||
initialItemCount: _chatController.messages.length,
|
||||
itemBuilder: (
|
||||
BuildContext context,
|
||||
int index,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
final message = _chatController.messages[
|
||||
max(_chatController.messages.length - 1 - index, 0)];
|
||||
return widget.itemBuilder(
|
||||
context,
|
||||
animation,
|
||||
message,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
builders.scrollToBottomBuilder?.call(
|
||||
context,
|
||||
_scrollToBottomAnimation,
|
||||
_handleScrollToBottom,
|
||||
) ??
|
||||
ScrollToBottom(
|
||||
animation: _scrollToBottomAnimation,
|
||||
onPressed: _handleScrollToBottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _subsequentScrollToEnd(Message data) async {
|
||||
final user = Provider.of<User>(context, listen: false);
|
||||
|
||||
// We only want to scroll to the bottom if user has not scrolled up
|
||||
// or if the message is sent by the current user.
|
||||
if (data.id == _lastInsertedMessageId &&
|
||||
widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent &&
|
||||
(user.id == data.author.id || !_userHasScrolled)) {
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
// Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
|
||||
// we need an additional jump to the end. Sometimes Flutter
|
||||
// will not scroll to the very end. Sometimes it will not scroll to the
|
||||
// very end even with this, so this is something that needs to be
|
||||
// addressed by the Flutter team.
|
||||
//
|
||||
// Additionally here we have a check for the message id, because
|
||||
// if new message arrives in the meantime it will trigger another
|
||||
// scroll to the end animation, making this logic redundant.
|
||||
if (data.id == _lastInsertedMessageId &&
|
||||
widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent &&
|
||||
(user.id == data.author.id || !_userHasScrolled)) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToEnd(Message data) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
_subsequentScrollToEnd(data);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleScrollToBottom() {
|
||||
_isScrollingToBottom = true;
|
||||
_scrollToBottomController.reverse();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
if (widget.scrollController.offset <
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
widget.scrollController.jumpTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
);
|
||||
}
|
||||
|
||||
_isScrollingToBottom = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleToggleScrollToBottom() {
|
||||
if (_isScrollingToBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
if (widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
_scrollToBottomShowTimer =
|
||||
Timer(widget.scrollToBottomAppearanceDelay, () {
|
||||
if (mounted) {
|
||||
_scrollToBottomController.forward();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (_scrollToBottomController.status != AnimationStatus.completed) {
|
||||
_scrollToBottomController.stop();
|
||||
}
|
||||
_scrollToBottomController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
void _onInserted(final int position, final Message data) {
|
||||
// There is a scroll notification listener the controls the
|
||||
// `_userHasScrolled` variable.
|
||||
//
|
||||
// If for some reason `_userHasScrolled` is true and the user is not at the
|
||||
// bottom of the list, set `_userHasScrolled` to false so that the scroll
|
||||
// animation is triggered.
|
||||
if (_userHasScrolled &&
|
||||
widget.scrollController.offset >=
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
_userHasScrolled = false;
|
||||
}
|
||||
|
||||
_listKey.currentState!.insertItem(
|
||||
position,
|
||||
duration: widget.insertAnimationDuration,
|
||||
);
|
||||
|
||||
// Used later to trigger scroll to end only for the last inserted message.
|
||||
_lastInsertedMessageId = data.id;
|
||||
|
||||
if (position == _oldList.length) {
|
||||
_scrollToEnd(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _onRemoved(final int position, final Message data) {
|
||||
final visualPosition = max(_oldList.length - position - 1, 0);
|
||||
_listKey.currentState!.removeItem(
|
||||
visualPosition,
|
||||
(context, animation) => widget.itemBuilder(
|
||||
context,
|
||||
animation,
|
||||
data,
|
||||
isRemoved: true,
|
||||
),
|
||||
duration: widget.removeAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
void _onChanged(int position, Message oldData, Message newData) {
|
||||
_onRemoved(position, oldData);
|
||||
_listKey.currentState!.insertItem(
|
||||
max(_oldList.length - position - 1, 0),
|
||||
duration: widget.insertAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
|
||||
update.when<void>(
|
||||
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
|
||||
remove: (pos, data) => _onRemoved(pos, data),
|
||||
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
|
||||
move: (_, __, ___) => throw UnimplementedError('unused'),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleLoadPreviousMessages() {
|
||||
if (widget.scrollController.offset >=
|
||||
widget.scrollController.position.maxScrollExtent) {
|
||||
widget.onLoadPreviousMessages?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-11
@@ -12,8 +12,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'ai_prompt_buttons.dart';
|
||||
@@ -25,7 +23,6 @@ class DesktopAIPromptInput extends StatefulWidget {
|
||||
super.key,
|
||||
required this.chatId,
|
||||
required this.indicateFocus,
|
||||
this.options = const InputOptions(),
|
||||
required this.isStreaming,
|
||||
required this.onStopStreaming,
|
||||
required this.onSubmitted,
|
||||
@@ -33,10 +30,9 @@ class DesktopAIPromptInput extends StatefulWidget {
|
||||
|
||||
final String chatId;
|
||||
final bool indicateFocus;
|
||||
final InputOptions options;
|
||||
final bool isStreaming;
|
||||
final void Function() onStopStreaming;
|
||||
final void Function(types.PartialText) onSubmitted;
|
||||
final void Function(String, Map<String, dynamic>) onSubmitted;
|
||||
|
||||
@override
|
||||
State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
|
||||
@@ -56,7 +52,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_textController = InputTextFieldController()
|
||||
_textController = TextEditingController()
|
||||
..addListener(_handleTextControllerChange);
|
||||
|
||||
_inputFocusNode = FocusNode(
|
||||
@@ -118,6 +114,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
@@ -209,11 +206,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
..addAll(mentionPageMetadata)
|
||||
..addAll(fileMetadata);
|
||||
|
||||
final partialText = types.PartialText(
|
||||
text: trimmedText,
|
||||
metadata: metadata,
|
||||
);
|
||||
widget.onSubmitted(partialText);
|
||||
widget.onSubmitted(trimmedText, metadata);
|
||||
}
|
||||
|
||||
void _handleTextControllerChange() {
|
||||
|
||||
+3
-11
@@ -12,8 +12,6 @@ import 'package:extended_text_field/extended_text_field.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_chat_types/flutter_chat_types.dart' as types;
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
|
||||
import 'ai_prompt_buttons.dart';
|
||||
import 'chat_input_span.dart';
|
||||
@@ -23,17 +21,15 @@ class MobileAIPromptInput extends StatefulWidget {
|
||||
const MobileAIPromptInput({
|
||||
super.key,
|
||||
required this.chatId,
|
||||
this.options = const InputOptions(),
|
||||
required this.isStreaming,
|
||||
required this.onStopStreaming,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final InputOptions options;
|
||||
final bool isStreaming;
|
||||
final void Function() onStopStreaming;
|
||||
final void Function(types.PartialText) onSubmitted;
|
||||
final void Function(String, Map<String, dynamic>) onSubmitted;
|
||||
|
||||
@override
|
||||
State<MobileAIPromptInput> createState() => _MobileAIPromptInputState();
|
||||
@@ -50,7 +46,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_textController = InputTextFieldController()
|
||||
_textController = TextEditingController()
|
||||
..addListener(_handleTextControllerChange);
|
||||
|
||||
_inputFocusNode = FocusNode();
|
||||
@@ -166,11 +162,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
..addAll(mentionPageMetadata)
|
||||
..addAll(fileMetadata);
|
||||
|
||||
final partialText = types.PartialText(
|
||||
text: trimmedText,
|
||||
metadata: metadata,
|
||||
);
|
||||
widget.onSubmitted(partialText);
|
||||
widget.onSubmitted(trimmedText, metadata);
|
||||
}
|
||||
|
||||
void _handleTextControllerChange() {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.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_animate/flutter_animate.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// An animated generating indicator for an AI response
|
||||
class ChatAILoading extends StatelessWidget {
|
||||
@@ -16,48 +18,56 @@ class ChatAILoading extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5);
|
||||
return SizedBox(
|
||||
height: 20,
|
||||
child: SeparatedRow(
|
||||
separatorBuilder: () => const HSpace(4),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 4.0),
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_generatingResponse.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: SizedBox(
|
||||
height: 20,
|
||||
child: SeparatedRow(
|
||||
separatorBuilder: () => const HSpace(4),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 4.0),
|
||||
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
|
||||
builder: (context, state) {
|
||||
return FlowyText(
|
||||
state.progress?.step ??
|
||||
LocaleKeys.chat_generatingResponse.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
buildDot(const Color(0xFF9327FF))
|
||||
.animate(onPlay: (controller) => controller.repeat())
|
||||
.slideY(duration: slice, begin: 0, end: -1)
|
||||
.then()
|
||||
.slideY(begin: -1, end: 1)
|
||||
.then()
|
||||
.slideY(begin: 1, end: 0)
|
||||
.then()
|
||||
.slideY(duration: slice * 2, begin: 0, end: 0),
|
||||
buildDot(const Color(0xFFFB006D))
|
||||
.animate(onPlay: (controller) => controller.repeat())
|
||||
.slideY(duration: slice, begin: 0, end: 0)
|
||||
.then()
|
||||
.slideY(begin: 0, end: -1)
|
||||
.then()
|
||||
.slideY(begin: -1, end: 1)
|
||||
.then()
|
||||
.slideY(begin: 1, end: 0)
|
||||
.then()
|
||||
.slideY(begin: 0, end: 0),
|
||||
buildDot(const Color(0xFFFFCE00))
|
||||
.animate(onPlay: (controller) => controller.repeat())
|
||||
.slideY(duration: slice * 2, begin: 0, end: 0)
|
||||
.then()
|
||||
.slideY(duration: slice, begin: 0, end: -1)
|
||||
.then()
|
||||
.slideY(begin: -1, end: 1)
|
||||
.then()
|
||||
.slideY(begin: 1, end: 0),
|
||||
],
|
||||
buildDot(const Color(0xFF9327FF))
|
||||
.animate(onPlay: (controller) => controller.repeat())
|
||||
.slideY(duration: slice, begin: 0, end: -1)
|
||||
.then()
|
||||
.slideY(begin: -1, end: 1)
|
||||
.then()
|
||||
.slideY(begin: 1, end: 0)
|
||||
.then()
|
||||
.slideY(duration: slice * 2, begin: 0, end: 0),
|
||||
buildDot(const Color(0xFFFB006D))
|
||||
.animate(onPlay: (controller) => controller.repeat())
|
||||
.slideY(duration: slice, begin: 0, end: 0)
|
||||
.then()
|
||||
.slideY(begin: 0, end: -1)
|
||||
.then()
|
||||
.slideY(begin: -1, end: 1)
|
||||
.then()
|
||||
.slideY(begin: 1, end: 0)
|
||||
.then()
|
||||
.slideY(begin: 0, end: 0),
|
||||
buildDot(const Color(0xFFFFCE00))
|
||||
.animate(onPlay: (controller) => controller.repeat())
|
||||
.slideY(duration: slice * 2, begin: 0, end: 0)
|
||||
.then()
|
||||
.slideY(duration: slice, begin: 0, end: -1)
|
||||
.then()
|
||||
.slideY(begin: -1, end: 1)
|
||||
.then()
|
||||
.slideY(begin: 1, end: 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+21
-15
@@ -1,6 +1,5 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
@@ -8,6 +7,8 @@ import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'layout_define.dart';
|
||||
|
||||
class RelatedQuestionList extends StatelessWidget {
|
||||
const RelatedQuestionList({
|
||||
super.key,
|
||||
@@ -16,7 +17,7 @@ class RelatedQuestionList extends StatelessWidget {
|
||||
});
|
||||
|
||||
final Function(String) onQuestionSelected;
|
||||
final List<RelatedQuestionPB> relatedQuestions;
|
||||
final List<String> relatedQuestions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -24,10 +25,8 @@ class RelatedQuestionList extends StatelessWidget {
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: relatedQuestions.length + 1,
|
||||
padding: const EdgeInsets.only(bottom: 8.0) +
|
||||
(UniversalPlatform.isMobile
|
||||
? const EdgeInsets.symmetric(horizontal: 16)
|
||||
: EdgeInsets.zero),
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin,
|
||||
separatorBuilder: (context, index) => const VSpace(4.0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
@@ -40,9 +39,12 @@ class RelatedQuestionList extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return RelatedQuestionItem(
|
||||
question: relatedQuestions[index - 1],
|
||||
onQuestionSelected: onQuestionSelected,
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: RelatedQuestionItem(
|
||||
question: relatedQuestions[index - 1],
|
||||
onQuestionSelected: onQuestionSelected,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -57,17 +59,21 @@ class RelatedQuestionItem extends StatelessWidget {
|
||||
super.key,
|
||||
});
|
||||
|
||||
final RelatedQuestionPB question;
|
||||
final String question;
|
||||
final Function(String) onQuestionSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyButton(
|
||||
text: FlowyText(
|
||||
question.content,
|
||||
lineHeight: 1.4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
text: Flexible(
|
||||
child: FlowyText(
|
||||
question,
|
||||
lineHeight: 1.4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
expandText: false,
|
||||
margin: UniversalPlatform.isMobile
|
||||
? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0)
|
||||
: const EdgeInsets.all(8.0),
|
||||
@@ -76,7 +82,7 @@ class RelatedQuestionItem extends StatelessWidget {
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: const Size.square(16.0),
|
||||
),
|
||||
onTap: () => onQuestionSelected(question.content),
|
||||
onTap: () => onQuestionSelected(question),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
|
||||
/// Default chat theme which extends [ChatTheme].
|
||||
@immutable
|
||||
class AFDefaultChatTheme extends ChatTheme {
|
||||
const AFDefaultChatTheme({
|
||||
required super.primaryColor,
|
||||
required super.secondaryColor,
|
||||
}) : super(
|
||||
backgroundColor: Colors.transparent,
|
||||
// TODO: think how to offset the default 12 pixels set by chat package
|
||||
bubbleMargin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
messageMaxWidth: double.infinity,
|
||||
// unused
|
||||
dateDividerMargin: EdgeInsets.zero,
|
||||
dateDividerTextStyle: const TextStyle(),
|
||||
attachmentButtonIcon: null,
|
||||
attachmentButtonMargin: null,
|
||||
deliveredIcon: null,
|
||||
documentIcon: null,
|
||||
emptyChatPlaceholderTextStyle: const TextStyle(),
|
||||
errorColor: error,
|
||||
errorIcon: null,
|
||||
inputBackgroundColor: neutral0,
|
||||
inputSurfaceTintColor: neutral0,
|
||||
inputElevation: 0,
|
||||
inputBorderRadius: BorderRadius.zero,
|
||||
inputContainerDecoration: null,
|
||||
inputMargin: EdgeInsets.zero,
|
||||
inputPadding: EdgeInsets.zero,
|
||||
inputTextColor: neutral7,
|
||||
inputTextCursorColor: null,
|
||||
inputTextDecoration: const InputDecoration(),
|
||||
inputTextStyle: const TextStyle(),
|
||||
messageBorderRadius: 0,
|
||||
messageInsetsHorizontal: 0,
|
||||
messageInsetsVertical: 0,
|
||||
receivedEmojiMessageTextStyle: const TextStyle(),
|
||||
receivedMessageBodyBoldTextStyle: null,
|
||||
receivedMessageBodyCodeTextStyle: null,
|
||||
receivedMessageBodyLinkTextStyle: null,
|
||||
receivedMessageBodyTextStyle: const TextStyle(),
|
||||
receivedMessageDocumentIconColor: primary,
|
||||
receivedMessageCaptionTextStyle: const TextStyle(),
|
||||
receivedMessageLinkDescriptionTextStyle: const TextStyle(),
|
||||
receivedMessageLinkTitleTextStyle: const TextStyle(),
|
||||
seenIcon: null,
|
||||
sendButtonIcon: null,
|
||||
sendButtonMargin: null,
|
||||
sendingIcon: null,
|
||||
sentEmojiMessageTextStyle: const TextStyle(),
|
||||
sentMessageBodyBoldTextStyle: null,
|
||||
sentMessageBodyCodeTextStyle: null,
|
||||
sentMessageBodyLinkTextStyle: null,
|
||||
sentMessageBodyTextStyle: const TextStyle(),
|
||||
sentMessageCaptionTextStyle: const TextStyle(),
|
||||
sentMessageDocumentIconColor: neutral7,
|
||||
sentMessageLinkDescriptionTextStyle: const TextStyle(),
|
||||
sentMessageLinkTitleTextStyle: const TextStyle(),
|
||||
statusIconPadding: EdgeInsets.zero,
|
||||
systemMessageTheme: const SystemMessageTheme(
|
||||
margin: EdgeInsets.zero,
|
||||
textStyle: TextStyle(),
|
||||
),
|
||||
typingIndicatorTheme: const TypingIndicatorTheme(
|
||||
animatedCirclesColor: neutral1,
|
||||
animatedCircleSize: 0.0,
|
||||
bubbleBorder: BorderRadius.zero,
|
||||
bubbleColor: neutral7,
|
||||
countAvatarColor: primary,
|
||||
countTextColor: secondary,
|
||||
multipleUserTextStyle: TextStyle(),
|
||||
),
|
||||
unreadHeaderTheme: const UnreadHeaderTheme(
|
||||
color: secondary,
|
||||
textStyle: TextStyle(),
|
||||
),
|
||||
userAvatarImageBackgroundColor: Colors.transparent,
|
||||
userAvatarNameColors: colors,
|
||||
userAvatarTextStyle: const TextStyle(),
|
||||
userNameTextStyle: const TextStyle(),
|
||||
highlightMessageColor: null,
|
||||
);
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class ChatInvalidUserMessage extends StatelessWidget {
|
||||
const ChatInvalidUserMessage({
|
||||
required this.message,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final errorMessage = message.metadata?[sendMessageErrorKey] ?? "";
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 16.0, bottom: 24.0),
|
||||
constraints: UniversalPlatform.isDesktop
|
||||
? const BoxConstraints(maxWidth: 480)
|
||||
: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).isLightMode
|
||||
? const Color(0x80FFE7EE)
|
||||
: const Color(0x80591734),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.warning_filled_s,
|
||||
blendMode: null,
|
||||
),
|
||||
const HSpace(8.0),
|
||||
Flexible(
|
||||
child: FlowyText(
|
||||
errorMessage,
|
||||
lineHeight: 1.4,
|
||||
maxLines: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ class WelcomeSampleQuestion extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AutoScrollingSampleQuestions extends StatelessWidget {
|
||||
class _AutoScrollingSampleQuestions extends StatefulWidget {
|
||||
const _AutoScrollingSampleQuestions({
|
||||
super.key,
|
||||
required this.questions,
|
||||
@@ -194,17 +194,29 @@ class _AutoScrollingSampleQuestions extends StatelessWidget {
|
||||
final double offset;
|
||||
final bool reverse;
|
||||
|
||||
@override
|
||||
State<_AutoScrollingSampleQuestions> createState() =>
|
||||
_AutoScrollingSampleQuestionsState();
|
||||
}
|
||||
|
||||
class _AutoScrollingSampleQuestionsState
|
||||
extends State<_AutoScrollingSampleQuestions> {
|
||||
late final scrollController = ScrollController(
|
||||
initialScrollOffset: widget.offset,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: InfiniteScrollView(
|
||||
scrollController: scrollController,
|
||||
centerKey: UniqueKey(),
|
||||
itemCount: questions.length,
|
||||
itemCount: widget.questions.length,
|
||||
itemBuilder: (context, index) {
|
||||
return WelcomeSampleQuestion(
|
||||
question: questions[index],
|
||||
onSelected: onSelected,
|
||||
question: widget.questions[index],
|
||||
onSelected: widget.onSelected,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const HSpace(8),
|
||||
|
||||
@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class AIChatUILayout {
|
||||
static double get messageWidthRatio => 0.94; // Chat adds extra 0.06
|
||||
|
||||
static EdgeInsets safeAreaInsets(BuildContext context) {
|
||||
final query = MediaQuery.of(context);
|
||||
return UniversalPlatform.isMobile
|
||||
@@ -15,6 +13,10 @@ class AIChatUILayout {
|
||||
)
|
||||
: const EdgeInsets.only(bottom: 16);
|
||||
}
|
||||
|
||||
static EdgeInsets get messageMargin => UniversalPlatform.isMobile
|
||||
? const EdgeInsets.symmetric(horizontal: 16)
|
||||
: EdgeInsets.zero;
|
||||
}
|
||||
|
||||
class DesktopAIPromptSizes {
|
||||
|
||||
+9
-7
@@ -4,7 +4,6 @@ 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/flowy_mobile_quick_action_button.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
@@ -14,16 +13,16 @@ 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_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../chat_avatar.dart';
|
||||
import '../layout_define.dart';
|
||||
|
||||
/// Wraps an AI response message with the avatar and actions. On desktop,
|
||||
/// the actions will be displayed below the response if the response is the
|
||||
/// last message in the chat. For other AI responses, the actions will be shown
|
||||
/// on hover. On mobile, the actions will be displayed in a bottom sheet on
|
||||
/// long press.
|
||||
/// last message in the chat. For the others, the actions will be shown on hover
|
||||
/// On mobile, the actions will be displayed in a bottom sheet on long press.
|
||||
class ChatAIMessageBubble extends StatelessWidget {
|
||||
const ChatAIMessageBubble({
|
||||
super.key,
|
||||
@@ -293,7 +292,11 @@ class AIResponseActionBar extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: DesktopAIConvoSizes.actionBarIconRadius,
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
border: Border.all(
|
||||
color: isLightMode
|
||||
? const Color(0x1F1F2329)
|
||||
: Theme.of(context).dividerColor,
|
||||
),
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
@@ -341,7 +344,6 @@ class CopyButton extends StatelessWidget {
|
||||
child: FlowyIconButton(
|
||||
width: DesktopAIConvoSizes.actionBarIconSize,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
fillColor: Theme.of(context).cardColor,
|
||||
radius: DesktopAIConvoSizes.actionBarIconRadius,
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.copy_s,
|
||||
|
||||
@@ -17,7 +17,7 @@ class AIMessageMetadata extends StatefulWidget {
|
||||
});
|
||||
|
||||
final List<ChatMessageRefSource> sources;
|
||||
final void Function(ChatMessageRefSource metadata) onSelectedMetadata;
|
||||
final void Function(ChatMessageRefSource metadata)? onSelectedMetadata;
|
||||
|
||||
@override
|
||||
State<AIMessageMetadata> createState() => _AIMessageMetadataState();
|
||||
@@ -92,7 +92,7 @@ class _AIMessageMetadataState extends State<AIMessageMetadata> {
|
||||
if (m.source != appflowySource) {
|
||||
return;
|
||||
}
|
||||
widget.onSelectedMetadata(m);
|
||||
widget.onSelectedMetadata?.call(m);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
+28
-154
@@ -1,23 +1,20 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_loading.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/gestures.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_chat_types/flutter_chat_types.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
|
||||
import '../chat_loading.dart';
|
||||
import '../layout_define.dart';
|
||||
import 'ai_markdown_text.dart';
|
||||
import 'ai_message_bubble.dart';
|
||||
import 'ai_metadata.dart';
|
||||
import 'error_text_message.dart';
|
||||
|
||||
/// [ChatAIMessageWidget] includes both the text of the AI response as well as
|
||||
/// the avatar, decorations and hover effects that are also rendered. This is
|
||||
@@ -34,7 +31,7 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
required this.questionId,
|
||||
required this.chatId,
|
||||
required this.refSourceJsonString,
|
||||
required this.onSelectedMetadata,
|
||||
this.onSelectedMetadata,
|
||||
this.isLastMessage = false,
|
||||
});
|
||||
|
||||
@@ -46,7 +43,7 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
final Int64? questionId;
|
||||
final String chatId;
|
||||
final String? refSourceJsonString;
|
||||
final void Function(ChatMessageRefSource metadata) onSelectedMetadata;
|
||||
final void Function(ChatMessageRefSource metadata)? onSelectedMetadata;
|
||||
final bool isLastMessage;
|
||||
|
||||
@override
|
||||
@@ -61,23 +58,20 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
|
||||
builder: (context, state) {
|
||||
return Padding(
|
||||
padding: UniversalPlatform.isMobile
|
||||
? const EdgeInsets.symmetric(horizontal: 16)
|
||||
: EdgeInsets.zero,
|
||||
padding: AIChatUILayout.messageMargin,
|
||||
child: state.messageState.when(
|
||||
loading: () {
|
||||
return ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(top: 8.0),
|
||||
child: ChatAILoading(),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: const ChatAILoading(),
|
||||
),
|
||||
ready: () {
|
||||
return state.text.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
? ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: const ChatAILoading(),
|
||||
)
|
||||
: ChatAIMessageBubble(
|
||||
message: message,
|
||||
isLastMessage: isLastMessage,
|
||||
@@ -91,12 +85,15 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
sources: state.sources,
|
||||
onSelectedMetadata: onSelectedMetadata,
|
||||
),
|
||||
if (state.sources.isNotEmpty && !isLastMessage)
|
||||
const VSpace(16.0),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err) {
|
||||
return StreamingError(
|
||||
onError: (error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
|
||||
onRetry: () {
|
||||
context
|
||||
.read<ChatAIMessageBloc>()
|
||||
@@ -105,7 +102,10 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
return const AIResponseLimitReachedError();
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage:
|
||||
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -114,129 +114,3 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StreamingError extends StatefulWidget {
|
||||
const StreamingError({
|
||||
required this.onRetry,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
State<StreamingError> createState() => _StreamingErrorState();
|
||||
}
|
||||
|
||||
class _StreamingErrorState extends State<StreamingError> {
|
||||
late final TapGestureRecognizer recognizer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
recognizer = TapGestureRecognizer()..onTap = widget.onRetry;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
recognizer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 16.0, bottom: 24.0),
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).isLightMode
|
||||
? const Color(0x80FFE7EE)
|
||||
: const Color(0x80591734),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
),
|
||||
constraints: _errorConstraints(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.warning_filled_s,
|
||||
blendMode: null,
|
||||
),
|
||||
const HSpace(8.0),
|
||||
Flexible(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: LocaleKeys.chat_aiServerUnavailable.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
TextSpan(
|
||||
text: " ",
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.chat_retry.tr(),
|
||||
recognizer: recognizer,
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AIResponseLimitReachedError extends StatelessWidget {
|
||||
const AIResponseLimitReachedError({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 16.0, bottom: 24.0),
|
||||
constraints: _errorConstraints(),
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).isLightMode
|
||||
? const Color(0x80FFE7EE)
|
||||
: const Color(0x80591734),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.warning_filled_s,
|
||||
blendMode: null,
|
||||
),
|
||||
const HSpace(8.0),
|
||||
Flexible(
|
||||
child: FlowyText(
|
||||
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
|
||||
lineHeight: 1.4,
|
||||
maxLines: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BoxConstraints _errorConstraints() {
|
||||
return UniversalPlatform.isDesktop
|
||||
? const BoxConstraints(maxWidth: 480)
|
||||
: const BoxConstraints();
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class ChatErrorMessageWidget extends StatefulWidget {
|
||||
const ChatErrorMessageWidget({
|
||||
super.key,
|
||||
required this.errorMessage,
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
final String errorMessage;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
State<ChatErrorMessageWidget> createState() => _ChatErrorMessageWidgetState();
|
||||
}
|
||||
|
||||
class _ChatErrorMessageWidgetState extends State<ChatErrorMessageWidget> {
|
||||
late final TapGestureRecognizer recognizer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
recognizer = TapGestureRecognizer()..onTap = widget.onRetry;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
recognizer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 16.0, bottom: 24.0) +
|
||||
(UniversalPlatform.isMobile
|
||||
? const EdgeInsets.symmetric(horizontal: 16)
|
||||
: EdgeInsets.zero),
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).isLightMode
|
||||
? const Color(0x80FFE7EE)
|
||||
: const Color(0x80591734),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
),
|
||||
constraints: UniversalPlatform.isDesktop
|
||||
? const BoxConstraints(maxWidth: 480)
|
||||
: null,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.warning_filled_s,
|
||||
blendMode: null,
|
||||
),
|
||||
const HSpace(8.0),
|
||||
Flexible(
|
||||
child: _buildText(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildText() {
|
||||
final errorMessage = widget.errorMessage;
|
||||
|
||||
return widget.onRetry != null
|
||||
? RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: errorMessage,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.chat_retry.tr(),
|
||||
recognizer: recognizer,
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: FlowyText(
|
||||
errorMessage,
|
||||
lineHeight: 1.4,
|
||||
maxLines: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
-41
@@ -1,67 +1,54 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_member_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bubble_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
|
||||
import '../chat_avatar.dart';
|
||||
import '../layout_define.dart';
|
||||
|
||||
class ChatUserMessageBubble extends StatelessWidget {
|
||||
const ChatUserMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.child,
|
||||
this.isCurrentUser = true,
|
||||
required this.isCurrentUser,
|
||||
this.files = const [],
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final Widget child;
|
||||
final bool isCurrentUser;
|
||||
final List<ChatFile> files;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.read<ChatMemberBloc>().state.members[message.author.id] ==
|
||||
null) {
|
||||
context
|
||||
.read<ChatMemberBloc>()
|
||||
.add(ChatMemberEvent.getMemberInfo(message.author.id));
|
||||
}
|
||||
context
|
||||
.read<ChatMemberBloc>()
|
||||
.add(ChatMemberEvent.getMemberInfo(message.author.id));
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) => ChatUserMessageBubbleBloc(
|
||||
message: message,
|
||||
),
|
||||
child: BlocBuilder<ChatUserMessageBubbleBloc, ChatUserMessageBubbleState>(
|
||||
builder: (context, state) {
|
||||
return Padding(
|
||||
padding: UniversalPlatform.isMobile
|
||||
? const EdgeInsets.symmetric(horizontal: 16)
|
||||
: EdgeInsets.zero,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (state.files.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 32),
|
||||
child: _MessageFileList(files: state.files),
|
||||
),
|
||||
const VSpace(6),
|
||||
],
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: getChildren(context),
|
||||
),
|
||||
],
|
||||
return Padding(
|
||||
padding: AIChatUILayout.messageMargin,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (files.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 32),
|
||||
child: _MessageFileList(files: files),
|
||||
),
|
||||
);
|
||||
},
|
||||
const VSpace(6),
|
||||
],
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: getChildren(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+45
-21
@@ -1,46 +1,70 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bloc.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_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
|
||||
import 'user_message_bubble.dart';
|
||||
|
||||
class ChatUserMessageWidget extends StatelessWidget {
|
||||
const ChatUserMessageWidget({
|
||||
super.key,
|
||||
required this.user,
|
||||
required this.message,
|
||||
required this.isCurrentUser,
|
||||
});
|
||||
|
||||
final User user;
|
||||
final dynamic message;
|
||||
final TextMessage message;
|
||||
final bool isCurrentUser;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stream = message.metadata?["$QuestionStream"];
|
||||
final messageText = stream is QuestionStream ? stream.text : message.text;
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) => ChatUserMessageBloc(message: message)
|
||||
..add(const ChatUserMessageEvent.initial()),
|
||||
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
|
||||
builder: (context, state) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: TextMessageText(
|
||||
text: state.text,
|
||||
),
|
||||
create: (context) => ChatUserMessageBloc(
|
||||
text: messageText,
|
||||
questionStream: stream,
|
||||
),
|
||||
child: ChatUserMessageBubble(
|
||||
message: message,
|
||||
isCurrentUser: isCurrentUser,
|
||||
files: _getFiles(),
|
||||
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
|
||||
builder: (context, state) {
|
||||
return Opacity(
|
||||
opacity: state.messageState.isFinish ? 1.0 : 0.8,
|
||||
child: TextMessageText(
|
||||
text: state.text,
|
||||
),
|
||||
if (!state.messageState.isFinish) ...[
|
||||
const HSpace(6),
|
||||
const CircularProgressIndicator.adaptive(),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<ChatFile> _getFiles() {
|
||||
if (message.metadata == null) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final refSourceMetadata =
|
||||
message.metadata?[messageRefSourceJsonStringKey] as String?;
|
||||
if (refSourceMetadata != null) {
|
||||
return chatFilesFromMetadataString(refSourceMetadata);
|
||||
}
|
||||
|
||||
final chatFileList =
|
||||
message.metadata![messageChatFileListKey] as List<ChatFile>?;
|
||||
return chatFileList ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget to reuse the markdown capabilities, e.g., for previews.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const BorderRadius _borderRadius = BorderRadius.all(Radius.circular(16));
|
||||
|
||||
class CustomScrollToBottom extends StatelessWidget {
|
||||
const CustomScrollToBottom({
|
||||
super.key,
|
||||
required this.animation,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final Animation<double> animation;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLightMode = Theme.of(context).isLightMode;
|
||||
|
||||
return Positioned(
|
||||
bottom: 24,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: ScaleTransition(
|
||||
scale: animation,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
strokeAlign: BorderSide.strokeAlignOutside,
|
||||
),
|
||||
borderRadius: _borderRadius,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
offset: const Offset(0, 8),
|
||||
blurRadius: 16,
|
||||
spreadRadius: 8,
|
||||
color: isLightMode
|
||||
? const Color(0x0F1F2329)
|
||||
: Theme.of(context).shadowColor.withOpacity(0.06),
|
||||
),
|
||||
BoxShadow(
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 8,
|
||||
color: isLightMode
|
||||
? const Color(0x141F2329)
|
||||
: Theme.of(context).shadowColor.withOpacity(0.08),
|
||||
),
|
||||
BoxShadow(
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 4,
|
||||
color: isLightMode
|
||||
? const Color(0x1F1F2329)
|
||||
: Theme.of(context).shadowColor.withOpacity(0.12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
borderRadius: _borderRadius,
|
||||
color: Colors.transparent,
|
||||
borderOnForeground: false,
|
||||
child: InkWell(
|
||||
overlayColor: WidgetStateProperty.all(
|
||||
AFThemeExtension.of(context).lightGreyHover,
|
||||
),
|
||||
borderRadius: _borderRadius,
|
||||
onTap: onPressed,
|
||||
child: const SizedBox.square(
|
||||
dimension: 32,
|
||||
child: Center(
|
||||
child: FlowySvg(
|
||||
FlowySvgs.ai_scroll_to_bottom_s,
|
||||
size: Size.square(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,11 +44,14 @@ class BlankPagePlugin extends Plugin {
|
||||
|
||||
class BlankPagePluginWidgetBuilder extends PluginWidgetBuilder
|
||||
with NavigationItem {
|
||||
@override
|
||||
String? get viewName => LocaleKeys.blankPageTitle.tr();
|
||||
|
||||
@override
|
||||
Widget get leftBarItem => FlowyText.medium(LocaleKeys.blankPageTitle.tr());
|
||||
|
||||
@override
|
||||
Widget tabBarItem(String pluginId) => leftBarItem;
|
||||
Widget tabBarItem(String pluginId, [bool shortForm = false]) => leftBarItem;
|
||||
|
||||
@override
|
||||
Widget buildWidget({
|
||||
|
||||
@@ -293,7 +293,6 @@ class DatabaseTabBarController {
|
||||
OnViewChildViewChanged? onViewChildViewChanged;
|
||||
|
||||
Future<void> dispose() async {
|
||||
await viewListener.stop();
|
||||
await controller.dispose();
|
||||
await Future.wait([viewListener.stop(), controller.dispose()]);
|
||||
}
|
||||
}
|
||||
@@ -179,9 +179,7 @@ class _DesktopBoardPageState extends State<DesktopBoardPage> {
|
||||
_focusScope.dispose();
|
||||
_boardBloc.close();
|
||||
_boardActionsCubit.close();
|
||||
_didCreateRow
|
||||
..removeListener(_handleDidCreateRow)
|
||||
..dispose();
|
||||
_didCreateRow.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -189,7 +187,7 @@ class _DesktopBoardPageState extends State<DesktopBoardPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<BoardBloc>.value(
|
||||
BlocProvider.value(
|
||||
value: _boardBloc,
|
||||
),
|
||||
BlocProvider.value(
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:appflowy/plugins/shared/share/share_button.dart';
|
||||
import 'package:appflowy/plugins/util.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart';
|
||||
@@ -88,11 +89,11 @@ class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<DatabaseTabBarBloc>(
|
||||
create: (context) => DatabaseTabBarBloc(view: widget.view)
|
||||
create: (_) => DatabaseTabBarBloc(view: widget.view)
|
||||
..add(const DatabaseTabBarEvent.initial()),
|
||||
),
|
||||
BlocProvider<ViewBloc>(
|
||||
create: (context) =>
|
||||
create: (_) =>
|
||||
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),
|
||||
),
|
||||
],
|
||||
@@ -100,7 +101,7 @@ class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
|
||||
listeners: [
|
||||
BlocListener<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
listenWhen: (p, c) => p.selectedIndex != c.selectedIndex,
|
||||
listener: (context, state) {
|
||||
listener: (_, state) {
|
||||
_initialRowId = null;
|
||||
_pageController.jumpToPage(state.selectedIndex);
|
||||
},
|
||||
@@ -132,15 +133,22 @@ class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
|
||||
builder: (context, state) =>
|
||||
pageSettingBarExtensionFromState(state),
|
||||
),
|
||||
Expanded(
|
||||
child: BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
builder: (context, state) => PageView(
|
||||
BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
|
||||
builder: (context, state) {
|
||||
final content = PageView(
|
||||
controller: _pageController,
|
||||
pageSnapping: false,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: _pageController,
|
||||
children: pageContentFromState(state),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.shrinkWrap) {
|
||||
final layout = state.tabBars[state.selectedIndex].layout;
|
||||
return SizedBox(height: layout.pluginHeight, child: content);
|
||||
}
|
||||
|
||||
return Expanded(child: content);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -249,12 +257,16 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
|
||||
///
|
||||
final String? initialRowId;
|
||||
|
||||
@override
|
||||
String? get viewName => notifier.view.nameOrDefault;
|
||||
|
||||
@override
|
||||
Widget get leftBarItem =>
|
||||
ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view);
|
||||
|
||||
@override
|
||||
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
|
||||
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
|
||||
ViewTabBarItem(view: notifier.view, shortForm: shortForm);
|
||||
|
||||
@override
|
||||
Widget buildWidget({
|
||||
|
||||
+40
-13
@@ -27,6 +27,7 @@ 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:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:reorderables/reorderables.dart';
|
||||
|
||||
const _dropFileKey = 'files_media';
|
||||
@@ -151,9 +152,10 @@ class DekstopRowDetailMediaCellSkin extends IEditableMediaCellSkin {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvg(
|
||||
FlowySvgs.add_thin_s,
|
||||
size: Size.square(12),
|
||||
size: const Size.square(12),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
const HSpace(6),
|
||||
FlowyText.medium(
|
||||
@@ -219,7 +221,9 @@ class _FilePreviewFeedback extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AddFileButton extends StatelessWidget {
|
||||
const _menuWidth = 350.0;
|
||||
|
||||
class _AddFileButton extends StatefulWidget {
|
||||
const _AddFileButton({
|
||||
this.mutex,
|
||||
required this.controller,
|
||||
@@ -232,15 +236,22 @@ class _AddFileButton extends StatelessWidget {
|
||||
final PopoverDirection direction;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_AddFileButton> createState() => _AddFileButtonState();
|
||||
}
|
||||
|
||||
class _AddFileButtonState extends State<_AddFileButton> {
|
||||
Offset? position;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppFlowyPopover(
|
||||
triggerActions: PopoverTriggerFlags.none,
|
||||
controller: controller,
|
||||
mutex: mutex,
|
||||
controller: widget.controller,
|
||||
mutex: widget.mutex,
|
||||
offset: const Offset(0, 10),
|
||||
direction: direction,
|
||||
constraints: const BoxConstraints(maxWidth: 350),
|
||||
direction: widget.direction,
|
||||
constraints: const BoxConstraints(maxWidth: _menuWidth),
|
||||
margin: EdgeInsets.zero,
|
||||
onClose: () =>
|
||||
context.read<EditorDropManagerState>().remove(_dropFileKey),
|
||||
@@ -273,7 +284,7 @@ class _AddFileButton extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
controller.close();
|
||||
widget.controller.close();
|
||||
},
|
||||
),
|
||||
onInsertNetworkFile: (url) {
|
||||
@@ -306,14 +317,25 @@ class _AddFileButton extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
controller.close();
|
||||
widget.controller.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: controller.show,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: FlowyHover(resetHoverOnRebuild: false, child: child),
|
||||
child: MouseRegion(
|
||||
onEnter: (event) => position = event.position,
|
||||
onExit: (_) => position = null,
|
||||
onHover: (event) => position = event.position,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (position != null) {
|
||||
widget.controller.showAt(
|
||||
position! - const Offset(_menuWidth / 2, 0),
|
||||
);
|
||||
}
|
||||
},
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: FlowyHover(resetHoverOnRebuild: false, child: widget.child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -456,6 +478,11 @@ class _FilePreviewRenderState extends State<_FilePreviewRender> {
|
||||
onTap: widget.foregroundText != null
|
||||
? null
|
||||
: () {
|
||||
if (file.uploadType == FileUploadTypePB.LocalFile) {
|
||||
OpenFilex.open(file.url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.fileType != MediaFileTypePB.Image) {
|
||||
afLaunchUrlString(widget.file.url);
|
||||
return;
|
||||
|
||||
+6
-1
@@ -4,6 +4,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
@@ -97,6 +98,9 @@ class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder
|
||||
final String documentId;
|
||||
final Selection? initialSelection;
|
||||
|
||||
@override
|
||||
String? get viewName => view.nameOrDefault;
|
||||
|
||||
@override
|
||||
EdgeInsets get contentPadding => EdgeInsets.zero;
|
||||
|
||||
@@ -123,7 +127,8 @@ class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder
|
||||
ViewTitleBarWithRow(view: view, databaseId: databaseId, rowId: rowId);
|
||||
|
||||
@override
|
||||
Widget tabBarItem(String pluginId) => const SizedBox.shrink();
|
||||
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
|
||||
const SizedBox.shrink();
|
||||
|
||||
@override
|
||||
Widget? get rightBarItem => const SizedBox.shrink();
|
||||
|
||||
@@ -246,10 +246,16 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
(event) async {
|
||||
final time = event.$1;
|
||||
final transaction = event.$2;
|
||||
final options = event.$3;
|
||||
if (time != TransactionTime.before) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.inMemoryUpdate) {
|
||||
Log.info('skip transaction for in-memory update');
|
||||
return;
|
||||
}
|
||||
|
||||
if (enableDocumentInternalLog) {
|
||||
Log.debug(
|
||||
'[TransactionAdapter] 1. transaction before apply: ${transaction.hashCode}',
|
||||
|
||||
+14
-1
@@ -204,6 +204,19 @@ extension NodeToBlock on Node {
|
||||
}
|
||||
|
||||
String _dataAdapter(String type, Attributes attributes) {
|
||||
return jsonEncode(attributes);
|
||||
try {
|
||||
return jsonEncode(
|
||||
attributes,
|
||||
toEncodable: (value) {
|
||||
if (value is Map) {
|
||||
return jsonEncode(value);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
Log.error('encode attributes error: $e');
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -4,7 +4,7 @@ import 'dart:convert';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_component.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
@@ -176,7 +176,7 @@ extension on InsertOperation {
|
||||
Path currentPath = path;
|
||||
final List<BlockActionWrapper> actions = [];
|
||||
for (final node in nodes) {
|
||||
if (node.type == SmartEditBlockKeys.type) {
|
||||
if (node.type == AskAIBlockKeys.type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:appflowy/plugins/shared/share/share_button.dart';
|
||||
import 'package:appflowy/plugins/util.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart';
|
||||
@@ -145,11 +146,15 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String? get viewName => notifier.view.nameOrDefault;
|
||||
|
||||
@override
|
||||
Widget get leftBarItem => ViewTitleBar(key: ValueKey(view.id), view: view);
|
||||
|
||||
@override
|
||||
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
|
||||
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
|
||||
ViewTabBarItem(view: notifier.view, shortForm: shortForm);
|
||||
|
||||
@override
|
||||
Widget? get rightBarItem {
|
||||
|
||||
@@ -163,7 +163,11 @@ class _DocumentPageState extends State<DocumentPage>
|
||||
return Provider(
|
||||
create: (_) {
|
||||
final context = SharedEditorContext();
|
||||
if (widget.view.name.isEmpty) {
|
||||
final children = editorState.document.root.children;
|
||||
final firstDelta = children.firstOrNull?.delta;
|
||||
final isEmptyDocument =
|
||||
children.length == 1 && (firstDelta == null || firstDelta.isEmpty);
|
||||
if (widget.view.name.isEmpty && isEmptyDocument) {
|
||||
context.requestCoverTitleFocus = true;
|
||||
}
|
||||
return context;
|
||||
|
||||
+88
-9
@@ -125,8 +125,9 @@ void _customBlockOptionActions(
|
||||
final actions = _buildOptionActions(context, entry.key);
|
||||
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
builder.showActions =
|
||||
(node) => node.parent?.type != TableCellBlockKeys.type;
|
||||
builder.showActions = (node) =>
|
||||
node.parent?.type != TableCellBlockKeys.type &&
|
||||
node.parent?.type != SimpleTableCellBlockKeys.type;
|
||||
builder.configuration = builder.configuration.copyWith(
|
||||
blockSelectionAreaMargin: (_) => const EdgeInsets.symmetric(
|
||||
vertical: 1,
|
||||
@@ -142,6 +143,8 @@ void _customBlockOptionActions(
|
||||
level > 0) {
|
||||
final offset = [14.0, 11.0, 8.0, 6.0, 4.0, 2.0];
|
||||
top += offset[level - 1];
|
||||
} else if (type == SimpleTableBlockKeys.type) {
|
||||
top += 8.0;
|
||||
} else {
|
||||
top += 2.0;
|
||||
}
|
||||
@@ -157,6 +160,7 @@ void _customBlockOptionActions(
|
||||
? () => customSlashCommand(
|
||||
slashMenuItems,
|
||||
shouldInsertSlash: false,
|
||||
deleteKeywordsByDefault: true,
|
||||
style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||
).handler.call(editorState)
|
||||
: () {},
|
||||
@@ -252,11 +256,11 @@ Map<String, BlockComponentBuilder> _buildBlockComponentBuilderMap(
|
||||
configuration,
|
||||
styleCustomizer,
|
||||
),
|
||||
AutoCompletionBlockKeys.type: _buildAutoCompletionBlockComponentBuilder(
|
||||
AIWriterBlockKeys.type: _buildAIWriterBlockComponentBuilder(
|
||||
context,
|
||||
configuration,
|
||||
),
|
||||
SmartEditBlockKeys.type: _buildSmartEditBlockComponentBuilder(
|
||||
AskAIBlockKeys.type: _buildAskAIBlockComponentBuilder(
|
||||
context,
|
||||
configuration,
|
||||
),
|
||||
@@ -286,6 +290,18 @@ Map<String, BlockComponentBuilder> _buildBlockComponentBuilderMap(
|
||||
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
),
|
||||
SimpleTableBlockKeys.type: _buildSimpleTableBlockComponentBuilder(
|
||||
context,
|
||||
configuration,
|
||||
),
|
||||
SimpleTableRowBlockKeys.type: _buildSimpleTableRowBlockComponentBuilder(
|
||||
context,
|
||||
configuration,
|
||||
),
|
||||
SimpleTableCellBlockKeys.type: _buildSimpleTableCellBlockComponentBuilder(
|
||||
context,
|
||||
configuration,
|
||||
),
|
||||
};
|
||||
|
||||
final builders = {
|
||||
@@ -296,6 +312,27 @@ Map<String, BlockComponentBuilder> _buildBlockComponentBuilderMap(
|
||||
return builders;
|
||||
}
|
||||
|
||||
SimpleTableBlockComponentBuilder _buildSimpleTableBlockComponentBuilder(
|
||||
BuildContext context,
|
||||
BlockComponentConfiguration configuration,
|
||||
) {
|
||||
return SimpleTableBlockComponentBuilder(configuration: configuration);
|
||||
}
|
||||
|
||||
SimpleTableRowBlockComponentBuilder _buildSimpleTableRowBlockComponentBuilder(
|
||||
BuildContext context,
|
||||
BlockComponentConfiguration configuration,
|
||||
) {
|
||||
return SimpleTableRowBlockComponentBuilder(configuration: configuration);
|
||||
}
|
||||
|
||||
SimpleTableCellBlockComponentBuilder _buildSimpleTableCellBlockComponentBuilder(
|
||||
BuildContext context,
|
||||
BlockComponentConfiguration configuration,
|
||||
) {
|
||||
return SimpleTableCellBlockComponentBuilder(configuration: configuration);
|
||||
}
|
||||
|
||||
ParagraphBlockComponentBuilder _buildParagraphBlockComponentBuilder(
|
||||
BuildContext context,
|
||||
BlockComponentConfiguration configuration,
|
||||
@@ -303,7 +340,17 @@ ParagraphBlockComponentBuilder _buildParagraphBlockComponentBuilder(
|
||||
String Function(Node)? placeholderText,
|
||||
) {
|
||||
return ParagraphBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(placeholderText: placeholderText),
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: placeholderText,
|
||||
textStyle: (node) {
|
||||
if (node.isInHeaderColumn || node.isInHeaderRow) {
|
||||
return configuration.textStyle(node).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
}
|
||||
return configuration.textStyle(node);
|
||||
},
|
||||
),
|
||||
showPlaceholder: showParagraphPlaceholder,
|
||||
);
|
||||
}
|
||||
@@ -315,6 +362,14 @@ TodoListBlockComponentBuilder _buildTodoListBlockComponentBuilder(
|
||||
return TodoListBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: (_) => LocaleKeys.blockPlaceholders_todoList.tr(),
|
||||
textStyle: (node) {
|
||||
if (node.isInHeaderColumn || node.isInHeaderRow) {
|
||||
return configuration.textStyle(node).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
}
|
||||
return configuration.textStyle(node);
|
||||
},
|
||||
),
|
||||
iconBuilder: (_, node, onCheck) => TodoListIcon(
|
||||
node: node,
|
||||
@@ -335,6 +390,14 @@ BulletedListBlockComponentBuilder _buildBulletedListBlockComponentBuilder(
|
||||
return BulletedListBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: (_) => LocaleKeys.blockPlaceholders_bulletList.tr(),
|
||||
textStyle: (node) {
|
||||
if (node.isInHeaderColumn || node.isInHeaderRow) {
|
||||
return configuration.textStyle(node).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
}
|
||||
return configuration.textStyle(node);
|
||||
},
|
||||
),
|
||||
iconBuilder: (_, node) => BulletedListIcon(node: node),
|
||||
);
|
||||
@@ -347,6 +410,14 @@ NumberedListBlockComponentBuilder _buildNumberedListBlockComponentBuilder(
|
||||
return NumberedListBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: (_) => LocaleKeys.blockPlaceholders_numberList.tr(),
|
||||
textStyle: (node) {
|
||||
if (node.isInHeaderColumn || node.isInHeaderRow) {
|
||||
return configuration.textStyle(node).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
}
|
||||
return configuration.textStyle(node);
|
||||
},
|
||||
),
|
||||
iconBuilder: (_, node, textDirection) => NumberedListIcon(
|
||||
node: node,
|
||||
@@ -362,6 +433,14 @@ QuoteBlockComponentBuilder _buildQuoteBlockComponentBuilder(
|
||||
return QuoteBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: (_) => LocaleKeys.blockPlaceholders_quote.tr(),
|
||||
textStyle: (node) {
|
||||
if (node.isInHeaderColumn || node.isInHeaderRow) {
|
||||
return configuration.textStyle(node).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
}
|
||||
return configuration.textStyle(node);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -553,18 +632,18 @@ CodeBlockComponentBuilder _buildCodeBlockComponentBuilder(
|
||||
);
|
||||
}
|
||||
|
||||
AutoCompletionBlockComponentBuilder _buildAutoCompletionBlockComponentBuilder(
|
||||
AIWriterBlockComponentBuilder _buildAIWriterBlockComponentBuilder(
|
||||
BuildContext context,
|
||||
BlockComponentConfiguration configuration,
|
||||
) {
|
||||
return AutoCompletionBlockComponentBuilder();
|
||||
return AIWriterBlockComponentBuilder();
|
||||
}
|
||||
|
||||
SmartEditBlockComponentBuilder _buildSmartEditBlockComponentBuilder(
|
||||
AskAIBlockComponentBuilder _buildAskAIBlockComponentBuilder(
|
||||
BuildContext context,
|
||||
BlockComponentConfiguration configuration,
|
||||
) {
|
||||
return SmartEditBlockComponentBuilder();
|
||||
return AskAIBlockComponentBuilder();
|
||||
}
|
||||
|
||||
ToggleListBlockComponentBuilder _buildToggleListBlockComponentBuilder(
|
||||
|
||||
@@ -79,7 +79,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
];
|
||||
|
||||
final List<ToolbarItem> toolbarItems = [
|
||||
smartEditItem..isActive = onlyShowInTextType,
|
||||
askAIItem..isActive = onlyShowInTextType,
|
||||
paragraphItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
|
||||
headingsToolbarItem
|
||||
..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
|
||||
@@ -404,8 +404,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
}
|
||||
|
||||
List<SelectionMenuItem> _customSlashMenuItems() {
|
||||
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
|
||||
return [
|
||||
aiWriterSlashMenuItem,
|
||||
if (!isLocalMode) aiWriterSlashMenuItem,
|
||||
textSlashMenuItem,
|
||||
heading1SlashMenuItem,
|
||||
heading2SlashMenuItem,
|
||||
|
||||
+2
-2
@@ -34,7 +34,7 @@ class BlockActionList extends StatelessWidget {
|
||||
editorState: editorState,
|
||||
showSlashMenu: showSlashMenu,
|
||||
),
|
||||
const HSpace(4.0),
|
||||
const HSpace(2.0),
|
||||
BlockOptionButton(
|
||||
blockComponentContext: blockComponentContext,
|
||||
blockComponentState: blockComponentState,
|
||||
@@ -42,7 +42,7 @@ class BlockActionList extends StatelessWidget {
|
||||
editorState: editorState,
|
||||
blockComponentBuilder: blockComponentBuilder,
|
||||
),
|
||||
const HSpace(4.0),
|
||||
const HSpace(8.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+44
-1
@@ -86,6 +86,11 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
Log.error('Block type $type is not valid');
|
||||
if (node.type == TableBlockKeys.type) {
|
||||
copiedNode = _fixTableBlock(node);
|
||||
copiedNode = _convertTableToSimpleTable(copiedNode);
|
||||
}
|
||||
} else {
|
||||
if (node.type == TableBlockKeys.type) {
|
||||
copiedNode = _convertTableToSimpleTable(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +143,44 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
);
|
||||
}
|
||||
|
||||
Node _convertTableToSimpleTable(Node node) {
|
||||
if (node.type != TableBlockKeys.type) {
|
||||
return node;
|
||||
}
|
||||
|
||||
// the table node should contains colsLen and rowsLen
|
||||
final colsLen = node.attributes[TableBlockKeys.colsLen];
|
||||
final rowsLen = node.attributes[TableBlockKeys.rowsLen];
|
||||
if (colsLen == null || rowsLen == null) {
|
||||
return node;
|
||||
}
|
||||
|
||||
final rows = <List<Node>>[];
|
||||
final children = node.children;
|
||||
for (var i = 0; i < rowsLen; i++) {
|
||||
final row = <Node>[];
|
||||
for (var j = 0; j < colsLen; j++) {
|
||||
final cell = children
|
||||
.where(
|
||||
(n) =>
|
||||
n.attributes[TableCellBlockKeys.rowPosition] == i &&
|
||||
n.attributes[TableCellBlockKeys.colPosition] == j,
|
||||
)
|
||||
.firstOrNull;
|
||||
row.add(
|
||||
simpleTableCellBlockNode(
|
||||
children: [cell?.children.first.copyWith() ?? paragraphNode()],
|
||||
),
|
||||
);
|
||||
}
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
return simpleTableBlockNode(
|
||||
children: rows.map((e) => simpleTableRowBlockNode(children: e)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _copyLinkToBlock(Node node) async {
|
||||
final context = editorState.document.root.context;
|
||||
final viewId = context?.read<DocumentBloc>().documentId;
|
||||
@@ -447,7 +490,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
// We move views after applying transaction to avoid performing side-effects on the views
|
||||
final viewIdsToMove = _extractChildViewIds(selectedNodes);
|
||||
for (final viewId in viewIdsToMove) {
|
||||
// Attempt to put back from trash if neccessary
|
||||
// Attempt to put back from trash if necessary
|
||||
await TrashService.putback(viewId);
|
||||
|
||||
await ViewBackendService.moveViewV2(
|
||||
|
||||
+1
-4
@@ -2,7 +2,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -111,9 +110,7 @@ class _OptionButtonState extends State<OptionButton> {
|
||||
widget.blockComponentContext.node,
|
||||
beforeSelection,
|
||||
);
|
||||
Log.info(
|
||||
'update block selection, beforeSelection: $beforeSelection, afterSelection: $selection',
|
||||
);
|
||||
|
||||
widget.editorState.updateSelectionWithReason(
|
||||
selection,
|
||||
customSelectionType: SelectionType.block,
|
||||
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -46,7 +48,7 @@ class VisualDragArea extends StatelessWidget {
|
||||
|
||||
Widget child = Container(
|
||||
height: 2,
|
||||
width: width,
|
||||
width: max(width, 0.0),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
);
|
||||
|
||||
|
||||
+21
-31
@@ -1,7 +1,6 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
@@ -62,41 +61,35 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
if (snapshot.hasData && page != null) {
|
||||
return _build(context, page);
|
||||
}
|
||||
|
||||
if (snapshot.connectionState == ConnectionState.done) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
// just delete the page if it is not found
|
||||
_deletePage();
|
||||
});
|
||||
return const Center(
|
||||
child: FlowyText('Cannot load the page'),
|
||||
);
|
||||
// Delete the page if not found
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _deletePage());
|
||||
|
||||
return const Center(child: FlowyText('Cannot load the page'));
|
||||
}
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
future: future,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _build(BuildContext context, ViewPB viewPB) {
|
||||
Widget _build(BuildContext context, ViewPB view) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => widget.editorState.service.scrollService?.disable(),
|
||||
onExit: (_) => widget.editorState.service.scrollService?.enable(),
|
||||
child: SizedBox(
|
||||
height: viewPB.pluginType == PluginType.calendar ? 700 : 400,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMenu(context, viewPB),
|
||||
Expanded(child: _buildPage(context, viewPB)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMenu(context, view),
|
||||
_buildPage(context, view),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPage(BuildContext context, ViewPB viewPB) {
|
||||
Widget _buildPage(BuildContext context, ViewPB view) {
|
||||
return Focus(
|
||||
focusNode: focusNode,
|
||||
onFocusChange: (value) {
|
||||
@@ -104,11 +97,11 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
widget.editorState.service.selectionService.clearSelection();
|
||||
}
|
||||
},
|
||||
child: widget.builder(viewPB),
|
||||
child: widget.builder(view),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenu(BuildContext context, ViewPB viewPB) {
|
||||
Widget _buildMenu(BuildContext context, ViewPB view) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -116,7 +109,7 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
// information
|
||||
FlowyIconButton(
|
||||
tooltipText: LocaleKeys.tooltip_referencePage.tr(
|
||||
namedArgs: {'name': viewPB.layout.name},
|
||||
namedArgs: {'name': view.layout.name},
|
||||
),
|
||||
width: 24,
|
||||
height: 24,
|
||||
@@ -147,8 +140,8 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
case _ActionType.viewDatabase:
|
||||
getIt<TabsBloc>().add(
|
||||
TabsEvent.openPlugin(
|
||||
plugin: viewPB.plugin(),
|
||||
view: viewPB,
|
||||
plugin: view.plugin(),
|
||||
view: view,
|
||||
),
|
||||
);
|
||||
break;
|
||||
@@ -172,10 +165,7 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
enum _ActionType {
|
||||
viewDatabase,
|
||||
delete,
|
||||
}
|
||||
enum _ActionType { viewDatabase, delete }
|
||||
|
||||
class _ActionWrapper extends ActionCell {
|
||||
_ActionWrapper(this.inner);
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
class MarkdownTextRobot {
|
||||
MarkdownTextRobot({
|
||||
required this.editorState,
|
||||
this.enableDebug = true,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final bool enableDebug;
|
||||
|
||||
final Lock lock = Lock();
|
||||
|
||||
// The selection before the text robot is ready.
|
||||
Selection? _startSelection;
|
||||
|
||||
// The markdown text to be inserted.
|
||||
String _markdownText = '';
|
||||
|
||||
// Only for debug. Enable by [enableDebug].
|
||||
@visibleForTesting
|
||||
final List<String> debugMarkdownTexts = [];
|
||||
|
||||
// The nodes inserted in the previous refresh.
|
||||
Iterable<Node> _previousInsertedNodes = [];
|
||||
|
||||
/// Start the text robot.
|
||||
///
|
||||
/// Must call this function before using the text robot.
|
||||
void start() {
|
||||
_startSelection = editorState.selection;
|
||||
|
||||
if (enableDebug) {
|
||||
Log.info(
|
||||
'MarkdownTextRobot prepare, current selection: $_startSelection',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append the markdown text to the text robot.
|
||||
///
|
||||
/// The text will be inserted into document but not persisted until the text
|
||||
/// robot is stopped.
|
||||
Future<void> appendMarkdownText(String text) async {
|
||||
_markdownText += text;
|
||||
|
||||
await lock.synchronized(() async {
|
||||
await _refresh();
|
||||
});
|
||||
|
||||
if (enableDebug) {
|
||||
debugMarkdownTexts.add(text);
|
||||
Log.info('debug markdown texts: ${jsonEncode(debugMarkdownTexts)}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the text robot.
|
||||
///
|
||||
/// The text will be persisted into document.
|
||||
Future<void> stop() async {
|
||||
// persist the markdown text
|
||||
await lock.synchronized(() async {
|
||||
await _refresh(inMemoryUpdate: false);
|
||||
});
|
||||
|
||||
_markdownText = '';
|
||||
|
||||
if (enableDebug) {
|
||||
Log.info(
|
||||
'debug markdown texts: ${jsonEncode(debugMarkdownTexts)}',
|
||||
);
|
||||
debugMarkdownTexts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the editor state with the current markdown text by:
|
||||
///
|
||||
/// 1. Converting markdown to document nodes
|
||||
/// 2. Replacing previously inserted nodes with new nodes
|
||||
/// 3. Updating selection position
|
||||
Future<void> _refresh({bool inMemoryUpdate = true}) async {
|
||||
final start = _startSelection?.start;
|
||||
if (start == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
|
||||
// Convert markdown and deep copy nodes
|
||||
final nodes = customMarkdownToDocument(_markdownText).root.children.map(
|
||||
(node) => node.copyWith(),
|
||||
); // deep copy the nodes to avoid the linked entities being changed.
|
||||
|
||||
// Insert new nodes at selection start
|
||||
transaction.insertNodes(start.path, nodes);
|
||||
|
||||
// Remove previously inserted nodes if they exist
|
||||
if (_previousInsertedNodes.isNotEmpty) {
|
||||
// fallback to the calculated position if the selection is null.
|
||||
final end = editorState.selection?.end ??
|
||||
Position(
|
||||
path: start.path.nextNPath(_previousInsertedNodes.length - 1),
|
||||
);
|
||||
final deletedNodes = editorState.getNodesInSelection(
|
||||
Selection(start: start, end: end),
|
||||
);
|
||||
transaction.deleteNodes(deletedNodes);
|
||||
}
|
||||
|
||||
// Update selection to end of inserted content if it contains text
|
||||
final lastDelta = nodes.lastOrNull?.delta;
|
||||
if (lastDelta != null) {
|
||||
transaction.afterSelection = Selection.collapsed(
|
||||
Position(
|
||||
path: start.path.nextNPath(nodes.length - 1),
|
||||
offset: lastDelta.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: ApplyOptions(
|
||||
inMemoryUpdate: inMemoryUpdate,
|
||||
recordUndo: false,
|
||||
),
|
||||
);
|
||||
|
||||
_previousInsertedNodes = nodes;
|
||||
}
|
||||
}
|
||||
+122
-27
@@ -1,59 +1,154 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
enum TextRobotInputType {
|
||||
character,
|
||||
word,
|
||||
sentence,
|
||||
}
|
||||
|
||||
class TextRobot {
|
||||
const TextRobot({
|
||||
TextRobot({
|
||||
required this.editorState,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final Lock lock = Lock();
|
||||
|
||||
/// This function is used to insert text in a synchronized way
|
||||
///
|
||||
/// It is suitable for inserting text in a loop.
|
||||
Future<void> autoInsertTextSync(
|
||||
String text, {
|
||||
TextRobotInputType inputType = TextRobotInputType.word,
|
||||
Duration delay = const Duration(milliseconds: 10),
|
||||
String separator = '\n',
|
||||
}) async {
|
||||
await lock.synchronized(() async {
|
||||
await autoInsertText(
|
||||
text,
|
||||
inputType: inputType,
|
||||
delay: delay,
|
||||
separator: separator,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// This function is used to insert text in an asynchronous way
|
||||
///
|
||||
/// It is suitable for inserting a long paragraph or a long sentence.
|
||||
Future<void> autoInsertText(
|
||||
String text, {
|
||||
TextRobotInputType inputType = TextRobotInputType.word,
|
||||
Duration delay = const Duration(milliseconds: 10),
|
||||
String separator = '\n',
|
||||
}) async {
|
||||
if (text == '\n') {
|
||||
return editorState.insertNewLine();
|
||||
if (text == separator) {
|
||||
await insertNewParagraph(delay);
|
||||
return;
|
||||
}
|
||||
final lines = text.split('\n');
|
||||
final lines = _splitText(text, separator);
|
||||
for (final line in lines) {
|
||||
if (line.isEmpty) {
|
||||
await editorState.insertNewLine();
|
||||
await insertNewParagraph(delay);
|
||||
continue;
|
||||
}
|
||||
switch (inputType) {
|
||||
case TextRobotInputType.character:
|
||||
final iterator = line.runes.iterator;
|
||||
while (iterator.moveNext()) {
|
||||
await editorState.insertTextAtCurrentSelection(
|
||||
iterator.currentAsString,
|
||||
);
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
await insertCharacter(line, delay);
|
||||
break;
|
||||
case TextRobotInputType.word:
|
||||
final words = line.split(' ');
|
||||
if (words.length == 1 ||
|
||||
(words.length == 2 &&
|
||||
(words.first.isEmpty || words.last.isEmpty))) {
|
||||
await editorState.insertTextAtCurrentSelection(
|
||||
line,
|
||||
);
|
||||
} else {
|
||||
for (final word in words.map((e) => '$e ')) {
|
||||
await editorState.insertTextAtCurrentSelection(
|
||||
word,
|
||||
);
|
||||
}
|
||||
}
|
||||
await Future.delayed(delay);
|
||||
await insertWord(line, delay);
|
||||
break;
|
||||
case TextRobotInputType.sentence:
|
||||
await insertSentence(line, delay);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertCharacter(String line, Duration delay) async {
|
||||
final iterator = line.runes.iterator;
|
||||
while (iterator.moveNext()) {
|
||||
await insertText(iterator.currentAsString, delay);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertWord(String line, Duration delay) async {
|
||||
final words = line.split(' ');
|
||||
if (words.length == 1 ||
|
||||
(words.length == 2 && (words.first.isEmpty || words.last.isEmpty))) {
|
||||
await insertText(line, delay);
|
||||
} else {
|
||||
for (final word in words.map((e) => '$e ')) {
|
||||
await insertText(word, delay);
|
||||
}
|
||||
}
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
|
||||
Future<void> insertSentence(String line, Duration delay) async {
|
||||
await insertText(line, delay);
|
||||
}
|
||||
|
||||
Future<void> insertNewParagraph(Duration delay) async {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
final next = selection.end.path.next;
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertNode(
|
||||
next,
|
||||
paragraphNode(),
|
||||
);
|
||||
transaction.afterSelection = Selection.collapsed(
|
||||
Position(path: next),
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
debugPrint(
|
||||
'AI insertNewParagraph: path: ${editorState.selection!.end.path}, index: ${editorState.selection!.endIndex}',
|
||||
);
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
|
||||
Future<void> insertText(String text, Duration delay) async {
|
||||
final selection = editorState.selection;
|
||||
debugPrint(
|
||||
'AI insertText: get selection, path: ${selection!.end.path}, index: ${selection.endIndex}',
|
||||
);
|
||||
if (!selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertText(node, selection.endIndex, text);
|
||||
await editorState.apply(transaction);
|
||||
await Future.delayed(delay);
|
||||
|
||||
debugPrint(
|
||||
'AI insertText: path: ${selection.end.path}, index: ${selection.endIndex}, text: "$text"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _splitText(String text, String separator) {
|
||||
final parts = text.split(RegExp(separator));
|
||||
final result = <String>[];
|
||||
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
result.add(parts[i]);
|
||||
// Only add empty string if it's not the last part and the next part is not empty
|
||||
if (i < parts.length - 1 && parts[i + 1].isNotEmpty) {
|
||||
result.add('');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
+35
-6
@@ -1,11 +1,11 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
|
||||
|
||||
/// Copy.
|
||||
///
|
||||
@@ -59,11 +59,12 @@ KeyEventResult handleCopyCommand(
|
||||
// plain text.
|
||||
text = editorState.getTextInSelection(selection).join('\n');
|
||||
|
||||
final selectedNodes = editorState.getSelectedNodes(selection: selection);
|
||||
final nodes = _handleSubPageNodes(selectedNodes, isCut);
|
||||
final document = Document.blank()..insert([0], nodes);
|
||||
final document = _buildCopiedDocument(
|
||||
editorState,
|
||||
selection,
|
||||
isCut: isCut,
|
||||
);
|
||||
|
||||
// in app json
|
||||
inAppJson = jsonEncode(document.toJson());
|
||||
|
||||
// html
|
||||
@@ -83,6 +84,34 @@ KeyEventResult handleCopyCommand(
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
Document _buildCopiedDocument(
|
||||
EditorState editorState,
|
||||
Selection selection, {
|
||||
bool isCut = false,
|
||||
}) {
|
||||
// filter the table nodes
|
||||
final filteredNodes = <Node>[];
|
||||
final selectedNodes = editorState.getSelectedNodes(selection: selection);
|
||||
final nodes = _handleSubPageNodes(selectedNodes, isCut);
|
||||
for (final node in nodes) {
|
||||
if (node.type == SimpleTableCellBlockKeys.type) {
|
||||
// if the node is a table cell, we will fetch its children instead.
|
||||
filteredNodes.addAll(node.children);
|
||||
} else if (node.type == SimpleTableRowBlockKeys.type) {
|
||||
// if the node is a table row, we will fetch its children instead.
|
||||
filteredNodes.addAll(node.children.expand((e) => e.children));
|
||||
} else {
|
||||
filteredNodes.add(node);
|
||||
}
|
||||
}
|
||||
final document = Document.blank()
|
||||
..insert(
|
||||
[0],
|
||||
filteredNodes.map((e) => e.copyWith()),
|
||||
);
|
||||
return document;
|
||||
}
|
||||
|
||||
List<Node> _handleSubPageNodes(List<Node> nodes, [bool isCut = false]) {
|
||||
final handled = <Node>[];
|
||||
for (final node in nodes) {
|
||||
|
||||
+6
-2
@@ -1,8 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/shared/clipboard_state.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// cut.
|
||||
@@ -42,6 +41,11 @@ CommandShortcutEventHandler _cutCommandHandler = (editorState) {
|
||||
if (node == null) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// prevent to cut the node that is selecting the table.
|
||||
if (node.parentTableNode != null) {
|
||||
return KeyEventResult.skipRemainingHandlers;
|
||||
}
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
transaction.deleteNode(node);
|
||||
final nextNode = node.next;
|
||||
|
||||
+2
-2
@@ -323,8 +323,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
FileUrlType urlType,
|
||||
String url,
|
||||
) async {
|
||||
if ([FileUrlType.cloud, FileUrlType.network].contains(urlType) ||
|
||||
UniversalPlatform.isDesktopOrWeb) {
|
||||
if ([FileUrlType.cloud, FileUrlType.network].contains(urlType)) {
|
||||
await afLaunchUrlString(url);
|
||||
} else {
|
||||
final result = await OpenFilex.open(url);
|
||||
@@ -347,6 +346,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
controller.show();
|
||||
dropManagerState?.add(FileBlockKeys.type);
|
||||
} else {
|
||||
editorState.updateSelectionWithReason(null, extraInfo: {});
|
||||
showUploadFileMobileMenu();
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -163,6 +163,10 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
bool _shouldFocus(ViewPB view, ViewState? state) {
|
||||
final name = state?.view.name ?? view.name;
|
||||
|
||||
if (editorState.document.root.children.isNotEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the view's name is empty, focus on the title
|
||||
if (name.isEmpty) {
|
||||
return true;
|
||||
@@ -180,6 +184,15 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
|
||||
void _onFocusChanged() {
|
||||
if (titleFocusNode.hasFocus) {
|
||||
// if the document is empty, disable the keyboard service
|
||||
final children = editorState.document.root.children;
|
||||
final firstDelta = children.firstOrNull?.delta;
|
||||
final isEmptyDocument =
|
||||
children.length == 1 && (firstDelta == null || firstDelta.isEmpty);
|
||||
if (!isEmptyDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editorState.selection != null) {
|
||||
Log.info('cover title got focus, clear the editor selection');
|
||||
editorState.selection = null;
|
||||
|
||||
+29
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -15,6 +16,34 @@ class EditorKeyboardInterceptor extends AppFlowyKeyboardServiceInterceptor {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> interceptDelete(
|
||||
TextEditingDeltaDeletion deletion,
|
||||
EditorState editorState,
|
||||
) async {
|
||||
// check if the current selection is in a code block
|
||||
final (isInTableCell, selection, tableCellNode, node) =
|
||||
editorState.isCurrentSelectionInTableCell();
|
||||
if (!isInTableCell ||
|
||||
selection == null ||
|
||||
tableCellNode == null ||
|
||||
node == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final onlyContainsOneChild = tableCellNode.children.length == 1;
|
||||
final isParagraphNode =
|
||||
tableCellNode.children.first.type == ParagraphBlockKeys.type;
|
||||
if (onlyContainsOneChild &&
|
||||
selection.isCollapsed &&
|
||||
selection.end.offset == 0 &&
|
||||
isParagraphNode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if the backtick pressed event should be handled
|
||||
Future<bool> _checkIfBacktickPressed(
|
||||
EditorState editorState,
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.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/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/shared/permission/permission_checker.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/startup/tasks/app_widget.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
@visibleForTesting
|
||||
const addAttachmentToolbarItemKey = ValueKey('add_attachment_toolbar_item');
|
||||
|
||||
final addAttachmentItem = AppFlowyMobileToolbarItem(
|
||||
itemBuilder: (context, editorState, service, _, onAction) {
|
||||
return AppFlowyMobileToolbarIconItem(
|
||||
key: addAttachmentToolbarItemKey,
|
||||
editorState: editorState,
|
||||
icon: FlowySvgs.media_s,
|
||||
onTap: () {
|
||||
final documentId = context.read<DocumentBloc>().documentId;
|
||||
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
|
||||
|
||||
final selection = editorState.selection;
|
||||
service.closeKeyboard();
|
||||
|
||||
// delay to wait the keyboard closed.
|
||||
Future.delayed(const Duration(milliseconds: 100), () async {
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
selection,
|
||||
extraInfo: {
|
||||
selectionExtraInfoDisableMobileToolbarKey: true,
|
||||
selectionExtraInfoDisableFloatingToolbar: true,
|
||||
selectionExtraInfoDoNotAttachTextService: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
keepEditorFocusNotifier.increase();
|
||||
final didAddAttachment = await showAddAttachmentMenu(
|
||||
AppGlobals.rootNavKey.currentContext!,
|
||||
documentId: documentId,
|
||||
isLocalMode: isLocalMode,
|
||||
editorState: editorState,
|
||||
selection: selection!,
|
||||
);
|
||||
|
||||
if (didAddAttachment != true) {
|
||||
unawaited(editorState.updateSelectionWithReason(selection));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future<bool?> showAddAttachmentMenu(
|
||||
BuildContext context, {
|
||||
required String documentId,
|
||||
required bool isLocalMode,
|
||||
required EditorState editorState,
|
||||
required Selection selection,
|
||||
}) async =>
|
||||
showMobileBottomSheet<bool>(
|
||||
context,
|
||||
showDragHandle: true,
|
||||
barrierColor: Colors.transparent,
|
||||
backgroundColor:
|
||||
ToolbarColorExtension.of(context).toolbarMenuBackgroundColor,
|
||||
elevation: 20,
|
||||
isScrollControlled: false,
|
||||
enableDraggableScrollable: true,
|
||||
builder: (_) => _AddAttachmentMenu(
|
||||
documentId: documentId,
|
||||
isLocalMode: isLocalMode,
|
||||
editorState: editorState,
|
||||
selection: selection,
|
||||
),
|
||||
);
|
||||
|
||||
class _AddAttachmentMenu extends StatelessWidget {
|
||||
const _AddAttachmentMenu({
|
||||
required this.documentId,
|
||||
required this.isLocalMode,
|
||||
required this.editorState,
|
||||
required this.selection,
|
||||
});
|
||||
|
||||
final String documentId;
|
||||
final bool isLocalMode;
|
||||
final EditorState editorState;
|
||||
final Selection selection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MobileQuickActionButton(
|
||||
text: LocaleKeys.document_attachmentMenu_choosePhoto.tr(),
|
||||
icon: FlowySvgs.image_rounded_s,
|
||||
iconSize: const Size.square(20),
|
||||
onTap: () async => selectPhoto(context),
|
||||
),
|
||||
const Divider(height: 8.5, thickness: 0.5),
|
||||
MobileQuickActionButton(
|
||||
text: LocaleKeys.document_attachmentMenu_takePicture.tr(),
|
||||
icon: FlowySvgs.camera_s,
|
||||
iconSize: const Size.square(20),
|
||||
onTap: () async => selectCamera(context),
|
||||
),
|
||||
const Divider(height: 8.5, thickness: 0.5),
|
||||
MobileQuickActionButton(
|
||||
text: LocaleKeys.document_attachmentMenu_chooseFile.tr(),
|
||||
icon: FlowySvgs.file_s,
|
||||
iconSize: const Size.square(20),
|
||||
onTap: () async => selectFile(context),
|
||||
),
|
||||
const Divider(height: 8.5, thickness: 0.5),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _insertNode(Node node) async {
|
||||
Future.delayed(
|
||||
const Duration(milliseconds: 100),
|
||||
() async {
|
||||
// if current selected block is a empty paragraph block, replace it with the new block.
|
||||
if (selection.isCollapsed) {
|
||||
final path = selection.end.path;
|
||||
final currentNode = editorState.getNodeAtPath(path);
|
||||
final text = currentNode?.delta?.toPlainText();
|
||||
if (currentNode != null &&
|
||||
currentNode.type == ParagraphBlockKeys.type &&
|
||||
text != null &&
|
||||
text.isEmpty) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertNode(path.next, node);
|
||||
transaction.deleteNode(currentNode);
|
||||
transaction.afterSelection =
|
||||
Selection.collapsed(Position(path: path));
|
||||
transaction.selectionExtraInfo = {};
|
||||
return editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
await editorState.insertBlockAfterCurrentSelection(selection, node);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertImage(BuildContext context, XFile image) async {
|
||||
CustomImageType type = CustomImageType.local;
|
||||
String? path;
|
||||
if (isLocalMode) {
|
||||
path = await saveImageToLocalStorage(image.path);
|
||||
} else {
|
||||
(path, _) = await saveImageToCloudStorage(image.path, documentId);
|
||||
type = CustomImageType.internal;
|
||||
}
|
||||
|
||||
if (path != null) {
|
||||
final node = customImageNode(url: path, type: type);
|
||||
await _insertNode(node);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> selectPhoto(BuildContext context) async {
|
||||
final image = await ImagePicker().pickImage(source: ImageSource.gallery);
|
||||
|
||||
if (image != null && context.mounted) {
|
||||
await insertImage(context, image);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> selectCamera(BuildContext context) async {
|
||||
final cameraPermission =
|
||||
await PermissionChecker.checkCameraPermission(context);
|
||||
if (!cameraPermission) {
|
||||
Log.error('Has no permission to access the camera');
|
||||
return;
|
||||
}
|
||||
|
||||
final image = await ImagePicker().pickImage(source: ImageSource.camera);
|
||||
|
||||
if (image != null && context.mounted) {
|
||||
await insertImage(context, image);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> selectFile(BuildContext context) async {
|
||||
final result = await getIt<FilePickerService>().pickFiles();
|
||||
final file = result?.files.first.xFile;
|
||||
if (file != null) {
|
||||
FileUrlType type = FileUrlType.local;
|
||||
String? path;
|
||||
if (isLocalMode) {
|
||||
path = await saveFileToLocalStorage(file.path);
|
||||
} else {
|
||||
(path, _) = await saveFileToCloudStorage(file.path, documentId);
|
||||
type = FileUrlType.cloud;
|
||||
}
|
||||
|
||||
if (path != null) {
|
||||
final node = fileNode(url: path, type: type, name: file.name);
|
||||
await _insertNode(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
-19
@@ -392,22 +392,3 @@ class AddBlockMenu extends StatelessWidget {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension on EditorState {
|
||||
Future<void> insertBlockAfterCurrentSelection(
|
||||
Selection selection,
|
||||
Node node,
|
||||
) async {
|
||||
final path = selection.end.path.next;
|
||||
final transaction = this.transaction;
|
||||
transaction.insertNode(
|
||||
path,
|
||||
node,
|
||||
);
|
||||
transaction.afterSelection = Selection.collapsed(
|
||||
Position(path: path),
|
||||
);
|
||||
transaction.selectionExtraInfo = {};
|
||||
await apply(transaction);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
+2
@@ -12,6 +12,7 @@ final _defaultToolbarItems = [
|
||||
aaToolbarItem,
|
||||
todoListToolbarItem,
|
||||
bulletedListToolbarItem,
|
||||
addAttachmentItem,
|
||||
numberedListToolbarItem,
|
||||
boldToolbarItem,
|
||||
italicToolbarItem,
|
||||
@@ -35,6 +36,7 @@ final _listToolbarItems = [
|
||||
underlineToolbarItem,
|
||||
strikethroughToolbarItem,
|
||||
colorToolbarItem,
|
||||
addAttachmentItem,
|
||||
undoToolbarItem,
|
||||
redoToolbarItem,
|
||||
];
|
||||
|
||||
+17
@@ -339,4 +339,21 @@ extension MobileToolbarEditorState on EditorState {
|
||||
|
||||
await apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> insertBlockAfterCurrentSelection(
|
||||
Selection selection,
|
||||
Node node,
|
||||
) async {
|
||||
final path = selection.end.path.next;
|
||||
final transaction = this.transaction;
|
||||
transaction.insertNode(
|
||||
path,
|
||||
node,
|
||||
);
|
||||
transaction.afterSelection = Selection.collapsed(
|
||||
Position(path: path),
|
||||
);
|
||||
transaction.selectionExtraInfo = {};
|
||||
await apply(transaction);
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
extension AskAINodeExtension on EditorState {
|
||||
String getMarkdownInSelection(Selection? selection) {
|
||||
selection ??= this.selection?.normalized;
|
||||
if (selection == null || selection.isCollapsed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// if the selected nodes are not entirely selected, slice the nodes
|
||||
final slicedNodes = <Node>[];
|
||||
final nodes = getNodesInSelection(selection);
|
||||
|
||||
for (final node in nodes) {
|
||||
final delta = node.delta;
|
||||
if (delta == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final slicedDelta = delta.slice(
|
||||
node == nodes.first ? selection.startIndex : 0,
|
||||
node == nodes.last ? selection.endIndex : delta.length,
|
||||
);
|
||||
|
||||
final copiedNode = node.copyWith(
|
||||
attributes: {
|
||||
...node.attributes,
|
||||
blockComponentDelta: slicedDelta.toJson(),
|
||||
},
|
||||
);
|
||||
|
||||
slicedNodes.add(copiedNode);
|
||||
}
|
||||
|
||||
final markdown = customDocumentToMarkdown(
|
||||
Document.blank()..insert([0], slicedNodes),
|
||||
);
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
List<String> getPlainTextInSelection(Selection? selection) {
|
||||
selection ??= this.selection?.normalized;
|
||||
if (selection == null || selection.isCollapsed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final res = <String>[];
|
||||
if (selection.isCollapsed) {
|
||||
return res;
|
||||
}
|
||||
|
||||
final nodes = getNodesInSelection(selection);
|
||||
|
||||
for (final node in nodes) {
|
||||
final delta = node.delta;
|
||||
if (delta == null) {
|
||||
continue;
|
||||
}
|
||||
final startIndex = node == nodes.first ? selection.startIndex : 0;
|
||||
final endIndex = node == nodes.last ? selection.endIndex : delta.length;
|
||||
res.add(delta.slice(startIndex, endIndex).toPlainText());
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_operations.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_widgets.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
|
||||
import 'package:appflowy/user/application/ai_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/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:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'ai_limit_dialog.dart';
|
||||
|
||||
class AIWriterBlockKeys {
|
||||
const AIWriterBlockKeys._();
|
||||
|
||||
static const String type = 'ai_writer';
|
||||
static const String prompt = 'prompt';
|
||||
static const String startSelection = 'start_selection';
|
||||
static const String generationCount = 'generation_count';
|
||||
|
||||
static String getRewritePrompt(String previousOutput, String prompt) {
|
||||
return 'I am not satisfied with your previous response ($previousOutput) to the query ($prompt). Please provide an alternative response.';
|
||||
}
|
||||
}
|
||||
|
||||
Node aiWriterNode({
|
||||
String prompt = '',
|
||||
required Selection start,
|
||||
}) {
|
||||
return Node(
|
||||
type: AIWriterBlockKeys.type,
|
||||
attributes: {
|
||||
AIWriterBlockKeys.prompt: prompt,
|
||||
AIWriterBlockKeys.startSelection: start.toJson(),
|
||||
AIWriterBlockKeys.generationCount: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class AIWriterBlockComponentBuilder extends BlockComponentBuilder {
|
||||
AIWriterBlockComponentBuilder();
|
||||
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
return AIWriterBlockComponent(
|
||||
key: node.key,
|
||||
node: node,
|
||||
showActions: showActions(node),
|
||||
actionBuilder: (context, state) => actionBuilder(
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
BlockComponentValidate get validate => (node) =>
|
||||
node.children.isEmpty &&
|
||||
node.attributes[AIWriterBlockKeys.prompt] is String &&
|
||||
node.attributes[AIWriterBlockKeys.startSelection] is Map;
|
||||
}
|
||||
|
||||
class AIWriterBlockComponent extends BlockComponentStatefulWidget {
|
||||
const AIWriterBlockComponent({
|
||||
super.key,
|
||||
required super.node,
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
@override
|
||||
State<AIWriterBlockComponent> createState() => _AIWriterBlockComponentState();
|
||||
}
|
||||
|
||||
class _AIWriterBlockComponentState extends State<AIWriterBlockComponent> {
|
||||
final controller = TextEditingController();
|
||||
final textFieldFocusNode = FocusNode();
|
||||
|
||||
late final editorState = context.read<EditorState>();
|
||||
late final SelectionGestureInterceptor interceptor;
|
||||
late final AIWriterBlockOperations aiWriterOperations =
|
||||
AIWriterBlockOperations(
|
||||
editorState: editorState,
|
||||
aiWriterNode: widget.node,
|
||||
);
|
||||
|
||||
String get prompt => widget.node.attributes[AIWriterBlockKeys.prompt];
|
||||
int get generationCount =>
|
||||
widget.node.attributes[AIWriterBlockKeys.generationCount] ?? 0;
|
||||
Selection? get startSelection {
|
||||
final selection = widget.node.attributes[AIWriterBlockKeys.startSelection];
|
||||
if (selection != null) {
|
||||
return Selection.fromJson(selection);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool isGenerating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_subscribeSelectionGesture();
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
editorState.selection = null;
|
||||
textFieldFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_onExit();
|
||||
_unsubscribeSelectionGesture();
|
||||
controller.dispose();
|
||||
textFieldFocusNode.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final child = Focus(
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
if (!isGenerating) {
|
||||
_onGenerate();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Card(
|
||||
elevation: 5,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AIWriterBlockHeader(),
|
||||
const Space(0, 10),
|
||||
if (prompt.isEmpty && generationCount < 1) ...[
|
||||
_buildInputWidget(context),
|
||||
const Space(0, 10),
|
||||
AIWriterBlockInputField(
|
||||
onGenerate: _onGenerate,
|
||||
onExit: _onExit,
|
||||
),
|
||||
] else ...[
|
||||
AIWriterBlockFooter(
|
||||
onKeep: _onExit,
|
||||
onRewrite: _onRewrite,
|
||||
onDiscard: _onDiscard,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 40),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputWidget(BuildContext context) {
|
||||
return FlowyTextField(
|
||||
hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(),
|
||||
controller: controller,
|
||||
maxLines: 5,
|
||||
focusNode: textFieldFocusNode,
|
||||
autoFocus: false,
|
||||
hintTextConstraints: const BoxConstraints(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onExit() async {
|
||||
await aiWriterOperations.removeAIWriterNode(widget.node);
|
||||
}
|
||||
|
||||
Future<void> _onGenerate() async {
|
||||
if (isGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
isGenerating = true;
|
||||
|
||||
await aiWriterOperations.updatePromptText(controller.text);
|
||||
|
||||
if (!_isAIWriterEnabled) {
|
||||
Log.error('AI Writer is not enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
final markdownTextRobot = MarkdownTextRobot(
|
||||
editorState: editorState,
|
||||
);
|
||||
|
||||
BarrierDialog? barrierDialog;
|
||||
|
||||
final aiRepository = AppFlowyAIService();
|
||||
await aiRepository.streamCompletion(
|
||||
text: controller.text,
|
||||
completionType: CompletionTypePB.ContinueWriting,
|
||||
onStart: () async {
|
||||
if (mounted) {
|
||||
barrierDialog = BarrierDialog(context);
|
||||
barrierDialog?.show();
|
||||
await aiWriterOperations.ensurePreviousNodeIsEmptyParagraphNode();
|
||||
markdownTextRobot.start();
|
||||
}
|
||||
},
|
||||
onProcess: (text) async {
|
||||
await markdownTextRobot.appendMarkdownText(text);
|
||||
},
|
||||
onEnd: () async {
|
||||
barrierDialog?.dismiss();
|
||||
await markdownTextRobot.stop();
|
||||
editorState.service.keyboardService?.enable();
|
||||
},
|
||||
onError: (error) async {
|
||||
barrierDialog?.dismiss();
|
||||
_showAIWriterError(error);
|
||||
},
|
||||
);
|
||||
|
||||
await aiWriterOperations.updateGenerationCount(generationCount + 1);
|
||||
|
||||
isGenerating = false;
|
||||
}
|
||||
|
||||
Future<void> _onDiscard() async {
|
||||
await aiWriterOperations.discardCurrentResponse(
|
||||
aiWriterNode: widget.node,
|
||||
selection: startSelection,
|
||||
);
|
||||
return _onExit();
|
||||
}
|
||||
|
||||
Future<void> _onRewrite() async {
|
||||
if (isGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
isGenerating = true;
|
||||
|
||||
final previousOutput = _getPreviousOutput();
|
||||
if (previousOutput == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// discard the current response
|
||||
await aiWriterOperations.discardCurrentResponse(
|
||||
aiWriterNode: widget.node,
|
||||
selection: startSelection,
|
||||
);
|
||||
|
||||
if (!_isAIWriterEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final markdownTextRobot = MarkdownTextRobot(
|
||||
editorState: editorState,
|
||||
);
|
||||
final aiService = AppFlowyAIService();
|
||||
await aiService.streamCompletion(
|
||||
text: AIWriterBlockKeys.getRewritePrompt(previousOutput, prompt),
|
||||
completionType: CompletionTypePB.ContinueWriting,
|
||||
onStart: () async {
|
||||
await aiWriterOperations.ensurePreviousNodeIsEmptyParagraphNode();
|
||||
|
||||
markdownTextRobot.start();
|
||||
},
|
||||
onProcess: (text) async {
|
||||
await markdownTextRobot.appendMarkdownText(text);
|
||||
},
|
||||
onEnd: () async {
|
||||
await markdownTextRobot.stop();
|
||||
},
|
||||
onError: (error) {
|
||||
_showAIWriterError(error);
|
||||
},
|
||||
);
|
||||
|
||||
await aiWriterOperations.updateGenerationCount(generationCount + 1);
|
||||
|
||||
isGenerating = false;
|
||||
}
|
||||
|
||||
String? _getPreviousOutput() {
|
||||
final startSelection = this.startSelection;
|
||||
if (startSelection != null) {
|
||||
final end = widget.node.previous?.path;
|
||||
|
||||
if (end != null) {
|
||||
final result = editorState
|
||||
.getNodesInSelection(
|
||||
startSelection.copyWith(end: Position(path: end)),
|
||||
)
|
||||
.fold(
|
||||
'',
|
||||
(previousValue, element) {
|
||||
final delta = element.delta;
|
||||
if (delta != null) {
|
||||
return "$previousValue\n${delta.toPlainText()}";
|
||||
} else {
|
||||
return previousValue;
|
||||
}
|
||||
},
|
||||
);
|
||||
return result.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _subscribeSelectionGesture() {
|
||||
interceptor = SelectionGestureInterceptor(
|
||||
key: AIWriterBlockKeys.type,
|
||||
canTap: (details) {
|
||||
if (!context.isOffsetInside(details.globalPosition)) {
|
||||
if (prompt.isNotEmpty || controller.text.isNotEmpty) {
|
||||
// show dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => DiscardDialog(
|
||||
onConfirm: _onDiscard,
|
||||
onCancel: () {},
|
||||
),
|
||||
);
|
||||
} else if (controller.text.isEmpty) {
|
||||
_onExit();
|
||||
}
|
||||
}
|
||||
editorState.service.keyboardService?.disable();
|
||||
return false;
|
||||
},
|
||||
);
|
||||
editorState.service.selectionService.registerGestureInterceptor(
|
||||
interceptor,
|
||||
);
|
||||
}
|
||||
|
||||
void _unsubscribeSelectionGesture() {
|
||||
editorState.service.selectionService.unregisterGestureInterceptor(
|
||||
AIWriterBlockKeys.type,
|
||||
);
|
||||
}
|
||||
|
||||
void _showAIWriterError(AIError error) {
|
||||
if (mounted) {
|
||||
if (error.isLimitExceeded) {
|
||||
showAILimitDialog(context, error.message);
|
||||
} else {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: error.message,
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isAIWriterEnabled {
|
||||
final userProfile = context.read<DocumentBloc>().state.userProfilePB;
|
||||
final isAIWriterEnabled = userProfile != null;
|
||||
|
||||
if (!isAIWriterEnabled) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
}
|
||||
|
||||
return isAIWriterEnabled;
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_component.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
/// Notes: All the operation related to the AI writer block will be applied
|
||||
/// in memory.
|
||||
class AIWriterBlockOperations {
|
||||
AIWriterBlockOperations({
|
||||
required this.editorState,
|
||||
required this.aiWriterNode,
|
||||
}) : assert(aiWriterNode.type == AIWriterBlockKeys.type);
|
||||
|
||||
final EditorState editorState;
|
||||
final Node aiWriterNode;
|
||||
|
||||
/// Update the prompt text in the node.
|
||||
Future<void> updatePromptText(String prompt) async {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(
|
||||
aiWriterNode,
|
||||
{AIWriterBlockKeys.prompt: prompt},
|
||||
);
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(
|
||||
inMemoryUpdate: true,
|
||||
recordUndo: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Update the generation count in the node.
|
||||
Future<void> updateGenerationCount(int count) async {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(
|
||||
aiWriterNode,
|
||||
{AIWriterBlockKeys.generationCount: count},
|
||||
);
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(inMemoryUpdate: true),
|
||||
);
|
||||
}
|
||||
|
||||
/// Ensure the previous node is a empty paragraph node without any styles.
|
||||
Future<void> ensurePreviousNodeIsEmptyParagraphNode() async {
|
||||
final previous = aiWriterNode.previous;
|
||||
final Selection selection;
|
||||
|
||||
// 1. previous node is null or
|
||||
// 2. previous node is not a paragraph node or
|
||||
// 3. previous node is a paragraph node but not empty
|
||||
final isNotEmptyParagraphNode = previous == null ||
|
||||
previous.type != ParagraphBlockKeys.type ||
|
||||
(previous.delta?.toPlainText().isNotEmpty ?? false);
|
||||
|
||||
if (isNotEmptyParagraphNode) {
|
||||
final path = aiWriterNode.path;
|
||||
final transaction = editorState.transaction;
|
||||
selection = Selection.collapsed(Position(path: path));
|
||||
transaction
|
||||
..insertNode(
|
||||
path,
|
||||
paragraphNode(),
|
||||
)
|
||||
..afterSelection = selection;
|
||||
await editorState.apply(transaction);
|
||||
} else {
|
||||
selection = Selection.collapsed(Position(path: previous.path));
|
||||
}
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(aiWriterNode, {
|
||||
AIWriterBlockKeys.startSelection: selection.toJson(),
|
||||
});
|
||||
transaction.afterSelection = selection;
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(inMemoryUpdate: true),
|
||||
);
|
||||
}
|
||||
|
||||
/// Discard the current response and delete the previous node.
|
||||
Future<void> discardCurrentResponse({
|
||||
required Node aiWriterNode,
|
||||
Selection? selection,
|
||||
}) async {
|
||||
if (selection != null) {
|
||||
final start = selection.start.path;
|
||||
final end = aiWriterNode.previous?.path;
|
||||
if (end != null) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.deleteNodesAtPath(
|
||||
start,
|
||||
end.last - start.last + 1,
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
await ensurePreviousNodeIsEmptyParagraphNode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the ai writer node from the editor.
|
||||
Future<void> removeAIWriterNode(Node aiWriterNode) async {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.deleteNode(aiWriterNode);
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(inMemoryUpdate: true, recordUndo: false),
|
||||
withUpdateSelection: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
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:flutter/material.dart';
|
||||
|
||||
class AIWriterBlockHeader extends StatelessWidget {
|
||||
const AIWriterBlockHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyText.medium(
|
||||
LocaleKeys.document_plugins_autoGeneratorTitleName.tr(),
|
||||
fontSize: 14,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AIWriterBlockInputField extends StatelessWidget {
|
||||
const AIWriterBlockInputField({
|
||||
super.key,
|
||||
required this.onGenerate,
|
||||
required this.onExit,
|
||||
});
|
||||
|
||||
final VoidCallback onGenerate;
|
||||
final VoidCallback onExit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PrimaryRoundedButton(
|
||||
text: LocaleKeys.button_generate.tr(),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 10.0,
|
||||
),
|
||||
radius: 8.0,
|
||||
onTap: onGenerate,
|
||||
),
|
||||
const Space(10, 0),
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.button_cancel.tr(),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 10.0,
|
||||
),
|
||||
onTap: onExit,
|
||||
),
|
||||
Flexible(
|
||||
child: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FlowyText.regular(
|
||||
LocaleKeys.document_plugins_warning.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AIWriterBlockFooter extends StatelessWidget {
|
||||
const AIWriterBlockFooter({
|
||||
super.key,
|
||||
required this.onKeep,
|
||||
required this.onRewrite,
|
||||
required this.onDiscard,
|
||||
});
|
||||
|
||||
final VoidCallback onKeep;
|
||||
final VoidCallback onRewrite;
|
||||
final VoidCallback onDiscard;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
PrimaryRoundedButton(
|
||||
text: LocaleKeys.button_keep.tr(),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 9.0,
|
||||
),
|
||||
onTap: onKeep,
|
||||
),
|
||||
const HSpace(10),
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(),
|
||||
onTap: onRewrite,
|
||||
),
|
||||
const HSpace(10),
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.button_discard.tr(),
|
||||
onTap: onDiscard,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+24
-24
@@ -1,9 +1,9 @@
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum SmartEditAction {
|
||||
enum AskAIAction {
|
||||
summarize,
|
||||
fixSpelling,
|
||||
improveWriting,
|
||||
@@ -11,62 +11,62 @@ enum SmartEditAction {
|
||||
|
||||
String get toInstruction {
|
||||
switch (this) {
|
||||
case SmartEditAction.summarize:
|
||||
case AskAIAction.summarize:
|
||||
return 'Tl;dr';
|
||||
case SmartEditAction.fixSpelling:
|
||||
case AskAIAction.fixSpelling:
|
||||
return 'Correct this to standard English:';
|
||||
case SmartEditAction.improveWriting:
|
||||
case AskAIAction.improveWriting:
|
||||
return 'Rewrite this in your own words:';
|
||||
case SmartEditAction.makeItLonger:
|
||||
case AskAIAction.makeItLonger:
|
||||
return 'Make this text longer:';
|
||||
}
|
||||
}
|
||||
|
||||
String prompt(String input) {
|
||||
switch (this) {
|
||||
case SmartEditAction.summarize:
|
||||
case AskAIAction.summarize:
|
||||
return '$input\n\nTl;dr';
|
||||
case SmartEditAction.fixSpelling:
|
||||
case AskAIAction.fixSpelling:
|
||||
return 'Correct this to standard English:\n\n$input';
|
||||
case SmartEditAction.improveWriting:
|
||||
case AskAIAction.improveWriting:
|
||||
return 'Rewrite this:\n\n$input';
|
||||
case SmartEditAction.makeItLonger:
|
||||
case AskAIAction.makeItLonger:
|
||||
return 'Make this text longer:\n\n$input';
|
||||
}
|
||||
}
|
||||
|
||||
static SmartEditAction from(int index) {
|
||||
static AskAIAction from(int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return SmartEditAction.summarize;
|
||||
return AskAIAction.summarize;
|
||||
case 1:
|
||||
return SmartEditAction.fixSpelling;
|
||||
return AskAIAction.fixSpelling;
|
||||
case 2:
|
||||
return SmartEditAction.improveWriting;
|
||||
return AskAIAction.improveWriting;
|
||||
case 3:
|
||||
return SmartEditAction.makeItLonger;
|
||||
return AskAIAction.makeItLonger;
|
||||
}
|
||||
return SmartEditAction.fixSpelling;
|
||||
return AskAIAction.fixSpelling;
|
||||
}
|
||||
|
||||
String get name {
|
||||
switch (this) {
|
||||
case SmartEditAction.summarize:
|
||||
case AskAIAction.summarize:
|
||||
return LocaleKeys.document_plugins_smartEditSummarize.tr();
|
||||
case SmartEditAction.fixSpelling:
|
||||
case AskAIAction.fixSpelling:
|
||||
return LocaleKeys.document_plugins_smartEditFixSpelling.tr();
|
||||
case SmartEditAction.improveWriting:
|
||||
case AskAIAction.improveWriting:
|
||||
return LocaleKeys.document_plugins_smartEditImproveWriting.tr();
|
||||
case SmartEditAction.makeItLonger:
|
||||
case AskAIAction.makeItLonger:
|
||||
return LocaleKeys.document_plugins_smartEditMakeLonger.tr();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SmartEditActionWrapper extends ActionCell {
|
||||
SmartEditActionWrapper(this.inner);
|
||||
class AskAIActionWrapper extends ActionCell {
|
||||
AskAIActionWrapper(this.inner);
|
||||
|
||||
final SmartEditAction inner;
|
||||
final AskAIAction inner;
|
||||
|
||||
Widget? icon(Color iconColor) => null;
|
||||
|
||||
+89
-47
@@ -2,27 +2,34 @@ import 'dart:async';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy/user/application/ai_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'smart_edit_bloc.freezed.dart';
|
||||
part 'ask_ai_action_bloc.freezed.dart';
|
||||
|
||||
class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
|
||||
SmartEditBloc({
|
||||
enum AskAIReplacementType {
|
||||
markdown,
|
||||
plainText,
|
||||
}
|
||||
|
||||
const _defaultReplacementType = AskAIReplacementType.markdown;
|
||||
|
||||
class AskAIActionBloc extends Bloc<AskAIEvent, AskAIState> {
|
||||
AskAIActionBloc({
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
required this.action,
|
||||
this.enableLogging = true,
|
||||
}) : super(
|
||||
SmartEditState.initial(action),
|
||||
AskAIState.initial(action),
|
||||
) {
|
||||
on<SmartEditEvent>((event, emit) async {
|
||||
on<AskAIEvent>((event, emit) async {
|
||||
await event.when(
|
||||
initial: (aiRepositoryProvider) async {
|
||||
aiRepository = await aiRepositoryProvider;
|
||||
@@ -61,7 +68,7 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
|
||||
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
final SmartEditAction action;
|
||||
final AskAIAction action;
|
||||
final bool enableLogging;
|
||||
// used to wait for the aiRepository to be initialized
|
||||
final aiRepositoryCompleter = Completer();
|
||||
@@ -75,14 +82,14 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
|
||||
await aiRepositoryCompleter.future;
|
||||
|
||||
if (rewrite) {
|
||||
add(const SmartEditEvent.update('', true, null));
|
||||
add(const AskAIEvent.update('', true, null));
|
||||
}
|
||||
|
||||
if (enableLogging) {
|
||||
Log.info('[smart_edit] request completions');
|
||||
}
|
||||
|
||||
final content = node.attributes[SmartEditBlockKeys.content] as String;
|
||||
final content = node.attributes[AskAIBlockKeys.content] as String;
|
||||
await aiRepository.streamCompletion(
|
||||
text: content,
|
||||
completionType: completionTypeFromInt(state.action),
|
||||
@@ -93,7 +100,7 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
|
||||
if (enableLogging) {
|
||||
Log.info('[smart_edit] start generating');
|
||||
}
|
||||
add(const SmartEditEvent.update('', true, null));
|
||||
add(const AskAIEvent.update('', true, null));
|
||||
},
|
||||
onProcess: (text) async {
|
||||
if (isCanceled) {
|
||||
@@ -104,7 +111,7 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
|
||||
Log.debug('[smart_edit] onProcess: $text');
|
||||
}
|
||||
final newResult = state.result + text;
|
||||
add(SmartEditEvent.update(newResult, false, null));
|
||||
add(AskAIEvent.update(newResult, false, null));
|
||||
},
|
||||
onEnd: () async {
|
||||
if (isCanceled) {
|
||||
@@ -113,7 +120,7 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
|
||||
if (enableLogging) {
|
||||
Log.info('[smart_edit] end generating');
|
||||
}
|
||||
add(SmartEditEvent.update('${state.result}\n', false, null));
|
||||
add(AskAIEvent.update('${state.result}\n', false, null));
|
||||
},
|
||||
onError: (error) async {
|
||||
if (isCanceled) {
|
||||
@@ -122,7 +129,7 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
|
||||
if (enableLogging) {
|
||||
Log.info('[smart_edit] onError: $error');
|
||||
}
|
||||
add(SmartEditEvent.update('', false, error));
|
||||
add(AskAIEvent.update('', false, error));
|
||||
await _exit();
|
||||
await _clearSelection();
|
||||
},
|
||||
@@ -135,35 +142,70 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
// return if the result is empty
|
||||
final result = state.result.trim();
|
||||
if (result.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final insertedText = result.split('\n')
|
||||
..removeWhere((element) => element.isEmpty);
|
||||
final nodes = customMarkdownToDocument(state.result)
|
||||
.root
|
||||
.children
|
||||
.map((e) => e.copyWith())
|
||||
.toList();
|
||||
final insertedPath = selection.end.path.next;
|
||||
final transaction = editorState.transaction;
|
||||
// todo: keep the style of the current node
|
||||
transaction.insertNodes(
|
||||
selection.end.path.next,
|
||||
insertedText.map(
|
||||
(e) => paragraphNode(
|
||||
text: e,
|
||||
),
|
||||
),
|
||||
);
|
||||
final start = Position(path: selection.end.path.next);
|
||||
final end = Position(
|
||||
path: [selection.end.path.next.first + insertedText.length],
|
||||
insertedPath,
|
||||
nodes,
|
||||
);
|
||||
final lastDeltaLength = nodes.lastOrNull?.delta?.length ?? 0;
|
||||
transaction.afterSelection = Selection(
|
||||
start: start,
|
||||
end: end,
|
||||
start: Position(path: insertedPath),
|
||||
end: Position(
|
||||
path: insertedPath.nextNPath(nodes.length - 1),
|
||||
offset: lastDeltaLength,
|
||||
),
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> _replace() async {
|
||||
switch (_defaultReplacementType) {
|
||||
case AskAIReplacementType.markdown:
|
||||
await _replaceWithMarkdown();
|
||||
case AskAIReplacementType.plainText:
|
||||
await _replaceWithPlainText();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _replaceWithMarkdown() async {
|
||||
final selection = editorState.selection?.normalized;
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final nodes = customMarkdownToDocument(state.result)
|
||||
.root
|
||||
.children
|
||||
.map((e) => e.copyWith())
|
||||
.toList();
|
||||
if (nodes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final nodesInSelection = editorState.getNodesInSelection(selection);
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertNodes(
|
||||
selection.start.path,
|
||||
nodes,
|
||||
);
|
||||
transaction.deleteNodes(nodesInSelection);
|
||||
transaction.afterSelection = Selection(
|
||||
start: selection.start,
|
||||
end: Position(
|
||||
path: selection.start.path.nextNPath(nodes.length - 1),
|
||||
offset: nodes.lastOrNull?.delta?.length ?? 0,
|
||||
),
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> _replaceWithPlainText() async {
|
||||
final result = state.result.trim();
|
||||
if (result.isEmpty) {
|
||||
return;
|
||||
@@ -222,16 +264,16 @@ class SmartEditBloc extends Bloc<SmartEditEvent, SmartEditState> {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SmartEditEvent with _$SmartEditEvent {
|
||||
const factory SmartEditEvent.initial(
|
||||
class AskAIEvent with _$AskAIEvent {
|
||||
const factory AskAIEvent.initial(
|
||||
Future<AIRepository> aiRepositoryProvider,
|
||||
) = _Initial;
|
||||
const factory SmartEditEvent.started() = _Started;
|
||||
const factory SmartEditEvent.rewrite() = _Rewrite;
|
||||
const factory SmartEditEvent.replace() = _Replace;
|
||||
const factory SmartEditEvent.insertBelow() = _InsertBelow;
|
||||
const factory SmartEditEvent.cancel() = _Cancel;
|
||||
const factory SmartEditEvent.update(
|
||||
const factory AskAIEvent.started() = _Started;
|
||||
const factory AskAIEvent.rewrite() = _Rewrite;
|
||||
const factory AskAIEvent.replace() = _Replace;
|
||||
const factory AskAIEvent.insertBelow() = _InsertBelow;
|
||||
const factory AskAIEvent.cancel() = _Cancel;
|
||||
const factory AskAIEvent.update(
|
||||
String result,
|
||||
bool isLoading,
|
||||
AIError? error,
|
||||
@@ -239,15 +281,15 @@ class SmartEditEvent with _$SmartEditEvent {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SmartEditState with _$SmartEditState {
|
||||
const factory SmartEditState({
|
||||
class AskAIState with _$AskAIState {
|
||||
const factory AskAIState({
|
||||
required bool loading,
|
||||
required String result,
|
||||
required SmartEditAction action,
|
||||
required AskAIAction action,
|
||||
@Default(null) AIError? requestError,
|
||||
}) = _SmartEditState;
|
||||
}) = _AskAIState;
|
||||
|
||||
factory SmartEditState.initial(SmartEditAction action) => SmartEditState(
|
||||
factory AskAIState.initial(AskAIAction action) => AskAIState(
|
||||
loading: true,
|
||||
action: action,
|
||||
result: '',
|
||||
+34
-141
@@ -4,8 +4,9 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_widgets.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
@@ -15,14 +16,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class SmartEditBlockKeys {
|
||||
const SmartEditBlockKeys._();
|
||||
class AskAIBlockKeys {
|
||||
const AskAIBlockKeys._();
|
||||
|
||||
static const type = 'smart_edit';
|
||||
static const type = 'ask_ai';
|
||||
|
||||
/// The instruction of the smart edit.
|
||||
///
|
||||
/// It is a [SmartEditAction] value.
|
||||
/// It is a [AskAIAction] value.
|
||||
static const action = 'action';
|
||||
|
||||
/// The input of the smart edit.
|
||||
@@ -31,26 +32,26 @@ class SmartEditBlockKeys {
|
||||
static const content = 'content';
|
||||
}
|
||||
|
||||
Node smartEditNode({
|
||||
required SmartEditAction action,
|
||||
Node askAINode({
|
||||
required AskAIAction action,
|
||||
required String content,
|
||||
}) {
|
||||
return Node(
|
||||
type: SmartEditBlockKeys.type,
|
||||
type: AskAIBlockKeys.type,
|
||||
attributes: {
|
||||
SmartEditBlockKeys.action: action.index,
|
||||
SmartEditBlockKeys.content: content,
|
||||
AskAIBlockKeys.action: action.index,
|
||||
AskAIBlockKeys.content: content,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class SmartEditBlockComponentBuilder extends BlockComponentBuilder {
|
||||
SmartEditBlockComponentBuilder();
|
||||
class AskAIBlockComponentBuilder extends BlockComponentBuilder {
|
||||
AskAIBlockComponentBuilder();
|
||||
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
return SmartEditBlockComponentWidget(
|
||||
return AskAIBlockComponentWidget(
|
||||
key: node.key,
|
||||
node: node,
|
||||
showActions: showActions(node),
|
||||
@@ -63,12 +64,12 @@ class SmartEditBlockComponentBuilder extends BlockComponentBuilder {
|
||||
|
||||
@override
|
||||
BlockComponentValidate get validate => (node) =>
|
||||
node.attributes[SmartEditBlockKeys.action] is int &&
|
||||
node.attributes[SmartEditBlockKeys.content] is String;
|
||||
node.attributes[AskAIBlockKeys.action] is int &&
|
||||
node.attributes[AskAIBlockKeys.content] is String;
|
||||
}
|
||||
|
||||
class SmartEditBlockComponentWidget extends BlockComponentStatefulWidget {
|
||||
const SmartEditBlockComponentWidget({
|
||||
class AskAIBlockComponentWidget extends BlockComponentStatefulWidget {
|
||||
const AskAIBlockComponentWidget({
|
||||
super.key,
|
||||
required super.node,
|
||||
super.showActions,
|
||||
@@ -77,18 +78,17 @@ class SmartEditBlockComponentWidget extends BlockComponentStatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<SmartEditBlockComponentWidget> createState() =>
|
||||
_SmartEditBlockComponentWidgetState();
|
||||
State<AskAIBlockComponentWidget> createState() =>
|
||||
_AskAIBlockComponentWidgetState();
|
||||
}
|
||||
|
||||
class _SmartEditBlockComponentWidgetState
|
||||
extends State<SmartEditBlockComponentWidget> {
|
||||
class _AskAIBlockComponentWidgetState extends State<AskAIBlockComponentWidget> {
|
||||
final popoverController = PopoverController();
|
||||
|
||||
late final editorState = context.read<EditorState>();
|
||||
late final action = SmartEditAction
|
||||
.values[widget.node.attributes[SmartEditBlockKeys.action] as int];
|
||||
late SmartEditBloc smartEditBloc;
|
||||
late final action =
|
||||
AskAIAction.values[widget.node.attributes[AskAIBlockKeys.action] as int];
|
||||
late AskAIActionBloc askAIBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -98,16 +98,16 @@ class _SmartEditBlockComponentWidgetState
|
||||
popoverController.show();
|
||||
});
|
||||
|
||||
smartEditBloc = SmartEditBloc(
|
||||
askAIBloc = AskAIActionBloc(
|
||||
node: widget.node,
|
||||
editorState: editorState,
|
||||
action: action,
|
||||
)..add(SmartEditEvent.initial(getIt.getAsync<AIRepository>()));
|
||||
)..add(AskAIEvent.initial(getIt.getAsync<AIRepository>()));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
smartEditBloc.close();
|
||||
askAIBloc.close();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
@@ -128,8 +128,8 @@ class _SmartEditBlockComponentWidgetState
|
||||
final width = _getEditorWidth();
|
||||
|
||||
return BlocProvider.value(
|
||||
value: smartEditBloc,
|
||||
child: BlocListener<SmartEditBloc, SmartEditState>(
|
||||
value: askAIBloc,
|
||||
child: BlocListener<AskAIActionBloc, AskAIState>(
|
||||
listener: _onListen,
|
||||
child: AppFlowyPopover(
|
||||
controller: popoverController,
|
||||
@@ -141,7 +141,7 @@ class _SmartEditBlockComponentWidgetState
|
||||
constraints: BoxConstraints(maxWidth: width),
|
||||
canClose: () async {
|
||||
final completer = Completer<bool>();
|
||||
final state = smartEditBloc.state;
|
||||
final state = askAIBloc.state;
|
||||
if (state.result.isEmpty) {
|
||||
completer.complete(true);
|
||||
} else {
|
||||
@@ -160,8 +160,8 @@ class _SmartEditBlockComponentWidgetState
|
||||
popupBuilder: (BuildContext popoverContext) {
|
||||
return BlocProvider.value(
|
||||
// request the result when opening the popover
|
||||
value: smartEditBloc..add(const SmartEditEvent.started()),
|
||||
child: const SmartEditInputContent(),
|
||||
value: askAIBloc..add(const AskAIEvent.started()),
|
||||
child: const AskAiInputContent(),
|
||||
);
|
||||
},
|
||||
child: const SizedBox(
|
||||
@@ -191,7 +191,7 @@ class _SmartEditBlockComponentWidgetState
|
||||
editorState.apply(transaction);
|
||||
}
|
||||
|
||||
void _onListen(BuildContext context, SmartEditState state) {
|
||||
void _onListen(BuildContext context, AskAIState state) {
|
||||
final error = state.requestError;
|
||||
if (error != null) {
|
||||
if (error.isLimitExceeded) {
|
||||
@@ -206,110 +206,3 @@ class _SmartEditBlockComponentWidgetState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SmartEditInputContent extends StatelessWidget {
|
||||
const SmartEditInputContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<SmartEditBloc, SmartEditState>(
|
||||
builder: (context, state) {
|
||||
return Card(
|
||||
elevation: 5,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowyText.medium(
|
||||
state.action.name,
|
||||
fontSize: 14,
|
||||
),
|
||||
const VSpace(16),
|
||||
state.loading
|
||||
? _buildLoadingWidget(context)
|
||||
: _buildResultWidget(context, state),
|
||||
const VSpace(16),
|
||||
const _SmartEditFooterWidget(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultWidget(BuildContext context, SmartEditState state) {
|
||||
// todo: replace it with appflowy_editor
|
||||
return Flexible(
|
||||
child: FlowyText.regular(
|
||||
state.result,
|
||||
maxLines: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingWidget(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: SizedBox.square(
|
||||
dimension: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmartEditFooterWidget extends StatelessWidget {
|
||||
const _SmartEditFooterWidget();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(),
|
||||
onTap: () =>
|
||||
context.read<SmartEditBloc>().add(const SmartEditEvent.rewrite()),
|
||||
),
|
||||
const HSpace(10),
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.button_replace.tr(),
|
||||
onTap: () =>
|
||||
context.read<SmartEditBloc>().add(const SmartEditEvent.replace()),
|
||||
),
|
||||
const HSpace(10),
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.button_insertBelow.tr(),
|
||||
onTap: () => context
|
||||
.read<SmartEditBloc>()
|
||||
.add(const SmartEditEvent.insertBelow()),
|
||||
),
|
||||
const HSpace(10),
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.button_cancel.tr(),
|
||||
onTap: () =>
|
||||
context.read<SmartEditBloc>().add(const SmartEditEvent.cancel()),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
LocaleKeys.document_plugins_warning.tr(),
|
||||
style: TextStyle(color: Theme.of(context).hintColor),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action_bloc.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_bloc/flutter_bloc.dart';
|
||||
|
||||
class AskAiInputContent extends StatelessWidget {
|
||||
const AskAiInputContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AskAIActionBloc, AskAIState>(
|
||||
builder: (context, state) {
|
||||
return Card(
|
||||
elevation: 5,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowyText.medium(
|
||||
state.action.name,
|
||||
fontSize: 14,
|
||||
),
|
||||
const VSpace(16),
|
||||
state.loading
|
||||
? _buildLoadingWidget(context)
|
||||
: _buildResultWidget(context, state),
|
||||
const VSpace(16),
|
||||
const AskAIFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultWidget(BuildContext context, AskAIState state) {
|
||||
return Flexible(
|
||||
child: AIMarkdownText(
|
||||
markdown: state.result,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingWidget(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: SizedBox.square(
|
||||
dimension: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AskAIFooter extends StatelessWidget {
|
||||
const AskAIFooter({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(),
|
||||
onTap: () =>
|
||||
context.read<AskAIActionBloc>().add(const AskAIEvent.rewrite()),
|
||||
),
|
||||
const HSpace(10),
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.button_replace.tr(),
|
||||
onTap: () =>
|
||||
context.read<AskAIActionBloc>().add(const AskAIEvent.replace()),
|
||||
),
|
||||
const HSpace(10),
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.button_insertBelow.tr(),
|
||||
onTap: () => context
|
||||
.read<AskAIActionBloc>()
|
||||
.add(const AskAIEvent.insertBelow()),
|
||||
),
|
||||
const HSpace(10),
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.button_cancel.tr(),
|
||||
onTap: () =>
|
||||
context.read<AskAIActionBloc>().add(const AskAIEvent.cancel()),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
LocaleKeys.document_plugins_warning.tr(),
|
||||
style: TextStyle(color: Theme.of(context).hintColor),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
-44
@@ -1,8 +1,9 @@
|
||||
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/openai/widgets/smart_edit_action.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/util/ask_ai_node_extension.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_component.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
@@ -11,20 +12,20 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
const _kSmartEditToolbarItemId = 'appflowy.editor.smart_edit';
|
||||
const _kAskAIToolbarItemId = 'appflowy.editor.ask_ai';
|
||||
|
||||
final ToolbarItem smartEditItem = ToolbarItem(
|
||||
id: _kSmartEditToolbarItemId,
|
||||
final ToolbarItem askAIItem = ToolbarItem(
|
||||
id: _kAskAIToolbarItemId,
|
||||
group: 0,
|
||||
isActive: onlyShowInSingleSelectionAndTextType,
|
||||
builder: (context, editorState, _, __, tooltipBuilder) => SmartEditActionList(
|
||||
builder: (context, editorState, _, __, tooltipBuilder) => AskAIActionList(
|
||||
editorState: editorState,
|
||||
tooltipBuilder: tooltipBuilder,
|
||||
),
|
||||
);
|
||||
|
||||
class SmartEditActionList extends StatefulWidget {
|
||||
const SmartEditActionList({
|
||||
class AskAIActionList extends StatefulWidget {
|
||||
const AskAIActionList({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
this.tooltipBuilder,
|
||||
@@ -34,25 +35,28 @@ class SmartEditActionList extends StatefulWidget {
|
||||
final ToolbarTooltipBuilder? tooltipBuilder;
|
||||
|
||||
@override
|
||||
State<SmartEditActionList> createState() => _SmartEditActionListState();
|
||||
State<AskAIActionList> createState() => _AskAIActionListState();
|
||||
}
|
||||
|
||||
class _SmartEditActionListState extends State<SmartEditActionList> {
|
||||
class _AskAIActionListState extends State<AskAIActionList> {
|
||||
bool isAIEnabled = true;
|
||||
|
||||
EditorState get editorState => widget.editorState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
isAIEnabled = _isAIEnabled();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopoverActionList<SmartEditActionWrapper>(
|
||||
return PopoverActionList<AskAIActionWrapper>(
|
||||
offset: const Offset(-5, 5),
|
||||
direction: PopoverDirection.bottomWithLeftAligned,
|
||||
actions: SmartEditAction.values
|
||||
.map((action) => SmartEditActionWrapper(action))
|
||||
actions: AskAIAction.values
|
||||
.map((action) => AskAIActionWrapper(action))
|
||||
.toList(),
|
||||
onClosed: () => keepEditorFocusNotifier.decrease(),
|
||||
buildChild: (controller) {
|
||||
@@ -88,7 +92,7 @@ class _SmartEditActionListState extends State<SmartEditActionList> {
|
||||
if (widget.tooltipBuilder != null) {
|
||||
return widget.tooltipBuilder!(
|
||||
context,
|
||||
_kSmartEditToolbarItemId,
|
||||
_kAskAIToolbarItemId,
|
||||
isAIEnabled
|
||||
? LocaleKeys.document_plugins_smartEdit.tr()
|
||||
: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
|
||||
@@ -100,59 +104,39 @@ class _SmartEditActionListState extends State<SmartEditActionList> {
|
||||
},
|
||||
onSelected: (action, controller) {
|
||||
controller.close();
|
||||
_insertSmartEditNode(action);
|
||||
_insertAskAINode(action);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _insertSmartEditNode(
|
||||
SmartEditActionWrapper actionWrapper,
|
||||
Future<void> _insertAskAINode(
|
||||
AskAIActionWrapper actionWrapper,
|
||||
) async {
|
||||
final selection = widget.editorState.selection?.normalized;
|
||||
final selection = editorState.selection?.normalized;
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// support multiple paragraphs
|
||||
final input = _getTextInSelection(selection);
|
||||
final markdown = editorState.getMarkdownInSelection(selection);
|
||||
|
||||
final transaction = widget.editorState.transaction;
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertNode(
|
||||
selection.normalized.end.path.next,
|
||||
smartEditNode(
|
||||
askAINode(
|
||||
action: actionWrapper.inner,
|
||||
content: input.join('\n\n'),
|
||||
content: markdown,
|
||||
),
|
||||
);
|
||||
await widget.editorState.apply(
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(
|
||||
recordUndo: false,
|
||||
inMemoryUpdate: true,
|
||||
),
|
||||
withUpdateSelection: false,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _getTextInSelection(
|
||||
Selection selection,
|
||||
) {
|
||||
final res = <String>[];
|
||||
if (selection.isCollapsed) {
|
||||
return res;
|
||||
}
|
||||
final nodes = widget.editorState.getNodesInSelection(selection);
|
||||
for (final node in nodes) {
|
||||
final delta = node.delta;
|
||||
if (delta == null) {
|
||||
continue;
|
||||
}
|
||||
final startIndex = node == nodes.first ? selection.startIndex : 0;
|
||||
final endIndex = node == nodes.last ? selection.endIndex : delta.length;
|
||||
res.add(delta.slice(startIndex, endIndex).toPlainText());
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
bool _isAIEnabled() {
|
||||
final documentContext = widget.editorState.document.root.context;
|
||||
if (documentContext == null) {
|
||||
-556
@@ -1,556 +0,0 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.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/selectable_svg_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
|
||||
import 'package:appflowy/user/application/ai_service.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/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 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'ai_limit_dialog.dart';
|
||||
|
||||
class AutoCompletionBlockKeys {
|
||||
const AutoCompletionBlockKeys._();
|
||||
|
||||
static const String type = 'auto_completion';
|
||||
static const String prompt = 'prompt';
|
||||
static const String startSelection = 'start_selection';
|
||||
static const String generationCount = 'generation_count';
|
||||
}
|
||||
|
||||
Node autoCompletionNode({
|
||||
String prompt = '',
|
||||
required Selection start,
|
||||
}) {
|
||||
return Node(
|
||||
type: AutoCompletionBlockKeys.type,
|
||||
attributes: {
|
||||
AutoCompletionBlockKeys.prompt: prompt,
|
||||
AutoCompletionBlockKeys.startSelection: start.toJson(),
|
||||
AutoCompletionBlockKeys.generationCount: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node(
|
||||
getName: LocaleKeys.document_plugins_autoGeneratorMenuItemName.tr,
|
||||
iconBuilder: (editorState, onSelected, style) => SelectableSvgWidget(
|
||||
data: FlowySvgs.menu_item_ai_writer_s,
|
||||
isSelected: onSelected,
|
||||
style: style,
|
||||
),
|
||||
keywords: ['ai', 'openai', 'writer', 'ai writer', 'autogenerator'],
|
||||
nodeBuilder: (editorState, _) {
|
||||
final node = autoCompletionNode(start: editorState.selection!);
|
||||
return node;
|
||||
},
|
||||
replace: (_, node) => false,
|
||||
);
|
||||
|
||||
class AutoCompletionBlockComponentBuilder extends BlockComponentBuilder {
|
||||
AutoCompletionBlockComponentBuilder();
|
||||
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
return AutoCompletionBlockComponent(
|
||||
key: node.key,
|
||||
node: node,
|
||||
showActions: showActions(node),
|
||||
actionBuilder: (context, state) => actionBuilder(
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
BlockComponentValidate get validate => (node) =>
|
||||
node.children.isEmpty &&
|
||||
node.attributes[AutoCompletionBlockKeys.prompt] is String &&
|
||||
node.attributes[AutoCompletionBlockKeys.startSelection] is Map;
|
||||
}
|
||||
|
||||
class AutoCompletionBlockComponent extends BlockComponentStatefulWidget {
|
||||
const AutoCompletionBlockComponent({
|
||||
super.key,
|
||||
required super.node,
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
@override
|
||||
State<AutoCompletionBlockComponent> createState() =>
|
||||
_AutoCompletionBlockComponentState();
|
||||
}
|
||||
|
||||
class _AutoCompletionBlockComponentState
|
||||
extends State<AutoCompletionBlockComponent> {
|
||||
final controller = TextEditingController();
|
||||
final textFieldFocusNode = FocusNode();
|
||||
|
||||
late final editorState = context.read<EditorState>();
|
||||
late final SelectionGestureInterceptor interceptor;
|
||||
|
||||
String get prompt => widget.node.attributes[AutoCompletionBlockKeys.prompt];
|
||||
int get generationCount =>
|
||||
widget.node.attributes[AutoCompletionBlockKeys.generationCount] ?? 0;
|
||||
Selection? get startSelection {
|
||||
final selection =
|
||||
widget.node.attributes[AutoCompletionBlockKeys.startSelection];
|
||||
if (selection != null) {
|
||||
return Selection.fromJson(selection);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_subscribeSelectionGesture();
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
editorState.selection = null;
|
||||
textFieldFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_onExit();
|
||||
_unsubscribeSelectionGesture();
|
||||
controller.dispose();
|
||||
textFieldFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final child = Card(
|
||||
elevation: 5,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AutoCompletionHeader(),
|
||||
const Space(0, 10),
|
||||
if (prompt.isEmpty && generationCount < 1) ...[
|
||||
_buildInputWidget(context),
|
||||
const Space(0, 10),
|
||||
AutoCompletionInputFooter(
|
||||
onGenerate: _onGenerate,
|
||||
onExit: _onExit,
|
||||
),
|
||||
] else ...[
|
||||
AutoCompletionFooter(
|
||||
onKeep: _onExit,
|
||||
onRewrite: _onRewrite,
|
||||
onDiscard: _onDiscard,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 40),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputWidget(BuildContext context) {
|
||||
return FlowyTextField(
|
||||
hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(),
|
||||
controller: controller,
|
||||
maxLines: 5,
|
||||
focusNode: textFieldFocusNode,
|
||||
autoFocus: false,
|
||||
hintTextConstraints: const BoxConstraints(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onExit() async {
|
||||
final transaction = editorState.transaction..deleteNode(widget.node);
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(recordUndo: false),
|
||||
withUpdateSelection: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onGenerate() async {
|
||||
await _updateEditingText();
|
||||
|
||||
final userProfile = await UserBackendService.getCurrentUserProfile()
|
||||
.then((value) => value.toNullable());
|
||||
if (userProfile == null) {
|
||||
if (mounted) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
|
||||
showCancel: true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final textRobot = TextRobot(editorState: editorState);
|
||||
BarrierDialog? barrierDialog;
|
||||
final aiRepository = AppFlowyAIService();
|
||||
await aiRepository.streamCompletion(
|
||||
text: controller.text,
|
||||
completionType: CompletionTypePB.ContinueWriting,
|
||||
onStart: () async {
|
||||
if (mounted) {
|
||||
barrierDialog = BarrierDialog(context);
|
||||
barrierDialog?.show();
|
||||
await _makeSurePreviousNodeIsEmptyParagraphNode();
|
||||
}
|
||||
},
|
||||
onProcess: (text) async {
|
||||
await textRobot.autoInsertText(
|
||||
text,
|
||||
delay: Duration.zero,
|
||||
);
|
||||
},
|
||||
onEnd: () async {
|
||||
barrierDialog?.dismiss();
|
||||
},
|
||||
onError: (error) async {
|
||||
barrierDialog?.dismiss();
|
||||
if (mounted) {
|
||||
if (error.isLimitExceeded) {
|
||||
showAILimitDialog(context, error.message);
|
||||
await _onDiscard();
|
||||
} else {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
error.message,
|
||||
showCancel: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
await _updateGenerationCount();
|
||||
}
|
||||
|
||||
Future<void> _onDiscard() async {
|
||||
final selection = startSelection;
|
||||
if (selection != null) {
|
||||
final start = selection.start.path;
|
||||
final end = widget.node.previous?.path;
|
||||
if (end != null) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.deleteNodesAtPath(
|
||||
start,
|
||||
end.last - start.last + 1,
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
await _makeSurePreviousNodeIsEmptyParagraphNode();
|
||||
}
|
||||
}
|
||||
return _onExit();
|
||||
}
|
||||
|
||||
Future<void> _onRewrite() async {
|
||||
final previousOutput = _getPreviousOutput();
|
||||
if (previousOutput == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// clear previous response
|
||||
final selection = startSelection;
|
||||
if (selection != null) {
|
||||
final start = selection.start.path;
|
||||
final end = widget.node.previous?.path;
|
||||
if (end != null) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.deleteNodesAtPath(
|
||||
start,
|
||||
end.last - start.last + 1,
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
// generate new response
|
||||
final userProfile = await UserBackendService.getCurrentUserProfile()
|
||||
.then((value) => value.toNullable());
|
||||
if (userProfile == null) {
|
||||
if (mounted) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
|
||||
showCancel: true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final textRobot = TextRobot(editorState: editorState);
|
||||
final aiService = AppFlowyAIService();
|
||||
await aiService.streamCompletion(
|
||||
text: _rewritePrompt(previousOutput),
|
||||
completionType: CompletionTypePB.ContinueWriting,
|
||||
onStart: () async {
|
||||
await _makeSurePreviousNodeIsEmptyParagraphNode();
|
||||
},
|
||||
onProcess: (text) async {
|
||||
await textRobot.autoInsertText(
|
||||
text,
|
||||
delay: Duration.zero,
|
||||
);
|
||||
},
|
||||
onEnd: () async {},
|
||||
onError: (error) async {
|
||||
if (mounted) {
|
||||
if (error.isLimitExceeded) {
|
||||
showAILimitDialog(context, error.message);
|
||||
} else {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
error.message,
|
||||
showCancel: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
await _updateGenerationCount();
|
||||
}
|
||||
|
||||
String? _getPreviousOutput() {
|
||||
final startSelection = this.startSelection;
|
||||
if (startSelection != null) {
|
||||
final end = widget.node.previous?.path;
|
||||
|
||||
if (end != null) {
|
||||
final result = editorState
|
||||
.getNodesInSelection(
|
||||
startSelection.copyWith(end: Position(path: end)),
|
||||
)
|
||||
.fold(
|
||||
'',
|
||||
(previousValue, element) {
|
||||
final delta = element.delta;
|
||||
if (delta != null) {
|
||||
return "$previousValue\n${delta.toPlainText()}";
|
||||
} else {
|
||||
return previousValue;
|
||||
}
|
||||
},
|
||||
);
|
||||
return result.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _updateEditingText() async {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(
|
||||
widget.node,
|
||||
{
|
||||
AutoCompletionBlockKeys.prompt: controller.text,
|
||||
},
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> _updateGenerationCount() async {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(
|
||||
widget.node,
|
||||
{
|
||||
AutoCompletionBlockKeys.generationCount: generationCount + 1,
|
||||
},
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
String _rewritePrompt(String previousOutput) {
|
||||
return 'I am not satisfied with your previous response ($previousOutput) to the query ($prompt). Please provide an alternative response.';
|
||||
}
|
||||
|
||||
Future<void> _makeSurePreviousNodeIsEmptyParagraphNode() async {
|
||||
// make sure the previous node is a empty paragraph node without any styles.
|
||||
final transaction = editorState.transaction;
|
||||
final previous = widget.node.previous;
|
||||
final Selection selection;
|
||||
if (previous == null ||
|
||||
previous.type != ParagraphBlockKeys.type ||
|
||||
(previous.delta?.toPlainText().isNotEmpty ?? false)) {
|
||||
selection = Selection.single(
|
||||
path: widget.node.path,
|
||||
startOffset: 0,
|
||||
);
|
||||
transaction.insertNode(
|
||||
widget.node.path,
|
||||
paragraphNode(),
|
||||
);
|
||||
} else {
|
||||
selection = Selection.single(
|
||||
path: previous.path,
|
||||
startOffset: 0,
|
||||
);
|
||||
}
|
||||
transaction.updateNode(widget.node, {
|
||||
AutoCompletionBlockKeys.startSelection: selection.toJson(),
|
||||
});
|
||||
transaction.afterSelection = selection;
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
void _subscribeSelectionGesture() {
|
||||
interceptor = SelectionGestureInterceptor(
|
||||
key: AutoCompletionBlockKeys.type,
|
||||
canTap: (details) {
|
||||
if (!context.isOffsetInside(details.globalPosition)) {
|
||||
if (prompt.isNotEmpty || controller.text.isNotEmpty) {
|
||||
// show dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => DiscardDialog(
|
||||
onConfirm: _onDiscard,
|
||||
onCancel: () {},
|
||||
),
|
||||
);
|
||||
} else if (controller.text.isEmpty) {
|
||||
_onExit();
|
||||
}
|
||||
}
|
||||
editorState.service.keyboardService?.disable();
|
||||
return false;
|
||||
},
|
||||
);
|
||||
editorState.service.selectionService.registerGestureInterceptor(
|
||||
interceptor,
|
||||
);
|
||||
}
|
||||
|
||||
void _unsubscribeSelectionGesture() {
|
||||
editorState.service.selectionService.unregisterGestureInterceptor(
|
||||
AutoCompletionBlockKeys.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AutoCompletionHeader extends StatelessWidget {
|
||||
const AutoCompletionHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyText.medium(
|
||||
LocaleKeys.document_plugins_autoGeneratorTitleName.tr(),
|
||||
fontSize: 14,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AutoCompletionInputFooter extends StatelessWidget {
|
||||
const AutoCompletionInputFooter({
|
||||
super.key,
|
||||
required this.onGenerate,
|
||||
required this.onExit,
|
||||
});
|
||||
|
||||
final VoidCallback onGenerate;
|
||||
final VoidCallback onExit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PrimaryRoundedButton(
|
||||
text: LocaleKeys.button_generate.tr(),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 10.0,
|
||||
),
|
||||
radius: 8.0,
|
||||
onTap: onGenerate,
|
||||
),
|
||||
const Space(10, 0),
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.button_cancel.tr(),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 10.0,
|
||||
),
|
||||
onTap: onExit,
|
||||
),
|
||||
Flexible(
|
||||
child: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FlowyText.regular(
|
||||
LocaleKeys.document_plugins_warning.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AutoCompletionFooter extends StatelessWidget {
|
||||
const AutoCompletionFooter({
|
||||
super.key,
|
||||
required this.onKeep,
|
||||
required this.onRewrite,
|
||||
required this.onDiscard,
|
||||
});
|
||||
|
||||
final VoidCallback onKeep;
|
||||
final VoidCallback onRewrite;
|
||||
final VoidCallback onDiscard;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
PrimaryRoundedButton(
|
||||
text: LocaleKeys.button_keep.tr(),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 9.0,
|
||||
),
|
||||
onTap: onKeep,
|
||||
),
|
||||
const HSpace(10),
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(),
|
||||
onTap: onRewrite,
|
||||
),
|
||||
const HSpace(10),
|
||||
OutlinedRoundedButton(
|
||||
text: LocaleKeys.button_discard.tr(),
|
||||
onTap: onDiscard,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -88,8 +88,7 @@ class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
|
||||
|
||||
@override
|
||||
late EditorState editorState = context.read<EditorState>();
|
||||
late Stream<(TransactionTime, Transaction)> stream =
|
||||
editorState.transactionStream;
|
||||
late Stream<EditorTransactionValue> stream = editorState.transactionStream;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
+2
@@ -1,4 +1,6 @@
|
||||
export 'callout_node_parser.dart';
|
||||
export 'custom_image_node_parser.dart';
|
||||
export 'markdown_code_parser.dart';
|
||||
export 'math_equation_node_parser.dart';
|
||||
export 'toggle_list_node_parser.dart';
|
||||
export 'simple_table_parser.dart';
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
|
||||
class MarkdownSimpleTableParser extends CustomMarkdownParser {
|
||||
const MarkdownSimpleTableParser();
|
||||
|
||||
@override
|
||||
List<Node> transform(
|
||||
md.Node element,
|
||||
List<CustomMarkdownParser> parsers, {
|
||||
MarkdownListType listType = MarkdownListType.unknown,
|
||||
int? startNumber,
|
||||
}) {
|
||||
if (element is! md.Element) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (element.tag != 'table') {
|
||||
return [];
|
||||
}
|
||||
|
||||
final ec = element.children;
|
||||
if (ec == null || ec.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final th = ec
|
||||
.whereType<md.Element>()
|
||||
.where((e) => e.tag == 'thead')
|
||||
.firstOrNull
|
||||
?.children
|
||||
?.whereType<md.Element>()
|
||||
.where((e) => e.tag == 'tr')
|
||||
.expand((e) => e.children?.whereType<md.Element>().toList() ?? [])
|
||||
.where((e) => e.tag == 'th')
|
||||
.toList();
|
||||
|
||||
final tr = ec
|
||||
.whereType<md.Element>()
|
||||
.where((e) => e.tag == 'tbody')
|
||||
.firstOrNull
|
||||
?.children
|
||||
?.whereType<md.Element>()
|
||||
.where((e) => e.tag == 'tr')
|
||||
.toList();
|
||||
|
||||
if (th == null || tr == null || th.isEmpty || tr.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final rows = <Node>[];
|
||||
|
||||
// Add header cells
|
||||
|
||||
rows.add(
|
||||
simpleTableRowBlockNode(
|
||||
children: th
|
||||
.map(
|
||||
(e) => simpleTableCellBlockNode(
|
||||
children: [
|
||||
paragraphNode(
|
||||
delta: DeltaMarkdownDecoder().convertNodes(e.children),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
|
||||
// Add body cells
|
||||
for (var i = 0; i < tr.length; i++) {
|
||||
final td = tr[i]
|
||||
.children
|
||||
?.whereType<md.Element>()
|
||||
.where((e) => e.tag == 'td')
|
||||
.toList();
|
||||
|
||||
if (td == null || td.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.add(
|
||||
simpleTableRowBlockNode(
|
||||
children: td
|
||||
.map(
|
||||
(e) => simpleTableCellBlockNode(
|
||||
children: [
|
||||
paragraphNode(
|
||||
delta: DeltaMarkdownDecoder().convertNodes(e.children),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return [simpleTableBlockNode(children: rows)];
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
/// Parser for converting SimpleTable nodes to markdown format
|
||||
class SimpleTableNodeParser extends NodeParser {
|
||||
const SimpleTableNodeParser();
|
||||
|
||||
@override
|
||||
String get id => SimpleTableBlockKeys.type;
|
||||
|
||||
@override
|
||||
String transform(Node node, DocumentMarkdownEncoder? encoder) {
|
||||
try {
|
||||
final tableData = _extractTableData(node);
|
||||
if (tableData.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
return _buildMarkdownTable(tableData);
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts table data from the node structure into a 2D list of strings
|
||||
/// Each inner list represents a row, and each string represents a cell's content
|
||||
List<List<String>> _extractTableData(Node node) {
|
||||
final tableData = <List<String>>[];
|
||||
final rows = node.children;
|
||||
|
||||
for (final row in rows) {
|
||||
final rowData = _extractRowData(row);
|
||||
tableData.add(rowData);
|
||||
}
|
||||
|
||||
return tableData;
|
||||
}
|
||||
|
||||
/// Extracts data from a single table row
|
||||
List<String> _extractRowData(Node row) {
|
||||
final rowData = <String>[];
|
||||
final cells = row.children;
|
||||
|
||||
for (final cell in cells) {
|
||||
final content = _extractCellContent(cell);
|
||||
rowData.add(content);
|
||||
}
|
||||
|
||||
return rowData;
|
||||
}
|
||||
|
||||
/// Extracts and formats content from a single table cell
|
||||
String _extractCellContent(Node cell) {
|
||||
final contentBuffer = StringBuffer();
|
||||
|
||||
for (final child in cell.children) {
|
||||
final delta = child.delta;
|
||||
if (delta == null) continue;
|
||||
|
||||
final content = DeltaMarkdownEncoder().convert(delta);
|
||||
// Escape pipe characters to prevent breaking markdown table structure
|
||||
contentBuffer.write(content.replaceAll('|', '\\|'));
|
||||
}
|
||||
|
||||
return contentBuffer.toString();
|
||||
}
|
||||
|
||||
/// Builds a markdown table string from the extracted table data
|
||||
/// First row is treated as header, followed by separator row and data rows
|
||||
String _buildMarkdownTable(List<List<String>> tableData) {
|
||||
final markdown = StringBuffer();
|
||||
final columnCount = tableData[0].length;
|
||||
|
||||
// Add header row
|
||||
markdown.writeln('|${tableData[0].join('|')}|');
|
||||
|
||||
// Add separator row
|
||||
markdown.writeln('|${List.filled(columnCount, '---').join('|')}|');
|
||||
|
||||
// Add data rows (skip header row)
|
||||
for (int i = 1; i < tableData.length; i++) {
|
||||
markdown.writeln('|${tableData[i].join('|')}|');
|
||||
}
|
||||
|
||||
return markdown.toString();
|
||||
}
|
||||
}
|
||||
+11
-8
@@ -24,9 +24,11 @@ export 'header/cover_editor_bloc.dart';
|
||||
export 'header/custom_cover_picker.dart';
|
||||
export 'header/document_cover_widget.dart';
|
||||
export 'heading/heading_toolbar_item.dart';
|
||||
export 'image/custom_image_block_component/custom_image_block_component.dart';
|
||||
export 'image/custom_image_block_component/image_menu.dart';
|
||||
export 'image/image_selection_menu.dart';
|
||||
export 'image/mobile_image_toolbar_item.dart';
|
||||
export 'image/multi_image_block_component/multi_image_block_component.dart';
|
||||
export 'image/multi_image_block_component/multi_image_menu.dart';
|
||||
export 'inline_math_equation/inline_math_equation.dart';
|
||||
export 'inline_math_equation/inline_math_equation_toolbar_item.dart';
|
||||
@@ -36,12 +38,14 @@ export 'link_preview/link_preview_cache.dart';
|
||||
export 'link_preview/link_preview_menu.dart';
|
||||
export 'math_equation/math_equation_block_component.dart';
|
||||
export 'math_equation/mobile_math_equation_toolbar_item.dart';
|
||||
export 'mention/mention_block.dart';
|
||||
export 'mobile_floating_toolbar/custom_mobile_floating_toolbar.dart';
|
||||
export 'mobile_toolbar_v3/aa_toolbar_item.dart';
|
||||
export 'mobile_toolbar_v3/add_attachment_item.dart';
|
||||
export 'mobile_toolbar_v3/add_block_toolbar_item.dart';
|
||||
export 'mobile_toolbar_v3/appflowy_mobile_toolbar.dart';
|
||||
export 'mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart';
|
||||
export 'mobile_toolbar_v3/biusc_toolbar_item.dart';
|
||||
export 'mobile_toolbar_v3/basic_toolbar_item.dart';
|
||||
export 'mobile_toolbar_v3/indent_outdent_toolbar_item.dart';
|
||||
export 'mobile_toolbar_v3/list_toolbar_item.dart';
|
||||
export 'mobile_toolbar_v3/more_toolbar_item.dart';
|
||||
@@ -49,21 +53,20 @@ export 'mobile_toolbar_v3/toolbar_item_builder.dart';
|
||||
export 'mobile_toolbar_v3/undo_redo_toolbar_item.dart';
|
||||
export 'mobile_toolbar_v3/util.dart';
|
||||
export 'numbered_list/numbered_list_icon.dart';
|
||||
export 'openai/widgets/auto_completion_node_widget.dart';
|
||||
export 'openai/widgets/smart_edit_node_widget.dart';
|
||||
export 'openai/widgets/smart_edit_toolbar_item.dart';
|
||||
export 'openai/widgets/ai_writer_block_component.dart';
|
||||
export 'openai/widgets/ask_ai_block_component.dart';
|
||||
export 'openai/widgets/ask_ai_toolbar_item.dart';
|
||||
export 'outline/outline_block_component.dart';
|
||||
export 'parsers/markdown_parsers.dart';
|
||||
export 'parsers/markdown_simple_table_parser.dart';
|
||||
export 'quote/quote_block_shortcuts.dart';
|
||||
export 'shortcuts/character_shortcuts.dart';
|
||||
export 'shortcuts/command_shortcuts.dart';
|
||||
export 'simple_table/simple_table.dart';
|
||||
export 'slash_menu/slash_menu_items.dart';
|
||||
export 'sub_page/sub_page_block_component.dart';
|
||||
export 'table/table_menu.dart';
|
||||
export 'table/table_option_action.dart';
|
||||
export 'todo_list/todo_list_icon.dart';
|
||||
export 'toggle/toggle_block_component.dart';
|
||||
export 'toggle/toggle_block_shortcuts.dart';
|
||||
export 'sub_page/sub_page_block_component.dart';
|
||||
export 'mention/mention_block.dart';
|
||||
export 'image/custom_image_block_component/custom_image_block_component.dart';
|
||||
export 'image/multi_image_block_component/multi_image_block_component.dart';
|
||||
+3
@@ -15,6 +15,8 @@ final List<CommandShortcutEvent> defaultCommandShortcutEvents = [
|
||||
|
||||
// Command shortcuts are order-sensitive. Verify order when modifying.
|
||||
List<CommandShortcutEvent> commandShortcutEvents = [
|
||||
...simpleTableCommands,
|
||||
|
||||
customExitEditingCommand,
|
||||
backspaceToTitle,
|
||||
removeToggleHeadingStyle,
|
||||
@@ -45,6 +47,7 @@ List<CommandShortcutEvent> commandShortcutEvents = [
|
||||
undoCommand,
|
||||
redoCommand,
|
||||
exitEditingCommand,
|
||||
// ...tableCommands,
|
||||
].contains(shortcut),
|
||||
),
|
||||
|
||||
|
||||
+736
@@ -0,0 +1,736 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Only displaying the add row / add column / add column and row button
|
||||
/// when hovering on the last row / last column / last cell.
|
||||
bool _enableHoveringLogicV2 = true;
|
||||
|
||||
class SimpleTableReorderButton extends StatelessWidget {
|
||||
const SimpleTableReorderButton({
|
||||
super.key,
|
||||
required this.isShowingMenu,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final ValueNotifier<bool> isShowingMenu;
|
||||
final SimpleTableMoreActionType type;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: isShowingMenu,
|
||||
builder: (context, isShowingMenu, child) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isShowingMenu
|
||||
? context.simpleTableMoreActionHoverColor
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
border: Border.all(
|
||||
color: context.simpleTableMoreActionBorderColor,
|
||||
),
|
||||
),
|
||||
height: 16.0,
|
||||
width: 16.0,
|
||||
child: FlowySvg(
|
||||
type.reorderIconSvg,
|
||||
color: isShowingMenu ? Colors.white : null,
|
||||
size: const Size.square(16.0),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableAddRowHoverButton extends StatefulWidget {
|
||||
const SimpleTableAddRowHoverButton({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
required this.tableNode,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final Node tableNode;
|
||||
|
||||
@override
|
||||
State<SimpleTableAddRowHoverButton> createState() =>
|
||||
_SimpleTableAddRowHoverButtonState();
|
||||
}
|
||||
|
||||
class _SimpleTableAddRowHoverButtonState
|
||||
extends State<SimpleTableAddRowHoverButton> {
|
||||
late final interceptorKey =
|
||||
'simple_table_add_row_hover_button_${widget.tableNode.id}';
|
||||
|
||||
SelectionGestureInterceptor? interceptor;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
interceptor = SelectionGestureInterceptor(
|
||||
key: interceptorKey,
|
||||
canTap: (details) => !_isTapInBounds(details.globalPosition),
|
||||
);
|
||||
widget.editorState.service.selectionService
|
||||
.registerGestureInterceptor(interceptor!);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.editorState.service.selectionService.unregisterGestureInterceptor(
|
||||
interceptorKey,
|
||||
);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(widget.tableNode.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (widget.tableNode.type != SimpleTableBlockKeys.type) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final simpleTableContext = context.read<SimpleTableContext>();
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: simpleTableContext.isHoveringOnTableArea,
|
||||
builder: (context, isHoveringOnTableArea, child) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: simpleTableContext.hoveringTableCell,
|
||||
builder: (context, hoveringTableCell, child) {
|
||||
bool shouldShow = isHoveringOnTableArea;
|
||||
if (hoveringTableCell != null && _enableHoveringLogicV2) {
|
||||
shouldShow =
|
||||
hoveringTableCell.rowIndex + 1 == hoveringTableCell.rowLength;
|
||||
}
|
||||
return shouldShow
|
||||
? Positioned(
|
||||
bottom: 2 * SimpleTableConstants.addRowButtonPadding,
|
||||
left: SimpleTableConstants.tableLeftPadding -
|
||||
SimpleTableConstants.cellBorderWidth,
|
||||
right: SimpleTableConstants.addRowButtonRightPadding,
|
||||
child: SimpleTableAddRowButton(
|
||||
onTap: () => widget.editorState.addRowInTable(
|
||||
widget.tableNode,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _isTapInBounds(Offset offset) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final localPosition = renderBox.globalToLocal(offset);
|
||||
final result = renderBox.paintBounds.contains(localPosition);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableAddRowButton extends StatelessWidget {
|
||||
const SimpleTableAddRowButton({
|
||||
super.key,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.document_plugins_simpleTable_clickToAddNewRow.tr(),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
height: SimpleTableConstants.addRowButtonHeight,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
vertical: SimpleTableConstants.addColumnButtonPadding,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
SimpleTableConstants.addRowButtonRadius,
|
||||
),
|
||||
color: context.simpleTableMoreActionBackgroundColor,
|
||||
),
|
||||
child: const FlowySvg(
|
||||
FlowySvgs.add_s,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableAddColumnHoverButton extends StatefulWidget {
|
||||
const SimpleTableAddColumnHoverButton({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
required this.node,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final Node node;
|
||||
|
||||
@override
|
||||
State<SimpleTableAddColumnHoverButton> createState() =>
|
||||
_SimpleTableAddColumnHoverButtonState();
|
||||
}
|
||||
|
||||
class _SimpleTableAddColumnHoverButtonState
|
||||
extends State<SimpleTableAddColumnHoverButton> {
|
||||
late final interceptorKey =
|
||||
'simple_table_add_column_hover_button_${widget.node.id}';
|
||||
|
||||
SelectionGestureInterceptor? interceptor;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
interceptor = SelectionGestureInterceptor(
|
||||
key: interceptorKey,
|
||||
canTap: (details) => !_isTapInBounds(details.globalPosition),
|
||||
);
|
||||
widget.editorState.service.selectionService
|
||||
.registerGestureInterceptor(interceptor!);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.editorState.service.selectionService.unregisterGestureInterceptor(
|
||||
interceptorKey,
|
||||
);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(widget.node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (widget.node.type != SimpleTableBlockKeys.type) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: context.read<SimpleTableContext>().isHoveringOnTableArea,
|
||||
builder: (context, isHoveringOnTableArea, _) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: context.read<SimpleTableContext>().hoveringTableCell,
|
||||
builder: (context, hoveringTableCell, _) {
|
||||
bool shouldShow = isHoveringOnTableArea;
|
||||
if (hoveringTableCell != null && _enableHoveringLogicV2) {
|
||||
shouldShow = hoveringTableCell.columnIndex + 1 ==
|
||||
hoveringTableCell.columnLength;
|
||||
}
|
||||
return Positioned(
|
||||
top: SimpleTableConstants.tableTopPadding -
|
||||
SimpleTableConstants.cellBorderWidth,
|
||||
bottom: SimpleTableConstants.addColumnButtonBottomPadding,
|
||||
right: 0,
|
||||
child: Opacity(
|
||||
opacity: shouldShow ? 1.0 : 0.0,
|
||||
child: SimpleTableAddColumnButton(
|
||||
onTap: () {
|
||||
widget.editorState.addColumnInTable(widget.node);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _isTapInBounds(Offset offset) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final localPosition = renderBox.globalToLocal(offset);
|
||||
final result = renderBox.paintBounds.contains(localPosition);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableAddColumnButton extends StatelessWidget {
|
||||
const SimpleTableAddColumnButton({
|
||||
super.key,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.document_plugins_simpleTable_clickToAddNewColumn.tr(),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: onTap,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
width: SimpleTableConstants.addColumnButtonWidth,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: SimpleTableConstants.addColumnButtonPadding,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
SimpleTableConstants.addColumnButtonRadius,
|
||||
),
|
||||
color: context.simpleTableMoreActionBackgroundColor,
|
||||
),
|
||||
child: const FlowySvg(
|
||||
FlowySvgs.add_s,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableAddColumnAndRowHoverButton extends StatelessWidget {
|
||||
const SimpleTableAddColumnAndRowHoverButton({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
required this.node,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final Node node;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: context.read<SimpleTableContext>().isHoveringOnTableArea,
|
||||
builder: (context, isHoveringOnTableArea, child) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: context.read<SimpleTableContext>().hoveringTableCell,
|
||||
builder: (context, hoveringTableCell, child) {
|
||||
bool shouldShow = isHoveringOnTableArea;
|
||||
if (hoveringTableCell != null && _enableHoveringLogicV2) {
|
||||
shouldShow = hoveringTableCell.isLastCellInTable;
|
||||
}
|
||||
return shouldShow
|
||||
? Positioned(
|
||||
bottom:
|
||||
SimpleTableConstants.addColumnAndRowButtonBottomPadding,
|
||||
right: SimpleTableConstants.addColumnButtonPadding,
|
||||
child: SimpleTableAddColumnAndRowButton(
|
||||
onTap: () => editorState.addColumnAndRowInTable(node),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableAddColumnAndRowButton extends StatelessWidget {
|
||||
const SimpleTableAddColumnAndRowButton({
|
||||
super.key,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.document_plugins_simpleTable_clickToAddNewRowAndColumn
|
||||
.tr(),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: onTap,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
width: SimpleTableConstants.addColumnAndRowButtonWidth,
|
||||
height: SimpleTableConstants.addColumnAndRowButtonHeight,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
SimpleTableConstants.addColumnAndRowButtonCornerRadius,
|
||||
),
|
||||
color: context.simpleTableMoreActionBackgroundColor,
|
||||
),
|
||||
child: const FlowySvg(
|
||||
FlowySvgs.add_s,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableRowDivider extends StatelessWidget {
|
||||
const SimpleTableRowDivider({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return VerticalDivider(
|
||||
color: context.simpleTableBorderColor,
|
||||
width: 1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableColumnDivider extends StatelessWidget {
|
||||
const SimpleTableColumnDivider({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Divider(
|
||||
color: context.simpleTableBorderColor,
|
||||
height: 1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableAlignMenu extends StatefulWidget {
|
||||
const SimpleTableAlignMenu({
|
||||
super.key,
|
||||
required this.type,
|
||||
required this.tableCellNode,
|
||||
this.mutex,
|
||||
});
|
||||
|
||||
final SimpleTableMoreActionType type;
|
||||
final Node tableCellNode;
|
||||
final PopoverMutex? mutex;
|
||||
|
||||
@override
|
||||
State<SimpleTableAlignMenu> createState() => _SimpleTableAlignMenuState();
|
||||
}
|
||||
|
||||
class _SimpleTableAlignMenuState extends State<SimpleTableAlignMenu> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final align = switch (widget.type) {
|
||||
SimpleTableMoreActionType.column => widget.tableCellNode.columnAlign,
|
||||
SimpleTableMoreActionType.row => widget.tableCellNode.rowAlign,
|
||||
};
|
||||
return AppFlowyPopover(
|
||||
mutex: widget.mutex,
|
||||
child: SimpleTableBasicButton(
|
||||
leftIconSvg: align.leftIconSvg,
|
||||
text: LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
|
||||
onTap: () {},
|
||||
),
|
||||
popupBuilder: (popoverContext) {
|
||||
void onClose() => PopoverContainer.of(popoverContext).closeAll();
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildAlignButton(context, TableAlign.left, onClose),
|
||||
_buildAlignButton(context, TableAlign.center, onClose),
|
||||
_buildAlignButton(context, TableAlign.right, onClose),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlignButton(
|
||||
BuildContext context,
|
||||
TableAlign align,
|
||||
VoidCallback onClose,
|
||||
) {
|
||||
return SimpleTableBasicButton(
|
||||
leftIconSvg: align.leftIconSvg,
|
||||
text: align.name,
|
||||
onTap: () {
|
||||
switch (widget.type) {
|
||||
case SimpleTableMoreActionType.column:
|
||||
context.read<EditorState>().updateColumnAlign(
|
||||
tableCellNode: widget.tableCellNode,
|
||||
align: align,
|
||||
);
|
||||
break;
|
||||
case SimpleTableMoreActionType.row:
|
||||
context.read<EditorState>().updateRowAlign(
|
||||
tableCellNode: widget.tableCellNode,
|
||||
align: align,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
onClose();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableBasicButton extends StatelessWidget {
|
||||
const SimpleTableBasicButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
this.leftIconSvg,
|
||||
this.leftIconBuilder,
|
||||
this.rightIcon,
|
||||
});
|
||||
|
||||
final FlowySvgData? leftIconSvg;
|
||||
final String text;
|
||||
final VoidCallback onTap;
|
||||
final Widget Function(bool onHover)? leftIconBuilder;
|
||||
final Widget? rightIcon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: SimpleTableConstants.moreActionHeight,
|
||||
padding: SimpleTableConstants.moreActionPadding,
|
||||
child: FlowyIconTextButton(
|
||||
margin: SimpleTableConstants.moreActionHorizontalMargin,
|
||||
leftIconBuilder: _buildLeftIcon,
|
||||
iconPadding: 10.0,
|
||||
textBuilder: (onHover) => FlowyText.regular(
|
||||
text,
|
||||
fontSize: 14.0,
|
||||
figmaLineHeight: 18.0,
|
||||
),
|
||||
onTap: onTap,
|
||||
rightIconBuilder: (onHover) => rightIcon ?? const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLeftIcon(bool onHover) {
|
||||
if (leftIconBuilder != null) {
|
||||
return leftIconBuilder!(onHover);
|
||||
}
|
||||
return leftIconSvg != null
|
||||
? FlowySvg(leftIconSvg!)
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableColumnResizeHandle extends StatefulWidget {
|
||||
const SimpleTableColumnResizeHandle({
|
||||
super.key,
|
||||
required this.node,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
|
||||
@override
|
||||
State<SimpleTableColumnResizeHandle> createState() =>
|
||||
_SimpleTableColumnResizeHandleState();
|
||||
}
|
||||
|
||||
class _SimpleTableColumnResizeHandleState
|
||||
extends State<SimpleTableColumnResizeHandle> {
|
||||
bool isStartDragging = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
onEnter: (event) => context
|
||||
.read<SimpleTableContext>()
|
||||
.hoveringOnResizeHandle
|
||||
.value = widget.node,
|
||||
onExit: (event) {
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
// the onExit event will be triggered before dragging started.
|
||||
// delay the hiding of the resize handle to avoid flickering.
|
||||
if (!isStartDragging) {
|
||||
context.read<SimpleTableContext>().hoveringOnResizeHandle.value =
|
||||
null;
|
||||
}
|
||||
});
|
||||
},
|
||||
child: GestureDetector(
|
||||
onHorizontalDragStart: (details) {
|
||||
// disable the two-finger drag on trackpad
|
||||
if (details.kind == PointerDeviceKind.trackpad) {
|
||||
return;
|
||||
}
|
||||
isStartDragging = true;
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if (!isStartDragging) {
|
||||
return;
|
||||
}
|
||||
context.read<EditorState>().updateColumnWidthInMemory(
|
||||
tableCellNode: widget.node,
|
||||
deltaX: details.delta.dx,
|
||||
);
|
||||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (!isStartDragging) {
|
||||
return;
|
||||
}
|
||||
context.read<SimpleTableContext>().hoveringOnResizeHandle.value =
|
||||
null;
|
||||
isStartDragging = false;
|
||||
context.read<EditorState>().updateColumnWidth(
|
||||
tableCellNode: widget.node,
|
||||
width: widget.node.columnWidth,
|
||||
);
|
||||
},
|
||||
child: ValueListenableBuilder<Node?>(
|
||||
valueListenable: context.read<SimpleTableContext>().hoveringTableCell,
|
||||
builder: (context, hoveringCell, child) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable:
|
||||
context.read<SimpleTableContext>().hoveringOnResizeHandle,
|
||||
builder: (context, hoveringOnResizeHandle, child) {
|
||||
final isSameRowIndex = hoveringOnResizeHandle?.columnIndex ==
|
||||
widget.node.columnIndex;
|
||||
return Opacity(
|
||||
opacity: isSameRowIndex ? 1.0 : 0.0,
|
||||
child: Container(
|
||||
height: double.infinity,
|
||||
width: SimpleTableConstants.resizeHandleWidth,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableBackgroundColorMenu extends StatefulWidget {
|
||||
const SimpleTableBackgroundColorMenu({
|
||||
super.key,
|
||||
required this.type,
|
||||
required this.tableCellNode,
|
||||
this.mutex,
|
||||
});
|
||||
|
||||
final SimpleTableMoreActionType type;
|
||||
final Node tableCellNode;
|
||||
final PopoverMutex? mutex;
|
||||
|
||||
@override
|
||||
State<SimpleTableBackgroundColorMenu> createState() =>
|
||||
_SimpleTableBackgroundColorMenuState();
|
||||
}
|
||||
|
||||
class _SimpleTableBackgroundColorMenuState
|
||||
extends State<SimpleTableBackgroundColorMenu> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AFThemeExtension.of(context);
|
||||
final backgroundColor = switch (widget.type) {
|
||||
SimpleTableMoreActionType.row =>
|
||||
widget.tableCellNode.buildRowColor(context),
|
||||
SimpleTableMoreActionType.column =>
|
||||
widget.tableCellNode.buildColumnColor(context),
|
||||
};
|
||||
return AppFlowyPopover(
|
||||
mutex: widget.mutex,
|
||||
popupBuilder: (popoverContext) {
|
||||
return _buildColorOptionMenu(
|
||||
context,
|
||||
theme: theme,
|
||||
onClose: () => PopoverContainer.of(popoverContext).closeAll(),
|
||||
);
|
||||
},
|
||||
direction: PopoverDirection.rightWithCenterAligned,
|
||||
child: SimpleTableBasicButton(
|
||||
leftIconBuilder: (onHover) => ColorOptionIcon(
|
||||
color: backgroundColor ?? Colors.transparent,
|
||||
),
|
||||
text: LocaleKeys.document_plugins_simpleTable_moreActions_color.tr(),
|
||||
onTap: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorOptionMenu(
|
||||
BuildContext context, {
|
||||
required AFThemeExtension theme,
|
||||
required VoidCallback onClose,
|
||||
}) {
|
||||
final colors = [
|
||||
// reset to default background color
|
||||
FlowyColorOption(
|
||||
color: Colors.transparent,
|
||||
i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(),
|
||||
id: optionActionColorDefaultColor,
|
||||
),
|
||||
...FlowyTint.values.map(
|
||||
(e) => FlowyColorOption(
|
||||
color: e.color(context, theme: theme),
|
||||
i18n: e.tintName(AppFlowyEditorL10n.current),
|
||||
id: e.id,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return FlowyColorPicker(
|
||||
colors: colors,
|
||||
border: Border.all(
|
||||
color: theme.onBackground,
|
||||
),
|
||||
onTap: (option, index) {
|
||||
switch (widget.type) {
|
||||
case SimpleTableMoreActionType.column:
|
||||
context.read<EditorState>().updateColumnBackgroundColor(
|
||||
tableCellNode: widget.tableCellNode,
|
||||
color: option.id,
|
||||
);
|
||||
break;
|
||||
case SimpleTableMoreActionType.row:
|
||||
context.read<EditorState>().updateRowBackgroundColor(
|
||||
tableCellNode: widget.tableCellNode,
|
||||
color: option.id,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
onClose();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export 'simple_table_block_component.dart';
|
||||
export 'simple_table_cell_block_component.dart';
|
||||
export 'simple_table_constants.dart';
|
||||
export 'simple_table_more_action.dart';
|
||||
export 'simple_table_operations/simple_table_operations.dart';
|
||||
export 'simple_table_row_block_component.dart';
|
||||
export 'simple_table_shortcuts/simple_table_commands.dart';
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
typedef SimpleTableColumnWidthMap = Map<String, double>;
|
||||
typedef SimpleTableRowAlignMap = Map<String, String>;
|
||||
typedef SimpleTableColumnAlignMap = Map<String, String>;
|
||||
typedef SimpleTableColorMap = Map<String, String>;
|
||||
|
||||
class SimpleTableBlockKeys {
|
||||
const SimpleTableBlockKeys._();
|
||||
|
||||
static const String type = 'simple_table';
|
||||
|
||||
// enable header row
|
||||
// it's a bool value, default is false
|
||||
static const String enableHeaderRow = 'enable_header_row';
|
||||
|
||||
// enable column header
|
||||
// it's a bool value, default is false
|
||||
static const String enableHeaderColumn = 'enable_header_column';
|
||||
|
||||
// column colors
|
||||
// it's a `SimpleTableColorMap` value, {column_index: color, ...}
|
||||
// the number of colors should be the same as the number of columns
|
||||
static const String columnColors = 'column_colors';
|
||||
|
||||
// row colors
|
||||
// it's a `SimpleTableColorMap` value, {row_index: color, ...}
|
||||
// the number of colors should be the same as the number of rows
|
||||
static const String rowColors = 'row_colors';
|
||||
|
||||
// column alignments
|
||||
// it's a `SimpleTableColumnAlignMap` value, {column_index: align, ...}
|
||||
// the value should be one of the following: 'left', 'center', 'right'
|
||||
static const String columnAligns = 'column_aligns';
|
||||
|
||||
// row alignments
|
||||
// it's a `SimpleTableRowAlignMap` value, {row_index: align, ...}
|
||||
// the value should be one of the following: 'top', 'center', 'bottom'
|
||||
static const String rowAligns = 'row_aligns';
|
||||
|
||||
// column widths
|
||||
// it's a `SimpleTableColumnWidthMap` value, {column_index: width, ...}
|
||||
static const String columnWidths = 'column_widths';
|
||||
}
|
||||
|
||||
Node simpleTableBlockNode({
|
||||
bool enableHeaderRow = false,
|
||||
bool enableHeaderColumn = false,
|
||||
SimpleTableColorMap? columnColors,
|
||||
SimpleTableColorMap? rowColors,
|
||||
SimpleTableColumnAlignMap? columnAligns,
|
||||
SimpleTableRowAlignMap? rowAligns,
|
||||
SimpleTableColumnWidthMap? columnWidths,
|
||||
required List<Node> children,
|
||||
}) {
|
||||
assert(children.every((e) => e.type == SimpleTableRowBlockKeys.type));
|
||||
|
||||
return Node(
|
||||
type: SimpleTableBlockKeys.type,
|
||||
attributes: {
|
||||
SimpleTableBlockKeys.enableHeaderRow: enableHeaderRow,
|
||||
SimpleTableBlockKeys.enableHeaderColumn: enableHeaderColumn,
|
||||
SimpleTableBlockKeys.columnColors: columnColors,
|
||||
SimpleTableBlockKeys.rowColors: rowColors,
|
||||
SimpleTableBlockKeys.columnAligns: columnAligns,
|
||||
SimpleTableBlockKeys.rowAligns: rowAligns,
|
||||
SimpleTableBlockKeys.columnWidths: columnWidths,
|
||||
},
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a simple table block node with the given column and row count.
|
||||
///
|
||||
/// The table will have cells filled with paragraph nodes.
|
||||
///
|
||||
/// For example, if you want to create a table with 2 columns and 3 rows, you can use:
|
||||
/// ```dart
|
||||
/// final table = createSimpleTableBlockNode(columnCount: 2, rowCount: 3);
|
||||
/// ```
|
||||
///
|
||||
/// | cell 1 | cell 2 |
|
||||
/// | cell 3 | cell 4 |
|
||||
/// | cell 5 | cell 6 |
|
||||
Node createSimpleTableBlockNode({
|
||||
required int columnCount,
|
||||
required int rowCount,
|
||||
String? defaultContent,
|
||||
String Function(int rowIndex, int columnIndex)? contentBuilder,
|
||||
}) {
|
||||
final rows = List.generate(rowCount, (rowIndex) {
|
||||
final cells = List.generate(
|
||||
columnCount,
|
||||
(columnIndex) => simpleTableCellBlockNode(
|
||||
children: [
|
||||
paragraphNode(
|
||||
text: defaultContent ?? contentBuilder?.call(rowIndex, columnIndex),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return simpleTableRowBlockNode(children: cells);
|
||||
});
|
||||
|
||||
return simpleTableBlockNode(children: rows);
|
||||
}
|
||||
|
||||
class SimpleTableBlockComponentBuilder extends BlockComponentBuilder {
|
||||
SimpleTableBlockComponentBuilder({
|
||||
super.configuration,
|
||||
});
|
||||
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
return SimpleTableBlockWidget(
|
||||
key: node.key,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
showActions: showActions(node),
|
||||
actionBuilder: (context, state) => actionBuilder(
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
BlockComponentValidate get validate => (node) => node.children.isNotEmpty;
|
||||
}
|
||||
|
||||
class SimpleTableBlockWidget extends BlockComponentStatefulWidget {
|
||||
const SimpleTableBlockWidget({
|
||||
super.key,
|
||||
required super.node,
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
@override
|
||||
State<SimpleTableBlockWidget> createState() => _SimpleTableBlockWidgetState();
|
||||
}
|
||||
|
||||
class _SimpleTableBlockWidgetState extends State<SimpleTableBlockWidget>
|
||||
with
|
||||
SelectableMixin,
|
||||
BlockComponentConfigurable,
|
||||
BlockComponentTextDirectionMixin,
|
||||
BlockComponentBackgroundColorMixin {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
|
||||
@override
|
||||
Node get node => widget.node;
|
||||
|
||||
@override
|
||||
late EditorState editorState = context.read<EditorState>();
|
||||
|
||||
final tableKey = GlobalKey();
|
||||
|
||||
final simpleTableContext = SimpleTableContext();
|
||||
final scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
editorState.selectionNotifier.addListener(_onSelectionChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
simpleTableContext.dispose();
|
||||
editorState.selectionNotifier.removeListener(_onSelectionChanged);
|
||||
scrollController.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = Transform.translate(
|
||||
offset: const Offset(
|
||||
-SimpleTableConstants.tableLeftPadding,
|
||||
0,
|
||||
),
|
||||
child: _buildTable(),
|
||||
);
|
||||
|
||||
child = Provider.value(
|
||||
value: simpleTableContext,
|
||||
child: MouseRegion(
|
||||
onEnter: (event) =>
|
||||
simpleTableContext.isHoveringOnTableBlock.value = true,
|
||||
onExit: (event) {
|
||||
simpleTableContext.isHoveringOnTableBlock.value = false;
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.showActions && widget.actionBuilder != null) {
|
||||
child = BlockComponentActionWrapper(
|
||||
node: node,
|
||||
actionBuilder: widget.actionBuilder!,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
Widget _buildTable() {
|
||||
// IntrinsicWidth and IntrinsicHeight are used to make the table size fit the content.
|
||||
return MouseRegion(
|
||||
onEnter: (event) => simpleTableContext.isHoveringOnTableArea.value = true,
|
||||
onExit: (event) {
|
||||
simpleTableContext.isHoveringOnTableArea.value = false;
|
||||
},
|
||||
child: Provider.value(
|
||||
value: simpleTableContext,
|
||||
child: Stack(
|
||||
children: [
|
||||
MouseRegion(
|
||||
hitTestBehavior: HitTestBehavior.opaque,
|
||||
onEnter: (event) =>
|
||||
simpleTableContext.isHoveringOnColumnsAndRows.value = true,
|
||||
onExit: (event) {
|
||||
simpleTableContext.isHoveringOnColumnsAndRows.value = false;
|
||||
simpleTableContext.hoveringTableCell.value = null;
|
||||
},
|
||||
child: Scrollbar(
|
||||
controller: scrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: SimpleTableConstants.tablePadding,
|
||||
child: IntrinsicWidth(
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildRows(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SimpleTableAddColumnHoverButton(
|
||||
editorState: editorState,
|
||||
node: node,
|
||||
),
|
||||
SimpleTableAddRowHoverButton(
|
||||
editorState: editorState,
|
||||
tableNode: node,
|
||||
),
|
||||
SimpleTableAddColumnAndRowHoverButton(
|
||||
editorState: editorState,
|
||||
node: node,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildRows() {
|
||||
final List<Widget> rows = [];
|
||||
|
||||
if (SimpleTableConstants.borderType == SimpleTableBorderRenderType.table) {
|
||||
rows.add(const SimpleTableColumnDivider());
|
||||
}
|
||||
|
||||
for (final child in node.children) {
|
||||
rows.add(editorState.renderer.build(context, child));
|
||||
|
||||
if (SimpleTableConstants.borderType ==
|
||||
SimpleTableBorderRenderType.table) {
|
||||
rows.add(const SimpleTableColumnDivider());
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
void _onSelectionChanged() {
|
||||
final selection = editorState.selectionNotifier.value;
|
||||
final selectionType = editorState.selectionType;
|
||||
if (selectionType == SelectionType.block &&
|
||||
widget.node.path.inSelection(selection)) {
|
||||
simpleTableContext.isSelectingTable.value = true;
|
||||
} else {
|
||||
simpleTableContext.isSelectingTable.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
|
||||
|
||||
@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
|
||||
List<Rect> getRectsInSelection(
|
||||
Selection selection, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
final parentBox = context.findRenderObject();
|
||||
final tableBox = tableKey.currentContext?.findRenderObject();
|
||||
if (parentBox is RenderBox && tableBox is RenderBox) {
|
||||
return [
|
||||
(shiftWithBaseOffset
|
||||
? tableBox.localToGlobal(Offset.zero, ancestor: parentBox)
|
||||
: Offset.zero) &
|
||||
tableBox.size,
|
||||
];
|
||||
}
|
||||
return [Offset.zero & _renderBox.size];
|
||||
}
|
||||
|
||||
@override
|
||||
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
|
||||
path: widget.node.path,
|
||||
startOffset: 0,
|
||||
endOffset: 1,
|
||||
);
|
||||
|
||||
@override
|
||||
bool get shouldCursorBlink => false;
|
||||
|
||||
@override
|
||||
CursorStyle get cursorStyle => CursorStyle.cover;
|
||||
|
||||
@override
|
||||
Offset localToGlobal(
|
||||
Offset offset, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) =>
|
||||
_renderBox.localToGlobal(offset);
|
||||
|
||||
@override
|
||||
Rect getBlockRect({
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
return getRectsInSelection(Selection.invalid()).first;
|
||||
}
|
||||
|
||||
@override
|
||||
Rect? getCursorRectInPosition(
|
||||
Position position, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
final size = _renderBox.size;
|
||||
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
|
||||
}
|
||||
}
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SimpleTableCellBlockKeys {
|
||||
const SimpleTableCellBlockKeys._();
|
||||
|
||||
static const String type = 'simple_table_cell';
|
||||
}
|
||||
|
||||
Node simpleTableCellBlockNode({
|
||||
List<Node>? children,
|
||||
}) {
|
||||
// Default children is a paragraph node.
|
||||
children ??= [
|
||||
paragraphNode(),
|
||||
];
|
||||
|
||||
return Node(
|
||||
type: SimpleTableCellBlockKeys.type,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
class SimpleTableCellBlockComponentBuilder extends BlockComponentBuilder {
|
||||
SimpleTableCellBlockComponentBuilder({
|
||||
super.configuration,
|
||||
});
|
||||
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
return SimpleTableCellBlockWidget(
|
||||
key: node.key,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
showActions: showActions(node),
|
||||
actionBuilder: (context, state) => actionBuilder(
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
BlockComponentValidate get validate => (node) => true;
|
||||
}
|
||||
|
||||
class SimpleTableCellBlockWidget extends BlockComponentStatefulWidget {
|
||||
const SimpleTableCellBlockWidget({
|
||||
super.key,
|
||||
required super.node,
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
@override
|
||||
State<SimpleTableCellBlockWidget> createState() =>
|
||||
SimpleTableCellBlockWidgetState();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class SimpleTableCellBlockWidgetState extends State<SimpleTableCellBlockWidget>
|
||||
with
|
||||
BlockComponentConfigurable,
|
||||
BlockComponentTextDirectionMixin,
|
||||
BlockComponentBackgroundColorMixin {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
|
||||
@override
|
||||
Node get node => widget.node;
|
||||
|
||||
@override
|
||||
late EditorState editorState = context.read<EditorState>();
|
||||
|
||||
late SimpleTableContext? simpleTableContext =
|
||||
context.read<SimpleTableContext?>();
|
||||
|
||||
ValueNotifier<bool> isEditingCellNotifier = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
simpleTableContext?.isSelectingTable.addListener(_onSelectingTableChanged);
|
||||
node.parentTableNode?.addListener(_onSelectingTableChanged);
|
||||
editorState.selectionNotifier.addListener(_onSelectionChanged);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
_onSelectionChanged();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
simpleTableContext?.isSelectingTable.removeListener(
|
||||
_onSelectingTableChanged,
|
||||
);
|
||||
node.parentTableNode?.removeListener(_onSelectingTableChanged);
|
||||
editorState.selectionNotifier.removeListener(_onSelectionChanged);
|
||||
isEditingCellNotifier.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (simpleTableContext == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
hitTestBehavior: HitTestBehavior.opaque,
|
||||
onEnter: (event) => simpleTableContext!.hoveringTableCell.value = node,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
_buildCell(),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: -SimpleTableConstants.tableLeftPadding,
|
||||
child: _buildRowMoreActionButton(),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _buildColumnMoreActionButton(),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: node.rowIndex == 0 ? SimpleTableConstants.tableTopPadding : 0,
|
||||
bottom: 0,
|
||||
child: SimpleTableColumnResizeHandle(
|
||||
node: node,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCell() {
|
||||
if (simpleTableContext == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
// add padding to the top of the cell if it is the first row, otherwise the
|
||||
// column action button is not clickable.
|
||||
// issue: https://github.com/flutter/flutter/issues/75747
|
||||
padding: EdgeInsets.only(
|
||||
top: node.rowIndex == 0 ? SimpleTableConstants.tableTopPadding : 0,
|
||||
),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: isEditingCellNotifier,
|
||||
builder: (context, isEditingCell, child) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: simpleTableContext!.selectingColumn,
|
||||
builder: (context, selectingColumn, child) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: simpleTableContext!.selectingRow,
|
||||
builder: (context, selectingRow, _) {
|
||||
return DecoratedBox(
|
||||
decoration: _buildDecoration(),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
children: node.children.map(_buildCellContent).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCellContent(Node childNode) {
|
||||
final alignment = _buildAlignment();
|
||||
return Container(
|
||||
padding: SimpleTableConstants.cellEdgePadding,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: SimpleTableConstants.minimumColumnWidth,
|
||||
),
|
||||
width: node.columnWidth,
|
||||
alignment: alignment,
|
||||
child: IntrinsicWidth(
|
||||
child: IntrinsicHeight(
|
||||
child: editorState.renderer.build(context, childNode),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRowMoreActionButton() {
|
||||
final columnIndex = node.columnIndex;
|
||||
final rowIndex = node.rowIndex;
|
||||
|
||||
if (columnIndex != 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SimpleTableMoreActionMenu(
|
||||
index: rowIndex,
|
||||
type: SimpleTableMoreActionType.row,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColumnMoreActionButton() {
|
||||
final columnIndex = node.columnIndex;
|
||||
final rowIndex = node.rowIndex;
|
||||
|
||||
if (rowIndex != 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SimpleTableMoreActionMenu(
|
||||
index: columnIndex,
|
||||
type: SimpleTableMoreActionType.column,
|
||||
);
|
||||
}
|
||||
|
||||
Alignment _buildAlignment() {
|
||||
Alignment alignment = Alignment.topLeft;
|
||||
if (node.columnAlign != TableAlign.left) {
|
||||
alignment = node.columnAlign.alignment;
|
||||
} else if (node.rowAlign != TableAlign.left) {
|
||||
alignment = node.rowAlign.alignment;
|
||||
}
|
||||
return alignment;
|
||||
}
|
||||
|
||||
Decoration _buildDecoration() {
|
||||
final backgroundColor = _buildBackgroundColor();
|
||||
final border = _buildBorder();
|
||||
|
||||
return BoxDecoration(
|
||||
border: border,
|
||||
color: backgroundColor,
|
||||
);
|
||||
}
|
||||
|
||||
Color? _buildBackgroundColor() {
|
||||
// Priority: column color > row color > header color > default color
|
||||
|
||||
final columnColor = node.buildColumnColor(context);
|
||||
if (columnColor != null && columnColor != Colors.transparent) {
|
||||
return columnColor;
|
||||
}
|
||||
|
||||
final rowColor = node.buildRowColor(context);
|
||||
if (rowColor != null && rowColor != Colors.transparent) {
|
||||
return rowColor;
|
||||
}
|
||||
|
||||
// Check if the cell is in the header.
|
||||
// If the cell is in the header, set the background color to the default header color.
|
||||
// Otherwise, set the background color to null.
|
||||
if (_isInHeader()) {
|
||||
return context.simpleTableDefaultHeaderColor;
|
||||
}
|
||||
|
||||
return Theme.of(context).colorScheme.surface;
|
||||
}
|
||||
|
||||
Border? _buildBorder() {
|
||||
if (SimpleTableConstants.borderType != SimpleTableBorderRenderType.cell) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final tableContext = context.watch<SimpleTableContext>();
|
||||
final isCellInSelectedColumn =
|
||||
node.columnIndex == tableContext.selectingColumn.value;
|
||||
final isCellInSelectedRow =
|
||||
node.rowIndex == tableContext.selectingRow.value;
|
||||
if (tableContext.isSelectingTable.value) {
|
||||
return _buildSelectingTableBorder();
|
||||
} else if (isCellInSelectedColumn) {
|
||||
return _buildColumnBorder();
|
||||
} else if (isCellInSelectedRow) {
|
||||
return _buildRowBorder();
|
||||
} else if (isEditingCellNotifier.value) {
|
||||
return _buildEditingBorder();
|
||||
} else {
|
||||
return _buildCellBorder();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isInHeader() {
|
||||
final isHeaderColumnEnabled = node.isHeaderColumnEnabled;
|
||||
final isHeaderRowEnabled = node.isHeaderRowEnabled;
|
||||
final cellPosition = node.cellPosition;
|
||||
final isFirstColumn = cellPosition.$1 == 0;
|
||||
final isFirstRow = cellPosition.$2 == 0;
|
||||
|
||||
return isHeaderColumnEnabled && isFirstRow ||
|
||||
isHeaderRowEnabled && isFirstColumn;
|
||||
}
|
||||
|
||||
/// the column border means the `VERTICAL` border of the cell
|
||||
///
|
||||
/// ____
|
||||
/// | 1 | 2 |
|
||||
/// | 3 | 4 |
|
||||
/// |___|
|
||||
///
|
||||
/// the border wrapping the cell 2 and cell 4 is the column border
|
||||
Border _buildColumnBorder() {
|
||||
return Border(
|
||||
left: _buildHighlightBorderSide(),
|
||||
right: _buildHighlightBorderSide(),
|
||||
top: node.rowIndex == 0
|
||||
? _buildHighlightBorderSide()
|
||||
: _buildDefaultBorderSide(),
|
||||
bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength
|
||||
? _buildHighlightBorderSide()
|
||||
: _buildDefaultBorderSide(),
|
||||
);
|
||||
}
|
||||
|
||||
/// the row border means the `HORIZONTAL` border of the cell
|
||||
///
|
||||
/// ________
|
||||
/// | 1 | 2 |
|
||||
/// |_______|
|
||||
/// | 3 | 4 |
|
||||
///
|
||||
/// the border wrapping the cell 1 and cell 2 is the row border
|
||||
Border _buildRowBorder() {
|
||||
return Border(
|
||||
top: _buildHighlightBorderSide(),
|
||||
bottom: _buildHighlightBorderSide(),
|
||||
left: node.columnIndex == 0
|
||||
? _buildHighlightBorderSide()
|
||||
: _buildDefaultBorderSide(),
|
||||
right: node.columnIndex + 1 == node.parentTableNode?.columnLength
|
||||
? _buildHighlightBorderSide()
|
||||
: _buildDefaultBorderSide(),
|
||||
);
|
||||
}
|
||||
|
||||
Border _buildCellBorder() {
|
||||
return Border(
|
||||
top: node.rowIndex == 0
|
||||
? _buildDefaultBorderSide()
|
||||
: _buildLightBorderSide(),
|
||||
bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength
|
||||
? _buildDefaultBorderSide()
|
||||
: _buildLightBorderSide(),
|
||||
left: node.columnIndex == 0
|
||||
? _buildDefaultBorderSide()
|
||||
: _buildLightBorderSide(),
|
||||
right: node.columnIndex + 1 == node.parentTableNode?.columnLength
|
||||
? _buildDefaultBorderSide()
|
||||
: _buildLightBorderSide(),
|
||||
);
|
||||
}
|
||||
|
||||
Border _buildEditingBorder() {
|
||||
return Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
);
|
||||
}
|
||||
|
||||
Border _buildSelectingTableBorder() {
|
||||
final rowIndex = node.rowIndex;
|
||||
final columnIndex = node.columnIndex;
|
||||
|
||||
return Border(
|
||||
top:
|
||||
rowIndex == 0 ? _buildHighlightBorderSide() : _buildLightBorderSide(),
|
||||
bottom: rowIndex + 1 == node.parentTableNode?.rowLength
|
||||
? _buildHighlightBorderSide()
|
||||
: _buildLightBorderSide(),
|
||||
left: columnIndex == 0
|
||||
? _buildHighlightBorderSide()
|
||||
: _buildLightBorderSide(),
|
||||
right: columnIndex + 1 == node.parentTableNode?.columnLength
|
||||
? _buildHighlightBorderSide()
|
||||
: _buildLightBorderSide(),
|
||||
);
|
||||
}
|
||||
|
||||
BorderSide _buildHighlightBorderSide() {
|
||||
return BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
);
|
||||
}
|
||||
|
||||
BorderSide _buildLightBorderSide() {
|
||||
return BorderSide(
|
||||
color: context.simpleTableBorderColor,
|
||||
width: 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
BorderSide _buildDefaultBorderSide() {
|
||||
return BorderSide(
|
||||
color: context.simpleTableBorderColor,
|
||||
);
|
||||
}
|
||||
|
||||
void _onSelectingTableChanged() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelectionChanged() {
|
||||
final selection = editorState.selection;
|
||||
|
||||
// check if the selection is in the cell
|
||||
if (selection != null &&
|
||||
node.path.isAncestorOf(selection.start.path) &&
|
||||
node.path.isAncestorOf(selection.end.path)) {
|
||||
isEditingCellNotifier.value = true;
|
||||
} else {
|
||||
isEditingCellNotifier.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const enableTableDebugLog = false;
|
||||
|
||||
class SimpleTableContext {
|
||||
SimpleTableContext() {
|
||||
if (enableTableDebugLog) {
|
||||
isHoveringOnColumnsAndRows.addListener(
|
||||
_onHoveringOnColumnsAndRowsChanged,
|
||||
);
|
||||
isHoveringOnTableArea.addListener(
|
||||
_onHoveringOnTableAreaChanged,
|
||||
);
|
||||
hoveringTableCell.addListener(_onHoveringTableNodeChanged);
|
||||
selectingColumn.addListener(_onSelectingColumnChanged);
|
||||
selectingRow.addListener(_onSelectingRowChanged);
|
||||
isSelectingTable.addListener(_onSelectingTableChanged);
|
||||
isHoveringOnTableBlock.addListener(_onHoveringOnTableBlockChanged);
|
||||
}
|
||||
}
|
||||
|
||||
// the area only contains the columns and rows,
|
||||
// the add row button, add column button, and add column and row button are not part of the table area
|
||||
final ValueNotifier<bool> isHoveringOnColumnsAndRows = ValueNotifier(false);
|
||||
|
||||
// the table area contains the columns and rows,
|
||||
// the add row button, add column button, and add column and row button are not part of the table area,
|
||||
// not including the selection area and padding
|
||||
final ValueNotifier<bool> isHoveringOnTableArea = ValueNotifier(false);
|
||||
|
||||
// the table block area contains the table area and the add row button, add column button, and add column and row button
|
||||
// also, the table block area contains the selection area and padding
|
||||
final ValueNotifier<bool> isHoveringOnTableBlock = ValueNotifier(false);
|
||||
|
||||
// the hovering table cell is the cell that the mouse is hovering on
|
||||
final ValueNotifier<Node?> hoveringTableCell = ValueNotifier(null);
|
||||
|
||||
// the hovering on resize handle is the resize handle that the mouse is hovering on
|
||||
final ValueNotifier<Node?> hoveringOnResizeHandle = ValueNotifier(null);
|
||||
|
||||
// the selecting column is the column that the user is selecting
|
||||
final ValueNotifier<int?> selectingColumn = ValueNotifier(null);
|
||||
|
||||
// the selecting row is the row that the user is selecting
|
||||
final ValueNotifier<int?> selectingRow = ValueNotifier(null);
|
||||
|
||||
// the is selecting table is the table that the user is selecting
|
||||
final ValueNotifier<bool> isSelectingTable = ValueNotifier(false);
|
||||
|
||||
void _onHoveringOnColumnsAndRowsChanged() {
|
||||
if (!enableTableDebugLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.debug('isHoveringOnTable: ${isHoveringOnColumnsAndRows.value}');
|
||||
}
|
||||
|
||||
void _onHoveringTableNodeChanged() {
|
||||
if (!enableTableDebugLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
final node = hoveringTableCell.value;
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.debug('hoveringTableNode: $node, ${node.cellPosition}');
|
||||
}
|
||||
|
||||
void _onSelectingColumnChanged() {
|
||||
if (!enableTableDebugLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.debug('selectingColumn: ${selectingColumn.value}');
|
||||
}
|
||||
|
||||
void _onSelectingRowChanged() {
|
||||
if (!enableTableDebugLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.debug('selectingRow: ${selectingRow.value}');
|
||||
}
|
||||
|
||||
void _onSelectingTableChanged() {
|
||||
if (!enableTableDebugLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.debug('isSelectingTable: ${isSelectingTable.value}');
|
||||
}
|
||||
|
||||
void _onHoveringOnTableBlockChanged() {
|
||||
if (!enableTableDebugLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.debug('isHoveringOnTableBlock: ${isHoveringOnTableBlock.value}');
|
||||
}
|
||||
|
||||
void _onHoveringOnTableAreaChanged() {
|
||||
if (!enableTableDebugLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.debug('isHoveringOnTableArea: ${isHoveringOnTableArea.value}');
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
isHoveringOnColumnsAndRows.dispose();
|
||||
isHoveringOnTableBlock.dispose();
|
||||
isHoveringOnTableArea.dispose();
|
||||
hoveringTableCell.dispose();
|
||||
hoveringOnResizeHandle.dispose();
|
||||
selectingColumn.dispose();
|
||||
selectingRow.dispose();
|
||||
isSelectingTable.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableConstants {
|
||||
// Table
|
||||
static const defaultColumnWidth = 120.0;
|
||||
static const minimumColumnWidth = 36.0;
|
||||
|
||||
static const tableTopPadding = 8.0;
|
||||
static const tableLeftPadding = 8.0;
|
||||
|
||||
static const tableBottomPadding =
|
||||
addRowButtonHeight + 3 * addRowButtonPadding;
|
||||
static const tableRightPadding =
|
||||
addColumnButtonWidth + 2 * SimpleTableConstants.addColumnButtonPadding;
|
||||
|
||||
static const tablePadding = EdgeInsets.only(
|
||||
// don't add padding to the top of the table, the first row will have padding
|
||||
// to make the column action button clickable.
|
||||
bottom: tableBottomPadding,
|
||||
left: tableLeftPadding,
|
||||
right: tableRightPadding,
|
||||
);
|
||||
|
||||
// Add row button
|
||||
static const addRowButtonHeight = 16.0;
|
||||
static const addRowButtonPadding = 4.0;
|
||||
static const addRowButtonRadius = 4.0;
|
||||
static const addRowButtonRightPadding =
|
||||
addColumnButtonWidth + addColumnButtonPadding * 2;
|
||||
|
||||
// Add column button
|
||||
static const addColumnButtonWidth = 16.0;
|
||||
static const addColumnButtonPadding = 2.0;
|
||||
static const addColumnButtonRadius = 4.0;
|
||||
static const addColumnButtonBottomPadding =
|
||||
addRowButtonHeight + 3 * addRowButtonPadding;
|
||||
|
||||
// Add column and row button
|
||||
static const addColumnAndRowButtonWidth = addColumnButtonWidth;
|
||||
static const addColumnAndRowButtonHeight = addRowButtonHeight;
|
||||
static const addColumnAndRowButtonCornerRadius = addColumnButtonWidth / 2.0;
|
||||
static const addColumnAndRowButtonBottomPadding = 2.5 * addRowButtonPadding;
|
||||
|
||||
// Table cell
|
||||
static const cellEdgePadding = EdgeInsets.symmetric(
|
||||
horizontal: 9.0,
|
||||
vertical: 2.0,
|
||||
);
|
||||
static const cellBorderWidth = 1.0;
|
||||
static const resizeHandleWidth = 3.0;
|
||||
|
||||
static const borderType = SimpleTableBorderRenderType.cell;
|
||||
|
||||
// Table more action
|
||||
static const moreActionHeight = 34.0;
|
||||
static const moreActionPadding = EdgeInsets.symmetric(vertical: 2.0);
|
||||
static const moreActionHorizontalMargin =
|
||||
EdgeInsets.symmetric(horizontal: 6.0);
|
||||
}
|
||||
|
||||
enum SimpleTableBorderRenderType {
|
||||
cell,
|
||||
table,
|
||||
}
|
||||
|
||||
extension SimpleTableColors on BuildContext {
|
||||
Color get simpleTableBorderColor => Theme.of(this).isLightMode
|
||||
? const Color(0xFFE4E5E5)
|
||||
: const Color(0xFF3A3F49);
|
||||
|
||||
Color get simpleTableDividerColor => Theme.of(this).isLightMode
|
||||
? const Color(0x141F2329)
|
||||
: const Color(0xFF23262B).withOpacity(0.5);
|
||||
|
||||
Color get simpleTableMoreActionBackgroundColor => Theme.of(this).isLightMode
|
||||
? const Color(0xFFF2F3F5)
|
||||
: const Color(0xFF2D3036);
|
||||
|
||||
Color get simpleTableMoreActionBorderColor => Theme.of(this).isLightMode
|
||||
? const Color(0xFFCFD3D9)
|
||||
: const Color(0xFF44484E);
|
||||
|
||||
Color get simpleTableMoreActionHoverColor => Theme.of(this).isLightMode
|
||||
? const Color(0xFF00C8FF)
|
||||
: const Color(0xFF00C8FF);
|
||||
|
||||
Color get simpleTableDefaultHeaderColor => Theme.of(this).isLightMode
|
||||
? const Color(0xFFF2F2F2)
|
||||
: const Color(0x08FFFFFF);
|
||||
}
|
||||
+690
@@ -0,0 +1,690 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.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';
|
||||
|
||||
enum SimpleTableMoreActionType {
|
||||
column,
|
||||
row;
|
||||
|
||||
List<SimpleTableMoreAction> get actions {
|
||||
switch (this) {
|
||||
case SimpleTableMoreActionType.row:
|
||||
return [
|
||||
SimpleTableMoreAction.insertAbove,
|
||||
SimpleTableMoreAction.insertBelow,
|
||||
SimpleTableMoreAction.duplicate,
|
||||
SimpleTableMoreAction.clearContents,
|
||||
SimpleTableMoreAction.delete,
|
||||
SimpleTableMoreAction.divider,
|
||||
SimpleTableMoreAction.align,
|
||||
SimpleTableMoreAction.backgroundColor,
|
||||
];
|
||||
case SimpleTableMoreActionType.column:
|
||||
return [
|
||||
SimpleTableMoreAction.insertLeft,
|
||||
SimpleTableMoreAction.insertRight,
|
||||
SimpleTableMoreAction.duplicate,
|
||||
SimpleTableMoreAction.clearContents,
|
||||
SimpleTableMoreAction.delete,
|
||||
SimpleTableMoreAction.divider,
|
||||
SimpleTableMoreAction.align,
|
||||
SimpleTableMoreAction.backgroundColor,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
FlowySvgData get reorderIconSvg {
|
||||
switch (this) {
|
||||
case SimpleTableMoreActionType.column:
|
||||
return FlowySvgs.table_reorder_column_s;
|
||||
case SimpleTableMoreActionType.row:
|
||||
return FlowySvgs.table_reorder_row_s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SimpleTableMoreAction {
|
||||
insertLeft,
|
||||
insertRight,
|
||||
insertAbove,
|
||||
insertBelow,
|
||||
duplicate,
|
||||
clearContents,
|
||||
delete,
|
||||
align,
|
||||
backgroundColor,
|
||||
enableHeaderColumn,
|
||||
enableHeaderRow,
|
||||
divider;
|
||||
|
||||
String get name {
|
||||
return switch (this) {
|
||||
SimpleTableMoreAction.align =>
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
|
||||
SimpleTableMoreAction.backgroundColor =>
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_color.tr(),
|
||||
SimpleTableMoreAction.enableHeaderColumn =>
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_headerColumn.tr(),
|
||||
SimpleTableMoreAction.enableHeaderRow =>
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_headerRow.tr(),
|
||||
SimpleTableMoreAction.insertLeft =>
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_insertLeft.tr(),
|
||||
SimpleTableMoreAction.insertRight =>
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_insertRight.tr(),
|
||||
SimpleTableMoreAction.insertBelow =>
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_insertBelow.tr(),
|
||||
SimpleTableMoreAction.insertAbove =>
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_insertAbove.tr(),
|
||||
SimpleTableMoreAction.clearContents =>
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_clearContents.tr(),
|
||||
SimpleTableMoreAction.delete =>
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_delete.tr(),
|
||||
SimpleTableMoreAction.duplicate =>
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_duplicate.tr(),
|
||||
SimpleTableMoreAction.divider => throw UnimplementedError(),
|
||||
};
|
||||
}
|
||||
|
||||
FlowySvgData get leftIconSvg {
|
||||
return switch (this) {
|
||||
SimpleTableMoreAction.insertLeft => FlowySvgs.table_insert_left_s,
|
||||
SimpleTableMoreAction.insertRight => FlowySvgs.table_insert_right_s,
|
||||
SimpleTableMoreAction.insertAbove => FlowySvgs.table_insert_above_s,
|
||||
SimpleTableMoreAction.insertBelow => FlowySvgs.table_insert_below_s,
|
||||
SimpleTableMoreAction.duplicate => FlowySvgs.duplicate_s,
|
||||
SimpleTableMoreAction.clearContents => FlowySvgs.table_clear_content_s,
|
||||
SimpleTableMoreAction.delete => FlowySvgs.trash_s,
|
||||
SimpleTableMoreAction.enableHeaderColumn =>
|
||||
FlowySvgs.table_header_column_s,
|
||||
SimpleTableMoreAction.enableHeaderRow => FlowySvgs.table_header_row_s,
|
||||
SimpleTableMoreAction.divider =>
|
||||
throw UnsupportedError('divider icon is not supported'),
|
||||
SimpleTableMoreAction.align =>
|
||||
throw UnsupportedError('align icon is not supported'),
|
||||
SimpleTableMoreAction.backgroundColor =>
|
||||
throw UnsupportedError('background color icon is not supported'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableMoreActionMenu extends StatefulWidget {
|
||||
const SimpleTableMoreActionMenu({
|
||||
super.key,
|
||||
required this.index,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final SimpleTableMoreActionType type;
|
||||
|
||||
@override
|
||||
State<SimpleTableMoreActionMenu> createState() =>
|
||||
_SimpleTableMoreActionMenuState();
|
||||
}
|
||||
|
||||
class _SimpleTableMoreActionMenuState extends State<SimpleTableMoreActionMenu> {
|
||||
ValueNotifier<bool> isShowingMenu = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
isShowingMenu.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: widget.type == SimpleTableMoreActionType.row
|
||||
? Alignment.centerLeft
|
||||
: Alignment.topCenter,
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: isShowingMenu,
|
||||
builder: (context, isShowingMenu, child) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable:
|
||||
context.read<SimpleTableContext>().hoveringTableCell,
|
||||
builder: (context, hoveringTableNode, child) {
|
||||
final hoveringIndex =
|
||||
widget.type == SimpleTableMoreActionType.column
|
||||
? hoveringTableNode?.columnIndex
|
||||
: hoveringTableNode?.rowIndex;
|
||||
|
||||
if (hoveringIndex != widget.index && !isShowingMenu) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return child!;
|
||||
},
|
||||
child: SimpleTableMoreActionPopup(
|
||||
key: ValueKey(widget.type.name + widget.index.toString()),
|
||||
index: widget.index,
|
||||
isShowingMenu: this.isShowingMenu,
|
||||
type: widget.type,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableMoreActionPopup extends StatefulWidget {
|
||||
const SimpleTableMoreActionPopup({
|
||||
super.key,
|
||||
required this.index,
|
||||
required this.isShowingMenu,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final ValueNotifier<bool> isShowingMenu;
|
||||
final SimpleTableMoreActionType type;
|
||||
|
||||
@override
|
||||
State<SimpleTableMoreActionPopup> createState() =>
|
||||
_SimpleTableMoreActionPopupState();
|
||||
}
|
||||
|
||||
class _SimpleTableMoreActionPopupState
|
||||
extends State<SimpleTableMoreActionPopup> {
|
||||
late final editorState = context.read<EditorState>();
|
||||
SelectionGestureInterceptor? gestureInterceptor;
|
||||
|
||||
RenderBox? get renderBox => context.findRenderObject() as RenderBox?;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final tableCellNode =
|
||||
context.read<SimpleTableContext>().hoveringTableCell.value;
|
||||
gestureInterceptor = SelectionGestureInterceptor(
|
||||
key: 'simple_table_more_action_popup_interceptor_${tableCellNode?.id}',
|
||||
canTap: (details) => !_isTapInBounds(details.globalPosition),
|
||||
);
|
||||
editorState.service.selectionService.registerGestureInterceptor(
|
||||
gestureInterceptor!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (gestureInterceptor != null) {
|
||||
editorState.service.selectionService.unregisterGestureInterceptor(
|
||||
gestureInterceptor!.key,
|
||||
);
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tableCellNode =
|
||||
context.read<SimpleTableContext>().hoveringTableCell.value;
|
||||
return AppFlowyPopover(
|
||||
onOpen: () {
|
||||
widget.isShowingMenu.value = true;
|
||||
switch (widget.type) {
|
||||
case SimpleTableMoreActionType.column:
|
||||
context.read<SimpleTableContext>().selectingColumn.value =
|
||||
tableCellNode?.columnIndex;
|
||||
case SimpleTableMoreActionType.row:
|
||||
context.read<SimpleTableContext>().selectingRow.value =
|
||||
tableCellNode?.rowIndex;
|
||||
}
|
||||
|
||||
// Workaround to clear the selection after the menu is opened.
|
||||
Future.delayed(Durations.short3, () {
|
||||
if (!editorState.isDisposed) {
|
||||
editorState.selection = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
onClose: () {
|
||||
widget.isShowingMenu.value = false;
|
||||
|
||||
// clear the selecting index
|
||||
context.read<SimpleTableContext>().selectingColumn.value = null;
|
||||
context.read<SimpleTableContext>().selectingRow.value = null;
|
||||
},
|
||||
direction: widget.type == SimpleTableMoreActionType.row
|
||||
? PopoverDirection.bottomWithCenterAligned
|
||||
: PopoverDirection.bottomWithLeftAligned,
|
||||
offset: widget.type == SimpleTableMoreActionType.row
|
||||
? const Offset(24, 14)
|
||||
: const Offset(-14, 8),
|
||||
popupBuilder: (_) {
|
||||
if (tableCellNode == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider.value(
|
||||
value: context.read<SimpleTableContext>(),
|
||||
),
|
||||
Provider.value(
|
||||
value: context.read<EditorState>(),
|
||||
),
|
||||
],
|
||||
child: SimpleTableMoreActionList(
|
||||
type: widget.type,
|
||||
index: widget.index,
|
||||
tableCellNode: tableCellNode,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: SimpleTableReorderButton(
|
||||
isShowingMenu: widget.isShowingMenu,
|
||||
type: widget.type,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isTapInBounds(Offset offset) {
|
||||
if (renderBox == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final localPosition = renderBox!.globalToLocal(offset);
|
||||
final result = renderBox!.paintBounds.contains(localPosition);
|
||||
if (result) {
|
||||
editorState.selection = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableMoreActionList extends StatefulWidget {
|
||||
const SimpleTableMoreActionList({
|
||||
super.key,
|
||||
required this.type,
|
||||
required this.index,
|
||||
required this.tableCellNode,
|
||||
this.mutex,
|
||||
});
|
||||
|
||||
final SimpleTableMoreActionType type;
|
||||
final int index;
|
||||
final Node tableCellNode;
|
||||
final PopoverMutex? mutex;
|
||||
|
||||
@override
|
||||
State<SimpleTableMoreActionList> createState() =>
|
||||
_SimpleTableMoreActionListState();
|
||||
}
|
||||
|
||||
class _SimpleTableMoreActionListState extends State<SimpleTableMoreActionList> {
|
||||
// ensure the background color menu and align menu exclusive
|
||||
final mutex = PopoverMutex();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildActions()
|
||||
.map(
|
||||
(action) => SimpleTableMoreActionItem(
|
||||
type: widget.type,
|
||||
action: action,
|
||||
tableCellNode: widget.tableCellNode,
|
||||
popoverMutex: mutex,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
List<SimpleTableMoreAction> _buildActions() {
|
||||
final actions = widget.type.actions;
|
||||
|
||||
// if the index is 0, add the divider and enable header action
|
||||
if (widget.index == 0) {
|
||||
actions.addAll([
|
||||
SimpleTableMoreAction.divider,
|
||||
if (widget.type == SimpleTableMoreActionType.column)
|
||||
SimpleTableMoreAction.enableHeaderColumn,
|
||||
if (widget.type == SimpleTableMoreActionType.row)
|
||||
SimpleTableMoreAction.enableHeaderRow,
|
||||
]);
|
||||
}
|
||||
|
||||
// if the table only contains one row or one column, remove the delete action
|
||||
if (widget.tableCellNode.rowLength == 1 &&
|
||||
widget.type == SimpleTableMoreActionType.row) {
|
||||
actions.remove(SimpleTableMoreAction.delete);
|
||||
}
|
||||
|
||||
if (widget.tableCellNode.columnLength == 1 &&
|
||||
widget.type == SimpleTableMoreActionType.column) {
|
||||
actions.remove(SimpleTableMoreAction.delete);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleTableMoreActionItem extends StatefulWidget {
|
||||
const SimpleTableMoreActionItem({
|
||||
super.key,
|
||||
required this.type,
|
||||
required this.action,
|
||||
required this.tableCellNode,
|
||||
required this.popoverMutex,
|
||||
});
|
||||
|
||||
final SimpleTableMoreActionType type;
|
||||
final SimpleTableMoreAction action;
|
||||
final Node tableCellNode;
|
||||
final PopoverMutex popoverMutex;
|
||||
|
||||
@override
|
||||
State<SimpleTableMoreActionItem> createState() =>
|
||||
_SimpleTableMoreActionItemState();
|
||||
}
|
||||
|
||||
class _SimpleTableMoreActionItemState extends State<SimpleTableMoreActionItem> {
|
||||
final isEnableHeader = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_initEnableHeader();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
isEnableHeader.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.action == SimpleTableMoreAction.divider) {
|
||||
return _buildDivider(context);
|
||||
} else if (widget.action == SimpleTableMoreAction.align) {
|
||||
return _buildAlignMenu(context);
|
||||
} else if (widget.action == SimpleTableMoreAction.backgroundColor) {
|
||||
return _buildBackgroundColorMenu(context);
|
||||
} else if (widget.action == SimpleTableMoreAction.enableHeaderColumn) {
|
||||
return _buildEnableHeaderButton(context);
|
||||
} else if (widget.action == SimpleTableMoreAction.enableHeaderRow) {
|
||||
return _buildEnableHeaderButton(context);
|
||||
}
|
||||
|
||||
return _buildActionButton(context);
|
||||
}
|
||||
|
||||
Widget _buildDivider(BuildContext context) {
|
||||
return const FlowyDivider(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 4.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlignMenu(BuildContext context) {
|
||||
return SimpleTableAlignMenu(
|
||||
type: widget.type,
|
||||
tableCellNode: widget.tableCellNode,
|
||||
mutex: widget.popoverMutex,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackgroundColorMenu(BuildContext context) {
|
||||
return SimpleTableBackgroundColorMenu(
|
||||
type: widget.type,
|
||||
tableCellNode: widget.tableCellNode,
|
||||
mutex: widget.popoverMutex,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEnableHeaderButton(BuildContext context) {
|
||||
return SimpleTableBasicButton(
|
||||
text: widget.action.name,
|
||||
leftIconSvg: widget.action.leftIconSvg,
|
||||
rightIcon: ValueListenableBuilder(
|
||||
valueListenable: isEnableHeader,
|
||||
builder: (context, isEnableHeader, child) {
|
||||
return Toggle(
|
||||
value: isEnableHeader,
|
||||
onChanged: (value) => _toggleEnableHeader(),
|
||||
padding: EdgeInsets.zero,
|
||||
);
|
||||
},
|
||||
),
|
||||
onTap: _toggleEnableHeader,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton(BuildContext context) {
|
||||
return Container(
|
||||
height: SimpleTableConstants.moreActionHeight,
|
||||
padding: SimpleTableConstants.moreActionPadding,
|
||||
child: FlowyIconTextButton(
|
||||
margin: SimpleTableConstants.moreActionHorizontalMargin,
|
||||
leftIconBuilder: (onHover) => FlowySvg(
|
||||
widget.action.leftIconSvg,
|
||||
color: widget.action == SimpleTableMoreAction.delete && onHover
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
iconPadding: 10.0,
|
||||
textBuilder: (onHover) => FlowyText.regular(
|
||||
widget.action.name,
|
||||
fontSize: 14.0,
|
||||
figmaLineHeight: 18.0,
|
||||
color: widget.action == SimpleTableMoreAction.delete && onHover
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
onTap: _onAction,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onAction() {
|
||||
switch (widget.action) {
|
||||
case SimpleTableMoreAction.delete:
|
||||
switch (widget.type) {
|
||||
case SimpleTableMoreActionType.column:
|
||||
_deleteColumn();
|
||||
break;
|
||||
case SimpleTableMoreActionType.row:
|
||||
_deleteRow();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case SimpleTableMoreAction.insertLeft:
|
||||
_insertColumnLeft();
|
||||
break;
|
||||
case SimpleTableMoreAction.insertRight:
|
||||
_insertColumnRight();
|
||||
break;
|
||||
case SimpleTableMoreAction.insertAbove:
|
||||
_insertRowAbove();
|
||||
break;
|
||||
case SimpleTableMoreAction.insertBelow:
|
||||
_insertRowBelow();
|
||||
break;
|
||||
case SimpleTableMoreAction.clearContents:
|
||||
_clearContent();
|
||||
break;
|
||||
case SimpleTableMoreAction.duplicate:
|
||||
switch (widget.type) {
|
||||
case SimpleTableMoreActionType.column:
|
||||
_duplicateColumn();
|
||||
break;
|
||||
case SimpleTableMoreActionType.row:
|
||||
_duplicateRow();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
PopoverContainer.of(context).close();
|
||||
}
|
||||
|
||||
void _duplicateRow() {
|
||||
final value = _getTableAndTableCellAndCellPosition();
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
final (table, node, _) = value;
|
||||
final editorState = context.read<EditorState>();
|
||||
editorState.duplicateRowInTable(table, node.rowIndex);
|
||||
}
|
||||
|
||||
void _duplicateColumn() {
|
||||
final value = _getTableAndTableCellAndCellPosition();
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
final (table, node, _) = value;
|
||||
final editorState = context.read<EditorState>();
|
||||
editorState.duplicateColumnInTable(table, node.columnIndex);
|
||||
}
|
||||
|
||||
void _toggleEnableHeader() {
|
||||
final value = _getTableAndTableCellAndCellPosition();
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
isEnableHeader.value = !isEnableHeader.value;
|
||||
|
||||
final (table, _, _) = value;
|
||||
final editorState = context.read<EditorState>();
|
||||
switch (widget.type) {
|
||||
case SimpleTableMoreActionType.column:
|
||||
editorState.toggleEnableHeaderColumn(
|
||||
table,
|
||||
isEnableHeader.value,
|
||||
);
|
||||
case SimpleTableMoreActionType.row:
|
||||
editorState.toggleEnableHeaderRow(
|
||||
table,
|
||||
isEnableHeader.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearContent() {
|
||||
final value = _getTableAndTableCellAndCellPosition();
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
final (table, node, _) = value;
|
||||
final editorState = context.read<EditorState>();
|
||||
if (widget.type == SimpleTableMoreActionType.column) {
|
||||
editorState.clearContentAtColumnIndex(table, node.columnIndex);
|
||||
} else if (widget.type == SimpleTableMoreActionType.row) {
|
||||
editorState.clearContentAtRowIndex(table, node.rowIndex);
|
||||
}
|
||||
}
|
||||
|
||||
void _insertColumnLeft() {
|
||||
final value = _getTableAndTableCellAndCellPosition();
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
final (table, node, _) = value;
|
||||
final columnIndex = node.columnIndex;
|
||||
final editorState = context.read<EditorState>();
|
||||
editorState.insertColumnInTable(table, columnIndex);
|
||||
}
|
||||
|
||||
void _insertColumnRight() {
|
||||
final value = _getTableAndTableCellAndCellPosition();
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
final (table, node, _) = value;
|
||||
final columnIndex = node.columnIndex;
|
||||
final editorState = context.read<EditorState>();
|
||||
editorState.insertColumnInTable(table, columnIndex + 1);
|
||||
}
|
||||
|
||||
void _insertRowAbove() {
|
||||
final value = _getTableAndTableCellAndCellPosition();
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
final (table, node, _) = value;
|
||||
final rowIndex = node.rowIndex;
|
||||
final editorState = context.read<EditorState>();
|
||||
editorState.insertRowInTable(table, rowIndex);
|
||||
}
|
||||
|
||||
void _insertRowBelow() {
|
||||
final value = _getTableAndTableCellAndCellPosition();
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
final (table, node, _) = value;
|
||||
final rowIndex = node.rowIndex;
|
||||
final editorState = context.read<EditorState>();
|
||||
editorState.insertRowInTable(table, rowIndex + 1);
|
||||
}
|
||||
|
||||
void _deleteRow() {
|
||||
final value = _getTableAndTableCellAndCellPosition();
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
final (table, node, _) = value;
|
||||
final rowIndex = node.rowIndex;
|
||||
final editorState = context.read<EditorState>();
|
||||
editorState.deleteRowInTable(table, rowIndex);
|
||||
}
|
||||
|
||||
void _deleteColumn() {
|
||||
final value = _getTableAndTableCellAndCellPosition();
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
final (table, node, _) = value;
|
||||
final columnIndex = node.columnIndex;
|
||||
final editorState = context.read<EditorState>();
|
||||
editorState.deleteColumnInTable(table, columnIndex);
|
||||
}
|
||||
|
||||
(Node, Node, TableCellPosition)? _getTableAndTableCellAndCellPosition() {
|
||||
final cell = widget.tableCellNode;
|
||||
final table = cell.parent?.parent;
|
||||
if (table == null || table.type != SimpleTableBlockKeys.type) {
|
||||
return null;
|
||||
}
|
||||
return (table, cell, cell.cellPosition);
|
||||
}
|
||||
|
||||
void _initEnableHeader() {
|
||||
final value = _getTableAndTableCellAndCellPosition();
|
||||
if (value != null) {
|
||||
final (table, _, _) = value;
|
||||
if (widget.type == SimpleTableMoreActionType.column) {
|
||||
isEnableHeader.value = table
|
||||
.attributes[SimpleTableBlockKeys.enableHeaderColumn] as bool? ??
|
||||
false;
|
||||
} else if (widget.type == SimpleTableMoreActionType.row) {
|
||||
isEnableHeader.value =
|
||||
table.attributes[SimpleTableBlockKeys.enableHeaderRow] as bool? ??
|
||||
false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
extension TableContentOperation on EditorState {
|
||||
/// Clear the content of the column at the given index.
|
||||
///
|
||||
/// Before:
|
||||
/// Given column index: 0
|
||||
/// Row 1: | 0 | 1 | ← The content of these cells will be cleared
|
||||
/// Row 2: | 2 | 3 |
|
||||
///
|
||||
/// Call this function with column index 0 will clear the first column of the table.
|
||||
///
|
||||
/// After:
|
||||
/// Row 1: | | |
|
||||
/// Row 2: | 2 | 3 |
|
||||
Future<void> clearContentAtRowIndex(Node node, int index) async {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index < 0 || index >= node.columnLength) {
|
||||
Log.warn('clear content in column: index out of range: $index');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info('clear content in column: $index in table ${node.id}');
|
||||
|
||||
final transaction = this.transaction;
|
||||
|
||||
final row = node.children[index];
|
||||
for (var i = 0; i < row.children.length; i++) {
|
||||
final cell = row.children[i];
|
||||
transaction.insertNode(cell.path.next, simpleTableCellBlockNode());
|
||||
transaction.deleteNode(cell);
|
||||
}
|
||||
await apply(transaction);
|
||||
}
|
||||
|
||||
/// Clear the content of the row at the given index.
|
||||
///
|
||||
/// Before:
|
||||
/// Given row index: 1
|
||||
/// ↓ The content of these cells will be cleared
|
||||
/// Row 1: | 0 | 1 |
|
||||
/// Row 2: | 2 | 3 |
|
||||
///
|
||||
/// Call this function with row index 1 will clear the second row of the table.
|
||||
///
|
||||
/// After:
|
||||
/// Row 1: | 0 | |
|
||||
/// Row 2: | 2 | |
|
||||
Future<void> clearContentAtColumnIndex(Node node, int index) async {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index < 0 || index >= node.columnLength) {
|
||||
Log.warn('clear content in column: index out of range: $index');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info('clear content in column: $index in table ${node.id}');
|
||||
|
||||
final transaction = this.transaction;
|
||||
for (var i = 0; i < node.rowLength; i++) {
|
||||
final row = node.children[i];
|
||||
final cell = index >= row.children.length
|
||||
? row.children.last
|
||||
: row.children[index];
|
||||
transaction.insertNode(cell.path.next, simpleTableCellBlockNode());
|
||||
transaction.deleteNode(cell);
|
||||
}
|
||||
await apply(transaction);
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
extension TableDeletionOperations on EditorState {
|
||||
/// Delete a row at the given index.
|
||||
///
|
||||
/// Before:
|
||||
/// Given index: 0
|
||||
/// Row 1: | | | | ← This row will be deleted
|
||||
/// Row 2: | | | |
|
||||
///
|
||||
/// Call this function with index 0 will delete the first row of the table.
|
||||
///
|
||||
/// After:
|
||||
/// Row 1: | | | |
|
||||
Future<void> deleteRowInTable(
|
||||
Node node,
|
||||
int index,
|
||||
) async {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
final rowLength = node.rowLength;
|
||||
if (index < 0 || index >= rowLength) {
|
||||
Log.warn(
|
||||
'delete row: index out of range: $index, row length: $rowLength',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info('delete row: $index in table ${node.id}');
|
||||
|
||||
final attributes = node.mapTableAttributes(
|
||||
node,
|
||||
type: TableMapOperationType.deleteRow,
|
||||
index: index,
|
||||
);
|
||||
|
||||
final row = node.children[index];
|
||||
final transaction = this.transaction;
|
||||
transaction.deleteNode(row);
|
||||
if (attributes != null) {
|
||||
transaction.updateNode(node, attributes);
|
||||
}
|
||||
await apply(transaction);
|
||||
}
|
||||
|
||||
/// Delete a column at the given index.
|
||||
///
|
||||
/// Before:
|
||||
/// Given index: 2
|
||||
/// ↓ This column will be deleted
|
||||
/// Row 1: | 0 | 1 | 2 |
|
||||
/// Row 2: | | | |
|
||||
///
|
||||
/// Call this function with index 2 will delete the third column of the table.
|
||||
///
|
||||
/// After:
|
||||
/// Row 1: | 0 | 1 |
|
||||
/// Row 2: | | |
|
||||
Future<void> deleteColumnInTable(Node node, int index) async {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
final rowLength = node.rowLength;
|
||||
final columnLength = node.columnLength;
|
||||
if (index < 0 || index >= columnLength) {
|
||||
Log.warn(
|
||||
'delete column: index out of range: $index, column length: $columnLength',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info('delete column: $index in table ${node.id}');
|
||||
|
||||
final attributes = node.mapTableAttributes(
|
||||
node,
|
||||
type: TableMapOperationType.deleteColumn,
|
||||
index: index,
|
||||
);
|
||||
|
||||
final transaction = this.transaction;
|
||||
for (var i = 0; i < rowLength; i++) {
|
||||
final row = node.children[i];
|
||||
transaction.deleteNode(row.children[index]);
|
||||
}
|
||||
if (attributes != null) {
|
||||
transaction.updateNode(node, attributes);
|
||||
}
|
||||
await apply(transaction);
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
extension TableDuplicationOperations on EditorState {
|
||||
/// Duplicate a row at the given index.
|
||||
///
|
||||
/// Before:
|
||||
/// | 0 | 1 | 2 |
|
||||
/// | 3 | 4 | 5 | ← This row will be duplicated
|
||||
///
|
||||
/// Call this function with index 1 will duplicate the second row of the table.
|
||||
///
|
||||
/// After:
|
||||
/// | 0 | 1 | 2 |
|
||||
/// | 3 | 4 | 5 |
|
||||
/// | 3 | 4 | 5 | ← New row
|
||||
Future<void> duplicateRowInTable(Node node, int index) async {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
final columnLength = node.columnLength;
|
||||
final rowLength = node.rowLength;
|
||||
|
||||
if (index < 0 || index >= rowLength) {
|
||||
Log.warn(
|
||||
'duplicate row: index out of range: $index, row length: $rowLength',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info(
|
||||
'duplicate row in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength',
|
||||
);
|
||||
|
||||
final attributes = node.mapTableAttributes(
|
||||
node,
|
||||
type: TableMapOperationType.duplicateRow,
|
||||
index: index,
|
||||
);
|
||||
|
||||
final newRow = node.children[index].copyWith();
|
||||
final transaction = this.transaction;
|
||||
final path = index >= columnLength
|
||||
? node.children.last.path.next
|
||||
: node.children[index].path;
|
||||
transaction.insertNode(path, newRow);
|
||||
if (attributes != null) {
|
||||
transaction.updateNode(node, attributes);
|
||||
}
|
||||
await apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> duplicateColumnInTable(Node node, int index) async {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
final columnLength = node.columnLength;
|
||||
final rowLength = node.rowLength;
|
||||
|
||||
if (index < 0 || index >= columnLength) {
|
||||
Log.warn(
|
||||
'duplicate column: index out of range: $index, column length: $columnLength',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info(
|
||||
'duplicate column in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength',
|
||||
);
|
||||
|
||||
final attributes = node.mapTableAttributes(
|
||||
node,
|
||||
type: TableMapOperationType.duplicateColumn,
|
||||
index: index,
|
||||
);
|
||||
|
||||
final transaction = this.transaction;
|
||||
for (var i = 0; i < rowLength; i++) {
|
||||
final row = node.children[i];
|
||||
final path = index >= rowLength
|
||||
? row.children.last.path.next
|
||||
: row.children[index].path;
|
||||
final newCell = row.children[index].copyWith();
|
||||
transaction.insertNode(
|
||||
path,
|
||||
newCell,
|
||||
);
|
||||
}
|
||||
if (attributes != null) {
|
||||
transaction.updateNode(node, attributes);
|
||||
}
|
||||
await apply(transaction);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
extension TableHeaderOperation on EditorState {
|
||||
/// Toggle the enable header column of the table.
|
||||
Future<void> toggleEnableHeaderColumn(Node node, bool enable) async {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info('toggle enable header column: $enable in table ${node.id}');
|
||||
|
||||
final transaction = this.transaction;
|
||||
transaction.updateNode(node, {
|
||||
SimpleTableBlockKeys.enableHeaderColumn: enable,
|
||||
});
|
||||
await apply(transaction);
|
||||
}
|
||||
|
||||
/// Toggle the enable header row of the table.
|
||||
Future<void> toggleEnableHeaderRow(Node node, bool enable) async {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info('toggle enable header row: $enable in table ${node.id}');
|
||||
|
||||
final transaction = this.transaction;
|
||||
transaction.updateNode(node, {
|
||||
SimpleTableBlockKeys.enableHeaderRow: enable,
|
||||
});
|
||||
await apply(transaction);
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
extension TableInsertionOperations on EditorState {
|
||||
/// Add a row at the end of the table.
|
||||
///
|
||||
/// Before:
|
||||
/// Row 1: | | | |
|
||||
/// Row 2: | | | |
|
||||
///
|
||||
/// Call this function will add a row at the end of the table.
|
||||
///
|
||||
/// After:
|
||||
/// Row 1: | | | |
|
||||
/// Row 2: | | | |
|
||||
/// Row 3: | | | | ← New row
|
||||
///
|
||||
Future<void> addRowInTable(Node tableNode) async {
|
||||
assert(tableNode.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (tableNode.type != SimpleTableBlockKeys.type) {
|
||||
Log.warn('node is not a table node: ${tableNode.type}');
|
||||
return;
|
||||
}
|
||||
|
||||
await insertRowInTable(tableNode, tableNode.rowLength);
|
||||
}
|
||||
|
||||
/// Add a column at the end of the table.
|
||||
///
|
||||
/// Before:
|
||||
/// Row 1: | | | |
|
||||
/// Row 2: | | | |
|
||||
///
|
||||
/// Call this function will add a column at the end of the table.
|
||||
///
|
||||
/// After:
|
||||
/// ↓ New column
|
||||
/// Row 1: | | | | |
|
||||
/// Row 2: | | | | |
|
||||
Future<void> addColumnInTable(Node node) async {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
Log.warn('node is not a table node: ${node.type}');
|
||||
return;
|
||||
}
|
||||
|
||||
await insertColumnInTable(node, node.columnLength);
|
||||
}
|
||||
|
||||
/// Add a column and a row at the end of the table.
|
||||
///
|
||||
/// Before:
|
||||
/// Row 1: | | | |
|
||||
/// Row 2: | | | |
|
||||
///
|
||||
/// Call this function will add a column and a row at the end of the table.
|
||||
///
|
||||
/// After:
|
||||
/// ↓ New column
|
||||
/// Row 1: | | | | |
|
||||
/// Row 2: | | | | |
|
||||
/// Row 3: | | | | | ← New row
|
||||
Future<void> addColumnAndRowInTable(Node node) async {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
await addColumnInTable(node);
|
||||
await addRowInTable(node);
|
||||
}
|
||||
|
||||
/// Add a column at the given index.
|
||||
///
|
||||
/// Before:
|
||||
/// Given index: 1
|
||||
/// Row 1: | 0 | 1 |
|
||||
/// Row 2: | | |
|
||||
///
|
||||
/// Call this function with index 1 will add a column at the second position of the table.
|
||||
///
|
||||
/// After: ↓ New column
|
||||
/// Row 1: | 0 | | 1 |
|
||||
/// Row 2: | | | |
|
||||
Future<void> insertColumnInTable(Node node, int index) async {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
Log.warn('node is not a table node: ${node.type}');
|
||||
return;
|
||||
}
|
||||
|
||||
final columnLength = node.rowLength;
|
||||
final rowLength = node.columnLength;
|
||||
|
||||
Log.info(
|
||||
'add column in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength',
|
||||
);
|
||||
|
||||
if (index < 0) {
|
||||
Log.warn(
|
||||
'insert column: index out of range: $index, column length: $columnLength',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final attributes = node.mapTableAttributes(
|
||||
node,
|
||||
type: TableMapOperationType.insertColumn,
|
||||
index: index,
|
||||
);
|
||||
|
||||
final transaction = this.transaction;
|
||||
for (var i = 0; i < columnLength; i++) {
|
||||
final row = node.children[i];
|
||||
// if the index is greater than the row length, we add the new column at the end of the row.
|
||||
final path = index >= rowLength
|
||||
? row.children.last.path.next
|
||||
: row.children[index].path;
|
||||
transaction.insertNode(
|
||||
path,
|
||||
simpleTableCellBlockNode(),
|
||||
);
|
||||
}
|
||||
if (attributes != null) {
|
||||
transaction.updateNode(node, attributes);
|
||||
}
|
||||
await apply(transaction);
|
||||
}
|
||||
|
||||
/// Add a row at the given index.
|
||||
///
|
||||
/// Before:
|
||||
/// Given index: 1
|
||||
/// Row 1: | | |
|
||||
/// Row 2: | | |
|
||||
///
|
||||
/// Call this function with index 1 will add a row at the second position of the table.
|
||||
///
|
||||
/// After:
|
||||
/// Row 1: | | |
|
||||
/// Row 2: | | |
|
||||
/// Row 3: | | | ← New row
|
||||
Future<void> insertRowInTable(Node node, int index) async {
|
||||
assert(node.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index < 0) {
|
||||
Log.warn(
|
||||
'insert row: index out of range: $index',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final columnLength = node.rowLength;
|
||||
final rowLength = node.columnLength;
|
||||
|
||||
Log.info(
|
||||
'insert row in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength',
|
||||
);
|
||||
|
||||
final newRow = simpleTableRowBlockNode(
|
||||
children: [
|
||||
for (var i = 0; i < rowLength; i++) simpleTableCellBlockNode(),
|
||||
],
|
||||
);
|
||||
|
||||
final attributes = node.mapTableAttributes(
|
||||
node,
|
||||
type: TableMapOperationType.insertRow,
|
||||
index: index,
|
||||
);
|
||||
|
||||
final transaction = this.transaction;
|
||||
final path = index >= columnLength
|
||||
? node.children.last.path.next
|
||||
: node.children[index].path;
|
||||
transaction.insertNode(path, newRow);
|
||||
if (attributes != null) {
|
||||
transaction.updateNode(node, attributes);
|
||||
}
|
||||
await apply(transaction);
|
||||
}
|
||||
}
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
enum TableMapOperationType {
|
||||
insertRow,
|
||||
deleteRow,
|
||||
insertColumn,
|
||||
deleteColumn,
|
||||
duplicateRow,
|
||||
duplicateColumn,
|
||||
}
|
||||
|
||||
extension TableMapOperation on Node {
|
||||
Attributes? mapTableAttributes(
|
||||
Node node, {
|
||||
required TableMapOperationType type,
|
||||
required int index,
|
||||
}) {
|
||||
assert(this.type == SimpleTableBlockKeys.type);
|
||||
|
||||
if (this.type != SimpleTableBlockKeys.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Attributes? attributes;
|
||||
|
||||
switch (type) {
|
||||
case TableMapOperationType.insertRow:
|
||||
attributes = _mapRowInsertionAttributes(index);
|
||||
case TableMapOperationType.insertColumn:
|
||||
attributes = _mapColumnInsertionAttributes(index);
|
||||
case TableMapOperationType.duplicateRow:
|
||||
attributes = _mapRowDuplicationAttributes(index);
|
||||
case TableMapOperationType.duplicateColumn:
|
||||
attributes = _mapColumnDuplicationAttributes(index);
|
||||
case TableMapOperationType.deleteRow:
|
||||
attributes = _mapRowDeletionAttributes(index);
|
||||
case TableMapOperationType.deleteColumn:
|
||||
attributes = _mapColumnDeletionAttributes(index);
|
||||
}
|
||||
|
||||
// clear the attributes that are null
|
||||
attributes?.removeWhere((key, value) => value == null);
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/// Map the attributes of a row insertion operation.
|
||||
///
|
||||
/// When inserting a row, the attributes of the table after the index should be updated
|
||||
/// For example:
|
||||
/// Before:
|
||||
/// | 0 | 1 | 2 |
|
||||
/// | 3 | 4 | 5 | ← insert a new row here
|
||||
///
|
||||
/// The original attributes of the table:
|
||||
/// {
|
||||
/// "rowColors": {
|
||||
/// 0: "#FF0000",
|
||||
/// 1: "#00FF00",
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// Insert a row at index 1:
|
||||
/// | 0 | 1 | 2 |
|
||||
/// | | | | ← new row
|
||||
/// | 3 | 4 | 5 |
|
||||
///
|
||||
/// The new attributes of the table:
|
||||
/// {
|
||||
/// "rowColors": {
|
||||
/// 0: "#FF0000",
|
||||
/// 2: "#00FF00", ← The attributes of the original second row
|
||||
/// }
|
||||
/// }
|
||||
Attributes? _mapRowInsertionAttributes(int index) {
|
||||
final attributes = this.attributes;
|
||||
try {
|
||||
final rowColors = _remapSource(
|
||||
this.rowColors,
|
||||
index,
|
||||
comparator: (iKey, index) => iKey >= index,
|
||||
);
|
||||
|
||||
final rowAligns = _remapSource(
|
||||
this.rowAligns,
|
||||
index,
|
||||
comparator: (iKey, index) => iKey >= index,
|
||||
);
|
||||
|
||||
return attributes
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.rowColors,
|
||||
rowColors,
|
||||
)
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.rowAligns,
|
||||
rowAligns,
|
||||
);
|
||||
} catch (e) {
|
||||
Log.warn('Failed to map row insertion attributes: $e');
|
||||
return attributes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the attributes of a column insertion operation.
|
||||
///
|
||||
/// When inserting a column, the attributes of the table after the index should be updated
|
||||
/// For example:
|
||||
/// Before:
|
||||
/// | 0 | 1 |
|
||||
/// | 2 | 3 |
|
||||
///
|
||||
/// The original attributes of the table:
|
||||
/// {
|
||||
/// "columnColors": {
|
||||
/// 0: "#FF0000",
|
||||
/// 1: "#00FF00",
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// Insert a column at index 1:
|
||||
/// | 0 | | 1 |
|
||||
/// | 2 | | 3 |
|
||||
///
|
||||
/// The new attributes of the table:
|
||||
/// {
|
||||
/// "columnColors": {
|
||||
/// 0: "#FF0000",
|
||||
/// 2: "#00FF00", ← The attributes of the original second column
|
||||
/// }
|
||||
/// }
|
||||
Attributes? _mapColumnInsertionAttributes(int index) {
|
||||
final attributes = this.attributes;
|
||||
try {
|
||||
final columnColors = _remapSource(
|
||||
this.columnColors,
|
||||
index,
|
||||
comparator: (iKey, index) => iKey >= index,
|
||||
);
|
||||
|
||||
final columnAligns = _remapSource(
|
||||
this.columnAligns,
|
||||
index,
|
||||
comparator: (iKey, index) => iKey >= index,
|
||||
);
|
||||
|
||||
final columnWidths = _remapSource(
|
||||
this.columnWidths,
|
||||
index,
|
||||
comparator: (iKey, index) => iKey >= index,
|
||||
);
|
||||
|
||||
return attributes
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.columnColors,
|
||||
columnColors,
|
||||
)
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.columnAligns,
|
||||
columnAligns,
|
||||
)
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.columnWidths,
|
||||
columnWidths,
|
||||
);
|
||||
} catch (e) {
|
||||
Log.warn('Failed to map row insertion attributes: $e');
|
||||
return attributes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the attributes of a row duplication operation.
|
||||
///
|
||||
/// When duplicating a row, the attributes of the table after the index should be updated
|
||||
/// For example:
|
||||
/// Before:
|
||||
/// | 0 | 1 | 2 |
|
||||
/// | 3 | 4 | 5 |
|
||||
///
|
||||
/// The original attributes of the table:
|
||||
/// {
|
||||
/// "rowColors": {
|
||||
/// 0: "#FF0000",
|
||||
/// 1: "#00FF00",
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// Duplicate the row at index 1:
|
||||
/// | 0 | 1 | 2 |
|
||||
/// | 3 | 4 | 5 |
|
||||
/// | 3 | 4 | 5 | ← duplicated row
|
||||
///
|
||||
/// The new attributes of the table:
|
||||
/// {
|
||||
/// "rowColors": {
|
||||
/// 0: "#FF0000",
|
||||
/// 1: "#00FF00",
|
||||
/// 2: "#00FF00", ← The attributes of the original second row
|
||||
/// }
|
||||
/// }
|
||||
Attributes? _mapRowDuplicationAttributes(int index) {
|
||||
final attributes = this.attributes;
|
||||
try {
|
||||
final (rowColors, duplicatedRowColor) = _findDuplicatedEntryAndRemap(
|
||||
this.rowColors,
|
||||
index,
|
||||
);
|
||||
|
||||
final (rowAligns, duplicatedRowAlign) = _findDuplicatedEntryAndRemap(
|
||||
this.rowAligns,
|
||||
index,
|
||||
);
|
||||
|
||||
return attributes
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.rowColors,
|
||||
rowColors,
|
||||
duplicatedEntry: duplicatedRowColor,
|
||||
)
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.rowAligns,
|
||||
rowAligns,
|
||||
duplicatedEntry: duplicatedRowAlign,
|
||||
);
|
||||
} catch (e) {
|
||||
Log.warn('Failed to map row insertion attributes: $e');
|
||||
return attributes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the attributes of a column duplication operation.
|
||||
///
|
||||
/// When duplicating a column, the attributes of the table after the index should be updated
|
||||
/// For example:
|
||||
/// Before:
|
||||
/// | 0 | 1 |
|
||||
/// | 2 | 3 |
|
||||
///
|
||||
/// The original attributes of the table:
|
||||
/// {
|
||||
/// "columnColors": {
|
||||
/// 0: "#FF0000",
|
||||
/// 1: "#00FF00",
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// Duplicate the column at index 1:
|
||||
/// | 0 | 1 | 1 | ← duplicated column
|
||||
/// | 2 | 3 | 2 | ← duplicated column
|
||||
///
|
||||
/// The new attributes of the table:
|
||||
/// {
|
||||
/// "columnColors": {
|
||||
/// 0: "#FF0000",
|
||||
/// 1: "#00FF00",
|
||||
/// 2: "#00FF00", ← The attributes of the original second column
|
||||
/// }
|
||||
/// }
|
||||
Attributes? _mapColumnDuplicationAttributes(int index) {
|
||||
final attributes = this.attributes;
|
||||
try {
|
||||
final (columnColors, duplicatedColumnColor) =
|
||||
_findDuplicatedEntryAndRemap(
|
||||
this.columnColors,
|
||||
index,
|
||||
);
|
||||
|
||||
final (columnAligns, duplicatedColumnAlign) =
|
||||
_findDuplicatedEntryAndRemap(
|
||||
this.columnAligns,
|
||||
index,
|
||||
);
|
||||
|
||||
final (columnWidths, duplicatedColumnWidth) =
|
||||
_findDuplicatedEntryAndRemap(
|
||||
this.columnWidths,
|
||||
index,
|
||||
);
|
||||
|
||||
return attributes
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.columnColors,
|
||||
columnColors,
|
||||
duplicatedEntry: duplicatedColumnColor,
|
||||
)
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.columnAligns,
|
||||
columnAligns,
|
||||
duplicatedEntry: duplicatedColumnAlign,
|
||||
)
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.columnWidths,
|
||||
columnWidths,
|
||||
duplicatedEntry: duplicatedColumnWidth,
|
||||
);
|
||||
} catch (e) {
|
||||
Log.warn('Failed to map column duplication attributes: $e');
|
||||
return attributes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the attributes of a column deletion operation.
|
||||
///
|
||||
/// When deleting a column, the attributes of the table after the index should be updated
|
||||
///
|
||||
/// For example:
|
||||
/// Before:
|
||||
/// | 0 | 1 | 2 |
|
||||
/// | 3 | 4 | 5 |
|
||||
///
|
||||
/// The original attributes of the table:
|
||||
/// {
|
||||
/// "columnColors": {
|
||||
/// 0: "#FF0000",
|
||||
/// 2: "#00FF00",
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// Delete the column at index 1:
|
||||
/// | 0 | 2 |
|
||||
/// | 3 | 5 |
|
||||
///
|
||||
/// The new attributes of the table:
|
||||
/// {
|
||||
/// "columnColors": {
|
||||
/// 0: "#FF0000",
|
||||
/// 1: "#00FF00", ← The attributes of the original second column
|
||||
/// }
|
||||
/// }
|
||||
Attributes? _mapColumnDeletionAttributes(int index) {
|
||||
final attributes = this.attributes;
|
||||
try {
|
||||
final columnColors = _remapSource(
|
||||
this.columnColors,
|
||||
index,
|
||||
increment: false,
|
||||
comparator: (iKey, index) => iKey > index,
|
||||
filterIndex: index,
|
||||
);
|
||||
final columnAligns = _remapSource(
|
||||
this.columnAligns,
|
||||
index,
|
||||
increment: false,
|
||||
comparator: (iKey, index) => iKey > index,
|
||||
filterIndex: index,
|
||||
);
|
||||
final columnWidths = _remapSource(
|
||||
this.columnWidths,
|
||||
index,
|
||||
increment: false,
|
||||
comparator: (iKey, index) => iKey > index,
|
||||
filterIndex: index,
|
||||
);
|
||||
|
||||
return attributes
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.columnColors,
|
||||
columnColors,
|
||||
)
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.columnAligns,
|
||||
columnAligns,
|
||||
)
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.columnWidths,
|
||||
columnWidths,
|
||||
);
|
||||
} catch (e) {
|
||||
Log.warn('Failed to map column deletion attributes: $e');
|
||||
return attributes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the attributes of a row deletion operation.
|
||||
///
|
||||
/// When deleting a row, the attributes of the table after the index should be updated
|
||||
///
|
||||
/// For example:
|
||||
/// Before:
|
||||
/// | 0 | 1 | 2 | ← delete this row
|
||||
/// | 3 | 4 | 5 |
|
||||
///
|
||||
/// The original attributes of the table:
|
||||
/// {
|
||||
/// "rowColors": {
|
||||
/// 0: "#FF0000",
|
||||
/// 1: "#00FF00",
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// Delete the row at index 0:
|
||||
/// | 3 | 4 | 5 |
|
||||
///
|
||||
/// The new attributes of the table:
|
||||
/// {
|
||||
/// "rowColors": {
|
||||
/// 0: "#00FF00",
|
||||
/// }
|
||||
/// }
|
||||
Attributes? _mapRowDeletionAttributes(int index) {
|
||||
final attributes = this.attributes;
|
||||
try {
|
||||
final rowColors = _remapSource(
|
||||
this.rowColors,
|
||||
index,
|
||||
increment: false,
|
||||
comparator: (iKey, index) => iKey > index,
|
||||
filterIndex: index,
|
||||
);
|
||||
final rowAligns = _remapSource(
|
||||
this.rowAligns,
|
||||
index,
|
||||
increment: false,
|
||||
comparator: (iKey, index) => iKey > index,
|
||||
filterIndex: index,
|
||||
);
|
||||
|
||||
return attributes
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.rowColors,
|
||||
rowColors,
|
||||
)
|
||||
.mergeValues(
|
||||
SimpleTableBlockKeys.rowAligns,
|
||||
rowAligns,
|
||||
);
|
||||
} catch (e) {
|
||||
Log.warn('Failed to map row deletion attributes: $e');
|
||||
return attributes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the duplicated entry and remap the source.
|
||||
///
|
||||
/// All the entries after the index will be remapped to the new index.
|
||||
(Map<String, dynamic> newSource, MapEntry? duplicatedEntry)
|
||||
_findDuplicatedEntryAndRemap(
|
||||
Map<String, dynamic> source,
|
||||
int index, {
|
||||
bool increment = true,
|
||||
}) {
|
||||
MapEntry? duplicatedEntry;
|
||||
final newSource = source.map((key, value) {
|
||||
final iKey = int.parse(key);
|
||||
if (iKey == index) {
|
||||
duplicatedEntry = MapEntry(key, value);
|
||||
}
|
||||
if (iKey >= index) {
|
||||
return MapEntry((iKey + (increment ? 1 : -1)).toString(), value);
|
||||
}
|
||||
return MapEntry(key, value);
|
||||
});
|
||||
return (newSource, duplicatedEntry);
|
||||
}
|
||||
|
||||
/// Remap the source to the new index.
|
||||
///
|
||||
/// All the entries after the index will be remapped to the new index.
|
||||
Map<String, dynamic> _remapSource(
|
||||
Map<String, dynamic> source,
|
||||
int index, {
|
||||
bool increment = true,
|
||||
required bool Function(int iKey, int index) comparator,
|
||||
int? filterIndex,
|
||||
}) {
|
||||
var newSource = {...source};
|
||||
if (filterIndex != null) {
|
||||
newSource.remove(filterIndex.toString());
|
||||
}
|
||||
newSource = newSource.map((key, value) {
|
||||
final iKey = int.parse(key);
|
||||
if (comparator(iKey, index)) {
|
||||
return MapEntry((iKey + (increment ? 1 : -1)).toString(), value);
|
||||
}
|
||||
return MapEntry(key, value);
|
||||
});
|
||||
return newSource;
|
||||
}
|
||||
|
||||
extension TableMapOperationAttributes on Attributes {
|
||||
Attributes mergeValues(
|
||||
String key,
|
||||
Map<String, dynamic> newSource, {
|
||||
MapEntry? duplicatedEntry,
|
||||
}) {
|
||||
final result = {...this};
|
||||
|
||||
if (duplicatedEntry != null) {
|
||||
newSource[duplicatedEntry.key] = duplicatedEntry.value;
|
||||
}
|
||||
|
||||
result[key] = newSource;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user