Compare commits

..
Author SHA1 Message Date
Nathan 58e04291c7 chore: run local ai on windows 2025-03-11 21:30:07 +08:00
299 changed files with 4134 additions and 15366 deletions

No files matched your search

-14
View File
@@ -1,18 +1,4 @@
# 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
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.8.8"
APPFLOWY_VERSION = "0.8.6"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
+26
View File
@@ -0,0 +1,26 @@
-----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-----
@@ -27,9 +27,8 @@ void main() {
await tester.pumpAndSettle();
// click the align center
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m);
await tester
.tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_center_m);
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s);
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s);
// expect to see the align center
final editorState = tester.editor.getCurrentEditorState();
@@ -37,15 +36,13 @@ void main() {
expect(first.attributes[blockComponentAlign], 'center');
// click the align right
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m);
await tester
.tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_right_m);
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s);
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_right_s);
expect(first.attributes[blockComponentAlign], 'right');
// click the align left
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m);
await tester
.tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_left_m);
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_right_s);
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s);
expect(first.attributes[blockComponentAlign], 'left');
});
@@ -76,12 +76,13 @@ 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.editor_bulletedListShortForm.tr():
LocaleKeys.document_slashMenu_name_bulletedList.tr():
BulletedListBlockKeys.type,
LocaleKeys.editor_numberedListShortForm.tr():
LocaleKeys.document_slashMenu_name_numberedList.tr():
NumberedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type,
LocaleKeys.editor_checkbox.tr(): TodoListBlockKeys.type,
LocaleKeys.document_slashMenu_name_todoList.tr():
TodoListBlockKeys.type,
LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type,
LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type,
};
@@ -116,12 +117,13 @@ 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.editor_bulletedListShortForm.tr():
LocaleKeys.document_slashMenu_name_bulletedList.tr():
BulletedListBlockKeys.type,
LocaleKeys.editor_numberedListShortForm.tr():
LocaleKeys.document_slashMenu_name_numberedList.tr():
NumberedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type,
LocaleKeys.editor_checkbox.tr(): TodoListBlockKeys.type,
LocaleKeys.document_slashMenu_name_todoList.tr():
TodoListBlockKeys.type,
LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type,
LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type,
};
@@ -1,19 +1,5 @@
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';
@@ -22,33 +8,24 @@ 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 prepareForToolbar(tester, 'font family');
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,
),
);
// 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);
@@ -69,262 +46,5 @@ 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);
});
});
}
@@ -1,4 +1,3 @@
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';
@@ -34,15 +33,9 @@ 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.text(
LocaleKeys.document_toolbar_equation.tr(),
final inlineMathEquationButton = find.findFlowyTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
);
await tester.tapButton(inlineMathEquationButton);
@@ -85,15 +78,10 @@ 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.byFlowySvg(FlowySvgs.type_formula_m);
var inlineMathEquationButton = find.findFlowyTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
);
await tester.tapButton(inlineMathEquationButton);
// expect to see the math equation block
@@ -105,7 +93,17 @@ void main() {
Selection.single(path: [0], startOffset: 0, endOffset: 1),
);
await tester.tapButton(moreOptionButton);
// 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,
);
// cancel the format
await tester.tapButton(inlineMathEquationButton);
@@ -136,15 +134,10 @@ 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.byFlowySvg(FlowySvgs.type_formula_m);
final inlineMathEquationButton = find.findFlowyTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
);
await tester.tapButton(inlineMathEquationButton);
// expect to see the math equation block
@@ -1,4 +1,3 @@
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';
@@ -86,10 +85,16 @@ void main() {
),
);
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_text_format_m));
await tester.tapButton(find.byType(HeadingPopup));
await tester.pumpAndSettle();
expect(
find.byType(HeadingButton),
findsNWidgets(3),
);
// tap the H1 button
await tester.tapButton(find.byFlowySvg(FlowySvgs.type_h1_m).at(0));
await tester.tapButton(find.byType(HeadingButton).at(0));
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
@@ -1,10 +1,8 @@
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';
@@ -41,114 +39,4 @@ 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);
});
});
}
@@ -13,6 +13,7 @@ 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,9 +1,17 @@
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';
@@ -19,11 +27,10 @@ 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';
@@ -37,7 +44,6 @@ 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';
@@ -70,8 +76,6 @@ 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';
@@ -86,9 +90,6 @@ 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
@@ -1571,7 +1572,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
of: textField,
matching: find.byWidgetPredicate(
(widget) =>
widget is FlowySvg && widget.svg == FlowySvgs.close_filled_s,
widget is FlowySvg && widget.svg == FlowySvgs.close_filled_m,
),
),
);
+23 -23
View File
@@ -181,37 +181,37 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
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
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
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
-3
View File
@@ -2,8 +2,6 @@ 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';
@@ -15,5 +13,4 @@ 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,28 +4,6 @@ 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,
@@ -1,181 +0,0 @@
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,8 +1,12 @@
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';
@@ -12,20 +16,19 @@ part 'ai_prompt_input_bloc.freezed.dart';
class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
AIPromptInputBloc({
required String objectId,
required PredefinedFormat? predefinedFormat,
}) : aiModelStateNotifier = AIModelStateNotifier(objectId: objectId),
}) : _listener = LocalLLMListener(),
super(AIPromptInputState.initial(predefinedFormat)) {
_dispatch();
_startListening();
_init();
}
final AIModelStateNotifier aiModelStateNotifier;
final LocalLLMListener _listener;
@override
Future<void> close() async {
await aiModelStateNotifier.dispose();
await _listener.stop();
return super.close();
}
@@ -33,19 +36,35 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
on<AIPromptInputEvent>(
(event, emit) {
event.when(
updateAIState: (aiType, editable, hintText) {
updateAIState: (LocalAIPB localAIState) {
if (localAIState.hasLackOfResource()) {
emit(
state.copyWith(
aiType: AIType.appflowyAI,
supportChatWithFile: false,
localAIState: localAIState,
),
);
return;
}
// Only user enable chat with file and the plugin is already running
final supportChatWithFile = localAIState.enabled &&
localAIState.state == RunningStatePB.Running;
final aiType =
localAIState.enabled ? AIType.localAI : AIType.appflowyAI;
emit(
state.copyWith(
aiType: aiType,
editable: editable,
hintText: hintText,
supportChatWithFile: supportChatWithFile,
localAIState: localAIState,
),
);
},
toggleShowPredefinedFormat: () {
final showPredefinedFormats = !state.showPredefinedFormats;
final predefinedFormat =
showPredefinedFormats && state.predefinedFormat == null
!state.showPredefinedFormats && state.predefinedFormat == null
? PredefinedFormat(
imageFormat: ImageFormat.text,
textFormat: TextFormat.paragraph,
@@ -53,15 +72,12 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
: null;
emit(
state.copyWith(
showPredefinedFormats: showPredefinedFormats,
showPredefinedFormats: !state.showPredefinedFormats,
predefinedFormat: predefinedFormat,
),
);
},
updatePredefinedFormat: (format) {
if (!state.showPredefinedFormats) {
return;
}
emit(state.copyWith(predefinedFormat: format));
},
attachFile: (filePath, fileName) {
@@ -104,16 +120,24 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
}
void _startListening() {
aiModelStateNotifier.addListener(
onStateChanged: (aiType, editable, hintText) {
add(AIPromptInputEvent.updateAIState(aiType, editable, hintText));
_listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(AIPromptInputEvent.updateAIState(pluginState));
}
},
);
}
void _init() {
final (aiType, hintText, isEditable) = aiModelStateNotifier.getState();
add(AIPromptInputEvent.updateAIState(aiType, isEditable, hintText));
AIEventGetLocalAIState().send().fold(
(localAIState) {
if (!isClosed) {
add(AIPromptInputEvent.updateAIState(localAIState));
}
},
Log.error,
);
}
Map<String, dynamic> consumeMetadata() {
@@ -132,12 +156,8 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
@freezed
class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.updateAIState(
AiType aiType,
bool editable,
String hintText,
) = _UpdateAIState;
const factory AIPromptInputEvent.updateAIState(LocalAIPB localAIState) =
_UpdateAIState;
const factory AIPromptInputEvent.toggleShowPredefinedFormat() =
_ToggleShowPredefinedFormat;
const factory AIPromptInputEvent.updatePredefinedFormat(
@@ -156,25 +176,30 @@ 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 LocalAIPB? localAIState,
required List<ChatFile> attachedFiles,
required List<ViewPB> mentionedPages,
required bool editable,
required String hintText,
}) = _AIPromptInputState;
factory AIPromptInputState.initial(PredefinedFormat? format) =>
AIPromptInputState(
aiType: AiType.cloud,
aiType: AIType.appflowyAI,
supportChatWithFile: false,
showPredefinedFormats: format != null,
predefinedFormat: format,
localAIState: null,
attachedFiles: [],
mentionedPages: [],
editable: true,
hintText: '',
);
}
enum AIType {
appflowyAI,
localAI;
bool get isLocalAI => this == localAI;
}
@@ -3,7 +3,6 @@ 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';
@@ -15,26 +14,16 @@ 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) processMessage,
required Future<void> Function(String text) processAssistMessage,
required Future<void> Function(String text) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
required void Function(LocalAIStreamingState state)
onLocalAIStreamingStateChange,
});
}
@@ -45,27 +34,19 @@ 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) processMessage,
required Future<void> Function(String text) processAssistMessage,
required Future<void> Function(String text) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
required void Function(LocalAIStreamingState state)
onLocalAIStreamingStateChange,
}) async {
final stream = AppFlowyCompletionStream(
onStart: onStart,
processMessage: processMessage,
processAssistMessage: processAssistMessage,
processError: onError,
onLocalAIStreamingStateChange: onLocalAIStreamingStateChange,
onProcess: onProcess,
onEnd: onEnd,
onError: onError,
);
final records = history.map((record) => record.toPB()).toList();
final payload = CompleteTextPB(
text: text,
completionType: completionType,
@@ -76,7 +57,6 @@ class AppFlowyAIService implements AIRepository {
if (objectId != null) objectId,
...sourceIds,
].unique(),
history: records,
);
return AIEventCompleteText(payload).send().fold(
@@ -92,30 +72,23 @@ class AppFlowyAIService implements AIRepository {
abstract class CompletionStream {
CompletionStream({
required this.onStart,
required this.processMessage,
required this.processAssistMessage,
required this.processError,
required this.onLocalAIStreamingStateChange,
required this.onProcess,
required this.onEnd,
required this.onError,
});
final Future<void> Function() onStart;
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(String text) onProcess;
final Future<void> Function() onEnd;
final void Function(AIError error) onError;
}
class AppFlowyCompletionStream extends CompletionStream {
AppFlowyCompletionStream({
required super.onStart,
required super.processMessage,
required super.processAssistMessage,
required super.processError,
required super.onProcess,
required super.onEnd,
required super.onLocalAIStreamingStateChange,
required super.onError,
}) {
_startListening();
}
@@ -129,7 +102,51 @@ class AppFlowyCompletionStream extends CompletionStream {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) async {
await _handleEvent(event);
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),
);
}
},
);
}
@@ -139,66 +156,4 @@ 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');
}
}
}
@@ -1,92 +0,0 @@
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,
);
}
}
@@ -17,24 +17,20 @@ 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();
@@ -46,6 +42,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
final overlayController = OverlayPortalController();
final inputControlCubit = ChatInputControlCubit();
final focusNode = FocusNode();
final textController = TextEditingController();
late SendButtonState sendButtonState;
bool isComposing = false;
@@ -54,19 +51,18 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
void initState() {
super.initState();
widget.textController.addListener(handleTextControllerChanged);
focusNode
..addListener(
() {
if (!widget.hideDecoration) {
setState(() {}); // refresh border color
}
if (!focusNode.hasFocus) {
cancelMentionPage(); // hide menu when lost focus
}
},
)
..onKeyEvent = handleKeyEvent;
textController.addListener(handleTextControllerChanged);
focusNode.addListener(
() {
if (!widget.hideDecoration) {
setState(() {}); // refresh border color
}
if (!focusNode.hasFocus) {
cancelMentionPage(); // hide menu when lost focus
}
},
);
updateSendButtonState();
@@ -84,7 +80,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
@override
void dispose() {
focusNode.dispose();
widget.textController.removeListener(handleTextControllerChanged);
textController.dispose();
inputControlCubit.close();
super.dispose();
}
@@ -109,7 +105,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
overlayChildBuilder: (context) {
return PromptInputMentionPageMenu(
anchor: PromptInputAnchor(textFieldKey, layerLink),
textController: widget.textController,
textController: textController,
onPageSelected: handlePageSelected,
);
},
@@ -152,13 +148,14 @@ 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,
),
),
),
),
@@ -183,8 +180,6 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
widget.selectedSourcesNotifier,
onUpdateSelectedSources:
widget.onUpdateSelectedSources,
extraBottomActionButton:
widget.extraBottomActionButton,
),
),
),
@@ -223,12 +218,12 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
if (!focusNode.hasFocus) {
focusNode.requestFocus();
}
widget.textController.text += '@';
textController.text += '@';
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context
.read<ChatInputControlCubit>()
.startSearching(widget.textController.value);
.startSearching(textController.value);
overlayController.show();
}
});
@@ -244,7 +239,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
void updateSendButtonState() {
if (widget.isStreaming) {
sendButtonState = SendButtonState.streaming;
} else if (widget.textController.text.trim().isEmpty) {
} else if (textController.text.trim().isEmpty) {
sendButtonState = SendButtonState.disabled;
} else {
sendButtonState = SendButtonState.enabled;
@@ -256,9 +251,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
return;
}
final trimmedText = inputControlCubit.formatIntputText(
widget.textController.text.trim(),
textController.text.trim(),
);
widget.textController.clear();
textController.clear();
if (trimmedText.isEmpty) {
return;
}
@@ -281,17 +276,17 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
setState(() {
// update whether send button is clickable
updateSendButtonState();
isComposing = !widget.textController.value.composing.isCollapsed;
isComposing = !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) {
@@ -299,7 +294,6 @@ 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 =
@@ -346,27 +340,22 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
}
KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
// 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;
if (event.character == '@') {
WidgetsBinding.instance.addPostFrameCallback((_) {
inputControlCubit.startSearching(textController.value);
overlayController.show();
});
}
return KeyEventResult.ignored;
}
void handlePageSelected(ViewPB view) {
final newText = widget.textController.text.replaceRange(
final newText = textController.text.replaceRange(
inputControlCubit.filterStartPosition,
inputControlCubit.filterEndPosition,
view.id,
);
widget.textController.value = TextEditingValue(
textController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: inputControlCubit.filterStartPosition + view.id.length,
@@ -387,27 +376,18 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
link: layerLink,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
Widget textField = PromptInputTextField(
return PromptInputTextField(
key: textFieldKey,
editable: state.editable,
cubit: inputControlCubit,
textController: widget.textController,
textController: textController,
textFieldFocusNode: focusNode,
contentPadding:
calculateContentPadding(state.showPredefinedFormats),
hintText: state.hintText,
hintText: switch (state.aiType) {
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
);
if (!state.editable) {
textField = FlowyTooltip(
message: LocaleKeys
.settings_aiPage_keys_localAINotReadyTextFieldPrompt
.tr(),
child: textField,
);
}
return textField;
},
),
),
@@ -512,7 +492,6 @@ class _FocusNextItemIntent extends Intent {
class PromptInputTextField extends StatelessWidget {
const PromptInputTextField({
super.key,
required this.editable,
required this.cubit,
required this.textController,
required this.textFieldFocusNode,
@@ -524,7 +503,6 @@ class PromptInputTextField extends StatelessWidget {
final TextEditingController textController;
final FocusNode textFieldFocusNode;
final EdgeInsetsGeometry contentPadding;
final bool editable;
final String hintText;
@override
@@ -532,8 +510,6 @@ class PromptInputTextField extends StatelessWidget {
return ExtendedTextField(
controller: textController,
focusNode: textFieldFocusNode,
readOnly: !editable,
enabled: editable,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
@@ -578,7 +554,6 @@ class _PromptBottomActions extends StatelessWidget {
required this.onStopStreaming,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
this.extraBottomActionButton,
});
final bool showPredefinedFormats;
@@ -589,7 +564,6 @@ 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) {
@@ -598,25 +572,18 @@ class _PromptBottomActions extends StatelessWidget {
margin: DesktopAIChatSizes.inputActionBarMargin,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
if (state.localAIState == 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.isCloud) ...[
_selectSourcesButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
if (extraBottomActionButton != null) ...[
extraBottomActionButton!,
if (state.aiType == AIType.appflowyAI) ...[
_selectSourcesButton(context),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
@@ -646,7 +613,7 @@ class _PromptBottomActions extends StatelessWidget {
);
}
Widget _selectSourcesButton() {
Widget _selectSourcesButton(BuildContext context) {
return PromptInputDesktopSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
selectedSourcesNotifier: selectedSourcesNotifier,
@@ -48,30 +48,25 @@ 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: [
if (showImageFormats) ...[
_buildFormatButton(context, ImageFormat.text),
_buildFormatButton(context, ImageFormat.textAndImage),
_buildFormatButton(context, ImageFormat.image),
],
if (showImageFormats && showTextFormats) _buildDivider(),
if (showTextFormats) ...[
_buildFormatButton(context, ImageFormat.text),
_buildFormatButton(context, ImageFormat.textAndImage),
_buildFormatButton(context, ImageFormat.image),
if (predefinedFormat?.imageFormat.hasText ?? true) ...[
_buildDivider(),
_buildTextFormatButton(context, TextFormat.paragraph),
_buildTextFormatButton(context, TextFormat.bulletList),
_buildTextFormatButton(context, TextFormat.numberedList),
@@ -104,7 +99,6 @@ class ChangeFormatBar extends StatelessWidget {
},
child: FlowyTooltip(
message: format.i18n,
preferBelow: false,
child: SizedBox.square(
dimension: _buttonSize,
child: FlowyHover(
@@ -151,7 +145,6 @@ class ChangeFormatBar extends StatelessWidget {
},
child: FlowyTooltip(
message: format.i18n,
preferBelow: false,
child: SizedBox.square(
dimension: _buttonSize,
child: FlowyHover(
@@ -1,259 +0,0 @@
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).hintColor,
color: Theme.of(context).iconTheme.color,
),
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(8),
size: const Size.square(10),
),
],
),
@@ -1,6 +1,4 @@
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';
@@ -25,23 +23,6 @@ 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,
-4
View File
@@ -100,10 +100,6 @@ bool get isAuthEnabled {
return false;
}
bool get isLocalAuthEnabled {
return currentCloudType().isLocal;
}
/// Determines if AppFlowy Cloud is enabled.
bool get isAppFlowyCloudEnabled {
return currentCloudType().isAppFlowyCloudEnabled;
@@ -100,6 +100,7 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
key: ValueKey(currentWorkspaceId),
userProfile: userProfile,
workspaceId: currentWorkspaceId,
currentWorkspaceMemberRole: state.currentWorkspace?.role,
),
const SupportSettingGroup(),
const AboutSettingGroup(),
@@ -36,16 +36,12 @@ 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();
@@ -57,20 +53,23 @@ class MobileSelectionMenu extends SelectionMenuService {
final completer = Completer<void>();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_show();
editorState.addScrollViewScrolledListener(_checkPositionAfterScrolling);
completer.complete();
});
return completer.future;
}
void _show() {
final position = _getCurrentPosition();
if (position == null) return;
final selectionRects = editorState.selectionRects();
if (selectionRects.isEmpty) {
return;
}
calculateSelectionMenuOffset(selectionRects.first);
final (left, top, right, bottom) = getPosition();
final editorHeight = editorState.renderBox!.size.height;
final editorWidth = editorState.renderBox!.size.width;
_positionNotifier = ValueNotifier(position);
_selectionMenuEntry = OverlayEntry(
builder: (context) {
return SizedBox(
@@ -81,54 +80,47 @@ class MobileSelectionMenu extends SelectionMenuService {
onTap: dismiss,
child: Stack(
children: [
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,
),
),
);
},
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,
),
),
),
],
),
@@ -143,34 +135,6 @@ 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;
@@ -202,6 +166,7 @@ class MobileSelectionMenu extends SelectionMenuService {
bottom = offset.dy;
break;
}
return (left, top, right, bottom);
}
@@ -221,9 +186,8 @@ class MobileSelectionMenu extends SelectionMenuService {
final bottomRight = rect.bottomRight;
final topRight = rect.topRight;
var offset = bottomRight + menuOffset;
final limitX = editorWidth - menuWidth + editorOffset.dx;
_offset = Offset(
min(offset.dx, limitX),
offset.dx,
offset.dy,
);
@@ -232,9 +196,10 @@ class MobileSelectionMenu extends SelectionMenuService {
offset = topRight - menuOffset;
_alignment = Alignment.bottomLeft;
final limitX = editorWidth - menuWidth;
_offset = Offset(
offset.dx,
editorOffset.dy + editorHeight - offset.dy,
min(offset.dx, limitX),
MediaQuery.of(context).size.height - offset.dy,
);
}
@@ -245,6 +210,7 @@ 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,
@@ -252,28 +218,3 @@ 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;
}
@@ -36,7 +36,9 @@ class MobileSelectionMenuItemWidget extends StatelessWidget {
),
style: ButtonStyle(
alignment: Alignment.centerLeft,
overlayColor: WidgetStateProperty.all(Colors.transparent),
overlayColor: WidgetStateProperty.all(
style.selectionMenuItemSelectedColor,
),
backgroundColor: isSelected
? WidgetStateProperty.all(
style.selectionMenuItemSelectedColor,
@@ -1,8 +1,6 @@
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';
@@ -316,32 +314,15 @@ class _MobileSelectionMenuWidgetState extends State<MobileSelectionMenuWidget> {
}
Widget _buildNoResultsWidget(BuildContext context) {
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),
),
return const Padding(
padding: EdgeInsets.all(8.0),
child: SizedBox(
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,
),
),
width: 140,
child: Material(
child: Text(
"No results",
style: TextStyle(fontSize: 18.0, color: Colors.grey),
textAlign: TextAlign.center,
),
),
),
@@ -5,6 +5,8 @@ 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';
@@ -16,10 +18,12 @@ 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) {
@@ -28,6 +32,7 @@ class AiSettingsGroup extends StatelessWidget {
create: (context) => SettingsAIBloc(
userProfile,
workspaceId,
currentWorkspaceMemberRole,
)..add(const SettingsAIEvent.started()),
child: BlocBuilder<SettingsAIBloc, SettingsAIState>(
builder: (context, state) {
@@ -43,7 +48,7 @@ class AiSettingsGroup extends StatelessWidget {
children: [
Flexible(
child: FlowyText(
state.availableModels?.selectedModel.name ?? "",
state.selectedAIModel,
color: theme.colorScheme.onSurface,
overflow: TextOverflow.ellipsis,
),
@@ -79,19 +84,16 @@ class AiSettingsGroup extends StatelessWidget {
title: LocaleKeys.settings_aiPage_keys_llmModelType.tr(),
builder: (_) {
return Column(
children: (availableModels?.models ?? [])
.asMap()
.entries
.map(
(entry) => FlowyOptionTile.checkbox(
text: entry.value.name,
showTopBorder: entry.key == 0,
isSelected:
availableModels?.selectedModel.name == entry.value.name,
children: availableModels
.mapIndexed(
(index, model) => FlowyOptionTile.checkbox(
text: model,
showTopBorder: index == 0,
isSelected: state.selectedAIModel == model,
onTap: () {
context
.read<SettingsAIBloc>()
.add(SettingsAIEvent.selectModel(entry.value));
.add(SettingsAIEvent.selectModel(model));
context.pop();
},
),
@@ -1,7 +1,6 @@
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';
@@ -19,7 +18,6 @@ class AppFlowyCloudPage extends StatelessWidget {
),
body: SettingCloud(
restartAppFlowy: () async {
await getIt<AuthService>().signOut();
await runAppFlowy();
},
),
@@ -1,53 +0,0 @@
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;
}
}
@@ -23,126 +23,11 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
parseMetadata(refSourceJsonString),
),
) {
_registerEventHandlers();
_initializeStreamListener();
_checkInitialStreamState();
}
_dispatch();
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) {
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()),
);
}
}
_startListening();
void _checkInitialStreamState() {
if (state.stream != null) {
if (state.stream!.aiLimitReached) {
add(const ChatAIMessageEvent.onAIResponseLimit());
} else if (state.stream!.error != null) {
@@ -151,10 +36,130 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
}
}
void _safeAdd(ChatAIMessageEvent event) {
if (!isClosed) {
add(event);
}
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));
}
},
);
}
}
@@ -169,8 +174,6 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
_OnAIImageResponseLimit;
const factory ChatAIMessageEvent.onAIMaxRequired(String message) =
_OnAIMaxRquired;
const factory ChatAIMessageEvent.onLocalAIInitializing() =
_OnLocalAIInitializing;
const factory ChatAIMessageEvent.receiveMetadata(
MetadataCollection metadata,
) = _ReceiveMetadata;
@@ -206,7 +209,6 @@ 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, model) {
regenerateAnswer: (id, format) {
_clearRelatedQuestions();
_regenerateAnswer(id, format, model);
_regenerateAnswer(id, format);
lastSentMessage = null;
isFetchingRelatedQuestions = false;
@@ -483,7 +483,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
void _regenerateAnswer(
String answerMessageIdString,
PredefinedFormat? format,
AIModelPB? model,
) async {
final id = temporaryMessageIDMap.entries
.firstWhereOrNull((e) => e.value == answerMessageIdString)
@@ -506,9 +505,6 @@ 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) {
@@ -641,7 +637,6 @@ class ChatEvent with _$ChatEvent {
const factory ChatEvent.regenerateAnswer(
String id,
PredefinedFormat? format,
AIModelPB? model,
) = _RegenerateAnswer;
// streaming answer
@@ -2,25 +2,55 @@ 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(
_handleEvent,
onDone: _onDoneCallback,
onError: _handleError,
(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());
},
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
bool _hasStarted = false;
bool _aiLimitReached = false;
bool _aiImageLimitReached = false;
@@ -32,15 +62,13 @@ 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 metadata)? _onMetadata;
void Function(MetadataCollection metadataCollection)? _onMetadata;
// Caches for events that occur before listen() is called.
// Buffer 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;
@@ -49,61 +77,12 @@ 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,
@@ -113,7 +92,6 @@ class AnswerStream {
void Function()? onAIImageResponseLimit,
void Function(String message)? onAIMaxRequired,
void Function(MetadataCollection metadata)? onMetadata,
void Function()? onLocalAIInitializing,
}) {
_onData = onData;
_onStart = onStart;
@@ -121,11 +99,10 @@ class AnswerStream {
_onError = onError;
_onAIResponseLimit = onAIResponseLimit;
_onAIImageResponseLimit = onAIImageResponseLimit;
_onAIMaxRequired = onAIMaxRequired;
_onMetadata = onMetadata;
_onLocalAIInitializing = onLocalAIInitializing;
_onAIMaxRequired = onAIMaxRequired;
// Flush pending AI_MAX_REQUIRED events.
// Flush any buffered AI_MAX_REQUIRED events.
if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) {
for (final msg in _pendingAIMaxRequiredEvents) {
_onAIMaxRequired!(msg);
@@ -133,12 +110,6 @@ class AnswerStream {
_pendingAIMaxRequiredEvents.clear();
}
// Flush pending LOCAL_AI_NOT_READY event.
if (_pendingLocalAINotReady && _onLocalAIInitializing != null) {
_onLocalAIInitializing!();
_pendingLocalAINotReady = false;
}
_onStart?.call();
}
}
@@ -19,17 +19,9 @@ class ChatSelectMessageBloc
on<ChatSelectMessageEvent>(
(event, emit) {
event.when(
enableStartSelectingMessages: () {
emit(state.copyWith(enabled: true));
},
toggleSelectingMessages: () {
if (state.isSelectingMessages) {
emit(
state.copyWith(
isSelectingMessages: false,
selectedMessages: [],
),
);
emit(ChatSelectMessageState.initial());
} else {
emit(state.copyWith(isSelectingMessages: true));
}
@@ -58,13 +50,8 @@ class ChatSelectMessageBloc
unselectAllMessages: () {
emit(state.copyWith(selectedMessages: const []));
},
reset: () {
emit(
state.copyWith(
isSelectingMessages: false,
selectedMessages: [],
),
);
saveAsPage: () {
emit(ChatSelectMessageState.initial());
},
);
},
@@ -83,8 +70,6 @@ class ChatSelectMessageBloc
@freezed
class ChatSelectMessageEvent with _$ChatSelectMessageEvent {
const factory ChatSelectMessageEvent.enableStartSelectingMessages() =
_EnableStartSelectingMessages;
const factory ChatSelectMessageEvent.toggleSelectingMessages() =
_ToggleSelectingMessages;
const factory ChatSelectMessageEvent.toggleSelectMessage(Message message) =
@@ -94,7 +79,7 @@ class ChatSelectMessageEvent with _$ChatSelectMessageEvent {
) = _SelectAllMessages;
const factory ChatSelectMessageEvent.unselectAllMessages() =
_UnselectAllMessages;
const factory ChatSelectMessageEvent.reset() = _Reset;
const factory ChatSelectMessageEvent.saveAsPage() = _SaveAsPage;
}
@freezed
@@ -102,11 +87,9 @@ 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,12 +178,8 @@ 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,7 +12,6 @@ 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'
@@ -73,7 +72,6 @@ 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,
@@ -94,29 +92,9 @@ class AIChatPage extends StatelessWidget {
}
}
},
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,
),
child: _ChatContentPage(
view: view,
userProfile: userProfile,
),
);
},
@@ -265,16 +243,10 @@ class _ChatContentPage extends StatelessWidget {
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
.add(ChatEvent.regenerateAnswer(message.id, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.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(),
),
.add(ChatEvent.regenerateAnswer(message.id, format)),
);
},
);
@@ -333,10 +305,6 @@ class _ChatContentPage extends StatelessWidget {
);
}
context
.read<ChatSelectMessageBloc>()
.add(ChatSelectMessageEvent.enableStartSelectingMessages());
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
@@ -358,56 +326,36 @@ class _ChatContentPage extends StatelessWidget {
BuildContext context,
ChatMessageRefSource metadata,
) async {
// When the source of metatdata is appflowy, which means it is a appflowy page
if (metadata.source == "appflowy") {
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 {
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 StatefulWidget {
class _Input extends StatelessWidget {
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>(
@@ -437,7 +385,6 @@ class _InputState extends State<_Input> {
return UniversalPlatform.isDesktop
? DesktopPromptInput(
isStreaming: !canSendMessage,
textController: textController,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
@@ -41,7 +41,7 @@ class _MobileChatInputState extends State<MobileChatInput> {
void initState() {
super.initState();
textController.addListener(handleTextControllerChanged);
textController.addListener(handleTextControllerChange);
// focusNode.onKeyEvent = handleKeyEvent;
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -197,7 +197,7 @@ class _MobileChatInputState extends State<MobileChatInput> {
);
}
void handleTextControllerChanged() {
void handleTextControllerChange() {
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.cloud => LocaleKeys.chat_inputMessageHint.tr(),
AiType.local => LocaleKeys.chat_inputLocalAIMessageHint.tr()
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
hintStyle: inputHintTextStyle(context),
isCollapsed: true,
@@ -250,7 +250,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
showSaveMessageSuccessToast(context, view);
}
bloc.add(const ChatSelectMessageEvent.reset());
bloc.add(const ChatSelectMessageEvent.saveAsPage());
return view;
}
@@ -275,7 +275,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
showSaveMessageSuccessToast(context, newView);
openPageFromMessage(context, newView);
}
bloc.add(const ChatSelectMessageEvent.reset());
bloc.add(const ChatSelectMessageEvent.saveAsPage());
}
Future<void> forceReload(String documentId) async {
@@ -72,8 +72,7 @@ class RelatedQuestionItem extends StatelessWidget {
child: FlowyText(
question,
lineHeight: 1.4,
maxLines: 2,
overflow: TextOverflow.ellipsis,
maxLines: null,
),
),
expandText: false,
@@ -1,145 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/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);
},
);
}
}
@@ -64,12 +64,8 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
super.didUpdateWidget(oldWidget);
if (oldWidget.markdown != widget.markdown) {
final editorState = _parseMarkdown(
widget.markdown.trim(),
previousDocument: this.editorState.document,
);
this.editorState.dispose();
this.editorState = editorState;
editorState.dispose();
editorState = _parseMarkdown(widget.markdown.trim());
scrollController.dispose();
scrollController = EditorScrollController(
editorState: editorState,
@@ -133,30 +129,8 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
);
}
EditorState _parseMarkdown(
String markdown, {
Document? previousDocument,
}) {
// merge the nodes from the previous document with the new document to keep the same node ids
EditorState _parseMarkdown(String markdown) {
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;
}
@@ -21,7 +21,6 @@ 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';
@@ -42,7 +41,6 @@ class AIMessageActionBar extends StatefulWidget {
required this.showDecoration,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
this.onOverrideVisibility,
});
@@ -50,7 +48,6 @@ 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
@@ -129,12 +126,6 @@ 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,
@@ -269,11 +260,8 @@ class _ChangeFormatButtonState extends State<ChangeFormatButton> {
constraints: const BoxConstraints(),
onClose: () => widget.onOverrideVisibility?.call(false),
child: buildButton(context),
popupBuilder: (_) => BlocProvider.value(
value: context.read<AIPromptInputBloc>(),
child: _ChangeFormatPopoverContent(
onRegenerate: widget.onRegenerate,
),
popupBuilder: (_) => _ChangeFormatPopoverContent(
onRegenerate: widget.onRegenerate,
),
);
}
@@ -371,16 +359,11 @@ class _ChangeFormatPopoverContentState
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return ChangeFormatBar(
spacing: 2.0,
showImageFormats: state.aiType.isCloud,
predefinedFormat: predefinedFormat,
onSelectPredefinedFormat: (format) {
setState(() => predefinedFormat = format);
},
);
ChangeFormatBar(
spacing: 2.0,
predefinedFormat: predefinedFormat,
onSelectPredefinedFormat: (format) {
setState(() => predefinedFormat = format);
},
),
const HSpace(4.0),
@@ -414,85 +397,6 @@ 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,
@@ -12,7 +12,6 @@ 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';
@@ -24,7 +23,6 @@ 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';
@@ -43,7 +41,6 @@ class ChatAIMessageBubble extends StatelessWidget {
this.isSelectingMessages = false,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Message message;
@@ -53,7 +50,6 @@ 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) {
@@ -77,7 +73,6 @@ class ChatAIMessageBubble extends StatelessWidget {
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: child,
);
}
@@ -87,7 +82,6 @@ class ChatAIMessageBubble extends StatelessWidget {
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: child,
);
}
@@ -97,7 +91,6 @@ class ChatAIMessageBubble extends StatelessWidget {
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: child,
);
}
@@ -110,14 +103,12 @@ 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) {
@@ -136,7 +127,6 @@ class ChatAIBottomInlineActions extends StatelessWidget {
showDecoration: false,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
),
),
const VSpace(32.0),
@@ -152,14 +142,12 @@ 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();
@@ -229,23 +217,22 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
setState(() => hoverActionBar = false);
}
},
child: SizedBox(
width: 784,
height: DesktopAIChatSizes.messageActionBarIconSize +
DesktopAIChatSizes.messageHoverActionBarPadding.vertical,
child: Container(
constraints: BoxConstraints(
maxWidth: 784,
maxHeight: DesktopAIChatSizes.messageActionBarIconSize +
DesktopAIChatSizes
.messageHoverActionBarPadding.vertical,
),
child: hoverBubble || hoverActionBar || overrideVisibility
? Align(
alignment: AlignmentDirectional.centerStart,
child: AIMessageActionBar(
message: widget.message,
showDecoration: true,
onRegenerate: widget.onRegenerate,
onChangeFormat: widget.onChangeFormat,
onChangeModel: widget.onChangeModel,
onOverrideVisibility: (visibility) {
overrideVisibility = visibility;
},
),
? AIMessageActionBar(
message: widget.message,
showDecoration: true,
onRegenerate: widget.onRegenerate,
onChangeFormat: widget.onChangeFormat,
onOverrideVisibility: (visibility) {
overrideVisibility = visibility;
},
)
: null,
),
@@ -315,14 +302,12 @@ 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) {
@@ -343,8 +328,6 @@ class ChatAIMessagePopup extends StatelessWidget {
_divider(),
_changeFormatButton(context),
_divider(),
_changeModelButton(context),
_divider(),
_saveToPageButton(context),
],
);
@@ -416,25 +399,6 @@ 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 {
@@ -4,7 +4,6 @@ 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';
@@ -33,11 +32,9 @@ 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,
@@ -53,9 +50,7 @@ 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;
@@ -113,13 +108,10 @@ 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(
@@ -154,15 +146,6 @@ class ChatAIMessageWidget extends StatelessWidget {
errorMessage: message,
);
},
onInitializingLocalAI: () {
onStopStream();
return ChatErrorMessageWidget(
errorMessage: LocaleKeys
.settings_aiPage_keys_localAIInitializing
.tr(),
);
},
),
),
);
@@ -47,6 +47,15 @@ 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_s),
icon: const FlowySvg(FlowySvgs.close_filled_m),
hoverColor: Colors.transparent,
onPressed: () => _textController.clear(),
),
@@ -9,7 +9,6 @@ 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';
@@ -72,16 +71,9 @@ class _RowEditor extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (_) => DocumentBloc(documentId: view.id)
..add(const DocumentEvent.initial()),
),
BlocProvider(
create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()),
),
],
return BlocProvider(
create: (_) =>
DocumentBloc(documentId: view.id)..add(const DocumentEvent.initial()),
child: BlocConsumer<DocumentBloc, DocumentState>(
listenWhen: (previous, current) =>
previous.isDocumentEmpty != current.isDocumentEmpty,
@@ -24,11 +24,11 @@ extension DatabaseSettingActionExtension on DatabaseSettingAction {
case DatabaseSettingAction.showProperties:
return FlowySvgs.multiselect_s;
case DatabaseSettingAction.showLayout:
return FlowySvgs.database_layout_s;
return FlowySvgs.database_layout_m;
case DatabaseSettingAction.showGroup:
return FlowySvgs.group_s;
case DatabaseSettingAction.showCalendarLayout:
return FlowySvgs.calendar_layout_s;
return FlowySvgs.calendar_layout_m;
}
}
@@ -21,8 +21,6 @@ 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 {
@@ -74,10 +72,6 @@ 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.trace('skip transaction for in-memory update');
Log.info('skip transaction for in-memory update');
return;
}
if (enableDocumentInternalLog) {
Log.trace(
Log.debug(
'[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.trace(
Log.debug(
'[TransactionAdapter] 4. transaction after apply: ${transaction.hashCode}',
);
}
@@ -5,7 +5,6 @@ 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';
@@ -89,12 +88,9 @@ class _DocumentPageState extends State<DocumentPage>
BlocProvider.value(value: documentBloc),
BlocProvider.value(
value: ViewLockStatusBloc(view: widget.view)
..add(ViewLockStatusEvent.initial()),
),
BlocProvider(
create: (context) =>
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),
lazy: false,
..add(
ViewLockStatusEvent.initial(),
),
),
],
child: BlocConsumer<ViewLockStatusBloc, ViewLockStatusState>(
@@ -128,20 +124,14 @@ class _DocumentPageState extends State<DocumentPage>
return const SizedBox.shrink();
}
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,
return BlocListener<ViewLockStatusBloc, ViewLockStatusState>(
listener: (context, state) {
editorState.editable = !state.isLocked;
},
child:
BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null,
listener: onNotificationAction,
child: buildEditorPage(context, state),
),
);
@@ -37,7 +37,6 @@ final Set<String> supportSlashMenuNodeTypes = {
NumberedListBlockKeys.type,
QuoteBlockKeys.type,
ToggleListBlockKeys.type,
CalloutBlockKeys.type,
// Simple table
SimpleTableBlockKeys.type,
@@ -125,15 +124,14 @@ BlockComponentConfiguration _buildDefaultConfiguration(BuildContext context) {
double padding = 26.0;
// only add indent padding for the top level node to align the children
if (UniversalPlatform.isMobile && node.level == 1) {
padding += EditorStyleCustomizer.nodeHorizontalPadding - 4;
if (UniversalPlatform.isMobile && node.path.length == 1) {
padding += EditorStyleCustomizer.nodeHorizontalPadding;
}
// 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;
if (node.isInQuote && node.level == 2) {
padding += 24;
}
return textDirection == TextDirection.ltr
@@ -613,19 +611,7 @@ 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;
},
indentPadding: (node, _) => EdgeInsets.zero,
),
);
}
@@ -1064,11 +1050,6 @@ 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 ||
@@ -1110,3 +1091,16 @@ TextAlign _buildTextAlignInTableCell(
return node.tableAlign.textAlign;
}
extension on Node {
bool get isInQuote {
Node? parent = this.parent;
while (parent != null) {
if (parent.type == QuoteBlockKeys.type) {
return true;
}
parent = parent.parent;
}
return false;
}
}
@@ -15,30 +15,17 @@ 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' hide QuoteBlockKeys;
import 'package:appflowy_editor/appflowy_editor.dart';
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({
@@ -94,25 +81,23 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
];
final List<ToolbarItem> toolbarItems = [
improveWritingItem,
group0PaddingItem,
aiWriterItem,
customTextHeadingItem,
buildPaddingPlaceholderItem(
1,
isActive: onlyShowInSingleTextTypeSelectionAndExcludeTable,
),
...customMarkdownFormatItems,
group1PaddingItem,
customTextColorItem,
group1PaddingItem,
customHighlightColorItem,
customInlineCodeItem,
suggestionsItem,
customLinkItem,
group4PaddingItem,
customTextAlignItem,
moreOptionItem,
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,
];
List<CharacterShortcutEvent> get characterShortcutEvents {
@@ -172,12 +157,10 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
indentableBlockTypes.addAll([
ToggleListBlockKeys.type,
CalloutBlockKeys.type,
QuoteBlockKeys.type,
]);
convertibleBlockTypes.addAll([
ToggleListBlockKeys.type,
CalloutBlockKeys.type,
QuoteBlockKeys.type,
]);
editorLaunchUrl = (url) {
@@ -351,8 +334,6 @@ 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(
@@ -396,7 +377,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
},
child: SizedBox(
width: double.infinity,
height: UniversalPlatform.isDesktopOrWeb ? 600 : 400,
height: UniversalPlatform.isDesktopOrWeb ? 300 : 400,
),
),
dropTargetStyle: AppFlowyDropTargetStyle(
@@ -426,39 +407,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
anchor: anchor,
closeToolbar: closeToolbar,
),
floatingToolbarHeight: 32,
child: editor,
),
);
}
return Center(
child: FloatingToolbar(
floatingToolbarHeight: 40,
padding: EdgeInsets.symmetric(horizontal: 6),
style: FloatingToolbarStyle(
backgroundColor: Theme.of(context).cardColor,
toolbarActiveColor: Color(0xffe0f8fd),
),
style: styleCustomizer.floatingToolbarStyleBuilder(),
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,
@@ -480,13 +437,11 @@ 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,
);
}
@@ -259,13 +259,11 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
emit(BlockActionOptionState()); // Emit a new state to trigger UI update
}
static Future<bool> turnIntoBlock(
Future<bool> turnIntoBlock(
String type,
Node node,
EditorState editorState, {
Node node, {
int? level,
String? currentViewId,
bool keepSelection = false,
}) async {
final selection = editorState.selection;
if (selection == null) {
@@ -289,8 +287,6 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
type: toType,
selectedNodes: selectedNodes,
level: level,
editorState: editorState,
afterSelection: keepSelection ? selection : null,
)) {
return true;
}
@@ -302,7 +298,6 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
selectedNodes: selectedNodes,
selection: selection,
currentViewId: currentViewId,
editorState: editorState,
)) {
return true;
}
@@ -327,8 +322,9 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
},
);
// heading block should not have children
if ([HeadingBlockKeys.type].contains(toType)) {
// heading block and callout block should not have children
if ([HeadingBlockKeys.type, CalloutBlockKeys.type, QuoteBlockKeys.type]
.contains(toType)) {
afterNode = afterNode.copyWith(children: []);
afterNode = await _handleSubPageNode(afterNode, node);
insertedNode.add(afterNode);
@@ -349,7 +345,6 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
insertedNode,
);
transaction.deleteNodes(selectedNodes);
if (keepSelection) transaction.afterSelection = selection;
await editorState.apply(transaction);
return true;
@@ -359,7 +354,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
///
/// Returns the altered [Node] with the delta as the Views' name.
///
static Future<Node> _handleSubPageNode(Node node, Node subPageNode) async {
Future<Node> _handleSubPageNode(Node node, Node subPageNode) async {
if (subPageNode.type != SubPageBlockKeys.type) {
return node;
}
@@ -376,7 +371,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
/// Returns the [Delta] from a SubPage [Node], where the
/// [Delta] is the views' name.
///
static Future<Delta?> _deltaFromSubPageNode(Node node) async {
Future<Delta?> _deltaFromSubPageNode(Node node) async {
if (node.type != SubPageBlockKeys.type) {
return null;
}
@@ -406,10 +401,9 @@ 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
static Future<bool> turnIntoSingleToggleHeading({
Future<bool> turnIntoSingleToggleHeading({
required String type,
required List<Node> selectedNodes,
required EditorState editorState,
int? level,
Delta? delta,
Selection? afterSelection,
@@ -469,7 +463,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
blockComponentDelta: newDelta.toJson(),
},
children: [
...node.children.map((e) => e.deepCopy()),
...node.children,
...insertedNodes.map((e) => e.deepCopy()),
],
);
@@ -499,12 +493,11 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
return true;
}
static Future<bool> turnIntoPage({
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;
@@ -560,7 +553,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
return true;
}
static Future<String> _extractNameFromNodes(List<Node>? nodes) async {
Future<String> _extractNameFromNodes(List<Node>? nodes) async {
if (nodes == null || nodes.isEmpty) {
return '';
}
@@ -610,7 +603,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
return name.substring(0, name.length > 30 ? 30 : name.length);
}
static List<String> _extractChildViewIds(List<Node> nodes) {
List<String> _extractChildViewIds(List<Node> nodes) {
final List<String> viewIds = [];
for (final node in nodes) {
if (node.type == SubPageBlockKeys.type) {
@@ -188,6 +188,7 @@ 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) {
@@ -246,6 +247,10 @@ Future<void> dragToMoveNode(
verticalPosition = VerticalPosition.bottom;
}
debugPrint(
'verticalPosition: $verticalPosition, horizontalPosition: $horizontalPosition',
);
return (verticalPosition, horizontalPosition, globalBlockRect);
}
@@ -2,7 +2,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/actions/block_action_option_cubit.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.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/workspace/presentation/home/menu/menu_shared_state.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
hide QuoteBlockKeys, quoteNode;
@@ -148,134 +149,213 @@ 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,
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,
);
}),
],
children: _buildTurnIntoOptions(context, node),
);
}
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,
),
),
);
}
List<Widget> _buildTurnIntoOptions(BuildContext context, Node node) {
final children = <Widget>[];
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),
),
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? _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');
}
}
@@ -2,11 +2,13 @@ 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/document_bloc.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_bloc.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: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';
@@ -15,8 +17,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 'widgets/ai_writer_suggestion_actions.dart';
import 'widgets/ai_writer_prompt_input_more_button.dart';
import 'suggestion_action_bar.dart';
import 'widgets/ai_writer_gesture_detector.dart';
class AiWriterBlockKeys {
const AiWriterBlockKeys._();
@@ -95,12 +97,19 @@ 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() {
@@ -108,14 +117,18 @@ class _AIWriterBlockComponentState extends State<AiWriterBlockComponent> {
WidgetsBinding.instance.addPostFrameCallback((_) {
overlayController.show();
context.read<AiWriterCubit>().register(widget.node);
textFieldFocusNode.requestFocus();
if (!widget.node.isAiWriterInitialized) {
aiWriterCubit.init();
}
});
}
@override
void dispose() {
textController.dispose();
focusNode.dispose();
textFieldFocusNode.dispose();
aiWriterCubit.close();
super.dispose();
}
@@ -125,49 +138,72 @@ class _AIWriterBlockComponentState extends State<AiWriterBlockComponent> {
return const SizedBox.shrink();
}
final documentId = context.read<DocumentBloc?>()?.documentId;
return BlocProvider(
create: (_) => AIPromptInputBloc(
predefinedFormat: null,
objectId: documentId ?? editorState.document.root.id,
),
return MultiBlocProvider(
providers: [
BlocProvider.value(
value: aiWriterCubit,
),
BlocProvider(
create: (_) => AIPromptInputBloc(
predefinedFormat: null,
),
),
],
child: LayoutBuilder(
builder: (context, constraints) {
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,
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 AiWriterGestureDetector(
behavior: state is GeneratingAiWriterState
? HitTestBehavior.opaque
: HitTestBehavior.translucent,
onPointerEvent: () => onTapOutside(),
);
},
),
width: constraints.maxWidth,
child: Focus(
focusNode: focusNode,
child: OverlayContent(
editorState: editorState,
node: widget.node,
textController: textController,
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,
),
),
),
),
],
);
},
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,
);
},
),
),
);
@@ -175,84 +211,78 @@ 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 StatefulWidget {
class OverlayContent extends StatelessWidget {
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) {
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 selection = node.aiWriterSelection;
final showSuggestionPopup =
state is ReadyAiWriterState && !state.isFirstRun;
final showActionPopup = state is ReadyAiWriterState && state.isFirstRun;
final markdownText = switch (state) {
final ReadyAiWriterState ready => ready.markdownText,
final GeneratingAiWriterState generating => generating.markdownText,
_ => '',
};
final hasSelection = selection != null && !selection.isCollapsed;
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);
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;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showSuggestedActionsPopup) ...[
if (showSuggestionPopup &&
state.command != AiWriterCommand.explain) ...[
Container(
padding: EdgeInsets.all(4.0),
decoration: _getModalDecoration(
context,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.all(Radius.circular(8.0)),
borderColor: borderColor,
borderColor: darkBorderColor,
),
child: SuggestionActionBar(
currentCommand: command,
hasSelection: hasSelection,
actions: _getSuggestedActions(
currentCommand: state.command,
hasSelection: hasSelection,
),
onTap: (action) {
_onSelectSuggestionAction(context, action);
},
@@ -260,81 +290,149 @@ class _OverlayContentState extends State<OverlayContent> {
),
const VSpace(4.0 + 1.0),
],
Container(
DecoratedBox(
decoration: _getModalDecoration(
context,
color: null,
borderColor: borderColor,
borderColor: darkBorderColor,
borderRadius: BorderRadius.all(Radius.circular(12.0)),
),
constraints: BoxConstraints(maxHeight: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (markdownText.isNotEmpty) ...[
Flexible(
child: DecoratedBox(
decoration: _secondaryContentDecoration(context),
child: SecondaryContentArea(
markdownText: markdownText,
onSelectSuggestionAction: (action) {
_onSelectSuggestionAction(context, action);
},
command: command,
showSuggestionActions: showSuggestedActionsWithin,
hasSelection: hasSelection,
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),
],
),
),
),
Divider(height: 1.0),
Divider(
height: 1.0,
),
],
DecoratedBox(
decoration: markdownText.isNotEmpty
? _mainContentDecoration(context)
? _getInputChildDecoration(context)
: _getSingleChildDeocoration(context),
child: MainContentArea(
textController: widget.textController,
isDocumentEmpty: _isDocumentEmpty(),
isInitialReadyState: isInitialReadyState,
showCommandsToggle: showCommandsToggle,
),
child: MainContentArea(),
),
],
),
),
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,
);
},
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,
),
),
);
},
),
),
),
],
],
);
},
);
}
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,
@@ -348,9 +446,13 @@ class _OverlayContentState extends State<OverlayContent> {
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: borderRadius,
boxShadow: Theme.of(context).isLightMode
? ShadowConstants.lightSmall
: ShadowConstants.darkSmall,
boxShadow: const [
BoxShadow(
offset: Offset(0, 4),
blurRadius: 20,
color: Color(0x1A1F2329),
),
],
);
}
@@ -361,20 +463,85 @@ class _OverlayContentState extends State<OverlayContent> {
);
}
BoxDecoration _secondaryContentDecoration(BuildContext context) {
BoxDecoration _getHelperChildDecoration(BuildContext context) {
return BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.vertical(top: Radius.circular(12.0)),
);
}
BoxDecoration _mainContentDecoration(BuildContext context) {
BoxDecoration _getInputChildDecoration(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,
@@ -386,99 +553,10 @@ class _OverlayContentState extends State<OverlayContent> {
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,
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;
const MainContentArea({super.key});
@override
Widget build(BuildContext context) {
@@ -490,10 +568,7 @@ class MainContentArea extends StatelessWidget {
return DesktopPromptInput(
isStreaming: false,
hideDecoration: true,
textController: textController,
onSubmitted: (message, format, _) {
cubit.runCommand(state.command, message, format);
},
onSubmitted: (message, format, _) => cubit.submit(message, format),
onStopStreaming: () => cubit.stopStream(),
selectedSourcesNotifier: cubit.selectedSourcesNotifier,
onUpdateSelectedSources: (sources) {
@@ -501,18 +576,6 @@ 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) {
@@ -568,26 +631,6 @@ 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();
},
);
@@ -2,11 +2,9 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/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';
@@ -19,7 +17,7 @@ const _aiWriterToolbarItemId = 'appflowy.editor.ai_writer';
final ToolbarItem improveWritingItem = ToolbarItem(
id: _improveWritingToolbarItemId,
group: 0,
isActive: onlyShowInTextTypeAndExcludeTable,
isActive: onlyShowInSingleSelectionAndTextType,
builder: (context, editorState, _, __, tooltipBuilder) =>
ImproveWritingButton(
editorState: editorState,
@@ -30,7 +28,7 @@ final ToolbarItem improveWritingItem = ToolbarItem(
final ToolbarItem aiWriterItem = ToolbarItem(
id: _aiWriterToolbarItemId,
group: 0,
isActive: onlyShowInTextTypeAndExcludeTable,
isActive: onlyShowInSingleSelectionAndTextType,
builder: (context, editorState, _, __, tooltipBuilder) =>
AiWriterToolbarActionList(
editorState: editorState,
@@ -55,23 +53,18 @@ 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(0, 2.0),
offset: const Offset(-8.0, 2.0),
margin: const EdgeInsets.all(8.0),
onOpen: () => keepEditorFocusNotifier.increase(),
onClose: () {
setState(() {
isSelected = false;
});
keepEditorFocusNotifier.decrease();
},
onClose: () => keepEditorFocusNotifier.decrease(),
popupBuilder: (context) => buildPopoverContent(),
child: buildChild(context),
child: buildChild(),
);
}
@@ -94,11 +87,12 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
Widget actionWrapper(AiWriterCommand command) {
return SizedBox(
height: 36,
height: 30.0,
child: FlowyButton(
leftIconSize: const Size.square(20),
leftIcon: FlowySvg(command.icon),
iconPadding: 12,
leftIcon: FlowySvg(
command.icon,
size: const Size.square(16),
),
text: FlowyText(
command.i18n,
figmaLineHeight: 20,
@@ -118,36 +112,33 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
);
}
Widget buildChild(BuildContext context) {
final themeV2 = AFThemeExtensionV2.of(context);
Widget buildChild() {
final child = FlowyIconButton(
width: 48,
height: 32,
isSelected: isSelected,
hoverColor: EditorStyleCustomizer.toolbarHoverColor(context),
hoverColor: Colors.transparent,
width: 36,
height: 24,
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.toolbar_ai_writer_m,
size: Size.square(20),
color: themeV2.icon_primary,
const FlowySvg(
FlowySvgs.ai_sparks_s,
size: Size.square(16.0),
color: Color(0xFFD08EED),
),
HSpace(4),
FlowySvg(
FlowySvgs.toolbar_arrow_down_m,
size: Size(12, 20),
color: themeV2.icon_tertiary,
const FlowySvg(
FlowySvgs.ai_source_drop_down_s,
size: Size.square(12),
color: Color(0xFF8F959E),
),
],
),
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,
@@ -182,13 +173,16 @@ class ImproveWritingButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final child = FlowyIconButton(
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,
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,
),
onPressed: () {
if (_isAIEnabled(editorState)) {
@@ -1,45 +1,10 @@
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> 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 {
Future<void> removeAiWriterNode(EditorState editorState, Node node) async {
final transaction = editorState.transaction..deleteNode(node);
await editorState.apply(
transaction,
@@ -48,16 +13,16 @@ Future<void> removeAiWriterNode(
);
}
Future<void> formatSelection(
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);
@@ -103,43 +68,29 @@ Future<void> formatSelection(
}
transaction.compose();
await editorState.apply(
transaction,
options: ApplyOptions(
inMemoryUpdate: true,
recordUndo: false,
),
withUpdateSelection: false,
);
}
Future<Position> ensurePreviousNodeIsEmptyParagraph(
Position ensurePreviousNodeIsEmptyParagraph(
EditorState editorState,
Node aiWriterNode,
) async {
Transaction transaction,
) {
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);
await editorState.apply(
transaction,
options: ApplyOptions(
inMemoryUpdate: true,
recordUndo: false,
),
);
transaction.updateNode(aiWriterNode, {
AiWriterBlockKeys.isInitialized: true,
});
return position;
}
@@ -1,14 +1,14 @@
import 'dart:async';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import '../../base/markdown_text_robot.dart';
@@ -20,245 +20,256 @@ class AiWriterCubit extends Cubit<AiWriterState> {
AiWriterCubit({
required this.documentId,
required this.editorState,
this.onCreateNode,
this.onRemoveNode,
this.onAppendToDocument,
required this.getAiWriterNode,
required this.initialCommand,
AppFlowyAIService? aiService,
}) : _aiService = aiService ?? AppFlowyAIService(),
_textRobot = MarkdownTextRobot(editorState: editorState),
selectedSourcesNotifier = ValueNotifier([documentId]),
super(IdleAiWriterState());
super(
ReadyAiWriterState(
initialCommand,
isFirstRun: true,
),
) {
editorState.service.keyboardService?.disableShortcuts();
}
final String documentId;
final EditorState editorState;
final Node Function() getAiWriterNode;
final AiWriterCommand initialCommand;
final AppFlowyAIService _aiService;
final MarkdownTextRobot _textRobot;
final void Function()? onCreateNode;
final void Function()? onRemoveNode;
final void Function()? onAppendToDocument;
Node? aiWriterNode;
final List<AiWriterRecord> records = [];
final ValueNotifier<List<String>> selectedSourcesNotifier;
(String, PredefinedFormat?)? _previousPrompt;
bool acceptReplacesOriginal = false;
@override
Future<void> close() async {
selectedSourcesNotifier.dispose();
editorState.service.keyboardService?.enableShortcuts();
await super.close();
}
Future<void> exit({
bool withDiscard = true,
bool withUnformat = true,
}) async {
if (withDiscard) {
await _textRobot.discard();
}
_textRobot.clear();
_textRobot.reset();
onRemoveNode?.call();
records.clear();
selectedSourcesNotifier.value = [documentId];
emit(IdleAiWriterState());
if (withUnformat) {
final selection = aiWriterNode!.aiWriterSelection;
if (selection == null) {
return;
}
await formatSelection(
editorState,
selection,
ApplySuggestionFormatType.clear,
);
}
if (aiWriterNode != null) {
await removeAiWriterNode(editorState, aiWriterNode!);
aiWriterNode = null;
}
void init() {
runCommand(initialCommand, null, isImmediateRun: true);
}
void register(Node node) async {
if (node.isAiWriterInitialized) {
return;
void submit(
String prompt,
PredefinedFormat? format,
) async {
final command = AiWriterCommand.userQuestion;
final node = getAiWriterNode();
_previousPrompt = (prompt, format);
final stream = await _aiService.streamCompletion(
objectId: documentId,
text: prompt,
format: format,
sourceIds: selectedSourcesNotifier.value,
completionType: command.toCompletionType(),
onStart: () async {
final transaction = editorState.transaction;
final position =
ensurePreviousNodeIsEmptyParagraph(editorState, node, transaction);
transaction.afterSelection = null;
await editorState.apply(
transaction,
options: ApplyOptions(
inMemoryUpdate: true,
recordUndo: false,
),
);
_textRobot.start(position: position);
},
onProcess: (text) async {
await _textRobot.appendMarkdownText(
text,
attributes: ApplySuggestionFormatType.replace.attributes,
);
},
onEnd: () async {
await _textRobot.stop(
attributes: ApplySuggestionFormatType.replace.attributes,
);
emit(ReadyAiWriterState(command, isFirstRun: false));
},
onError: (error) async {
emit(ErrorAiWriterState(state.command, error: error));
},
);
if (stream != null) {
emit(
GeneratingAiWriterState(
command,
taskId: stream.$1,
),
);
}
if (aiWriterNode != null && node.id != aiWriterNode!.id) {
await removeAiWriterNode(editorState, node);
return;
}
aiWriterNode = node;
onCreateNode?.call();
await setAiWriterNodeIsInitialized(editorState, node);
final command = node.aiWriterCommand;
final (run, prompt) = await _addSelectionTextToRecords(command);
if (!run) {
await exit();
return;
}
runCommand(command, prompt, null);
}
void runCommand(
AiWriterCommand command,
String prompt,
PredefinedFormat? predefinedFormat,
) async {
if (aiWriterNode == null) {
return;
}
await _textRobot.discard();
_textRobot.clear();
PredefinedFormat? predefinedFormat, {
bool isImmediateRun = false,
bool isRetry = false,
}) async {
switch (command) {
case AiWriterCommand.continueWriting:
await _startContinueWriting(
command,
predefinedFormat,
isImmediateRun: isImmediateRun,
);
break;
case AiWriterCommand.fixSpellingAndGrammar:
case AiWriterCommand.improveWriting:
case AiWriterCommand.makeLonger:
case AiWriterCommand.makeShorter:
await _startSuggestingEdits(command, prompt, predefinedFormat);
await _startSuggestingEdits(command, predefinedFormat);
break;
case AiWriterCommand.explain:
await _startInforming(command, prompt, predefinedFormat);
break;
case AiWriterCommand.userQuestion when prompt.isNotEmpty:
_startAskingQuestion(prompt, predefinedFormat);
await _startInforming(command, predefinedFormat);
break;
case AiWriterCommand.userQuestion:
emit(
ReadyAiWriterState(AiWriterCommand.userQuestion, isFirstRun: true),
);
if (isRetry && _previousPrompt != null) {
submit(_previousPrompt!.$1, _previousPrompt!.$2);
}
break;
}
}
void _retry({
required PredefinedFormat? predefinedFormat,
}) async {
final lastQuestion =
records.lastWhereOrNull((record) => record.role == AiRole.user);
if (lastQuestion != null && state is RegisteredAiWriter) {
runCommand(
(state as RegisteredAiWriter).command,
lastQuestion.content,
lastQuestion.format,
);
}
}
Future<void> stopStream() async {
if (aiWriterNode == null) {
void stopStream() async {
if (state is! GeneratingAiWriterState) {
return;
}
final generatingState = state as GeneratingAiWriterState;
await AIEventStopCompleteText(
CompleteTextTaskPB(
taskId: generatingState.taskId,
),
).send();
emit(
ReadyAiWriterState(
state.command,
isFirstRun: false,
markdownText: generatingState.markdownText,
),
);
}
if (state is GeneratingAiWriterState) {
final generatingState = state as GeneratingAiWriterState;
await _textRobot.stop(
attributes: ApplySuggestionFormatType.replace.attributes,
);
if (_textRobot.hasAnyResult) {
records.add(AiWriterRecord.ai(content: _textRobot.markdownText));
}
await AIEventStopCompleteText(
CompleteTextTaskPB(
taskId: generatingState.taskId,
),
).send();
emit(
ReadyAiWriterState(
generatingState.command,
isFirstRun: false,
markdownText: generatingState.markdownText,
),
);
void exit() async {
await _textRobot.discard();
final selection = getAiWriterNode().aiWriterSelection;
if (selection == null) {
return;
}
final transaction = editorState.transaction;
formatSelection(
editorState,
selection,
transaction,
ApplySuggestionFormatType.clear,
);
await editorState.apply(
transaction,
options: const ApplyOptions(
inMemoryUpdate: true,
recordUndo: false,
),
withUpdateSelection: false,
);
await removeAiWriterNode(editorState, getAiWriterNode());
}
void runResponseAction(
SuggestionAction action, [
PredefinedFormat? predefinedFormat,
]) async {
if (aiWriterNode == null) {
return;
}
if (action case SuggestionAction.rewrite || SuggestionAction.tryAgain) {
_retry(predefinedFormat: predefinedFormat);
return;
}
if (action case SuggestionAction.discard || SuggestionAction.close) {
await exit();
await _textRobot.discard();
_textRobot.reset();
runCommand(state.command, predefinedFormat, isRetry: true);
return;
}
final selection = aiWriterNode?.aiWriterSelection;
final selection = getAiWriterNode().aiWriterSelection;
if (selection == null) {
return;
}
if (action case SuggestionAction.accept) {
await _textRobot.persist();
await formatSelection(
if (action case SuggestionAction.discard || SuggestionAction.close) {
await _textRobot.discard();
final transaction = editorState.transaction;
formatSelection(
editorState,
selection,
transaction,
ApplySuggestionFormatType.clear,
);
final nodes = editorState.getNodesInSelection(selection);
final transaction = editorState.transaction..deleteNodes(nodes);
await editorState.apply(
transaction,
withUpdateSelection: false,
options: const ApplyOptions(recordUndo: false),
);
await exit(withDiscard: false, withUnformat: false);
return;
}
if (action case SuggestionAction.keep) {
if (action case SuggestionAction.accept || SuggestionAction.keep) {
await _textRobot.persist();
await exit(withDiscard: false);
return;
if (acceptReplacesOriginal) {
final nodes = editorState.getNodesInSelection(selection);
final transaction = editorState.transaction..deleteNodes(nodes);
await editorState.apply(
transaction,
options: const ApplyOptions(recordUndo: false),
withUpdateSelection: false,
);
}
}
if (action case SuggestionAction.insertBelow) {
if (state is! ReadyAiWriterState) {
return;
}
final command = (state as ReadyAiWriterState).command;
final markdownText = (state as ReadyAiWriterState).markdownText;
if (command == AiWriterCommand.explain && markdownText.isNotEmpty) {
final position = await ensurePreviousNodeIsEmptyParagraph(
if (state case final ReadyAiWriterState readyState
when readyState.markdownText.isNotEmpty) {
final transaction = editorState.transaction;
final position = ensurePreviousNodeIsEmptyParagraph(
editorState,
aiWriterNode!,
getAiWriterNode(),
transaction,
);
transaction.afterSelection = null;
await editorState.apply(
transaction,
options: ApplyOptions(
inMemoryUpdate: true,
recordUndo: false,
),
);
_textRobot.start(position: position);
await _textRobot.persist(markdownText: markdownText);
} else if (_textRobot.hasAnyResult) {
await _textRobot.persist(markdownText: readyState.markdownText);
} else {
await _textRobot.persist();
}
await formatSelection(
final transaction = editorState.transaction;
formatSelection(
editorState,
selection,
transaction,
ApplySuggestionFormatType.clear,
);
await exit(withDiscard: false);
await editorState.apply(
transaction,
options: const ApplyOptions(recordUndo: false),
withUpdateSelection: false,
);
}
await removeAiWriterNode(editorState, getAiWriterNode());
}
bool hasUnusedResponse() {
@@ -273,223 +284,86 @@ class AiWriterCubit extends Cubit<AiWriterState> {
};
}
Future<(bool, String)> _addSelectionTextToRecords(
AiWriterCommand command,
) async {
final node = aiWriterNode;
if (node == null) {
Log.warn('[AI writer] Node is null');
return (false, '');
}
final selection = node.aiWriterSelection?.normalized;
if (selection == null) {
Log.warn('[AI writer]Selection is null');
return (false, '');
}
if (command == AiWriterCommand.continueWriting) {
return (true, '');
}
if (selection.isCollapsed) {
return (true, '');
}
final selectionText = await editorState.getMarkdownInSelection(selection);
Log.warn('[AI writer] Selection is null');
if (command == AiWriterCommand.userQuestion) {
records.add(
AiWriterRecord.user(content: selectionText, format: null),
);
return (true, '');
} else {
return (true, selectionText);
}
}
Future<String> _getDocumentContentFromTopToPosition(Position position) async {
final beginningToCursorSelection = Selection(
start: Position(path: [0]),
end: position,
).normalized;
final documentText =
(await editorState.getMarkdownInSelection(beginningToCursorSelection))
.trim();
final view = await ViewBackendService.getView(documentId).toNullable();
final viewName = view?.name ?? '';
return "$viewName\n$documentText".trim();
}
void _startAskingQuestion(
String prompt,
PredefinedFormat? format,
) async {
if (aiWriterNode == null) {
return;
}
final command = AiWriterCommand.userQuestion;
final stream = await _aiService.streamCompletion(
objectId: documentId,
text: prompt,
format: format,
history: records,
sourceIds: selectedSourcesNotifier.value,
completionType: command.toCompletionType(),
onStart: () async {
final position = await ensurePreviousNodeIsEmptyParagraph(
editorState,
aiWriterNode!,
);
_textRobot.start(position: position);
records.add(
AiWriterRecord.user(
content: prompt,
format: format,
),
);
},
processMessage: (text) async {
await _textRobot.appendMarkdownText(
text,
updateSelection: false,
attributes: ApplySuggestionFormatType.replace.attributes,
);
onAppendToDocument?.call();
},
processAssistMessage: (text) async {
if (state case final GeneratingAiWriterState generatingState) {
emit(
GeneratingAiWriterState(
command,
taskId: generatingState.taskId,
markdownText: generatingState.markdownText + text,
),
);
}
},
onEnd: () async {
if (state case final GeneratingAiWriterState generatingState) {
await _textRobot.stop(
attributes: ApplySuggestionFormatType.replace.attributes,
);
emit(
ReadyAiWriterState(
command,
isFirstRun: false,
markdownText: generatingState.markdownText,
),
);
records.add(
AiWriterRecord.ai(content: _textRobot.markdownText),
);
}
},
onError: (error) async {
emit(ErrorAiWriterState(command, error: error));
records.add(
AiWriterRecord.ai(content: _textRobot.markdownText),
);
},
onLocalAIStreamingStateChange: (state) {
emit(LocalAIStreamingAiWriterState(command, state: state));
},
);
if (stream != null) {
emit(
GeneratingAiWriterState(
command,
taskId: stream.$1,
),
);
}
}
Future<void> _startContinueWriting(
AiWriterCommand command,
PredefinedFormat? predefinedFormat,
) async {
final position = aiWriterNode?.aiWriterSelection?.start;
if (position == null) {
PredefinedFormat? predefinedFormat, {
required bool isImmediateRun,
}) async {
final node = getAiWriterNode();
final cursorPosition = getAiWriterNode().aiWriterSelection?.start;
if (cursorPosition == null) {
return;
}
final text = await _getDocumentContentFromTopToPosition(position);
final selection = Selection(
start: Position(path: [0]),
end: cursorPosition,
).normalized;
String text = await editorState.getMarkdownInSelection(selection);
if (text.isEmpty) {
final stateCopy = state;
emit(DocumentContentEmptyAiWriterState(command, onConfirm: exit));
emit(stateCopy);
return;
if (state is! ReadyAiWriterState) {
return;
}
final view = await ViewBackendService.getView(documentId).toNullable();
if (view == null ||
view.name.isEmpty ||
view.name == LocaleKeys.menuAppHeader_defaultNewPageName.tr()) {
final readyState = state as ReadyAiWriterState;
emit(
SingleShotAiWriterState(
command,
title: LocaleKeys.ai_continueWritingEmptyDocumentTitle.tr(),
description:
LocaleKeys.ai_continueWritingEmptyDocumentDescription.tr(),
onDismiss: () {
if (isImmediateRun) {
removeAiWriterNode(editorState, node);
}
},
),
);
emit(readyState);
return;
} else {
text += view.name;
}
}
final stream = await _aiService.streamCompletion(
objectId: documentId,
text: text,
completionType: command.toCompletionType(),
history: records,
sourceIds: selectedSourcesNotifier.value,
format: predefinedFormat,
onStart: () async {
final position = await ensurePreviousNodeIsEmptyParagraph(
editorState,
aiWriterNode!,
);
_textRobot.start(position: position);
records.add(
AiWriterRecord.user(
content: text,
format: predefinedFormat,
final transaction = editorState.transaction;
final position =
ensurePreviousNodeIsEmptyParagraph(editorState, node, transaction);
transaction.afterSelection = null;
await editorState.apply(
transaction,
options: ApplyOptions(
inMemoryUpdate: true,
recordUndo: false,
),
);
_textRobot.start(position: position);
},
processMessage: (text) async {
onProcess: (text) async {
await _textRobot.appendMarkdownText(
text,
updateSelection: false,
attributes: ApplySuggestionFormatType.replace.attributes,
);
onAppendToDocument?.call();
},
processAssistMessage: (text) async {
if (state case final GeneratingAiWriterState generatingState) {
emit(
GeneratingAiWriterState(
command,
taskId: generatingState.taskId,
markdownText: generatingState.markdownText + text,
),
);
}
},
onEnd: () async {
if (state case final GeneratingAiWriterState generatingState) {
if (state case GeneratingAiWriterState _) {
await _textRobot.stop(
attributes: ApplySuggestionFormatType.replace.attributes,
);
emit(
ReadyAiWriterState(
command,
isFirstRun: false,
markdownText: generatingState.markdownText,
),
);
emit(ReadyAiWriterState(command, isFirstRun: false));
}
records.add(
AiWriterRecord.ai(content: _textRobot.markdownText),
);
},
onError: (error) async {
emit(ErrorAiWriterState(command, error: error));
records.add(
AiWriterRecord.ai(content: _textRobot.markdownText),
);
},
onLocalAIStreamingStateChange: (state) {
emit(LocalAIStreamingAiWriterState(command, state: state));
},
);
if (stream != null) {
@@ -501,63 +375,48 @@ class AiWriterCubit extends Cubit<AiWriterState> {
Future<void> _startSuggestingEdits(
AiWriterCommand command,
String prompt,
PredefinedFormat? predefinedFormat,
) async {
final selection = aiWriterNode?.aiWriterSelection;
final node = getAiWriterNode();
final selection = node.aiWriterSelection;
if (selection == null) {
return;
}
if (prompt.isEmpty) {
prompt = records.removeAt(0).content;
}
acceptReplacesOriginal = true;
final stream = await _aiService.streamCompletion(
objectId: documentId,
text: prompt,
format: predefinedFormat,
text: await editorState.getMarkdownInSelection(selection),
completionType: command.toCompletionType(),
history: records,
sourceIds: selectedSourcesNotifier.value,
onStart: () async {
await formatSelection(
final transaction = editorState.transaction;
formatSelection(
editorState,
selection,
transaction,
ApplySuggestionFormatType.original,
);
final position = await ensurePreviousNodeIsEmptyParagraph(
editorState,
aiWriterNode!,
);
_textRobot.start(position: position, previousSelection: selection);
records.add(
AiWriterRecord.user(
content: prompt,
format: predefinedFormat,
final position =
ensurePreviousNodeIsEmptyParagraph(editorState, node, transaction);
transaction.afterSelection = null;
await editorState.apply(
transaction,
options: ApplyOptions(
inMemoryUpdate: true,
recordUndo: false,
),
);
_textRobot.start(position: position);
},
processMessage: (text) async {
onProcess: (text) async {
await _textRobot.appendMarkdownText(
text,
updateSelection: false,
attributes: ApplySuggestionFormatType.replace.attributes,
);
onAppendToDocument?.call();
},
processAssistMessage: (text) async {
if (state case final GeneratingAiWriterState generatingState) {
emit(
GeneratingAiWriterState(
command,
taskId: generatingState.taskId,
markdownText: generatingState.markdownText + text,
),
);
}
},
onEnd: () async {
if (state case final GeneratingAiWriterState generatingState) {
if (state is GeneratingAiWriterState) {
await _textRobot.stop(
attributes: ApplySuggestionFormatType.replace.attributes,
);
@@ -565,22 +424,12 @@ class AiWriterCubit extends Cubit<AiWriterState> {
ReadyAiWriterState(
command,
isFirstRun: false,
markdownText: generatingState.markdownText,
),
);
records.add(
AiWriterRecord.ai(content: _textRobot.markdownText),
);
}
},
onError: (error) async {
emit(ErrorAiWriterState(command, error: error));
records.add(
AiWriterRecord.ai(content: _textRobot.markdownText),
);
},
onLocalAIStreamingStateChange: (state) {
emit(LocalAIStreamingAiWriterState(command, state: state));
},
);
if (stream != null) {
@@ -592,33 +441,20 @@ class AiWriterCubit extends Cubit<AiWriterState> {
Future<void> _startInforming(
AiWriterCommand command,
String prompt,
PredefinedFormat? predefinedFormat,
) async {
final selection = aiWriterNode?.aiWriterSelection;
final node = getAiWriterNode();
final selection = node.aiWriterSelection;
if (selection == null) {
return;
}
if (prompt.isEmpty) {
prompt = records.removeAt(0).content;
}
final stream = await _aiService.streamCompletion(
objectId: documentId,
text: prompt,
text: await editorState.getMarkdownInSelection(selection),
completionType: command.toCompletionType(),
history: records,
sourceIds: selectedSourcesNotifier.value,
format: predefinedFormat,
onStart: () async {
records.add(
AiWriterRecord.user(
content: prompt,
format: predefinedFormat,
),
);
},
processMessage: (text) async {
onStart: () async {},
onProcess: (text) async {
if (state case final GeneratingAiWriterState generatingState) {
emit(
GeneratingAiWriterState(
@@ -629,7 +465,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
);
}
},
processAssistMessage: (_) async {},
onEnd: () async {
if (state case final GeneratingAiWriterState generatingState) {
emit(
@@ -639,22 +474,11 @@ class AiWriterCubit extends Cubit<AiWriterState> {
markdownText: generatingState.markdownText,
),
);
records.add(
AiWriterRecord.ai(content: generatingState.markdownText),
);
}
},
onError: (error) async {
if (state case final GeneratingAiWriterState generatingState) {
records.add(
AiWriterRecord.ai(content: generatingState.markdownText),
);
}
emit(ErrorAiWriterState(command, error: error));
},
onLocalAIStreamingStateChange: (state) {
emit(LocalAIStreamingAiWriterState(command, state: state));
},
);
if (stream != null) {
emit(
@@ -664,82 +488,54 @@ class AiWriterCubit extends Cubit<AiWriterState> {
}
}
mixin RegisteredAiWriter {
AiWriterCommand get command;
}
sealed class AiWriterState {
const AiWriterState();
const AiWriterState(this.command);
final AiWriterCommand command;
}
class IdleAiWriterState extends AiWriterState {
const IdleAiWriterState();
}
class ReadyAiWriterState extends AiWriterState with RegisteredAiWriter {
class ReadyAiWriterState extends AiWriterState {
const ReadyAiWriterState(
this.command, {
super.command, {
required this.isFirstRun,
this.markdownText = '',
});
@override
final AiWriterCommand command;
final bool isFirstRun;
final String markdownText;
}
class GeneratingAiWriterState extends AiWriterState with RegisteredAiWriter {
class GeneratingAiWriterState extends AiWriterState {
const GeneratingAiWriterState(
this.command, {
super.command, {
required this.taskId,
this.progress = '',
this.markdownText = '',
});
@override
final AiWriterCommand command;
final String taskId;
final String progress;
final String markdownText;
}
class ErrorAiWriterState extends AiWriterState with RegisteredAiWriter {
class ErrorAiWriterState extends AiWriterState {
const ErrorAiWriterState(
this.command, {
super.command, {
required this.error,
});
@override
final AiWriterCommand command;
final AIError error;
}
class DocumentContentEmptyAiWriterState extends AiWriterState
with RegisteredAiWriter {
const DocumentContentEmptyAiWriterState(
this.command, {
required this.onConfirm,
class SingleShotAiWriterState extends AiWriterState {
const SingleShotAiWriterState(
super.command, {
required this.title,
required this.description,
required this.onDismiss,
});
@override
final AiWriterCommand command;
final void Function() onConfirm;
}
class LocalAIStreamingAiWriterState extends AiWriterState
with RegisteredAiWriter {
const LocalAIStreamingAiWriterState(
this.command, {
required this.state,
});
@override
final AiWriterCommand command;
final LocalAIStreamingState state;
final String title;
final String description;
final void Function() onDismiss;
}
@@ -1,9 +1,7 @@
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';
@@ -92,7 +90,7 @@ enum AiWriterCommand {
FlowySvgData get icon => switch (this) {
userQuestion => FlowySvgs.ai_sparks_s,
explain => FlowySvgs.ai_explain_m,
explain => FlowySvgs.ai_explain_s,
// summarize => FlowySvgs.ai_summarize_s,
continueWriting || improveWriting => FlowySvgs.ai_improve_writing_s,
fixSpellingAndGrammar => FlowySvgs.ai_fix_spelling_grammar_s,
@@ -122,38 +120,3 @@ 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,
},
);
}
}
@@ -1,5 +1,3 @@
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';
@@ -89,64 +87,4 @@ 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;
}
}
@@ -0,0 +1,63 @@
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,
),
);
}
}
@@ -21,17 +21,18 @@ class AiWriterGestureDetector extends StatelessWidget {
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(instance) => instance
..onTapDown = ((_) => onPointerEvent())
..onSecondaryTapDown = ((_) => onPointerEvent())
..onTertiaryTapDown = ((_) => onPointerEvent()),
(instance) {
instance
..onTap = onPointerEvent
..onTapDown = (_) => onPointerEvent();
},
),
ImmediateMultiDragGestureRecognizer:
GestureRecognizerFactoryWithHandlers<
ImmediateMultiDragGestureRecognizer>(
() => ImmediateMultiDragGestureRecognizer(),
(instance) => instance.onStart = (offset) => null,
),
ImmediateMultiDragGestureRecognizer>(
() => ImmediateMultiDragGestureRecognizer(), (instance) {
instance.onStart = (offset) => null;
}),
},
child: child,
);
@@ -1,151 +0,0 @@
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),
);
},
);
}
}
@@ -1,226 +0,0 @@
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();
});
}
}
@@ -1,110 +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.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,10 +1,8 @@
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;
@@ -30,13 +28,8 @@ 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) {
@@ -61,11 +54,9 @@ class MarkdownTextRobot {
}
void start({
Selection? previousSelection,
Position? position,
}) {
_insertPosition = position ?? editorState.selection?.start;
_previousSelection = previousSelection ?? editorState.selection;
_insertPosition ??= position ?? editorState.selection?.start;
if (_enableDebug) {
Log.info(
@@ -77,7 +68,6 @@ 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;
@@ -85,7 +75,6 @@ class MarkdownTextRobot {
await _lock.synchronized(() async {
await _refresh(
inMemoryUpdate: true,
updateSelection: updateSelection,
attributes: attributes,
);
});
@@ -104,6 +93,7 @@ class MarkdownTextRobot {
await _lock.synchronized(() async {
await _refresh(
inMemoryUpdate: true,
updateSelection: false,
attributes: attributes,
);
});
@@ -147,7 +137,7 @@ class MarkdownTextRobot {
await editorState.apply(
transaction,
options: const ApplyOptions(recordUndo: false, inMemoryUpdate: true),
options: const ApplyOptions(recordUndo: false),
);
if (_enableDebug) {
@@ -155,18 +145,15 @@ class MarkdownTextRobot {
}
}
void clear() {
void reset() {
_markdownText = '';
_insertedNodes = [];
}
void reset() {
_insertPosition = null;
}
Future<void> _refresh({
required bool inMemoryUpdate,
bool updateSelection = false,
bool updateSelection = true,
Map<String, dynamic>? attributes,
}) async {
final position = _insertPosition;
@@ -182,40 +169,11 @@ 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.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();
: documentNodes
.map((node) => _styleDelta(node: node, attributes: attributes))
.toList();
if (newNodes.isEmpty) {
return;
@@ -243,6 +201,10 @@ class MarkdownTextRobot {
offset: lastDelta.length,
),
);
if (!updateSelection) {
insertTransaction.afterSelection = null;
}
}
await editorState.apply(
@@ -251,7 +213,6 @@ class MarkdownTextRobot {
inMemoryUpdate: inMemoryUpdate,
recordUndo: !inMemoryUpdate,
),
withUpdateSelection: updateSelection,
);
_insertedNodes = newNodes;
@@ -283,13 +244,3 @@ class MarkdownTextRobot {
);
}
}
class AINodeExternalValues extends NodeExternalValues {
const AINodeExternalValues({
this.isAINode = false,
this.isFirstNumberedListNode = false,
});
final bool isAINode;
final bool isFirstNumberedListNode;
}
@@ -1,8 +1,4 @@
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) {
@@ -36,31 +32,3 @@ 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,
};
@@ -3,6 +3,7 @@ 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/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart'
show StringTranslateExtension;
@@ -175,32 +176,21 @@ class _CalloutBlockComponentWidgetState
EmojiIconData result = EmojiIconData.emoji('📌');
try {
result = EmojiIconData(FlowyIconType.values.byName(type), icon);
} catch (_) {}
} catch (e) {
Log.info(
'get emoji error with icon:[$icon], type:[$type] within calloutBlockComponentWidget',
e,
);
}
return result;
}
@override
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(
return Stack(
children: [
Positioned.fill(
left: UniversalPlatform.isMobile ? 0 : cachedLeft,
left: cachedLeft,
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(6.0)),
@@ -218,15 +208,6 @@ class _CalloutBlockComponentWidgetState
),
],
);
if (UniversalPlatform.isMobile) {
child = Padding(
padding: padding,
child: child,
);
}
return child;
}
// build the callout block widget
@@ -243,17 +224,16 @@ class _CalloutBlockComponentWidgetState
Widget child = Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(6.0)),
color: withBackgroundColor ? backgroundColor : null,
color: backgroundColor,
),
padding: widget.inlinePadding(widget.node),
width: double.infinity,
alignment: alignment,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
textDirection: textDirection,
children: [
const HSpace(6.0),
if (UniversalPlatform.isDesktopOrWeb) const HSpace(6.0),
// the emoji picker button for the note
EmojiPickerButton(
// force to refresh the popover state
@@ -290,19 +270,11 @@ class _CalloutBlockComponentWidgetState
),
);
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 = Padding(
key: blockComponentKey,
padding: EdgeInsets.zero,
child: child,
);
child = BlockSelectionContainer(
node: node,
@@ -32,8 +32,7 @@ CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async {
await editorState.deleteSelection(selection);
if (HardwareKeyboard.instance.isShiftPressed) {
// ignore the shift+enter event, fallback to the default behavior
return false;
await editorState.insertNewLine();
} else if (node.children.isEmpty) {
// insert a new paragraph within the callout block
final path = node.path.child(0);
@@ -1,4 +1,5 @@
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';
@@ -118,6 +119,9 @@ 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(
@@ -162,14 +162,12 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
children.add(child);
if (i != length - 1) {
children.add(
SimpleColumnBlockWidthResizer(
columnNode: childNode,
editorState: editorState,
),
);
}
children.add(
SimpleColumnBlockWidthResizer(
columnNode: childNode,
editorState: editorState,
),
);
}
return children;
}
@@ -32,11 +32,8 @@ 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) {
@@ -1,7 +1,6 @@
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';
@@ -20,13 +19,9 @@ class DocumentImmersiveCoverBloc
(event, emit) async {
await event.when(
initial: () async {
final latestView = await ViewBackendService.getView(view.id);
add(
DocumentImmersiveCoverEvent.updateCoverAndIcon(
latestView.fold(
(s) => s.cover,
(e) => view.cover,
),
view.cover,
EmojiIconData.fromViewIconPB(view.icon),
view.name,
),
@@ -1,330 +0,0 @@
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);
}
}
@@ -1,94 +0,0 @@
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;
}
@@ -1,273 +0,0 @@
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),
),
],
);
@@ -1,429 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/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};
}
@@ -1,397 +0,0 @@
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();
}
}
}
@@ -1,326 +0,0 @@
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();
}
}
@@ -1,34 +0,0 @@
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,
);
}
}
@@ -1,87 +0,0 @@
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,
),
);
}
}
@@ -1,17 +0,0 @@
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._();
}
@@ -10,7 +10,6 @@ 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';
@@ -96,17 +95,6 @@ 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({
@@ -1,17 +1,15 @@
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 {
@@ -61,37 +59,11 @@ 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),
@@ -9,6 +9,8 @@ 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';
@@ -19,6 +21,68 @@ 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,
@@ -99,11 +163,6 @@ 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();
@@ -112,25 +171,27 @@ class _FontFamilyDropDownState extends State<FontFamilyDropDown> {
child: widget.child,
popupBuilder: (_) {
widget.onOpen?.call();
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(() {
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) {
query.value = value;
});
},
},
),
),
),
Container(height: 1, color: Theme.of(context).dividerColor),
const SliverToBoxAdapter(
child: SizedBox(height: 4),
),
ValueListenableBuilder(
valueListenable: query,
builder: (context, value, child) {
@@ -145,32 +206,14 @@ class _FontFamilyDropDownState extends State<FontFamilyDropDown> {
.sorted((a, b) => levenshtein(a, b))
.toList();
}
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]),
),
),
),
);
return SliverFixedExtentList.builder(
itemBuilder: (context, index) => _fontFamilyItemButton(
context,
getGoogleFontSafely(displayed[index]),
),
itemCount: displayed.length,
itemExtent: 32,
);
},
),
],
@@ -190,18 +233,16 @@ class _FontFamilyDropDownState extends State<FontFamilyDropDown> {
waitDuration: const Duration(milliseconds: 150),
child: SizedBox(
key: ValueKey(buttonFontFamily),
height: 36,
height: 32,
child: FlowyButton(
onHover: (_) => FocusScope.of(context).unfocus(),
text: FlowyText(
text: FlowyText.medium(
buttonFontFamily.fontFamilyDisplayName,
fontFamily: buttonFontFamily,
figmaLineHeight: 20,
fontWeight: FontWeight.w400,
),
rightIcon:
buttonFontFamily == widget.currentFontFamily.parseFontFamilyName()
? const FlowySvg(FlowySvgs.toolbar_check_m)
? const FlowySvg(FlowySvgs.check_s)
: null,
onTap: () {
if (widget.onFontFamilyChanged != null) {
@@ -188,65 +188,49 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
onChangeCover: (type, details) =>
_saveIconOrCover(cover: (type, details)),
),
_buildAlignedCoverIcon(context),
_buildCoverIcon(
context,
constraints,
offset,
),
],
),
_buildAlignedTitle(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,
),
),
),
],
);
},
);
}
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) {
Widget _buildCoverIcon(
BuildContext context,
BoxConstraints constraints,
double offset,
) {
if (!hasIcon || offset == 0) {
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,
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(),
],
),
),
child: DocumentIcon(
editorState: widget.editorState,
node: widget.node,
icon: viewIcon,
documentId: view.id,
onChangeIcon: (icon) => _saveIconOrCover(icon: icon),
),
);
}
@@ -71,13 +71,11 @@ 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();
@@ -113,11 +111,13 @@ class _RawEmojiIconWidgetState extends State<RawEmojiIconWidget> {
try {
switch (widget.emoji.type) {
case FlowyIconType.emoji:
return EmojiText(
emoji: widget.emoji.emoji,
fontSize: widget.emojiSize,
textAlign: TextAlign.justify,
lineHeight: widget.lineHeight,
return SizedBox(
width: widget.emojiSize,
child: EmojiText(
emoji: widget.emoji.emoji,
fontSize: widget.emojiSize,
textAlign: TextAlign.justify,
),
);
case FlowyIconType.icon:
IconsData iconData =
@@ -9,7 +9,7 @@ const _kInlineMathEquationToolbarItemId = 'editor.inline_math_equation';
final ToolbarItem inlineMathEquationItem = ToolbarItem(
id: _kInlineMathEquationToolbarItemId,
group: 4,
group: 2,
isActive: onlyShowInSingleSelectionAndTextType,
builder: (context, editorState, highlightColor, _, tooltipBuilder) {
final selection = editorState.selection!;
@@ -60,6 +60,8 @@ class MentionDateBlock extends StatefulWidget {
}
class _MentionDateBlockState extends State<MentionDateBlock> {
final PopoverMutex mutex = PopoverMutex();
late bool _includeTime = widget.includeTime;
late DateTime? parsedDate = DateTime.tryParse(widget.date);
@@ -69,6 +71,12 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
mutex.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (parsedDate == null) {
@@ -97,6 +105,7 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
final options = DatePickerOptions(
focusedDay: parsedDate,
popoverMutex: mutex,
selectedDay: parsedDate,
includeTime: _includeTime,
dateFormat: appearance.dateFormat,
@@ -118,7 +118,7 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
view: view,
content: state.blockContent,
textStyle: widget.textStyle,
handleTap: () => handleMentionBlockTap(
handleTap: () => _handleTap(
context,
widget.editorState,
view,
@@ -138,7 +138,7 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
content: state.blockContent,
textStyle: widget.textStyle,
showTrashHint: state.isInTrash,
handleTap: () => handleMentionBlockTap(
handleTap: () => _handleTap(
context,
widget.editorState,
view,
@@ -221,8 +221,7 @@ class _MentionSubPageBlockState extends State<MentionSubPageBlock> {
view: view,
showTrashHint: state.isInTrash,
textStyle: widget.textStyle,
handleTap: () =>
handleMentionBlockTap(context, widget.editorState, view),
handleTap: () => _handleTap(context, widget.editorState, view),
isChildPage: true,
content: '',
handleDoubleTap: () => _handleDoubleTap(
@@ -240,8 +239,7 @@ class _MentionSubPageBlockState extends State<MentionSubPageBlock> {
content: null,
textStyle: widget.textStyle,
isChildPage: true,
handleTap: () =>
handleMentionBlockTap(context, widget.editorState, view),
handleTap: () => _handleTap(context, widget.editorState, view),
);
}
},
@@ -323,7 +321,7 @@ Path? _findNodePathByBlockId(EditorState editorState, String blockId) {
return null;
}
Future<void> handleMentionBlockTap(
Future<void> _handleTap(
BuildContext context,
EditorState editorState,
ViewPB view, {
@@ -1,4 +1,3 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -32,7 +31,7 @@ class NumberedListIcon extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(left: 6.0, right: 10.0),
child: Text(
node.buildLevelString(context),
node.levelString,
style: adjustedTextStyle,
strutStyle: StrutStyle.fromTextStyle(combinedTextStyle),
textHeightBehavior: TextHeightBehavior(
@@ -48,12 +47,9 @@ class NumberedListIcon extends StatelessWidget {
}
}
extension NumberedListNodeIndex on Node {
String buildLevelString(BuildContext context) {
final builder = NumberedListIndexBuilder(
editorState: context.read<EditorState>(),
node: this,
);
extension on Node {
String get levelString {
final builder = _NumberedListIconBuilder(node: this);
final indexInRootLevel = builder.indexInRootLevel;
final indexInSameLevel = builder.indexInSameLevel;
final level = indexInRootLevel % 3;
@@ -66,13 +62,11 @@ extension NumberedListNodeIndex on Node {
}
}
class NumberedListIndexBuilder {
NumberedListIndexBuilder({
required this.editorState,
class _NumberedListIconBuilder {
_NumberedListIconBuilder({
required this.node,
});
final EditorState editorState;
final Node node;
// the level of the current node
@@ -94,13 +88,7 @@ class NumberedListIndexBuilder {
Node? previous = node.previous;
// if the previous one is not a numbered list, then it is the first one
final aiNodeExternalValues =
node.externalValues?.unwrapOrNull<AINodeExternalValues>();
if (previous == null ||
previous.type != NumberedListBlockKeys.type ||
(aiNodeExternalValues != null &&
aiNodeExternalValues.isFirstNumberedListNode)) {
if (previous == null || previous.type != NumberedListBlockKeys.type) {
return node.attributes[NumberedListBlockKeys.number] ?? level;
}
@@ -109,17 +97,10 @@ class NumberedListIndexBuilder {
startNumber = previous.attributes[NumberedListBlockKeys.number] as int?;
level++;
previous = previous.previous;
// break the loop if the start number is found when the current node is an AI node
if (aiNodeExternalValues != null && startNumber != null) {
return startNumber + level - 1;
}
}
if (startNumber != null) {
level = startNumber + level - 1;
return startNumber + level - 1;
}
return level;
}
}
@@ -10,6 +10,7 @@ class CalloutNodeParser extends NodeParser {
@override
String transform(Node node, DocumentMarkdownEncoder? encoder) {
assert(node.children.isEmpty);
final delta = node.delta ?? Delta()
..insert('');
final String markdown = DeltaMarkdownEncoder()
@@ -3,10 +3,6 @@ import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
/// In memory cache of the quote block height to avoid flashing when the quote block is updated.
Map<String, double> _quoteBlockHeightCache = {};
typedef QuoteBlockIconBuilder = Widget Function(
BuildContext context,
@@ -118,9 +114,7 @@ class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
@override
Node get node => widget.node;
late ValueNotifier<double> quoteBlockHeightNotifier = ValueNotifier(
_quoteBlockHeightCache[node.id] ?? 0,
);
ValueNotifier<double> quoteBlockHeightNotifier = ValueNotifier(0);
StreamSubscription<EditorTransactionValue>? _transactionSubscription;
@@ -130,7 +124,7 @@ class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
void initState() {
super.initState();
_updateQuoteBlockHeight();
_observerQuoteBlockChanges();
}
@override
@@ -142,45 +136,20 @@ class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
@override
Widget build(BuildContext context) {
return NotificationListener<SizeChangedLayoutNotification>(
key: layoutBuilderKey,
onNotification: (notification) {
return LayoutBuilder(
builder: (context, constraints) {
_updateQuoteBlockHeight();
return true;
return KeyedSubtree(
key: layoutBuilderKey,
child: node.children.isEmpty
? buildComponent(context)
: buildComponentWithChildren(context),
);
},
child: SizeChangedLayoutNotifier(
child: node.children.isEmpty
? buildComponent(context)
: buildComponentWithChildren(context),
),
);
}
@override
Widget buildComponentWithChildren(BuildContext context) {
final Widget child = Stack(
children: [
Positioned.fill(
left: UniversalPlatform.isMobile ? padding.left : cachedLeft,
right: UniversalPlatform.isMobile ? padding.right : 0,
child: Container(
color: backgroundColor,
),
),
NestedListWidget(
indentPadding: indentPadding,
child: buildComponent(context, withBackgroundColor: false),
children: editorState.renderer.buildList(
context,
widget.node.children,
),
),
],
);
return child;
}
@override
Widget buildComponent(
BuildContext context, {
@@ -235,7 +204,7 @@ class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
);
child = Container(
color: withBackgroundColor ? backgroundColor : null,
color: backgroundColor,
child: Padding(
key: blockComponentKey,
padding: padding,
@@ -249,7 +218,6 @@ class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor,
selectionAboveBlock: true,
supportTypes: const [
BlockSelectionType.block,
],
@@ -271,19 +239,30 @@ class _QuoteBlockComponentWidgetState extends State<QuoteBlockComponentWidget>
void _updateQuoteBlockHeight() {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final renderObject = layoutBuilderKey.currentContext?.findRenderObject();
double height = _quoteBlockHeightCache[node.id] ?? 0;
if (renderObject != null && renderObject is RenderBox) {
if (UniversalPlatform.isMobile) {
height = renderObject.size.height - padding.top;
} else {
height = renderObject.size.height - padding.top * 2;
}
quoteBlockHeightNotifier.value =
renderObject.size.height - padding.top * 2;
} else {
height = 0;
quoteBlockHeightNotifier.value = 0;
}
});
}
void _observerQuoteBlockChanges() {
_transactionSubscription = editorState.transactionStream.listen((event) {
final time = event.$1;
if (time != TransactionTime.before) {
return;
}
quoteBlockHeightNotifier.value = height;
_quoteBlockHeightCache[node.id] = height;
final transaction = event.$2;
final operations = transaction.operations;
for (final operation in operations) {
if (node.path.isAncestorOf(operation.path)) {
_updateQuoteBlockHeight();
}
}
});
}
}
@@ -308,6 +287,7 @@ class QuoteIcon extends StatelessWidget {
padding: const EdgeInsets.only(right: 6.0),
child: SizedBox(
width: 3 * textScaleFactor,
// use overflow box to ensure the container can overflow the height so that the children of the quote block can have the quote
child: OverflowBox(
alignment: Alignment.topCenter,
@@ -30,10 +30,8 @@ CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async {
await editorState.deleteSelection(selection);
if (HardwareKeyboard.instance.isShiftPressed) {
// ignore the shift+enter event, fallback to the default behavior
return false;
} else if (node.children.isEmpty &&
selection.endIndex == node.delta?.length) {
await editorState.insertNewLine();
} else if (node.children.isEmpty) {
// insert a new paragraph within the callout block
final path = node.path.child(0);
final transaction = editorState.transaction;
@@ -47,8 +45,7 @@ CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async {
),
);
await editorState.apply(transaction);
return true;
}
return false;
return true;
};
@@ -7,7 +7,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da
import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/heading_block_shortcuts.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/numbered_list_block_shortcuts.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/plugins/emoji/emoji_actions_command.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -83,9 +82,5 @@ List<CharacterShortcutEvent> buildCharacterShortcutEvents(
documentBloc.documentId,
styleCustomizer.inlineActionsMenuStyleBuilder(),
),
/// show emoji list
/// - Using `:`
emojiCommand(context),
];
}
@@ -238,13 +238,8 @@ extension TableNodeExtension on Node {
try {
final columnWidths =
parentTableNode.attributes[SimpleTableBlockKeys.columnWidths];
final width = columnWidths?[columnIndex.toString()] as Object?;
if (width == null) {
return SimpleTableConstants.defaultColumnWidth;
}
return width.toDouble(
defaultValue: SimpleTableConstants.defaultColumnWidth,
);
final width = columnWidths?[columnIndex.toString()];
return width ?? SimpleTableConstants.defaultColumnWidth;
} catch (e) {
Log.warn('get column width: $e');
return SimpleTableConstants.defaultColumnWidth;
@@ -861,18 +856,3 @@ extension TableNodeExtension on Node {
return TableAlign.left;
}
}
extension on Object {
double toDouble({double defaultValue = 0}) {
if (this is double) {
return this as double;
}
if (this is String) {
return double.tryParse(this as String) ?? defaultValue;
}
if (this is int) {
return (this as int).toDouble();
}
return defaultValue;
}
}
@@ -1,4 +1,3 @@
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_cell_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -41,9 +40,7 @@ KeyEventResult _enterInTableCellHandler(EditorState editorState) {
return KeyEventResult.handled;
}
}
if (node.type != CalloutBlockKeys.type) {
return convertToParagraphCommand.execute(editorState);
}
return convertToParagraphCommand.execute(editorState);
}
return KeyEventResult.ignored;
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff. Show more