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:
Lucas.Xu
2023-02-28 14:34:13 +08:00
committed by GitHub
parent 1945b0fe05
commit 085ef8f668
22 changed files with 701 additions and 44 deletions

View File

@ -137,7 +137,8 @@
"esc": "ESC", "esc": "ESC",
"keep": "Keep", "keep": "Keep",
"tryAgain": "Try again", "tryAgain": "Try again",
"discard": "Discard" "discard": "Discard",
"replace": "Replace"
}, },
"label": { "label": {
"welcome": "Welcome!", "welcome": "Welcome!",
@ -347,7 +348,12 @@
"autoGeneratorLearnMore": "Learn more", "autoGeneratorLearnMore": "Learn more",
"autoGeneratorGenerate": "Generate", "autoGeneratorGenerate": "Generate",
"autoGeneratorHintText": "Tell us what you want to generate by OpenAI ...", "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": { "board": {

View File

@ -80,8 +80,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
if (userProfile.isRight()) { if (userProfile.isRight()) {
emit( emit(
state.copyWith( state.copyWith(
loadingState: loadingState: DocumentLoadingState.finish(
DocumentLoadingState.finish(right(userProfile.asRight())), right(userProfile.asRight()),
),
), ),
); );
return; return;

View File

@ -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/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_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/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_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
@ -142,6 +144,8 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
kCalloutType: CalloutNodeWidgetBuilder(), kCalloutType: CalloutNodeWidgetBuilder(),
// Auto Generator, // Auto Generator,
kAutoCompletionInputType: AutoCompletionInputBuilder(), kAutoCompletionInputType: AutoCompletionInputBuilder(),
// Smart Edit,
kSmartEditType: SmartEditInputBuilder(),
}, },
shortcutEvents: [ shortcutEvents: [
// Divider // Divider
@ -172,6 +176,9 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
autoGeneratorMenuItem, autoGeneratorMenuItem,
] ]
], ],
toolbarItems: [
smartEditItem,
],
themeData: theme.copyWith(extensions: [ themeData: theme.copyWith(extensions: [
...theme.extensions.values, ...theme.extensions.values,
customEditorTheme(context), customEditorTheme(context),
@ -203,6 +210,7 @@ class _AppFlowyEditorPageState extends State<_AppFlowyEditorPage> {
} }
final temporaryNodeTypes = [ final temporaryNodeTypes = [
kAutoCompletionInputType, kAutoCompletionInputType,
kSmartEditType,
]; ];
final iterator = NodeIterator( final iterator = NodeIterator(
document: document, document: document,

View File

@ -1,5 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/text_edit.dart';
import 'text_completion.dart'; import 'text_completion.dart';
import 'package:dartz/dartz.dart'; import 'package:dartz/dartz.dart';
import 'dart:async'; import 'dart:async';
@ -38,6 +40,18 @@ abstract class OpenAIRepository {
int maxTokens = 50, int maxTokens = 50,
double temperature = .3, 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 { class HttpOpenAIRepository implements OpenAIRepository {
@ -70,7 +84,7 @@ class HttpOpenAIRepository implements OpenAIRepository {
'stream': false, 'stream': false,
}; };
final response = await http.post( final response = await client.post(
OpenAIRequestType.textCompletion.uri, OpenAIRequestType.textCompletion.uri,
headers: headers, headers: headers,
body: json.encode(parameters), body: json.encode(parameters),
@ -82,4 +96,30 @@ class HttpOpenAIRepository implements OpenAIRepository {
return Left(OpenAIError.fromJson(json.decode(response.body)['error'])); 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']));
}
}
} }

View File

@ -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);
}

View File

@ -167,7 +167,7 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
text: '', text: '',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey, color: Colors.grey,
), // FIXME: color ),
), ),
], ],
), ),
@ -185,7 +185,7 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
text: LocaleKeys.button_esc.tr(), text: LocaleKeys.button_esc.tr(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey, color: Colors.grey,
), // FIXME: color ),
), ),
], ],
), ),
@ -198,7 +198,6 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
Widget _buildFooterWidget(BuildContext context) { Widget _buildFooterWidget(BuildContext context) {
return Row( return Row(
children: [ children: [
// FIXME: l10n
FlowyRichTextButton( FlowyRichTextButton(
TextSpan( TextSpan(
children: [ children: [

View File

@ -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();
}
}
}

View File

@ -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(),
),
);
});
}
}

View File

@ -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,
);
},
);
}
}

View File

@ -121,7 +121,6 @@ class _OpenaiKeyInputState extends State<_OpenaiKeyInput> {
), ),
), ),
onSubmitted: (val) { onSubmitted: (val) {
// TODO: validate key
context context
.read<SettingsUserViewBloc>() .read<SettingsUserViewBloc>()
.add(SettingsUserEvent.updateUserOpenAIKey(val)); .add(SettingsUserEvent.updateUserOpenAIKey(val));

View File

@ -43,6 +43,7 @@ export 'src/plugins/markdown/decoder/delta_markdown_decoder.dart';
export 'src/plugins/markdown/document_markdown.dart'; export 'src/plugins/markdown/document_markdown.dart';
export 'src/plugins/quill_delta/delta_document_encoder.dart'; export 'src/plugins/quill_delta/delta_document_encoder.dart';
export 'src/commands/text/text_commands.dart'; export 'src/commands/text/text_commands.dart';
export 'src/commands/command_extension.dart';
export 'src/render/toolbar/toolbar_item.dart'; export 'src/render/toolbar/toolbar_item.dart';
export 'src/extensions/node_extensions.dart'; export 'src/extensions/node_extensions.dart';
export 'src/render/action_menu/action_menu.dart'; export 'src/render/action_menu/action_menu.dart';

View File

@ -51,4 +51,23 @@ extension CommandExtension on EditorState {
} }
throw Exception('path and textNode cannot be null at the same time'); 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');
}
} }

View File

@ -1,5 +1,4 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/commands/command_extension.dart';
extension TextCommands on EditorState { extension TextCommands on EditorState {
/// Insert text at the given index of the given [TextNode] or the [Path]. /// Insert text at the given index of the given [TextNode] or the [Path].

View File

@ -266,6 +266,9 @@ extension TextTransaction on Transaction {
textNode.delta.slice(max(index - 1, 0), index).first.attributes; textNode.delta.slice(max(index - 1, 0), index).first.attributes;
if (newAttributes != null) { if (newAttributes != null) {
newAttributes = {...newAttributes}; // make a copy newAttributes = {...newAttributes}; // make a copy
} else {
newAttributes =
textNode.delta.slice(index, index + length).first.attributes;
} }
} }
updateText( 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,
);
}
}
}
}
} }

View File

@ -37,3 +37,17 @@ extension NodeExtensions on Node {
currentSelectedNodes.first == this; 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;
}
}

View File

@ -20,20 +20,31 @@ class ToolbarItem {
ToolbarItem({ ToolbarItem({
required this.id, required this.id,
required this.type, required this.type,
required this.iconBuilder,
this.tooltipsMessage = '', this.tooltipsMessage = '',
this.iconBuilder,
required this.validator, required this.validator,
required this.highlightCallback, this.highlightCallback,
required this.handler, 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 String id;
final int type; final int type;
final Widget Function(bool isHighlight) iconBuilder;
final String tooltipsMessage; final String tooltipsMessage;
final ToolbarItemValidator validator; 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() { factory ToolbarItem.divider() {
return ToolbarItem( return ToolbarItem(

View File

@ -16,24 +16,27 @@ class ToolbarItemWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( if (item.iconBuilder != null) {
width: 28, return SizedBox(
height: 28, width: 28,
child: Tooltip( height: 28,
preferBelow: false, child: Tooltip(
message: item.tooltipsMessage, preferBelow: false,
child: MouseRegion( message: item.tooltipsMessage,
cursor: SystemMouseCursors.click, child: MouseRegion(
child: IconButton( cursor: SystemMouseCursors.click,
hoverColor: Colors.transparent, child: IconButton(
highlightColor: Colors.transparent, hoverColor: Colors.transparent,
padding: EdgeInsets.zero, highlightColor: Colors.transparent,
icon: item.iconBuilder(isHighlight), padding: EdgeInsets.zero,
iconSize: 28, icon: item.iconBuilder!(isHighlight),
onPressed: onPressed, iconSize: 28,
onPressed: onPressed,
),
), ),
), ),
), );
); }
return const SizedBox.shrink();
} }
} }

View File

@ -66,14 +66,19 @@ class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
children: widget.items children: widget.items
.map( .map(
(item) => Center( (item) => Center(
child: ToolbarItemWidget( child:
item: item, item.itemBuilder?.call(context, widget.editorState) ??
isHighlight: item.highlightCallback(widget.editorState), ToolbarItemWidget(
onPressed: () { item: item,
item.handler(widget.editorState, context); isHighlight: item.highlightCallback
widget.editorState.service.keyboardService?.enable(); ?.call(widget.editorState) ??
}, false,
), onPressed: () {
item.handler?.call(widget.editorState, context);
widget.editorState.service.keyboardService
?.enable();
},
),
), ),
) )
.toList(growable: false), .toList(growable: false),

View File

@ -78,7 +78,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
assert(items.length == 1, 'The toolbar item\'s id must be unique'); assert(items.length == 1, 'The toolbar item\'s id must be unique');
return false; return false;
} }
items.first.handler(widget.editorState, context); items.first.handler?.call(widget.editorState, context);
return true; return true;
} }

View File

@ -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');
});
});
}

View File

@ -2,12 +2,13 @@ import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../infra/test_editor.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
class MockNode extends Mock implements Node {} class MockNode extends Mock implements Node {}
void main() { void main() {
group('NodeExtensions::', () { group('node_extension.dart', () {
final selection = Selection( final selection = Selection(
start: Position(path: [0]), start: Position(path: [0]),
end: Position(path: [1]), end: Position(path: [1]),
@ -43,5 +44,36 @@ void main() {
final result = node.inSelection(selection); final result = node.inSelection(selection);
expect(result, false); 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]]',
);
});
}); });
} }

View File

@ -14,6 +14,7 @@ class FlowyIconButton extends StatelessWidget {
final EdgeInsets iconPadding; final EdgeInsets iconPadding;
final BorderRadius? radius; final BorderRadius? radius;
final String? tooltipText; final String? tooltipText;
final bool preferBelow;
const FlowyIconButton({ const FlowyIconButton({
Key? key, Key? key,
@ -25,6 +26,7 @@ class FlowyIconButton extends StatelessWidget {
this.iconPadding = EdgeInsets.zero, this.iconPadding = EdgeInsets.zero,
this.radius, this.radius,
this.tooltipText, this.tooltipText,
this.preferBelow = true,
required this.icon, required this.icon,
}) : super(key: key); }) : super(key: key);
@ -44,6 +46,7 @@ class FlowyIconButton extends StatelessWidget {
constraints: constraints:
BoxConstraints.tightFor(width: size.width, height: size.height), BoxConstraints.tightFor(width: size.width, height: size.height),
child: Tooltip( child: Tooltip(
preferBelow: preferBelow,
message: tooltipText ?? '', message: tooltipText ?? '',
showDuration: Duration.zero, showDuration: Duration.zero,
child: RawMaterialButton( child: RawMaterialButton(