diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart index 0a1d551390..3f94a7bda3 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart @@ -5,6 +5,7 @@ import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:example/plugin/AI/continue_to_write.dart'; import 'package:example/plugin/AI/auto_completion.dart'; import 'package:example/plugin/AI/getgpt3completions.dart'; +import 'package:example/plugin/AI/smart_edit.dart'; import 'package:flutter/material.dart'; class SimpleEditor extends StatelessWidget { @@ -73,6 +74,9 @@ class SimpleEditor extends StatelessWidget { continueToWriteMenuItem, ] ], + toolbarItems: [ + smartEditItem, + ], ); } else { return const Center( diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/continue_to_write.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/continue_to_write.dart index 5af22bd4e8..692e459b81 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/continue_to_write.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/continue_to_write.dart @@ -29,7 +29,6 @@ SelectionMenuItem continueToWriteMenuItem = SelectionMenuItem( textNode.toPlainText().length, ) .toPlainText(); - debugPrint('AI: prompt = $prompt, suffix = $suffix'); final textRobot = TextRobot(editorState: editorState); getGPT3Completion( apiKey, diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/getgpt3completions.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/getgpt3completions.dart index c7b8c094fc..553c4fc31c 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/getgpt3completions.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/getgpt3completions.dart @@ -14,13 +14,14 @@ Future getGPT3Completion( { int maxTokens = 200, double temperature = .3, + bool stream = true, }) async { final data = { 'prompt': prompt, 'suffix': suffix, 'max_tokens': maxTokens, 'temperature': temperature, - 'stream': true, // set stream parameter to true + 'stream': stream, // set stream parameter to true }; final headers = { @@ -70,3 +71,41 @@ Future getGPT3Completion( } } } + +Future getGPT3Edit( + String apiKey, + String input, + String instruction, { + required Future Function(List result) onResult, + required Future Function() onError, + int n = 1, + double temperature = .3, +}) async { + final data = { + 'model': 'text-davinci-edit-001', + 'input': input, + 'instruction': instruction, + 'temperature': temperature, + 'n': n, + }; + + final headers = { + 'Authorization': apiKey, + 'Content-Type': 'application/json', + }; + + var response = await http.post( + Uri.parse('https://api.openai.com/v1/edits'), + headers: headers, + body: json.encode(data), + ); + if (response.statusCode == 200) { + final result = json.decode(response.body); + final choices = result['choices']; + if (choices != null && choices is List) { + onResult(choices.map((e) => e['text'] as String).toList()); + } + } else { + onError(); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/smart_edit.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/smart_edit.dart new file mode 100644 index 0000000000..dc299928c4 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/AI/smart_edit.dart @@ -0,0 +1,198 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:example/plugin/AI/getgpt3completions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +ToolbarItem smartEditItem = ToolbarItem( + id: 'appflowy.toolbar.smart_edit', + type: 5, + iconBuilder: (isHighlight) { + return Icon( + Icons.edit, + color: isHighlight ? Colors.lightBlue : Colors.white, + size: 14, + ); + }, + validator: (editorState) { + final nodes = editorState.service.selectionService.currentSelectedNodes; + return nodes.whereType().length == nodes.length && + 1 == nodes.length; + }, + highlightCallback: (_) => false, + tooltipsMessage: 'Smart Edit', + handler: (editorState, context) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + content: SmartEditWidget( + editorState: editorState, + ), + ); + }, + ); + }, +); + +class SmartEditWidget extends StatefulWidget { + const SmartEditWidget({ + super.key, + required this.editorState, + }); + + final EditorState editorState; + + @override + State createState() => _SmartEditWidgetState(); +} + +class _SmartEditWidgetState extends State { + final inputEventController = TextEditingController(text: ''); + final resultController = TextEditingController(text: ''); + + var result = ''; + + Iterable get currentSelectedTextNodes => + widget.editorState.service.selectionService.currentSelectedNodes + .whereType(); + Selection? get currentSelection => + widget.editorState.service.selectionService.currentSelection.value; + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + RawKeyboardListener( + focusNode: FocusNode(), + child: TextField( + autofocus: true, + controller: inputEventController, + maxLines: null, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Describe how you\'d like AppFlowy to edit this text', + ), + ), + onKey: (key) { + if (key is! RawKeyDownEvent) return; + if (key.logicalKey == LogicalKeyboardKey.enter) { + _requestGPT3EditResult(); + } else if (key.logicalKey == LogicalKeyboardKey.escape) { + Navigator.of(context).pop(); + } + }, + ), + if (result.isNotEmpty) ...[ + const SizedBox(height: 20), + const Text( + 'Result: ', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 10), + SizedBox( + height: 300, + child: TextField( + controller: resultController..text = result, + maxLines: null, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: + 'Describe how you\'d like AppFlowy to edit this text', + ), + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + + // replace the text + final selection = currentSelection; + if (selection != null) { + assert(selection.isSingle); + final transaction = widget.editorState.transaction; + transaction.replaceText( + currentSelectedTextNodes.first, + selection.startIndex, + selection.length, + resultController.text, + ); + widget.editorState.apply(transaction); + } + }, + child: const Text('Replace'), + ), + ], + ), + ] + ], + ), + ); + } + + void _requestGPT3EditResult() { + final selection = + widget.editorState.service.selectionService.currentSelection.value; + if (selection == null || !selection.isSingle) { + return; + } + final text = + widget.editorState.service.selectionService.currentSelectedNodes + .whereType() + .first + .delta + .slice( + selection.startIndex, + selection.endIndex, + ) + .toPlainText(); + if (text.isEmpty) { + Navigator.of(context).pop(); + return; + } + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + CircularProgressIndicator(), + SizedBox(height: 10), + Text('Loading'), + ], + ), + ); + }, + ); + + getGPT3Edit( + apiKey, + text, + inputEventController.text, + onResult: (result) async { + Navigator.of(context).pop(true); + setState(() { + this.result = result.join('\n').trim(); + }); + }, + onError: () async { + Navigator.of(context).pop(true); + }, + ); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart index 710200e589..eab2d43c60 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart @@ -43,3 +43,4 @@ export 'src/plugins/markdown/decoder/delta_markdown_decoder.dart'; export 'src/plugins/markdown/document_markdown.dart'; export 'src/plugins/quill_delta/delta_document_encoder.dart'; export 'src/commands/text/text_commands.dart'; +export 'src/render/toolbar/toolbar_item.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart index 7b756e30c2..00613fa16b 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart @@ -3,6 +3,7 @@ import 'package:appflowy_editor/src/core/document/node.dart'; import 'package:appflowy_editor/src/infra/log.dart'; import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; import 'package:appflowy_editor/src/render/style/editor_style.dart'; +import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; import 'package:appflowy_editor/src/service/service.dart'; import 'package:flutter/material.dart'; @@ -60,6 +61,9 @@ class EditorState { /// Stores the selection menu items. List selectionMenuItems = []; + /// Stores the toolbar items. + List toolbarItems = []; + /// Operation stream. Stream get transactionStream => _observer.stream; final StreamController _observer = StreamController.broadcast(); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart index 9d58eb066c..7f9bbe43b9 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart @@ -1,6 +1,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/flutter/overlay.dart'; import 'package:appflowy_editor/src/render/image/image_node_builder.dart'; +import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart'; import 'package:flutter/material.dart' hide Overlay, OverlayEntry; @@ -30,6 +31,7 @@ class AppFlowyEditor extends StatefulWidget { this.customBuilders = const {}, this.shortcutEvents = const [], this.selectionMenuItems = const [], + this.toolbarItems = const [], this.editable = true, this.autoFocus = false, ThemeData? themeData, @@ -51,6 +53,8 @@ class AppFlowyEditor extends StatefulWidget { final List selectionMenuItems; + final List toolbarItems; + late final ThemeData themeData; final bool editable; @@ -74,6 +78,7 @@ class _AppFlowyEditorState extends State { super.initState(); editorState.selectionMenuItems = widget.selectionMenuItems; + editorState.toolbarItems = widget.toolbarItems; editorState.themeData = widget.themeData; editorState.service.renderPluginService = _createRenderPlugin(); editorState.editable = widget.editable; @@ -94,6 +99,7 @@ class _AppFlowyEditorState extends State { if (editorState.service != oldWidget.editorState.service) { editorState.selectionMenuItems = widget.selectionMenuItems; + editorState.toolbarItems = widget.toolbarItems; editorState.service.renderPluginService = _createRenderPlugin(); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart index 9b9d002cd1..d17f639340 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart @@ -35,11 +35,20 @@ class _FlowyToolbarState extends State implements AppFlowyToolbarService { OverlayEntry? _toolbarOverlay; final _toolbarWidgetKey = GlobalKey(debugLabel: '_toolbar_widget'); + late final List toolbarItems; + + @override + void initState() { + super.initState(); + + toolbarItems = [...defaultToolbarItems, ...widget.editorState.toolbarItems] + ..sort((a, b) => a.type.compareTo(b.type)); + } @override void showInOffset(Offset offset, Alignment alignment, LayerLink layerLink) { hide(); - final items = _filterItems(defaultToolbarItems); + final items = _filterItems(toolbarItems); if (items.isEmpty) { return; } @@ -65,7 +74,7 @@ class _FlowyToolbarState extends State @override bool triggerHandler(String id) { - final items = defaultToolbarItems.where((item) => item.id == id); + final items = toolbarItems.where((item) => item.id == id); if (items.length != 1) { assert(items.length == 1, 'The toolbar item\'s id must be unique'); return false;