2026-07-27 15:37:27 +08:00
|
|
|
import 'dart:async';
|
2023-04-28 10:54:21 +08:00
|
|
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
2023-07-02 23:51:18 -05:00
|
|
|
import 'package:appflowy_editor/src/editor/editor_component/service/ime/text_input_service.dart';
|
2023-04-28 10:54:21 +08:00
|
|
|
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';
|
|
|
|
|
|
2023-08-10 16:38:45 +05:30
|
|
|
import '../find_replace_menu/find_replace_menu_utils.dart';
|
2023-04-28 10:54:21 +08:00
|
|
|
import '../util/util.dart';
|
|
|
|
|
|
2023-09-27 13:04:33 +05:30
|
|
|
final floatingToolbarItems = [
|
|
|
|
|
paragraphItem,
|
|
|
|
|
...headingItems,
|
|
|
|
|
...markdownFormatItems,
|
|
|
|
|
quoteItem,
|
|
|
|
|
bulletedListItem,
|
|
|
|
|
numberedListItem,
|
|
|
|
|
linkItem,
|
|
|
|
|
buildTextColorItem(),
|
|
|
|
|
buildHighlightColorItem(),
|
|
|
|
|
];
|
|
|
|
|
|
2023-04-28 10:54:21 +08:00
|
|
|
class TestableEditor {
|
|
|
|
|
TestableEditor({
|
|
|
|
|
required this.tester,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final WidgetTester tester;
|
|
|
|
|
|
|
|
|
|
EditorState get editorState => _editorState;
|
|
|
|
|
late EditorState _editorState;
|
|
|
|
|
|
|
|
|
|
Document get document => _editorState.document;
|
2025-12-23 15:12:57 +08:00
|
|
|
|
2023-04-28 10:54:21 +08:00
|
|
|
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;
|
2025-12-23 15:12:57 +08:00
|
|
|
|
2023-05-02 14:54:06 +08:00
|
|
|
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'),
|
2023-05-02 23:01:48 +08:00
|
|
|
bool autoFocus = false,
|
|
|
|
|
bool editable = true,
|
2023-06-12 20:41:15 +08:00
|
|
|
bool shrinkWrap = false,
|
2023-06-20 06:12:57 +03:30
|
|
|
bool withFloatingToolbar = false,
|
2023-07-07 16:07:47 +08:00
|
|
|
bool inMobile = false,
|
2023-06-12 20:41:15 +08:00
|
|
|
ScrollController? scrollController,
|
|
|
|
|
Widget Function(Widget child)? wrapper,
|
2023-07-25 07:08:18 +05:00
|
|
|
TargetPlatform? platform,
|
2023-09-20 09:37:38 +03:30
|
|
|
String? defaultTextDirection,
|
2023-09-27 13:04:33 +05:30
|
|
|
TextDirection textDirection = TextDirection.ltr,
|
2023-04-28 10:54:21 +08:00
|
|
|
}) async {
|
2023-06-20 06:12:57 +03:30
|
|
|
await AppFlowyEditorLocalizations.load(locale);
|
|
|
|
|
|
|
|
|
|
if (withFloatingToolbar) {
|
|
|
|
|
scrollController ??= ScrollController();
|
|
|
|
|
}
|
2023-09-11 15:03:14 +08:00
|
|
|
|
|
|
|
|
final editorScrollController = EditorScrollController(
|
|
|
|
|
editorState: editorState,
|
|
|
|
|
shrinkWrap: shrinkWrap,
|
|
|
|
|
scrollController: scrollController,
|
|
|
|
|
);
|
|
|
|
|
|
2023-08-10 16:38:45 +05:30
|
|
|
Widget editor = Builder(
|
|
|
|
|
builder: (context) {
|
|
|
|
|
return AppFlowyEditor(
|
|
|
|
|
editorState: editorState,
|
|
|
|
|
editable: editable,
|
|
|
|
|
autoFocus: autoFocus,
|
|
|
|
|
shrinkWrap: shrinkWrap,
|
2023-09-11 15:03:14 +08:00
|
|
|
editorScrollController: editorScrollController,
|
2023-08-10 16:38:45 +05:30
|
|
|
commandShortcutEvents: [
|
|
|
|
|
...standardCommandShortcutEvents,
|
|
|
|
|
...TestableFindAndReplaceCommands(context: context)
|
|
|
|
|
.testableFindAndReplaceCommands,
|
|
|
|
|
],
|
2024-01-14 20:17:19 +05:30
|
|
|
characterShortcutEvents: [
|
|
|
|
|
...standardCharacterShortcutEvents,
|
|
|
|
|
formatGreaterHyphen,
|
|
|
|
|
],
|
2025-11-06 11:48:13 +08:00
|
|
|
contextMenuBuilder: (context, position, editorState, onPressed) {
|
|
|
|
|
return ContextMenu(
|
|
|
|
|
position: position,
|
|
|
|
|
editorState: editorState,
|
|
|
|
|
items: standardContextMenuItems,
|
|
|
|
|
onPressed: onPressed,
|
|
|
|
|
);
|
|
|
|
|
},
|
2023-08-10 16:38:45 +05:30
|
|
|
editorStyle: inMobile
|
2023-09-20 09:37:38 +03:30
|
|
|
? EditorStyle.mobile(
|
|
|
|
|
defaultTextDirection: defaultTextDirection,
|
|
|
|
|
)
|
|
|
|
|
: EditorStyle.desktop(
|
|
|
|
|
defaultTextDirection: defaultTextDirection,
|
|
|
|
|
),
|
2023-08-10 16:38:45 +05:30
|
|
|
);
|
|
|
|
|
},
|
2023-04-28 10:54:21 +08:00
|
|
|
);
|
2023-09-11 15:03:14 +08:00
|
|
|
|
2023-06-20 06:12:57 +03:30
|
|
|
if (withFloatingToolbar) {
|
2023-07-07 16:07:47 +08:00
|
|
|
if (inMobile) {
|
|
|
|
|
final items = [
|
|
|
|
|
textDecorationMobileToolbarItem,
|
|
|
|
|
headingMobileToolbarItem,
|
|
|
|
|
todoListMobileToolbarItem,
|
|
|
|
|
listMobileToolbarItem,
|
|
|
|
|
linkMobileToolbarItem,
|
|
|
|
|
quoteMobileToolbarItem,
|
|
|
|
|
codeMobileToolbarItem,
|
|
|
|
|
];
|
|
|
|
|
editor = Column(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: AppFlowyEditor(
|
|
|
|
|
editorStyle: const EditorStyle.mobile(),
|
|
|
|
|
editorState: editorState,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
MobileToolbar(
|
|
|
|
|
editorState: editorState,
|
|
|
|
|
toolbarItems: items,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
editor = FloatingToolbar(
|
2023-09-27 13:04:33 +05:30
|
|
|
items: floatingToolbarItems,
|
2023-07-07 16:07:47 +08:00
|
|
|
editorState: editorState,
|
2023-09-27 13:04:33 +05:30
|
|
|
textDirection: textDirection,
|
2023-09-11 15:03:14 +08:00
|
|
|
editorScrollController: editorScrollController,
|
2023-07-07 16:07:47 +08:00
|
|
|
child: editor,
|
|
|
|
|
);
|
|
|
|
|
}
|
2023-06-20 06:12:57 +03:30
|
|
|
}
|
2023-09-27 13:04:33 +05:30
|
|
|
|
|
|
|
|
editor = Directionality(
|
|
|
|
|
textDirection: textDirection,
|
|
|
|
|
child: editor,
|
|
|
|
|
);
|
|
|
|
|
|
2023-04-28 10:54:21 +08:00
|
|
|
await tester.pumpWidget(
|
|
|
|
|
MaterialApp(
|
2023-07-25 07:08:18 +05:00
|
|
|
theme: ThemeData(platform: platform),
|
2023-04-28 10:54:21 +08:00
|
|
|
localizationsDelegates: const [
|
|
|
|
|
GlobalMaterialLocalizations.delegate,
|
|
|
|
|
GlobalCupertinoLocalizations.delegate,
|
|
|
|
|
GlobalWidgetsLocalizations.delegate,
|
|
|
|
|
AppFlowyEditorLocalizations.delegate,
|
|
|
|
|
],
|
|
|
|
|
supportedLocales: AppFlowyEditorLocalizations.delegate.supportedLocales,
|
|
|
|
|
locale: locale,
|
|
|
|
|
home: Scaffold(
|
2023-06-12 20:41:15 +08:00
|
|
|
body: wrapper == null
|
|
|
|
|
? editor
|
2023-07-02 23:51:18 -05:00
|
|
|
: wrapper(
|
2023-06-12 20:41:15 +08:00
|
|
|
editor,
|
|
|
|
|
),
|
2023-04-28 10:54:21 +08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
2025-12-23 15:12:57 +08:00
|
|
|
|
2023-04-28 10:54:21 +08:00
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void initialize() {
|
|
|
|
|
_editorState = EditorState(
|
|
|
|
|
document: Document.blank(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-10 14:22:43 +07:00
|
|
|
void initializeWithDocument(Document document) {
|
2023-07-25 07:08:18 +05:00
|
|
|
_editorState = EditorState(
|
|
|
|
|
document: document,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-28 10:54:21 +08:00
|
|
|
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 {
|
2026-07-27 15:37:27 +08:00
|
|
|
unawaited(
|
|
|
|
|
_editorState.updateSelectionWithReason(
|
|
|
|
|
selection,
|
|
|
|
|
reason: SelectionUpdateReason.uiEvent,
|
|
|
|
|
),
|
2023-05-02 20:20:32 +08:00
|
|
|
);
|
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 19:59:22 +08:00
|
|
|
LogicalKeyboardKey.backquote: '`',
|
|
|
|
|
LogicalKeyboardKey.tilde: '~',
|
|
|
|
|
LogicalKeyboardKey.asterisk: '*',
|
|
|
|
|
LogicalKeyboardKey.underscore: '_',
|
2023-05-02 14:54:06 +08:00
|
|
|
};
|
2024-05-14 14:05:38 +08:00
|
|
|
|
2023-05-03 11:10:17 +08:00
|
|
|
Future<void> pressKey({
|
2023-05-01 14:37:25 +08:00
|
|
|
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);
|
|
|
|
|
}
|
2023-05-03 11:10:17 +08:00
|
|
|
} else if (character != null) {
|
|
|
|
|
await ime.typeText(character);
|
2023-05-01 14:37:25 +08:00
|
|
|
}
|
|
|
|
|
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;
|
2025-12-23 15:12:57 +08:00
|
|
|
|
2023-05-02 14:54:06 +08:00
|
|
|
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) {
|
2023-06-20 17:13:10 +08:00
|
|
|
await insertText(text);
|
2023-05-02 17:14:26 +08:00
|
|
|
} else {
|
2023-06-20 17:13:10 +08:00
|
|
|
await replaceText(text);
|
2023-05-02 17:14:26 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2023-06-20 17:13:10 +08:00
|
|
|
await imeInput.apply([
|
2023-05-02 14:54:06 +08:00
|
|
|
TextEditingDeltaInsertion(
|
2024-08-16 10:52:48 +08:00
|
|
|
oldText: ' ${delta.toPlainText()}',
|
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,
|
2023-09-13 15:26:29 +08:00
|
|
|
),
|
2023-05-02 17:14:26 +08:00
|
|
|
]);
|
2023-06-20 17:13:10 +08:00
|
|
|
await tester.pumpAndSettle();
|
2023-05-02 17:14:26 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> replaceText(String text) async {
|
2023-08-21 05:46:45 +02:00
|
|
|
var selection = editorState.selection?.normalized;
|
|
|
|
|
if (selection == null) {
|
2023-05-02 17:14:26 +08:00
|
|
|
return;
|
|
|
|
|
}
|
2023-08-21 05:46:45 +02:00
|
|
|
if (!selection.isSingle || !selection.isCollapsed) {
|
|
|
|
|
await editorState.deleteSelection(selection);
|
|
|
|
|
}
|
|
|
|
|
selection = editorState.selection?.normalized;
|
|
|
|
|
if (selection == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final oldText =
|
|
|
|
|
editorState.getNodeAtPath(selection.start.path)!.delta!.toPlainText();
|
|
|
|
|
|
2023-06-20 17:13:10 +08:00
|
|
|
await imeInput.apply([
|
2023-05-02 17:14:26 +08:00
|
|
|
TextEditingDeltaReplacement(
|
2023-08-21 05:46:45 +02:00
|
|
|
oldText: ' $oldText',
|
2023-05-02 17:14:26 +08:00
|
|
|
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,
|
2023-09-13 15:26:29 +08:00
|
|
|
),
|
2023-05-02 14:54:06 +08:00
|
|
|
]);
|
2023-06-20 17:13:10 +08:00
|
|
|
await tester.pumpAndSettle();
|
2023-05-02 14:54:06 +08:00
|
|
|
}
|
|
|
|
|
}
|