mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Feature/smart edit v2 (#1880)
* feat: add edit api to openai client * feat: add translation * chore: format code * feat: add smart edit plugin * fix: close http.client when dispose * fix: insert openai result to wrong position * feat: optimize the replace text logic * test: add test for normalize and getTextInSelection function * chore: update error message
This commit is contained in:
parent
1945b0fe05
commit
085ef8f668
@ -137,7 +137,8 @@
|
||||
"esc": "ESC",
|
||||
"keep": "Keep",
|
||||
"tryAgain": "Try again",
|
||||
"discard": "Discard"
|
||||
"discard": "Discard",
|
||||
"replace": "Replace"
|
||||
},
|
||||
"label": {
|
||||
"welcome": "Welcome!",
|
||||
@ -347,7 +348,12 @@
|
||||
"autoGeneratorLearnMore": "Learn more",
|
||||
"autoGeneratorGenerate": "Generate",
|
||||
"autoGeneratorHintText": "Tell us what you want to generate by OpenAI ...",
|
||||
"autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key"
|
||||
"autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key",
|
||||
"smartEditTitleName": "Open AI: Smart Edit",
|
||||
"smartEditFixSpelling": "Fix spelling",
|
||||
"smartEditSummarize": "Summarize",
|
||||
"smartEditCouldNotFetchResult": "Could not fetch result from OpenAI",
|
||||
"smartEditCouldNotFetchKey": "Could not fetch OpenAI key"
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
|
@ -80,8 +80,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
if (userProfile.isRight()) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
loadingState:
|
||||
DocumentLoadingState.finish(right(userProfile.asRight())),
|
||||
loadingState: DocumentLoadingState.finish(
|
||||
right(userProfile.asRight()),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
@ -4,6 +4,8 @@ import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_menu_it
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
@ -142,6 +144,8 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
|
||||
kCalloutType: CalloutNodeWidgetBuilder(),
|
||||
// Auto Generator,
|
||||
kAutoCompletionInputType: AutoCompletionInputBuilder(),
|
||||
// Smart Edit,
|
||||
kSmartEditType: SmartEditInputBuilder(),
|
||||
},
|
||||
shortcutEvents: [
|
||||
// Divider
|
||||
@ -172,6 +176,9 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
|
||||
autoGeneratorMenuItem,
|
||||
]
|
||||
],
|
||||
toolbarItems: [
|
||||
smartEditItem,
|
||||
],
|
||||
themeData: theme.copyWith(extensions: [
|
||||
...theme.extensions.values,
|
||||
customEditorTheme(context),
|
||||
@ -203,6 +210,7 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
|
||||
}
|
||||
final temporaryNodeTypes = [
|
||||
kAutoCompletionInputType,
|
||||
kSmartEditType,
|
||||
];
|
||||
final iterator = NodeIterator(
|
||||
document: document,
|
||||
|
@ -1,5 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/text_edit.dart';
|
||||
|
||||
import 'text_completion.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'dart:async';
|
||||
@ -38,6 +40,18 @@ abstract class OpenAIRepository {
|
||||
int maxTokens = 50,
|
||||
double temperature = .3,
|
||||
});
|
||||
|
||||
/// Get edits from GPT-3
|
||||
///
|
||||
/// [input] is the input text
|
||||
/// [instruction] is the instruction text
|
||||
/// [temperature] is the temperature of the model
|
||||
///
|
||||
Future<Either<OpenAIError, TextEditResponse>> getEdits({
|
||||
required String input,
|
||||
required String instruction,
|
||||
double temperature = 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
class HttpOpenAIRepository implements OpenAIRepository {
|
||||
@ -70,7 +84,7 @@ class HttpOpenAIRepository implements OpenAIRepository {
|
||||
'stream': false,
|
||||
};
|
||||
|
||||
final response = await http.post(
|
||||
final response = await client.post(
|
||||
OpenAIRequestType.textCompletion.uri,
|
||||
headers: headers,
|
||||
body: json.encode(parameters),
|
||||
@ -82,4 +96,30 @@ class HttpOpenAIRepository implements OpenAIRepository {
|
||||
return Left(OpenAIError.fromJson(json.decode(response.body)['error']));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<OpenAIError, TextEditResponse>> getEdits({
|
||||
required String input,
|
||||
required String instruction,
|
||||
double temperature = 0.3,
|
||||
}) async {
|
||||
final parameters = {
|
||||
'model': 'text-davinci-edit-001',
|
||||
'input': input,
|
||||
'instruction': instruction,
|
||||
'temperature': temperature,
|
||||
};
|
||||
|
||||
final response = await client.post(
|
||||
OpenAIRequestType.textEdit.uri,
|
||||
headers: headers,
|
||||
body: json.encode(parameters),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return Right(TextEditResponse.fromJson(json.decode(response.body)));
|
||||
} else {
|
||||
return Left(OpenAIError.fromJson(json.decode(response.body)['error']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,24 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'text_edit.freezed.dart';
|
||||
part 'text_edit.g.dart';
|
||||
|
||||
@freezed
|
||||
class TextEditChoice with _$TextEditChoice {
|
||||
factory TextEditChoice({
|
||||
required String text,
|
||||
required int index,
|
||||
}) = _TextEditChoice;
|
||||
|
||||
factory TextEditChoice.fromJson(Map<String, Object?> json) =>
|
||||
_$TextEditChoiceFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class TextEditResponse with _$TextEditResponse {
|
||||
const factory TextEditResponse({
|
||||
required List<TextEditChoice> choices,
|
||||
}) = _TextEditResponse;
|
||||
|
||||
factory TextEditResponse.fromJson(Map<String, Object?> json) =>
|
||||
_$TextEditResponseFromJson(json);
|
||||
}
|
@ -167,7 +167,7 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
|
||||
text: '↵',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey,
|
||||
), // FIXME: color
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -185,7 +185,7 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
|
||||
text: LocaleKeys.button_esc.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey,
|
||||
), // FIXME: color
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -198,7 +198,6 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
|
||||
Widget _buildFooterWidget(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// FIXME: l10n
|
||||
FlowyRichTextButton(
|
||||
TextSpan(
|
||||
children: [
|
||||
|
@ -0,0 +1,36 @@
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
enum SmartEditAction {
|
||||
summarize,
|
||||
fixSpelling;
|
||||
|
||||
String get toInstruction {
|
||||
switch (this) {
|
||||
case SmartEditAction.summarize:
|
||||
return 'Summarize';
|
||||
case SmartEditAction.fixSpelling:
|
||||
return 'Fix the spelling mistakes';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SmartEditActionWrapper extends ActionCell {
|
||||
final SmartEditAction inner;
|
||||
|
||||
SmartEditActionWrapper(this.inner);
|
||||
|
||||
Widget? icon(Color iconColor) => null;
|
||||
|
||||
@override
|
||||
String get name {
|
||||
switch (inner) {
|
||||
case SmartEditAction.summarize:
|
||||
return LocaleKeys.document_plugins_smartEditSummarize.tr();
|
||||
case SmartEditAction.fixSpelling:
|
||||
return LocaleKeys.document_plugins_smartEditFixSpelling.tr();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,277 @@
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/error.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/text_edit.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:dartz/dartz.dart' as dartz;
|
||||
import 'package:appflowy/util/either_extension.dart';
|
||||
|
||||
const String kSmartEditType = 'smart_edit_input';
|
||||
const String kSmartEditInstructionType = 'smart_edit_instruction';
|
||||
const String kSmartEditInputType = 'smart_edit_input';
|
||||
|
||||
class SmartEditInputBuilder extends NodeWidgetBuilder<Node> {
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => (node) {
|
||||
return SmartEditAction.values.map((e) => e.toInstruction).contains(
|
||||
node.attributes[kSmartEditInstructionType],
|
||||
) &&
|
||||
node.attributes[kSmartEditInputType] is String;
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(NodeWidgetContext<Node> context) {
|
||||
return _SmartEditInput(
|
||||
key: context.node.key,
|
||||
node: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmartEditInput extends StatefulWidget {
|
||||
final Node node;
|
||||
|
||||
final EditorState editorState;
|
||||
const _SmartEditInput({
|
||||
Key? key,
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SmartEditInput> createState() => _SmartEditInputState();
|
||||
}
|
||||
|
||||
class _SmartEditInputState extends State<_SmartEditInput> {
|
||||
String get instruction => widget.node.attributes[kSmartEditInstructionType];
|
||||
String get input => widget.node.attributes[kSmartEditInputType];
|
||||
|
||||
final focusNode = FocusNode();
|
||||
final client = http.Client();
|
||||
dartz.Either<OpenAIError, TextEditResponse>? result;
|
||||
bool loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
widget.editorState.service.keyboardService?.disable(showCursor: true);
|
||||
focusNode.requestFocus();
|
||||
focusNode.addListener(() {
|
||||
if (!focusNode.hasFocus) {
|
||||
widget.editorState.service.keyboardService?.enable();
|
||||
}
|
||||
});
|
||||
_requestEdits().then(
|
||||
(value) => setState(() {
|
||||
result = value;
|
||||
loading = false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
client.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 5,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(10),
|
||||
child: _buildSmartEditPanel(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSmartEditPanel(BuildContext context) {
|
||||
return RawKeyboardListener(
|
||||
focusNode: focusNode,
|
||||
onKey: (RawKeyEvent event) async {
|
||||
if (event is! RawKeyDownEvent) return;
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
await _onReplace();
|
||||
await _onExit();
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
await _onExit();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeaderWidget(context),
|
||||
const Space(0, 10),
|
||||
_buildResultWidget(context),
|
||||
const Space(0, 10),
|
||||
_buildInputFooterWidget(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderWidget(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
FlowyText.medium(
|
||||
LocaleKeys.document_plugins_smartEditTitleName.tr(),
|
||||
fontSize: 14,
|
||||
),
|
||||
const Spacer(),
|
||||
FlowyText.regular(
|
||||
LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultWidget(BuildContext context) {
|
||||
final loading = SizedBox.fromSize(
|
||||
size: const Size.square(14),
|
||||
child: const CircularProgressIndicator(),
|
||||
);
|
||||
if (result == null) {
|
||||
return loading;
|
||||
}
|
||||
return result!.fold((error) {
|
||||
return Flexible(
|
||||
child: Text(
|
||||
error.message,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
);
|
||||
}, (response) {
|
||||
return Flexible(
|
||||
child: Text(
|
||||
response.choices.map((e) => e.text).join('\n'),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildInputFooterWidget(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
FlowyRichTextButton(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.button_replace.tr()} ',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
TextSpan(
|
||||
text: '↵',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
_onReplace();
|
||||
_onExit();
|
||||
},
|
||||
),
|
||||
const Space(10, 0),
|
||||
FlowyRichTextButton(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.button_Cancel.tr()} ',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.button_esc.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () async => await _onExit(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onReplace() async {
|
||||
final selection = widget.editorState.service.selectionService
|
||||
.currentSelection.value?.normalized;
|
||||
final selectedNodes = widget
|
||||
.editorState.service.selectionService.currentSelectedNodes.normalized
|
||||
.whereType<TextNode>();
|
||||
if (selection == null || result == null || result!.isLeft()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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),
|
||||
selection,
|
||||
texts,
|
||||
);
|
||||
return widget.editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> _onExit() async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.deleteNode(widget.node);
|
||||
return widget.editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(
|
||||
recordRedo: false,
|
||||
recordUndo: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<dartz.Either<OpenAIError, TextEditResponse>> _requestEdits() async {
|
||||
final result = await UserBackendService.getCurrentUserProfile();
|
||||
return result.fold((userProfile) async {
|
||||
final openAIRepository = HttpOpenAIRepository(
|
||||
client: client,
|
||||
apiKey: userProfile.openaiKey,
|
||||
);
|
||||
final edits = await openAIRepository.getEdits(
|
||||
input: input,
|
||||
instruction: instruction,
|
||||
);
|
||||
return edits.fold((error) async {
|
||||
return dartz.Left(
|
||||
OpenAIError(
|
||||
message:
|
||||
LocaleKeys.document_plugins_smartEditCouldNotFetchResult.tr(),
|
||||
),
|
||||
);
|
||||
}, (textEdit) async {
|
||||
return dartz.Right(textEdit);
|
||||
});
|
||||
}, (error) async {
|
||||
// error
|
||||
return dartz.Left(
|
||||
OpenAIError(
|
||||
message: LocaleKeys.document_plugins_smartEditCouldNotFetchKey.tr(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
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/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:flutter/material.dart';
|
||||
|
||||
ToolbarItem smartEditItem = ToolbarItem(
|
||||
id: 'appflowy.toolbar.smart_edit',
|
||||
type: 0, // headmost
|
||||
validator: (editorState) {
|
||||
// All selected nodes must be text.
|
||||
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||
return nodes.whereType<TextNode>().length == nodes.length;
|
||||
},
|
||||
itemBuilder: (context, editorState) {
|
||||
return _SmartEditWidget(
|
||||
editorState: editorState,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
class _SmartEditWidget extends StatefulWidget {
|
||||
const _SmartEditWidget({
|
||||
required this.editorState,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<_SmartEditWidget> createState() => _SmartEditWidgetState();
|
||||
}
|
||||
|
||||
class _SmartEditWidgetState extends State<_SmartEditWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopoverActionList<SmartEditActionWrapper>(
|
||||
direction: PopoverDirection.bottomWithLeftAligned,
|
||||
actions: SmartEditAction.values
|
||||
.map((action) => SmartEditActionWrapper(action))
|
||||
.toList(),
|
||||
buildChild: (controller) {
|
||||
return FlowyIconButton(
|
||||
tooltipText: 'Smart Edit',
|
||||
preferBelow: false,
|
||||
icon: const Icon(
|
||||
Icons.edit,
|
||||
size: 14,
|
||||
),
|
||||
onPressed: () {
|
||||
controller.show();
|
||||
},
|
||||
);
|
||||
},
|
||||
onSelected: (action, controller) {
|
||||
controller.close();
|
||||
final selection =
|
||||
widget.editorState.service.selectionService.currentSelection.value;
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
final textNodes = widget
|
||||
.editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>()
|
||||
.toList(growable: false);
|
||||
final input = widget.editorState.getTextInSelection(
|
||||
textNodes.normalized,
|
||||
selection.normalized,
|
||||
);
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.insertNode(
|
||||
selection.normalized.end.path.next,
|
||||
Node(
|
||||
type: kSmartEditType,
|
||||
attributes: {
|
||||
kSmartEditInstructionType: action.inner.toInstruction,
|
||||
kSmartEditInputType: input,
|
||||
},
|
||||
),
|
||||
);
|
||||
widget.editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(
|
||||
recordUndo: false,
|
||||
recordRedo: false,
|
||||
),
|
||||
withUpdateCursor: false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -121,7 +121,6 @@ class _OpenaiKeyInputState extends State<_OpenaiKeyInput> {
|
||||
),
|
||||
),
|
||||
onSubmitted: (val) {
|
||||
// TODO: validate key
|
||||
context
|
||||
.read<SettingsUserViewBloc>()
|
||||
.add(SettingsUserEvent.updateUserOpenAIKey(val));
|
||||
|
@ -43,6 +43,7 @@ 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/commands/command_extension.dart';
|
||||
export 'src/render/toolbar/toolbar_item.dart';
|
||||
export 'src/extensions/node_extensions.dart';
|
||||
export 'src/render/action_menu/action_menu.dart';
|
||||
|
@ -51,4 +51,23 @@ extension CommandExtension on EditorState {
|
||||
}
|
||||
throw Exception('path and textNode cannot be null at the same time');
|
||||
}
|
||||
|
||||
String getTextInSelection(
|
||||
List<TextNode> textNodes,
|
||||
Selection selection,
|
||||
) {
|
||||
List<String> res = [];
|
||||
if (!selection.isCollapsed) {
|
||||
for (var i = 0; i < textNodes.length; i++) {
|
||||
if (i == 0) {
|
||||
res.add(textNodes[i].toPlainText().substring(selection.startIndex));
|
||||
} else if (i == textNodes.length - 1) {
|
||||
res.add(textNodes[i].toPlainText().substring(0, selection.endIndex));
|
||||
} else {
|
||||
res.add(textNodes[i].toPlainText());
|
||||
}
|
||||
}
|
||||
}
|
||||
return res.join('\n');
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/commands/command_extension.dart';
|
||||
|
||||
extension TextCommands on EditorState {
|
||||
/// Insert text at the given index of the given [TextNode] or the [Path].
|
||||
|
@ -266,6 +266,9 @@ extension TextTransaction on Transaction {
|
||||
textNode.delta.slice(max(index - 1, 0), index).first.attributes;
|
||||
if (newAttributes != null) {
|
||||
newAttributes = {...newAttributes}; // make a copy
|
||||
} else {
|
||||
newAttributes =
|
||||
textNode.delta.slice(index, index + length).first.attributes;
|
||||
}
|
||||
}
|
||||
updateText(
|
||||
@ -282,4 +285,52 @@ extension TextTransaction on Transaction {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void replaceTexts(
|
||||
List<TextNode> textNodes,
|
||||
Selection selection,
|
||||
List<String> texts,
|
||||
) {
|
||||
if (textNodes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection.isSingle) {
|
||||
assert(textNodes.length == 1 && texts.length == 1);
|
||||
replaceText(
|
||||
textNodes.first,
|
||||
selection.startIndex,
|
||||
selection.length,
|
||||
texts.first,
|
||||
);
|
||||
} else {
|
||||
final length = textNodes.length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
final textNode = textNodes[i];
|
||||
final text = texts[i];
|
||||
if (i == 0) {
|
||||
replaceText(
|
||||
textNode,
|
||||
selection.startIndex,
|
||||
textNode.toPlainText().length,
|
||||
text,
|
||||
);
|
||||
} else if (i == length - 1) {
|
||||
replaceText(
|
||||
textNode,
|
||||
0,
|
||||
selection.endIndex,
|
||||
text,
|
||||
);
|
||||
} else {
|
||||
replaceText(
|
||||
textNode,
|
||||
0,
|
||||
textNode.toPlainText().length,
|
||||
text,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -37,3 +37,17 @@ extension NodeExtensions on Node {
|
||||
currentSelectedNodes.first == this;
|
||||
}
|
||||
}
|
||||
|
||||
extension NodesExtensions<T extends Node> on List<T> {
|
||||
List<T> get normalized {
|
||||
if (isEmpty) {
|
||||
return this;
|
||||
}
|
||||
|
||||
if (first.path > last.path) {
|
||||
return reversed.toList();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
@ -20,20 +20,31 @@ class ToolbarItem {
|
||||
ToolbarItem({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.iconBuilder,
|
||||
this.tooltipsMessage = '',
|
||||
this.iconBuilder,
|
||||
required this.validator,
|
||||
required this.highlightCallback,
|
||||
required this.handler,
|
||||
});
|
||||
this.highlightCallback,
|
||||
this.handler,
|
||||
this.itemBuilder,
|
||||
}) {
|
||||
assert(
|
||||
(iconBuilder != null && itemBuilder == null) ||
|
||||
(iconBuilder == null && itemBuilder != null),
|
||||
'iconBuilder and itemBuilder must be set one of them',
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final int type;
|
||||
final Widget Function(bool isHighlight) iconBuilder;
|
||||
final String tooltipsMessage;
|
||||
final ToolbarItemValidator validator;
|
||||
final ToolbarItemEventHandler handler;
|
||||
final ToolbarItemHighlightCallback highlightCallback;
|
||||
|
||||
final Widget Function(bool isHighlight)? iconBuilder;
|
||||
final ToolbarItemEventHandler? handler;
|
||||
final ToolbarItemHighlightCallback? highlightCallback;
|
||||
|
||||
final Widget Function(BuildContext context, EditorState editorState)?
|
||||
itemBuilder;
|
||||
|
||||
factory ToolbarItem.divider() {
|
||||
return ToolbarItem(
|
||||
|
@ -16,24 +16,27 @@ class ToolbarItemWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: Tooltip(
|
||||
preferBelow: false,
|
||||
message: item.tooltipsMessage,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: IconButton(
|
||||
hoverColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
padding: EdgeInsets.zero,
|
||||
icon: item.iconBuilder(isHighlight),
|
||||
iconSize: 28,
|
||||
onPressed: onPressed,
|
||||
if (item.iconBuilder != null) {
|
||||
return SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: Tooltip(
|
||||
preferBelow: false,
|
||||
message: item.tooltipsMessage,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: IconButton(
|
||||
hoverColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
padding: EdgeInsets.zero,
|
||||
icon: item.iconBuilder!(isHighlight),
|
||||
iconSize: 28,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
@ -66,14 +66,19 @@ class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
|
||||
children: widget.items
|
||||
.map(
|
||||
(item) => Center(
|
||||
child: ToolbarItemWidget(
|
||||
item: item,
|
||||
isHighlight: item.highlightCallback(widget.editorState),
|
||||
onPressed: () {
|
||||
item.handler(widget.editorState, context);
|
||||
widget.editorState.service.keyboardService?.enable();
|
||||
},
|
||||
),
|
||||
child:
|
||||
item.itemBuilder?.call(context, widget.editorState) ??
|
||||
ToolbarItemWidget(
|
||||
item: item,
|
||||
isHighlight: item.highlightCallback
|
||||
?.call(widget.editorState) ??
|
||||
false,
|
||||
onPressed: () {
|
||||
item.handler?.call(widget.editorState, context);
|
||||
widget.editorState.service.keyboardService
|
||||
?.enable();
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
|
@ -78,7 +78,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
|
||||
assert(items.length == 1, 'The toolbar item\'s id must be unique');
|
||||
return false;
|
||||
}
|
||||
items.first.handler(widget.editorState, context);
|
||||
items.first.handler?.call(widget.editorState, context);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,36 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import '../infra/test_editor.dart';
|
||||
|
||||
void main() {
|
||||
group('command_extension.dart', () {
|
||||
testWidgets('insert a new checkbox after an exsiting checkbox',
|
||||
(tester) async {
|
||||
final editor = tester.editor
|
||||
..insertTextNode(
|
||||
'Welcome',
|
||||
)
|
||||
..insertTextNode(
|
||||
'to',
|
||||
)
|
||||
..insertTextNode(
|
||||
'Appflowy 😁',
|
||||
);
|
||||
await editor.startTesting();
|
||||
final selection = Selection(
|
||||
start: Position(path: [2], offset: 5),
|
||||
end: Position(path: [0], offset: 5),
|
||||
);
|
||||
await editor.updateSelection(selection);
|
||||
final textNodes = editor
|
||||
.editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>()
|
||||
.toList(growable: false);
|
||||
final text = editor.editorState.getTextInSelection(
|
||||
textNodes.normalized,
|
||||
selection.normalized,
|
||||
);
|
||||
expect(text, 'me\nto\nAppfl');
|
||||
});
|
||||
});
|
||||
}
|
@ -2,12 +2,13 @@ import 'dart:collection';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import '../infra/test_editor.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
class MockNode extends Mock implements Node {}
|
||||
|
||||
void main() {
|
||||
group('NodeExtensions::', () {
|
||||
group('node_extension.dart', () {
|
||||
final selection = Selection(
|
||||
start: Position(path: [0]),
|
||||
end: Position(path: [1]),
|
||||
@ -43,5 +44,36 @@ void main() {
|
||||
final result = node.inSelection(selection);
|
||||
expect(result, false);
|
||||
});
|
||||
|
||||
testWidgets('insert a new checkbox after an exsiting checkbox',
|
||||
(tester) async {
|
||||
const text = 'Welcome to Appflowy 😁';
|
||||
final editor = tester.editor
|
||||
..insertTextNode(
|
||||
text,
|
||||
)
|
||||
..insertTextNode(
|
||||
text,
|
||||
)
|
||||
..insertTextNode(
|
||||
text,
|
||||
);
|
||||
await editor.startTesting();
|
||||
final selection = Selection(
|
||||
start: Position(path: [2], offset: 5),
|
||||
end: Position(path: [0], offset: 5),
|
||||
);
|
||||
await editor.updateSelection(selection);
|
||||
final nodes =
|
||||
editor.editorState.service.selectionService.currentSelectedNodes;
|
||||
expect(
|
||||
nodes.map((e) => e.path).toList().toString(),
|
||||
'[[2], [1], [0]]',
|
||||
);
|
||||
expect(
|
||||
nodes.normalized.map((e) => e.path).toList().toString(),
|
||||
'[[0], [1], [2]]',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ class FlowyIconButton extends StatelessWidget {
|
||||
final EdgeInsets iconPadding;
|
||||
final BorderRadius? radius;
|
||||
final String? tooltipText;
|
||||
final bool preferBelow;
|
||||
|
||||
const FlowyIconButton({
|
||||
Key? key,
|
||||
@ -25,6 +26,7 @@ class FlowyIconButton extends StatelessWidget {
|
||||
this.iconPadding = EdgeInsets.zero,
|
||||
this.radius,
|
||||
this.tooltipText,
|
||||
this.preferBelow = true,
|
||||
required this.icon,
|
||||
}) : super(key: key);
|
||||
|
||||
@ -44,6 +46,7 @@ class FlowyIconButton extends StatelessWidget {
|
||||
constraints:
|
||||
BoxConstraints.tightFor(width: size.width, height: size.height),
|
||||
child: Tooltip(
|
||||
preferBelow: preferBelow,
|
||||
message: tooltipText ?? '',
|
||||
showDuration: Duration.zero,
|
||||
child: RawMaterialButton(
|
||||
|
Loading…
Reference in New Issue
Block a user