2023-04-28 10:54:21 +08:00
|
|
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
|
|
|
|
import 'package:flutter/material.dart';
|
2023-05-01 14:37:25 +08:00
|
|
|
import 'package:flutter/services.dart';
|
2023-04-28 10:54:21 +08:00
|
|
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
|
|
|
|
|
import '../util/util.dart';
|
|
|
|
|
|
|
|
|
|
class TestableEditor {
|
|
|
|
|
TestableEditor({
|
|
|
|
|
required this.tester,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final WidgetTester tester;
|
|
|
|
|
|
|
|
|
|
EditorState get editorState => _editorState;
|
|
|
|
|
late EditorState _editorState;
|
|
|
|
|
|
|
|
|
|
Document get document => _editorState.document;
|
|
|
|
|
int get documentRootLen => document.root.children.length;
|
|
|
|
|
|
2023-04-28 11:12:49 +08:00
|
|
|
Selection? get selection => _editorState.selection;
|
|
|
|
|
|
2023-05-02 14:54:06 +08:00
|
|
|
MockIMEInput? _ime;
|
|
|
|
|
MockIMEInput get ime {
|
|
|
|
|
return _ime ??= MockIMEInput(
|
|
|
|
|
editorState: editorState,
|
|
|
|
|
tester: tester,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-28 10:54:21 +08:00
|
|
|
Future<TestableEditor> startTesting({
|
|
|
|
|
Locale locale = const Locale('en'),
|
|
|
|
|
}) async {
|
|
|
|
|
final editor = AppFlowyEditor(
|
|
|
|
|
editorState: editorState,
|
|
|
|
|
blockComponentBuilders: {
|
|
|
|
|
'document': DocumentComponentBuilder(),
|
|
|
|
|
'paragraph': TextBlockComponentBuilder(),
|
|
|
|
|
'todo_list': TodoListBlockComponentBuilder(),
|
|
|
|
|
'bulleted_list': BulletedListBlockComponentBuilder(),
|
|
|
|
|
'numbered_list': NumberedListBlockComponentBuilder(),
|
|
|
|
|
'quote': QuoteBlockComponentBuilder(),
|
2023-04-28 19:48:34 +08:00
|
|
|
'heading': HeadingBlockComponentBuilder(),
|
2023-04-28 10:54:21 +08:00
|
|
|
},
|
|
|
|
|
characterShortcutEvents: [
|
2023-05-02 14:54:06 +08:00
|
|
|
// '\n'
|
|
|
|
|
insertNewLineAfterBulletedList,
|
|
|
|
|
insertNewLineAfterTodoList,
|
|
|
|
|
insertNewLineAfterNumberedList,
|
2023-04-28 10:54:21 +08:00
|
|
|
insertNewLine,
|
2023-05-02 14:54:06 +08:00
|
|
|
|
|
|
|
|
// bulleted list
|
2023-04-28 10:54:21 +08:00
|
|
|
formatAsteriskToBulletedList,
|
|
|
|
|
formatMinusToBulletedList,
|
2023-05-02 14:54:06 +08:00
|
|
|
|
|
|
|
|
// numbered list
|
2023-04-28 10:54:21 +08:00
|
|
|
formatNumberToNumberedList,
|
2023-05-02 14:54:06 +08:00
|
|
|
|
|
|
|
|
// quote
|
2023-04-28 10:54:21 +08:00
|
|
|
formatGreaterToQuote,
|
2023-05-02 14:54:06 +08:00
|
|
|
|
|
|
|
|
// heading
|
2023-04-28 19:48:34 +08:00
|
|
|
formatSignToHeading,
|
2023-05-02 14:54:06 +08:00
|
|
|
|
|
|
|
|
// checkbox
|
|
|
|
|
// format unchecked box, [] or -[]
|
|
|
|
|
formatEmptyBracketsToUncheckedBox,
|
|
|
|
|
formatHyphenEmptyBracketsToUncheckedBox,
|
|
|
|
|
|
|
|
|
|
// format checked box, [x] or -[x]
|
|
|
|
|
formatFilledBracketsToCheckedBox,
|
|
|
|
|
formatHyphenFilledBracketsToCheckedBox,
|
|
|
|
|
|
|
|
|
|
// slash
|
2023-04-28 10:54:21 +08:00
|
|
|
slashCommand,
|
2023-05-02 14:54:06 +08:00
|
|
|
|
|
|
|
|
// markdown syntax
|
|
|
|
|
...markdownSyntaxShortcutEvents,
|
2023-04-28 10:54:21 +08:00
|
|
|
],
|
|
|
|
|
commandShortcutEvents: [
|
2023-05-02 14:54:06 +08:00
|
|
|
// backspace
|
|
|
|
|
convertToParagraphCommand,
|
2023-04-28 10:54:21 +08:00
|
|
|
backspaceCommand,
|
2023-05-02 15:42:30 +08:00
|
|
|
deleteLeftWordCommand,
|
|
|
|
|
deleteLeftSentenceCommand,
|
2023-05-02 14:54:06 +08:00
|
|
|
|
|
|
|
|
// arrow keys
|
2023-05-01 14:37:25 +08:00
|
|
|
...arrowLeftKeys,
|
|
|
|
|
...arrowRightKeys,
|
2023-05-01 15:24:51 +08:00
|
|
|
...arrowUpKeys,
|
2023-05-01 15:32:39 +08:00
|
|
|
...arrowDownKeys,
|
2023-05-02 14:54:06 +08:00
|
|
|
|
|
|
|
|
//
|
|
|
|
|
homeCommand,
|
|
|
|
|
endCommand,
|
2023-05-02 15:19:07 +08:00
|
|
|
|
|
|
|
|
toggleTodoListCommand,
|
2023-05-02 19:05:46 +08:00
|
|
|
...toggleMarkdownCommands,
|
2023-05-02 17:14:26 +08:00
|
|
|
|
|
|
|
|
//
|
|
|
|
|
indentCommand,
|
|
|
|
|
outdentCommand,
|
2023-05-02 17:19:38 +08:00
|
|
|
|
|
|
|
|
exitEditingCommand,
|
2023-04-28 10:54:21 +08:00
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
MaterialApp(
|
|
|
|
|
localizationsDelegates: const [
|
|
|
|
|
GlobalMaterialLocalizations.delegate,
|
|
|
|
|
GlobalCupertinoLocalizations.delegate,
|
|
|
|
|
GlobalWidgetsLocalizations.delegate,
|
|
|
|
|
AppFlowyEditorLocalizations.delegate,
|
|
|
|
|
],
|
|
|
|
|
supportedLocales: AppFlowyEditorLocalizations.delegate.supportedLocales,
|
|
|
|
|
locale: locale,
|
|
|
|
|
home: Scaffold(
|
|
|
|
|
body: editor,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void initialize() {
|
|
|
|
|
_editorState = EditorState(
|
|
|
|
|
document: Document.blank(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> dispose() async {
|
2023-05-02 14:54:06 +08:00
|
|
|
_ime = null;
|
2023-04-28 10:54:21 +08:00
|
|
|
// Workaround: to wait all the debounce calls expire.
|
|
|
|
|
// https://github.com/flutter/flutter/issues/11181#issuecomment-568737491
|
|
|
|
|
await tester.pumpAndSettle(const Duration(seconds: 1));
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-01 15:13:55 +08:00
|
|
|
void addNode(Node node) {
|
|
|
|
|
_editorState.document.root.insert(node);
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-28 10:54:21 +08:00
|
|
|
void addParagraph({
|
|
|
|
|
TextBuilder? builder,
|
|
|
|
|
String? initialText,
|
|
|
|
|
NodeDecorator? decorator,
|
|
|
|
|
}) {
|
2023-04-28 11:12:49 +08:00
|
|
|
addParagraphs(
|
|
|
|
|
1,
|
|
|
|
|
builder: builder,
|
|
|
|
|
initialText: initialText,
|
|
|
|
|
decorator: decorator,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void addParagraphs(
|
|
|
|
|
int count, {
|
|
|
|
|
TextBuilder? builder,
|
|
|
|
|
String? initialText,
|
|
|
|
|
NodeDecorator? decorator,
|
|
|
|
|
}) {
|
|
|
|
|
_editorState.document.addParagraphs(
|
|
|
|
|
count,
|
2023-04-28 10:54:21 +08:00
|
|
|
builder: builder,
|
|
|
|
|
initialText: initialText,
|
|
|
|
|
decorator: decorator,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-01 15:13:55 +08:00
|
|
|
void addEmptyParagraph() {
|
2023-04-28 10:54:21 +08:00
|
|
|
_editorState.document.addParagraph(initialText: '');
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-28 13:33:13 +08:00
|
|
|
Future<void> updateSelection(Selection? selection) async {
|
2023-04-28 10:54:21 +08:00
|
|
|
_editorState.selection = selection;
|
2023-04-28 13:33:13 +08:00
|
|
|
await tester.pumpAndSettle();
|
2023-04-28 10:54:21 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Node? nodeAtPath(Path path) {
|
|
|
|
|
return _editorState.getNodeAtPath(path);
|
|
|
|
|
}
|
2023-05-01 14:37:25 +08:00
|
|
|
|
2023-05-02 14:54:06 +08:00
|
|
|
final keyToCharacterMap = {
|
|
|
|
|
LogicalKeyboardKey.space: ' ',
|
2023-05-02 17:14:26 +08:00
|
|
|
LogicalKeyboardKey.enter: '\n',
|
2023-05-02 19:59:22 +08:00
|
|
|
LogicalKeyboardKey.backquote: '`',
|
|
|
|
|
LogicalKeyboardKey.tilde: '~',
|
|
|
|
|
LogicalKeyboardKey.asterisk: '*',
|
|
|
|
|
LogicalKeyboardKey.underscore: '_',
|
2023-05-02 14:54:06 +08:00
|
|
|
};
|
2023-05-01 14:37:25 +08:00
|
|
|
Future<void> pressLogicKey({
|
|
|
|
|
String? character,
|
|
|
|
|
LogicalKeyboardKey? key,
|
|
|
|
|
bool isControlPressed = false,
|
|
|
|
|
bool isShiftPressed = false,
|
|
|
|
|
bool isAltPressed = false,
|
|
|
|
|
bool isMetaPressed = false,
|
|
|
|
|
}) async {
|
|
|
|
|
if (key != null) {
|
|
|
|
|
if (isControlPressed) {
|
|
|
|
|
await simulateKeyDownEvent(LogicalKeyboardKey.control);
|
|
|
|
|
}
|
|
|
|
|
if (isShiftPressed) {
|
|
|
|
|
await simulateKeyDownEvent(LogicalKeyboardKey.shift);
|
|
|
|
|
}
|
|
|
|
|
if (isAltPressed) {
|
|
|
|
|
await simulateKeyDownEvent(LogicalKeyboardKey.alt);
|
|
|
|
|
}
|
|
|
|
|
if (isMetaPressed) {
|
|
|
|
|
await simulateKeyDownEvent(LogicalKeyboardKey.meta);
|
|
|
|
|
}
|
2023-05-02 14:54:06 +08:00
|
|
|
if (keyToCharacterMap.containsKey(key)) {
|
|
|
|
|
final character = keyToCharacterMap[key]!;
|
2023-05-02 17:14:26 +08:00
|
|
|
await ime.typeText(character);
|
2023-05-02 14:54:06 +08:00
|
|
|
} else {
|
|
|
|
|
await simulateKeyDownEvent(key);
|
|
|
|
|
await simulateKeyUpEvent(key);
|
|
|
|
|
}
|
2023-05-01 14:37:25 +08:00
|
|
|
if (isControlPressed) {
|
|
|
|
|
await simulateKeyUpEvent(LogicalKeyboardKey.control);
|
|
|
|
|
}
|
|
|
|
|
if (isShiftPressed) {
|
|
|
|
|
await simulateKeyUpEvent(LogicalKeyboardKey.shift);
|
|
|
|
|
}
|
|
|
|
|
if (isAltPressed) {
|
|
|
|
|
await simulateKeyUpEvent(LogicalKeyboardKey.alt);
|
|
|
|
|
}
|
|
|
|
|
if (isMetaPressed) {
|
|
|
|
|
await simulateKeyUpEvent(LogicalKeyboardKey.meta);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
2023-04-28 10:54:21 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension TestableEditorExtension on WidgetTester {
|
|
|
|
|
TestableEditor get editor => TestableEditor(tester: this)..initialize();
|
|
|
|
|
|
|
|
|
|
EditorState get editorState => editor.editorState;
|
|
|
|
|
}
|
2023-05-02 14:54:06 +08:00
|
|
|
|
|
|
|
|
class MockIMEInput {
|
|
|
|
|
MockIMEInput({
|
|
|
|
|
required this.editorState,
|
|
|
|
|
required this.tester,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final EditorState editorState;
|
|
|
|
|
final WidgetTester tester;
|
|
|
|
|
|
|
|
|
|
TextInputService get imeInput {
|
|
|
|
|
final keyboardService = tester.state(find.byType(KeyboardServiceWidget))
|
|
|
|
|
as KeyboardServiceWidgetState;
|
|
|
|
|
return keyboardService.textInputService;
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-02 17:14:26 +08:00
|
|
|
Future<void> typeText(String text) async {
|
|
|
|
|
final selection = editorState.selection;
|
|
|
|
|
if (selection == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// if the selection is collapsed, do insertion.
|
|
|
|
|
// else if the selection is not collapsed, do replacement.
|
|
|
|
|
if (selection.isCollapsed) {
|
|
|
|
|
return insertText(text);
|
|
|
|
|
} else {
|
|
|
|
|
return replaceText(text);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-02 14:54:06 +08:00
|
|
|
Future<void> insertText(String text) async {
|
|
|
|
|
final selection = editorState.selection;
|
|
|
|
|
if (selection == null || !selection.isCollapsed) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final node = editorState.getNodeAtPath(selection.end.path);
|
|
|
|
|
final delta = node?.delta;
|
|
|
|
|
if (delta == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
return imeInput.apply([
|
|
|
|
|
TextEditingDeltaInsertion(
|
2023-05-02 17:14:26 +08:00
|
|
|
oldText: ' ${delta.toPlainText()}', // TODO: fix this workaround
|
2023-05-02 14:54:06 +08:00
|
|
|
textInserted: text,
|
2023-05-02 17:14:26 +08:00
|
|
|
insertionOffset: selection.startIndex + 1,
|
|
|
|
|
selection: TextSelection.collapsed(
|
|
|
|
|
offset: selection.startIndex + 1 + text.length,
|
|
|
|
|
),
|
|
|
|
|
composing: TextRange.empty,
|
|
|
|
|
)
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> replaceText(String text) async {
|
|
|
|
|
final selection = editorState.selection?.normalized;
|
|
|
|
|
if (selection == null || selection.isCollapsed) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final texts = editorState.getTextInSelection(selection).join('\n');
|
|
|
|
|
return imeInput.apply([
|
|
|
|
|
TextEditingDeltaReplacement(
|
|
|
|
|
oldText: ' $texts',
|
|
|
|
|
replacementText: text,
|
|
|
|
|
replacedRange: TextSelection(
|
|
|
|
|
baseOffset: selection.startIndex + 1,
|
|
|
|
|
extentOffset: selection.endIndex + 1,
|
|
|
|
|
),
|
|
|
|
|
selection: TextSelection.collapsed(
|
|
|
|
|
offset: selection.startIndex + 1 + text.length,
|
|
|
|
|
),
|
2023-05-02 14:54:06 +08:00
|
|
|
composing: TextRange.empty,
|
|
|
|
|
)
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|