diff --git a/frontend/appflowy_flutter/assets/translations/en.json b/frontend/appflowy_flutter/assets/translations/en.json index 1a799f8ec5..fdd0531526 100644 --- a/frontend/appflowy_flutter/assets/translations/en.json +++ b/frontend/appflowy_flutter/assets/translations/en.json @@ -344,16 +344,18 @@ "referencedGrid": "Referenced Grid", "autoCompletionMenuItemName": "Auto Completion", "autoGeneratorMenuItemName": "Auto Generator", - "autoGeneratorTitleName": "Open AI: Auto Generator", + "autoGeneratorTitleName": "OpenAI: Ask AI to write anything...", "autoGeneratorLearnMore": "Learn more", "autoGeneratorGenerate": "Generate", "autoGeneratorHintText": "Tell us what you want to generate by OpenAI ...", "autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key", - "smartEditTitleName": "Open AI: Smart Edit", + "smartEdit": "Smart Edit", + "smartEditTitleName": "OpenAI: Smart Edit", "smartEditFixSpelling": "Fix spelling", "smartEditSummarize": "Summarize", "smartEditCouldNotFetchResult": "Could not fetch result from OpenAI", "smartEditCouldNotFetchKey": "Could not fetch OpenAI key", + "smartEditDisabled": "Connect OpenAI in Settings", "cover": { "changeCover": "Change Cover", "colors": "Colors", diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index b5d45eb71d..7f54bf4d27 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -183,9 +183,7 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> { ], ], toolbarItems: [ - if (openAIKey != null && openAIKey!.isNotEmpty) ...[ - smartEditItem, - ] + smartEditItem, ], themeData: theme.copyWith(extensions: [ ...theme.extensions.values, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover.dart index 66957652e3..d7e9c45ef8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover.dart @@ -16,6 +16,7 @@ import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:path/path.dart' as path; const String kLocalImagesKey = 'local_images'; @@ -263,7 +264,7 @@ class _ChangeCoverPopoverState extends State { if (path != null) { final directory = await _coverPath(); final newPath = await File(path).copy( - '$directory/${path.split('/').last}', + '$directory/${path.split(path).last}}', ); imageNames.add(newPath.path); } @@ -274,7 +275,7 @@ class _ChangeCoverPopoverState extends State { Future _coverPath() async { final directory = await getIt().fetchLocation(); - return Directory('$directory/covers') + return Directory(path.join(directory, 'covers')) .create(recursive: true) .then((value) => value.path); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart index 5ceb4d7ea6..21678c16e5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart @@ -10,9 +10,9 @@ enum SmartEditAction { String get toInstruction { switch (this) { case SmartEditAction.summarize: - return 'Make it shorter'; + return 'Make this shorter and more concise:'; case SmartEditAction.fixSpelling: - return 'Fix all the spelling mistakes'; + return 'Correct this to standard English:'; } } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart index d98595e0da..0d9c9c29fa 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart @@ -140,9 +140,12 @@ class _SmartEditInputState extends State<_SmartEditInput> { } Widget _buildResultWidget(BuildContext context) { - final loading = SizedBox.fromSize( - size: const Size.square(14), - child: const CircularProgressIndicator(), + final loading = Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: SizedBox.fromSize( + size: const Size.square(14), + child: const CircularProgressIndicator(), + ), ); if (result == null) { return loading; @@ -222,7 +225,6 @@ class _SmartEditInputState extends State<_SmartEditInput> { final texts = result!.asRight().choices.first.text.split('\n') ..removeWhere((element) => element.isEmpty); - assert(texts.length == selectedNodes.length); final transaction = widget.editorState.transaction; transaction.replaceTexts( selectedNodes.toList(growable: false), @@ -254,7 +256,7 @@ class _SmartEditInputState extends State<_SmartEditInput> { final edits = await openAIRepository.getEdits( input: input, instruction: instruction, - n: input.split('\n').length, + n: 1, ); return edits.fold((error) async { return dartz.Left( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart index 0247413544..844ea8df91 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart @@ -1,10 +1,14 @@ import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart'; import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart'; +import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; ToolbarItem smartEditItem = ToolbarItem( id: 'appflowy.toolbar.smart_edit', @@ -33,6 +37,20 @@ class _SmartEditWidget extends StatefulWidget { } class _SmartEditWidgetState extends State<_SmartEditWidget> { + bool isOpenAIEnabled = false; + + @override + void initState() { + super.initState(); + + UserBackendService.getCurrentUserProfile().then((value) { + setState(() { + isOpenAIEnabled = + value.fold((l) => l.openaiKey.isNotEmpty, (r) => false); + }); + }); + } + @override Widget build(BuildContext context) { return PopoverActionList( @@ -43,7 +61,9 @@ class _SmartEditWidgetState extends State<_SmartEditWidget> { buildChild: (controller) { return FlowyIconButton( hoverColor: Colors.transparent, - tooltipText: 'Smart Edit', + tooltipText: isOpenAIEnabled + ? LocaleKeys.document_plugins_smartEdit.tr() + : LocaleKeys.document_plugins_smartEditDisabled.tr(), preferBelow: false, icon: const Icon( Icons.lightbulb_outline, @@ -51,7 +71,11 @@ class _SmartEditWidgetState extends State<_SmartEditWidget> { color: Colors.white, ), onPressed: () { - controller.show(); + if (isOpenAIEnabled) { + controller.show(); + } else { + _showError(LocaleKeys.document_plugins_smartEditDisabled.tr()); + } }, ); }, @@ -97,4 +121,18 @@ class _SmartEditWidgetState extends State<_SmartEditWidget> { withUpdateCursor: false, ); } + + Future _showError(String message) async { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + action: SnackBarAction( + label: LocaleKeys.button_Cancel.tr(), + onPressed: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + }, + ), + content: FlowyText(message), + ), + ); + } } diff --git a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart index 95227e8c0a..9507e9fd0c 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:appflowy_backend/appflowy_backend.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; import '../startup.dart'; @@ -35,11 +36,11 @@ Future appFlowyDocumentDirectory() async { switch (integrationEnv()) { case IntegrationMode.develop: Directory documentsDir = await getApplicationDocumentsDirectory(); - return Directory('${documentsDir.path}/flowy_dev').create(); + return Directory(path.join(documentsDir.path, 'flowy_dev')).create(); case IntegrationMode.release: Directory documentsDir = await getApplicationDocumentsDirectory(); - return Directory('${documentsDir.path}/flowy').create(); + return Directory(path.join(documentsDir.path, 'flowy')).create(); case IntegrationMode.test: - return Directory("${Directory.current.path}/.sandbox"); + return Directory(path.join(Directory.current.path, '.sandbox')); } } diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/command_extension.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/command_extension.dart index 5a9ea4f4bd..8a9648f6a5 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/command_extension.dart +++ b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/commands/command_extension.dart @@ -57,7 +57,10 @@ extension CommandExtension on EditorState { Selection selection, ) { List res = []; - if (!selection.isCollapsed) { + if (selection.isSingle) { + final plainText = textNodes.first.toPlainText(); + res.add(plainText.substring(selection.startIndex, selection.endIndex)); + } else if (!selection.isCollapsed) { for (var i = 0; i < textNodes.length; i++) { final plainText = textNodes[i].toPlainText(); if (i == 0) { diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/core/transform/transaction.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/core/transform/transaction.dart index a0bfe1cb0c..c1a311d648 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/core/transform/transaction.dart +++ b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/core/transform/transaction.dart @@ -291,46 +291,125 @@ extension TextTransaction on Transaction { Selection selection, List texts, ) { - if (textNodes.isEmpty) { + if (textNodes.isEmpty || texts.isEmpty) { return; } - if (selection.isSingle) { - assert(textNodes.length == 1 && texts.length == 1); - replaceText( - textNodes.first, - selection.startIndex, - selection.length, - texts.first, - ); - } else { + if (textNodes.length == texts.length) { final length = textNodes.length; - for (var i = 0; i < length; i++) { + + if (length == 1) { + replaceText( + textNodes.first, + selection.startIndex, + selection.endIndex - selection.startIndex, + texts.first, + ); + return; + } + + for (var i = 0; i < textNodes.length; i++) { final textNode = textNodes[i]; - final text = texts[i]; if (i == 0) { replaceText( textNode, selection.startIndex, textNode.toPlainText().length, - text, + texts.first, ); } else if (i == length - 1) { replaceText( textNode, 0, selection.endIndex, - text, + texts.last, ); } else { replaceText( textNode, 0, textNode.toPlainText().length, - text, + texts[i], ); } } + return; + } + + if (textNodes.length > texts.length) { + final length = textNodes.length; + for (var i = 0; i < textNodes.length; i++) { + final textNode = textNodes[i]; + if (i == 0) { + replaceText( + textNode, + selection.startIndex, + textNode.toPlainText().length, + texts.first, + ); + } else if (i == length - 1) { + replaceText( + textNode, + 0, + selection.endIndex, + texts.last, + ); + } else { + if (i < texts.length - 1) { + replaceText( + textNode, + 0, + textNode.toPlainText().length, + texts[i], + ); + } else { + deleteNode(textNode); + } + } + } + afterSelection = null; + return; + } + + if (textNodes.length < texts.length) { + final length = texts.length; + for (var i = 0; i < texts.length; i++) { + final text = texts[i]; + if (i == 0) { + replaceText( + textNodes.first, + selection.startIndex, + textNodes.first.toPlainText().length, + text, + ); + } else if (i == length - 1) { + replaceText( + textNodes.last, + 0, + selection.endIndex, + text, + ); + } else { + if (i < textNodes.length - 1) { + replaceText( + textNodes[i], + 0, + textNodes[i].toPlainText().length, + text, + ); + } else { + var path = textNodes.first.path; + var j = i - textNodes.length + length - 1; + while (j > 0) { + path = path.next; + j--; + } + insertNode(path, TextNode(delta: Delta()..insert(text))); + } + } + } + afterSelection = null; + return; } } } diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/keyboard_service.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/keyboard_service.dart index a25b7a6f1e..b903fed4f8 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/keyboard_service.dart +++ b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/keyboard_service.dart @@ -112,6 +112,7 @@ class _AppFlowyKeyboardState extends State isFocus = false; this.showCursor = showCursor; _focusNode.unfocus(disposition: disposition); + _onFocusChange(false); } @override diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/selection_service.dart b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/selection_service.dart index b522aa9cef..a2e716c9e2 100644 --- a/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/selection_service.dart +++ b/frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/selection_service.dart @@ -347,8 +347,9 @@ class _AppFlowySelectionState extends State void _onPanStart(DragStartDetails details) { clearSelection(); + _clearToolbar(); - _panStartOffset = details.globalPosition; + _panStartOffset = details.globalPosition.translate(-3.0, 0); _panStartScrollDy = editorState.service.scrollService?.dy; _enableInteraction(); diff --git a/frontend/appflowy_flutter/packages/appflowy_editor/test/core/transform/transaction_test.dart b/frontend/appflowy_flutter/packages/appflowy_editor/test/core/transform/transaction_test.dart new file mode 100644 index 0000000000..27a3701d84 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_editor/test/core/transform/transaction_test.dart @@ -0,0 +1,132 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +Document createEmptyDocument() { + return Document( + root: Node( + type: 'editor', + ), + ); +} + +void main() async { + group('transaction.dart', () { + testWidgets('test replaceTexts, textNodes.length == texts.length', + (tester) async { + TestWidgetsFlutterBinding.ensureInitialized(); + + final editor = tester.editor + ..insertTextNode('0123456789') + ..insertTextNode('0123456789') + ..insertTextNode('0123456789') + ..insertTextNode('0123456789'); + await editor.startTesting(); + await tester.pumpAndSettle(); + + expect(editor.documentLength, 4); + + final selection = Selection( + start: Position(path: [0], offset: 4), + end: Position(path: [3], offset: 4), + ); + final transaction = editor.editorState.transaction; + var textNodes = [0, 1, 2, 3] + .map((e) => editor.nodeAtPath([e])!) + .whereType() + .toList(growable: false); + final texts = ['ABC', 'ABC', 'ABC', 'ABC']; + transaction.replaceTexts(textNodes, selection, texts); + editor.editorState.apply(transaction); + await tester.pumpAndSettle(); + + expect(editor.documentLength, 4); + textNodes = [0, 1, 2, 3] + .map((e) => editor.nodeAtPath([e])!) + .whereType() + .toList(growable: false); + expect(textNodes[0].toPlainText(), '0123ABC'); + expect(textNodes[1].toPlainText(), 'ABC'); + expect(textNodes[2].toPlainText(), 'ABC'); + expect(textNodes[3].toPlainText(), 'ABC456789'); + }); + + testWidgets('test replaceTexts, textNodes.length > texts.length', + (tester) async { + TestWidgetsFlutterBinding.ensureInitialized(); + + final editor = tester.editor + ..insertTextNode('0123456789') + ..insertTextNode('0123456789') + ..insertTextNode('0123456789') + ..insertTextNode('0123456789') + ..insertTextNode('0123456789'); + await editor.startTesting(); + await tester.pumpAndSettle(); + + expect(editor.documentLength, 5); + + final selection = Selection( + start: Position(path: [0], offset: 4), + end: Position(path: [4], offset: 4), + ); + final transaction = editor.editorState.transaction; + var textNodes = [0, 1, 2, 3, 4] + .map((e) => editor.nodeAtPath([e])!) + .whereType() + .toList(growable: false); + final texts = ['ABC', 'ABC', 'ABC', 'ABC']; + transaction.replaceTexts(textNodes, selection, texts); + editor.editorState.apply(transaction); + await tester.pumpAndSettle(); + + expect(editor.documentLength, 4); + textNodes = [0, 1, 2, 3] + .map((e) => editor.nodeAtPath([e])!) + .whereType() + .toList(growable: false); + expect(textNodes[0].toPlainText(), '0123ABC'); + expect(textNodes[1].toPlainText(), 'ABC'); + expect(textNodes[2].toPlainText(), 'ABC'); + expect(textNodes[3].toPlainText(), 'ABC456789'); + }); + + testWidgets('test replaceTexts, textNodes.length < texts.length', + (tester) async { + TestWidgetsFlutterBinding.ensureInitialized(); + + final editor = tester.editor + ..insertTextNode('0123456789') + ..insertTextNode('0123456789') + ..insertTextNode('0123456789'); + await editor.startTesting(); + await tester.pumpAndSettle(); + + expect(editor.documentLength, 3); + + final selection = Selection( + start: Position(path: [0], offset: 4), + end: Position(path: [2], offset: 4), + ); + final transaction = editor.editorState.transaction; + var textNodes = [0, 1, 2] + .map((e) => editor.nodeAtPath([e])!) + .whereType() + .toList(growable: false); + final texts = ['ABC', 'ABC', 'ABC', 'ABC']; + transaction.replaceTexts(textNodes, selection, texts); + editor.editorState.apply(transaction); + await tester.pumpAndSettle(); + + expect(editor.documentLength, 4); + textNodes = [0, 1, 2, 3] + .map((e) => editor.nodeAtPath([e])!) + .whereType() + .toList(growable: false); + expect(textNodes[0].toPlainText(), '0123ABC'); + expect(textNodes[1].toPlainText(), 'ABC'); + expect(textNodes[2].toPlainText(), 'ABC'); + expect(textNodes[3].toPlainText(), 'ABC456789'); + }); + }); +} diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index a54627aa28..f880223728 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -830,7 +830,7 @@ packages: source: hosted version: "1.0.5" path: - dependency: transitive + dependency: "direct main" description: name: path url: "https://pub.dartlang.org" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 56f4ffc0d7..b96af94cd0 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -95,6 +95,7 @@ dependencies: window_manager: ^0.3.0 http: ^0.13.5 json_annotation: ^4.7.0 + path: ^1.8.2 dev_dependencies: flutter_lints: ^2.0.1