Compare commits
212
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89647ae683 | ||
|
|
c87d9ab74f | ||
|
|
e3a0806eee | ||
|
|
b63c4dfe21 | ||
|
|
2277d7d234 | ||
|
|
f76ce2be14 | ||
|
|
3c74208ab9 | ||
|
|
5ae3f42313 | ||
|
|
af5c4bfe76 | ||
|
|
9c25769d8e | ||
|
|
12a4bf67f6 | ||
|
|
34a858e948 | ||
|
|
3a879b0186 | ||
|
|
dbe72b32b2 | ||
|
|
917aa60c98 | ||
|
|
84e0f5e6ff | ||
|
|
7b89d76cea | ||
|
|
fbf031b06d | ||
|
|
671e855b0e | ||
|
|
8aa32ca3fa | ||
|
|
07c767c4fa | ||
|
|
4ce0782a05 | ||
|
|
7456c65799 | ||
|
|
8cf31b8afc | ||
|
|
ff70595a15 | ||
|
|
f6f19a0a07 | ||
|
|
b83b964678 | ||
|
|
f574b6b9c2 | ||
|
|
7ee29dcbc5 | ||
|
|
d348361889 | ||
|
|
07a78b4ad7 | ||
|
|
a26ebbccc1 | ||
|
|
ccb020e885 | ||
|
|
76cb23e233 | ||
|
|
4686e13390 | ||
|
|
584f762e11 | ||
|
|
8528811992 | ||
|
|
9147f64b65 | ||
|
|
f7f2e71ee1 | ||
|
|
f9e1dcca6c | ||
|
|
e11388491f | ||
|
|
eb0cff36c9 | ||
|
|
ac8141ab15 | ||
|
|
b3b13e550d | ||
|
|
ba1767e312 | ||
|
|
10048dadec | ||
|
|
815bb11cde | ||
|
|
7372f5583c | ||
|
|
9115e208ac | ||
|
|
24bb1b58a0 | ||
|
|
cfca70ae14 | ||
|
|
1db6da7024 | ||
|
|
c212c568e9 | ||
|
|
1d437fb81e | ||
|
|
5a0478ad56 | ||
|
|
039c191d1f | ||
|
|
eb4b015de8 | ||
|
|
5269bfbf8e | ||
|
|
ed44c20281 | ||
|
|
878323a299 | ||
|
|
0dc2363962 | ||
|
|
72d660f1ac | ||
|
|
46532a861f | ||
|
|
4c39908748 | ||
|
|
35081fd311 | ||
|
|
7463e4e3eb | ||
|
|
66ce786726 | ||
|
|
682a50da53 | ||
|
|
d372abd5a1 | ||
|
|
dfb5a6629f | ||
|
|
bb72f7e70a | ||
|
|
37085042f8 | ||
|
|
2cbcb320fe | ||
|
|
949556e2fa | ||
|
|
08d1d3602e | ||
|
|
910c45e457 | ||
|
|
6f031d0c7e | ||
|
|
44c9d572c8 | ||
|
|
05949d2f87 | ||
|
|
ad695e43b9 | ||
|
|
87015f7133 | ||
|
|
deb019aa4a | ||
|
|
c79d014305 | ||
|
|
182101023b | ||
|
|
f1b2f51a06 | ||
|
|
a5c0ad5998 | ||
|
|
6fd250d4d1 | ||
|
|
db2270c8d8 | ||
|
|
10f19069c6 | ||
|
|
13a7ea07a8 | ||
|
|
aacd795ae0 | ||
|
|
566e7b2f40 | ||
|
|
f413b9e070 | ||
|
|
6e4206a8e2 | ||
|
|
954aa48f52 | ||
|
|
461ac91b32 | ||
|
|
8a9cc278ec | ||
|
|
9db87944f2 | ||
|
|
9230981e54 | ||
|
|
72b13dd941 | ||
|
|
e6b0c8ff05 | ||
|
|
f0d967f0e4 | ||
|
|
85b9aab015 | ||
|
|
69571f668c | ||
|
|
a89dd87c16 | ||
|
|
22b03eee29 | ||
|
|
e3ea3fcdfa | ||
|
|
ccfbde9a92 | ||
|
|
7358860bfc | ||
|
|
17b355197c | ||
|
|
aa7e50cc6c | ||
|
|
69ce105806 | ||
|
|
cafdfcca51 | ||
|
|
a8c5c9c34e | ||
|
|
6dd83675fc | ||
|
|
b65fad6214 | ||
|
|
884046ba3f | ||
|
|
6d327adb83 | ||
|
|
eddb623fba | ||
|
|
2ea8e831cd | ||
|
|
270c981051 | ||
|
|
7971566159 | ||
|
|
9fbf2d5ef6 | ||
|
|
21bf2968a9 | ||
|
|
117950c922 | ||
|
|
b4c56f7998 | ||
|
|
af0c802486 | ||
|
|
33d518f383 | ||
|
|
1f9fe89f87 | ||
|
|
5fef4f1d49 | ||
|
|
d6446872ee | ||
|
|
5b5feb2515 | ||
|
|
b1bca1b55b | ||
|
|
4e1a70c7ac | ||
|
|
54f25e4b91 | ||
|
|
aa176f2c12 | ||
|
|
58895620c1 | ||
|
|
e10aade895 | ||
|
|
1fdd7c343b | ||
|
|
e36b08cd14 | ||
|
|
6ac9ad1cac | ||
|
|
36bf90e81b | ||
|
|
22b9acf386 | ||
|
|
e4e75acdac | ||
|
|
7996736592 | ||
|
|
3ad8f624cf | ||
|
|
0657aeb07d | ||
|
|
723971e423 | ||
|
|
86b67a1b65 | ||
|
|
d94b4daa70 | ||
|
|
ad62e85b3a | ||
|
|
133eec8163 | ||
|
|
81bac5950c | ||
|
|
651046ab68 | ||
|
|
9bd13ac29e | ||
|
|
c7d3d612ae | ||
|
|
69dd2ab20f | ||
|
|
f0b8b00461 | ||
|
|
2b8aaf1d46 | ||
|
|
ee69283a23 | ||
|
|
caaf5f7986 | ||
|
|
392964ffd2 | ||
|
|
1f76412790 | ||
|
|
555254e8fe | ||
|
|
3aa55f83b1 | ||
|
|
d15a8a88a6 | ||
|
|
1f7ab9d22d | ||
|
|
44945b2912 | ||
|
|
8d50caa86e | ||
|
|
b59eba76a6 | ||
|
|
070cde9ecb | ||
|
|
1e81e4c68f | ||
|
|
402ca7d765 | ||
|
|
d0ca7f311c | ||
|
|
e553627ee5 | ||
|
|
cbdac71025 | ||
|
|
6f35ae9857 | ||
|
|
20d64cc7ae | ||
|
|
654e18aacf | ||
|
|
75dd5c1d93 | ||
|
|
f8f9c3404a | ||
|
|
01e5817b24 | ||
|
|
96608bd005 | ||
|
|
57a5b38509 | ||
|
|
83c53188e3 | ||
|
|
6ba7f93f69 | ||
|
|
702a486cce | ||
|
|
eb0ed1ad86 | ||
|
|
e264b3a5b8 | ||
|
|
667d15c627 | ||
|
|
bd06e1d559 | ||
|
|
459aca5291 | ||
|
|
e7cd90b6ab | ||
|
|
940db70447 | ||
|
|
59139ff323 | ||
|
|
22fed1bfbc | ||
|
|
5e593bd36e | ||
|
|
ba1dfc6de4 | ||
|
|
c81f87dcdc | ||
|
|
a8b55ca3f0 | ||
|
|
0cefaf633c | ||
|
|
ba4aebd005 | ||
|
|
7b32a92290 | ||
|
|
41b99209f1 | ||
|
|
c1612fe298 | ||
|
|
e69a09d332 | ||
|
|
4e0d9fdb0b | ||
|
|
8b2e769fca | ||
|
|
d29a90a472 | ||
|
|
2e4beb0652 | ||
|
|
addb041816 | ||
|
|
4ff71b5dce |
No files matched your search
@@ -1,4 +1,18 @@
|
||||
# Release Notes
|
||||
## Version 0.8.7 - 18/03/2025
|
||||
### New Features
|
||||
- Made local AI free and integrated with Ollama
|
||||
- Supported nested lists within callout and quote blocks
|
||||
- Revamped the document's floating toolbar and added Turn Into
|
||||
- Enabled custom icons in callout blocks
|
||||
### Bug Fixes
|
||||
- Fixed occasional incorrect positioning of the slash menu
|
||||
- Improved AI Chat and AI Writers with various bug fixes
|
||||
- Adjusted the columns block to match the width of the editor
|
||||
- Fixed a potential segfault caused by infinite recursion in the trash view
|
||||
- Resolved an issue where the first added cover might be invisible
|
||||
- Fixed adding cover images via Unsplash
|
||||
|
||||
## Version 0.8.6 - 06/03/2025
|
||||
### Bug Fixes
|
||||
- Fix the incorrect title positioning when adjusting the document width setting
|
||||
|
||||
@@ -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.8.6"
|
||||
APPFLOWY_VERSION = "0.8.8"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
MACOSX_DEPLOYMENT_TARGET = "11.0"
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEXAIBADCCBDUGByqGSM44BAEwggQoAoICAQDlkozRmUnVH1MJFqOamAmUYu0Y
|
||||
ruaTrrt6rCIZ0LFrfNnmHA4LOQEcXwBTTyn5sBmkPq+lb/rjmERKhmvl1rfo6q7t
|
||||
J8mG4TWqSu0tOJQ6QxexnNW4yhzK/r9MS5MQus4Al+y2hQLaAMOUIOnaWIrC9OHy
|
||||
7xyw+sVipECVKyQqipS4shGUSqbcN+ocQuTB+I0MtIjBii0DGSEY3pxQrfNWjHFL
|
||||
7iTVKiTn3YOWPJQvv3FvEDrN+5xU5JZpD97ZhXaJpLUyOQaNvcPaOELPWcOSJwqH
|
||||
Opf5b5N/VZ8SGbHNdxy9d5sSChBgtuAOihEhSp6SjFQ9eVHOf4NyJwSEMmi0gpdp
|
||||
qm4ZQRJUnM2zIi0p9twR9FRYXzrxOs6yGCQEY+xFG93ShTLTj3zMrIyFqBsqEwFy
|
||||
JiJWYWe/zp0V7UlLP+jXO9u9eghNmly7QVqD2P0qs/1V0jZFRuLWpsv4inau/qMZ
|
||||
5EhGG4xCJZXfN1pkehy6e05/h+vs5anK3Wa/H8AtY6cK4CpzAanELvn3AH7VLbAh
|
||||
Lswu6d5CV+DoFgxCWMzGBSdmCYU+2wRLaL8Q9TZHDR+pvQlunEFdfFoGES9WjBPh
|
||||
AsVA6Mq22U8XSje9yHI3X9Eqe/7a+ajSgcGmB7oQ11+4xf5h2PtubRW/JL0KMjxC
|
||||
xMTpq1md6Ndx/ptBUwIdAIOyiKb2YcTLWAOt+LAlRXMsY1+W4pTXJfV6RcMCggIA
|
||||
Pxbd0HNj2O/aQhJxNZDMBIcx6+cZ+LKch7qLcaEpVqWHvDSnR2eOJJzWn0RoKK+V
|
||||
uix/4T8texSQkWxAeFFdo6kyrR9XNL7hqEFFq8o9VpmvRzvG6h/bBgh3AHAQE3p/
|
||||
8WrbK13IhnlWqd0MjFufSphm63o0gaWl95j+6KeUoKQnioetu9HiMtFKx0d/KYqT
|
||||
QJg7hvR6VNCU2oShfXR3ce7RnUYwD37+djrUjUkoAZkZq2KoxBiKyeoSIeqAme19
|
||||
tKcOs6b17mhALELuJ+NtDwlDunyiCDUYX9lTPijHwKeIFtBs38+OtRk3aIqmWTQd
|
||||
bsCzAxp+kUMA5ESBME/RBNCSPHuDvtA3wfWvNbA5DXfZLwCgNSxhekq8XntIsRzf
|
||||
J4v4uPzKFcVM3+sUUfSF04HHC9ol+PpLqXUyMnskiizqxFPq7H+6tyFZ7X2HiG6T
|
||||
jcfVWthmv+JyfcABjVnk2qFH7GagENbdtYmfUox13LhE59Sh5chaJnCFtCDp8NCl
|
||||
WgZnixCOFQ9EgTLaH6MovTvWpEgG2MfBCu5SMUHi2qSflorqpRFH+rA7NZSnyz3w
|
||||
m7NB+fJSOP0IjEkOh7MafU6Z61oK9WY/Fc+F1zIENVv8PUc3p75y/4RAp4xzyKcT
|
||||
ilaNC9U/3MRr3QmWwY7ejtZx6xdOxsvWBRDRSNbDdIkEHgIcfy0+ZHp+4MBcWSDv
|
||||
uzWeM8QmNvbP+owM+H4F7A==
|
||||
-----END PRIVATE KEY-----
|
||||
+9
-6
@@ -27,8 +27,9 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// click the align center
|
||||
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s);
|
||||
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s);
|
||||
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m);
|
||||
await tester
|
||||
.tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_center_m);
|
||||
|
||||
// expect to see the align center
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
@@ -36,13 +37,15 @@ void main() {
|
||||
expect(first.attributes[blockComponentAlign], 'center');
|
||||
|
||||
// click the align right
|
||||
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s);
|
||||
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_right_s);
|
||||
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m);
|
||||
await tester
|
||||
.tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_right_m);
|
||||
expect(first.attributes[blockComponentAlign], 'right');
|
||||
|
||||
// click the align left
|
||||
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_right_s);
|
||||
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s);
|
||||
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m);
|
||||
await tester
|
||||
.tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_left_m);
|
||||
expect(first.attributes[blockComponentAlign], 'left');
|
||||
});
|
||||
|
||||
|
||||
+6
-8
@@ -76,13 +76,12 @@ void main() {
|
||||
LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_bulletedList.tr():
|
||||
LocaleKeys.editor_bulletedListShortForm.tr():
|
||||
BulletedListBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_numberedList.tr():
|
||||
LocaleKeys.editor_numberedListShortForm.tr():
|
||||
NumberedListBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_todoList.tr():
|
||||
TodoListBlockKeys.type,
|
||||
LocaleKeys.editor_checkbox.tr(): TodoListBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type,
|
||||
};
|
||||
@@ -117,13 +116,12 @@ void main() {
|
||||
LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_bulletedList.tr():
|
||||
LocaleKeys.editor_bulletedListShortForm.tr():
|
||||
BulletedListBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_numberedList.tr():
|
||||
LocaleKeys.editor_numberedListShortForm.tr():
|
||||
NumberedListBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_todoList.tr():
|
||||
TodoListBlockKeys.type,
|
||||
LocaleKeys.editor_checkbox.tr(): TodoListBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type,
|
||||
LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type,
|
||||
};
|
||||
|
||||
+295
-15
@@ -1,5 +1,19 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart';
|
||||
import 'package:appflowy/startup/startup.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';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@@ -8,24 +22,33 @@ import '../../shared/util.dart';
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Future<void> selectText(WidgetTester tester, String text) async {
|
||||
await tester.editor.updateSelection(
|
||||
Selection.single(
|
||||
path: [0],
|
||||
startOffset: 0,
|
||||
endOffset: text.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> prepareForToolbar(WidgetTester tester, String text) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent();
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.ime.insertText(text);
|
||||
await selectText(tester, text);
|
||||
}
|
||||
|
||||
group('document toolbar:', () {
|
||||
testWidgets('font family', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent();
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
const text = 'font family';
|
||||
await tester.ime.insertText(text);
|
||||
await tester.editor.updateSelection(
|
||||
Selection.single(
|
||||
path: [0],
|
||||
startOffset: 0,
|
||||
endOffset: text.length,
|
||||
),
|
||||
);
|
||||
await prepareForToolbar(tester, 'font family');
|
||||
|
||||
// tap more options button
|
||||
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_more_m);
|
||||
// tap the font family button
|
||||
final fontFamilyButton = find.byKey(kFontFamilyToolbarItemKey);
|
||||
await tester.tapButton(fontFamilyButton);
|
||||
@@ -46,5 +69,262 @@ void main() {
|
||||
abel,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('heading 1~3', (tester) async {
|
||||
const text = 'heading';
|
||||
await prepareForToolbar(tester, text);
|
||||
|
||||
Future<void> testChangeHeading(
|
||||
FlowySvgData svg,
|
||||
String title,
|
||||
int level,
|
||||
) async {
|
||||
/// tap suggestions item
|
||||
final suggestionsButton = find.byKey(kSuggestionsItemKey);
|
||||
await tester.tapButton(suggestionsButton);
|
||||
|
||||
/// tap item
|
||||
await tester.ensureVisible(find.byFlowySvg(svg));
|
||||
await tester.tapButton(find.byFlowySvg(svg));
|
||||
|
||||
/// check the type of node is [HeadingBlockKeys.type]
|
||||
await selectText(tester, text);
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
final selection = editorState.selection!;
|
||||
final node = editorState.getNodeAtPath(selection.start.path)!,
|
||||
nodeLevel = node.attributes[HeadingBlockKeys.level]!;
|
||||
expect(node.type, HeadingBlockKeys.type);
|
||||
expect(nodeLevel, level);
|
||||
|
||||
/// show toolbar again
|
||||
await selectText(tester, text);
|
||||
|
||||
/// the text of suggestions item should be changed
|
||||
expect(
|
||||
find.descendant(of: suggestionsButton, matching: find.text(title)),
|
||||
findsOneWidget,
|
||||
);
|
||||
}
|
||||
|
||||
await testChangeHeading(
|
||||
FlowySvgs.type_h1_m,
|
||||
LocaleKeys.document_toolbar_h1.tr(),
|
||||
1,
|
||||
);
|
||||
|
||||
await testChangeHeading(
|
||||
FlowySvgs.type_h2_m,
|
||||
LocaleKeys.document_toolbar_h2.tr(),
|
||||
2,
|
||||
);
|
||||
await testChangeHeading(
|
||||
FlowySvgs.type_h3_m,
|
||||
LocaleKeys.document_toolbar_h3.tr(),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('toggle 1~3', (tester) async {
|
||||
const text = 'toggle';
|
||||
await prepareForToolbar(tester, text);
|
||||
|
||||
Future<void> testChangeToggle(
|
||||
FlowySvgData svg,
|
||||
String title,
|
||||
int? level,
|
||||
) async {
|
||||
/// tap suggestions item
|
||||
final suggestionsButton = find.byKey(kSuggestionsItemKey);
|
||||
await tester.tapButton(suggestionsButton);
|
||||
|
||||
/// tap item
|
||||
await tester.ensureVisible(find.byFlowySvg(svg));
|
||||
await tester.tapButton(find.byFlowySvg(svg));
|
||||
|
||||
/// check the type of node is [HeadingBlockKeys.type]
|
||||
await selectText(tester, text);
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
final selection = editorState.selection!;
|
||||
final node = editorState.getNodeAtPath(selection.start.path)!,
|
||||
nodeLevel = node.attributes[ToggleListBlockKeys.level];
|
||||
expect(node.type, ToggleListBlockKeys.type);
|
||||
expect(nodeLevel, level);
|
||||
|
||||
/// show toolbar again
|
||||
await selectText(tester, text);
|
||||
|
||||
/// the text of suggestions item should be changed
|
||||
expect(
|
||||
find.descendant(of: suggestionsButton, matching: find.text(title)),
|
||||
findsOneWidget,
|
||||
);
|
||||
}
|
||||
|
||||
await testChangeToggle(
|
||||
FlowySvgs.type_toggle_list_m,
|
||||
LocaleKeys.editor_toggleListShortForm.tr(),
|
||||
null,
|
||||
);
|
||||
|
||||
await testChangeToggle(
|
||||
FlowySvgs.type_toggle_h1_m,
|
||||
LocaleKeys.editor_toggleHeading1ShortForm.tr(),
|
||||
1,
|
||||
);
|
||||
|
||||
await testChangeToggle(
|
||||
FlowySvgs.type_toggle_h2_m,
|
||||
LocaleKeys.editor_toggleHeading2ShortForm.tr(),
|
||||
2,
|
||||
);
|
||||
|
||||
await testChangeToggle(
|
||||
FlowySvgs.type_toggle_h3_m,
|
||||
LocaleKeys.editor_toggleHeading3ShortForm.tr(),
|
||||
3,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('document toolbar: link', () {
|
||||
String? getLinkFromNode(Node node) {
|
||||
for (final insert in node.delta!) {
|
||||
final link = insert.attributes?.href;
|
||||
if (link != null) return link;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool isPageLink(Node node) {
|
||||
for (final insert in node.delta!) {
|
||||
final isPage = insert.attributes?.isPage;
|
||||
if (isPage == true) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String getNodeText(Node node) {
|
||||
for (final insert in node.delta!) {
|
||||
if (insert is TextInsert) return insert.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
testWidgets('insert link and remove link', (tester) async {
|
||||
const text = 'insert link', link = 'https://test.appflowy.cloud';
|
||||
await prepareForToolbar(tester, text);
|
||||
|
||||
final toolbar = find.byType(DesktopFloatingToolbar);
|
||||
expect(toolbar, findsOneWidget);
|
||||
|
||||
/// tap link button to show CreateLinkMenu
|
||||
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
|
||||
await tester.tapButton(linkButton);
|
||||
final createLinkMenu = find.byType(LinkCreateMenu);
|
||||
expect(createLinkMenu, findsOneWidget);
|
||||
|
||||
/// test esc to close
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
|
||||
expect(toolbar, findsNothing);
|
||||
|
||||
/// show toolbar again
|
||||
await selectText(tester, text);
|
||||
await tester.tapButton(linkButton);
|
||||
|
||||
/// insert link
|
||||
final textField = find.descendant(
|
||||
of: createLinkMenu,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(textField, link);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
Node node = tester.editor.getNodeAtPath([0]);
|
||||
expect(getLinkFromNode(node), link);
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
|
||||
|
||||
/// hover link
|
||||
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
|
||||
final hoverMenu = find.byType(LinkHoverMenu);
|
||||
expect(hoverMenu, findsOneWidget);
|
||||
|
||||
/// copy link
|
||||
final copyButton = find.descendant(
|
||||
of: hoverMenu,
|
||||
matching: find.byFlowySvg(FlowySvgs.toolbar_link_m),
|
||||
);
|
||||
await tester.tapButton(copyButton);
|
||||
final clipboardContent = await getIt<ClipboardService>().getData();
|
||||
final plainText = clipboardContent.plainText;
|
||||
expect(plainText, link);
|
||||
|
||||
/// remove link
|
||||
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
|
||||
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
|
||||
node = tester.editor.getNodeAtPath([0]);
|
||||
expect(getLinkFromNode(node), null);
|
||||
});
|
||||
|
||||
testWidgets('insert link and edit link', (tester) async {
|
||||
const text = 'edit link',
|
||||
link = 'https://test.appflowy.cloud',
|
||||
afterText = '$text after';
|
||||
await prepareForToolbar(tester, text);
|
||||
|
||||
/// tap link button to show CreateLinkMenu
|
||||
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
|
||||
await tester.tapButton(linkButton);
|
||||
|
||||
/// search for page and select it
|
||||
final textField = find.descendant(
|
||||
of: find.byType(LinkCreateMenu),
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(textField, gettingStarted);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
|
||||
|
||||
Node node = tester.editor.getNodeAtPath([0]);
|
||||
expect(isPageLink(node), true);
|
||||
expect(getLinkFromNode(node) == link, false);
|
||||
|
||||
/// hover link
|
||||
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
|
||||
|
||||
/// click edit button to show LinkEditMenu
|
||||
final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m);
|
||||
await tester.tapButton(editButton);
|
||||
final linkEditMenu = find.byType(LinkEditMenu);
|
||||
expect(linkEditMenu, findsOneWidget);
|
||||
|
||||
/// change the link text
|
||||
final titleField = find.descendant(
|
||||
of: linkEditMenu,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(titleField, afterText);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(
|
||||
find.descendant(of: linkEditMenu, matching: find.text(gettingStarted)),
|
||||
);
|
||||
final linkField = find.ancestor(
|
||||
of: find.text(LocaleKeys.document_toolbar_linkInputHint.tr()),
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(linkField, link);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
|
||||
/// apply the change
|
||||
final applyButton =
|
||||
find.text(LocaleKeys.settings_appearance_documentSettings_apply.tr());
|
||||
await tester.tapButton(applyButton);
|
||||
|
||||
node = tester.editor.getNodeAtPath([0]);
|
||||
expect(isPageLink(node), false);
|
||||
expect(getLinkFromNode(node), link);
|
||||
expect(getNodeText(node), afterText);
|
||||
});
|
||||
});
|
||||
}
|
||||
+26
-19
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -33,9 +34,15 @@ void main() {
|
||||
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
|
||||
);
|
||||
|
||||
// tap the more options button
|
||||
final moreOptionButton = find.findFlowyTooltip(
|
||||
LocaleKeys.document_toolbar_moreOptions.tr(),
|
||||
);
|
||||
await tester.tapButton(moreOptionButton);
|
||||
|
||||
// tap the inline math equation button
|
||||
final inlineMathEquationButton = find.findFlowyTooltip(
|
||||
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
|
||||
final inlineMathEquationButton = find.text(
|
||||
LocaleKeys.document_toolbar_equation.tr(),
|
||||
);
|
||||
await tester.tapButton(inlineMathEquationButton);
|
||||
|
||||
@@ -78,10 +85,15 @@ void main() {
|
||||
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
|
||||
);
|
||||
|
||||
// tap the inline math equation button
|
||||
var inlineMathEquationButton = find.findFlowyTooltip(
|
||||
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
|
||||
// tap the more options button
|
||||
final moreOptionButton = find.findFlowyTooltip(
|
||||
LocaleKeys.document_toolbar_moreOptions.tr(),
|
||||
);
|
||||
await tester.tapButton(moreOptionButton);
|
||||
|
||||
// tap the inline math equation button
|
||||
final inlineMathEquationButton =
|
||||
find.byFlowySvg(FlowySvgs.type_formula_m);
|
||||
await tester.tapButton(inlineMathEquationButton);
|
||||
|
||||
// expect to see the math equation block
|
||||
@@ -93,17 +105,7 @@ void main() {
|
||||
Selection.single(path: [0], startOffset: 0, endOffset: 1),
|
||||
);
|
||||
|
||||
// expect to the see the inline math equation button is highlighted
|
||||
inlineMathEquationButton = find.descendant(
|
||||
of: find.findFlowyTooltip(
|
||||
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
|
||||
),
|
||||
matching: find.byType(SVGIconItemWidget),
|
||||
);
|
||||
expect(
|
||||
tester.widget<SVGIconItemWidget>(inlineMathEquationButton).isHighlight,
|
||||
isTrue,
|
||||
);
|
||||
await tester.tapButton(moreOptionButton);
|
||||
|
||||
// cancel the format
|
||||
await tester.tapButton(inlineMathEquationButton);
|
||||
@@ -134,10 +136,15 @@ void main() {
|
||||
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
|
||||
);
|
||||
|
||||
// tap the inline math equation button
|
||||
final inlineMathEquationButton = find.findFlowyTooltip(
|
||||
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
|
||||
// tap the more options button
|
||||
final moreOptionButton = find.findFlowyTooltip(
|
||||
LocaleKeys.document_toolbar_moreOptions.tr(),
|
||||
);
|
||||
await tester.tapButton(moreOptionButton);
|
||||
|
||||
// tap the inline math equation button
|
||||
final inlineMathEquationButton =
|
||||
find.byFlowySvg(FlowySvgs.type_formula_m);
|
||||
await tester.tapButton(inlineMathEquationButton);
|
||||
|
||||
// expect to see the math equation block
|
||||
|
||||
+3
-8
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -85,16 +86,10 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tapButton(find.byType(HeadingPopup));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byType(HeadingButton),
|
||||
findsNWidgets(3),
|
||||
);
|
||||
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_text_format_m));
|
||||
|
||||
// tap the H1 button
|
||||
await tester.tapButton(find.byType(HeadingButton).at(0));
|
||||
await tester.tapButton(find.byFlowySvg(FlowySvgs.type_h1_m).at(0));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
|
||||
+112
@@ -1,8 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/plugins/emoji/emoji_handler.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
@@ -39,4 +41,114 @@ void main() {
|
||||
expect(find.byType(EmojiSelectionMenu), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('insert emoji by colon', () {
|
||||
Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent();
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.ime.insertText(':');
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
testWidgets('insert with click', (tester) async {
|
||||
await createNewDocumentAndShowEmojiList(tester);
|
||||
|
||||
/// emoji list is showing
|
||||
final emojiHandler = find.byType(EmojiHandler);
|
||||
expect(emojiHandler, findsOneWidget);
|
||||
final emojiButtons =
|
||||
find.descendant(of: emojiHandler, matching: find.byType(FlowyButton));
|
||||
final firstTextFinder = find.descendant(
|
||||
of: emojiButtons.first,
|
||||
matching: find.byType(FlowyText),
|
||||
);
|
||||
final emojiText =
|
||||
(firstTextFinder.evaluate().first.widget as FlowyText).text;
|
||||
|
||||
/// click first emoji item
|
||||
await tester.tapButton(emojiButtons.first);
|
||||
final firstNode =
|
||||
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
|
||||
/// except the emoji is in document
|
||||
expect(emojiText.contains(firstNode.delta!.toPlainText()), true);
|
||||
});
|
||||
|
||||
testWidgets('insert with arrow and enter', (tester) async {
|
||||
await createNewDocumentAndShowEmojiList(tester);
|
||||
|
||||
/// emoji list is showing
|
||||
final emojiHandler = find.byType(EmojiHandler);
|
||||
expect(emojiHandler, findsOneWidget);
|
||||
final emojiButtons =
|
||||
find.descendant(of: emojiHandler, matching: find.byType(FlowyButton));
|
||||
|
||||
/// tap arrow down and arrow up
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
|
||||
final firstTextFinder = find.descendant(
|
||||
of: emojiButtons.first,
|
||||
matching: find.byType(FlowyText),
|
||||
);
|
||||
final emojiText =
|
||||
(firstTextFinder.evaluate().first.widget as FlowyText).text;
|
||||
|
||||
/// tap enter
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
final firstNode =
|
||||
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
|
||||
/// except the emoji is in document
|
||||
expect(emojiText.contains(firstNode.delta!.toPlainText()), true);
|
||||
});
|
||||
|
||||
testWidgets('insert with searching', (tester) async {
|
||||
await createNewDocumentAndShowEmojiList(tester);
|
||||
|
||||
/// search for `smiling eyes`, IME is not working, use keyboard input
|
||||
final searchText = [
|
||||
LogicalKeyboardKey.keyS,
|
||||
LogicalKeyboardKey.keyM,
|
||||
LogicalKeyboardKey.keyI,
|
||||
LogicalKeyboardKey.keyL,
|
||||
LogicalKeyboardKey.keyI,
|
||||
LogicalKeyboardKey.keyN,
|
||||
LogicalKeyboardKey.keyG,
|
||||
LogicalKeyboardKey.space,
|
||||
LogicalKeyboardKey.keyE,
|
||||
LogicalKeyboardKey.keyY,
|
||||
LogicalKeyboardKey.keyE,
|
||||
LogicalKeyboardKey.keyS,
|
||||
];
|
||||
|
||||
for (final key in searchText) {
|
||||
await tester.simulateKeyEvent(key);
|
||||
}
|
||||
|
||||
/// tap enter
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
final firstNode =
|
||||
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
|
||||
/// except the emoji is in document
|
||||
expect(firstNode.delta!.toPlainText().contains('😄'), true);
|
||||
});
|
||||
|
||||
testWidgets('start searching with sapce', (tester) async {
|
||||
await createNewDocumentAndShowEmojiList(tester);
|
||||
|
||||
/// emoji list is showing
|
||||
final emojiHandler = find.byType(EmojiHandler);
|
||||
expect(emojiHandler, findsOneWidget);
|
||||
|
||||
/// input space
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.space);
|
||||
|
||||
/// emoji list is dismissed
|
||||
expect(emojiHandler, findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
-1
@@ -13,7 +13,6 @@ void main() {
|
||||
hotkeys_test.main();
|
||||
emoji_shortcut_test.main();
|
||||
hotkeys_test.main();
|
||||
emoji_shortcut_test.main();
|
||||
share_markdown_test.main();
|
||||
import_files_test.main();
|
||||
zoom_in_out_test.main();
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart';
|
||||
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
|
||||
import 'package:appflowy/plugins/database/board/presentation/board_page.dart';
|
||||
import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart';
|
||||
import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart';
|
||||
@@ -27,10 +19,11 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/choicechip.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/footer/grid_footer.dart';
|
||||
@@ -44,6 +37,7 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filt
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart';
|
||||
import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_add_button.dart';
|
||||
import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart';
|
||||
@@ -76,6 +70,8 @@ import 'package:appflowy/plugins/database/widgets/setting/database_setting_actio
|
||||
import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/util/field_type_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart';
|
||||
@@ -90,6 +86,9 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text_input.dart';
|
||||
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
// Non-exported member of the table_calendar library
|
||||
@@ -1572,7 +1571,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
of: textField,
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is FlowySvg && widget.svg == FlowySvgs.close_filled_m,
|
||||
widget is FlowySvg && widget.svg == FlowySvgs.close_filled_s,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -181,37 +181,37 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874
|
||||
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
|
||||
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
|
||||
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
|
||||
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
|
||||
appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88
|
||||
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
|
||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
||||
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
|
||||
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
|
||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038
|
||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
|
||||
keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05
|
||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
|
||||
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
|
||||
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
|
||||
saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87
|
||||
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
|
||||
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
|
||||
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
|
||||
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
|
||||
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
|
||||
|
||||
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ export 'service/ai_entities.dart';
|
||||
export 'service/ai_prompt_input_bloc.dart';
|
||||
export 'service/appflowy_ai_service.dart';
|
||||
export 'service/error.dart';
|
||||
export 'service/ai_model_state_notifier.dart';
|
||||
export 'service/select_model_bloc.dart';
|
||||
export 'widgets/loading_indicator.dart';
|
||||
export 'widgets/prompt_input/action_buttons.dart';
|
||||
export 'widgets/prompt_input/desktop_prompt_text_field.dart';
|
||||
@@ -13,4 +15,5 @@ export 'widgets/prompt_input/mentioned_page_text_span.dart';
|
||||
export 'widgets/prompt_input/predefined_format_buttons.dart';
|
||||
export 'widgets/prompt_input/select_sources_bottom_sheet.dart';
|
||||
export 'widgets/prompt_input/select_sources_menu.dart';
|
||||
export 'widgets/prompt_input/select_model_menu.dart';
|
||||
export 'widgets/prompt_input/send_button.dart';
|
||||
@@ -4,6 +4,28 @@ import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class AIStreamEventPrefix {
|
||||
static const data = 'data:';
|
||||
static const error = 'error:';
|
||||
static const metadata = 'metadata:';
|
||||
static const start = 'start:';
|
||||
static const finish = 'finish:';
|
||||
static const comment = 'comment:';
|
||||
static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
|
||||
static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
|
||||
static const aiMaxRequired = 'AI_MAX_REQUIRED:';
|
||||
static const localAINotReady = 'LOCAL_AI_NOT_READY';
|
||||
static const localAIDisabled = 'LOCAL_AI_DISABLED';
|
||||
}
|
||||
|
||||
enum AiType {
|
||||
cloud,
|
||||
local;
|
||||
|
||||
bool get isCloud => this == cloud;
|
||||
bool get isLocal => this == local;
|
||||
}
|
||||
|
||||
class PredefinedFormat extends Equatable {
|
||||
const PredefinedFormat({
|
||||
required this.imageFormat,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:protobuf/protobuf.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
typedef OnModelStateChangedCallback = void Function(AiType, bool, String);
|
||||
typedef OnAvailableModelsChangedCallback = void Function(
|
||||
List<AIModelPB>,
|
||||
AIModelPB?,
|
||||
);
|
||||
|
||||
class AIModelStateNotifier {
|
||||
AIModelStateNotifier({required this.objectId})
|
||||
: _localAIListener =
|
||||
UniversalPlatform.isDesktop ? LocalAIStateListener() : null,
|
||||
_aiModelSwitchListener = AIModelSwitchListener(objectId: objectId) {
|
||||
_startListening();
|
||||
_init();
|
||||
}
|
||||
|
||||
final String objectId;
|
||||
final LocalAIStateListener? _localAIListener;
|
||||
final AIModelSwitchListener _aiModelSwitchListener;
|
||||
LocalAIPB? _localAIState;
|
||||
AvailableModelsPB? _availableModels;
|
||||
|
||||
// callbacks
|
||||
final List<OnModelStateChangedCallback> _stateChangedCallbacks = [];
|
||||
final List<OnAvailableModelsChangedCallback>
|
||||
_availableModelsChangedCallbacks = [];
|
||||
|
||||
void _startListening() {
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
_localAIListener?.start(
|
||||
stateCallback: (state) async {
|
||||
_localAIState = state;
|
||||
_notifyStateChanged();
|
||||
|
||||
if (state.state == RunningStatePB.Running ||
|
||||
state.state == RunningStatePB.Stopped) {
|
||||
await _loadAvailableModels();
|
||||
_notifyAvailableModelsChanged();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_aiModelSwitchListener.start(
|
||||
onUpdateSelectedModel: (model) async {
|
||||
final updatedModels = _availableModels?.deepCopy()
|
||||
?..selectedModel = model;
|
||||
_availableModels = updatedModels;
|
||||
_notifyAvailableModelsChanged();
|
||||
|
||||
if (model.isLocal && UniversalPlatform.isDesktop) {
|
||||
await _loadLocalAiState();
|
||||
}
|
||||
_notifyStateChanged();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _init() async {
|
||||
await Future.wait([_loadLocalAiState(), _loadAvailableModels()]);
|
||||
_notifyStateChanged();
|
||||
_notifyAvailableModelsChanged();
|
||||
}
|
||||
|
||||
void addListener({
|
||||
OnModelStateChangedCallback? onStateChanged,
|
||||
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
|
||||
}) {
|
||||
if (onStateChanged != null) {
|
||||
_stateChangedCallbacks.add(onStateChanged);
|
||||
}
|
||||
if (onAvailableModelsChanged != null) {
|
||||
_availableModelsChangedCallbacks.add(onAvailableModelsChanged);
|
||||
}
|
||||
}
|
||||
|
||||
void removeListener({
|
||||
OnModelStateChangedCallback? onStateChanged,
|
||||
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
|
||||
}) {
|
||||
if (onStateChanged != null) {
|
||||
_stateChangedCallbacks.remove(onStateChanged);
|
||||
}
|
||||
if (onAvailableModelsChanged != null) {
|
||||
_availableModelsChangedCallbacks.remove(onAvailableModelsChanged);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_stateChangedCallbacks.clear();
|
||||
_availableModelsChangedCallbacks.clear();
|
||||
await _localAIListener?.stop();
|
||||
await _aiModelSwitchListener.stop();
|
||||
}
|
||||
|
||||
(AiType, String, bool) getState() {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
|
||||
}
|
||||
|
||||
final availableModels = _availableModels;
|
||||
final localAiState = _localAIState;
|
||||
|
||||
if (availableModels == null) {
|
||||
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
|
||||
}
|
||||
if (localAiState == null) {
|
||||
Log.warn("Cannot get local AI state");
|
||||
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
|
||||
}
|
||||
|
||||
if (!availableModels.selectedModel.isLocal) {
|
||||
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
|
||||
}
|
||||
|
||||
final editable = localAiState.state == RunningStatePB.Running;
|
||||
final hintText = editable
|
||||
? LocaleKeys.chat_inputLocalAIMessageHint.tr()
|
||||
: LocaleKeys.settings_aiPage_keys_localAIInitializing.tr();
|
||||
|
||||
return (AiType.local, hintText, editable);
|
||||
}
|
||||
|
||||
(List<AIModelPB>, AIModelPB?) getAvailableModels() {
|
||||
final availableModels = _availableModels;
|
||||
if (availableModels == null) {
|
||||
return ([], null);
|
||||
}
|
||||
return (availableModels.models, availableModels.selectedModel);
|
||||
}
|
||||
|
||||
void _notifyAvailableModelsChanged() {
|
||||
final (models, selectedModel) = getAvailableModels();
|
||||
for (final callback in _availableModelsChangedCallbacks) {
|
||||
callback(models, selectedModel);
|
||||
}
|
||||
}
|
||||
|
||||
void _notifyStateChanged() {
|
||||
final (type, hintText, isEditable) = getState();
|
||||
for (final callback in _stateChangedCallbacks) {
|
||||
callback(type, isEditable, hintText);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAvailableModels() {
|
||||
final payload = AvailableModelsQueryPB(source: objectId);
|
||||
return AIEventGetAvailableModels(payload).send().fold(
|
||||
(models) => _availableModels = models,
|
||||
(err) => Log.error("Failed to get available models: $err"),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadLocalAiState() {
|
||||
return AIEventGetLocalAIState().send().fold(
|
||||
(localAIState) => _localAIState = localAIState,
|
||||
(error) => Log.error("Failed to get local AI state: $error"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension AiModelExtension on AIModelPB {
|
||||
bool get isDefault {
|
||||
return name == "Auto";
|
||||
}
|
||||
|
||||
String get i18n {
|
||||
return isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/ai/service/ai_model_state_notifier.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
@@ -16,19 +12,20 @@ part 'ai_prompt_input_bloc.freezed.dart';
|
||||
|
||||
class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
AIPromptInputBloc({
|
||||
required String objectId,
|
||||
required PredefinedFormat? predefinedFormat,
|
||||
}) : _listener = LocalLLMListener(),
|
||||
}) : aiModelStateNotifier = AIModelStateNotifier(objectId: objectId),
|
||||
super(AIPromptInputState.initial(predefinedFormat)) {
|
||||
_dispatch();
|
||||
_startListening();
|
||||
_init();
|
||||
}
|
||||
|
||||
final LocalLLMListener _listener;
|
||||
final AIModelStateNotifier aiModelStateNotifier;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _listener.stop();
|
||||
await aiModelStateNotifier.dispose();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
@@ -36,42 +33,19 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
on<AIPromptInputEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
updateChatState: (LocalAIChatPB chatState) {
|
||||
// Only user enable chat with file and the plugin is already running
|
||||
final supportChatWithFile = chatState.fileEnabled &&
|
||||
chatState.pluginState.state == RunningStatePB.Running;
|
||||
|
||||
final aiType = chatState.pluginState.state == RunningStatePB.Running
|
||||
? AIType.localAI
|
||||
: AIType.appflowyAI;
|
||||
|
||||
updateAIState: (aiType, editable, hintText) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
aiType: aiType,
|
||||
supportChatWithFile: supportChatWithFile,
|
||||
chatState: chatState,
|
||||
),
|
||||
);
|
||||
},
|
||||
updatePluginState: (LocalAIPluginStatePB chatState) {
|
||||
final fileEnabled = state.chatState?.fileEnabled ?? false;
|
||||
final supportChatWithFile =
|
||||
fileEnabled && chatState.state == RunningStatePB.Running;
|
||||
|
||||
final aiType = chatState.state == RunningStatePB.Running
|
||||
? AIType.localAI
|
||||
: AIType.appflowyAI;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
supportChatWithFile: supportChatWithFile,
|
||||
aiType: aiType,
|
||||
editable: editable,
|
||||
hintText: hintText,
|
||||
),
|
||||
);
|
||||
},
|
||||
toggleShowPredefinedFormat: () {
|
||||
final showPredefinedFormats = !state.showPredefinedFormats;
|
||||
final predefinedFormat =
|
||||
!state.showPredefinedFormats && state.predefinedFormat == null
|
||||
showPredefinedFormats && state.predefinedFormat == null
|
||||
? PredefinedFormat(
|
||||
imageFormat: ImageFormat.text,
|
||||
textFormat: TextFormat.paragraph,
|
||||
@@ -79,12 +53,15 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
: null;
|
||||
emit(
|
||||
state.copyWith(
|
||||
showPredefinedFormats: !state.showPredefinedFormats,
|
||||
showPredefinedFormats: showPredefinedFormats,
|
||||
predefinedFormat: predefinedFormat,
|
||||
),
|
||||
);
|
||||
},
|
||||
updatePredefinedFormat: (format) {
|
||||
if (!state.showPredefinedFormats) {
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(predefinedFormat: format));
|
||||
},
|
||||
attachFile: (filePath, fileName) {
|
||||
@@ -127,29 +104,16 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
}
|
||||
|
||||
void _startListening() {
|
||||
_listener.start(
|
||||
stateCallback: (pluginState) {
|
||||
if (!isClosed) {
|
||||
add(AIPromptInputEvent.updatePluginState(pluginState));
|
||||
}
|
||||
},
|
||||
chatStateCallback: (chatState) {
|
||||
if (!isClosed) {
|
||||
add(AIPromptInputEvent.updateChatState(chatState));
|
||||
}
|
||||
aiModelStateNotifier.addListener(
|
||||
onStateChanged: (aiType, editable, hintText) {
|
||||
add(AIPromptInputEvent.updateAIState(aiType, editable, hintText));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _init() {
|
||||
AIEventGetLocalAIChatState().send().fold(
|
||||
(chatState) {
|
||||
if (!isClosed) {
|
||||
add(AIPromptInputEvent.updateChatState(chatState));
|
||||
}
|
||||
},
|
||||
Log.error,
|
||||
);
|
||||
final (aiType, hintText, isEditable) = aiModelStateNotifier.getState();
|
||||
add(AIPromptInputEvent.updateAIState(aiType, isEditable, hintText));
|
||||
}
|
||||
|
||||
Map<String, dynamic> consumeMetadata() {
|
||||
@@ -168,12 +132,12 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
|
||||
@freezed
|
||||
class AIPromptInputEvent with _$AIPromptInputEvent {
|
||||
const factory AIPromptInputEvent.updateChatState(
|
||||
LocalAIChatPB chatState,
|
||||
) = _UpdateChatState;
|
||||
const factory AIPromptInputEvent.updatePluginState(
|
||||
LocalAIPluginStatePB chatState,
|
||||
) = _UpdatePluginState;
|
||||
const factory AIPromptInputEvent.updateAIState(
|
||||
AiType aiType,
|
||||
bool editable,
|
||||
String hintText,
|
||||
) = _UpdateAIState;
|
||||
|
||||
const factory AIPromptInputEvent.toggleShowPredefinedFormat() =
|
||||
_ToggleShowPredefinedFormat;
|
||||
const factory AIPromptInputEvent.updatePredefinedFormat(
|
||||
@@ -192,30 +156,25 @@ class AIPromptInputEvent with _$AIPromptInputEvent {
|
||||
@freezed
|
||||
class AIPromptInputState with _$AIPromptInputState {
|
||||
const factory AIPromptInputState({
|
||||
required AIType aiType,
|
||||
required AiType aiType,
|
||||
required bool supportChatWithFile,
|
||||
required bool showPredefinedFormats,
|
||||
required PredefinedFormat? predefinedFormat,
|
||||
required LocalAIChatPB? chatState,
|
||||
required List<ChatFile> attachedFiles,
|
||||
required List<ViewPB> mentionedPages,
|
||||
required bool editable,
|
||||
required String hintText,
|
||||
}) = _AIPromptInputState;
|
||||
|
||||
factory AIPromptInputState.initial(PredefinedFormat? format) =>
|
||||
AIPromptInputState(
|
||||
aiType: AIType.appflowyAI,
|
||||
aiType: AiType.cloud,
|
||||
supportChatWithFile: false,
|
||||
showPredefinedFormats: format != null,
|
||||
predefinedFormat: format,
|
||||
chatState: null,
|
||||
attachedFiles: [],
|
||||
mentionedPages: [],
|
||||
editable: true,
|
||||
hintText: '',
|
||||
);
|
||||
}
|
||||
|
||||
enum AIType {
|
||||
appflowyAI,
|
||||
localAI;
|
||||
|
||||
bool get isLocalAI => this == localAI;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
|
||||
import 'package:appflowy/shared/list_extension.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
@@ -14,16 +15,26 @@ import 'package:fixnum/fixnum.dart' as fixnum;
|
||||
import 'ai_entities.dart';
|
||||
import 'error.dart';
|
||||
|
||||
enum LocalAIStreamingState {
|
||||
notReady,
|
||||
disabled,
|
||||
}
|
||||
|
||||
abstract class AIRepository {
|
||||
Future<void> streamCompletion({
|
||||
String? objectId,
|
||||
required String text,
|
||||
PredefinedFormat? format,
|
||||
List<String> sourceIds = const [],
|
||||
List<AiWriterRecord> history = const [],
|
||||
required CompletionTypePB completionType,
|
||||
required Future<void> Function() onStart,
|
||||
required Future<void> Function(String text) onProcess,
|
||||
required Future<void> Function(String text) processMessage,
|
||||
required Future<void> Function(String text) processAssistMessage,
|
||||
required Future<void> Function() onEnd,
|
||||
required void Function(AIError error) onError,
|
||||
required void Function(LocalAIStreamingState state)
|
||||
onLocalAIStreamingStateChange,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,19 +45,27 @@ class AppFlowyAIService implements AIRepository {
|
||||
required String text,
|
||||
PredefinedFormat? format,
|
||||
List<String> sourceIds = const [],
|
||||
List<AiWriterRecord> history = const [],
|
||||
required CompletionTypePB completionType,
|
||||
required Future<void> Function() onStart,
|
||||
required Future<void> Function(String text) onProcess,
|
||||
required Future<void> Function(String text) processMessage,
|
||||
required Future<void> Function(String text) processAssistMessage,
|
||||
required Future<void> Function() onEnd,
|
||||
required void Function(AIError error) onError,
|
||||
required void Function(LocalAIStreamingState state)
|
||||
onLocalAIStreamingStateChange,
|
||||
}) async {
|
||||
final stream = AppFlowyCompletionStream(
|
||||
onStart: onStart,
|
||||
onProcess: onProcess,
|
||||
processMessage: processMessage,
|
||||
processAssistMessage: processAssistMessage,
|
||||
processError: onError,
|
||||
onLocalAIStreamingStateChange: onLocalAIStreamingStateChange,
|
||||
onEnd: onEnd,
|
||||
onError: onError,
|
||||
);
|
||||
|
||||
final records = history.map((record) => record.toPB()).toList();
|
||||
|
||||
final payload = CompleteTextPB(
|
||||
text: text,
|
||||
completionType: completionType,
|
||||
@@ -57,6 +76,7 @@ class AppFlowyAIService implements AIRepository {
|
||||
if (objectId != null) objectId,
|
||||
...sourceIds,
|
||||
].unique(),
|
||||
history: records,
|
||||
);
|
||||
|
||||
return AIEventCompleteText(payload).send().fold(
|
||||
@@ -72,23 +92,30 @@ class AppFlowyAIService implements AIRepository {
|
||||
abstract class CompletionStream {
|
||||
CompletionStream({
|
||||
required this.onStart,
|
||||
required this.onProcess,
|
||||
required this.processMessage,
|
||||
required this.processAssistMessage,
|
||||
required this.processError,
|
||||
required this.onLocalAIStreamingStateChange,
|
||||
required this.onEnd,
|
||||
required this.onError,
|
||||
});
|
||||
|
||||
final Future<void> Function() onStart;
|
||||
final Future<void> Function(String text) onProcess;
|
||||
final Future<void> Function(String text) processMessage;
|
||||
final Future<void> Function(String text) processAssistMessage;
|
||||
final void Function(AIError error) processError;
|
||||
final void Function(LocalAIStreamingState state)
|
||||
onLocalAIStreamingStateChange;
|
||||
final Future<void> Function() onEnd;
|
||||
final void Function(AIError error) onError;
|
||||
}
|
||||
|
||||
class AppFlowyCompletionStream extends CompletionStream {
|
||||
AppFlowyCompletionStream({
|
||||
required super.onStart,
|
||||
required super.onProcess,
|
||||
required super.processMessage,
|
||||
required super.processAssistMessage,
|
||||
required super.processError,
|
||||
required super.onEnd,
|
||||
required super.onError,
|
||||
required super.onLocalAIStreamingStateChange,
|
||||
}) {
|
||||
_startListening();
|
||||
}
|
||||
@@ -102,51 +129,7 @@ class AppFlowyCompletionStream extends CompletionStream {
|
||||
_port.handler = _controller.add;
|
||||
_subscription = _controller.stream.listen(
|
||||
(event) async {
|
||||
if (event == "AI_RESPONSE_LIMIT") {
|
||||
onError(
|
||||
AIError(
|
||||
message: LocaleKeys.ai_textLimitReachedDescription.tr(),
|
||||
code: AIErrorCode.aiResponseLimitExceeded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (event == "AI_IMAGE_RESPONSE_LIMIT") {
|
||||
onError(
|
||||
AIError(
|
||||
message: LocaleKeys.ai_imageLimitReachedDescription.tr(),
|
||||
code: AIErrorCode.aiImageResponseLimitExceeded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (event.startsWith("AI_MAX_REQUIRED:")) {
|
||||
final msg = event.substring(16);
|
||||
onError(
|
||||
AIError(
|
||||
message: msg,
|
||||
code: AIErrorCode.other,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (event.startsWith("start:")) {
|
||||
await onStart();
|
||||
}
|
||||
|
||||
if (event.startsWith("data:")) {
|
||||
await onProcess(event.substring(5));
|
||||
}
|
||||
|
||||
if (event.startsWith("finish:")) {
|
||||
await onEnd();
|
||||
}
|
||||
|
||||
if (event.startsWith("error:")) {
|
||||
onError(
|
||||
AIError(message: event.substring(6), code: AIErrorCode.other),
|
||||
);
|
||||
}
|
||||
await _handleEvent(event);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -156,4 +139,66 @@ class AppFlowyCompletionStream extends CompletionStream {
|
||||
await _subscription.cancel();
|
||||
_port.close();
|
||||
}
|
||||
|
||||
Future<void> _handleEvent(String event) async {
|
||||
// Check simple matches first
|
||||
if (event == AIStreamEventPrefix.aiResponseLimit) {
|
||||
processError(
|
||||
AIError(
|
||||
message: LocaleKeys.ai_textLimitReachedDescription.tr(),
|
||||
code: AIErrorCode.aiResponseLimitExceeded,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event == AIStreamEventPrefix.aiImageResponseLimit) {
|
||||
processError(
|
||||
AIError(
|
||||
message: LocaleKeys.ai_imageLimitReachedDescription.tr(),
|
||||
code: AIErrorCode.aiImageResponseLimitExceeded,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, parse out prefix:content
|
||||
if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) {
|
||||
processError(
|
||||
AIError(
|
||||
message: event.substring(AIStreamEventPrefix.aiMaxRequired.length),
|
||||
code: AIErrorCode.other,
|
||||
),
|
||||
);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.start)) {
|
||||
await onStart();
|
||||
} else if (event.startsWith(AIStreamEventPrefix.data)) {
|
||||
await processMessage(
|
||||
event.substring(AIStreamEventPrefix.data.length),
|
||||
);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.comment)) {
|
||||
await processAssistMessage(
|
||||
event.substring(AIStreamEventPrefix.comment.length),
|
||||
);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.finish)) {
|
||||
await onEnd();
|
||||
} else if (event.startsWith(AIStreamEventPrefix.localAIDisabled)) {
|
||||
onLocalAIStreamingStateChange(
|
||||
LocalAIStreamingState.disabled,
|
||||
);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) {
|
||||
onLocalAIStreamingStateChange(
|
||||
LocalAIStreamingState.notReady,
|
||||
);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.error)) {
|
||||
processError(
|
||||
AIError(
|
||||
message: event.substring(AIStreamEventPrefix.error.length),
|
||||
code: AIErrorCode.other,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Log.debug('Unknown AI event: $event');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/ai/service/ai_model_state_notifier.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbserver.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'select_model_bloc.freezed.dart';
|
||||
|
||||
class SelectModelBloc extends Bloc<SelectModelEvent, SelectModelState> {
|
||||
SelectModelBloc({
|
||||
required AIModelStateNotifier aiModelStateNotifier,
|
||||
}) : _aiModelStateNotifier = aiModelStateNotifier,
|
||||
super(SelectModelState.initial(aiModelStateNotifier)) {
|
||||
on<SelectModelEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
selectModel: (model) {
|
||||
AIEventUpdateSelectedModel(
|
||||
UpdateSelectedModelPB(
|
||||
source: _aiModelStateNotifier.objectId,
|
||||
selectedModel: model,
|
||||
),
|
||||
).send();
|
||||
|
||||
emit(state.copyWith(selectedModel: model));
|
||||
},
|
||||
didLoadModels: (models, selectedModel) {
|
||||
emit(
|
||||
SelectModelState(
|
||||
models: models,
|
||||
selectedModel: selectedModel,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
_aiModelStateNotifier.addListener(
|
||||
onAvailableModelsChanged: _onAvailableModelsChanged,
|
||||
);
|
||||
}
|
||||
|
||||
final AIModelStateNotifier _aiModelStateNotifier;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
_aiModelStateNotifier.removeListener(
|
||||
onAvailableModelsChanged: _onAvailableModelsChanged,
|
||||
);
|
||||
await super.close();
|
||||
}
|
||||
|
||||
void _onAvailableModelsChanged(
|
||||
List<AIModelPB> models,
|
||||
AIModelPB? selectedModel,
|
||||
) {
|
||||
if (!isClosed) {
|
||||
add(SelectModelEvent.didLoadModels(models, selectedModel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SelectModelEvent with _$SelectModelEvent {
|
||||
const factory SelectModelEvent.selectModel(
|
||||
AIModelPB model,
|
||||
) = _SelectModel;
|
||||
|
||||
const factory SelectModelEvent.didLoadModels(
|
||||
List<AIModelPB> models,
|
||||
AIModelPB? selectedModel,
|
||||
) = _DidLoadModels;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SelectModelState with _$SelectModelState {
|
||||
const factory SelectModelState({
|
||||
required List<AIModelPB> models,
|
||||
required AIModelPB? selectedModel,
|
||||
}) = _SelectModelState;
|
||||
|
||||
factory SelectModelState.initial(AIModelStateNotifier notifier) {
|
||||
final (models, selectedModel) = notifier.getAvailableModels();
|
||||
return SelectModelState(
|
||||
models: models,
|
||||
selectedModel: selectedModel,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,48 +16,50 @@ class AILoadingIndicator 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(
|
||||
text,
|
||||
color: Theme.of(context).hintColor,
|
||||
return SelectionContainer.disabled(
|
||||
child: SizedBox(
|
||||
height: 20,
|
||||
child: SeparatedRow(
|
||||
separatorBuilder: () => const HSpace(4),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 4.0),
|
||||
child: FlowyText(
|
||||
text,
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+81
-48
@@ -17,20 +17,24 @@ class DesktopPromptInput extends StatefulWidget {
|
||||
const DesktopPromptInput({
|
||||
super.key,
|
||||
required this.isStreaming,
|
||||
required this.textController,
|
||||
required this.onStopStreaming,
|
||||
required this.onSubmitted,
|
||||
required this.selectedSourcesNotifier,
|
||||
required this.onUpdateSelectedSources,
|
||||
this.hideDecoration = false,
|
||||
this.extraBottomActionButton,
|
||||
});
|
||||
|
||||
final bool isStreaming;
|
||||
final TextEditingController textController;
|
||||
final void Function() onStopStreaming;
|
||||
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
|
||||
onSubmitted;
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
final bool hideDecoration;
|
||||
final Widget? extraBottomActionButton;
|
||||
|
||||
@override
|
||||
State<DesktopPromptInput> createState() => _DesktopPromptInputState();
|
||||
@@ -42,7 +46,6 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
final overlayController = OverlayPortalController();
|
||||
final inputControlCubit = ChatInputControlCubit();
|
||||
final focusNode = FocusNode();
|
||||
final textController = TextEditingController();
|
||||
|
||||
late SendButtonState sendButtonState;
|
||||
bool isComposing = false;
|
||||
@@ -51,18 +54,19 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
textController.addListener(handleTextControllerChanged);
|
||||
|
||||
focusNode.addListener(
|
||||
() {
|
||||
if (!widget.hideDecoration) {
|
||||
setState(() {}); // refresh border color
|
||||
}
|
||||
if (!focusNode.hasFocus) {
|
||||
cancelMentionPage(); // hide menu when lost focus
|
||||
}
|
||||
},
|
||||
);
|
||||
widget.textController.addListener(handleTextControllerChanged);
|
||||
focusNode
|
||||
..addListener(
|
||||
() {
|
||||
if (!widget.hideDecoration) {
|
||||
setState(() {}); // refresh border color
|
||||
}
|
||||
if (!focusNode.hasFocus) {
|
||||
cancelMentionPage(); // hide menu when lost focus
|
||||
}
|
||||
},
|
||||
)
|
||||
..onKeyEvent = handleKeyEvent;
|
||||
|
||||
updateSendButtonState();
|
||||
|
||||
@@ -80,7 +84,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
textController.dispose();
|
||||
widget.textController.removeListener(handleTextControllerChanged);
|
||||
inputControlCubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -105,7 +109,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
overlayChildBuilder: (context) {
|
||||
return PromptInputMentionPageMenu(
|
||||
anchor: PromptInputAnchor(textFieldKey, layerLink),
|
||||
textController: textController,
|
||||
textController: widget.textController,
|
||||
onPageSelected: handlePageSelected,
|
||||
);
|
||||
},
|
||||
@@ -148,14 +152,13 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
start: 8.0,
|
||||
),
|
||||
child: ChangeFormatBar(
|
||||
showImageFormats: state.aiType.isCloud,
|
||||
predefinedFormat: state.predefinedFormat,
|
||||
spacing: 4.0,
|
||||
onSelectPredefinedFormat: (format) =>
|
||||
context.read<AIPromptInputBloc>().add(
|
||||
AIPromptInputEvent
|
||||
.updatePredefinedFormat(
|
||||
format,
|
||||
),
|
||||
.updatePredefinedFormat(format),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -180,6 +183,8 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
widget.selectedSourcesNotifier,
|
||||
onUpdateSelectedSources:
|
||||
widget.onUpdateSelectedSources,
|
||||
extraBottomActionButton:
|
||||
widget.extraBottomActionButton,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -218,12 +223,12 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
if (!focusNode.hasFocus) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
textController.text += '@';
|
||||
widget.textController.text += '@';
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) {
|
||||
context
|
||||
.read<ChatInputControlCubit>()
|
||||
.startSearching(textController.value);
|
||||
.startSearching(widget.textController.value);
|
||||
overlayController.show();
|
||||
}
|
||||
});
|
||||
@@ -239,7 +244,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
void updateSendButtonState() {
|
||||
if (widget.isStreaming) {
|
||||
sendButtonState = SendButtonState.streaming;
|
||||
} else if (textController.text.trim().isEmpty) {
|
||||
} else if (widget.textController.text.trim().isEmpty) {
|
||||
sendButtonState = SendButtonState.disabled;
|
||||
} else {
|
||||
sendButtonState = SendButtonState.enabled;
|
||||
@@ -251,9 +256,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
return;
|
||||
}
|
||||
final trimmedText = inputControlCubit.formatIntputText(
|
||||
textController.text.trim(),
|
||||
widget.textController.text.trim(),
|
||||
);
|
||||
textController.clear();
|
||||
widget.textController.clear();
|
||||
if (trimmedText.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@@ -276,17 +281,17 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
setState(() {
|
||||
// update whether send button is clickable
|
||||
updateSendButtonState();
|
||||
isComposing = !textController.value.composing.isCollapsed;
|
||||
isComposing = !widget.textController.value.composing.isCollapsed;
|
||||
});
|
||||
|
||||
if (isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// handle text and selection changes ONLY when mentioning a page
|
||||
|
||||
// disable mention
|
||||
return;
|
||||
|
||||
// handle text and selection changes ONLY when mentioning a page
|
||||
// ignore: dead_code
|
||||
if (!overlayController.isShowing ||
|
||||
inputControlCubit.filterStartPosition == -1) {
|
||||
@@ -294,6 +299,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
}
|
||||
|
||||
// handle cases where mention a page is cancelled
|
||||
final textController = widget.textController;
|
||||
final textSelection = textController.value.selection;
|
||||
final isSelectingMultipleCharacters = !textSelection.isCollapsed;
|
||||
final isCaretBeforeStartOfRange =
|
||||
@@ -340,22 +346,27 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
}
|
||||
|
||||
KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event.character == '@') {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
inputControlCubit.startSearching(textController.value);
|
||||
overlayController.show();
|
||||
});
|
||||
// if (event.character == '@') {
|
||||
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// inputControlCubit.startSearching(widget.textController.value);
|
||||
// overlayController.show();
|
||||
// });
|
||||
// }
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
node.unfocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void handlePageSelected(ViewPB view) {
|
||||
final newText = textController.text.replaceRange(
|
||||
final newText = widget.textController.text.replaceRange(
|
||||
inputControlCubit.filterStartPosition,
|
||||
inputControlCubit.filterEndPosition,
|
||||
view.id,
|
||||
);
|
||||
textController.value = TextEditingValue(
|
||||
widget.textController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: inputControlCubit.filterStartPosition + view.id.length,
|
||||
@@ -376,18 +387,27 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
link: layerLink,
|
||||
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
|
||||
builder: (context, state) {
|
||||
return PromptInputTextField(
|
||||
Widget textField = PromptInputTextField(
|
||||
key: textFieldKey,
|
||||
editable: state.editable,
|
||||
cubit: inputControlCubit,
|
||||
textController: textController,
|
||||
textController: widget.textController,
|
||||
textFieldFocusNode: focusNode,
|
||||
contentPadding:
|
||||
calculateContentPadding(state.showPredefinedFormats),
|
||||
hintText: switch (state.aiType) {
|
||||
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
|
||||
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
|
||||
},
|
||||
hintText: state.hintText,
|
||||
);
|
||||
|
||||
if (!state.editable) {
|
||||
textField = FlowyTooltip(
|
||||
message: LocaleKeys
|
||||
.settings_aiPage_keys_localAINotReadyTextFieldPrompt
|
||||
.tr(),
|
||||
child: textField,
|
||||
);
|
||||
}
|
||||
|
||||
return textField;
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -492,6 +512,7 @@ class _FocusNextItemIntent extends Intent {
|
||||
class PromptInputTextField extends StatelessWidget {
|
||||
const PromptInputTextField({
|
||||
super.key,
|
||||
required this.editable,
|
||||
required this.cubit,
|
||||
required this.textController,
|
||||
required this.textFieldFocusNode,
|
||||
@@ -503,6 +524,7 @@ class PromptInputTextField extends StatelessWidget {
|
||||
final TextEditingController textController;
|
||||
final FocusNode textFieldFocusNode;
|
||||
final EdgeInsetsGeometry contentPadding;
|
||||
final bool editable;
|
||||
final String hintText;
|
||||
|
||||
@override
|
||||
@@ -510,6 +532,8 @@ class PromptInputTextField extends StatelessWidget {
|
||||
return ExtendedTextField(
|
||||
controller: textController,
|
||||
focusNode: textFieldFocusNode,
|
||||
readOnly: !editable,
|
||||
enabled: editable,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
@@ -554,6 +578,7 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
required this.onStopStreaming,
|
||||
required this.selectedSourcesNotifier,
|
||||
required this.onUpdateSelectedSources,
|
||||
this.extraBottomActionButton,
|
||||
});
|
||||
|
||||
final bool showPredefinedFormats;
|
||||
@@ -564,6 +589,7 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
final void Function() onStopStreaming;
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
final Widget? extraBottomActionButton;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -572,18 +598,25 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
margin: DesktopAIChatSizes.inputActionBarMargin,
|
||||
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
|
||||
builder: (context, state) {
|
||||
if (state.chatState == null) {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: _sendButton(),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
_predefinedFormatButton(),
|
||||
const HSpace(
|
||||
DesktopAIChatSizes.inputActionBarButtonSpacing,
|
||||
),
|
||||
SelectModelMenu(
|
||||
aiModelStateNotifier:
|
||||
context.read<AIPromptInputBloc>().aiModelStateNotifier,
|
||||
),
|
||||
const Spacer(),
|
||||
if (state.aiType == AIType.appflowyAI) ...[
|
||||
_selectSourcesButton(context),
|
||||
if (state.aiType.isCloud) ...[
|
||||
_selectSourcesButton(),
|
||||
const HSpace(
|
||||
DesktopAIChatSizes.inputActionBarButtonSpacing,
|
||||
),
|
||||
],
|
||||
if (extraBottomActionButton != null) ...[
|
||||
extraBottomActionButton!,
|
||||
const HSpace(
|
||||
DesktopAIChatSizes.inputActionBarButtonSpacing,
|
||||
),
|
||||
@@ -613,7 +646,7 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _selectSourcesButton(BuildContext context) {
|
||||
Widget _selectSourcesButton() {
|
||||
return PromptInputDesktopSelectSourcesButton(
|
||||
onUpdateSelectedSources: onUpdateSelectedSources,
|
||||
selectedSourcesNotifier: selectedSourcesNotifier,
|
||||
|
||||
+12
-5
@@ -48,25 +48,30 @@ class ChangeFormatBar extends StatelessWidget {
|
||||
required this.predefinedFormat,
|
||||
required this.spacing,
|
||||
required this.onSelectPredefinedFormat,
|
||||
this.showImageFormats = true,
|
||||
});
|
||||
|
||||
final PredefinedFormat? predefinedFormat;
|
||||
final double spacing;
|
||||
final void Function(PredefinedFormat) onSelectPredefinedFormat;
|
||||
final bool showImageFormats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showTextFormats = predefinedFormat?.imageFormat.hasText ?? true;
|
||||
return SizedBox(
|
||||
height: DesktopAIPromptSizes.predefinedFormatButtonHeight,
|
||||
child: SeparatedRow(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
separatorBuilder: () => HSpace(spacing),
|
||||
children: [
|
||||
_buildFormatButton(context, ImageFormat.text),
|
||||
_buildFormatButton(context, ImageFormat.textAndImage),
|
||||
_buildFormatButton(context, ImageFormat.image),
|
||||
if (predefinedFormat?.imageFormat.hasText ?? true) ...[
|
||||
_buildDivider(),
|
||||
if (showImageFormats) ...[
|
||||
_buildFormatButton(context, ImageFormat.text),
|
||||
_buildFormatButton(context, ImageFormat.textAndImage),
|
||||
_buildFormatButton(context, ImageFormat.image),
|
||||
],
|
||||
if (showImageFormats && showTextFormats) _buildDivider(),
|
||||
if (showTextFormats) ...[
|
||||
_buildTextFormatButton(context, TextFormat.paragraph),
|
||||
_buildTextFormatButton(context, TextFormat.bulletList),
|
||||
_buildTextFormatButton(context, TextFormat.numberedList),
|
||||
@@ -99,6 +104,7 @@ class ChangeFormatBar extends StatelessWidget {
|
||||
},
|
||||
child: FlowyTooltip(
|
||||
message: format.i18n,
|
||||
preferBelow: false,
|
||||
child: SizedBox.square(
|
||||
dimension: _buttonSize,
|
||||
child: FlowyHover(
|
||||
@@ -145,6 +151,7 @@ class ChangeFormatBar extends StatelessWidget {
|
||||
},
|
||||
child: FlowyTooltip(
|
||||
message: format.i18n,
|
||||
preferBelow: false,
|
||||
child: SizedBox.square(
|
||||
dimension: _buttonSize,
|
||||
child: FlowyHover(
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SelectModelMenu extends StatefulWidget {
|
||||
const SelectModelMenu({
|
||||
super.key,
|
||||
required this.aiModelStateNotifier,
|
||||
});
|
||||
|
||||
final AIModelStateNotifier aiModelStateNotifier;
|
||||
|
||||
@override
|
||||
State<SelectModelMenu> createState() => _SelectModelMenuState();
|
||||
}
|
||||
|
||||
class _SelectModelMenuState extends State<SelectModelMenu> {
|
||||
final popoverController = PopoverController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => SelectModelBloc(
|
||||
aiModelStateNotifier: widget.aiModelStateNotifier,
|
||||
),
|
||||
child: BlocBuilder<SelectModelBloc, SelectModelState>(
|
||||
builder: (context, state) {
|
||||
if (state.selectedModel == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return AppFlowyPopover(
|
||||
offset: Offset(-12.0, 0.0),
|
||||
constraints: BoxConstraints(maxWidth: 250, maxHeight: 600),
|
||||
direction: PopoverDirection.topWithLeftAligned,
|
||||
margin: EdgeInsets.zero,
|
||||
controller: popoverController,
|
||||
popupBuilder: (popoverContext) {
|
||||
return SelectModelPopoverContent(
|
||||
models: state.models,
|
||||
selectedModel: state.selectedModel,
|
||||
onSelectModel: (model) {
|
||||
if (model != state.selectedModel) {
|
||||
context
|
||||
.read<SelectModelBloc>()
|
||||
.add(SelectModelEvent.selectModel(model));
|
||||
}
|
||||
popoverController.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
child: _CurrentModelButton(
|
||||
model: state.selectedModel!,
|
||||
onTap: () => popoverController.show(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SelectModelPopoverContent extends StatelessWidget {
|
||||
const SelectModelPopoverContent({
|
||||
super.key,
|
||||
required this.models,
|
||||
required this.selectedModel,
|
||||
this.onSelectModel,
|
||||
});
|
||||
|
||||
final List<AIModelPB> models;
|
||||
final AIModelPB? selectedModel;
|
||||
final void Function(AIModelPB)? onSelectModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (models.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// separate models into local and cloud models
|
||||
final localModels = models.where((model) => model.isLocal).toList();
|
||||
final cloudModels = models.where((model) => !model.isLocal).toList();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (localModels.isNotEmpty) ...[
|
||||
_ModelSectionHeader(
|
||||
title: LocaleKeys.chat_switchModel_localModel.tr(),
|
||||
),
|
||||
const VSpace(4.0),
|
||||
],
|
||||
...localModels.map(
|
||||
(model) => _ModelItem(
|
||||
model: model,
|
||||
isSelected: model == selectedModel,
|
||||
onTap: () => onSelectModel?.call(model),
|
||||
),
|
||||
),
|
||||
if (cloudModels.isNotEmpty && localModels.isNotEmpty) ...[
|
||||
const VSpace(8.0),
|
||||
_ModelSectionHeader(
|
||||
title: LocaleKeys.chat_switchModel_cloudModel.tr(),
|
||||
),
|
||||
const VSpace(4.0),
|
||||
],
|
||||
...cloudModels.map(
|
||||
(model) => _ModelItem(
|
||||
model: model,
|
||||
isSelected: model == selectedModel,
|
||||
onTap: () => onSelectModel?.call(model),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModelSectionHeader extends StatelessWidget {
|
||||
const _ModelSectionHeader({
|
||||
required this.title,
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 2),
|
||||
child: FlowyText(
|
||||
title,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
color: Theme.of(context).hintColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModelItem extends StatelessWidget {
|
||||
const _ModelItem({
|
||||
required this.model,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AIModelPB model;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 32),
|
||||
child: FlowyButton(
|
||||
onTap: onTap,
|
||||
margin: EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
|
||||
text: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowyText(
|
||||
model.i18n,
|
||||
figmaLineHeight: 20,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
if (model.desc.isNotEmpty)
|
||||
FlowyText(
|
||||
model.desc,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
color: Theme.of(context).hintColor,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
rightIcon: isSelected
|
||||
? FlowySvg(
|
||||
FlowySvgs.check_s,
|
||||
size: const Size.square(20),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CurrentModelButton extends StatelessWidget {
|
||||
const _CurrentModelButton({
|
||||
required this.model,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AIModelPB model;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.chat_switchModel_label.tr(),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
height: DesktopAIPromptSizes.actionBarButtonSize,
|
||||
child: FlowyHover(
|
||||
style: const HoverStyle(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsetsDirectional.all(4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
// TODO: remove this after change icon to 20px
|
||||
padding: EdgeInsets.all(2),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.ai_sparks_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: Size.square(16),
|
||||
),
|
||||
),
|
||||
if (!model.isDefault)
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(end: 2.0),
|
||||
child: FlowyText(
|
||||
model.i18n,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
color: Theme.of(context).hintColor,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_source_drop_down_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ class _IndicatorButton extends StatelessWidget {
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_page_s,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
const HSpace(2.0),
|
||||
ValueListenableBuilder(
|
||||
@@ -170,7 +170,7 @@ class _IndicatorButton extends StatelessWidget {
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_source_drop_down_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(10),
|
||||
size: const Size.square(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
@@ -23,6 +25,23 @@ class PromptInputSendButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyIconButton(
|
||||
width: _buttonSize,
|
||||
richTooltipText: switch (state) {
|
||||
SendButtonState.streaming => TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.chat_stopTooltip.tr()} ',
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
TextSpan(
|
||||
text: 'ESC',
|
||||
style: context
|
||||
.tooltipTextStyle()
|
||||
?.copyWith(color: Theme.of(context).hintColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
icon: switch (state) {
|
||||
SendButtonState.enabled => FlowySvg(
|
||||
FlowySvgs.ai_send_filled_s,
|
||||
|
||||
@@ -100,6 +100,10 @@ bool get isAuthEnabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool get isLocalAuthEnabled {
|
||||
return currentCloudType().isLocal;
|
||||
}
|
||||
|
||||
/// Determines if AppFlowy Cloud is enabled.
|
||||
bool get isAppFlowyCloudEnabled {
|
||||
return currentCloudType().isAppFlowyCloudEnabled;
|
||||
|
||||
@@ -100,7 +100,6 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
|
||||
key: ValueKey(currentWorkspaceId),
|
||||
userProfile: userProfile,
|
||||
workspaceId: currentWorkspaceId,
|
||||
currentWorkspaceMemberRole: state.currentWorkspace?.role,
|
||||
),
|
||||
const SupportSettingGroup(),
|
||||
const AboutSettingGroup(),
|
||||
|
||||
+113
-54
@@ -36,12 +36,16 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
Alignment _alignment = Alignment.topLeft;
|
||||
final int itemCountFilter;
|
||||
final int startOffset;
|
||||
ValueNotifier<_Position> _positionNotifier = ValueNotifier(_Position.zero);
|
||||
|
||||
@override
|
||||
void dismiss() {
|
||||
if (_selectionMenuEntry != null) {
|
||||
editorState.service.keyboardService?.enable();
|
||||
editorState.service.scrollService?.enable();
|
||||
editorState
|
||||
.removeScrollViewScrolledListener(_checkPositionAfterScrolling);
|
||||
_positionNotifier.dispose();
|
||||
}
|
||||
|
||||
_selectionMenuEntry?.remove();
|
||||
@@ -53,23 +57,20 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
final completer = Completer<void>();
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
_show();
|
||||
editorState.addScrollViewScrolledListener(_checkPositionAfterScrolling);
|
||||
completer.complete();
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void _show() {
|
||||
final selectionRects = editorState.selectionRects();
|
||||
if (selectionRects.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
calculateSelectionMenuOffset(selectionRects.first);
|
||||
final (left, top, right, bottom) = getPosition();
|
||||
final position = _getCurrentPosition();
|
||||
if (position == null) return;
|
||||
|
||||
final editorHeight = editorState.renderBox!.size.height;
|
||||
final editorWidth = editorState.renderBox!.size.width;
|
||||
|
||||
_positionNotifier = ValueNotifier(position);
|
||||
_selectionMenuEntry = OverlayEntry(
|
||||
builder: (context) {
|
||||
return SizedBox(
|
||||
@@ -80,47 +81,54 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
onTap: dismiss,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: top,
|
||||
bottom: bottom,
|
||||
left: left,
|
||||
right: right,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: MobileSelectionMenuWidget(
|
||||
selectionMenuStyle: style,
|
||||
singleColumn: singleColumn,
|
||||
items: selectionMenuItems
|
||||
..forEach((element) {
|
||||
if (element is MobileSelectionMenuItem) {
|
||||
element.deleteSlash = false;
|
||||
element.deleteKeywords = deleteKeywordsByDefault;
|
||||
for (final e in element.children) {
|
||||
e.deleteSlash = deleteSlashByDefault;
|
||||
e.deleteKeywords = deleteKeywordsByDefault;
|
||||
e.onSelected = () {
|
||||
dismiss();
|
||||
};
|
||||
}
|
||||
} else {
|
||||
element.deleteSlash = deleteSlashByDefault;
|
||||
element.deleteKeywords = deleteKeywordsByDefault;
|
||||
element.onSelected = () {
|
||||
dismiss();
|
||||
};
|
||||
}
|
||||
}),
|
||||
maxItemInRow: 5,
|
||||
editorState: editorState,
|
||||
itemCountFilter: itemCountFilter,
|
||||
startOffset: startOffset,
|
||||
menuService: this,
|
||||
onExit: () {
|
||||
dismiss();
|
||||
},
|
||||
deleteSlashByDefault: deleteSlashByDefault,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _positionNotifier,
|
||||
builder: (context, value, _) {
|
||||
return Positioned(
|
||||
top: value.top,
|
||||
bottom: value.bottom,
|
||||
left: value.left,
|
||||
right: value.right,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: MobileSelectionMenuWidget(
|
||||
selectionMenuStyle: style,
|
||||
singleColumn: singleColumn,
|
||||
items: selectionMenuItems
|
||||
..forEach((element) {
|
||||
if (element is MobileSelectionMenuItem) {
|
||||
element.deleteSlash = false;
|
||||
element.deleteKeywords =
|
||||
deleteKeywordsByDefault;
|
||||
for (final e in element.children) {
|
||||
e.deleteSlash = deleteSlashByDefault;
|
||||
e.deleteKeywords = deleteKeywordsByDefault;
|
||||
e.onSelected = () {
|
||||
dismiss();
|
||||
};
|
||||
}
|
||||
} else {
|
||||
element.deleteSlash = deleteSlashByDefault;
|
||||
element.deleteKeywords =
|
||||
deleteKeywordsByDefault;
|
||||
element.onSelected = () {
|
||||
dismiss();
|
||||
};
|
||||
}
|
||||
}),
|
||||
maxItemInRow: 5,
|
||||
editorState: editorState,
|
||||
itemCountFilter: itemCountFilter,
|
||||
startOffset: startOffset,
|
||||
menuService: this,
|
||||
onExit: () {
|
||||
dismiss();
|
||||
},
|
||||
deleteSlashByDefault: deleteSlashByDefault,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -135,6 +143,34 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
editorState.service.scrollService?.disable();
|
||||
}
|
||||
|
||||
/// the workaround for: editor auto scrolling that will cause wrong position
|
||||
/// of slash menu
|
||||
void _checkPositionAfterScrolling() {
|
||||
final position = _getCurrentPosition();
|
||||
if (position == null) return;
|
||||
if (position == _positionNotifier.value) {
|
||||
Future.delayed(const Duration(milliseconds: 100)).then((_) {
|
||||
final position = _getCurrentPosition();
|
||||
if (position == null) return;
|
||||
if (position != _positionNotifier.value) {
|
||||
_positionNotifier.value = position;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_positionNotifier.value = position;
|
||||
}
|
||||
}
|
||||
|
||||
_Position? _getCurrentPosition() {
|
||||
final selectionRects = editorState.selectionRects();
|
||||
if (selectionRects.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
calculateSelectionMenuOffset(selectionRects.first);
|
||||
final (left, top, right, bottom) = getPosition();
|
||||
return _Position(left, top, right, bottom);
|
||||
}
|
||||
|
||||
@override
|
||||
Alignment get alignment {
|
||||
return _alignment;
|
||||
@@ -166,7 +202,6 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
bottom = offset.dy;
|
||||
break;
|
||||
}
|
||||
|
||||
return (left, top, right, bottom);
|
||||
}
|
||||
|
||||
@@ -186,8 +221,9 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
final bottomRight = rect.bottomRight;
|
||||
final topRight = rect.topRight;
|
||||
var offset = bottomRight + menuOffset;
|
||||
final limitX = editorWidth - menuWidth + editorOffset.dx;
|
||||
_offset = Offset(
|
||||
offset.dx,
|
||||
min(offset.dx, limitX),
|
||||
offset.dy,
|
||||
);
|
||||
|
||||
@@ -196,10 +232,9 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
offset = topRight - menuOffset;
|
||||
_alignment = Alignment.bottomLeft;
|
||||
|
||||
final limitX = editorWidth - menuWidth;
|
||||
_offset = Offset(
|
||||
min(offset.dx, limitX),
|
||||
MediaQuery.of(context).size.height - offset.dy,
|
||||
offset.dx,
|
||||
editorOffset.dy + editorHeight - offset.dy,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,7 +245,6 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
: Alignment.bottomRight;
|
||||
|
||||
final x = editorWidth - _offset.dx + editorOffset.dx;
|
||||
final limitX = editorWidth - menuWidth + editorOffset.dx;
|
||||
_offset = Offset(
|
||||
min(x, limitX),
|
||||
_offset.dy,
|
||||
@@ -218,3 +252,28 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Position {
|
||||
const _Position(this.left, this.top, this.right, this.bottom);
|
||||
|
||||
final double? left;
|
||||
final double? top;
|
||||
final double? right;
|
||||
final double? bottom;
|
||||
|
||||
static const _Position zero = _Position(0, 0, 0, 0);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is _Position &&
|
||||
runtimeType == other.runtimeType &&
|
||||
left == other.left &&
|
||||
top == other.top &&
|
||||
right == other.right &&
|
||||
bottom == other.bottom;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
left.hashCode ^ top.hashCode ^ right.hashCode ^ bottom.hashCode;
|
||||
}
|
||||
+1
-3
@@ -36,9 +36,7 @@ class MobileSelectionMenuItemWidget extends StatelessWidget {
|
||||
),
|
||||
style: ButtonStyle(
|
||||
alignment: Alignment.centerLeft,
|
||||
overlayColor: WidgetStateProperty.all(
|
||||
style.selectionMenuItemSelectedColor,
|
||||
),
|
||||
overlayColor: WidgetStateProperty.all(Colors.transparent),
|
||||
backgroundColor: isSelected
|
||||
? WidgetStateProperty.all(
|
||||
style.selectionMenuItemSelectedColor,
|
||||
|
||||
+27
-8
@@ -1,6 +1,8 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'mobile_selection_menu_item.dart';
|
||||
@@ -314,15 +316,32 @@ class _MobileSelectionMenuWidgetState extends State<MobileSelectionMenuWidget> {
|
||||
}
|
||||
|
||||
Widget _buildNoResultsWidget(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 5,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 140,
|
||||
child: Material(
|
||||
child: Text(
|
||||
"No results",
|
||||
style: TextStyle(fontSize: 18.0, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
width: 240,
|
||||
height: 48,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Center(
|
||||
child: Text(
|
||||
LocaleKeys.inlineActions_noResults.tr(),
|
||||
style: TextStyle(fontSize: 18.0, color: Color(0x801F2225)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+11
-13
@@ -5,8 +5,6 @@ import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item
|
||||
import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -18,12 +16,10 @@ class AiSettingsGroup extends StatelessWidget {
|
||||
super.key,
|
||||
required this.userProfile,
|
||||
required this.workspaceId,
|
||||
this.currentWorkspaceMemberRole,
|
||||
});
|
||||
|
||||
final UserProfilePB userProfile;
|
||||
final String workspaceId;
|
||||
final AFRolePB? currentWorkspaceMemberRole;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -32,7 +28,6 @@ class AiSettingsGroup extends StatelessWidget {
|
||||
create: (context) => SettingsAIBloc(
|
||||
userProfile,
|
||||
workspaceId,
|
||||
currentWorkspaceMemberRole,
|
||||
)..add(const SettingsAIEvent.started()),
|
||||
child: BlocBuilder<SettingsAIBloc, SettingsAIState>(
|
||||
builder: (context, state) {
|
||||
@@ -48,7 +43,7 @@ class AiSettingsGroup extends StatelessWidget {
|
||||
children: [
|
||||
Flexible(
|
||||
child: FlowyText(
|
||||
state.selectedAIModel,
|
||||
state.availableModels?.selectedModel.name ?? "",
|
||||
color: theme.colorScheme.onSurface,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -84,16 +79,19 @@ class AiSettingsGroup extends StatelessWidget {
|
||||
title: LocaleKeys.settings_aiPage_keys_llmModelType.tr(),
|
||||
builder: (_) {
|
||||
return Column(
|
||||
children: availableModels
|
||||
.mapIndexed(
|
||||
(index, model) => FlowyOptionTile.checkbox(
|
||||
text: model,
|
||||
showTopBorder: index == 0,
|
||||
isSelected: state.selectedAIModel == model,
|
||||
children: (availableModels?.models ?? [])
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
(entry) => FlowyOptionTile.checkbox(
|
||||
text: entry.value.name,
|
||||
showTopBorder: entry.key == 0,
|
||||
isSelected:
|
||||
availableModels?.selectedModel.name == entry.value.name,
|
||||
onTap: () {
|
||||
context
|
||||
.read<SettingsAIBloc>()
|
||||
.add(SettingsAIEvent.selectModel(model));
|
||||
.add(SettingsAIEvent.selectModel(entry.value));
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -18,6 +19,7 @@ class AppFlowyCloudPage extends StatelessWidget {
|
||||
),
|
||||
body: SettingCloud(
|
||||
restartAppFlowy: () async {
|
||||
await getIt<AuthService>().signOut();
|
||||
await runAppFlowy();
|
||||
},
|
||||
),
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_notification.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
|
||||
import 'package:appflowy_backend/rust_stream.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
typedef OnUpdateSelectedModel = void Function(AIModelPB model);
|
||||
|
||||
class AIModelSwitchListener {
|
||||
AIModelSwitchListener({required this.objectId}) {
|
||||
_parser = ChatNotificationParser(id: objectId, callback: _callback);
|
||||
_subscription = RustStreamReceiver.listen(
|
||||
(observable) => _parser?.parse(observable),
|
||||
);
|
||||
}
|
||||
|
||||
final String objectId;
|
||||
StreamSubscription<SubscribeObject>? _subscription;
|
||||
ChatNotificationParser? _parser;
|
||||
|
||||
void start({
|
||||
OnUpdateSelectedModel? onUpdateSelectedModel,
|
||||
}) {
|
||||
this.onUpdateSelectedModel = onUpdateSelectedModel;
|
||||
}
|
||||
|
||||
OnUpdateSelectedModel? onUpdateSelectedModel;
|
||||
|
||||
void _callback(
|
||||
ChatNotification ty,
|
||||
FlowyResult<Uint8List, FlowyError> result,
|
||||
) {
|
||||
result.map((r) {
|
||||
switch (ty) {
|
||||
case ChatNotification.DidUpdateSelectedModel:
|
||||
onUpdateSelectedModel?.call(AIModelPB.fromBuffer(r));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
}
|
||||
}
|
||||
+124
-126
@@ -23,11 +23,126 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
parseMetadata(refSourceJsonString),
|
||||
),
|
||||
) {
|
||||
_dispatch();
|
||||
_registerEventHandlers();
|
||||
_initializeStreamListener();
|
||||
_checkInitialStreamState();
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
final Int64? questionId;
|
||||
|
||||
void _registerEventHandlers() {
|
||||
on<_UpdateText>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
text: event.text,
|
||||
messageState: const MessageState.ready(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_ReceiveError>((event, emit) {
|
||||
emit(state.copyWith(messageState: MessageState.onError(event.error)));
|
||||
});
|
||||
|
||||
on<_Retry>((event, emit) async {
|
||||
if (questionId == null) {
|
||||
Log.error("Question id is not valid: $questionId");
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(messageState: const MessageState.loading()));
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: questionId,
|
||||
);
|
||||
final result = await AIEventGetAnswerForQuestion(payload).send();
|
||||
if (!isClosed) {
|
||||
result.fold(
|
||||
(answer) => add(ChatAIMessageEvent.retryResult(answer.content)),
|
||||
(err) {
|
||||
Log.error("Failed to get answer: $err");
|
||||
add(ChatAIMessageEvent.receiveError(err.toString()));
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
on<_RetryResult>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
text: event.text,
|
||||
messageState: const MessageState.ready(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_OnAIResponseLimit>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: const MessageState.onAIResponseLimit(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_OnAIImageResponseLimit>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: const MessageState.onAIImageResponseLimit(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_OnAIMaxRquired>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: MessageState.onAIMaxRequired(event.message),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_OnLocalAIInitializing>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: const MessageState.onInitializingLocalAI(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_ReceiveMetadata>((event, emit) {
|
||||
Log.debug("AI Steps: ${event.metadata.progress?.step}");
|
||||
emit(
|
||||
state.copyWith(
|
||||
sources: event.metadata.sources,
|
||||
progress: event.metadata.progress,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _initializeStreamListener() {
|
||||
if (state.stream != null) {
|
||||
_startListening();
|
||||
state.stream!.listen(
|
||||
onData: (text) => _safeAdd(ChatAIMessageEvent.updateText(text)),
|
||||
onError: (error) =>
|
||||
_safeAdd(ChatAIMessageEvent.receiveError(error.toString())),
|
||||
onAIResponseLimit: () =>
|
||||
_safeAdd(const ChatAIMessageEvent.onAIResponseLimit()),
|
||||
onAIImageResponseLimit: () =>
|
||||
_safeAdd(const ChatAIMessageEvent.onAIImageResponseLimit()),
|
||||
onMetadata: (metadata) =>
|
||||
_safeAdd(ChatAIMessageEvent.receiveMetadata(metadata)),
|
||||
onAIMaxRequired: (message) {
|
||||
Log.info(message);
|
||||
_safeAdd(ChatAIMessageEvent.onAIMaxRequired(message));
|
||||
},
|
||||
onLocalAIInitializing: () =>
|
||||
_safeAdd(const ChatAIMessageEvent.onLocalAIInitializing()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _checkInitialStreamState() {
|
||||
if (state.stream != null) {
|
||||
if (state.stream!.aiLimitReached) {
|
||||
add(const ChatAIMessageEvent.onAIResponseLimit());
|
||||
} else if (state.stream!.error != null) {
|
||||
@@ -36,130 +151,10 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
}
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
final Int64? questionId;
|
||||
|
||||
void _dispatch() {
|
||||
on<ChatAIMessageEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
updateText: (newText) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
text: newText,
|
||||
messageState: const MessageState.ready(),
|
||||
),
|
||||
);
|
||||
},
|
||||
receiveError: (error) {
|
||||
emit(state.copyWith(messageState: MessageState.onError(error)));
|
||||
},
|
||||
retry: () {
|
||||
if (questionId is! Int64) {
|
||||
Log.error("Question id is not Int64: $questionId");
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: const MessageState.loading(),
|
||||
),
|
||||
);
|
||||
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: questionId,
|
||||
);
|
||||
AIEventGetAnswerForQuestion(payload).send().then((result) {
|
||||
if (!isClosed) {
|
||||
result.fold(
|
||||
(answer) {
|
||||
add(ChatAIMessageEvent.retryResult(answer.content));
|
||||
},
|
||||
(err) {
|
||||
Log.error("Failed to get answer: $err");
|
||||
add(ChatAIMessageEvent.receiveError(err.toString()));
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
retryResult: (String text) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
text: text,
|
||||
messageState: const MessageState.ready(),
|
||||
),
|
||||
);
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: const MessageState.onAIResponseLimit(),
|
||||
),
|
||||
);
|
||||
},
|
||||
onAIImageResponseLimit: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: const MessageState.onAIImageResponseLimit(),
|
||||
),
|
||||
);
|
||||
},
|
||||
onAIMaxRequired: (message) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: MessageState.onAIMaxRequired(message),
|
||||
),
|
||||
);
|
||||
},
|
||||
receiveMetadata: (metadata) {
|
||||
Log.debug("AI Steps: ${metadata.progress?.step}");
|
||||
emit(
|
||||
state.copyWith(
|
||||
sources: metadata.sources,
|
||||
progress: metadata.progress,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
},
|
||||
onAIImageResponseLimit: () {
|
||||
if (!isClosed) {
|
||||
add(const ChatAIMessageEvent.onAIImageResponseLimit());
|
||||
}
|
||||
},
|
||||
onMetadata: (metadata) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveMetadata(metadata));
|
||||
}
|
||||
},
|
||||
onAIMaxRequired: (message) {
|
||||
if (!isClosed) {
|
||||
Log.info(message);
|
||||
add(ChatAIMessageEvent.onAIMaxRequired(message));
|
||||
}
|
||||
},
|
||||
);
|
||||
void _safeAdd(ChatAIMessageEvent event) {
|
||||
if (!isClosed) {
|
||||
add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +169,8 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
|
||||
_OnAIImageResponseLimit;
|
||||
const factory ChatAIMessageEvent.onAIMaxRequired(String message) =
|
||||
_OnAIMaxRquired;
|
||||
const factory ChatAIMessageEvent.onLocalAIInitializing() =
|
||||
_OnLocalAIInitializing;
|
||||
const factory ChatAIMessageEvent.receiveMetadata(
|
||||
MetadataCollection metadata,
|
||||
) = _ReceiveMetadata;
|
||||
@@ -209,6 +206,7 @@ class MessageState with _$MessageState {
|
||||
const factory MessageState.onAIResponseLimit() = _AIResponseLimit;
|
||||
const factory MessageState.onAIImageResponseLimit() = _AIImageResponseLimit;
|
||||
const factory MessageState.onAIMaxRequired(String message) = _AIMaxRequired;
|
||||
const factory MessageState.onInitializingLocalAI() = _LocalAIInitializing;
|
||||
const factory MessageState.ready() = _Ready;
|
||||
const factory MessageState.loading() = _Loading;
|
||||
}
|
||||
@@ -239,9 +239,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
regenerateAnswer: (id, format) {
|
||||
regenerateAnswer: (id, format, model) {
|
||||
_clearRelatedQuestions();
|
||||
_regenerateAnswer(id, format);
|
||||
_regenerateAnswer(id, format, model);
|
||||
lastSentMessage = null;
|
||||
|
||||
isFetchingRelatedQuestions = false;
|
||||
@@ -483,6 +483,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
void _regenerateAnswer(
|
||||
String answerMessageIdString,
|
||||
PredefinedFormat? format,
|
||||
AIModelPB? model,
|
||||
) async {
|
||||
final id = temporaryMessageIDMap.entries
|
||||
.firstWhereOrNull((e) => e.value == answerMessageIdString)
|
||||
@@ -505,6 +506,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
if (format != null) {
|
||||
payload.format = format.toPB();
|
||||
}
|
||||
if (model != null) {
|
||||
payload.model = model;
|
||||
}
|
||||
|
||||
await AIEventRegenerateResponse(payload).send().fold(
|
||||
(success) {
|
||||
@@ -637,6 +641,7 @@ class ChatEvent with _$ChatEvent {
|
||||
const factory ChatEvent.regenerateAnswer(
|
||||
String id,
|
||||
PredefinedFormat? format,
|
||||
AIModelPB? model,
|
||||
) = _RegenerateAnswer;
|
||||
|
||||
// streaming answer
|
||||
|
||||
+70
-41
@@ -2,55 +2,25 @@ import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:appflowy/ai/service/ai_entities.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
|
||||
|
||||
/// A stream that receives answer events from an isolate or external process.
|
||||
/// It caches events that might occur before a listener is attached.
|
||||
class AnswerStream {
|
||||
AnswerStream() {
|
||||
_port.handler = _controller.add;
|
||||
_subscription = _controller.stream.listen(
|
||||
(event) {
|
||||
if (event.startsWith("data:")) {
|
||||
_hasStarted = true;
|
||||
final newText = event.substring(5);
|
||||
_text += newText;
|
||||
_onData?.call(_text);
|
||||
} else if (event.startsWith("error:")) {
|
||||
_error = event.substring(5);
|
||||
_onError?.call(_error!);
|
||||
} else if (event.startsWith("metadata:")) {
|
||||
if (_onMetadata != null) {
|
||||
final s = event.substring(9);
|
||||
_onMetadata!(parseMetadata(s));
|
||||
}
|
||||
} else if (event == "AI_RESPONSE_LIMIT") {
|
||||
_aiLimitReached = true;
|
||||
_onAIResponseLimit?.call();
|
||||
} else if (event == "AI_IMAGE_RESPONSE_LIMIT") {
|
||||
_aiImageLimitReached = true;
|
||||
_onAIImageResponseLimit?.call();
|
||||
} else if (event.startsWith("AI_MAX_REQUIRED:")) {
|
||||
final msg = event.substring(16);
|
||||
// If the callback is not registered yet, add the event to the buffer.
|
||||
if (_onAIMaxRequired != null) {
|
||||
_onAIMaxRequired!(msg);
|
||||
} else {
|
||||
_pendingAIMaxRequiredEvents.add(msg);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
_onEnd?.call();
|
||||
},
|
||||
onError: (error) {
|
||||
_error = error.toString();
|
||||
_onError?.call(error.toString());
|
||||
},
|
||||
_handleEvent,
|
||||
onDone: _onDoneCallback,
|
||||
onError: _handleError,
|
||||
);
|
||||
}
|
||||
|
||||
final RawReceivePort _port = RawReceivePort();
|
||||
final StreamController<String> _controller = StreamController.broadcast();
|
||||
late StreamSubscription<String> _subscription;
|
||||
|
||||
bool _hasStarted = false;
|
||||
bool _aiLimitReached = false;
|
||||
bool _aiImageLimitReached = false;
|
||||
@@ -62,13 +32,15 @@ class AnswerStream {
|
||||
void Function()? _onStart;
|
||||
void Function()? _onEnd;
|
||||
void Function(String error)? _onError;
|
||||
void Function()? _onLocalAIInitializing;
|
||||
void Function()? _onAIResponseLimit;
|
||||
void Function()? _onAIImageResponseLimit;
|
||||
void Function(String message)? _onAIMaxRequired;
|
||||
void Function(MetadataCollection metadataCollection)? _onMetadata;
|
||||
void Function(MetadataCollection metadata)? _onMetadata;
|
||||
|
||||
// Buffer for events that occur before listen() is called.
|
||||
// Caches for events that occur before listen() is called.
|
||||
final List<String> _pendingAIMaxRequiredEvents = [];
|
||||
bool _pendingLocalAINotReady = false;
|
||||
|
||||
int get nativePort => _port.sendPort.nativePort;
|
||||
bool get hasStarted => _hasStarted;
|
||||
@@ -77,12 +49,61 @@ class AnswerStream {
|
||||
String? get error => _error;
|
||||
String get text => _text;
|
||||
|
||||
/// Releases the resources used by the AnswerStream.
|
||||
Future<void> dispose() async {
|
||||
await _controller.close();
|
||||
await _subscription.cancel();
|
||||
_port.close();
|
||||
}
|
||||
|
||||
/// Handles incoming events from the underlying stream.
|
||||
void _handleEvent(String event) {
|
||||
if (event.startsWith(AIStreamEventPrefix.data)) {
|
||||
_hasStarted = true;
|
||||
final newText = event.substring(AIStreamEventPrefix.data.length);
|
||||
_text += newText;
|
||||
_onData?.call(_text);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.error)) {
|
||||
_error = event.substring(AIStreamEventPrefix.error.length);
|
||||
_onError?.call(_error!);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.metadata)) {
|
||||
final s = event.substring(AIStreamEventPrefix.metadata.length);
|
||||
_onMetadata?.call(parseMetadata(s));
|
||||
} else if (event == AIStreamEventPrefix.aiResponseLimit) {
|
||||
_aiLimitReached = true;
|
||||
_onAIResponseLimit?.call();
|
||||
} else if (event == AIStreamEventPrefix.aiImageResponseLimit) {
|
||||
_aiImageLimitReached = true;
|
||||
_onAIImageResponseLimit?.call();
|
||||
} else if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) {
|
||||
final msg = event.substring(AIStreamEventPrefix.aiMaxRequired.length);
|
||||
if (_onAIMaxRequired != null) {
|
||||
_onAIMaxRequired!(msg);
|
||||
} else {
|
||||
_pendingAIMaxRequiredEvents.add(msg);
|
||||
}
|
||||
} else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) {
|
||||
if (_onLocalAIInitializing != null) {
|
||||
_onLocalAIInitializing!();
|
||||
} else {
|
||||
_pendingLocalAINotReady = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onDoneCallback() {
|
||||
_onEnd?.call();
|
||||
}
|
||||
|
||||
void _handleError(dynamic error) {
|
||||
_error = error.toString();
|
||||
_onError?.call(_error!);
|
||||
}
|
||||
|
||||
/// Registers listeners for various events.
|
||||
///
|
||||
/// If certain events have already occurred (e.g. AI_MAX_REQUIRED or LOCAL_AI_NOT_READY),
|
||||
/// they will be flushed immediately.
|
||||
void listen({
|
||||
void Function(String text)? onData,
|
||||
void Function()? onStart,
|
||||
@@ -92,6 +113,7 @@ class AnswerStream {
|
||||
void Function()? onAIImageResponseLimit,
|
||||
void Function(String message)? onAIMaxRequired,
|
||||
void Function(MetadataCollection metadata)? onMetadata,
|
||||
void Function()? onLocalAIInitializing,
|
||||
}) {
|
||||
_onData = onData;
|
||||
_onStart = onStart;
|
||||
@@ -99,10 +121,11 @@ class AnswerStream {
|
||||
_onError = onError;
|
||||
_onAIResponseLimit = onAIResponseLimit;
|
||||
_onAIImageResponseLimit = onAIImageResponseLimit;
|
||||
_onMetadata = onMetadata;
|
||||
_onAIMaxRequired = onAIMaxRequired;
|
||||
_onMetadata = onMetadata;
|
||||
_onLocalAIInitializing = onLocalAIInitializing;
|
||||
|
||||
// Flush any buffered AI_MAX_REQUIRED events.
|
||||
// Flush pending AI_MAX_REQUIRED events.
|
||||
if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) {
|
||||
for (final msg in _pendingAIMaxRequiredEvents) {
|
||||
_onAIMaxRequired!(msg);
|
||||
@@ -110,6 +133,12 @@ class AnswerStream {
|
||||
_pendingAIMaxRequiredEvents.clear();
|
||||
}
|
||||
|
||||
// Flush pending LOCAL_AI_NOT_READY event.
|
||||
if (_pendingLocalAINotReady && _onLocalAIInitializing != null) {
|
||||
_onLocalAIInitializing!();
|
||||
_pendingLocalAINotReady = false;
|
||||
}
|
||||
|
||||
_onStart?.call();
|
||||
}
|
||||
}
|
||||
|
||||
+21
-4
@@ -19,9 +19,17 @@ class ChatSelectMessageBloc
|
||||
on<ChatSelectMessageEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
enableStartSelectingMessages: () {
|
||||
emit(state.copyWith(enabled: true));
|
||||
},
|
||||
toggleSelectingMessages: () {
|
||||
if (state.isSelectingMessages) {
|
||||
emit(ChatSelectMessageState.initial());
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSelectingMessages: false,
|
||||
selectedMessages: [],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(state.copyWith(isSelectingMessages: true));
|
||||
}
|
||||
@@ -50,8 +58,13 @@ class ChatSelectMessageBloc
|
||||
unselectAllMessages: () {
|
||||
emit(state.copyWith(selectedMessages: const []));
|
||||
},
|
||||
saveAsPage: () {
|
||||
emit(ChatSelectMessageState.initial());
|
||||
reset: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSelectingMessages: false,
|
||||
selectedMessages: [],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -70,6 +83,8 @@ class ChatSelectMessageBloc
|
||||
|
||||
@freezed
|
||||
class ChatSelectMessageEvent with _$ChatSelectMessageEvent {
|
||||
const factory ChatSelectMessageEvent.enableStartSelectingMessages() =
|
||||
_EnableStartSelectingMessages;
|
||||
const factory ChatSelectMessageEvent.toggleSelectingMessages() =
|
||||
_ToggleSelectingMessages;
|
||||
const factory ChatSelectMessageEvent.toggleSelectMessage(Message message) =
|
||||
@@ -79,7 +94,7 @@ class ChatSelectMessageEvent with _$ChatSelectMessageEvent {
|
||||
) = _SelectAllMessages;
|
||||
const factory ChatSelectMessageEvent.unselectAllMessages() =
|
||||
_UnselectAllMessages;
|
||||
const factory ChatSelectMessageEvent.saveAsPage() = _SaveAsPage;
|
||||
const factory ChatSelectMessageEvent.reset() = _Reset;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -87,9 +102,11 @@ class ChatSelectMessageState with _$ChatSelectMessageState {
|
||||
const factory ChatSelectMessageState({
|
||||
required bool isSelectingMessages,
|
||||
required List<Message> selectedMessages,
|
||||
required bool enabled,
|
||||
}) = _ChatSelectMessageState;
|
||||
|
||||
factory ChatSelectMessageState.initial() => const ChatSelectMessageState(
|
||||
enabled: false,
|
||||
isSelectingMessages: false,
|
||||
selectedMessages: [],
|
||||
);
|
||||
|
||||
@@ -178,8 +178,12 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
|
||||
customActions: [
|
||||
CustomViewAction(
|
||||
view: notifier.view,
|
||||
disabled: !state.enabled,
|
||||
leftIcon: FlowySvgs.ai_add_to_page_s,
|
||||
label: LocaleKeys.moreAction_saveAsNewPage.tr(),
|
||||
tooltipMessage: state.enabled
|
||||
? null
|
||||
: LocaleKeys.moreAction_saveAsNewPageDisabled.tr(),
|
||||
onTap: () {
|
||||
chatMessageSelectorBloc.add(
|
||||
const ChatSelectMessageEvent
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:desktop_drop/desktop_drop.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:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
|
||||
@@ -72,6 +73,7 @@ class AIChatPage extends StatelessWidget {
|
||||
/// [AIPromptInputBloc] is used to handle the user prompt
|
||||
BlocProvider(
|
||||
create: (_) => AIPromptInputBloc(
|
||||
objectId: view.id,
|
||||
predefinedFormat: PredefinedFormat(
|
||||
imageFormat: ImageFormat.text,
|
||||
textFormat: TextFormat.bulletList,
|
||||
@@ -92,9 +94,29 @@ class AIChatPage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
},
|
||||
child: _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
child: FocusScope(
|
||||
onKeyEvent: (focusNode, event) {
|
||||
if (event is! KeyUpEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
if (event.logicalKey == LogicalKeyboardKey.escape ||
|
||||
event.logicalKey == LogicalKeyboardKey.keyC &&
|
||||
HardwareKeyboard.instance.isControlPressed) {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
if (chatBloc.state.promptResponseState !=
|
||||
PromptResponseState.ready) {
|
||||
chatBloc.add(ChatEvent.stopStream());
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -243,10 +265,16 @@ class _ChatContentPage extends StatelessWidget {
|
||||
_onSelectMetadata(context, metadata),
|
||||
onRegenerate: () => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id, null)),
|
||||
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
|
||||
onChangeFormat: (format) => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id, format)),
|
||||
.add(ChatEvent.regenerateAnswer(message.id, format, null)),
|
||||
onChangeModel: (model) => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id, null, model)),
|
||||
onStopStream: () => context.read<ChatBloc>().add(
|
||||
const ChatEvent.stopStream(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -305,6 +333,10 @@ class _ChatContentPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
context
|
||||
.read<ChatSelectMessageBloc>()
|
||||
.add(ChatSelectMessageEvent.enableStartSelectingMessages());
|
||||
|
||||
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
|
||||
selector: (state) => state.isSelectingMessages,
|
||||
builder: (context, isSelectingMessages) {
|
||||
@@ -326,36 +358,56 @@ class _ChatContentPage extends StatelessWidget {
|
||||
BuildContext context,
|
||||
ChatMessageRefSource metadata,
|
||||
) async {
|
||||
if (isURL(metadata.name)) {
|
||||
late Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(metadata.name);
|
||||
// `Uri` identifies `localhost` as a scheme
|
||||
if (!uri.hasScheme || uri.scheme == 'localhost') {
|
||||
uri = Uri.parse("http://${metadata.name}");
|
||||
await InternetAddress.lookup(uri.host);
|
||||
}
|
||||
await launchUrl(uri);
|
||||
} catch (err) {
|
||||
Log.error("failed to open url $err");
|
||||
}
|
||||
} else {
|
||||
// When the source of metatdata is appflowy, which means it is a appflowy page
|
||||
if (metadata.source == "appflowy") {
|
||||
final sidebarView =
|
||||
await ViewBackendService.getView(metadata.id).toNullable();
|
||||
if (context.mounted) {
|
||||
openPageFromMessage(context, sidebarView);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.source == "web") {
|
||||
if (isURL(metadata.name)) {
|
||||
late Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(metadata.name);
|
||||
// `Uri` identifies `localhost` as a scheme
|
||||
if (!uri.hasScheme || uri.scheme == 'localhost') {
|
||||
uri = Uri.parse("http://${metadata.name}");
|
||||
await InternetAddress.lookup(uri.host);
|
||||
}
|
||||
await launchUrl(uri);
|
||||
} catch (err) {
|
||||
Log.error("failed to open url $err");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Input extends StatelessWidget {
|
||||
class _Input extends StatefulWidget {
|
||||
const _Input({
|
||||
required this.view,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
State<_Input> createState() => _InputState();
|
||||
}
|
||||
|
||||
class _InputState extends State<_Input> {
|
||||
final textController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
|
||||
@@ -385,6 +437,7 @@ class _Input extends StatelessWidget {
|
||||
return UniversalPlatform.isDesktop
|
||||
? DesktopPromptInput(
|
||||
isStreaming: !canSendMessage,
|
||||
textController: textController,
|
||||
onStopStreaming: () {
|
||||
chatBloc.add(const ChatEvent.stopStream());
|
||||
},
|
||||
|
||||
+4
-4
@@ -41,7 +41,7 @@ class _MobileChatInputState extends State<MobileChatInput> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
textController.addListener(handleTextControllerChange);
|
||||
textController.addListener(handleTextControllerChanged);
|
||||
// focusNode.onKeyEvent = handleKeyEvent;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -197,7 +197,7 @@ class _MobileChatInputState extends State<MobileChatInput> {
|
||||
);
|
||||
}
|
||||
|
||||
void handleTextControllerChange() {
|
||||
void handleTextControllerChanged() {
|
||||
if (textController.value.isComposingRangeValid) {
|
||||
return;
|
||||
}
|
||||
@@ -268,8 +268,8 @@ class _MobileChatInputState extends State<MobileChatInput> {
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: MobileAIPromptSizes.textFieldContentPadding,
|
||||
hintText: switch (state.aiType) {
|
||||
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
|
||||
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
|
||||
AiType.cloud => LocaleKeys.chat_inputMessageHint.tr(),
|
||||
AiType.local => LocaleKeys.chat_inputLocalAIMessageHint.tr()
|
||||
},
|
||||
hintStyle: inputHintTextStyle(context),
|
||||
isCollapsed: true,
|
||||
|
||||
+2
-2
@@ -250,7 +250,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
showSaveMessageSuccessToast(context, view);
|
||||
}
|
||||
|
||||
bloc.add(const ChatSelectMessageEvent.saveAsPage());
|
||||
bloc.add(const ChatSelectMessageEvent.reset());
|
||||
|
||||
return view;
|
||||
}
|
||||
@@ -275,7 +275,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
showSaveMessageSuccessToast(context, newView);
|
||||
openPageFromMessage(context, newView);
|
||||
}
|
||||
bloc.add(const ChatSelectMessageEvent.saveAsPage());
|
||||
bloc.add(const ChatSelectMessageEvent.reset());
|
||||
}
|
||||
|
||||
Future<void> forceReload(String documentId) async {
|
||||
|
||||
+31
-28
@@ -21,33 +21,35 @@ class RelatedQuestionList extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: relatedQuestions.length + 1,
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin,
|
||||
separatorBuilder: (context, index) => const VSpace(4.0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_relatedQuestion.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: RelatedQuestionItem(
|
||||
question: relatedQuestions[index - 1],
|
||||
onQuestionSelected: onQuestionSelected,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
return SelectionContainer.disabled(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: relatedQuestions.length + 1,
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin,
|
||||
separatorBuilder: (context, index) => const VSpace(4.0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_relatedQuestion.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: RelatedQuestionItem(
|
||||
question: relatedQuestions[index - 1],
|
||||
onQuestionSelected: onQuestionSelected,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -70,7 +72,8 @@ class RelatedQuestionItem extends StatelessWidget {
|
||||
child: FlowyText(
|
||||
question,
|
||||
lineHeight: 1.4,
|
||||
maxLines: null,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
expandText: false,
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<AIModelPB?> showChangeModelBottomSheet(
|
||||
BuildContext context,
|
||||
List<AIModelPB> models,
|
||||
) {
|
||||
return showMobileBottomSheet<AIModelPB?>(
|
||||
context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _ChangeModelBottomSheetContent(models: models),
|
||||
);
|
||||
}
|
||||
|
||||
class _ChangeModelBottomSheetContent extends StatefulWidget {
|
||||
const _ChangeModelBottomSheetContent({
|
||||
required this.models,
|
||||
});
|
||||
|
||||
final List<AIModelPB> models;
|
||||
|
||||
@override
|
||||
State<_ChangeModelBottomSheetContent> createState() =>
|
||||
_ChangeModelBottomSheetContentState();
|
||||
}
|
||||
|
||||
class _ChangeModelBottomSheetContentState
|
||||
extends State<_ChangeModelBottomSheetContent> {
|
||||
AIModelPB? model;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Header(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onDone: () => Navigator.of(context).pop(model),
|
||||
),
|
||||
const VSpace(4.0),
|
||||
_Body(
|
||||
models: widget.models,
|
||||
selectedModel: model,
|
||||
onSelectModel: (format) {
|
||||
setState(() => model = format);
|
||||
},
|
||||
),
|
||||
const VSpace(16.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({
|
||||
required this.onCancel,
|
||||
required this.onDone,
|
||||
});
|
||||
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onDone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 44.0,
|
||||
child: Stack(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: AppBarBackButton(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
),
|
||||
onTap: onCancel,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_switchModel_label.tr(),
|
||||
fontSize: 17.0,
|
||||
fontWeight: FontWeight.w500,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: AppBarDoneButton(
|
||||
onTap: onDone,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
const _Body({
|
||||
required this.models,
|
||||
required this.selectedModel,
|
||||
required this.onSelectModel,
|
||||
});
|
||||
|
||||
final List<AIModelPB> models;
|
||||
final AIModelPB? selectedModel;
|
||||
final void Function(AIModelPB) onSelectModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: models
|
||||
.mapIndexed(
|
||||
(index, model) => _buildModelButton(model, index == 0),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModelButton(
|
||||
AIModelPB model, [
|
||||
bool isFirst = false,
|
||||
]) {
|
||||
return FlowyOptionTile.checkbox(
|
||||
text: model.name,
|
||||
isSelected: model == selectedModel,
|
||||
showTopBorder: isFirst,
|
||||
onTap: () {
|
||||
onSelectModel(model);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+29
-3
@@ -64,8 +64,12 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.markdown != widget.markdown) {
|
||||
editorState.dispose();
|
||||
editorState = _parseMarkdown(widget.markdown.trim());
|
||||
final editorState = _parseMarkdown(
|
||||
widget.markdown.trim(),
|
||||
previousDocument: this.editorState.document,
|
||||
);
|
||||
this.editorState.dispose();
|
||||
this.editorState = editorState;
|
||||
scrollController.dispose();
|
||||
scrollController = EditorScrollController(
|
||||
editorState: editorState,
|
||||
@@ -129,8 +133,30 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
);
|
||||
}
|
||||
|
||||
EditorState _parseMarkdown(String markdown) {
|
||||
EditorState _parseMarkdown(
|
||||
String markdown, {
|
||||
Document? previousDocument,
|
||||
}) {
|
||||
// merge the nodes from the previous document with the new document to keep the same node ids
|
||||
final document = customMarkdownToDocument(markdown);
|
||||
final documentIterator = NodeIterator(
|
||||
document: document,
|
||||
startNode: document.root,
|
||||
);
|
||||
if (previousDocument != null) {
|
||||
final previousDocumentIterator = NodeIterator(
|
||||
document: previousDocument,
|
||||
startNode: previousDocument.root,
|
||||
);
|
||||
while (
|
||||
documentIterator.moveNext() && previousDocumentIterator.moveNext()) {
|
||||
final currentNode = documentIterator.current;
|
||||
final previousNode = previousDocumentIterator.current;
|
||||
if (currentNode.path.equals(previousNode.path)) {
|
||||
currentNode.id = previousNode.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
final editorState = EditorState(document: document);
|
||||
return editorState;
|
||||
}
|
||||
|
||||
+103
-7
@@ -21,6 +21,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
@@ -41,6 +42,7 @@ class AIMessageActionBar extends StatefulWidget {
|
||||
required this.showDecoration,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
this.onOverrideVisibility,
|
||||
});
|
||||
|
||||
@@ -48,6 +50,7 @@ class AIMessageActionBar extends StatefulWidget {
|
||||
final bool showDecoration;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function(PredefinedFormat)? onChangeFormat;
|
||||
final void Function(AIModelPB)? onChangeModel;
|
||||
final void Function(bool)? onOverrideVisibility;
|
||||
|
||||
@override
|
||||
@@ -126,6 +129,12 @@ class _AIMessageActionBarState extends State<AIMessageActionBar> {
|
||||
popoverMutex: popoverMutex,
|
||||
onOverrideVisibility: widget.onOverrideVisibility,
|
||||
),
|
||||
ChangeModelButton(
|
||||
isInHoverBar: widget.showDecoration,
|
||||
onRegenerate: widget.onChangeModel,
|
||||
popoverMutex: popoverMutex,
|
||||
onOverrideVisibility: widget.onOverrideVisibility,
|
||||
),
|
||||
SaveToPageButton(
|
||||
textMessage: widget.message as TextMessage,
|
||||
isInHoverBar: widget.showDecoration,
|
||||
@@ -260,8 +269,11 @@ class _ChangeFormatButtonState extends State<ChangeFormatButton> {
|
||||
constraints: const BoxConstraints(),
|
||||
onClose: () => widget.onOverrideVisibility?.call(false),
|
||||
child: buildButton(context),
|
||||
popupBuilder: (_) => _ChangeFormatPopoverContent(
|
||||
onRegenerate: widget.onRegenerate,
|
||||
popupBuilder: (_) => BlocProvider.value(
|
||||
value: context.read<AIPromptInputBloc>(),
|
||||
child: _ChangeFormatPopoverContent(
|
||||
onRegenerate: widget.onRegenerate,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -359,11 +371,16 @@ class _ChangeFormatPopoverContentState
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ChangeFormatBar(
|
||||
spacing: 2.0,
|
||||
predefinedFormat: predefinedFormat,
|
||||
onSelectPredefinedFormat: (format) {
|
||||
setState(() => predefinedFormat = format);
|
||||
BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
|
||||
builder: (context, state) {
|
||||
return ChangeFormatBar(
|
||||
spacing: 2.0,
|
||||
showImageFormats: state.aiType.isCloud,
|
||||
predefinedFormat: predefinedFormat,
|
||||
onSelectPredefinedFormat: (format) {
|
||||
setState(() => predefinedFormat = format);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const HSpace(4.0),
|
||||
@@ -397,6 +414,85 @@ class _ChangeFormatPopoverContentState
|
||||
}
|
||||
}
|
||||
|
||||
class ChangeModelButton extends StatefulWidget {
|
||||
const ChangeModelButton({
|
||||
super.key,
|
||||
required this.isInHoverBar,
|
||||
this.popoverMutex,
|
||||
this.onRegenerate,
|
||||
this.onOverrideVisibility,
|
||||
});
|
||||
|
||||
final bool isInHoverBar;
|
||||
final PopoverMutex? popoverMutex;
|
||||
final void Function(AIModelPB)? onRegenerate;
|
||||
final void Function(bool)? onOverrideVisibility;
|
||||
|
||||
@override
|
||||
State<ChangeModelButton> createState() => _ChangeModelButtonState();
|
||||
}
|
||||
|
||||
class _ChangeModelButtonState extends State<ChangeModelButton> {
|
||||
final popoverController = PopoverController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppFlowyPopover(
|
||||
controller: popoverController,
|
||||
mutex: widget.popoverMutex,
|
||||
triggerActions: PopoverTriggerFlags.none,
|
||||
margin: EdgeInsets.zero,
|
||||
offset: Offset(8, 0),
|
||||
direction: PopoverDirection.rightWithBottomAligned,
|
||||
constraints: BoxConstraints(maxWidth: 250, maxHeight: 600),
|
||||
onClose: () => widget.onOverrideVisibility?.call(false),
|
||||
child: buildButton(context),
|
||||
popupBuilder: (_) {
|
||||
final bloc = context.read<AIPromptInputBloc>();
|
||||
final (models, _) = bloc.aiModelStateNotifier.getAvailableModels();
|
||||
return SelectModelPopoverContent(
|
||||
models: models,
|
||||
selectedModel: null,
|
||||
onSelectModel: widget.onRegenerate,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildButton(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.chat_switchModel_label.tr(),
|
||||
child: FlowyIconButton(
|
||||
width: 32.0,
|
||||
height: DesktopAIChatSizes.messageActionBarIconSize,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: widget.isInHoverBar
|
||||
? DesktopAIChatSizes.messageHoverActionBarIconRadius
|
||||
: DesktopAIChatSizes.messageActionBarIconRadius,
|
||||
icon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_sparks_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(16),
|
||||
),
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_source_drop_down_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
widget.onOverrideVisibility?.call(true);
|
||||
popoverController.show();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SaveToPageButton extends StatefulWidget {
|
||||
const SaveToPageButton({
|
||||
super.key,
|
||||
|
||||
+51
-15
@@ -12,6 +12,7 @@ import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -23,6 +24,7 @@ import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../chat_avatar.dart';
|
||||
import '../layout_define.dart';
|
||||
import 'ai_change_model_bottom_sheet.dart';
|
||||
import 'ai_message_action_bar.dart';
|
||||
import 'ai_change_format_bottom_sheet.dart';
|
||||
import 'message_util.dart';
|
||||
@@ -41,6 +43,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
this.isSelectingMessages = false,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
@@ -50,6 +53,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
final bool isSelectingMessages;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function(PredefinedFormat)? onChangeFormat;
|
||||
final void Function(AIModelPB)? onChangeModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -73,6 +77,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
message: message,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -82,6 +87,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
message: message,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -91,6 +97,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
message: message,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -103,12 +110,14 @@ class ChatAIBottomInlineActions extends StatelessWidget {
|
||||
required this.message,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Message message;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function(PredefinedFormat)? onChangeFormat;
|
||||
final void Function(AIModelPB)? onChangeModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -127,6 +136,7 @@ class ChatAIBottomInlineActions extends StatelessWidget {
|
||||
showDecoration: false,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
),
|
||||
),
|
||||
const VSpace(32.0),
|
||||
@@ -142,12 +152,14 @@ class ChatAIMessageHover extends StatefulWidget {
|
||||
required this.message,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Message message;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function(PredefinedFormat)? onChangeFormat;
|
||||
final void Function(AIModelPB)? onChangeModel;
|
||||
|
||||
@override
|
||||
State<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
|
||||
@@ -217,22 +229,23 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
|
||||
setState(() => hoverActionBar = false);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 784,
|
||||
maxHeight: DesktopAIChatSizes.messageActionBarIconSize +
|
||||
DesktopAIChatSizes
|
||||
.messageHoverActionBarPadding.vertical,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 784,
|
||||
height: DesktopAIChatSizes.messageActionBarIconSize +
|
||||
DesktopAIChatSizes.messageHoverActionBarPadding.vertical,
|
||||
child: hoverBubble || hoverActionBar || overrideVisibility
|
||||
? AIMessageActionBar(
|
||||
message: widget.message,
|
||||
showDecoration: true,
|
||||
onRegenerate: widget.onRegenerate,
|
||||
onChangeFormat: widget.onChangeFormat,
|
||||
onOverrideVisibility: (visibility) {
|
||||
overrideVisibility = visibility;
|
||||
},
|
||||
? Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: AIMessageActionBar(
|
||||
message: widget.message,
|
||||
showDecoration: true,
|
||||
onRegenerate: widget.onRegenerate,
|
||||
onChangeFormat: widget.onChangeFormat,
|
||||
onChangeModel: widget.onChangeModel,
|
||||
onOverrideVisibility: (visibility) {
|
||||
overrideVisibility = visibility;
|
||||
},
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
@@ -302,12 +315,14 @@ class ChatAIMessagePopup extends StatelessWidget {
|
||||
required this.message,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Message message;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function(PredefinedFormat)? onChangeFormat;
|
||||
final void Function(AIModelPB)? onChangeModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -328,6 +343,8 @@ class ChatAIMessagePopup extends StatelessWidget {
|
||||
_divider(),
|
||||
_changeFormatButton(context),
|
||||
_divider(),
|
||||
_changeModelButton(context),
|
||||
_divider(),
|
||||
_saveToPageButton(context),
|
||||
],
|
||||
);
|
||||
@@ -399,6 +416,25 @@ class ChatAIMessagePopup extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _changeModelButton(BuildContext context) {
|
||||
return MobileQuickActionButton(
|
||||
onTap: () async {
|
||||
final bloc = context.read<AIPromptInputBloc>();
|
||||
final (models, _) = bloc.aiModelStateNotifier.getAvailableModels();
|
||||
final result = await showChangeModelBottomSheet(context, models);
|
||||
if (result != null) {
|
||||
onChangeModel?.call(result);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: FlowySvgs.ai_sparks_s,
|
||||
iconSize: const Size.square(20),
|
||||
text: LocaleKeys.chat_switchModel_label.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _saveToPageButton(BuildContext context) {
|
||||
return MobileQuickActionButton(
|
||||
onTap: () async {
|
||||
|
||||
+18
-1
@@ -4,6 +4,7 @@ import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_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_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -32,9 +33,11 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
required this.questionId,
|
||||
required this.chatId,
|
||||
required this.refSourceJsonString,
|
||||
required this.onStopStream,
|
||||
this.onSelectedMetadata,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
this.isLastMessage = false,
|
||||
this.isStreaming = false,
|
||||
this.isSelectingMessages = false,
|
||||
@@ -50,7 +53,9 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
final String? refSourceJsonString;
|
||||
final void Function(ChatMessageRefSource metadata)? onSelectedMetadata;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function() onStopStream;
|
||||
final void Function(PredefinedFormat)? onChangeFormat;
|
||||
final void Function(AIModelPB)? onChangeModel;
|
||||
final bool isStreaming;
|
||||
final bool isLastMessage;
|
||||
final bool isSelectingMessages;
|
||||
@@ -108,10 +113,13 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
isSelectingMessages: isSelectingMessages,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AIMarkdownText(markdown: state.text),
|
||||
AIMarkdownText(
|
||||
markdown: state.text,
|
||||
),
|
||||
if (state.sources.isNotEmpty)
|
||||
SelectionContainer.disabled(
|
||||
child: AIMessageMetadata(
|
||||
@@ -146,6 +154,15 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
errorMessage: message,
|
||||
);
|
||||
},
|
||||
onInitializingLocalAI: () {
|
||||
onStopStream();
|
||||
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys
|
||||
.settings_aiPage_keys_localAIInitializing
|
||||
.tr(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
-9
@@ -47,15 +47,6 @@ class NumberCellBloc extends Bloc<NumberCellEvent, NumberCellState> {
|
||||
if (state.content != text) {
|
||||
emit(state.copyWith(content: text));
|
||||
await cellController.saveCellData(text);
|
||||
|
||||
// If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string.
|
||||
// So for every cell data that will be formatted in the backend.
|
||||
// It needs to get the formatted data after saving.
|
||||
add(
|
||||
NumberCellEvent.didReceiveCellUpdate(
|
||||
cellController.getCellData(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -847,7 +847,7 @@ class _BoardTrailingState extends State<BoardTrailing> {
|
||||
suffixIcon: Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8.0),
|
||||
child: FlowyIconButton(
|
||||
icon: const FlowySvg(FlowySvgs.close_filled_m),
|
||||
icon: const FlowySvg(FlowySvgs.close_filled_s),
|
||||
hoverColor: Colors.transparent,
|
||||
onPressed: () => _textController.clear(),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_con
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/shared/flowy_error_page.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
@@ -71,9 +72,16 @@ class _RowEditor extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) =>
|
||||
DocumentBloc(documentId: view.id)..add(const DocumentEvent.initial()),
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (_) => DocumentBloc(documentId: view.id)
|
||||
..add(const DocumentEvent.initial()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()),
|
||||
),
|
||||
],
|
||||
child: BlocConsumer<DocumentBloc, DocumentState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.isDocumentEmpty != current.isDocumentEmpty,
|
||||
|
||||
+2
-2
@@ -24,11 +24,11 @@ extension DatabaseSettingActionExtension on DatabaseSettingAction {
|
||||
case DatabaseSettingAction.showProperties:
|
||||
return FlowySvgs.multiselect_s;
|
||||
case DatabaseSettingAction.showLayout:
|
||||
return FlowySvgs.database_layout_m;
|
||||
return FlowySvgs.database_layout_s;
|
||||
case DatabaseSettingAction.showGroup:
|
||||
return FlowySvgs.group_s;
|
||||
case DatabaseSettingAction.showCalendarLayout:
|
||||
return FlowySvgs.calendar_layout_m;
|
||||
return FlowySvgs.calendar_layout_s;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../workspace/application/view/view_bloc.dart';
|
||||
|
||||
// This widget is largely copied from `plugins/document/document_page.dart` intentionally instead of opting for an abstraction. We can make an abstraction after the view refactor is done and there's more clarity in that department.
|
||||
|
||||
class DatabaseDocumentPage extends StatefulWidget {
|
||||
@@ -72,6 +74,10 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
|
||||
documentId: widget.documentId,
|
||||
)..add(const DocumentEvent.initial()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) =>
|
||||
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<DocumentBloc, DocumentState>(
|
||||
builder: (context, state) {
|
||||
|
||||
@@ -272,12 +272,12 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
}
|
||||
|
||||
if (options.inMemoryUpdate) {
|
||||
Log.info('skip transaction for in-memory update');
|
||||
Log.trace('skip transaction for in-memory update');
|
||||
return;
|
||||
}
|
||||
|
||||
if (enableDocumentInternalLog) {
|
||||
Log.debug(
|
||||
Log.trace(
|
||||
'[TransactionAdapter] 1. transaction before apply: ${transaction.hashCode}',
|
||||
);
|
||||
}
|
||||
@@ -289,7 +289,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
await _documentRules.applyRules(value: value);
|
||||
|
||||
if (enableDocumentInternalLog) {
|
||||
Log.debug(
|
||||
Log.trace(
|
||||
'[TransactionAdapter] 4. transaction after apply: ${transaction.hashCode}',
|
||||
);
|
||||
}
|
||||
|
||||
-1
@@ -13,7 +13,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
NodeIterator,
|
||||
NodeExternalValues,
|
||||
HeadingBlockKeys,
|
||||
QuoteBlockKeys,
|
||||
NumberedListBlockKeys,
|
||||
BulletedListBlockKeys,
|
||||
blockComponentDelta;
|
||||
|
||||
@@ -104,6 +104,28 @@ class DocumentRules {
|
||||
} else {
|
||||
// otherwise, delete the column
|
||||
deleteColumnsTransaction.deleteNode(column);
|
||||
|
||||
final deletedColumnRatio =
|
||||
column.attributes[SimpleColumnBlockKeys.ratio];
|
||||
if (deletedColumnRatio != null) {
|
||||
// update the ratio of the columns
|
||||
final columnsNode = column.columnsParent;
|
||||
if (columnsNode != null) {
|
||||
final length = columnsNode.children.length;
|
||||
for (final columnNode in columnsNode.children) {
|
||||
final ratio =
|
||||
columnNode.attributes[SimpleColumnBlockKeys.ratio] ??
|
||||
1.0 / length;
|
||||
if (ratio != null) {
|
||||
deleteColumnsTransaction.updateNode(columnNode, {
|
||||
...columnNode.attributes,
|
||||
SimpleColumnBlockKeys.ratio:
|
||||
ratio + deletedColumnRatio / (length - 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/banner.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart';
|
||||
@@ -88,9 +89,12 @@ class _DocumentPageState extends State<DocumentPage>
|
||||
BlocProvider.value(value: documentBloc),
|
||||
BlocProvider.value(
|
||||
value: ViewLockStatusBloc(view: widget.view)
|
||||
..add(
|
||||
ViewLockStatusEvent.initial(),
|
||||
),
|
||||
..add(ViewLockStatusEvent.initial()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),
|
||||
lazy: false,
|
||||
),
|
||||
],
|
||||
child: BlocConsumer<ViewLockStatusBloc, ViewLockStatusState>(
|
||||
@@ -124,14 +128,20 @@ class _DocumentPageState extends State<DocumentPage>
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BlocListener<ViewLockStatusBloc, ViewLockStatusState>(
|
||||
listener: (context, state) {
|
||||
editorState.editable = !state.isLocked;
|
||||
},
|
||||
child:
|
||||
BlocListener<ActionNavigationBloc, ActionNavigationState>(
|
||||
listenWhen: (_, curr) => curr.action != null,
|
||||
listener: onNotificationAction,
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<ViewLockStatusBloc, ViewLockStatusState>(
|
||||
listener: (context, state) =>
|
||||
editorState.editable = !state.isLocked,
|
||||
),
|
||||
BlocListener<ActionNavigationBloc, ActionNavigationState>(
|
||||
listenWhen: (_, curr) => curr.action != null,
|
||||
listener: onNotificationAction,
|
||||
),
|
||||
],
|
||||
child: AiWriterScrollWrapper(
|
||||
viewId: widget.view.id,
|
||||
editorState: editorState,
|
||||
child: buildEditorPage(context, state),
|
||||
),
|
||||
);
|
||||
|
||||
+49
-4
@@ -6,7 +6,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mo
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys;
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
@@ -36,6 +37,7 @@ final Set<String> supportSlashMenuNodeTypes = {
|
||||
NumberedListBlockKeys.type,
|
||||
QuoteBlockKeys.type,
|
||||
ToggleListBlockKeys.type,
|
||||
CalloutBlockKeys.type,
|
||||
|
||||
// Simple table
|
||||
SimpleTableBlockKeys.type,
|
||||
@@ -121,10 +123,19 @@ BlockComponentConfiguration _buildDefaultConfiguration(BuildContext context) {
|
||||
},
|
||||
indentPadding: (node, textDirection) {
|
||||
double padding = 26.0;
|
||||
|
||||
// only add indent padding for the top level node to align the children
|
||||
if (UniversalPlatform.isMobile && node.path.length == 1) {
|
||||
padding += EditorStyleCustomizer.nodeHorizontalPadding;
|
||||
if (UniversalPlatform.isMobile && node.level == 1) {
|
||||
padding += EditorStyleCustomizer.nodeHorizontalPadding - 4;
|
||||
}
|
||||
|
||||
// in the quote block, we reduce the indent padding for the first level block.
|
||||
// So we have to add more padding for the second level to avoid the drag menu overlay the quote icon.
|
||||
if (node.parent?.type == QuoteBlockKeys.type &&
|
||||
UniversalPlatform.isDesktop) {
|
||||
padding += 22;
|
||||
}
|
||||
|
||||
return textDirection == TextDirection.ltr
|
||||
? EdgeInsets.only(left: padding)
|
||||
: EdgeInsets.only(right: padding);
|
||||
@@ -203,6 +214,16 @@ void _customBlockOptionActions(
|
||||
),
|
||||
);
|
||||
|
||||
builder.actionTrailingBuilder = (context, state) {
|
||||
if (context.node.parent?.type == QuoteBlockKeys.type) {
|
||||
return const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
};
|
||||
|
||||
builder.actionBuilder = (context, state) {
|
||||
double top = builder.configuration.padding(context.node).top;
|
||||
final type = context.node.type;
|
||||
@@ -592,6 +613,19 @@ QuoteBlockComponentBuilder _buildQuoteBlockComponentBuilder(
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
),
|
||||
indentPadding: (node, textDirection) {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return configuration.indentPadding(node, textDirection);
|
||||
}
|
||||
|
||||
if (node.isInTable) {
|
||||
return textDirection == TextDirection.ltr
|
||||
? EdgeInsets.only(left: 24)
|
||||
: EdgeInsets.only(right: 24);
|
||||
}
|
||||
|
||||
return EdgeInsets.zero;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -798,8 +832,14 @@ CalloutBlockComponentBuilder _buildCalloutBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
textSpan: textSpan,
|
||||
),
|
||||
indentPadding: (node, _) => EdgeInsets.only(left: 42),
|
||||
),
|
||||
inlinePadding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
inlinePadding: (node) {
|
||||
if (node.children.isEmpty) {
|
||||
return const EdgeInsets.symmetric(vertical: 8.0);
|
||||
}
|
||||
return EdgeInsets.only(top: 8.0, bottom: 2.0);
|
||||
},
|
||||
defaultColor: calloutBGColor,
|
||||
);
|
||||
}
|
||||
@@ -1024,6 +1064,11 @@ TextStyle _buildTextStyleInTableCell(
|
||||
}) {
|
||||
TextStyle textStyle = configuration.textStyle(node, textSpan: textSpan);
|
||||
|
||||
textStyle = textStyle.copyWith(
|
||||
fontFamily: textSpan?.style?.fontFamily,
|
||||
fontSize: textSpan?.style?.fontSize,
|
||||
);
|
||||
|
||||
if (node.isInHeaderColumn ||
|
||||
node.isInHeaderRow ||
|
||||
node.isInBoldColumn ||
|
||||
|
||||
@@ -15,17 +15,30 @@ import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
|
||||
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra/theme_extension_v2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
|
||||
import 'editor_plugins/toolbar_item/custom_format_toolbar_items.dart';
|
||||
import 'editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart';
|
||||
import 'editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
|
||||
import 'editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart';
|
||||
import 'editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart';
|
||||
import 'editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart';
|
||||
import 'editor_plugins/toolbar_item/more_option_toolbar_item.dart';
|
||||
import 'editor_plugins/toolbar_item/text_heading_toolbar_item.dart';
|
||||
import 'editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart';
|
||||
|
||||
/// Wrapper for the appflowy editor.
|
||||
class AppFlowyEditorPage extends StatefulWidget {
|
||||
const AppFlowyEditorPage({
|
||||
@@ -81,23 +94,25 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
];
|
||||
|
||||
final List<ToolbarItem> toolbarItems = [
|
||||
improveWritingItem..isActive = onlyShowInTextTypeAndExcludeTable,
|
||||
aiWriterItem..isActive = onlyShowInTextTypeAndExcludeTable,
|
||||
paragraphItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
|
||||
headingsToolbarItem
|
||||
..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
|
||||
...markdownFormatItems..forEach((e) => e.isActive = showInAnyTextType),
|
||||
quoteItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
|
||||
bulletedListItem
|
||||
..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
|
||||
numberedListItem
|
||||
..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable,
|
||||
inlineMathEquationItem,
|
||||
linkItem,
|
||||
alignToolbarItem,
|
||||
buildTextColorItem()..isActive = showInAnyTextType,
|
||||
buildHighlightColorItem()..isActive = showInAnyTextType,
|
||||
customizeFontToolbarItem..isActive = showInAnyTextType,
|
||||
improveWritingItem,
|
||||
group0PaddingItem,
|
||||
aiWriterItem,
|
||||
customTextHeadingItem,
|
||||
buildPaddingPlaceholderItem(
|
||||
1,
|
||||
isActive: onlyShowInSingleTextTypeSelectionAndExcludeTable,
|
||||
),
|
||||
...customMarkdownFormatItems,
|
||||
group1PaddingItem,
|
||||
customTextColorItem,
|
||||
group1PaddingItem,
|
||||
customHighlightColorItem,
|
||||
customInlineCodeItem,
|
||||
suggestionsItem,
|
||||
customLinkItem,
|
||||
group4PaddingItem,
|
||||
customTextAlignItem,
|
||||
moreOptionItem,
|
||||
];
|
||||
|
||||
List<CharacterShortcutEvent> get characterShortcutEvents {
|
||||
@@ -154,10 +169,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
InlineMathEquationKeys.formula,
|
||||
]);
|
||||
|
||||
indentableBlockTypes.add(ToggleListBlockKeys.type);
|
||||
indentableBlockTypes.addAll([
|
||||
ToggleListBlockKeys.type,
|
||||
CalloutBlockKeys.type,
|
||||
QuoteBlockKeys.type,
|
||||
]);
|
||||
convertibleBlockTypes.addAll([
|
||||
ToggleListBlockKeys.type,
|
||||
CalloutBlockKeys.type,
|
||||
QuoteBlockKeys.type,
|
||||
]);
|
||||
|
||||
editorLaunchUrl = (url) {
|
||||
@@ -331,6 +351,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
final isViewDeleted = context.read<DocumentBloc>().state.isDeleted;
|
||||
final isLocked =
|
||||
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
|
||||
|
||||
final themeV2 = AFThemeExtensionV2.of(context);
|
||||
final editor = Directionality(
|
||||
textDirection: textDirection,
|
||||
child: AppFlowyEditor(
|
||||
@@ -363,7 +385,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
contextMenuItems: customContextMenuItems,
|
||||
// customize the header and footer.
|
||||
header: widget.header,
|
||||
|
||||
autoScrollEdgeOffset: UniversalPlatform.isDesktopOrWeb
|
||||
? 250
|
||||
: appFlowyEditorAutoScrollEdgeOffset,
|
||||
footer: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () async {
|
||||
@@ -372,7 +396,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
},
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: UniversalPlatform.isDesktopOrWeb ? 200 : 400,
|
||||
height: UniversalPlatform.isDesktopOrWeb ? 600 : 400,
|
||||
),
|
||||
),
|
||||
dropTargetStyle: AppFlowyDropTargetStyle(
|
||||
@@ -402,15 +426,39 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
anchor: anchor,
|
||||
closeToolbar: closeToolbar,
|
||||
),
|
||||
floatingToolbarHeight: 32,
|
||||
child: editor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: FloatingToolbar(
|
||||
style: styleCustomizer.floatingToolbarStyleBuilder(),
|
||||
floatingToolbarHeight: 40,
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
style: FloatingToolbarStyle(
|
||||
backgroundColor: Theme.of(context).cardColor,
|
||||
toolbarActiveColor: Color(0xffe0f8fd),
|
||||
),
|
||||
items: toolbarItems,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
offset: Offset(0, 4),
|
||||
blurRadius: 24,
|
||||
color: themeV2.shadow_medium,
|
||||
),
|
||||
],
|
||||
),
|
||||
toolbarBuilder: (context, child, onDismiss, isMetricsChanged) =>
|
||||
DesktopFloatingToolbar(
|
||||
editorState: editorState,
|
||||
onDismiss: onDismiss,
|
||||
enableAnimation: !isMetricsChanged,
|
||||
child: child,
|
||||
),
|
||||
placeHolderBuilder: (_) => customPlaceholderItem,
|
||||
editorState: editorState,
|
||||
editorScrollController: editorScrollController,
|
||||
textDirection: textDirection,
|
||||
@@ -432,11 +480,13 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
}) {
|
||||
final documentBloc = context.read<DocumentBloc>();
|
||||
final isLocalMode = documentBloc.isLocalMode;
|
||||
final view = context.read<ViewBloc>().state.view;
|
||||
return slashMenuItemsBuilder(
|
||||
editorState: editorState,
|
||||
node: node,
|
||||
isLocalMode: isLocalMode,
|
||||
documentBloc: documentBloc,
|
||||
view: view,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+21
-13
@@ -10,7 +10,8 @@ import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
hide QuoteBlockKeys, quoteNode;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class BlockActionOptionState {}
|
||||
@@ -258,11 +259,13 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
emit(BlockActionOptionState()); // Emit a new state to trigger UI update
|
||||
}
|
||||
|
||||
Future<bool> turnIntoBlock(
|
||||
static Future<bool> turnIntoBlock(
|
||||
String type,
|
||||
Node node, {
|
||||
Node node,
|
||||
EditorState editorState, {
|
||||
int? level,
|
||||
String? currentViewId,
|
||||
bool keepSelection = false,
|
||||
}) async {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
@@ -286,6 +289,8 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
type: toType,
|
||||
selectedNodes: selectedNodes,
|
||||
level: level,
|
||||
editorState: editorState,
|
||||
afterSelection: keepSelection ? selection : null,
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
@@ -297,6 +302,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
selectedNodes: selectedNodes,
|
||||
selection: selection,
|
||||
currentViewId: currentViewId,
|
||||
editorState: editorState,
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
@@ -321,9 +327,8 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
},
|
||||
);
|
||||
|
||||
// heading block and callout block should not have children
|
||||
if ([HeadingBlockKeys.type, CalloutBlockKeys.type, QuoteBlockKeys.type]
|
||||
.contains(toType)) {
|
||||
// heading block should not have children
|
||||
if ([HeadingBlockKeys.type].contains(toType)) {
|
||||
afterNode = afterNode.copyWith(children: []);
|
||||
afterNode = await _handleSubPageNode(afterNode, node);
|
||||
insertedNode.add(afterNode);
|
||||
@@ -344,6 +349,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
insertedNode,
|
||||
);
|
||||
transaction.deleteNodes(selectedNodes);
|
||||
if (keepSelection) transaction.afterSelection = selection;
|
||||
await editorState.apply(transaction);
|
||||
|
||||
return true;
|
||||
@@ -353,7 +359,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
///
|
||||
/// Returns the altered [Node] with the delta as the Views' name.
|
||||
///
|
||||
Future<Node> _handleSubPageNode(Node node, Node subPageNode) async {
|
||||
static Future<Node> _handleSubPageNode(Node node, Node subPageNode) async {
|
||||
if (subPageNode.type != SubPageBlockKeys.type) {
|
||||
return node;
|
||||
}
|
||||
@@ -370,7 +376,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
/// Returns the [Delta] from a SubPage [Node], where the
|
||||
/// [Delta] is the views' name.
|
||||
///
|
||||
Future<Delta?> _deltaFromSubPageNode(Node node) async {
|
||||
static Future<Delta?> _deltaFromSubPageNode(Node node) async {
|
||||
if (node.type != SubPageBlockKeys.type) {
|
||||
return null;
|
||||
}
|
||||
@@ -400,9 +406,10 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
// - paragraph 1
|
||||
// - paragraph 2
|
||||
// when turning "Toggle Heading 1" into toggle heading, the bulleted items will be moved into the toggle heading
|
||||
Future<bool> turnIntoSingleToggleHeading({
|
||||
static Future<bool> turnIntoSingleToggleHeading({
|
||||
required String type,
|
||||
required List<Node> selectedNodes,
|
||||
required EditorState editorState,
|
||||
int? level,
|
||||
Delta? delta,
|
||||
Selection? afterSelection,
|
||||
@@ -462,7 +469,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
blockComponentDelta: newDelta.toJson(),
|
||||
},
|
||||
children: [
|
||||
...node.children,
|
||||
...node.children.map((e) => e.deepCopy()),
|
||||
...insertedNodes.map((e) => e.deepCopy()),
|
||||
],
|
||||
);
|
||||
@@ -492,11 +499,12 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> turnIntoPage({
|
||||
static Future<bool> turnIntoPage({
|
||||
required String type,
|
||||
required List<Node> selectedNodes,
|
||||
required Selection selection,
|
||||
required String currentViewId,
|
||||
required EditorState editorState,
|
||||
}) async {
|
||||
if (type != SubPageBlockKeys.type || selectedNodes.isEmpty) {
|
||||
return false;
|
||||
@@ -552,7 +560,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<String> _extractNameFromNodes(List<Node>? nodes) async {
|
||||
static Future<String> _extractNameFromNodes(List<Node>? nodes) async {
|
||||
if (nodes == null || nodes.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
@@ -602,7 +610,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
return name.substring(0, name.length > 30 ? 30 : name.length);
|
||||
}
|
||||
|
||||
List<String> _extractChildViewIds(List<Node> nodes) {
|
||||
static List<String> _extractChildViewIds(List<Node> nodes) {
|
||||
final List<String> viewIds = [];
|
||||
for (final node in nodes) {
|
||||
if (node.type == SubPageBlockKeys.type) {
|
||||
|
||||
+2
-2
@@ -89,7 +89,7 @@ class _DraggableOptionButtonState extends State<DraggableOptionButton> {
|
||||
interceptor: (context, targetNode) {
|
||||
// if the cursor node is in a columns block or a column block,
|
||||
// we will return the node's parent instead to support dragging a node to the inside of a columns block or a column block.
|
||||
final parentColumnNode = targetNode.parentColumn;
|
||||
final parentColumnNode = targetNode.columnParent;
|
||||
if (parentColumnNode != null) {
|
||||
final position = getDragAreaPosition(
|
||||
context,
|
||||
@@ -147,7 +147,7 @@ class _DraggableOptionButtonState extends State<DraggableOptionButton> {
|
||||
interceptor: (context, targetNode) {
|
||||
// if the cursor node is in a columns block or a column block,
|
||||
// we will return the node's parent instead to support dragging a node to the inside of a columns block or a column block.
|
||||
final parentColumnNode = targetNode.parentColumn;
|
||||
final parentColumnNode = targetNode.columnParent;
|
||||
if (parentColumnNode != null) {
|
||||
final position = getDragAreaPosition(
|
||||
context,
|
||||
|
||||
+32
-40
@@ -1,7 +1,7 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
hide QuoteBlockKeys, quoteNode;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -59,38 +59,37 @@ Future<void> dragToMoveNode(
|
||||
// 1. if the targetNode is a column block, it means we should create a column block to contain the node and insert the column node to the target node's parent
|
||||
// 2. if the targetNode is not a column block, it means we should create a columns block to contain the target node and the drag node
|
||||
final transaction = editorState.transaction;
|
||||
final targetNodeParent = targetNode.parentColumnsBlock;
|
||||
final targetNodeParent = targetNode.columnsParent;
|
||||
|
||||
if (targetNodeParent != null) {
|
||||
final length = targetNodeParent.children.length;
|
||||
final ratios = targetNodeParent.children
|
||||
.map(
|
||||
(e) =>
|
||||
e.attributes[SimpleColumnBlockKeys.ratio]?.toDouble() ??
|
||||
1.0 / length,
|
||||
)
|
||||
.map((e) => e * length / (length + 1))
|
||||
.toList();
|
||||
|
||||
final columnNode = simpleColumnNode(
|
||||
children: [node.deepCopy()],
|
||||
width: (node.rect.width * 1 / (length + 1)).clamp(
|
||||
SimpleColumnsBlockConstants.minimumColumnWidth,
|
||||
double.infinity,
|
||||
),
|
||||
ratio: 1.0 / (length + 1),
|
||||
);
|
||||
|
||||
for (final column in targetNodeParent.children) {
|
||||
final width =
|
||||
column.attributes[SimpleColumnBlockKeys.width]?.toDouble() ??
|
||||
SimpleColumnsBlockConstants.minimumColumnWidth;
|
||||
for (final (index, column) in targetNodeParent.children.indexed) {
|
||||
transaction.updateNode(column, {
|
||||
...column.attributes,
|
||||
SimpleColumnBlockKeys.width: (width * length / (length + 1)).clamp(
|
||||
SimpleColumnsBlockConstants.minimumColumnWidth,
|
||||
double.infinity,
|
||||
),
|
||||
SimpleColumnBlockKeys.ratio: ratios[index],
|
||||
});
|
||||
}
|
||||
|
||||
transaction.insertNode(targetNode.path.next, columnNode);
|
||||
transaction.deleteNode(node);
|
||||
} else {
|
||||
final width = targetNode.rect.width / 2 - 16;
|
||||
final columnsNode = simpleColumnsNode(
|
||||
children: [
|
||||
simpleColumnNode(children: [targetNode.deepCopy()], width: width),
|
||||
simpleColumnNode(children: [node.deepCopy()], width: width),
|
||||
simpleColumnNode(children: [targetNode.deepCopy()], ratio: 0.5),
|
||||
simpleColumnNode(children: [node.deepCopy()], ratio: 0.5),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -108,39 +107,37 @@ Future<void> dragToMoveNode(
|
||||
// 1. if the target node is a column block, we should create a column block to contain the node and insert the column node to the target node's parent
|
||||
// 2. if the target node is not a column block, we should create a columns block to contain the target node and the drag node
|
||||
final transaction = editorState.transaction;
|
||||
final targetNodeParent = targetNode.parentColumnsBlock;
|
||||
final targetNodeParent = targetNode.columnsParent;
|
||||
if (targetNodeParent != null) {
|
||||
// find the previous sibling node of the target node
|
||||
final length = targetNodeParent.children.length;
|
||||
final ratios = targetNodeParent.children
|
||||
.map(
|
||||
(e) =>
|
||||
e.attributes[SimpleColumnBlockKeys.ratio]?.toDouble() ??
|
||||
1.0 / length,
|
||||
)
|
||||
.map((e) => e * length / (length + 1))
|
||||
.toList();
|
||||
final columnNode = simpleColumnNode(
|
||||
children: [node.deepCopy()],
|
||||
width: (node.rect.width * 1 / (length + 1)).clamp(
|
||||
SimpleColumnsBlockConstants.minimumColumnWidth,
|
||||
double.infinity,
|
||||
),
|
||||
ratio: 1.0 / (length + 1),
|
||||
);
|
||||
|
||||
for (final column in targetNodeParent.children) {
|
||||
final width =
|
||||
column.attributes[SimpleColumnBlockKeys.width]?.toDouble() ??
|
||||
SimpleColumnsBlockConstants.minimumColumnWidth;
|
||||
for (final (index, column) in targetNodeParent.children.indexed) {
|
||||
transaction.updateNode(column, {
|
||||
...column.attributes,
|
||||
SimpleColumnBlockKeys.width: (width * length / (length + 1)).clamp(
|
||||
SimpleColumnsBlockConstants.minimumColumnWidth,
|
||||
double.infinity,
|
||||
),
|
||||
SimpleColumnBlockKeys.ratio: ratios[index],
|
||||
});
|
||||
}
|
||||
|
||||
transaction.insertNode(targetNode.path.previous, columnNode);
|
||||
transaction.deleteNode(node);
|
||||
} else {
|
||||
final width = targetNode.rect.width / 2 - 16;
|
||||
final columnsNode = simpleColumnsNode(
|
||||
children: [
|
||||
simpleColumnNode(children: [node.deepCopy()], width: width),
|
||||
simpleColumnNode(children: [targetNode.deepCopy()], width: width),
|
||||
simpleColumnNode(children: [node.deepCopy()], ratio: 0.5),
|
||||
simpleColumnNode(children: [targetNode.deepCopy()], ratio: 0.5),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -191,7 +188,6 @@ Future<void> dragToMoveNode(
|
||||
Node dragTargetNode,
|
||||
Offset dragOffset,
|
||||
) {
|
||||
debugPrint('getDragAreaPosition - dragTargetNode: ${dragTargetNode.type}');
|
||||
final selectable = dragTargetNode.selectable;
|
||||
final renderBox = selectable?.context.findRenderObject() as RenderBox?;
|
||||
if (selectable == null || renderBox == null) {
|
||||
@@ -250,10 +246,6 @@ Future<void> dragToMoveNode(
|
||||
verticalPosition = VerticalPosition.bottom;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'verticalPosition: $verticalPosition, horizontalPosition: $horizontalPosition',
|
||||
);
|
||||
|
||||
return (verticalPosition, horizontalPosition, globalBlockRect);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
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:appflowy_editor/appflowy_editor.dart'
|
||||
hide QuoteBlockKeys, quoteNode;
|
||||
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
|
||||
+125
-204
@@ -2,10 +2,10 @@ 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_option_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
hide QuoteBlockKeys, quoteNode;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -148,213 +148,134 @@ class TurnIntoOptionMenu extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (hasNonSupportedTypes) {
|
||||
return buildItem(
|
||||
pateItem,
|
||||
textSuggestionItem,
|
||||
context.read<BlockActionOptionCubit>().editorState,
|
||||
);
|
||||
}
|
||||
|
||||
return _buildTurnIntoOptions(context, node);
|
||||
}
|
||||
|
||||
Widget _buildTurnIntoOptions(BuildContext context, Node node) {
|
||||
final editorState = context.read<BlockActionOptionCubit>().editorState;
|
||||
SuggestionItem currentSuggestionItem = textSuggestionItem;
|
||||
final List<SuggestionItem> suggestionItems = suggestions.sublist(0, 4);
|
||||
final List<SuggestionItem> turnIntoItems =
|
||||
suggestions.sublist(4, suggestions.length);
|
||||
final textColor = Color(0xff99A1A8);
|
||||
|
||||
void refreshSuggestions() {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || !selection.isSingle) return;
|
||||
final node = editorState.getNodeAtPath(selection.start.path);
|
||||
if (node == null || node.delta == null) return;
|
||||
final nodeType = node.type;
|
||||
SuggestionType? suggestionType;
|
||||
if (nodeType == HeadingBlockKeys.type) {
|
||||
final level = node.attributes[HeadingBlockKeys.level] ?? 1;
|
||||
if (level == 1) {
|
||||
suggestionType = SuggestionType.h1;
|
||||
} else if (level == 2) {
|
||||
suggestionType = SuggestionType.h2;
|
||||
} else if (level == 3) {
|
||||
suggestionType = SuggestionType.h3;
|
||||
}
|
||||
} else if (nodeType == ToggleListBlockKeys.type) {
|
||||
final level = node.attributes[ToggleListBlockKeys.level];
|
||||
if (level == null) {
|
||||
suggestionType = SuggestionType.toggle;
|
||||
} else if (level == 1) {
|
||||
suggestionType = SuggestionType.toggleH1;
|
||||
} else if (level == 2) {
|
||||
suggestionType = SuggestionType.toggleH2;
|
||||
} else if (level == 3) {
|
||||
suggestionType = SuggestionType.toggleH3;
|
||||
}
|
||||
} else {
|
||||
suggestionType = nodeType2SuggestionType[nodeType];
|
||||
}
|
||||
if (suggestionType == null) return;
|
||||
suggestionItems.clear();
|
||||
turnIntoItems.clear();
|
||||
for (final item in suggestions) {
|
||||
if (item.type.group == suggestionType.group &&
|
||||
item.type != suggestionType) {
|
||||
suggestionItems.add(item);
|
||||
} else {
|
||||
turnIntoItems.add(item);
|
||||
}
|
||||
}
|
||||
currentSuggestionItem =
|
||||
suggestions.where((item) => item.type == suggestionType).first;
|
||||
}
|
||||
|
||||
refreshSuggestions();
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildTurnIntoOptions(context, node),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildSubTitle(
|
||||
LocaleKeys.document_toolbar_suggestions.tr(),
|
||||
textColor,
|
||||
),
|
||||
...List.generate(suggestionItems.length, (index) {
|
||||
return buildItem(
|
||||
suggestionItems[index],
|
||||
currentSuggestionItem,
|
||||
editorState,
|
||||
);
|
||||
}),
|
||||
buildSubTitle(LocaleKeys.document_toolbar_turnInto.tr(), textColor),
|
||||
...List.generate(turnIntoItems.length, (index) {
|
||||
return buildItem(
|
||||
turnIntoItems[index],
|
||||
currentSuggestionItem,
|
||||
editorState,
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTurnIntoOptions(BuildContext context, Node node) {
|
||||
final children = <Widget>[];
|
||||
Widget buildSubTitle(String text, Color color) {
|
||||
return Container(
|
||||
height: 32,
|
||||
margin: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FlowyText.semibold(
|
||||
text,
|
||||
color: color,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNonSupportedTypes) {
|
||||
return children
|
||||
..add(
|
||||
_TurnInfoButton(
|
||||
type: SubPageBlockKeys.type,
|
||||
node: node,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final type in EditorOptionActionType.turnInto.supportTypes) {
|
||||
if (type == ToggleListBlockKeys.type) {
|
||||
// toggle list block and toggle heading block are the same type,
|
||||
// but they have different attributes.
|
||||
|
||||
// toggle list block
|
||||
children.add(
|
||||
_TurnInfoButton(
|
||||
type: type,
|
||||
node: node,
|
||||
),
|
||||
);
|
||||
|
||||
// toggle heading block
|
||||
for (final i in [1, 2, 3]) {
|
||||
children.add(
|
||||
_TurnInfoButton(
|
||||
type: type,
|
||||
node: node,
|
||||
level: i,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (type != HeadingBlockKeys.type) {
|
||||
children.add(
|
||||
_TurnInfoButton(
|
||||
type: type,
|
||||
node: node,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
for (final i in [1, 2, 3]) {
|
||||
children.add(
|
||||
_TurnInfoButton(
|
||||
type: type,
|
||||
node: node,
|
||||
level: i,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
class _TurnInfoButton extends StatelessWidget {
|
||||
const _TurnInfoButton({
|
||||
required this.type,
|
||||
required this.node,
|
||||
this.level,
|
||||
});
|
||||
|
||||
final String type;
|
||||
final Node node;
|
||||
final int? level;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = _buildLocalization(type, level: level);
|
||||
final leftIcon = _buildLeftIcon(type, level: level);
|
||||
final rightIcon = _buildRightIcon(type, node, level: level);
|
||||
|
||||
return HoverButton(
|
||||
name: name,
|
||||
leftIcon: FlowySvg(leftIcon),
|
||||
rightIcon: rightIcon,
|
||||
itemHeight: ActionListSizes.itemHeight,
|
||||
onTap: () => context.read<BlockActionOptionCubit>().turnIntoBlock(
|
||||
type,
|
||||
node,
|
||||
level: level,
|
||||
currentViewId: getIt<MenuSharedState>().latestOpenView?.id,
|
||||
),
|
||||
Widget buildItem(
|
||||
SuggestionItem item,
|
||||
SuggestionItem currentSuggestionItem,
|
||||
EditorState state,
|
||||
) {
|
||||
final isSelected = item.type == currentSuggestionItem.type;
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: FlowyButton(
|
||||
leftIconSize: const Size.square(20),
|
||||
leftIcon: FlowySvg(item.svg),
|
||||
iconPadding: 12,
|
||||
text: FlowyText(
|
||||
item.title,
|
||||
fontWeight: FontWeight.w400,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
rightIcon: isSelected ? FlowySvg(FlowySvgs.toolbar_check_m) : null,
|
||||
onTap: () => item.onTap.call(state, false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildRightIcon(String type, Node node, {int? level}) {
|
||||
if (type != node.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.type == HeadingBlockKeys.type) {
|
||||
final nodeLevel = node.attributes[HeadingBlockKeys.level] ?? 1;
|
||||
if (level != nodeLevel) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type == ToggleListBlockKeys.type) {
|
||||
final nodeLevel = node.attributes[ToggleListBlockKeys.level];
|
||||
if (level != nodeLevel) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return const FlowySvg(
|
||||
FlowySvgs.workspace_selected_s,
|
||||
blendMode: null,
|
||||
);
|
||||
}
|
||||
|
||||
FlowySvgData _buildLeftIcon(String type, {int? level}) {
|
||||
if (type == ParagraphBlockKeys.type) {
|
||||
return FlowySvgs.slash_menu_icon_text_s;
|
||||
} else if (type == HeadingBlockKeys.type) {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return FlowySvgs.slash_menu_icon_h1_s;
|
||||
case 2:
|
||||
return FlowySvgs.slash_menu_icon_h2_s;
|
||||
case 3:
|
||||
return FlowySvgs.slash_menu_icon_h3_s;
|
||||
default:
|
||||
return FlowySvgs.slash_menu_icon_text_s;
|
||||
}
|
||||
} else if (type == QuoteBlockKeys.type) {
|
||||
return FlowySvgs.slash_menu_icon_quote_s;
|
||||
} else if (type == BulletedListBlockKeys.type) {
|
||||
return FlowySvgs.slash_menu_icon_bulleted_list_s;
|
||||
} else if (type == NumberedListBlockKeys.type) {
|
||||
return FlowySvgs.slash_menu_icon_numbered_list_s;
|
||||
} else if (type == TodoListBlockKeys.type) {
|
||||
return FlowySvgs.slash_menu_icon_checkbox_s;
|
||||
} else if (type == CalloutBlockKeys.type) {
|
||||
return FlowySvgs.slash_menu_icon_callout_s;
|
||||
} else if (type == SubPageBlockKeys.type) {
|
||||
return FlowySvgs.icon_document_s;
|
||||
} else if (type == ToggleListBlockKeys.type) {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return FlowySvgs.toggle_heading1_s;
|
||||
case 2:
|
||||
return FlowySvgs.toggle_heading2_s;
|
||||
case 3:
|
||||
return FlowySvgs.toggle_heading3_s;
|
||||
default:
|
||||
return FlowySvgs.slash_menu_icon_toggle_s;
|
||||
}
|
||||
}
|
||||
|
||||
throw UnimplementedError('Unsupported block type: $type');
|
||||
}
|
||||
|
||||
String _buildLocalization(
|
||||
String type, {
|
||||
int? level,
|
||||
}) {
|
||||
switch (type) {
|
||||
case ParagraphBlockKeys.type:
|
||||
return LocaleKeys.document_slashMenu_name_text.tr();
|
||||
case HeadingBlockKeys.type:
|
||||
switch (level) {
|
||||
case 1:
|
||||
return LocaleKeys.document_slashMenu_name_heading1.tr();
|
||||
case 2:
|
||||
return LocaleKeys.document_slashMenu_name_heading2.tr();
|
||||
case 3:
|
||||
return LocaleKeys.document_slashMenu_name_heading3.tr();
|
||||
default:
|
||||
return LocaleKeys.document_slashMenu_name_text.tr();
|
||||
}
|
||||
case QuoteBlockKeys.type:
|
||||
return LocaleKeys.document_slashMenu_name_quote.tr();
|
||||
case BulletedListBlockKeys.type:
|
||||
return LocaleKeys.document_slashMenu_name_bulletedList.tr();
|
||||
case NumberedListBlockKeys.type:
|
||||
return LocaleKeys.document_slashMenu_name_numberedList.tr();
|
||||
case TodoListBlockKeys.type:
|
||||
return LocaleKeys.document_slashMenu_name_todoList.tr();
|
||||
case CalloutBlockKeys.type:
|
||||
return LocaleKeys.document_slashMenu_name_callout.tr();
|
||||
case SubPageBlockKeys.type:
|
||||
return LocaleKeys.editor_page.tr();
|
||||
case ToggleListBlockKeys.type:
|
||||
switch (level) {
|
||||
case 1:
|
||||
return LocaleKeys.document_slashMenu_name_toggleHeading1.tr();
|
||||
case 2:
|
||||
return LocaleKeys.document_slashMenu_name_toggleHeading2.tr();
|
||||
case 3:
|
||||
return LocaleKeys.document_slashMenu_name_toggleHeading3.tr();
|
||||
default:
|
||||
return LocaleKeys.document_slashMenu_name_toggleList.tr();
|
||||
}
|
||||
}
|
||||
|
||||
throw UnimplementedError('Unsupported block type: $type');
|
||||
}
|
||||
}
|
||||
+275
-311
@@ -2,13 +2,11 @@ import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
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/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/colorscheme/default_colorscheme.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -17,7 +15,8 @@ import 'package:universal_platform/universal_platform.dart';
|
||||
import 'operations/ai_writer_cubit.dart';
|
||||
import 'operations/ai_writer_entities.dart';
|
||||
import 'operations/ai_writer_node_extension.dart';
|
||||
import 'suggestion_action_bar.dart';
|
||||
import 'widgets/ai_writer_suggestion_actions.dart';
|
||||
import 'widgets/ai_writer_prompt_input_more_button.dart';
|
||||
|
||||
class AiWriterBlockKeys {
|
||||
const AiWriterBlockKeys._();
|
||||
@@ -66,6 +65,10 @@ class AIWriterBlockComponentBuilder extends BlockComponentBuilder {
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
actionTrailingBuilder: (context, state) => actionTrailingBuilder(
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +86,7 @@ class AiWriterBlockComponent extends BlockComponentStatefulWidget {
|
||||
required super.node,
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.actionTrailingBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
@@ -91,19 +95,12 @@ class AiWriterBlockComponent extends BlockComponentStatefulWidget {
|
||||
}
|
||||
|
||||
class _AIWriterBlockComponentState extends State<AiWriterBlockComponent> {
|
||||
final key = GlobalKey();
|
||||
final textController = TextEditingController();
|
||||
final textFieldFocusNode = FocusNode();
|
||||
final overlayController = OverlayPortalController();
|
||||
final layerLink = LayerLink();
|
||||
final focusNode = FocusNode();
|
||||
|
||||
late final editorState = context.read<EditorState>();
|
||||
late final aiWriterCubit = AiWriterCubit(
|
||||
documentId: context.read<DocumentBloc>().documentId,
|
||||
editorState: editorState,
|
||||
getAiWriterNode: () => widget.node,
|
||||
initialCommand: widget.node.aiWriterCommand,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -111,18 +108,14 @@ class _AIWriterBlockComponentState extends State<AiWriterBlockComponent> {
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
overlayController.show();
|
||||
textFieldFocusNode.requestFocus();
|
||||
if (!widget.node.isAiWriterInitialized) {
|
||||
aiWriterCubit.init();
|
||||
}
|
||||
context.read<AiWriterCubit>().register(widget.node);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
textController.dispose();
|
||||
textFieldFocusNode.dispose();
|
||||
aiWriterCubit.close();
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -132,71 +125,49 @@ class _AIWriterBlockComponentState extends State<AiWriterBlockComponent> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(
|
||||
value: aiWriterCubit,
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => AIPromptInputBloc(
|
||||
predefinedFormat: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
final documentId = context.read<DocumentBloc?>()?.documentId;
|
||||
|
||||
return BlocProvider(
|
||||
create: (_) => AIPromptInputBloc(
|
||||
predefinedFormat: null,
|
||||
objectId: documentId ?? editorState.document.root.id,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return BlocListener<AiWriterCubit, AiWriterState>(
|
||||
listener: (context, state) {
|
||||
if (state is SingleShotAiWriterState) {
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title: state.title,
|
||||
description: state.description,
|
||||
onConfirm: state.onDismiss,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: OverlayPortal(
|
||||
controller: overlayController,
|
||||
overlayChildBuilder: (context) {
|
||||
return Stack(
|
||||
children: [
|
||||
BlocBuilder<AiWriterCubit, AiWriterState>(
|
||||
builder: (context, state) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onTapOutside(),
|
||||
onTapDown: (_) => onTapOutside(),
|
||||
);
|
||||
},
|
||||
return OverlayPortal(
|
||||
controller: overlayController,
|
||||
overlayChildBuilder: (context) {
|
||||
return Center(
|
||||
child: CompositedTransformFollower(
|
||||
link: layerLink,
|
||||
showWhenUnlinked: false,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 40.0,
|
||||
bottom: 16.0,
|
||||
),
|
||||
CompositedTransformFollower(
|
||||
link: layerLink,
|
||||
showWhenUnlinked: false,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 40.0,
|
||||
bottom: 16.0,
|
||||
),
|
||||
width: constraints.maxWidth,
|
||||
child: OverlayContent(
|
||||
node: widget.node,
|
||||
),
|
||||
width: constraints.maxWidth,
|
||||
child: Focus(
|
||||
focusNode: focusNode,
|
||||
child: OverlayContent(
|
||||
editorState: editorState,
|
||||
node: widget.node,
|
||||
textController: textController,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: CompositedTransformTarget(
|
||||
link: layerLink,
|
||||
child: BlocBuilder<AiWriterCubit, AiWriterState>(
|
||||
builder: (context, state) {
|
||||
return SizedBox(
|
||||
key: key,
|
||||
width: double.infinity,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: CompositedTransformTarget(
|
||||
link: layerLink,
|
||||
child: BlocBuilder<AiWriterCubit, AiWriterState>(
|
||||
builder: (context, state) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 1.0,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -204,78 +175,84 @@ class _AIWriterBlockComponentState extends State<AiWriterBlockComponent> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onTapOutside() {
|
||||
if (aiWriterCubit.hasUnusedResponse()) {
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.button_discard.tr(),
|
||||
description: LocaleKeys.document_plugins_discardResponse.tr(),
|
||||
confirmLabel: LocaleKeys.button_discard.tr(),
|
||||
style: ConfirmPopupStyle.cancelAndOk,
|
||||
onConfirm: () => aiWriterCubit
|
||||
..stopStream()
|
||||
..exit(),
|
||||
onCancel: () {},
|
||||
);
|
||||
} else {
|
||||
aiWriterCubit
|
||||
..stopStream()
|
||||
..exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OverlayContent extends StatelessWidget {
|
||||
class OverlayContent extends StatefulWidget {
|
||||
const OverlayContent({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
required this.node,
|
||||
required this.textController,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final Node node;
|
||||
final TextEditingController textController;
|
||||
|
||||
@override
|
||||
State<OverlayContent> createState() => _OverlayContentState();
|
||||
}
|
||||
|
||||
class _OverlayContentState extends State<OverlayContent> {
|
||||
final showCommandsToggle = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
showCommandsToggle.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AiWriterCubit, AiWriterState>(
|
||||
builder: (context, state) {
|
||||
final selection = node.aiWriterSelection;
|
||||
final showSuggestionPopup =
|
||||
state is ReadyAiWriterState && !state.isFirstRun;
|
||||
final showActionPopup = state is ReadyAiWriterState && state.isFirstRun;
|
||||
if (state is IdleAiWriterState ||
|
||||
state is DocumentContentEmptyAiWriterState) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final command = (state as RegisteredAiWriter).command;
|
||||
|
||||
final selection = widget.node.aiWriterSelection;
|
||||
final hasSelection = selection != null && !selection.isCollapsed;
|
||||
|
||||
final markdownText = switch (state) {
|
||||
final ReadyAiWriterState ready => ready.markdownText,
|
||||
final GeneratingAiWriterState generating => generating.markdownText,
|
||||
_ => '',
|
||||
};
|
||||
final hasSelection = selection != null && !selection.isCollapsed;
|
||||
|
||||
final isLightMode = Theme.of(context).isLightMode;
|
||||
final darkBorderColor =
|
||||
isLightMode ? Color(0x1F1F2329) : Color(0xFF505469);
|
||||
final lightBorderColor =
|
||||
Theme.of(context).brightness == Brightness.light
|
||||
? ColorSchemeConstants.lightBorderColor
|
||||
: ColorSchemeConstants.darkBorderColor;
|
||||
final showSuggestedActions =
|
||||
state is ReadyAiWriterState && !state.isFirstRun;
|
||||
final isInitialReadyState =
|
||||
state is ReadyAiWriterState && state.isFirstRun;
|
||||
final showSuggestedActionsPopup =
|
||||
showSuggestedActions && markdownText.isEmpty ||
|
||||
(markdownText.isNotEmpty && command != AiWriterCommand.explain);
|
||||
final showSuggestedActionsWithin = showSuggestedActions &&
|
||||
markdownText.isNotEmpty &&
|
||||
command == AiWriterCommand.explain;
|
||||
|
||||
final borderColor = Theme.of(context).isLightMode
|
||||
? Color(0x1F1F2329)
|
||||
: Color(0xFF505469);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showSuggestionPopup &&
|
||||
state.command != AiWriterCommand.explain) ...[
|
||||
if (showSuggestedActionsPopup) ...[
|
||||
Container(
|
||||
padding: EdgeInsets.all(4.0),
|
||||
decoration: _getModalDecoration(
|
||||
context,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
||||
borderColor: darkBorderColor,
|
||||
borderColor: borderColor,
|
||||
),
|
||||
child: SuggestionActionBar(
|
||||
actions: _getSuggestedActions(
|
||||
currentCommand: state.command,
|
||||
hasSelection: hasSelection,
|
||||
),
|
||||
currentCommand: command,
|
||||
hasSelection: hasSelection,
|
||||
onTap: (action) {
|
||||
_onSelectSuggestionAction(context, action);
|
||||
},
|
||||
@@ -283,149 +260,81 @@ class OverlayContent extends StatelessWidget {
|
||||
),
|
||||
const VSpace(4.0 + 1.0),
|
||||
],
|
||||
DecoratedBox(
|
||||
Container(
|
||||
decoration: _getModalDecoration(
|
||||
context,
|
||||
color: null,
|
||||
borderColor: darkBorderColor,
|
||||
borderColor: borderColor,
|
||||
borderRadius: BorderRadius.all(Radius.circular(12.0)),
|
||||
),
|
||||
constraints: BoxConstraints(maxHeight: 400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (markdownText.isNotEmpty) ...[
|
||||
DecoratedBox(
|
||||
decoration: _getHelperChildDecoration(context),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxHeight: 140),
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: ClampingScrollPhysics(),
|
||||
padding: EdgeInsets.only(top: 8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: 24.0,
|
||||
padding:
|
||||
EdgeInsets.symmetric(horizontal: 6.0),
|
||||
alignment:
|
||||
AlignmentDirectional.centerStart,
|
||||
child: FlowyText(
|
||||
state.command.i18n,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF666D76),
|
||||
),
|
||||
),
|
||||
const VSpace(4.0),
|
||||
Padding(
|
||||
padding:
|
||||
EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: AIMarkdownText(
|
||||
markdown: markdownText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showSuggestionPopup) ...[
|
||||
const VSpace(4.0),
|
||||
SuggestionActionBar(
|
||||
actions: _getSuggestedActions(
|
||||
currentCommand: state.command,
|
||||
hasSelection: hasSelection,
|
||||
),
|
||||
onTap: (action) {
|
||||
_onSelectSuggestionAction(context, action);
|
||||
},
|
||||
),
|
||||
],
|
||||
const VSpace(8.0),
|
||||
],
|
||||
Flexible(
|
||||
child: DecoratedBox(
|
||||
decoration: _secondaryContentDecoration(context),
|
||||
child: SecondaryContentArea(
|
||||
markdownText: markdownText,
|
||||
onSelectSuggestionAction: (action) {
|
||||
_onSelectSuggestionAction(context, action);
|
||||
},
|
||||
command: command,
|
||||
showSuggestionActions: showSuggestedActionsWithin,
|
||||
hasSelection: hasSelection,
|
||||
),
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
height: 1.0,
|
||||
),
|
||||
Divider(height: 1.0),
|
||||
],
|
||||
DecoratedBox(
|
||||
decoration: markdownText.isNotEmpty
|
||||
? _getInputChildDecoration(context)
|
||||
? _mainContentDecoration(context)
|
||||
: _getSingleChildDeocoration(context),
|
||||
child: MainContentArea(),
|
||||
child: MainContentArea(
|
||||
textController: widget.textController,
|
||||
isDocumentEmpty: _isDocumentEmpty(),
|
||||
isInitialReadyState: isInitialReadyState,
|
||||
showCommandsToggle: showCommandsToggle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showActionPopup) ...[
|
||||
const VSpace(4.0 + 1.0),
|
||||
Container(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
constraints: BoxConstraints(minWidth: 240.0),
|
||||
decoration: _getModalDecoration(
|
||||
context,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderColor: lightBorderColor,
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
||||
),
|
||||
child: IntrinsicWidth(
|
||||
child: SeparatedColumn(
|
||||
separatorBuilder: () => const VSpace(4.0),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _getCommands(
|
||||
hasSelection: hasSelection,
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: showCommandsToggle,
|
||||
builder: (context, value, child) {
|
||||
if (!value || !isInitialReadyState) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: MoreAiWriterCommands(
|
||||
hasSelection: hasSelection,
|
||||
editorState: widget.editorState,
|
||||
onSelectCommand: (command) {
|
||||
final state = context.read<AIPromptInputBloc>().state;
|
||||
final showPredefinedFormats = state.showPredefinedFormats;
|
||||
final predefinedFormat = state.predefinedFormat;
|
||||
final text = widget.textController.text;
|
||||
|
||||
context.read<AiWriterCubit>().runCommand(
|
||||
command,
|
||||
text,
|
||||
showPredefinedFormats ? predefinedFormat : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomButton(AiWriterCommand command) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return SizedBox(
|
||||
height: 30.0,
|
||||
child: FlowyButton(
|
||||
leftIcon: FlowySvg(
|
||||
command.icon,
|
||||
size: const Size.square(16),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
margin: const EdgeInsets.all(6.0),
|
||||
text: FlowyText(
|
||||
command.i18n,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
onTap: () {
|
||||
final aiInputBloc = context.read<AIPromptInputBloc>();
|
||||
final showPredefinedFormats =
|
||||
aiInputBloc.state.showPredefinedFormats;
|
||||
final predefinedFormat = aiInputBloc.state.predefinedFormat;
|
||||
|
||||
context.read<AiWriterCubit>().runCommand(
|
||||
command,
|
||||
showPredefinedFormats ? predefinedFormat : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
BoxDecoration _getModalDecoration(
|
||||
BuildContext context, {
|
||||
required Color? color,
|
||||
@@ -439,13 +348,9 @@ class OverlayContent extends StatelessWidget {
|
||||
strokeAlign: BorderSide.strokeAlignOutside,
|
||||
),
|
||||
borderRadius: borderRadius,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
offset: Offset(0, 4),
|
||||
blurRadius: 20,
|
||||
color: Color(0x1A1F2329),
|
||||
),
|
||||
],
|
||||
boxShadow: Theme.of(context).isLightMode
|
||||
? ShadowConstants.lightSmall
|
||||
: ShadowConstants.darkSmall,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -456,85 +361,20 @@ class OverlayContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
BoxDecoration _getHelperChildDecoration(BuildContext context) {
|
||||
BoxDecoration _secondaryContentDecoration(BuildContext context) {
|
||||
return BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12.0)),
|
||||
);
|
||||
}
|
||||
|
||||
BoxDecoration _getInputChildDecoration(BuildContext context) {
|
||||
BoxDecoration _mainContentDecoration(BuildContext context) {
|
||||
return BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.vertical(bottom: Radius.circular(12.0)),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _getCommands({required bool hasSelection}) {
|
||||
if (hasSelection) {
|
||||
return [
|
||||
_bottomButton(AiWriterCommand.improveWriting),
|
||||
_bottomButton(AiWriterCommand.fixSpellingAndGrammar),
|
||||
_bottomButton(AiWriterCommand.explain),
|
||||
const Divider(height: 1.0, thickness: 1.0),
|
||||
_bottomButton(AiWriterCommand.makeLonger),
|
||||
_bottomButton(AiWriterCommand.makeShorter),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
_bottomButton(AiWriterCommand.continueWriting),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
List<SuggestionAction> _getSuggestedActions({
|
||||
required AiWriterCommand currentCommand,
|
||||
required bool hasSelection,
|
||||
}) {
|
||||
if (hasSelection) {
|
||||
return switch (currentCommand) {
|
||||
AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [
|
||||
SuggestionAction.keep,
|
||||
SuggestionAction.discard,
|
||||
SuggestionAction.rewrite,
|
||||
],
|
||||
AiWriterCommand.explain => [
|
||||
SuggestionAction.insertBelow,
|
||||
SuggestionAction.tryAgain,
|
||||
SuggestionAction.close,
|
||||
],
|
||||
AiWriterCommand.fixSpellingAndGrammar ||
|
||||
AiWriterCommand.improveWriting ||
|
||||
AiWriterCommand.makeShorter ||
|
||||
AiWriterCommand.makeLonger =>
|
||||
[
|
||||
SuggestionAction.accept,
|
||||
SuggestionAction.discard,
|
||||
SuggestionAction.insertBelow,
|
||||
SuggestionAction.rewrite,
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return switch (currentCommand) {
|
||||
AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [
|
||||
SuggestionAction.keep,
|
||||
SuggestionAction.discard,
|
||||
SuggestionAction.rewrite,
|
||||
],
|
||||
AiWriterCommand.explain => [
|
||||
SuggestionAction.insertBelow,
|
||||
SuggestionAction.tryAgain,
|
||||
SuggestionAction.close,
|
||||
],
|
||||
_ => [
|
||||
SuggestionAction.keep,
|
||||
SuggestionAction.discard,
|
||||
SuggestionAction.rewrite,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelectSuggestionAction(
|
||||
BuildContext context,
|
||||
SuggestionAction action,
|
||||
@@ -546,10 +386,99 @@ class OverlayContent extends StatelessWidget {
|
||||
predefinedFormat,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isDocumentEmpty() {
|
||||
if (widget.editorState.isEmptyForContinueWriting()) {
|
||||
final documentContext = widget.editorState.document.root.context;
|
||||
if (documentContext == null) {
|
||||
return true;
|
||||
}
|
||||
final view = documentContext.read<ViewBloc>().state.view;
|
||||
if (view.name.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class SecondaryContentArea extends StatelessWidget {
|
||||
const SecondaryContentArea({
|
||||
super.key,
|
||||
required this.command,
|
||||
required this.markdownText,
|
||||
required this.showSuggestionActions,
|
||||
required this.hasSelection,
|
||||
required this.onSelectSuggestionAction,
|
||||
});
|
||||
|
||||
final AiWriterCommand command;
|
||||
final String markdownText;
|
||||
final bool showSuggestionActions;
|
||||
final bool hasSelection;
|
||||
final void Function(SuggestionAction) onSelectSuggestionAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const VSpace(8.0),
|
||||
Container(
|
||||
height: 24.0,
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.0),
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: FlowyText(
|
||||
command.i18n,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF666D76),
|
||||
),
|
||||
),
|
||||
const VSpace(4.0),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
physics: ClampingScrollPhysics(),
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.0),
|
||||
child: AIMarkdownText(
|
||||
markdown: markdownText,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showSuggestionActions) ...[
|
||||
const VSpace(4.0),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: SuggestionActionBar(
|
||||
currentCommand: command,
|
||||
hasSelection: hasSelection,
|
||||
onTap: onSelectSuggestionAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
const VSpace(8.0),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MainContentArea extends StatelessWidget {
|
||||
const MainContentArea({super.key});
|
||||
const MainContentArea({
|
||||
super.key,
|
||||
required this.textController,
|
||||
required this.isInitialReadyState,
|
||||
required this.isDocumentEmpty,
|
||||
required this.showCommandsToggle,
|
||||
});
|
||||
|
||||
final TextEditingController textController;
|
||||
final bool isInitialReadyState;
|
||||
final bool isDocumentEmpty;
|
||||
final ValueNotifier<bool> showCommandsToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -561,7 +490,10 @@ class MainContentArea extends StatelessWidget {
|
||||
return DesktopPromptInput(
|
||||
isStreaming: false,
|
||||
hideDecoration: true,
|
||||
onSubmitted: (message, format, _) => cubit.submit(message, format),
|
||||
textController: textController,
|
||||
onSubmitted: (message, format, _) {
|
||||
cubit.runCommand(state.command, message, format);
|
||||
},
|
||||
onStopStreaming: () => cubit.stopStream(),
|
||||
selectedSourcesNotifier: cubit.selectedSourcesNotifier,
|
||||
onUpdateSelectedSources: (sources) {
|
||||
@@ -569,6 +501,18 @@ class MainContentArea extends StatelessWidget {
|
||||
...sources,
|
||||
];
|
||||
},
|
||||
extraBottomActionButton: isInitialReadyState
|
||||
? ValueListenableBuilder(
|
||||
valueListenable: showCommandsToggle,
|
||||
builder: (context, value, _) {
|
||||
return AiWriterPromptMoreButton(
|
||||
isEnabled: !isDocumentEmpty,
|
||||
isSelected: value,
|
||||
onTap: () => showCommandsToggle.value = !value,
|
||||
);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
if (state is GeneratingAiWriterState) {
|
||||
@@ -624,6 +568,26 @@ class MainContentArea extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is LocalAIStreamingAiWriterState) {
|
||||
final text = switch (state.state) {
|
||||
LocalAIStreamingState.notReady =>
|
||||
LocaleKeys.settings_aiPage_keys_localAINotReadyRetryLater.tr(),
|
||||
LocalAIStreamingState.disabled =>
|
||||
LocaleKeys.settings_aiPage_keys_localAIDisabled.tr(),
|
||||
};
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(8.0),
|
||||
Opacity(
|
||||
opacity: 0.5,
|
||||
child: FlowyText(text),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
|
||||
+43
-37
@@ -2,9 +2,11 @@ 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/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension_v2.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -17,7 +19,7 @@ const _aiWriterToolbarItemId = 'appflowy.editor.ai_writer';
|
||||
final ToolbarItem improveWritingItem = ToolbarItem(
|
||||
id: _improveWritingToolbarItemId,
|
||||
group: 0,
|
||||
isActive: onlyShowInSingleSelectionAndTextType,
|
||||
isActive: onlyShowInTextTypeAndExcludeTable,
|
||||
builder: (context, editorState, _, __, tooltipBuilder) =>
|
||||
ImproveWritingButton(
|
||||
editorState: editorState,
|
||||
@@ -28,7 +30,7 @@ final ToolbarItem improveWritingItem = ToolbarItem(
|
||||
final ToolbarItem aiWriterItem = ToolbarItem(
|
||||
id: _aiWriterToolbarItemId,
|
||||
group: 0,
|
||||
isActive: onlyShowInSingleSelectionAndTextType,
|
||||
isActive: onlyShowInTextTypeAndExcludeTable,
|
||||
builder: (context, editorState, _, __, tooltipBuilder) =>
|
||||
AiWriterToolbarActionList(
|
||||
editorState: editorState,
|
||||
@@ -53,18 +55,23 @@ class AiWriterToolbarActionList extends StatefulWidget {
|
||||
|
||||
class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
|
||||
final popoverController = PopoverController();
|
||||
bool isSelected = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppFlowyPopover(
|
||||
controller: popoverController,
|
||||
direction: PopoverDirection.bottomWithLeftAligned,
|
||||
offset: const Offset(-8.0, 2.0),
|
||||
margin: const EdgeInsets.all(8.0),
|
||||
offset: const Offset(0, 2.0),
|
||||
onOpen: () => keepEditorFocusNotifier.increase(),
|
||||
onClose: () => keepEditorFocusNotifier.decrease(),
|
||||
onClose: () {
|
||||
setState(() {
|
||||
isSelected = false;
|
||||
});
|
||||
keepEditorFocusNotifier.decrease();
|
||||
},
|
||||
popupBuilder: (context) => buildPopoverContent(),
|
||||
child: buildChild(),
|
||||
child: buildChild(context),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,12 +94,11 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
|
||||
|
||||
Widget actionWrapper(AiWriterCommand command) {
|
||||
return SizedBox(
|
||||
height: 30.0,
|
||||
height: 36,
|
||||
child: FlowyButton(
|
||||
leftIcon: FlowySvg(
|
||||
command.icon,
|
||||
size: const Size.square(16),
|
||||
),
|
||||
leftIconSize: const Size.square(20),
|
||||
leftIcon: FlowySvg(command.icon),
|
||||
iconPadding: 12,
|
||||
text: FlowyText(
|
||||
command.i18n,
|
||||
figmaLineHeight: 20,
|
||||
@@ -112,33 +118,36 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildChild() {
|
||||
Widget buildChild(BuildContext context) {
|
||||
final themeV2 = AFThemeExtensionV2.of(context);
|
||||
final child = FlowyIconButton(
|
||||
hoverColor: Colors.transparent,
|
||||
width: 36,
|
||||
height: 24,
|
||||
width: 48,
|
||||
height: 32,
|
||||
isSelected: isSelected,
|
||||
hoverColor: EditorStyleCustomizer.toolbarHoverColor(context),
|
||||
icon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.ai_sparks_s,
|
||||
size: Size.square(16.0),
|
||||
color: Color(0xFFD08EED),
|
||||
FlowySvg(
|
||||
FlowySvgs.toolbar_ai_writer_m,
|
||||
size: Size.square(20),
|
||||
color: themeV2.icon_primary,
|
||||
),
|
||||
const FlowySvg(
|
||||
FlowySvgs.ai_source_drop_down_s,
|
||||
size: Size.square(12),
|
||||
color: Color(0xFF8F959E),
|
||||
HSpace(4),
|
||||
FlowySvg(
|
||||
FlowySvgs.toolbar_arrow_down_m,
|
||||
size: Size(12, 20),
|
||||
color: themeV2.icon_tertiary,
|
||||
),
|
||||
],
|
||||
),
|
||||
iconPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4.0,
|
||||
vertical: 2.0,
|
||||
),
|
||||
onPressed: () {
|
||||
if (_isAIEnabled(widget.editorState)) {
|
||||
keepEditorFocusNotifier.increase();
|
||||
popoverController.show();
|
||||
setState(() {
|
||||
isSelected = true;
|
||||
});
|
||||
} else {
|
||||
showToastNotification(
|
||||
context,
|
||||
@@ -173,16 +182,13 @@ class ImproveWritingButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = FlowyIconButton(
|
||||
hoverColor: Colors.transparent,
|
||||
width: 24,
|
||||
icon: const FlowySvg(
|
||||
FlowySvgs.ai_improve_writing_s,
|
||||
size: Size.square(16.0),
|
||||
color: Color(0xFFD08EED),
|
||||
),
|
||||
iconPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4.0,
|
||||
vertical: 2.0,
|
||||
width: 36,
|
||||
height: 32,
|
||||
hoverColor: EditorStyleCustomizer.toolbarHoverColor(context),
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.toolbar_ai_improve_writing_m,
|
||||
size: Size.square(20.0),
|
||||
color: AFThemeExtensionV2.of(context).icon_primary,
|
||||
),
|
||||
onPressed: () {
|
||||
if (_isAIEnabled(editorState)) {
|
||||
|
||||
+59
-10
@@ -1,10 +1,45 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
import '../ai_writer_block_component.dart';
|
||||
import 'ai_writer_entities.dart';
|
||||
import 'ai_writer_node_extension.dart';
|
||||
|
||||
Future<void> removeAiWriterNode(EditorState editorState, Node node) async {
|
||||
Future<void> setAiWriterNodeIsInitialized(
|
||||
EditorState editorState,
|
||||
Node node,
|
||||
) async {
|
||||
final transaction = editorState.transaction
|
||||
..updateNode(node, {
|
||||
AiWriterBlockKeys.isInitialized: true,
|
||||
});
|
||||
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(
|
||||
recordUndo: false,
|
||||
inMemoryUpdate: true,
|
||||
),
|
||||
withUpdateSelection: false,
|
||||
);
|
||||
|
||||
final selection = node.aiWriterSelection;
|
||||
if (selection != null && !selection.isCollapsed) {
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
selection,
|
||||
extraInfo: {selectionExtraInfoDisableToolbar: true},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeAiWriterNode(
|
||||
EditorState editorState,
|
||||
Node node,
|
||||
) async {
|
||||
final transaction = editorState.transaction..deleteNode(node);
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
@@ -13,16 +48,16 @@ Future<void> removeAiWriterNode(EditorState editorState, Node node) async {
|
||||
);
|
||||
}
|
||||
|
||||
void formatSelection(
|
||||
Future<void> formatSelection(
|
||||
EditorState editorState,
|
||||
Selection selection,
|
||||
Transaction transaction,
|
||||
ApplySuggestionFormatType formatType,
|
||||
) {
|
||||
) async {
|
||||
final nodes = editorState.getNodesInSelection(selection).toList();
|
||||
if (nodes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final transaction = editorState.transaction;
|
||||
|
||||
if (nodes.length == 1) {
|
||||
final node = nodes.removeAt(0);
|
||||
@@ -68,29 +103,43 @@ void formatSelection(
|
||||
}
|
||||
|
||||
transaction.compose();
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: ApplyOptions(
|
||||
inMemoryUpdate: true,
|
||||
recordUndo: false,
|
||||
),
|
||||
withUpdateSelection: false,
|
||||
);
|
||||
}
|
||||
|
||||
Position ensurePreviousNodeIsEmptyParagraph(
|
||||
Future<Position> ensurePreviousNodeIsEmptyParagraph(
|
||||
EditorState editorState,
|
||||
Node aiWriterNode,
|
||||
Transaction transaction,
|
||||
) {
|
||||
) async {
|
||||
final previous = aiWriterNode.previous;
|
||||
final needsEmptyParagraphNode = previous == null ||
|
||||
previous.type != ParagraphBlockKeys.type ||
|
||||
(previous.delta?.toPlainText().isNotEmpty ?? false);
|
||||
|
||||
final Position position;
|
||||
final transaction = editorState.transaction;
|
||||
|
||||
if (needsEmptyParagraphNode) {
|
||||
position = Position(path: aiWriterNode.path);
|
||||
transaction.insertNode(aiWriterNode.path, paragraphNode());
|
||||
} else {
|
||||
position = Position(path: previous.path);
|
||||
}
|
||||
transaction.afterSelection = Selection.collapsed(position);
|
||||
|
||||
transaction.updateNode(aiWriterNode, {
|
||||
AiWriterBlockKeys.isInitialized: true,
|
||||
});
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: ApplyOptions(
|
||||
inMemoryUpdate: true,
|
||||
recordUndo: false,
|
||||
),
|
||||
);
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
+538
-334
File diff suppressed because it is too large.
Load diff
+38
-1
@@ -1,7 +1,9 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../ai_writer_block_component.dart';
|
||||
@@ -90,7 +92,7 @@ enum AiWriterCommand {
|
||||
|
||||
FlowySvgData get icon => switch (this) {
|
||||
userQuestion => FlowySvgs.ai_sparks_s,
|
||||
explain => FlowySvgs.ai_explain_s,
|
||||
explain => FlowySvgs.ai_explain_m,
|
||||
// summarize => FlowySvgs.ai_summarize_s,
|
||||
continueWriting || improveWriting => FlowySvgs.ai_improve_writing_s,
|
||||
fixSpellingAndGrammar => FlowySvgs.ai_fix_spelling_grammar_s,
|
||||
@@ -120,3 +122,38 @@ enum ApplySuggestionFormatType {
|
||||
|
||||
Map<String, dynamic> get attributes => {AiWriterBlockKeys.suggestion: value};
|
||||
}
|
||||
|
||||
enum AiRole {
|
||||
user,
|
||||
system,
|
||||
ai,
|
||||
}
|
||||
|
||||
class AiWriterRecord extends Equatable {
|
||||
const AiWriterRecord.user({
|
||||
required this.content,
|
||||
required this.format,
|
||||
}) : role = AiRole.user;
|
||||
|
||||
const AiWriterRecord.ai({
|
||||
required this.content,
|
||||
}) : role = AiRole.ai,
|
||||
format = null;
|
||||
|
||||
final AiRole role;
|
||||
final String content;
|
||||
final PredefinedFormat? format;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [role, content, format];
|
||||
|
||||
CompletionRecordPB toPB() {
|
||||
return CompletionRecordPB(
|
||||
content: content,
|
||||
role: switch (role) {
|
||||
AiRole.user => ChatMessageTypePB.User,
|
||||
AiRole.system || AiRole.ai => ChatMessageTypePB.System,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+62
@@ -1,3 +1,5 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart';
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
@@ -87,4 +89,64 @@ extension AiWriterNodeExtension on EditorState {
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/// Determines whether the document is empty up to the selection
|
||||
///
|
||||
/// If empty and the title is also empty, the continue writing option will be disabled.
|
||||
bool isEmptyForContinueWriting({
|
||||
Selection? selection,
|
||||
}) {
|
||||
if (selection != null && !selection.isCollapsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final effectiveSelection = Selection(
|
||||
start: Position(path: [0]),
|
||||
end: selection?.normalized.end ??
|
||||
this.selection?.normalized.end ??
|
||||
Position(path: getLastSelectable()?.$1.path ?? [0]),
|
||||
);
|
||||
|
||||
// if the selected nodes are not entirely selected, slice the nodes
|
||||
final slicedNodes = <Node>[];
|
||||
final nodes = getNodesInSelection(effectiveSelection);
|
||||
|
||||
for (final node in nodes) {
|
||||
final delta = node.delta;
|
||||
if (delta == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final slicedDelta = delta.slice(
|
||||
node == nodes.first ? effectiveSelection.startIndex : 0,
|
||||
node == nodes.last ? effectiveSelection.endIndex : delta.length,
|
||||
);
|
||||
|
||||
final copiedNode = node.copyWith(
|
||||
attributes: {
|
||||
...node.attributes,
|
||||
blockComponentDelta: slicedDelta.toJson(),
|
||||
},
|
||||
);
|
||||
|
||||
slicedNodes.add(copiedNode);
|
||||
}
|
||||
|
||||
// using less custom parsers to avoid futures
|
||||
final markdown = documentToMarkdown(
|
||||
Document.blank()..insert([0], slicedNodes),
|
||||
customParsers: [
|
||||
const MathEquationNodeParser(),
|
||||
const CalloutNodeParser(),
|
||||
const ToggleListNodeParser(),
|
||||
const CustomParagraphNodeParser(),
|
||||
const SubPageNodeParser(),
|
||||
const SimpleTableNodeParser(),
|
||||
const LinkPreviewNodeParser(),
|
||||
const FileBlockNodeParser(),
|
||||
],
|
||||
);
|
||||
|
||||
return markdown.trim().isEmpty;
|
||||
}
|
||||
}
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'operations/ai_writer_entities.dart';
|
||||
|
||||
class SuggestionActionBar extends StatelessWidget {
|
||||
const SuggestionActionBar({
|
||||
super.key,
|
||||
required this.actions,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final List<SuggestionAction> actions;
|
||||
final void Function(SuggestionAction) onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SeparatedRow(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
separatorBuilder: () => const HSpace(4.0),
|
||||
children: actions
|
||||
.map(
|
||||
(action) => SuggestionActionButton(
|
||||
action: action,
|
||||
onTap: () => onTap(action),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SuggestionActionButton extends StatelessWidget {
|
||||
const SuggestionActionButton({
|
||||
super.key,
|
||||
required this.action,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final SuggestionAction action;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 28,
|
||||
child: FlowyButton(
|
||||
text: FlowyText(
|
||||
action.i18n,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
leftIcon: action.buildIcon(context),
|
||||
iconPadding: 4.0,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 6.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
onTap: onTap,
|
||||
useIntrinsicWidth: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AiWriterGestureDetector extends StatelessWidget {
|
||||
const AiWriterGestureDetector({
|
||||
super.key,
|
||||
required this.behavior,
|
||||
required this.onPointerEvent,
|
||||
this.child,
|
||||
});
|
||||
|
||||
final HitTestBehavior behavior;
|
||||
final void Function() onPointerEvent;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RawGestureDetector(
|
||||
behavior: behavior,
|
||||
gestures: <Type, GestureRecognizerFactory>{
|
||||
TapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(),
|
||||
(instance) => instance
|
||||
..onTapDown = ((_) => onPointerEvent())
|
||||
..onSecondaryTapDown = ((_) => onPointerEvent())
|
||||
..onTertiaryTapDown = ((_) => onPointerEvent()),
|
||||
),
|
||||
ImmediateMultiDragGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<
|
||||
ImmediateMultiDragGestureRecognizer>(
|
||||
() => ImmediateMultiDragGestureRecognizer(),
|
||||
(instance) => instance.onStart = (offset) => null,
|
||||
),
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
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:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/colorscheme/default_colorscheme.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../operations/ai_writer_entities.dart';
|
||||
|
||||
class AiWriterPromptMoreButton extends StatelessWidget {
|
||||
const AiWriterPromptMoreButton({
|
||||
super.key,
|
||||
required this.isEnabled,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool isEnabled;
|
||||
final bool isSelected;
|
||||
final void Function() onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
ignoring: !isEnabled,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
height: DesktopAIPromptSizes.actionBarButtonSize,
|
||||
child: FlowyHover(
|
||||
style: const HoverStyle(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
isSelected: () => isSelected,
|
||||
child: Padding(
|
||||
padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyText(
|
||||
LocaleKeys.ai_more.tr(),
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
color: isEnabled
|
||||
? Theme.of(context).hintColor
|
||||
: Theme.of(context).disabledColor,
|
||||
),
|
||||
const HSpace(2.0),
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_source_drop_down_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MoreAiWriterCommands extends StatelessWidget {
|
||||
const MoreAiWriterCommands({
|
||||
super.key,
|
||||
required this.hasSelection,
|
||||
required this.editorState,
|
||||
required this.onSelectCommand,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final bool hasSelection;
|
||||
final void Function(AiWriterCommand) onSelectCommand;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
// add one here to take into account the border of the main message box.
|
||||
// It is configured to be on the outside to hide some graphical
|
||||
// artifacts.
|
||||
margin: EdgeInsets.only(top: 4.0 + 1.0),
|
||||
padding: EdgeInsets.all(8.0),
|
||||
constraints: BoxConstraints(minWidth: 240.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).brightness == Brightness.light
|
||||
? ColorSchemeConstants.lightBorderColor
|
||||
: ColorSchemeConstants.darkBorderColor,
|
||||
strokeAlign: BorderSide.strokeAlignOutside,
|
||||
),
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
||||
boxShadow: Theme.of(context).isLightMode
|
||||
? ShadowConstants.lightSmall
|
||||
: ShadowConstants.darkSmall,
|
||||
),
|
||||
child: IntrinsicWidth(
|
||||
child: Column(
|
||||
spacing: 4.0,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _getCommands(
|
||||
hasSelection: hasSelection,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _getCommands({required bool hasSelection}) {
|
||||
if (hasSelection) {
|
||||
return [
|
||||
_bottomButton(AiWriterCommand.improveWriting),
|
||||
_bottomButton(AiWriterCommand.fixSpellingAndGrammar),
|
||||
_bottomButton(AiWriterCommand.explain),
|
||||
const Divider(height: 1.0, thickness: 1.0),
|
||||
_bottomButton(AiWriterCommand.makeLonger),
|
||||
_bottomButton(AiWriterCommand.makeShorter),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
_bottomButton(AiWriterCommand.continueWriting),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Widget _bottomButton(AiWriterCommand command) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return FlowyButton(
|
||||
leftIcon: FlowySvg(
|
||||
command.icon,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
leftIconSize: const Size.square(20),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
|
||||
text: FlowyText(
|
||||
command.i18n,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
onTap: () => onSelectCommand(command),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/util/throttle.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.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_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../operations/ai_writer_cubit.dart';
|
||||
import 'ai_writer_gesture_detector.dart';
|
||||
|
||||
class AiWriterScrollWrapper extends StatefulWidget {
|
||||
const AiWriterScrollWrapper({
|
||||
super.key,
|
||||
required this.viewId,
|
||||
required this.editorState,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final String viewId;
|
||||
final EditorState editorState;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<AiWriterScrollWrapper> createState() => _AiWriterScrollWrapperState();
|
||||
}
|
||||
|
||||
class _AiWriterScrollWrapperState extends State<AiWriterScrollWrapper> {
|
||||
final overlayController = OverlayPortalController();
|
||||
late final throttler = Throttler();
|
||||
late final aiWriterCubit = AiWriterCubit(
|
||||
documentId: widget.viewId,
|
||||
editorState: widget.editorState,
|
||||
onCreateNode: () {
|
||||
aiWriterRegistered = true;
|
||||
widget.editorState.service.keyboardService?.disableShortcuts();
|
||||
},
|
||||
onRemoveNode: () {
|
||||
aiWriterRegistered = false;
|
||||
widget.editorState.service.keyboardService?.enableShortcuts();
|
||||
},
|
||||
onAppendToDocument: onAppendToDocument,
|
||||
);
|
||||
|
||||
bool userHasScrolled = false;
|
||||
bool aiWriterRegistered = false;
|
||||
bool dialogShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
overlayController.show();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
aiWriterCubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: aiWriterCubit,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: handleScrollNotification,
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: handleKeyEvent,
|
||||
child: MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<AiWriterCubit, AiWriterState>(
|
||||
listener: (context, state) {
|
||||
if (state is DocumentContentEmptyAiWriterState) {
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title:
|
||||
LocaleKeys.ai_continueWritingEmptyDocumentTitle.tr(),
|
||||
description: LocaleKeys
|
||||
.ai_continueWritingEmptyDocumentDescription
|
||||
.tr(),
|
||||
onConfirm: state.onConfirm,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<AiWriterCubit, AiWriterState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous is GeneratingAiWriterState &&
|
||||
current is ReadyAiWriterState,
|
||||
listener: (context, state) {
|
||||
widget.editorState.updateSelectionWithReason(null);
|
||||
},
|
||||
),
|
||||
],
|
||||
child: OverlayPortal(
|
||||
controller: overlayController,
|
||||
overlayChildBuilder: (context) {
|
||||
return BlocBuilder<AiWriterCubit, AiWriterState>(
|
||||
builder: (context, state) {
|
||||
return AiWriterGestureDetector(
|
||||
behavior: state is RegisteredAiWriter
|
||||
? HitTestBehavior.translucent
|
||||
: HitTestBehavior.deferToChild,
|
||||
onPointerEvent: () => onTapOutside(context),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool handleScrollNotification(ScrollNotification notification) {
|
||||
if (!aiWriterRegistered) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (notification is UserScrollNotification) {
|
||||
debounceResetUserHasScrolled();
|
||||
userHasScrolled = true;
|
||||
throttler.cancel();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void debounceResetUserHasScrolled() {
|
||||
Debounce.debounce(
|
||||
'user_has_scrolled',
|
||||
const Duration(seconds: 3),
|
||||
() => userHasScrolled = false,
|
||||
);
|
||||
}
|
||||
|
||||
void onTapOutside(BuildContext context) {
|
||||
final aiWriterCubit = context.read<AiWriterCubit>();
|
||||
|
||||
if (aiWriterCubit.hasUnusedResponse()) {
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.button_discard.tr(),
|
||||
description: LocaleKeys.document_plugins_discardResponse.tr(),
|
||||
confirmLabel: LocaleKeys.button_discard.tr(),
|
||||
style: ConfirmPopupStyle.cancelAndOk,
|
||||
onConfirm: stopAndExit,
|
||||
onCancel: () {},
|
||||
);
|
||||
} else {
|
||||
stopAndExit();
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (!aiWriterRegistered) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (dialogShown) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event is! KeyDownEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
switch (event.logicalKey) {
|
||||
case LogicalKeyboardKey.escape:
|
||||
if (aiWriterCubit.state case GeneratingAiWriterState _) {
|
||||
aiWriterCubit.stopStream();
|
||||
} else if (aiWriterCubit.hasUnusedResponse()) {
|
||||
dialogShown = true;
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.button_discard.tr(),
|
||||
description: LocaleKeys.document_plugins_discardResponse.tr(),
|
||||
confirmLabel: LocaleKeys.button_discard.tr(),
|
||||
style: ConfirmPopupStyle.cancelAndOk,
|
||||
onConfirm: stopAndExit,
|
||||
onCancel: () {},
|
||||
).then((_) => dialogShown = false);
|
||||
} else {
|
||||
stopAndExit();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.keyC
|
||||
when HardwareKeyboard.instance.isControlPressed:
|
||||
if (aiWriterCubit.state case GeneratingAiWriterState _) {
|
||||
aiWriterCubit.stopStream();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void onAppendToDocument() {
|
||||
if (!aiWriterRegistered || userHasScrolled) {
|
||||
return;
|
||||
}
|
||||
|
||||
throttler.call(() {
|
||||
if (aiWriterCubit.aiWriterNode != null) {
|
||||
final path = aiWriterCubit.aiWriterNode!.path;
|
||||
if (path.isNotEmpty) {
|
||||
widget.editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: path)),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void stopAndExit() {
|
||||
Future(() async {
|
||||
await aiWriterCubit.stopStream();
|
||||
await aiWriterCubit.exit();
|
||||
});
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../operations/ai_writer_entities.dart';
|
||||
|
||||
class SuggestionActionBar extends StatelessWidget {
|
||||
const SuggestionActionBar({
|
||||
super.key,
|
||||
required this.currentCommand,
|
||||
required this.hasSelection,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AiWriterCommand currentCommand;
|
||||
final bool hasSelection;
|
||||
final void Function(SuggestionAction) onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SeparatedRow(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
separatorBuilder: () => const HSpace(4.0),
|
||||
children: _getSuggestedActions()
|
||||
.map(
|
||||
(action) => SuggestionActionButton(
|
||||
action: action,
|
||||
onTap: () => onTap(action),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
List<SuggestionAction> _getSuggestedActions() {
|
||||
if (hasSelection) {
|
||||
return switch (currentCommand) {
|
||||
AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [
|
||||
SuggestionAction.keep,
|
||||
SuggestionAction.discard,
|
||||
SuggestionAction.rewrite,
|
||||
],
|
||||
AiWriterCommand.explain => [
|
||||
SuggestionAction.insertBelow,
|
||||
SuggestionAction.tryAgain,
|
||||
SuggestionAction.close,
|
||||
],
|
||||
AiWriterCommand.fixSpellingAndGrammar ||
|
||||
AiWriterCommand.improveWriting ||
|
||||
AiWriterCommand.makeShorter ||
|
||||
AiWriterCommand.makeLonger =>
|
||||
[
|
||||
SuggestionAction.accept,
|
||||
SuggestionAction.discard,
|
||||
SuggestionAction.insertBelow,
|
||||
SuggestionAction.rewrite,
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return switch (currentCommand) {
|
||||
AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [
|
||||
SuggestionAction.keep,
|
||||
SuggestionAction.discard,
|
||||
SuggestionAction.rewrite,
|
||||
],
|
||||
AiWriterCommand.explain => [
|
||||
SuggestionAction.insertBelow,
|
||||
SuggestionAction.tryAgain,
|
||||
SuggestionAction.close,
|
||||
],
|
||||
_ => [
|
||||
SuggestionAction.keep,
|
||||
SuggestionAction.discard,
|
||||
SuggestionAction.rewrite,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SuggestionActionButton extends StatelessWidget {
|
||||
const SuggestionActionButton({
|
||||
super.key,
|
||||
required this.action,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final SuggestionAction action;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 28,
|
||||
child: FlowyButton(
|
||||
text: FlowyText(
|
||||
action.i18n,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
leftIcon: action.buildIcon(context),
|
||||
iconPadding: 4.0,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 6.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
onTap: onTap,
|
||||
useIntrinsicWidth: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -208,7 +208,7 @@ class _MobileEmojiPickerButton extends StatelessWidget {
|
||||
MobileEmojiPickerScreen.iconSelectedType: emoji.type.name,
|
||||
MobileEmojiPickerScreen.uploadDocumentId: documentId,
|
||||
MobileEmojiPickerScreen.selectTabs:
|
||||
tabs.map((e) => e.name).toList(),
|
||||
tabs.map((e) => e.name).toList().join('-'),
|
||||
},
|
||||
).toString(),
|
||||
);
|
||||
|
||||
+61
-12
@@ -1,8 +1,10 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart';
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
const _enableDebug = false;
|
||||
@@ -28,8 +30,13 @@ class MarkdownTextRobot {
|
||||
/// Only for debug via [_enableDebug].
|
||||
final List<String> _debugMarkdownTexts = [];
|
||||
|
||||
/// Selection before the refresh.
|
||||
Selection? _previousSelection;
|
||||
|
||||
bool get hasAnyResult => _markdownText.isNotEmpty;
|
||||
|
||||
String get markdownText => _markdownText;
|
||||
|
||||
Selection? getInsertedSelection() {
|
||||
final position = _insertPosition;
|
||||
if (position == null) {
|
||||
@@ -54,9 +61,11 @@ class MarkdownTextRobot {
|
||||
}
|
||||
|
||||
void start({
|
||||
Selection? previousSelection,
|
||||
Position? position,
|
||||
}) {
|
||||
_insertPosition ??= position ?? editorState.selection?.start;
|
||||
_insertPosition = position ?? editorState.selection?.start;
|
||||
_previousSelection = previousSelection ?? editorState.selection;
|
||||
|
||||
if (_enableDebug) {
|
||||
Log.info(
|
||||
@@ -68,6 +77,7 @@ class MarkdownTextRobot {
|
||||
/// The text will be inserted into the document but only in memory
|
||||
Future<void> appendMarkdownText(
|
||||
String text, {
|
||||
bool updateSelection = true,
|
||||
Map<String, dynamic>? attributes,
|
||||
}) async {
|
||||
_markdownText += text;
|
||||
@@ -75,6 +85,7 @@ class MarkdownTextRobot {
|
||||
await _lock.synchronized(() async {
|
||||
await _refresh(
|
||||
inMemoryUpdate: true,
|
||||
updateSelection: updateSelection,
|
||||
attributes: attributes,
|
||||
);
|
||||
});
|
||||
@@ -93,7 +104,6 @@ class MarkdownTextRobot {
|
||||
await _lock.synchronized(() async {
|
||||
await _refresh(
|
||||
inMemoryUpdate: true,
|
||||
updateSelection: false,
|
||||
attributes: attributes,
|
||||
);
|
||||
});
|
||||
@@ -137,7 +147,7 @@ class MarkdownTextRobot {
|
||||
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(recordUndo: false),
|
||||
options: const ApplyOptions(recordUndo: false, inMemoryUpdate: true),
|
||||
);
|
||||
|
||||
if (_enableDebug) {
|
||||
@@ -145,15 +155,18 @@ class MarkdownTextRobot {
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
void clear() {
|
||||
_markdownText = '';
|
||||
_insertedNodes = [];
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_insertPosition = null;
|
||||
}
|
||||
|
||||
Future<void> _refresh({
|
||||
required bool inMemoryUpdate,
|
||||
bool updateSelection = true,
|
||||
bool updateSelection = false,
|
||||
Map<String, dynamic>? attributes,
|
||||
}) async {
|
||||
final position = _insertPosition;
|
||||
@@ -169,11 +182,40 @@ class MarkdownTextRobot {
|
||||
tableWidth: 250.0,
|
||||
).root.children;
|
||||
|
||||
// check if the first selected node before the refresh is a numbered list node
|
||||
final previousSelection = _previousSelection;
|
||||
final previousSelectedNode = previousSelection == null
|
||||
? null
|
||||
: editorState.getNodeAtPath(previousSelection.start.path);
|
||||
final firstNodeIsNumberedList = previousSelectedNode != null &&
|
||||
previousSelectedNode.type == NumberedListBlockKeys.type;
|
||||
|
||||
final newNodes = attributes == null
|
||||
? documentNodes
|
||||
: documentNodes
|
||||
.map((node) => _styleDelta(node: node, attributes: attributes))
|
||||
.toList();
|
||||
: documentNodes.mapIndexed((index, node) {
|
||||
final n = _styleDelta(node: node, attributes: attributes);
|
||||
n.externalValues = AINodeExternalValues(
|
||||
isAINode: true,
|
||||
);
|
||||
if (index == 0 && n.type == NumberedListBlockKeys.type) {
|
||||
if (firstNodeIsNumberedList) {
|
||||
final builder = NumberedListIndexBuilder(
|
||||
editorState: editorState,
|
||||
node: previousSelectedNode,
|
||||
);
|
||||
final firstIndex = builder.indexInSameLevel;
|
||||
n.updateAttributes({
|
||||
NumberedListBlockKeys.number: firstIndex,
|
||||
});
|
||||
}
|
||||
|
||||
n.externalValues = AINodeExternalValues(
|
||||
isAINode: true,
|
||||
isFirstNumberedListNode: true,
|
||||
);
|
||||
}
|
||||
return n;
|
||||
}).toList();
|
||||
|
||||
if (newNodes.isEmpty) {
|
||||
return;
|
||||
@@ -201,10 +243,6 @@ class MarkdownTextRobot {
|
||||
offset: lastDelta.length,
|
||||
),
|
||||
);
|
||||
|
||||
if (!updateSelection) {
|
||||
insertTransaction.afterSelection = null;
|
||||
}
|
||||
}
|
||||
|
||||
await editorState.apply(
|
||||
@@ -213,6 +251,7 @@ class MarkdownTextRobot {
|
||||
inMemoryUpdate: inMemoryUpdate,
|
||||
recordUndo: !inMemoryUpdate,
|
||||
),
|
||||
withUpdateSelection: updateSelection,
|
||||
);
|
||||
|
||||
_insertedNodes = newNodes;
|
||||
@@ -244,3 +283,13 @@ class MarkdownTextRobot {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AINodeExternalValues extends NodeExternalValues {
|
||||
const AINodeExternalValues({
|
||||
this.isAINode = false,
|
||||
this.isFirstNumberedListNode = false,
|
||||
});
|
||||
|
||||
final bool isAINode;
|
||||
final bool isFirstNumberedListNode;
|
||||
}
|
||||
+32
@@ -1,4 +1,8 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
bool _isTableType(String type) {
|
||||
@@ -32,3 +36,31 @@ bool onlyShowInSingleTextTypeSelectionAndExcludeTable(
|
||||
return onlyShowInSingleSelectionAndTextType(editorState) &&
|
||||
notShowInTable(editorState);
|
||||
}
|
||||
|
||||
bool enableSuggestions(EditorState editorState) {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || !selection.isSingle) {
|
||||
return false;
|
||||
}
|
||||
final node = editorState.getNodeAtPath(selection.start.path);
|
||||
if (node == null) {
|
||||
return false;
|
||||
}
|
||||
if (isNarrowWindow(editorState)) return false;
|
||||
|
||||
return (node.delta != null && suggestionsItemTypes.contains(node.type)) &&
|
||||
notShowInTable(editorState);
|
||||
}
|
||||
|
||||
bool isNarrowWindow(EditorState editorState) {
|
||||
final editorSize = editorState.renderBox?.size ?? Size.zero;
|
||||
if (editorSize.width < 650) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
final Set<String> suggestionsItemTypes = {
|
||||
...toolbarItemWhiteList,
|
||||
ToggleListBlockKeys.type,
|
||||
TodoListBlockKeys.type,
|
||||
CalloutBlockKeys.type,
|
||||
};
|
||||
+93
-27
@@ -1,7 +1,8 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart' show LocaleKeys;
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart'
|
||||
show StringTranslateExtension;
|
||||
@@ -80,7 +81,7 @@ class CalloutBlockComponentBuilder extends BlockComponentBuilder {
|
||||
});
|
||||
|
||||
final Color defaultColor;
|
||||
final EdgeInsets inlinePadding;
|
||||
final EdgeInsets Function(Node node) inlinePadding;
|
||||
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
@@ -96,12 +97,15 @@ class CalloutBlockComponentBuilder extends BlockComponentBuilder {
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
actionTrailingBuilder: (context, state) => actionTrailingBuilder(
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
BlockComponentValidate get validate =>
|
||||
(node) => node.delta != null && node.children.isEmpty;
|
||||
BlockComponentValidate get validate => (node) => node.delta != null;
|
||||
}
|
||||
|
||||
// the main widget for rendering the callout block
|
||||
@@ -111,13 +115,14 @@ class CalloutBlockComponentWidget extends BlockComponentStatefulWidget {
|
||||
required super.node,
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.actionTrailingBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
required this.defaultColor,
|
||||
required this.inlinePadding,
|
||||
});
|
||||
|
||||
final Color defaultColor;
|
||||
final EdgeInsets inlinePadding;
|
||||
final EdgeInsets Function(Node node) inlinePadding;
|
||||
|
||||
@override
|
||||
State<CalloutBlockComponentWidget> createState() =>
|
||||
@@ -132,7 +137,8 @@ class _CalloutBlockComponentWidgetState
|
||||
BlockComponentConfigurable,
|
||||
BlockComponentTextDirectionMixin,
|
||||
BlockComponentAlignMixin,
|
||||
BlockComponentBackgroundColorMixin {
|
||||
BlockComponentBackgroundColorMixin,
|
||||
NestedBlockComponentStatefulWidgetMixin {
|
||||
// the key used to forward focus to the richtext child
|
||||
@override
|
||||
final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');
|
||||
@@ -169,41 +175,85 @@ class _CalloutBlockComponentWidgetState
|
||||
EmojiIconData result = EmojiIconData.emoji('📌');
|
||||
try {
|
||||
result = EmojiIconData(FlowyIconType.values.byName(type), icon);
|
||||
} catch (e) {
|
||||
Log.error(
|
||||
'get emoji error with icon:[$icon], type:[$type] within alloutBlockComponentWidget',
|
||||
e,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
return result;
|
||||
}
|
||||
|
||||
// get access to the editor state via provider
|
||||
@override
|
||||
late final editorState = Provider.of<EditorState>(context, listen: false);
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = node.children.isEmpty
|
||||
? buildComponent(context)
|
||||
: buildComponentWithChildren(context);
|
||||
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
child = Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildComponentWithChildren(BuildContext context) {
|
||||
Widget child = Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
left: UniversalPlatform.isMobile ? 0 : cachedLeft,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6.0)),
|
||||
color: backgroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
NestedListWidget(
|
||||
indentPadding: indentPadding.copyWith(bottom: 8),
|
||||
child: buildComponent(context, withBackgroundColor: false),
|
||||
children: editorState.renderer.buildList(
|
||||
context,
|
||||
widget.node.children,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (UniversalPlatform.isMobile) {
|
||||
child = Padding(
|
||||
padding: padding,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
// build the callout block widget
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget buildComponent(
|
||||
BuildContext context, {
|
||||
bool withBackgroundColor = true,
|
||||
}) {
|
||||
final textDirection = calculateTextDirection(
|
||||
layoutDirection: Directionality.maybeOf(context),
|
||||
);
|
||||
final (emojiSize, emojiButtonSize) = calculateEmojiSize();
|
||||
|
||||
final documentId = context.read<DocumentBloc?>()?.documentId;
|
||||
Widget child = Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6.0)),
|
||||
color: withBackgroundColor ? backgroundColor : null,
|
||||
),
|
||||
padding: widget.inlinePadding,
|
||||
padding: widget.inlinePadding(widget.node),
|
||||
width: double.infinity,
|
||||
alignment: alignment,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
textDirection: textDirection,
|
||||
children: [
|
||||
if (UniversalPlatform.isDesktopOrWeb) const HSpace(4.0),
|
||||
const HSpace(6.0),
|
||||
// the emoji picker button for the note
|
||||
EmojiPickerButton(
|
||||
// force to refresh the popover state
|
||||
@@ -217,12 +267,18 @@ class _CalloutBlockComponentWidgetState
|
||||
emojiSize: emojiSize,
|
||||
showBorder: false,
|
||||
buttonSize: emojiButtonSize,
|
||||
documentId: documentId,
|
||||
tabs: const [
|
||||
PickerTabType.emoji,
|
||||
PickerTabType.icon,
|
||||
PickerTabType.custom,
|
||||
],
|
||||
onSubmitted: (r, controller) {
|
||||
setEmojiIconData(r.data);
|
||||
if (!r.keepOpen) controller?.close();
|
||||
},
|
||||
),
|
||||
if (UniversalPlatform.isDesktopOrWeb) const HSpace(4.0),
|
||||
if (UniversalPlatform.isDesktopOrWeb) const HSpace(6.0),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
@@ -234,17 +290,26 @@ class _CalloutBlockComponentWidgetState
|
||||
),
|
||||
);
|
||||
|
||||
child = Padding(
|
||||
key: blockComponentKey,
|
||||
padding: padding,
|
||||
child: child,
|
||||
);
|
||||
if (UniversalPlatform.isMobile && node.children.isEmpty) {
|
||||
child = Padding(
|
||||
key: blockComponentKey,
|
||||
padding: padding,
|
||||
child: child,
|
||||
);
|
||||
} else {
|
||||
child = Container(
|
||||
key: blockComponentKey,
|
||||
padding: EdgeInsets.zero,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
child = BlockSelectionContainer(
|
||||
node: node,
|
||||
delegate: this,
|
||||
listenable: editorState.selectionNotifier,
|
||||
blockColor: editorState.editorStyle.selectionColor,
|
||||
selectionAboveBlock: true,
|
||||
supportTypes: const [
|
||||
BlockSelectionType.block,
|
||||
],
|
||||
@@ -255,6 +320,7 @@ class _CalloutBlockComponentWidgetState
|
||||
child = BlockComponentActionWrapper(
|
||||
node: widget.node,
|
||||
actionBuilder: widget.actionBuilder!,
|
||||
actionTrailingBuilder: widget.actionTrailingBuilder,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
+16
-3
@@ -32,9 +32,22 @@ CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async {
|
||||
await editorState.deleteSelection(selection);
|
||||
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
await editorState.insertNewLine();
|
||||
} else {
|
||||
await editorState.insertTextAtCurrentSelection('\n');
|
||||
// ignore the shift+enter event, fallback to the default behavior
|
||||
return false;
|
||||
} else if (node.children.isEmpty) {
|
||||
// insert a new paragraph within the callout block
|
||||
final path = node.path.child(0);
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertNode(
|
||||
path,
|
||||
paragraphNode(),
|
||||
);
|
||||
transaction.afterSelection = Selection.collapsed(
|
||||
Position(
|
||||
path: path,
|
||||
),
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
+40
-18
@@ -1,5 +1,4 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -7,27 +6,62 @@ import 'package:provider/provider.dart';
|
||||
|
||||
Node simpleColumnNode({
|
||||
List<Node>? children,
|
||||
double? width,
|
||||
double? ratio,
|
||||
}) {
|
||||
return Node(
|
||||
type: SimpleColumnBlockKeys.type,
|
||||
children: children ?? [paragraphNode()],
|
||||
attributes: {
|
||||
SimpleColumnBlockKeys.width: width,
|
||||
SimpleColumnBlockKeys.ratio: ratio,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
extension SimpleColumnBlockAttributes on Node {
|
||||
// get the next column node of the current column node
|
||||
// if the current column node is the last column node, return null
|
||||
Node? get nextColumn {
|
||||
final index = path.last;
|
||||
final parent = this.parent;
|
||||
if (parent == null || index == parent.children.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return parent.children[index + 1];
|
||||
}
|
||||
|
||||
// get the previous column node of the current column node
|
||||
// if the current column node is the first column node, return null
|
||||
Node? get previousColumn {
|
||||
final index = path.last;
|
||||
final parent = this.parent;
|
||||
if (parent == null || index == 0) {
|
||||
return null;
|
||||
}
|
||||
return parent.children[index - 1];
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleColumnBlockKeys {
|
||||
const SimpleColumnBlockKeys._();
|
||||
|
||||
static const String type = 'simple_column';
|
||||
|
||||
/// @Deprecated Use [SimpleColumnBlockKeys.ratio] instead.
|
||||
///
|
||||
/// This field is no longer used since v0.6.9
|
||||
@Deprecated('Use [SimpleColumnBlockKeys.ratio] instead.')
|
||||
static const String width = 'width';
|
||||
|
||||
/// The ratio of the column width.
|
||||
///
|
||||
/// The value is a double number between 0 and 1.
|
||||
static const String ratio = 'ratio';
|
||||
}
|
||||
|
||||
class SimpleColumnBlockComponentBuilder extends BlockComponentBuilder {
|
||||
SimpleColumnBlockComponentBuilder({super.configuration});
|
||||
SimpleColumnBlockComponentBuilder({
|
||||
super.configuration,
|
||||
});
|
||||
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
@@ -51,6 +85,7 @@ class SimpleColumnBlockComponent extends BlockComponentStatefulWidget {
|
||||
required super.node,
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.actionTrailingBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
@@ -73,16 +108,6 @@ class SimpleColumnBlockComponentState extends State<SimpleColumnBlockComponent>
|
||||
|
||||
late final EditorState editorState = context.read<EditorState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = Column(
|
||||
@@ -93,9 +118,6 @@ class SimpleColumnBlockComponentState extends State<SimpleColumnBlockComponent>
|
||||
Widget child = IntrinsicHeight(
|
||||
child: editorState.renderer.build(context, e),
|
||||
);
|
||||
if (e.type == CustomImageBlockKeys.type) {
|
||||
child = IntrinsicWidth(child: child);
|
||||
}
|
||||
if (SimpleColumnsBlockConstants.enableDebugBorder) {
|
||||
child = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
@@ -120,7 +142,7 @@ class SimpleColumnBlockComponentState extends State<SimpleColumnBlockComponent>
|
||||
if (SimpleColumnsBlockConstants.enableDebugBorder) {
|
||||
child = Container(
|
||||
color: Colors.green.withValues(
|
||||
alpha: 0.2,
|
||||
alpha: 0.3,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
+49
-15
@@ -1,6 +1,5 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -26,6 +25,13 @@ class _SimpleColumnBlockWidthResizerState
|
||||
|
||||
ValueNotifier<bool> isHovering = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
isHovering.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
@@ -78,25 +84,47 @@ class _SimpleColumnBlockWidthResizerState
|
||||
|
||||
// update the column width in memory
|
||||
final columnNode = widget.columnNode;
|
||||
final columnsNode = columnNode.columnsParent;
|
||||
if (columnsNode == null) {
|
||||
return;
|
||||
}
|
||||
final editorWidth = columnsNode.rect.width;
|
||||
final rect = columnNode.rect;
|
||||
final width =
|
||||
columnNode.attributes[SimpleColumnBlockKeys.width] ?? rect.width;
|
||||
final width = rect.width;
|
||||
final originalRatio = columnNode.attributes[SimpleColumnBlockKeys.ratio];
|
||||
final newWidth = width + details.delta.dx;
|
||||
|
||||
final transaction = widget.editorState.transaction;
|
||||
final newRatio = newWidth / editorWidth;
|
||||
transaction.updateNode(columnNode, {
|
||||
...columnNode.attributes,
|
||||
SimpleColumnBlockKeys.width: newWidth.clamp(
|
||||
SimpleColumnsBlockConstants.minimumColumnWidth,
|
||||
double.infinity,
|
||||
),
|
||||
SimpleColumnBlockKeys.ratio: newRatio,
|
||||
});
|
||||
final columnsNode = columnNode.parent;
|
||||
if (columnsNode != null) {
|
||||
transaction.updateNode(columnsNode, {
|
||||
...columnsNode.attributes,
|
||||
ColumnsBlockKeys.columnCount: columnsNode.children.length,
|
||||
|
||||
if (newRatio < 0.1 && newRatio < originalRatio) {
|
||||
return;
|
||||
}
|
||||
|
||||
final nextColumn = columnNode.nextColumn;
|
||||
if (nextColumn != null) {
|
||||
final nextColumnRect = nextColumn.rect;
|
||||
final nextColumnWidth = nextColumnRect.width;
|
||||
final newNextColumnWidth = nextColumnWidth - details.delta.dx;
|
||||
final newNextColumnRatio = newNextColumnWidth / editorWidth;
|
||||
if (newNextColumnRatio < 0.1) {
|
||||
return;
|
||||
}
|
||||
transaction.updateNode(nextColumn, {
|
||||
...nextColumn.attributes,
|
||||
SimpleColumnBlockKeys.ratio: newNextColumnRatio,
|
||||
});
|
||||
}
|
||||
|
||||
transaction.updateNode(columnsNode, {
|
||||
...columnsNode.attributes,
|
||||
ColumnsBlockKeys.columnCount: columnsNode.children.length,
|
||||
});
|
||||
|
||||
widget.editorState.apply(
|
||||
transaction,
|
||||
options: ApplyOptions(inMemoryUpdate: true),
|
||||
@@ -113,9 +141,15 @@ class _SimpleColumnBlockWidthResizerState
|
||||
|
||||
// apply the transaction again to make sure the width is updated
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.updateNode(widget.columnNode, {
|
||||
...widget.columnNode.attributes,
|
||||
});
|
||||
final columnsNode = widget.columnNode.columnsParent;
|
||||
if (columnsNode == null) {
|
||||
return;
|
||||
}
|
||||
for (final columnNode in columnsNode.children) {
|
||||
transaction.updateNode(columnNode, {
|
||||
...columnNode.attributes,
|
||||
});
|
||||
}
|
||||
widget.editorState.apply(transaction);
|
||||
|
||||
isDragging = false;
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
extension SimpleColumnNodeExtension on Node {
|
||||
/// Returns the parent [Node] of the current node if it is a [SimpleColumnsBlock].
|
||||
Node? get parentColumnsBlock {
|
||||
Node? get columnsParent {
|
||||
Node? currentNode = parent;
|
||||
while (currentNode != null) {
|
||||
if (currentNode.type == SimpleColumnsBlockKeys.type) {
|
||||
@@ -15,7 +15,7 @@ extension SimpleColumnNodeExtension on Node {
|
||||
}
|
||||
|
||||
/// Returns the parent [Node] of the current node if it is a [SimpleColumnBlock].
|
||||
Node? get parentColumn {
|
||||
Node? get columnParent {
|
||||
Node? currentNode = parent;
|
||||
while (currentNode != null) {
|
||||
if (currentNode.type == SimpleColumnBlockKeys.type) {
|
||||
@@ -27,8 +27,8 @@ extension SimpleColumnNodeExtension on Node {
|
||||
}
|
||||
|
||||
/// Returns whether the current node is in a [SimpleColumnsBlock].
|
||||
bool get isInColumnsBlock => parentColumnsBlock != null;
|
||||
bool get isInColumnsBlock => columnsParent != null;
|
||||
|
||||
/// Returns whether the current node is in a [SimpleColumnBlock].
|
||||
bool get isInColumnBlock => parentColumn != null;
|
||||
bool get isInColumnBlock => columnParent != null;
|
||||
}
|
||||
+52
-33
@@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
@@ -5,20 +7,19 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
// if the children is not provided, it will create two columns by default.
|
||||
// if the columnCount is provided, it will create the specified number of columns.
|
||||
Node simpleColumnsNode({
|
||||
List<Node>? children,
|
||||
int? columnCount,
|
||||
double? width,
|
||||
double? ratio,
|
||||
}) {
|
||||
columnCount ??= 2;
|
||||
children ??= List.generate(
|
||||
columnCount,
|
||||
(index) => simpleColumnNode(
|
||||
width: width,
|
||||
ratio: ratio,
|
||||
children: [paragraphNode()],
|
||||
),
|
||||
);
|
||||
@@ -67,6 +68,7 @@ class ColumnsBlockComponent extends BlockComponentStatefulWidget {
|
||||
required super.node,
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.actionTrailingBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
@@ -90,6 +92,13 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
|
||||
final ScrollController scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_updateColumnsBlock();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollController.dispose();
|
||||
@@ -99,23 +108,12 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: scrollController,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: _buildChildren(),
|
||||
),
|
||||
Widget child = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: _buildChildren(),
|
||||
);
|
||||
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
// only show the scrollbar on desktop
|
||||
child = Scrollbar(
|
||||
controller: scrollController,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
child = Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: IntrinsicHeight(
|
||||
@@ -147,34 +145,55 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
}
|
||||
|
||||
List<Widget> _buildChildren() {
|
||||
final length = node.children.length;
|
||||
final children = <Widget>[];
|
||||
for (var i = 0; i < node.children.length; i++) {
|
||||
for (var i = 0; i < length; i++) {
|
||||
final childNode = node.children[i];
|
||||
final width =
|
||||
childNode.attributes[SimpleColumnBlockKeys.width]?.toDouble() ??
|
||||
SimpleColumnsBlockConstants.minimumColumnWidth;
|
||||
final double ratio =
|
||||
childNode.attributes[SimpleColumnBlockKeys.ratio]?.toDouble() ??
|
||||
1.0 / length;
|
||||
|
||||
Widget child = editorState.renderer.build(context, childNode);
|
||||
|
||||
child = SizedBox(
|
||||
width: width.clamp(
|
||||
SimpleColumnsBlockConstants.minimumColumnWidth,
|
||||
double.infinity,
|
||||
),
|
||||
child = Expanded(
|
||||
flex: (max(ratio, 0.1) * 10000).toInt(),
|
||||
child: child,
|
||||
);
|
||||
|
||||
children.add(child);
|
||||
|
||||
children.add(
|
||||
SimpleColumnBlockWidthResizer(
|
||||
columnNode: childNode,
|
||||
editorState: editorState,
|
||||
),
|
||||
);
|
||||
if (i != length - 1) {
|
||||
children.add(
|
||||
SimpleColumnBlockWidthResizer(
|
||||
columnNode: childNode,
|
||||
editorState: editorState,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
// Update the existing columns block data
|
||||
// if the column ratio is not existing, it will be set to 1.0 / columnCount
|
||||
void _updateColumnsBlock() {
|
||||
final transaction = editorState.transaction;
|
||||
final length = node.children.length;
|
||||
for (int i = 0; i < length; i++) {
|
||||
final childNode = node.children[i];
|
||||
final ratio = childNode.attributes[SimpleColumnBlockKeys.ratio];
|
||||
if (ratio == null) {
|
||||
transaction.updateNode(childNode, {
|
||||
...childNode.attributes,
|
||||
SimpleColumnBlockKeys.ratio: 1.0 / length,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (transaction.operations.isNotEmpty) {
|
||||
editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Position start() => Position(path: widget.node.path);
|
||||
|
||||
|
||||
+3
@@ -32,8 +32,11 @@ extension PasteFromPlainText on EditorState {
|
||||
|
||||
await deleteSelectionIfNeeded();
|
||||
|
||||
/// try to parse the plain text as markdown
|
||||
final nodes = customMarkdownToDocument(plainText).root.children;
|
||||
if (nodes.isEmpty) {
|
||||
/// if the markdown parser failed, fallback to the plain text parser
|
||||
await pastePlainText(plainText);
|
||||
return;
|
||||
}
|
||||
if (nodes.length == 1) {
|
||||
|
||||
+6
-1
@@ -1,6 +1,7 @@
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_listener.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
@@ -19,9 +20,13 @@ class DocumentImmersiveCoverBloc
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
initial: () async {
|
||||
final latestView = await ViewBackendService.getView(view.id);
|
||||
add(
|
||||
DocumentImmersiveCoverEvent.updateCoverAndIcon(
|
||||
view.cover,
|
||||
latestView.fold(
|
||||
(s) => s.cover,
|
||||
(e) => view.cover,
|
||||
),
|
||||
EmojiIconData.fromViewIconPB(view.icon),
|
||||
view.name,
|
||||
),
|
||||
|
||||
+5
@@ -42,6 +42,10 @@ class DatabaseViewBlockComponentBuilder extends BlockComponentBuilder {
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
actionTrailingBuilder: (context, state) => actionTrailingBuilder(
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +62,7 @@ class DatabaseBlockComponentWidget extends BlockComponentStatefulWidget {
|
||||
required super.node,
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.actionTrailingBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/overlay_util.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ColorPicker extends StatefulWidget {
|
||||
const ColorPicker({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.selectedColorHex,
|
||||
required this.onSubmittedColorHex,
|
||||
required this.colorOptions,
|
||||
this.resetText,
|
||||
this.customColorHex,
|
||||
this.resetIconName,
|
||||
this.showClearButton = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? selectedColorHex;
|
||||
final String? customColorHex;
|
||||
final void Function(String? color, bool isCustomColor) onSubmittedColorHex;
|
||||
final String? resetText;
|
||||
final String? resetIconName;
|
||||
final bool showClearButton;
|
||||
|
||||
final List<ColorOption> colorOptions;
|
||||
|
||||
@override
|
||||
State<ColorPicker> createState() => _ColorPickerState();
|
||||
}
|
||||
|
||||
class _ColorPickerState extends State<ColorPicker> {
|
||||
final TextEditingController _colorHexController = TextEditingController();
|
||||
final TextEditingController _colorOpacityController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final selectedColorHex = widget.selectedColorHex,
|
||||
customColorHex = widget.customColorHex;
|
||||
_colorHexController.text =
|
||||
_extractColorHex(customColorHex ?? selectedColorHex) ?? 'FFFFFF';
|
||||
_colorOpacityController.text =
|
||||
_convertHexToOpacity(customColorHex ?? selectedColorHex) ?? '100';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return basicOverlay(
|
||||
context,
|
||||
width: 300,
|
||||
height: 250,
|
||||
children: [
|
||||
EditorOverlayTitle(text: widget.title),
|
||||
const SizedBox(height: 6),
|
||||
widget.showClearButton &&
|
||||
widget.resetText != null &&
|
||||
widget.resetIconName != null
|
||||
? ResetColorButton(
|
||||
resetText: widget.resetText!,
|
||||
resetIconName: widget.resetIconName!,
|
||||
onPressed: (color) =>
|
||||
widget.onSubmittedColorHex.call(color, false),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
CustomColorItem(
|
||||
colorController: _colorHexController,
|
||||
opacityController: _colorOpacityController,
|
||||
onSubmittedColorHex: (color) =>
|
||||
widget.onSubmittedColorHex.call(color, true),
|
||||
),
|
||||
_buildColorItems(
|
||||
widget.colorOptions,
|
||||
widget.selectedColorHex,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorItems(
|
||||
List<ColorOption> options,
|
||||
String? selectedColor,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: options
|
||||
.map((e) => _buildColorItem(e, e.colorHex == selectedColor))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorItem(ColorOption option, bool isChecked) {
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
widget.onSubmittedColorHex(option.colorHex, false);
|
||||
},
|
||||
icon: SizedBox.square(
|
||||
dimension: 12,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: option.colorHex.tryToColor(),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
style: buildOverlayButtonStyle(context),
|
||||
label: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
option.name,
|
||||
softWrap: false,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).textTheme.labelLarge?.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
// checkbox
|
||||
if (isChecked) const FlowySvg(FlowySvgs.toolbar_check_m),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _convertHexToOpacity(String? colorHex) {
|
||||
if (colorHex == null) return null;
|
||||
final opacityHex = colorHex.substring(2, 4);
|
||||
final opacity = int.parse(opacityHex, radix: 16) / 2.55;
|
||||
return opacity.toStringAsFixed(0);
|
||||
}
|
||||
|
||||
String? _extractColorHex(String? colorHex) {
|
||||
if (colorHex == null) return null;
|
||||
return colorHex.substring(4);
|
||||
}
|
||||
}
|
||||
|
||||
class ResetColorButton extends StatelessWidget {
|
||||
const ResetColorButton({
|
||||
super.key,
|
||||
required this.resetText,
|
||||
required this.resetIconName,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final Function(String? color) onPressed;
|
||||
final String resetText;
|
||||
final String resetIconName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 32,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => onPressed(null),
|
||||
icon: EditorSvg(
|
||||
name: resetIconName,
|
||||
width: 13,
|
||||
height: 13,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
label: Text(
|
||||
resetText,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStateProperty.resolveWith<Color>(
|
||||
(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.hovered)) {
|
||||
return Theme.of(context).hoverColor;
|
||||
}
|
||||
return Colors.transparent;
|
||||
},
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomColorItem extends StatefulWidget {
|
||||
const CustomColorItem({
|
||||
super.key,
|
||||
required this.colorController,
|
||||
required this.opacityController,
|
||||
required this.onSubmittedColorHex,
|
||||
});
|
||||
|
||||
final TextEditingController colorController;
|
||||
final TextEditingController opacityController;
|
||||
final void Function(String color) onSubmittedColorHex;
|
||||
|
||||
@override
|
||||
State<CustomColorItem> createState() => _CustomColorItemState();
|
||||
}
|
||||
|
||||
class _CustomColorItemState extends State<CustomColorItem> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExpansionTile(
|
||||
tilePadding: const EdgeInsets.only(left: 8),
|
||||
shape: Border.all(
|
||||
color: Colors.transparent,
|
||||
), // remove the default border when it is expanded
|
||||
title: Row(
|
||||
children: [
|
||||
// color sample box
|
||||
SizedBox.square(
|
||||
dimension: 12,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Color(
|
||||
int.tryParse(
|
||||
_combineColorHexAndOpacity(
|
||||
widget.colorController.text,
|
||||
widget.opacityController.text,
|
||||
),
|
||||
) ??
|
||||
0xFFFFFFFF,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppFlowyEditorL10n.current.customColor,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
// same style as TextButton.icon
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
const SizedBox(height: 6),
|
||||
_customColorDetailsTextField(
|
||||
labelText: AppFlowyEditorL10n.current.hexValue,
|
||||
controller: widget.colorController,
|
||||
// update the color sample box when the text changes
|
||||
onChanged: (_) => setState(() {}),
|
||||
onSubmitted: _submitCustomColorHex,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_customColorDetailsTextField(
|
||||
labelText: AppFlowyEditorL10n.current.opacity,
|
||||
controller: widget.opacityController,
|
||||
// update the color sample box when the text changes
|
||||
onChanged: (_) => setState(() {}),
|
||||
onSubmitted: _submitCustomColorHex,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _customColorDetailsTextField({
|
||||
required String labelText,
|
||||
required TextEditingController controller,
|
||||
Function(String)? onChanged,
|
||||
Function(String)? onSubmitted,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 3),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: labelText,
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _combineColorHexAndOpacity(String colorHex, String opacity) {
|
||||
colorHex = _fixColorHex(colorHex);
|
||||
opacity = _fixOpacity(opacity);
|
||||
final opacityHex = (int.parse(opacity) * 2.55).round().toRadixString(16);
|
||||
return '0x$opacityHex$colorHex';
|
||||
}
|
||||
|
||||
String _fixColorHex(String colorHex) {
|
||||
if (colorHex.length > 6) {
|
||||
colorHex = colorHex.substring(0, 6);
|
||||
}
|
||||
if (int.tryParse(colorHex, radix: 16) == null) {
|
||||
colorHex = 'FFFFFF';
|
||||
}
|
||||
return colorHex;
|
||||
}
|
||||
|
||||
String _fixOpacity(String opacity) {
|
||||
// if opacity is 0 - 99, return it
|
||||
// otherwise return 100
|
||||
final RegExp regex = RegExp('^(0|[1-9][0-9]?)');
|
||||
if (regex.hasMatch(opacity)) {
|
||||
return opacity;
|
||||
} else {
|
||||
return '100';
|
||||
}
|
||||
}
|
||||
|
||||
void _submitCustomColorHex(String value) {
|
||||
final String color = _combineColorHexAndOpacity(
|
||||
widget.colorController.text,
|
||||
widget.opacityController.text,
|
||||
);
|
||||
widget.onSubmittedColorHex(color);
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'toolbar_animation.dart';
|
||||
import 'toolbar_cubit.dart';
|
||||
|
||||
class DesktopFloatingToolbar extends StatefulWidget {
|
||||
const DesktopFloatingToolbar({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
required this.child,
|
||||
required this.onDismiss,
|
||||
this.enableAnimation = true,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final Widget child;
|
||||
final VoidCallback onDismiss;
|
||||
final bool enableAnimation;
|
||||
|
||||
@override
|
||||
State<DesktopFloatingToolbar> createState() => _DesktopFloatingToolbarState();
|
||||
}
|
||||
|
||||
class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
|
||||
EditorState get editorState => widget.editorState;
|
||||
|
||||
_Position? position;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
final selectionRect = editorState.selectionRects();
|
||||
if (selectionRect.isEmpty) return;
|
||||
position = calculateSelectionMenuOffset(selectionRect.first);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (position == null) return Container();
|
||||
return BlocProvider<ToolbarCubit>(
|
||||
create: (_) => ToolbarCubit(widget.onDismiss),
|
||||
child: Positioned(
|
||||
left: position!.left,
|
||||
top: position!.top,
|
||||
right: position!.right,
|
||||
child: widget.enableAnimation
|
||||
? ToolbarAnimationWidget(child: widget.child)
|
||||
: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_Position calculateSelectionMenuOffset(
|
||||
Rect rect,
|
||||
) {
|
||||
const toolbarHeight = 40, topLimit = toolbarHeight + 8;
|
||||
final bool isLongMenu = onlyShowInSingleSelectionAndTextType(editorState);
|
||||
final editorOffset =
|
||||
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
final editorSize = editorState.renderBox?.size ?? Size.zero;
|
||||
final menuWidth =
|
||||
isLongMenu ? (isNarrowWindow(editorState) ? 490.0 : 660.0) : 420.0;
|
||||
final editorRect = editorOffset & editorSize;
|
||||
final left = rect.left, leftStart = 50;
|
||||
final top =
|
||||
rect.top < topLimit ? rect.bottom + topLimit : rect.top - topLimit;
|
||||
if (left + menuWidth > editorRect.right) {
|
||||
return _Position(
|
||||
editorRect.right - menuWidth,
|
||||
top,
|
||||
null,
|
||||
);
|
||||
} else if (rect.left - leftStart > 0) {
|
||||
return _Position(rect.left - leftStart, top, null);
|
||||
} else {
|
||||
return _Position(rect.left, top, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Position {
|
||||
_Position(this.left, this.top, this.right);
|
||||
|
||||
final double? left;
|
||||
final double? top;
|
||||
final double? right;
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
|
||||
import 'package:appflowy/plugins/shared/share/constants.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.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_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'link_search_text_field.dart';
|
||||
|
||||
class LinkCreateMenu extends StatefulWidget {
|
||||
const LinkCreateMenu({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
required this.onSubmitted,
|
||||
required this.onDismiss,
|
||||
required this.alignment,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final void Function(String link, bool isPage) onSubmitted;
|
||||
final VoidCallback onDismiss;
|
||||
final LinkMenuAlignment alignment;
|
||||
|
||||
@override
|
||||
State<LinkCreateMenu> createState() => _LinkCreateMenuState();
|
||||
}
|
||||
|
||||
class _LinkCreateMenuState extends State<LinkCreateMenu> {
|
||||
late LinkSearchTextField searchTextField = LinkSearchTextField(
|
||||
onEnter: () {
|
||||
searchTextField.onSearchResult(
|
||||
onLink: () => onSubmittedLink(),
|
||||
onRecentViews: () =>
|
||||
onSubmittedPageLink(searchTextField.currentRecentView),
|
||||
onSearchViews: () =>
|
||||
onSubmittedPageLink(searchTextField.currentSearchedView),
|
||||
onEmpty: () {},
|
||||
);
|
||||
},
|
||||
onEscape: widget.onDismiss,
|
||||
onDataRefresh: () {
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
);
|
||||
|
||||
bool get isButtonEnable => searchText.isNotEmpty;
|
||||
|
||||
String get searchText => searchTextField.searchText;
|
||||
|
||||
bool get showAtTop => widget.alignment.isTop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
searchTextField.requestFocus();
|
||||
searchTextField.searchRecentViews();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
searchTextField.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
children: showAtTop
|
||||
? [
|
||||
searchTextField.buildResultContainer(
|
||||
margin: EdgeInsets.only(bottom: 2),
|
||||
context: context,
|
||||
onLinkSelected: onSubmittedLink,
|
||||
onPageLinkSelected: onSubmittedPageLink,
|
||||
),
|
||||
buildSearchContainer(),
|
||||
]
|
||||
: [
|
||||
buildSearchContainer(),
|
||||
searchTextField.buildResultContainer(
|
||||
margin: EdgeInsets.only(top: 2),
|
||||
context: context,
|
||||
onLinkSelected: onSubmittedLink,
|
||||
onPageLinkSelected: onSubmittedPageLink,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildSearchContainer() {
|
||||
return Container(
|
||||
width: 320,
|
||||
height: 48,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
padding: EdgeInsets.all(8),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: searchTextField.textEditingController,
|
||||
builder: (context, _, __) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: searchTextField.buildTextField()),
|
||||
HSpace(8),
|
||||
FlowyTextButton(
|
||||
LocaleKeys.document_toolbar_insert.tr(),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: BoxConstraints(maxWidth: 72, minHeight: 32),
|
||||
fontSize: 14,
|
||||
fontColor:
|
||||
isButtonEnable ? Colors.white : LinkStyle.textTertiary,
|
||||
fillColor: isButtonEnable
|
||||
? LinkStyle.fillThemeThick
|
||||
: LinkStyle.borderColor,
|
||||
hoverColor: LinkStyle.fillThemeThick,
|
||||
lineHeight: 20 / 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
onPressed: isButtonEnable ? () => onSubmittedLink() : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onSubmittedLink() => widget.onSubmitted(searchText, false);
|
||||
|
||||
void onSubmittedPageLink(ViewPB view) async {
|
||||
final workspaceId = context
|
||||
.read<UserWorkspaceBloc?>()
|
||||
?.state
|
||||
.currentWorkspace
|
||||
?.workspaceId ??
|
||||
'';
|
||||
final link = ShareConstants.buildShareUrl(
|
||||
workspaceId: workspaceId,
|
||||
viewId: view.id,
|
||||
);
|
||||
widget.onSubmitted(link, true);
|
||||
}
|
||||
}
|
||||
|
||||
void showLinkCreateMenu(
|
||||
BuildContext context,
|
||||
EditorState editorState,
|
||||
Selection selection,
|
||||
) {
|
||||
final (left, top, right, bottom, alignment) = _getPosition(editorState);
|
||||
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
OverlayEntry? overlay;
|
||||
|
||||
void dismissOverlay() {
|
||||
keepEditorFocusNotifier.decrease();
|
||||
overlay?.remove();
|
||||
overlay = null;
|
||||
}
|
||||
|
||||
keepEditorFocusNotifier.increase();
|
||||
overlay = FullScreenOverlayEntry(
|
||||
top: top,
|
||||
bottom: bottom,
|
||||
left: left,
|
||||
right: right,
|
||||
dismissCallback: () => keepEditorFocusNotifier.decrease(),
|
||||
builder: (context) {
|
||||
return LinkCreateMenu(
|
||||
alignment: alignment,
|
||||
editorState: editorState,
|
||||
onSubmitted: (link, isPage) async {
|
||||
await editorState.formatDelta(selection, {
|
||||
BuiltInAttributeKey.href: link,
|
||||
kIsPageLink: isPage,
|
||||
});
|
||||
dismissOverlay();
|
||||
},
|
||||
onDismiss: dismissOverlay,
|
||||
);
|
||||
},
|
||||
).build();
|
||||
|
||||
Overlay.of(context, rootOverlay: true).insert(overlay!);
|
||||
}
|
||||
|
||||
// get a proper position for link menu
|
||||
(
|
||||
double? left,
|
||||
double? top,
|
||||
double? right,
|
||||
double? bottom,
|
||||
LinkMenuAlignment alignment,
|
||||
) _getPosition(
|
||||
EditorState editorState,
|
||||
) {
|
||||
final rect = editorState.selectionRects().first;
|
||||
const menuHeight = 222.0, menuWidth = 320.0;
|
||||
|
||||
double? left, right, top, bottom;
|
||||
LinkMenuAlignment alignment = LinkMenuAlignment.topLeft;
|
||||
final editorOffset = editorState.renderBox!.localToGlobal(Offset.zero),
|
||||
editorSize = editorState.renderBox!.size;
|
||||
final editorBottom = editorSize.height + editorOffset.dy,
|
||||
editorRight = editorSize.width + editorOffset.dx;
|
||||
final overflowBottom = rect.bottom + menuHeight > editorBottom,
|
||||
overflowTop = rect.top - menuHeight < 0,
|
||||
overflowLeft = rect.left - menuWidth < 0,
|
||||
overflowRight = rect.right + menuWidth > editorRight;
|
||||
|
||||
if (overflowTop && !overflowBottom) {
|
||||
/// show at bottom
|
||||
top = rect.bottom;
|
||||
} else if (overflowBottom && !overflowTop) {
|
||||
/// show at top
|
||||
bottom = editorBottom - rect.top;
|
||||
} else if (!overflowTop && !overflowBottom) {
|
||||
/// show at bottom
|
||||
top = rect.bottom;
|
||||
} else {
|
||||
top = 0;
|
||||
}
|
||||
|
||||
if (overflowLeft && !overflowRight) {
|
||||
/// show at right
|
||||
left = rect.left;
|
||||
} else if (overflowRight && !overflowLeft) {
|
||||
/// show at left
|
||||
right = editorRight - rect.right;
|
||||
} else if (!overflowLeft && !overflowRight) {
|
||||
/// show at right
|
||||
left = rect.left;
|
||||
} else {
|
||||
left = 0;
|
||||
}
|
||||
|
||||
if (left != null && top != null) {
|
||||
alignment = LinkMenuAlignment.bottomRight;
|
||||
} else if (left != null && bottom != null) {
|
||||
alignment = LinkMenuAlignment.topRight;
|
||||
} else if (right != null && top != null) {
|
||||
alignment = LinkMenuAlignment.bottomLeft;
|
||||
} else if (right != null && bottom != null) {
|
||||
alignment = LinkMenuAlignment.topLeft;
|
||||
}
|
||||
|
||||
return (left, top, right, bottom, alignment);
|
||||
}
|
||||
|
||||
ShapeDecoration buildToolbarLinkDecoration(BuildContext context) =>
|
||||
ShapeDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shadows: [
|
||||
const BoxShadow(
|
||||
color: LinkStyle.shadowMedium,
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
);
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
|
||||
import 'package:appflowy/plugins/shared/share/constants.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'link_create_menu.dart';
|
||||
import 'link_search_text_field.dart';
|
||||
import 'link_styles.dart';
|
||||
|
||||
class LinkEditMenu extends StatefulWidget {
|
||||
const LinkEditMenu({
|
||||
super.key,
|
||||
required this.linkInfo,
|
||||
required this.onDismiss,
|
||||
required this.onApply,
|
||||
required this.onRemoveLink,
|
||||
});
|
||||
|
||||
final LinkInfo linkInfo;
|
||||
final ValueChanged<LinkInfo> onApply;
|
||||
final VoidCallback onRemoveLink;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
State<LinkEditMenu> createState() => _LinkEditMenuState();
|
||||
}
|
||||
|
||||
class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
ValueChanged<LinkInfo> get onApply => widget.onApply;
|
||||
|
||||
VoidCallback get onRemoveLink => widget.onRemoveLink;
|
||||
|
||||
VoidCallback get onDismiss => widget.onDismiss;
|
||||
|
||||
late TextEditingController linkNameController =
|
||||
TextEditingController(text: linkInfo.name);
|
||||
final textFocusNode = FocusNode();
|
||||
late LinkInfo linkInfo = widget.linkInfo;
|
||||
late LinkSearchTextField searchTextField;
|
||||
bool isShowingSearchResult = false;
|
||||
ViewPB? currentView;
|
||||
|
||||
bool get enableApply =>
|
||||
linkInfo.link.isNotEmpty && linkNameController.text.isNotEmpty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final isPageLink = linkInfo.isPage;
|
||||
if (isPageLink) getPageView();
|
||||
searchTextField = LinkSearchTextField(
|
||||
initialSearchText: isPageLink ? '' : linkInfo.link,
|
||||
onEnter: () {
|
||||
searchTextField.onSearchResult(
|
||||
onLink: onLinkSelected,
|
||||
onRecentViews: () =>
|
||||
onPageSelected(searchTextField.currentRecentView),
|
||||
onSearchViews: () =>
|
||||
onPageSelected(searchTextField.currentSearchedView),
|
||||
onEmpty: () {
|
||||
searchTextField.unfocus();
|
||||
},
|
||||
);
|
||||
},
|
||||
onEscape: () {
|
||||
if (isShowingSearchResult) {
|
||||
hideSearchResult();
|
||||
} else {
|
||||
onDismiss();
|
||||
}
|
||||
},
|
||||
onDataRefresh: () {
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
)..searchRecentViews();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
linkNameController.dispose();
|
||||
textFocusNode.dispose();
|
||||
searchTextField.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showingRecent =
|
||||
searchTextField.showingRecent && isShowingSearchResult;
|
||||
return GestureDetector(
|
||||
onTap: onDismiss,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 250 + (showingRecent ? 32 : 0),
|
||||
color: Colors.white.withAlpha(1),
|
||||
child: Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: hideSearchResult,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 192,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 20,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.document_toolbar_pageOrURL.tr(),
|
||||
color: LinkStyle.textTertiary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 80,
|
||||
left: 20,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.document_toolbar_linkName.tr(),
|
||||
color: LinkStyle.textTertiary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 152,
|
||||
left: 20,
|
||||
child: buildButtons(),
|
||||
),
|
||||
Positioned(
|
||||
top: 108,
|
||||
left: 20,
|
||||
child: buildNameTextField(),
|
||||
),
|
||||
Positioned(
|
||||
top: 36,
|
||||
left: 20,
|
||||
child: buildLinkField(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLinkField() {
|
||||
final showPageView = linkInfo.isPage && !isShowingSearchResult;
|
||||
if (showPageView) return buildPageView();
|
||||
if (!isShowingSearchResult) return buildLinkView();
|
||||
return SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 360,
|
||||
height: 32,
|
||||
child: searchTextField.buildTextField(
|
||||
autofocus: true,
|
||||
),
|
||||
),
|
||||
VSpace(6),
|
||||
searchTextField.buildResultContainer(
|
||||
context: context,
|
||||
width: 360,
|
||||
onPageLinkSelected: onPageSelected,
|
||||
onLinkSelected: onLinkSelected,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildButtons() {
|
||||
return GestureDetector(
|
||||
onTap: hideSearchResult,
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
height: 32,
|
||||
child: Row(
|
||||
children: [
|
||||
FlowyIconButton(
|
||||
icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m),
|
||||
width: 32,
|
||||
height: 32,
|
||||
tooltipText: LocaleKeys.editor_removeLink.tr(),
|
||||
preferBelow: false,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(color: LinkStyle.borderColor),
|
||||
),
|
||||
onPressed: onRemoveLink,
|
||||
),
|
||||
Spacer(),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(color: LinkStyle.borderColor),
|
||||
),
|
||||
child: FlowyTextButton(
|
||||
LocaleKeys.button_cancel.tr(),
|
||||
padding: EdgeInsets.zero,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
constraints: BoxConstraints(maxWidth: 78, minHeight: 32),
|
||||
fontSize: 14,
|
||||
lineHeight: 20 / 14,
|
||||
fontColor: LinkStyle.textPrimary,
|
||||
fillColor: Colors.transparent,
|
||||
fontWeight: FontWeight.w400,
|
||||
onPressed: onDismiss,
|
||||
),
|
||||
),
|
||||
HSpace(12),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: linkNameController,
|
||||
builder: (context, _, __) {
|
||||
return FlowyTextButton(
|
||||
LocaleKeys.settings_appearance_documentSettings_apply.tr(),
|
||||
padding: EdgeInsets.zero,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
constraints: BoxConstraints(maxWidth: 78, minHeight: 32),
|
||||
fontSize: 14,
|
||||
lineHeight: 20 / 14,
|
||||
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
|
||||
fontColor:
|
||||
enableApply ? Colors.white : LinkStyle.textTertiary,
|
||||
fillColor: enableApply
|
||||
? LinkStyle.fillThemeThick
|
||||
: LinkStyle.borderColor,
|
||||
fontWeight: FontWeight.w400,
|
||||
onPressed:
|
||||
enableApply ? () => widget.onApply.call(linkInfo) : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildNameTextField() {
|
||||
return SizedBox(
|
||||
width: 360,
|
||||
height: 32,
|
||||
child: TextFormField(
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
focusNode: textFocusNode,
|
||||
textAlign: TextAlign.left,
|
||||
controller: linkNameController,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 20 / 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
onChanged: (text) {
|
||||
linkInfo = LinkInfo(
|
||||
name: text,
|
||||
link: linkInfo.link,
|
||||
isPage: linkInfo.isPage,
|
||||
);
|
||||
},
|
||||
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
|
||||
LocaleKeys.document_toolbar_linkNameHint.tr(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPageView() {
|
||||
late Widget child;
|
||||
final view = currentView;
|
||||
if (view == null) {
|
||||
child = Center(
|
||||
child: SizedBox.fromSize(
|
||||
size: Size(10, 10),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
child = GestureDetector(
|
||||
onTap: showSearchResult,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
height: 32,
|
||||
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
searchTextField.buildIcon(view),
|
||||
HSpace(8),
|
||||
Flexible(
|
||||
child: FlowyText.regular(
|
||||
view.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
width: 360,
|
||||
height: 32,
|
||||
decoration: buildDecoration(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLinkView() {
|
||||
return Container(
|
||||
width: 360,
|
||||
height: 32,
|
||||
decoration: buildDecoration(),
|
||||
child: GestureDetector(
|
||||
onTap: showSearchResult,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
FlowySvg(FlowySvgs.toolbar_link_earth_m),
|
||||
HSpace(8),
|
||||
Flexible(
|
||||
child: FlowyText.regular(
|
||||
linkInfo.link,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> getPageView() async {
|
||||
if (!linkInfo.isPage) return;
|
||||
final link = linkInfo.link;
|
||||
final viewId = link.split('/').lastOrNull ?? '';
|
||||
final (view, isInTrash, isDeleted) =
|
||||
await ViewBackendService.getMentionPageStatus(viewId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
currentView = view;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void showSearchResult() {
|
||||
setState(() {
|
||||
if (linkInfo.isPage) searchTextField.updateText('');
|
||||
isShowingSearchResult = true;
|
||||
searchTextField.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
void hideSearchResult() {
|
||||
setState(() {
|
||||
isShowingSearchResult = false;
|
||||
searchTextField.unfocus();
|
||||
textFocusNode.unfocus();
|
||||
});
|
||||
}
|
||||
|
||||
void onLinkSelected() {
|
||||
if (mounted) {
|
||||
linkInfo = LinkInfo(
|
||||
name: linkInfo.name,
|
||||
link: searchTextField.searchText,
|
||||
);
|
||||
hideSearchResult();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onPageSelected(ViewPB view) async {
|
||||
currentView = view;
|
||||
final link = ShareConstants.buildShareUrl(
|
||||
workspaceId: await UserBackendService.getCurrentWorkspace().fold(
|
||||
(s) => s.id,
|
||||
(f) => '',
|
||||
),
|
||||
viewId: view.id,
|
||||
);
|
||||
linkInfo = LinkInfo(
|
||||
name: linkInfo.name,
|
||||
link: link,
|
||||
isPage: true,
|
||||
);
|
||||
searchTextField.updateText(linkInfo.link);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isShowingSearchResult = false;
|
||||
searchTextField.unfocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
BoxDecoration buildDecoration() => BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: LinkStyle.borderColor),
|
||||
);
|
||||
}
|
||||
|
||||
class LinkInfo {
|
||||
LinkInfo({this.isPage = false, required this.name, required this.link});
|
||||
|
||||
final bool isPage;
|
||||
final String name;
|
||||
final String link;
|
||||
|
||||
Attributes toAttribute() =>
|
||||
{AppFlowyRichTextKeys.href: link, kIsPageLink: isPage};
|
||||
}
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.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 'link_create_menu.dart';
|
||||
import 'link_edit_menu.dart';
|
||||
import 'link_styles.dart';
|
||||
|
||||
class LinkHoverTrigger extends StatefulWidget {
|
||||
const LinkHoverTrigger({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
required this.selection,
|
||||
required this.node,
|
||||
required this.attribute,
|
||||
required this.size,
|
||||
this.delayToShow = const Duration(milliseconds: 50),
|
||||
this.delayToHide = const Duration(milliseconds: 300),
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final Selection selection;
|
||||
final Node node;
|
||||
final Attributes attribute;
|
||||
final Size size;
|
||||
final Duration delayToShow;
|
||||
final Duration delayToHide;
|
||||
|
||||
@override
|
||||
State<LinkHoverTrigger> createState() => _LinkHoverTriggerState();
|
||||
}
|
||||
|
||||
class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
final hoverMenuController = PopoverController();
|
||||
final editMenuController = PopoverController();
|
||||
bool isHoverMenuShowing = false;
|
||||
bool isHoverMenuHovering = false;
|
||||
bool isHoverTriggerHovering = false;
|
||||
|
||||
Size get size => widget.size;
|
||||
|
||||
EditorState get editorState => widget.editorState;
|
||||
|
||||
Selection get selection => widget.selection;
|
||||
|
||||
Attributes get attribute => widget.attribute;
|
||||
|
||||
HoverTriggerKey get triggerKey => HoverTriggerKey(widget.node.id, selection);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
getIt<LinkHoverTriggers>()._add(triggerKey, showLinkHoverMenu);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
hoverMenuController.close();
|
||||
editMenuController.close();
|
||||
getIt<LinkHoverTriggers>()._remove(triggerKey, showLinkHoverMenu);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (v) {
|
||||
isHoverTriggerHovering = true;
|
||||
Future.delayed(widget.delayToShow, () {
|
||||
if (isHoverTriggerHovering && !isHoverMenuShowing) {
|
||||
showLinkHoverMenu();
|
||||
}
|
||||
});
|
||||
},
|
||||
onExit: (v) {
|
||||
isHoverTriggerHovering = false;
|
||||
tryToDismissLinkHoverMenu();
|
||||
},
|
||||
child: buildHoverPopover(
|
||||
buildEditPopover(
|
||||
Container(
|
||||
color: Colors.black.withAlpha(1),
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildHoverPopover(Widget child) {
|
||||
return AppFlowyPopover(
|
||||
controller: hoverMenuController,
|
||||
direction: PopoverDirection.topWithLeftAligned,
|
||||
offset: Offset(0, size.height),
|
||||
onOpen: () {
|
||||
keepEditorFocusNotifier.increase();
|
||||
isHoverMenuShowing = true;
|
||||
},
|
||||
onClose: () {
|
||||
keepEditorFocusNotifier.decrease();
|
||||
isHoverMenuShowing = false;
|
||||
},
|
||||
margin: EdgeInsets.zero,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: max(320, size.width),
|
||||
maxHeight: 48 + size.height,
|
||||
),
|
||||
decorationColor: Colors.transparent,
|
||||
popoverDecoration: BoxDecoration(),
|
||||
popupBuilder: (context) => LinkHoverMenu(
|
||||
attribute: widget.attribute,
|
||||
triggerSize: size,
|
||||
onEnter: (_) {
|
||||
isHoverMenuHovering = true;
|
||||
},
|
||||
onExit: (_) {
|
||||
isHoverMenuHovering = false;
|
||||
tryToDismissLinkHoverMenu();
|
||||
},
|
||||
onOpenLink: openLink,
|
||||
onCopyLink: copyLink,
|
||||
onEditLink: showLinkEditMenu,
|
||||
onRemoveLink: () => removeLink(editorState, selection, true),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildEditPopover(Widget child) {
|
||||
final href = attribute.href ?? '',
|
||||
isPage = attribute.isPage,
|
||||
title = editorState.getTextInSelection(selection).join();
|
||||
return AppFlowyPopover(
|
||||
controller: editMenuController,
|
||||
direction: PopoverDirection.bottomWithLeftAligned,
|
||||
offset: Offset(0, 0),
|
||||
onOpen: () => keepEditorFocusNotifier.increase(),
|
||||
onClose: () => keepEditorFocusNotifier.decrease(),
|
||||
margin: EdgeInsets.zero,
|
||||
asBarrier: true,
|
||||
decorationColor: Colors.transparent,
|
||||
popoverDecoration: BoxDecoration(),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 400,
|
||||
minHeight: 282,
|
||||
),
|
||||
popupBuilder: (context) => LinkEditMenu(
|
||||
linkInfo: LinkInfo(name: title, link: href, isPage: isPage),
|
||||
onDismiss: () => editMenuController.close(),
|
||||
onApply: (info) async {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.replaceText(
|
||||
widget.node,
|
||||
selection.startIndex,
|
||||
selection.length,
|
||||
info.name,
|
||||
attributes: info.toAttribute(),
|
||||
);
|
||||
editMenuController.close();
|
||||
await editorState.apply(transaction);
|
||||
},
|
||||
onRemoveLink: () => removeLink(editorState, selection, true),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
void showLinkHoverMenu() {
|
||||
if (isHoverMenuShowing) return;
|
||||
keepEditorFocusNotifier.increase();
|
||||
hoverMenuController.show();
|
||||
}
|
||||
|
||||
void showLinkEditMenu() {
|
||||
keepEditorFocusNotifier.increase();
|
||||
hoverMenuController.close();
|
||||
editMenuController.show();
|
||||
}
|
||||
|
||||
void tryToDismissLinkHoverMenu() {
|
||||
Future.delayed(widget.delayToHide, () {
|
||||
if (isHoverMenuHovering || isHoverTriggerHovering) {
|
||||
return;
|
||||
}
|
||||
hoverMenuController.close();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> openLink() async {
|
||||
final href = widget.attribute.href ?? '', isPage = widget.attribute.isPage;
|
||||
|
||||
if (isPage) {
|
||||
final viewId = href.split('/').lastOrNull ?? '';
|
||||
if (viewId.isEmpty) {
|
||||
await afLaunchUrlString(href);
|
||||
} else {
|
||||
final (view, isInTrash, isDeleted) =
|
||||
await ViewBackendService.getMentionPageStatus(viewId);
|
||||
if (view != null) {
|
||||
await handleMentionBlockTap(context, widget.editorState, view);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await afLaunchUrlString(href);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> copyLink() async {
|
||||
final href = widget.attribute.href ?? '';
|
||||
if (href.isEmpty) return;
|
||||
await getIt<ClipboardService>()
|
||||
.setData(ClipboardServiceData(plainText: href));
|
||||
hoverMenuController.close();
|
||||
}
|
||||
|
||||
void removeLink(
|
||||
EditorState editorState,
|
||||
Selection selection,
|
||||
bool isHref,
|
||||
) {
|
||||
if (!isHref) return;
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
final index = selection.normalized.startIndex;
|
||||
final length = selection.length;
|
||||
final transaction = editorState.transaction
|
||||
..formatText(
|
||||
node,
|
||||
index,
|
||||
length,
|
||||
{
|
||||
BuiltInAttributeKey.href: null,
|
||||
kIsPageLink: null,
|
||||
},
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
class LinkHoverMenu extends StatelessWidget {
|
||||
const LinkHoverMenu({
|
||||
super.key,
|
||||
required this.attribute,
|
||||
required this.onEnter,
|
||||
required this.onExit,
|
||||
required this.triggerSize,
|
||||
required this.onCopyLink,
|
||||
required this.onOpenLink,
|
||||
required this.onEditLink,
|
||||
required this.onRemoveLink,
|
||||
});
|
||||
|
||||
final Attributes attribute;
|
||||
final PointerEnterEventListener onEnter;
|
||||
final PointerExitEventListener onExit;
|
||||
final Size triggerSize;
|
||||
final VoidCallback onCopyLink;
|
||||
final VoidCallback onOpenLink;
|
||||
final VoidCallback onEditLink;
|
||||
final VoidCallback onRemoveLink;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final href = attribute.href ?? '';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MouseRegion(
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
child: SizedBox(
|
||||
width: max(320, triggerSize.width),
|
||||
height: 48,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: 320,
|
||||
height: 48,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FlowyText.regular(
|
||||
href,
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 20,
|
||||
width: 1,
|
||||
color: LinkStyle.borderColor,
|
||||
margin: EdgeInsets.symmetric(horizontal: 6),
|
||||
),
|
||||
FlowyIconButton(
|
||||
icon: FlowySvg(FlowySvgs.toolbar_link_m),
|
||||
tooltipText: LocaleKeys.editor_copyLink.tr(),
|
||||
width: 36,
|
||||
height: 32,
|
||||
onPressed: onCopyLink,
|
||||
),
|
||||
FlowyIconButton(
|
||||
icon: FlowySvg(FlowySvgs.toolbar_link_edit_m),
|
||||
tooltipText: LocaleKeys.editor_editLink.tr(),
|
||||
width: 36,
|
||||
height: 32,
|
||||
onPressed: onEditLink,
|
||||
),
|
||||
FlowyIconButton(
|
||||
icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m),
|
||||
tooltipText: LocaleKeys.editor_removeLink.tr(),
|
||||
width: 36,
|
||||
height: 32,
|
||||
onPressed: onRemoveLink,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
child: GestureDetector(
|
||||
onTap: onOpenLink,
|
||||
child: Container(
|
||||
width: triggerSize.width,
|
||||
height: triggerSize.height,
|
||||
color: Colors.black.withAlpha(1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HoverTriggerKey {
|
||||
HoverTriggerKey(this.nodeId, this.selection);
|
||||
|
||||
final String nodeId;
|
||||
final Selection selection;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is HoverTriggerKey &&
|
||||
runtimeType == other.runtimeType &&
|
||||
nodeId == other.nodeId &&
|
||||
selection == other.selection;
|
||||
|
||||
@override
|
||||
int get hashCode => nodeId.hashCode ^ selection.hashCode;
|
||||
}
|
||||
|
||||
class LinkHoverTriggers {
|
||||
final Map<HoverTriggerKey, Set<VoidCallback>> _map = {};
|
||||
|
||||
void _add(HoverTriggerKey key, VoidCallback callback) {
|
||||
final callbacks = _map[key] ?? {};
|
||||
callbacks.add(callback);
|
||||
_map[key] = callbacks;
|
||||
}
|
||||
|
||||
void _remove(HoverTriggerKey key, VoidCallback callback) {
|
||||
final callbacks = _map[key] ?? {};
|
||||
callbacks.remove(callback);
|
||||
_map[key] = callbacks;
|
||||
}
|
||||
|
||||
void call(HoverTriggerKey key) {
|
||||
final callbacks = _map[key] ?? {};
|
||||
for (final callback in callbacks) {
|
||||
callback.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/list_extension.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/recent/cached_recent_service.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.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 'link_create_menu.dart';
|
||||
import 'link_styles.dart';
|
||||
|
||||
class LinkSearchTextField {
|
||||
LinkSearchTextField({
|
||||
this.onEscape,
|
||||
this.onEnter,
|
||||
this.onDataRefresh,
|
||||
String? initialSearchText,
|
||||
}) : textEditingController = TextEditingController(
|
||||
text: initialSearchText ?? '',
|
||||
);
|
||||
|
||||
final TextEditingController textEditingController;
|
||||
final ItemScrollController searchController = ItemScrollController();
|
||||
late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent);
|
||||
final List<ViewPB> searchedViews = [];
|
||||
final List<ViewPB> recentViews = [];
|
||||
int selectedIndex = 0;
|
||||
|
||||
final VoidCallback? onEscape;
|
||||
final VoidCallback? onEnter;
|
||||
final VoidCallback? onDataRefresh;
|
||||
|
||||
String get searchText => textEditingController.text;
|
||||
|
||||
bool get isButtonEnable => searchText.isNotEmpty;
|
||||
|
||||
bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty;
|
||||
|
||||
ViewPB get currentSearchedView => searchedViews[selectedIndex];
|
||||
|
||||
ViewPB get currentRecentView => recentViews[selectedIndex];
|
||||
|
||||
void dispose() {
|
||||
textEditingController.dispose();
|
||||
focusNode.dispose();
|
||||
searchedViews.clear();
|
||||
recentViews.clear();
|
||||
}
|
||||
|
||||
Widget buildTextField({bool autofocus = false}) {
|
||||
return TextFormField(
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
autofocus: autofocus,
|
||||
focusNode: focusNode,
|
||||
textAlign: TextAlign.left,
|
||||
controller: textEditingController,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 20 / 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
onChanged: (text) {
|
||||
if (text.isEmpty) {
|
||||
searchedViews.clear();
|
||||
selectedIndex = 0;
|
||||
onDataRefresh?.call();
|
||||
} else {
|
||||
searchViews(text);
|
||||
}
|
||||
},
|
||||
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
|
||||
LocaleKeys.document_toolbar_linkInputHint.tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildResultContainer({
|
||||
EdgeInsetsGeometry? margin,
|
||||
required BuildContext context,
|
||||
VoidCallback? onLinkSelected,
|
||||
ValueChanged<ViewPB>? onPageLinkSelected,
|
||||
double width = 320.0,
|
||||
}) {
|
||||
return onSearchResult<Widget>(
|
||||
onEmpty: () => SizedBox.shrink(),
|
||||
onLink: () => Container(
|
||||
height: 48,
|
||||
width: width,
|
||||
padding: EdgeInsets.all(8),
|
||||
margin: margin,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
child: FlowyButton(
|
||||
leftIcon: FlowySvg(FlowySvgs.toolbar_link_earth_m),
|
||||
isSelected: true,
|
||||
text: FlowyText.regular(
|
||||
searchText,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
onTap: onLinkSelected,
|
||||
),
|
||||
),
|
||||
onRecentViews: () => Container(
|
||||
width: width,
|
||||
height: recentViews.length.clamp(1, 5) * 32.0 + 48,
|
||||
margin: margin,
|
||||
padding: EdgeInsets.all(8),
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 32,
|
||||
padding: EdgeInsets.all(8),
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.inlineActions_recentPages.tr(),
|
||||
color: LinkStyle.textTertiary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
final currentView = recentViews[index];
|
||||
return buildPageItem(
|
||||
currentView,
|
||||
index == selectedIndex,
|
||||
onPageLinkSelected,
|
||||
);
|
||||
},
|
||||
itemCount: recentViews.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onSearchViews: () => Container(
|
||||
width: width,
|
||||
height: searchedViews.length.clamp(1, 5) * 32.0 + 16,
|
||||
margin: margin,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
child: ScrollablePositionedList.builder(
|
||||
padding: EdgeInsets.all(8),
|
||||
physics: const ClampingScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: searchedViews.length,
|
||||
itemScrollController: searchController,
|
||||
initialScrollIndex: max(0, selectedIndex),
|
||||
itemBuilder: (context, index) {
|
||||
final currentView = searchedViews[index];
|
||||
return buildPageItem(
|
||||
currentView,
|
||||
index == selectedIndex,
|
||||
onPageLinkSelected,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPageItem(
|
||||
ViewPB view,
|
||||
bool isSelected,
|
||||
ValueChanged<ViewPB>? onSubmittedPageLink,
|
||||
) {
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: FlowyButton(
|
||||
isSelected: isSelected,
|
||||
leftIcon: buildIcon(view),
|
||||
text: FlowyText.regular(
|
||||
view.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
onTap: () => onSubmittedPageLink?.call(view),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildIcon(ViewPB view) {
|
||||
if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20));
|
||||
final iconData = view.icon.toEmojiIconData();
|
||||
return RawEmojiIconWidget(
|
||||
emoji: iconData,
|
||||
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
|
||||
lineHeight: 1,
|
||||
);
|
||||
}
|
||||
|
||||
void requestFocus() => focusNode.requestFocus();
|
||||
|
||||
void unfocus() => focusNode.unfocus();
|
||||
|
||||
void updateText(String text) => textEditingController.text = text;
|
||||
|
||||
T onSearchResult<T>({
|
||||
required ValueGetter<T> onLink,
|
||||
required ValueGetter<T> onRecentViews,
|
||||
required ValueGetter<T> onSearchViews,
|
||||
required ValueGetter<T> onEmpty,
|
||||
}) {
|
||||
if (searchedViews.isEmpty && recentViews.isEmpty && searchText.isEmpty) {
|
||||
return onEmpty.call();
|
||||
}
|
||||
if (searchedViews.isEmpty && searchText.isNotEmpty) {
|
||||
return onLink.call();
|
||||
}
|
||||
if (searchedViews.isEmpty) return onRecentViews.call();
|
||||
return onSearchViews.call();
|
||||
}
|
||||
|
||||
KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) {
|
||||
if (key is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
int index = selectedIndex;
|
||||
if (key.logicalKey == LogicalKeyboardKey.escape) {
|
||||
onEscape?.call();
|
||||
return KeyEventResult.handled;
|
||||
} else if (key.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
index = onSearchResult(
|
||||
onLink: () => 0,
|
||||
onRecentViews: () {
|
||||
int result = index - 1;
|
||||
if (result < 0) result = recentViews.length - 1;
|
||||
return result;
|
||||
},
|
||||
onSearchViews: () {
|
||||
int result = index - 1;
|
||||
if (result < 0) result = searchedViews.length - 1;
|
||||
searchController.scrollTo(
|
||||
index: result,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
onEmpty: () => 0,
|
||||
);
|
||||
refreshIndex(index);
|
||||
return KeyEventResult.handled;
|
||||
} else if (key.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
index = onSearchResult(
|
||||
onLink: () => 0,
|
||||
onRecentViews: () {
|
||||
int result = index + 1;
|
||||
if (result >= recentViews.length) result = 0;
|
||||
return result;
|
||||
},
|
||||
onSearchViews: () {
|
||||
int result = index + 1;
|
||||
if (result >= searchedViews.length) result = 0;
|
||||
searchController.scrollTo(
|
||||
index: result,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
onEmpty: () => 0,
|
||||
);
|
||||
refreshIndex(index);
|
||||
return KeyEventResult.handled;
|
||||
} else if (key.logicalKey == LogicalKeyboardKey.enter) {
|
||||
onEnter?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
Future<void> searchRecentViews() async {
|
||||
final recentService = getIt<CachedRecentService>();
|
||||
final sectionViews = await recentService.recentViews();
|
||||
final views = sectionViews
|
||||
.unique((e) => e.item.id)
|
||||
.map((e) => e.item)
|
||||
.take(5)
|
||||
.toList();
|
||||
recentViews.clear();
|
||||
recentViews.addAll(views);
|
||||
selectedIndex = 0;
|
||||
onDataRefresh?.call();
|
||||
}
|
||||
|
||||
Future<void> searchViews(String search) async {
|
||||
final viewResult = await ViewBackendService.getAllViews();
|
||||
final allViews = viewResult
|
||||
.toNullable()
|
||||
?.items
|
||||
.where(
|
||||
(view) =>
|
||||
view.name.toLowerCase().contains(search.toLowerCase()) ||
|
||||
(view.name.isEmpty && search.isEmpty) ||
|
||||
(view.name.isEmpty &&
|
||||
LocaleKeys.menuAppHeader_defaultNewPageName
|
||||
.tr()
|
||||
.toLowerCase()
|
||||
.contains(search.toLowerCase())),
|
||||
)
|
||||
.take(10)
|
||||
.toList();
|
||||
searchedViews.clear();
|
||||
searchedViews.addAll(allViews ?? []);
|
||||
selectedIndex = 0;
|
||||
onDataRefresh?.call();
|
||||
}
|
||||
|
||||
void refreshIndex(int index) {
|
||||
selectedIndex = index;
|
||||
onDataRefresh?.call();
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LinkStyle {
|
||||
static const borderColor = Color(0xFFE8ECF3);
|
||||
static const textTertiary = Color(0xFF99A1A8);
|
||||
static const fillThemeThick = Color(0xFF00B5FF);
|
||||
static const shadowMedium = Color(0x1F22251F);
|
||||
static const textPrimary = Color(0xFF1F2329);
|
||||
|
||||
static InputDecoration buildLinkTextFieldInputDecoration(String hintText) {
|
||||
const border = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
||||
borderSide: BorderSide(color: LinkStyle.borderColor),
|
||||
);
|
||||
final enableBorder = border.copyWith(
|
||||
borderSide: BorderSide(color: LinkStyle.fillThemeThick),
|
||||
);
|
||||
const hintStyle = TextStyle(
|
||||
fontSize: 14,
|
||||
height: 20 / 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: LinkStyle.textTertiary,
|
||||
);
|
||||
return InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: hintStyle,
|
||||
contentPadding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
isDense: true,
|
||||
border: border,
|
||||
enabledBorder: border,
|
||||
focusedBorder: enableBorder,
|
||||
);
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ToolbarAnimationWidget extends StatefulWidget {
|
||||
const ToolbarAnimationWidget({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.duration = const Duration(milliseconds: 150),
|
||||
this.beginOpacity = 0.0,
|
||||
this.endOpacity = 1.0,
|
||||
this.beginScaleFactor = 0.95,
|
||||
this.endScaleFactor = 1.0,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Duration duration;
|
||||
final double beginScaleFactor;
|
||||
final double endScaleFactor;
|
||||
final double beginOpacity;
|
||||
final double endOpacity;
|
||||
|
||||
@override
|
||||
State<ToolbarAnimationWidget> createState() => _ToolbarAnimationWidgetState();
|
||||
}
|
||||
|
||||
class _ToolbarAnimationWidgetState extends State<ToolbarAnimationWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController controller;
|
||||
late Animation<double> fadeAnimation;
|
||||
late Animation<double> scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.duration,
|
||||
);
|
||||
fadeAnimation = _buildFadeAnimation();
|
||||
scaleAnimation = _buildScaleAnimation();
|
||||
controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (_, child) => Opacity(
|
||||
opacity: fadeAnimation.value,
|
||||
child: Transform.scale(
|
||||
scale: scaleAnimation.value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
Animation<double> _buildFadeAnimation() {
|
||||
return Tween<double>(
|
||||
begin: widget.beginOpacity,
|
||||
end: widget.endOpacity,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: controller,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Animation<double> _buildScaleAnimation() {
|
||||
return Tween<double>(
|
||||
begin: widget.beginScaleFactor,
|
||||
end: widget.endScaleFactor,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: controller,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
|
||||
class ToolbarCubit extends Cubit<ToolbarState> {
|
||||
ToolbarCubit(this.onDismissCallback) : super(ToolbarState._());
|
||||
|
||||
final VoidCallback onDismissCallback;
|
||||
|
||||
void dismiss() {
|
||||
onDismissCallback.call();
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarState {
|
||||
const ToolbarState._();
|
||||
}
|
||||
+6
@@ -30,6 +30,10 @@ class ErrorBlockComponentBuilder extends BlockComponentBuilder {
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
actionTrailingBuilder: (context, state) => actionTrailingBuilder(
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +47,7 @@ class ErrorBlockComponentWidget extends BlockComponentStatefulWidget {
|
||||
required super.node,
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.actionTrailingBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
@@ -81,6 +86,7 @@ class _ErrorBlockComponentWidgetState extends State<ErrorBlockComponentWidget>
|
||||
child = BlockComponentActionWrapper(
|
||||
node: node,
|
||||
actionBuilder: widget.actionBuilder!,
|
||||
actionTrailingBuilder: widget.actionTrailingBuilder,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
+14
@@ -10,6 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
@@ -95,6 +96,17 @@ enum FileUrlType {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
FileUploadTypePB toFileUploadTypePB() {
|
||||
switch (this) {
|
||||
case FileUrlType.local:
|
||||
return FileUploadTypePB.LocalFile;
|
||||
case FileUrlType.network:
|
||||
return FileUploadTypePB.NetworkFile;
|
||||
case FileUrlType.cloud:
|
||||
return FileUploadTypePB.CloudFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Node fileNode({
|
||||
@@ -141,6 +153,7 @@ class FileBlockComponent extends BlockComponentStatefulWidget {
|
||||
required super.node,
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.actionTrailingBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
@@ -303,6 +316,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
child = BlockComponentActionWrapper(
|
||||
node: node,
|
||||
actionBuilder: widget.actionBuilder!,
|
||||
actionTrailingBuilder: widget.actionTrailingBuilder,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
+31
-3
@@ -1,15 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
|
||||
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.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_bloc/flutter_bloc.dart';
|
||||
|
||||
class FileBlockMenu extends StatefulWidget {
|
||||
@@ -59,11 +61,37 @@ class _FileBlockMenuState extends State<FileBlockMenu> {
|
||||
final dateFormat = context.read<AppearanceSettingsCubit>().state.dateFormat;
|
||||
final urlType =
|
||||
FileUrlType.fromIntValue(widget.node.attributes[FileBlockKeys.urlType]);
|
||||
|
||||
final fileUploadType = urlType.toFileUploadTypePB();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
HoverButton(
|
||||
itemHeight: 20,
|
||||
leftIcon: const FlowySvg(FlowySvgs.download_s),
|
||||
name: LocaleKeys.button_download.tr(),
|
||||
onTap: () {
|
||||
final userProfile = widget.editorState.document.root.context
|
||||
?.read<DocumentBloc>()
|
||||
.state
|
||||
.userProfilePB;
|
||||
final url = widget.node.attributes[FileBlockKeys.url];
|
||||
final name = widget.node.attributes[FileBlockKeys.name];
|
||||
if (url != null && name != null) {
|
||||
final filePB = MediaFilePB(
|
||||
url: url,
|
||||
name: name,
|
||||
uploadType: fileUploadType,
|
||||
);
|
||||
downloadMediaFile(
|
||||
context,
|
||||
filePB,
|
||||
userProfile: userProfile,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const VSpace(4),
|
||||
HoverButton(
|
||||
itemHeight: 20,
|
||||
leftIcon: const FlowySvg(FlowySvgs.edit_s),
|
||||
|
||||
+52
-93
@@ -9,8 +9,6 @@ import 'package:appflowy/workspace/application/settings/appearance/appearance_cu
|
||||
import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/shared/setting_value_dropdown.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -21,68 +19,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
const kFontToolbarItemId = 'editor.font';
|
||||
|
||||
@visibleForTesting
|
||||
const kFontFamilyToolbarItemKey = ValueKey('FontFamilyToolbarItem');
|
||||
|
||||
final customizeFontToolbarItem = ToolbarItem(
|
||||
id: kFontToolbarItemId,
|
||||
group: 4,
|
||||
isActive: onlyShowInTextType,
|
||||
builder: (context, editorState, highlightColor, _, tooltipBuilder) {
|
||||
final selection = editorState.selection!;
|
||||
final popoverController = PopoverController();
|
||||
final String? currentFontFamily = editorState
|
||||
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.fontFamily);
|
||||
|
||||
Widget child = FontFamilyDropDown(
|
||||
currentFontFamily: currentFontFamily ?? '',
|
||||
offset: const Offset(0, 12),
|
||||
popoverController: popoverController,
|
||||
onOpen: () => keepEditorFocusNotifier.increase(),
|
||||
onClose: () => keepEditorFocusNotifier.decrease(),
|
||||
onFontFamilyChanged: (fontFamily) async {
|
||||
popoverController.close();
|
||||
try {
|
||||
await editorState.formatDelta(selection, {
|
||||
AppFlowyRichTextKeys.fontFamily: fontFamily,
|
||||
});
|
||||
} catch (e) {
|
||||
Log.error('Failed to set font family: $e');
|
||||
}
|
||||
},
|
||||
onResetFont: () async {
|
||||
popoverController.close();
|
||||
await editorState
|
||||
.formatDelta(selection, {AppFlowyRichTextKeys.fontFamily: null});
|
||||
},
|
||||
child: FlowyButton(
|
||||
key: kFontFamilyToolbarItemKey,
|
||||
useIntrinsicWidth: true,
|
||||
hoverColor: Colors.grey.withValues(alpha: 0.3),
|
||||
onTap: () => popoverController.show(),
|
||||
text: const FlowySvg(
|
||||
FlowySvgs.font_family_s,
|
||||
size: Size.square(16.0),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (tooltipBuilder != null) {
|
||||
child = tooltipBuilder(
|
||||
context,
|
||||
kFontToolbarItemId,
|
||||
LocaleKeys.document_plugins_fonts.tr(),
|
||||
child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
},
|
||||
);
|
||||
|
||||
class ThemeFontFamilySetting extends StatefulWidget {
|
||||
const ThemeFontFamilySetting({
|
||||
super.key,
|
||||
@@ -163,6 +99,11 @@ class _FontFamilyDropDownState extends State<FontFamilyDropDown> {
|
||||
popoverKey: ThemeFontFamilySetting.popoverKey,
|
||||
popoverController: widget.popoverController,
|
||||
currentValue: currentValue,
|
||||
margin: EdgeInsets.zero,
|
||||
boxConstraints: const BoxConstraints(
|
||||
maxWidth: 240,
|
||||
maxHeight: 420,
|
||||
),
|
||||
onClose: () {
|
||||
query.value = '';
|
||||
widget.onClose?.call();
|
||||
@@ -171,27 +112,25 @@ class _FontFamilyDropDownState extends State<FontFamilyDropDown> {
|
||||
child: widget.child,
|
||||
popupBuilder: (_) {
|
||||
widget.onOpen?.call();
|
||||
return CustomScrollView(
|
||||
shrinkWrap: true,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: FlowyTextField(
|
||||
key: ThemeFontFamilySetting.textFieldKey,
|
||||
hintText:
|
||||
LocaleKeys.settings_appearance_fontFamily_search.tr(),
|
||||
autoFocus: false,
|
||||
debounceDuration: const Duration(milliseconds: 300),
|
||||
onChanged: (value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FlowyTextField(
|
||||
key: ThemeFontFamilySetting.textFieldKey,
|
||||
hintText: LocaleKeys.settings_appearance_fontFamily_search.tr(),
|
||||
autoFocus: false,
|
||||
debounceDuration: const Duration(milliseconds: 300),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
query.value = value;
|
||||
},
|
||||
),
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 4),
|
||||
),
|
||||
Container(height: 1, color: Theme.of(context).dividerColor),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: query,
|
||||
builder: (context, value, child) {
|
||||
@@ -206,14 +145,32 @@ class _FontFamilyDropDownState extends State<FontFamilyDropDown> {
|
||||
.sorted((a, b) => levenshtein(a, b))
|
||||
.toList();
|
||||
}
|
||||
return SliverFixedExtentList.builder(
|
||||
itemBuilder: (context, index) => _fontFamilyItemButton(
|
||||
context,
|
||||
getGoogleFontSafely(displayed[index]),
|
||||
),
|
||||
itemCount: displayed.length,
|
||||
itemExtent: 32,
|
||||
);
|
||||
return displayed.length >= 10
|
||||
? Flexible(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
itemBuilder: (context, index) =>
|
||||
_fontFamilyItemButton(
|
||||
context,
|
||||
getGoogleFontSafely(displayed[index]),
|
||||
),
|
||||
itemCount: displayed.length,
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(
|
||||
displayed.length,
|
||||
(index) => _fontFamilyItemButton(
|
||||
context,
|
||||
getGoogleFontSafely(displayed[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -233,16 +190,18 @@ class _FontFamilyDropDownState extends State<FontFamilyDropDown> {
|
||||
waitDuration: const Duration(milliseconds: 150),
|
||||
child: SizedBox(
|
||||
key: ValueKey(buttonFontFamily),
|
||||
height: 32,
|
||||
height: 36,
|
||||
child: FlowyButton(
|
||||
onHover: (_) => FocusScope.of(context).unfocus(),
|
||||
text: FlowyText.medium(
|
||||
text: FlowyText(
|
||||
buttonFontFamily.fontFamilyDisplayName,
|
||||
fontFamily: buttonFontFamily,
|
||||
figmaLineHeight: 20,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
rightIcon:
|
||||
buttonFontFamily == widget.currentFontFamily.parseFontFamilyName()
|
||||
? const FlowySvg(FlowySvgs.check_s)
|
||||
? const FlowySvg(FlowySvgs.toolbar_check_m)
|
||||
: null,
|
||||
onTap: () {
|
||||
if (widget.onFontFamilyChanged != null) {
|
||||
|
||||
+46
-30
@@ -188,49 +188,65 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
|
||||
onChangeCover: (type, details) =>
|
||||
_saveIconOrCover(cover: (type, details)),
|
||||
),
|
||||
_buildCoverIcon(
|
||||
context,
|
||||
constraints,
|
||||
offset,
|
||||
),
|
||||
_buildAlignedCoverIcon(context),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(offset, 0, offset, 12),
|
||||
child: MouseRegion(
|
||||
onEnter: (event) => isCoverTitleHovered.value = true,
|
||||
onExit: (event) => isCoverTitleHovered.value = false,
|
||||
child: CoverTitle(
|
||||
view: widget.view,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildAlignedTitle(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoverIcon(
|
||||
BuildContext context,
|
||||
BoxConstraints constraints,
|
||||
double offset,
|
||||
) {
|
||||
if (!hasIcon || offset == 0) {
|
||||
Widget _buildAlignedTitle(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.editorState.editorStyle.maxWidth ?? double.infinity,
|
||||
),
|
||||
padding: widget.editorState.editorStyle.padding +
|
||||
const EdgeInsets.symmetric(horizontal: 44),
|
||||
child: MouseRegion(
|
||||
onEnter: (event) => isCoverTitleHovered.value = true,
|
||||
onExit: (event) => isCoverTitleHovered.value = false,
|
||||
child: CoverTitle(
|
||||
view: widget.view,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlignedCoverIcon(BuildContext context) {
|
||||
if (!hasIcon) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
// if hasCover, there shouldn't be icons present so the icon can
|
||||
// be closer to the bottom.
|
||||
left: offset,
|
||||
bottom: hasCover ? kToolbarHeight - kIconHeight / 2 : kToolbarHeight,
|
||||
child: DocumentIcon(
|
||||
editorState: widget.editorState,
|
||||
node: widget.node,
|
||||
icon: viewIcon,
|
||||
documentId: view.id,
|
||||
onChangeIcon: (icon) => _saveIconOrCover(icon: icon),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth:
|
||||
widget.editorState.editorStyle.maxWidth ?? double.infinity,
|
||||
),
|
||||
padding: widget.editorState.editorStyle.padding +
|
||||
const EdgeInsets.symmetric(horizontal: 44),
|
||||
child: Row(
|
||||
children: [
|
||||
DocumentIcon(
|
||||
editorState: widget.editorState,
|
||||
node: widget.node,
|
||||
icon: viewIcon,
|
||||
documentId: view.id,
|
||||
onChangeIcon: (icon) => _saveIconOrCover(icon: icon),
|
||||
),
|
||||
Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+7
-7
@@ -71,11 +71,13 @@ class RawEmojiIconWidget extends StatefulWidget {
|
||||
required this.emoji,
|
||||
required this.emojiSize,
|
||||
this.enableColor = true,
|
||||
this.lineHeight,
|
||||
});
|
||||
|
||||
final EmojiIconData emoji;
|
||||
final double emojiSize;
|
||||
final bool enableColor;
|
||||
final double? lineHeight;
|
||||
|
||||
@override
|
||||
State<RawEmojiIconWidget> createState() => _RawEmojiIconWidgetState();
|
||||
@@ -111,13 +113,11 @@ class _RawEmojiIconWidgetState extends State<RawEmojiIconWidget> {
|
||||
try {
|
||||
switch (widget.emoji.type) {
|
||||
case FlowyIconType.emoji:
|
||||
return SizedBox(
|
||||
width: widget.emojiSize,
|
||||
child: EmojiText(
|
||||
emoji: widget.emoji.emoji,
|
||||
fontSize: widget.emojiSize,
|
||||
textAlign: TextAlign.justify,
|
||||
),
|
||||
return EmojiText(
|
||||
emoji: widget.emoji.emoji,
|
||||
fontSize: widget.emojiSize,
|
||||
textAlign: TextAlign.justify,
|
||||
lineHeight: widget.lineHeight,
|
||||
);
|
||||
case FlowyIconType.icon:
|
||||
IconsData iconData =
|
||||
|
||||
Loaded 100 of 340 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user