feat: support auto complete for text block (#764)

* feat: support auto complete for text block

* feat: support auto complete for text block

* test: add auto complete test

* feat: support tab to auto complete

* chore: optimize the code

* chore: update token
This commit is contained in:
Lucas.Xu 2024-04-11 10:48:12 +08:00 • committed by GitHub
commit 61d892a09c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 399 additions and 19 deletions

View file

@ -23,7 +23,7 @@ jobs:
desktop:
strategy:
matrix:
os: [macos-13, macos-12, macos-11, ubuntu-22.04, ubuntu-20.04, windows-2022, windows-2019]
os: [macos-14, macos-13, macos-12, macos-11, ubuntu-22.04, ubuntu-20.04, windows-2022, windows-2019]
runs-on: ${{ matrix.os }}
@ -52,6 +52,6 @@ jobs:
fail_ci_if_error: true
verbose: true
os: ${{ matrix.os }}
token: ${{ secrets.CODECOV_TOKEN }}
token: 398c9ded-28ad-4750-bfdb-e7f502539137
attempt_limit: 10
attempt_delay: 10000

View file

@ -4,6 +4,7 @@ import 'dart:io';
import 'dart:math';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:example/pages/auto_complete_editor.dart';
import 'package:example/pages/collab_editor.dart';
import 'package:example/pages/collab_selection_editor.dart';
import 'package:example/pages/customize_theme_for_editor.dart';
@ -161,6 +162,14 @@ class _HomePageState extends State<HomePage> {
// Theme Demo
_buildSeparator(context, 'Showcases'),
_buildListTile(context, 'Auto complete Editor', () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AutoCompleteEditor(),
),
);
}),
_buildListTile(context, 'Collab Editor', () {
Navigator.push(
context,

View file

@ -0,0 +1,70 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
final _options = {
'hello ': 'world',
'support@g': 'mail.com',
'appflowy ': 'editor',
};
class AutoCompleteEditor extends StatelessWidget {
const AutoCompleteEditor({
super.key,
});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: SafeArea(
child: Column(
children: [
const SizedBox(height: 100),
Container(
height: 500,
decoration: BoxDecoration(
border: Border.all(color: Colors.blue),
),
child: AppFlowyEditor(
editorState: EditorState(
document: Document.blank(withInitialText: true),
),
commandShortcutEvents: [
tabToAutoCompleteCommand,
...standardCommandShortcutEvents,
],
enableAutoComplete: true,
autoCompleteTextProvider: (context, node, textSpan) {
final editorState = context.read<EditorState>();
final selection = editorState.selection;
final delta = node.delta;
if (selection == null ||
delta == null ||
!selection.isCollapsed ||
selection.endIndex != delta.length ||
!node.path.equals(selection.start.path)) {
return null;
}
final text = delta.toPlainText().toLowerCase();
for (final option in _options.keys) {
if (text.endsWith(option)) {
return _options[option];
}
}
return null;
},
),
),
const SizedBox(height: 16),
Text(
'AutoComplete Options: $_options',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
),
),
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
class DesktopEditor extends StatefulWidget {
const DesktopEditor({
@ -84,6 +85,8 @@ class _DesktopEditorState extends State<DesktopEditor> {
blockComponentBuilders: blockComponentBuilders,
commandShortcutEvents: commandShortcuts,
editorStyle: editorStyle,
enableAutoComplete: true,
autoCompleteTextProvider: _buildAutoCompleteTextProvider,
header: Padding(
padding: const EdgeInsets.only(bottom: 10.0),
child: Image.asset(
@ -188,4 +191,27 @@ class _DesktopEditorState extends State<DesktopEditor> {
),
];
}
String? _buildAutoCompleteTextProvider(
BuildContext context,
Node node,
TextSpan? textSpan,
) {
final editorState = context.read<EditorState>();
final selection = editorState.selection;
final delta = node.delta;
if (selection == null ||
delta == null ||
!selection.isCollapsed ||
selection.endIndex != delta.length ||
!node.path.equals(selection.start.path)) {
return null;
}
final text = delta.toPlainText();
// An example, if the text ends with 'hello', then show the autocomplete.
if (text.endsWith('hello')) {
return ' world';
}
return null;
}
}

View file

@ -0,0 +1,65 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
// Add your custom block keys if it supports auto complete
final autoCompletableBlockTypes = {
ParagraphBlockKeys.type,
NumberedListBlockKeys.type,
TodoListBlockKeys.type,
BulletedListBlockKeys.type,
QuoteBlockKeys.type,
HeadingBlockKeys.type,
};
/// Auto complete the current block
///
/// - support
/// - desktop
/// - web
///
final CommandShortcutEvent tabToAutoCompleteCommand = CommandShortcutEvent(
key: 'tab to auto complete',
getDescription: () => 'Tab to auto complete',
command: 'tab',
handler: _tabToAutoCompleteCommandHandler,
);
CommandShortcutEventHandler _tabToAutoCompleteCommandHandler = (editorState) {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return KeyEventResult.ignored;
}
final context = editorState.document.root.context;
final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
// Now, this command only support auto complete the text if the cursor is at the end of the block
if (context == null ||
node == null ||
!autoCompletableBlockTypes.contains(node.type) ||
delta == null ||
selection.endIndex != delta.length) {
return KeyEventResult.ignored;
}
// Support async auto complete text provider in the future
final autoCompleteText = editorState.autoCompleteTextProvider?.call(
context,
node,
null,
);
if (autoCompleteText == null || autoCompleteText.isEmpty) {
return KeyEventResult.ignored;
}
final transaction = editorState.transaction
..insertText(
node,
selection.endIndex,
autoCompleteText,
);
editorState.apply(transaction);
return KeyEventResult.handled;
};

View file

@ -20,6 +20,9 @@ class TextStyleConfiguration {
color: Colors.red,
backgroundColor: Color.fromARGB(98, 0, 195, 255),
),
this.autoComplete = const TextStyle(
color: Colors.grey,
),
});
/// default text style
@ -43,6 +46,9 @@ class TextStyleConfiguration {
/// code text style
final TextStyle code;
/// auto complete text style
final TextStyle autoComplete;
TextStyleConfiguration copyWith({
TextStyle? text,
TextStyle? bold,
@ -51,6 +57,7 @@ class TextStyleConfiguration {
TextStyle? strikethrough,
TextStyle? href,
TextStyle? code,
TextStyle? autoComplete,
}) {
return TextStyleConfiguration(
text: text ?? this.text,
@ -60,6 +67,7 @@ class TextStyleConfiguration {
strikethrough: strikethrough ?? this.strikethrough,
href: href ?? this.href,
code: code ?? this.code,
autoComplete: autoComplete ?? this.autoComplete,
);
}
}

View file

@ -1,5 +1,6 @@
// paragraph
export 'base_component/align_mixin.dart';
export 'base_component/auto_complete_command.dart';
export 'base_component/background_color_mixin.dart';
export 'base_component/block_component_action_wrapper.dart';
export 'base_component/block_component_configuration.dart';

View file

@ -27,6 +27,11 @@ typedef TextSpanDecoratorForAttribute = InlineSpan Function(
);
typedef AppFlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
typedef AppFlowyAutoCompleteTextProvider = String? Function(
BuildContext context,
Node node,
TextSpan? textSpan,
);
class AppFlowyRichText extends StatefulWidget {
const AppFlowyRichText({
@ -42,6 +47,7 @@ class AppFlowyRichText extends StatefulWidget {
this.textAlign,
this.cursorColor = const Color.fromARGB(255, 0, 0, 0),
this.selectionColor = const Color.fromARGB(53, 111, 201, 231),
this.autoCompleteTextProvider,
required this.delegate,
required this.node,
required this.editorState,
@ -78,6 +84,9 @@ class AppFlowyRichText extends StatefulWidget {
// get the cursor rect, selection rects or block rect from the delegate
final SelectableMixin delegate;
// this span will be appended to the current text span, mostly, it is used for auto complete
final AppFlowyAutoCompleteTextProvider? autoCompleteTextProvider;
/// customize the text span for custom attributes
///
/// You can use this to customize the text span for custom attributes
@ -107,19 +116,35 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
widget.textSpanDecoratorForCustomAttributes ??
widget.editorState.editorStyle.textSpanDecorator;
AppFlowyAutoCompleteTextProvider? get autoCompleteTextProvider =>
widget.autoCompleteTextProvider ??
widget.editorState.autoCompleteTextProvider;
bool get enableAutoComplete =>
widget.editorState.enableAutoComplete && autoCompleteTextProvider != null;
@override
Widget build(BuildContext context) {
final child = MouseRegion(
cursor: SystemMouseCursors.text,
child: widget.node.delta?.toPlainText().isEmpty ?? true
? Stack(
children: [
_buildPlaceholderText(context),
_buildRichText(context),
],
)
: _buildRichText(context),
);
Widget child = _buildRichText(context);
final delta = widget.node.delta;
if (delta == null || delta.isEmpty) {
child = Stack(
children: [
_buildPlaceholderText(context),
_buildRichText(context),
],
);
}
if (enableAutoComplete) {
final autoCompleteText = _buildAutoCompleteRichText();
child = Stack(
children: [
autoCompleteText,
child,
],
);
}
return BlockSelectionContainer(
delegate: widget.delegate,
@ -128,7 +153,10 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
node: widget.node,
cursorColor: widget.cursorColor,
selectionColor: widget.selectionColor,
child: child,
child: MouseRegion(
cursor: SystemMouseCursors.text,
child: child,
),
);
}
@ -314,7 +342,8 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
}
Widget _buildRichText(BuildContext context) {
final textSpan = getTextSpan();
final textInserts = widget.node.delta!.whereType<TextInsert>();
TextSpan textSpan = getTextSpan(textInserts: textInserts);
return RichText(
key: textKey,
textAlign: widget.textAlign ?? TextAlign.start,
@ -331,6 +360,55 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
);
}
Widget _buildAutoCompleteRichText() {
final textInserts = widget.node.delta!.whereType<TextInsert>();
TextSpan textSpan = getTextSpan(textInserts: textInserts);
return ValueListenableBuilder(
valueListenable: widget.editorState.selectionNotifier,
builder: (_, __, ___) {
final autoCompleteText = autoCompleteTextProvider?.call(
context,
widget.node,
textSpan,
);
if (autoCompleteText == null || autoCompleteText.isEmpty) {
return const SizedBox.shrink();
}
textSpan = getTextSpan(
textInserts: [
...textInserts.map(
(e) => TextInsert(
e.text,
attributes: {
AppFlowyRichTextKeys.transparent: true,
},
),
),
TextInsert(
autoCompleteText,
attributes: {
AppFlowyRichTextKeys.autoComplete: true,
},
),
],
);
return RichText(
textAlign: widget.textAlign ?? TextAlign.start,
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
text: widget.textSpanDecorator != null
? widget.textSpanDecorator!(textSpan)
: textSpan,
textDirection: textDirection(),
textScaler:
TextScaler.linear(widget.editorState.editorStyle.textScaleFactor),
);
},
);
}
TextSpan getPlaceholderTextSpan() {
final style = widget.editorState.editorStyle.textStyleConfiguration;
return TextSpan(
@ -343,11 +421,12 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
);
}
TextSpan getTextSpan() {
TextSpan getTextSpan({
required Iterable<TextInsert> textInserts,
}) {
int offset = 0;
List<InlineSpan> textSpans = [];
final style = widget.editorState.editorStyle.textStyleConfiguration;
final textInserts = widget.node.delta!.whereType<TextInsert>();
for (final textInsert in textInserts) {
TextStyle textStyle = style.text.copyWith(height: widget.lineHeight);
final attributes = textInsert.attributes;
@ -395,6 +474,14 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
TextStyle(fontSize: attributes.fontSize),
);
}
if (attributes.autoComplete == true) {
textStyle = textStyle.combine(style.autoComplete);
}
if (attributes.transparent == true) {
textStyle = textStyle.combine(
const TextStyle(color: Colors.transparent),
);
}
}
final textSpan = TextSpan(
text: textInsert.text,
@ -523,4 +610,8 @@ extension AppFlowyRichTextAttributes on Attributes {
}
return null;
}
bool get autoComplete => this[AppFlowyRichTextKeys.autoComplete] == true;
bool get transparent => this[AppFlowyRichTextKeys.transparent] == true;
}

View file

@ -10,6 +10,8 @@ class AppFlowyRichTextKeys {
static String href = 'href';
static String fontFamily = 'font_family';
static String fontSize = 'font_size';
static String autoComplete = 'auto_complete';
static String transparent = 'transparent';
static List<String> supportSliced = [
bold,

View file

@ -38,6 +38,8 @@ class AppFlowyEditor extends StatefulWidget {
this.header,
this.footer,
this.focusNode,
this.enableAutoComplete = false,
this.autoCompleteTextProvider,
}) : blockComponentBuilders =
blockComponentBuilders ?? standardBlockComponentBuilderMap,
characterShortcutEvents =
@ -162,6 +164,12 @@ class AppFlowyEditor extends StatefulWidget {
/// only works on iOS or Android.
final bool showMagnifier;
/// If you want to enable the auto complete feature, you must set this value to true
/// and provide the [autoCompleteTextProvider].
final bool enableAutoComplete;
final AppFlowyAutoCompleteTextProvider? autoCompleteTextProvider;
/// {@macro flutter.widgets.editableText.contentInsertionConfiguration}
final ContentInsertionConfiguration? contentInsertionConfiguration;
@ -296,6 +304,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
editorState.editable = widget.editable;
editorState.showHeader = widget.header != null;
editorState.showFooter = widget.footer != null;
editorState.enableAutoComplete = widget.enableAutoComplete;
editorState.autoCompleteTextProvider = widget.autoCompleteTextProvider;
}
BlockComponentRendererService get _renderer => BlockComponentRenderer(

View file

@ -187,6 +187,9 @@ class EditorState {
bool showHeader = false;
bool showFooter = false;
bool enableAutoComplete = false;
AppFlowyAutoCompleteTextProvider? autoCompleteTextProvider;
// only used for testing
bool disableSealTimer = false;
bool disableRules = false;
@ -294,12 +297,16 @@ class EditorState {
selection = _applyTransactionFromRemote(transaction);
} else {
// broadcast to other users here, before applying the transaction
_observer.add((TransactionTime.before, transaction));
if (!_observer.isClosed) {
_observer.add((TransactionTime.before, transaction));
}
_applyTransactionInLocal(transaction);
// broadcast to other users here, after applying the transaction
_observer.add((TransactionTime.after, transaction));
if (!_observer.isClosed) {
_observer.add((TransactionTime.after, transaction));
}
_recordRedoOrUndo(options, transaction);

View file

@ -0,0 +1,91 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import '../new/util/util.dart';
void main() async {
testWidgets('auto complete', (tester) async {
const input = 'Hello';
final document = Document.blank()
..addParagraph(
initialText: input,
);
final editorState = EditorState(document: document);
final widget = AutoCompleteEditor(
editorState: editorState,
);
await tester.pumpWidget(widget);
await tester.pumpAndSettle();
editorState.selection = Selection.collapsed(
Position(path: [0], offset: input.length),
);
await tester.pumpAndSettle();
expect(
find.textContaining('world', findRichText: true),
findsOneWidget,
);
editorState.selection = Selection(
start: Position(path: [0], offset: 0),
end: Position(path: [0], offset: input.length),
);
await tester.pumpAndSettle();
expect(
find.textContaining('world', findRichText: true),
findsNothing,
);
});
}
class AutoCompleteEditor extends StatelessWidget {
const AutoCompleteEditor({
super.key,
required this.editorState,
});
final EditorState editorState;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: SafeArea(
child: Container(
width: 500,
decoration: BoxDecoration(
border: Border.all(color: Colors.blue),
),
child: AppFlowyEditor(
editorState: editorState,
enableAutoComplete: true,
autoCompleteTextProvider: (context, node, textSpan) {
final editorState = context.read<EditorState>();
final selection = editorState.selection;
final delta = node.delta;
if (selection == null ||
delta == null ||
!selection.isCollapsed ||
selection.endIndex != delta.length ||
!node.path.equals(selection.start.path)) {
return null;
}
final text = delta.toPlainText();
// An example, if the text ends with 'hello', then show the autocomplete.
if (text.toLowerCase().endsWith('hello')) {
return ' world';
}
return null;
},
),
),
),
),
);
}
}