refactor(learn): the editor's general parts move to halkyon_editor (ADR 0033, D37)

Todo stores editor documents too, so the document, the editor, its view,
the format bar, links, the clipboard, scripts and EditorBlocks move, with
their strings and tests, to a shared package on the same appflowy_editor
fork. What is Learn's alone stays in Learn on top of it, and what the
editor took from it directly is inverted: EditorBlocks gains textMarks,
formatActions and onEditorClosed, RichContentEditor a wrapEditor, and an
InsertableBlock's name takes the BuildContext. Learn's screens use
LearnContentEditor and LearnContentView, which add Learn's blocks and the
grammar check. Learn behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-06 21:25:44 +01:00
commit 648dfe4051
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 1676 additions and 1366 deletions

View file

@ -0,0 +1,371 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../support/test_app.dart';
/// Wraps text marked `shout` in capitals, or lower case while [editing].
TextSpanDecoratorForAttribute _shouting(
TextSpanDecoratorForAttribute base, {
required bool editing,
}) => (context, node, index, text, before, after) {
final span = base(context, node, index, text, before, after);
return text.attributes?['shout'] == true
? TextSpan(
text: editing ? text.text.toLowerCase() : text.text.toUpperCase(),
style: span.style,
)
: span;
};
RichContent _shouted(String text) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert': text,
'attributes': {'shout': true},
},
],
},
},
],
},
});
/// A block of a type nothing here knows, as a newer app may have written.
RichContent _unknownBlock() => RichContent({
'document': {
'type': 'page',
'children': [
{'type': 'from_the_future'},
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'around it'},
],
},
},
],
},
});
Widget _app(Widget child) => MaterialApp(
localizationsDelegates: editorTestDelegates,
home: Scaffold(body: child),
);
Widget _editor({
RichContent? content,
EditorBlocks? blocks,
EditorWrapper? wrapEditor,
ValueChanged<RichContent>? onChanged,
}) => SizedBox(
height: 400,
child: RichContentEditor(
initialContent: content ?? RichContent.plain('Water'),
onChanged: onChanged ?? (_) {},
formatBar: true,
blocks: blocks,
wrapEditor: wrapEditor,
),
);
InsertableBlock _block(String name) => InsertableBlock(
name: (context) => name,
icon: LucideIcons.star,
group: InsertGroup.blocks,
keywords: const [],
insert: (context, editorState, at) async {},
);
FormatAction _action({
bool refocus = false,
Future<void> Function(BuildContext, EditorState)? run,
}) => FormatAction(
icon: LucideIcons.star,
tooltip: (context) => 'Star',
active: (_) => false,
run: run ?? (context, editorState) async {},
refocus: refocus,
);
Future<void> _focusEditor(WidgetTester tester) async {
await tester.tap(find.byType(AppFlowyEditor));
await tester.pump(const Duration(seconds: 1));
}
void main() {
group('without any blocks of an app', () {
testWidgets('the editor offers the basic blocks, listed by kind', (
tester,
) async {
await tester.pumpWidget(_app(_editor()));
await _focusEditor(tester);
await tester.tap(find.byTooltip('Insert'));
await tester.pumpAndSettle();
expect(find.text('Heading 1'), findsOneWidget);
expect(find.text('Checklist'), findsOneWidget);
expect(find.text('Table'), findsOneWidget);
expect(find.text('Media'), findsNothing);
});
testWidgets('a block it does not know says so in the view', (tester) async {
await tester.pumpWidget(
_app(ListView(children: [RichContentView(content: _unknownBlock())])),
);
await tester.pump(const Duration(seconds: 1));
final l10n = lookupHalkyonEditorLocalizations(const Locale('en'));
expect(find.text(l10n.blockUnknown), findsOneWidget);
expect(find.text('around it', findRichText: true), findsOneWidget);
});
});
group('what an app adds', () {
testWidgets('marks of its own are drawn, set in the view and as written '
'in the editor', (tester) async {
final blocks = EditorBlocks(
builders: basicBlocks.builders,
insertable: basicBlocks.insertable,
textMarks: const [_shouting],
);
await tester.pumpWidget(
_app(
ListView(
children: [
RichContentView(content: _shouted('Water'), blocks: blocks),
_editor(content: _shouted('Water'), blocks: blocks),
],
),
),
);
await tester.pump(const Duration(seconds: 1));
expect(find.text('WATER', findRichText: true), findsOneWidget);
expect(find.text('water', findRichText: true), findsOneWidget);
});
testWidgets('its format button comes last of the formats and its run gets '
"the editor's state", (tester) async {
EditorState? ran;
await tester.pumpWidget(
_app(
_editor(
blocks: EditorBlocks(
builders: basicBlocks.builders,
insertable: basicBlocks.insertable,
formatActions: [
_action(run: (context, editorState) async => ran = editorState),
],
),
),
),
);
await _focusEditor(tester);
await tester.ensureVisible(find.byTooltip('Star'));
await tester.pump();
expect(
tester.getTopLeft(find.byTooltip('Star')).dx,
greaterThan(tester.getTopLeft(find.byTooltip('Subscript')).dx),
);
await tester.tap(find.byTooltip('Star'));
await tester.pump(const Duration(seconds: 1));
expect(
ran,
same(
tester
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor))
.editorState,
),
);
});
testWidgets('a format button asked to takes the focus back to the editor', (
tester,
) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
_app(
SizedBox(
height: 400,
child: RichContentEditor(
initialContent: RichContent.plain('Water'),
onChanged: (_) {},
formatBar: true,
focusNode: focusNode,
blocks: EditorBlocks(
builders: basicBlocks.builders,
insertable: basicBlocks.insertable,
formatActions: [
_action(
refocus: true,
run: (context, editorState) async => focusNode.unfocus(),
),
],
),
),
),
),
);
await _focusEditor(tester);
await tester.ensureVisible(find.byTooltip('Star'));
await tester.pump();
await tester.tap(find.byTooltip('Star'));
await tester.pump(const Duration(seconds: 1));
expect(focusNode.hasFocus, isTrue);
});
testWidgets('it hears of an editor going', (tester) async {
var closed = 0;
await tester.pumpWidget(
_app(
_editor(
blocks: EditorBlocks(
builders: basicBlocks.builders,
insertable: basicBlocks.insertable,
onEditorClosed: [() => closed++],
),
),
),
);
await tester.pump(const Duration(seconds: 1));
expect(closed, 0);
await tester.pumpWidget(_app(const SizedBox()));
await tester.pump(const Duration(seconds: 1));
expect(closed, 1);
});
testWidgets('what it wraps the editor in knows its state and focus', (
tester,
) async {
const wrapper = Key('wrapper');
EditorState? seen;
FocusNode? seenFocus;
await tester.pumpWidget(
_app(
_editor(
wrapEditor: (context, editorState, focusNode, editor) {
seen = editorState;
seenFocus = focusNode;
return KeyedSubtree(key: wrapper, child: editor);
},
),
),
);
await _focusEditor(tester);
final editor = find.byType(AppFlowyEditor);
expect(
find.descendant(of: find.byKey(wrapper), matching: editor),
findsOneWidget,
);
expect(seen, same(tester.widget<AppFlowyEditor>(editor).editorState));
expect(seenFocus?.hasFocus, isTrue);
});
testWidgets('blocks from a scope count, and those given beat them', (
tester,
) async {
Future<void> insertMenu(Widget editor) async {
await tester.pumpWidget(
_app(
EditorBlocksScope(
blocks: EditorBlocks(
builders: basicBlocks.builders,
insertable: [_block('From the scope')],
),
child: editor,
),
),
);
await _focusEditor(tester);
await tester.tap(find.byTooltip('Insert'));
await tester.pumpAndSettle();
}
await insertMenu(_editor());
expect(find.text('From the scope'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await insertMenu(
_editor(
blocks: EditorBlocks(
builders: basicBlocks.builders,
insertable: [_block('Given')],
),
),
);
expect(find.text('Given'), findsOneWidget);
expect(find.text('From the scope'), findsNothing);
});
});
test('plus keeps what either adds, in their order', () {
void first() {}
void second() {}
final star = _action();
final moon = _action();
final both =
EditorBlocks(
builders: const {},
insertable: const [],
textMarks: const [_shouting],
formatActions: [star],
onEditorClosed: [first],
).plus(
EditorBlocks(
builders: const {},
insertable: const [],
formatActions: [moon],
onEditorClosed: [second],
),
);
expect(both.textMarks, [_shouting]);
expect(both.formatActions, [star, moon]);
expect(both.onEditorClosed, [first, second]);
});
test('offering changes what is offered, nothing else', () {
void closed() {}
final star = _action();
final shortcut = CharacterShortcutEvent(
key: 'nothing',
character: '~',
handler: (_) async => false,
);
final block = _block('One');
final blocks = EditorBlocks(
builders: basicBlocks.builders,
insertable: basicBlocks.insertable,
shortcuts: [shortcut],
textMarks: const [_shouting],
formatActions: [star],
onEditorClosed: [closed],
).offering([block]);
expect(blocks.insertable, [block]);
expect(blocks.builders, basicBlocks.builders);
expect(blocks.shortcuts, [shortcut]);
expect(blocks.textMarks, [_shouting]);
expect(blocks.formatActions, [star]);
expect(blocks.onEditorClosed, [closed]);
});
}

View file

@ -0,0 +1,49 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:material_ui/material_ui.dart';
void main() {
final german = lookupHalkyonEditorLocalizations(const Locale('de'));
/// The placeholder an empty [node] shows, among the builders for [l10n].
String placeholderOf(Node node, HalkyonEditorLocalizations l10n) =>
buildersWithUnknown(
basicBlocks,
l10n,
)[node.type]!.configuration.placeholderText(node);
test("the editor's own text blocks say what they are in the app's "
'language', () {
expect(placeholderOf(bulletedListNode(), german), german.blockListItem);
expect(placeholderOf(numberedListNode(), german), german.blockListItem);
expect(
placeholderOf(todoListNode(checked: false), german),
german.blockChecklistItem,
);
expect(placeholderOf(quoteNode(), german), german.blockQuote);
expect(
placeholderOf(headingNode(level: 2), german),
german.blockHeading(2),
);
});
test('an empty line hints at the / menu on the desktop only, where it '
'is', () {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
expect(placeholderOf(paragraphNode(), german), german.editorSlashHint);
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(placeholderOf(paragraphNode(), german).trim(), isEmpty);
debugDefaultTargetPlatformOverride = null;
});
test('the blocks keep what else the editor set them up with', () {
final todo =
buildersWithUnknown(basicBlocks, german)[TodoListBlockKeys.type]!
as TodoListBlockComponentBuilder;
expect(todo.toggleChildrenTriggers, isNotEmpty);
});
}

View file

@ -0,0 +1,188 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:material_ui/material_ui.dart';
import '../support/test_app.dart';
void main() {
late RichContent latest;
/// Edits [text], or [content].
Future<EditorState> edit(
WidgetTester tester,
String text, {
RichContent? content,
}) async {
latest = content ?? RichContent.plain(text);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: editorTestDelegates,
home: Scaffold(
body: ListView(
children: [
SizedBox(
height: 400,
child: RichContentEditor(
initialContent: latest,
onChanged: (content) => latest = content,
formatBar: true,
),
),
],
),
),
),
);
await tester.pump(const Duration(seconds: 1));
return tester
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor))
.editorState;
}
/// Focuses the editor and selects [start] to [end] of its first line.
Future<void> select(
WidgetTester tester,
EditorState editorState,
int start,
int end,
) async {
await tester.tap(find.byType(AppFlowyEditor));
await tester.pump(const Duration(seconds: 1));
editorState.selection = Selection.single(
path: [0],
startOffset: start,
endOffset: end,
);
await tester.pump(const Duration(seconds: 1));
}
Future<void> press(WidgetTester tester, String tooltip) async {
await tester.tap(find.byTooltip(tooltip));
await tester.pump(const Duration(seconds: 1));
}
/// The attributes of the first line's runs, in order.
List<Map<String, Object?>?> runs() {
final document = latest.editorJson['document']! as Map<String, Object?>;
final first = (document['children']! as List).first as Map;
return [
for (final run in (first['data']! as Map)['delta']! as List)
(run as Map)['attributes'] as Map<String, Object?>?,
];
}
testWidgets('shows while the editor has the focus, and only then', (
tester,
) async {
await edit(tester, 'Water is H2O');
expect(find.byType(FormatBar), findsNothing);
await tester.tap(find.byType(AppFlowyEditor));
await tester.pump(const Duration(seconds: 1));
expect(find.byType(FormatBar), findsOneWidget);
FocusManager.instance.primaryFocus?.unfocus();
await tester.pump(const Duration(seconds: 1));
expect(find.byType(FormatBar), findsNothing);
});
testWidgets('bold marks the selection, shows it, and keeps the editor '
'focused', (tester) async {
final editorState = await edit(tester, 'Water is H2O');
await select(tester, editorState, 0, 5);
await press(tester, 'Bold');
expect(runs().first, {AppFlowyRichTextKeys.bold: true});
expect(find.byType(FormatBar), findsOneWidget);
expect(
tester
.widget<IconButton>(
find.ancestor(
of: find.byTooltip('Bold'),
matching: find.byType(IconButton),
),
)
.isSelected,
isTrue,
);
});
testWidgets('a subscript replaces a superscript on the same text', (
tester,
) async {
final editorState = await edit(tester, 'H2O');
await select(tester, editorState, 1, 2);
await press(tester, 'Superscript');
expect(runs()[1], {superscriptKey: true});
await press(tester, 'Subscript');
expect(runs()[1], {subscriptKey: true});
});
testWidgets('centres the selected line', (tester) async {
final editorState = await edit(tester, 'Water is H2O');
await select(tester, editorState, 0, 5);
await press(tester, 'Center');
final document = latest.editorJson['document']! as Map<String, Object?>;
final first = (document['children']! as List).first as Map;
expect((first['data']! as Map)['align'], 'center');
});
testWidgets('read-only text shows a subscript raised by the font, '
'smaller', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: editorTestDelegates,
home: Scaffold(
body: ListView(
children: [
RichContentView(
content: RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'H'},
{
'insert': '2',
'attributes': {subscriptKey: true},
},
{'insert': 'O'},
],
},
},
],
},
}),
),
],
),
),
),
);
await tester.pump(const Duration(seconds: 1));
final spans = <TextSpan>[];
for (final text in tester.widgetList<RichText>(find.byType(RichText))) {
text.text.visitChildren((span) {
if (span is TextSpan && span.text == '2') {
spans.add(span);
}
return true;
});
}
expect(spans, isNotEmpty);
expect(
spans.first.style?.fontFeatures,
contains(const FontFeature.subscripts()),
);
});
}

View file

@ -0,0 +1,296 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:material_ui/material_ui.dart';
import '../support/test_app.dart';
/// Content of one line of [runs], each a text and its marks.
RichContent line(List<(String, Map<String, Object?>)> runs) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
for (final (text, marks) in runs)
{'insert': text, if (marks.isNotEmpty) 'attributes': marks},
],
},
},
],
},
});
/// The runs of [content]'s first line: each its text and its marks.
List<List<Object?>> runsOf(RichContent content) {
final document = content.editorJson['document']! as Map<String, Object?>;
final first = (document['children']! as List).first as Map;
return [
for (final op in (first['data']! as Map)['delta']! as List)
run((op as Map)['insert']! as String, {
...?(op['attributes'] as Map<String, Object?>?),
}),
];
}
/// A run of text with its marks, as [runsOf] gives it.
List<Object?> run(String text, [Map<String, Object?> marks = const {}]) => [
text,
marks,
];
void main() {
/// What the system's clipboard holds, as the app's editors left it.
String? systemClipboard;
setUp(() {
systemClipboard = null;
EditorClipboard.clear();
});
/// Pumps an editor for each of [contents], one above the other, and gives
/// back what each holds after every change, by index.
Future<List<RichContent>> edit(
WidgetTester tester,
List<RichContent> contents,
) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async => switch (call.method) {
'Clipboard.setData' => () {
systemClipboard = (call.arguments as Map)['text'] as String?;
return null;
}(),
'Clipboard.getData' => {'text': systemClipboard},
_ => null,
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
final latest = [...contents];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: editorTestDelegates,
home: Scaffold(
body: Column(
children: [
for (final (index, content) in contents.indexed)
SizedBox(
height: 200,
child: RichContentEditor(
initialContent: content,
onChanged: (content) => latest[index] = content,
formatBar: true,
),
),
],
),
),
),
);
await tester.pump(const Duration(seconds: 1));
return latest;
}
EditorState editorAt(WidgetTester tester, int index) => tester
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor).at(index))
.editorState;
/// Focuses the editor [index] and selects [start] to [end] of its first
/// line.
Future<EditorState> select(
WidgetTester tester,
int index,
int start,
int end,
) async {
await tester.tap(find.byType(AppFlowyEditor).at(index));
await tester.pump(const Duration(seconds: 1));
final editorState = editorAt(tester, index)
..selection = Selection.single(
path: [0],
startOffset: start,
endOffset: end,
);
await tester.pump(const Duration(seconds: 1));
return editorState;
}
Future<void> shortcut(WidgetTester tester, LogicalKeyboardKey key) async {
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(key);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump(const Duration(seconds: 1));
}
group('links', () {
testWidgets('the link button gives the selection the address typed, on '
'the web without its scheme; Remove link takes it away', (
tester,
) async {
final latest = await edit(tester, [RichContent.plain('Tae Kim guide')]);
await select(tester, 0, 0, 7);
await tester.tap(find.byTooltip('Link'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'guidetojapanese.org');
await tester.tap(find.text('Save'));
await tester.pump(const Duration(seconds: 1));
expect(runsOf(latest[0]), [
run('Tae Kim', {'href': 'https://guidetojapanese.org'}),
run(' guide'),
]);
await select(tester, 0, 0, 7);
await tester.tap(find.byTooltip('Link'));
await tester.pumpAndSettle();
expect(find.text('https://guidetojapanese.org'), findsOneWidget);
await tester.tap(find.text('Remove link'));
await tester.pump(const Duration(seconds: 1));
// The same text, unmarked (the editor may leave it in two runs).
expect(
[for (final [text, _] in runsOf(latest[0])) text].join(),
'Tae Kim guide',
);
expect(
[for (final [_, marks] in runsOf(latest[0])) marks],
everyElement(isEmpty),
);
});
testWidgets('at a caret, asks for the text too and writes it linked', (
tester,
) async {
final latest = await edit(tester, [RichContent.plain('See: ')]);
await select(tester, 0, 5, 5);
await tester.tap(find.byTooltip('Link'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).at(0), 'the guide');
await tester.enterText(
find.byType(TextField).at(1),
'mailto:tae@example.org',
);
await tester.tap(find.text('Save'));
await tester.pump(const Duration(seconds: 1));
expect(runsOf(latest[0]), [
run('See: '),
run('the guide', {'href': 'mailto:tae@example.org'}),
]);
});
testWidgets(
'Ctrl+K asks for the link of the selection',
(tester) async {
await edit(tester, [RichContent.plain('Tae Kim guide')]);
await select(tester, 0, 0, 7);
await shortcut(tester, LogicalKeyboardKey.keyK);
expect(find.widgetWithText(AlertDialog, 'Address'), findsOneWidget);
},
variant: TargetPlatformVariant.only(TargetPlatform.linux),
);
test('an address without its scheme leads to the web; none is none', () {
expect(linkAddress(' example.org/a '), 'https://example.org/a');
expect(linkAddress('http://example.org'), 'http://example.org');
expect(linkAddress('mailto:a@example.org'), 'mailto:a@example.org');
expect(linkAddress(' '), isNull);
});
});
group('copying and pasting', () {
final formatted = line([
('学生', {rubyKey: 'がくせい'}),
('だ', {'bold': true}),
('。', {}),
]);
testWidgets(
'between the editors keeps every format, while the system clipboard '
'holds what was copied',
(tester) async {
final latest = await edit(tester, [formatted, RichContent.empty()]);
await select(tester, 0, 0, 4);
await shortcut(tester, LogicalKeyboardKey.keyC);
expect(systemClipboard, '学生だ。');
await select(tester, 1, 0, 0);
await shortcut(tester, LogicalKeyboardKey.keyV);
expect(runsOf(latest[1]), runsOf(formatted));
},
variant: TargetPlatformVariant.only(TargetPlatform.linux),
);
testWidgets(
'pastes plain text once the system clipboard holds something else',
(tester) async {
final latest = await edit(tester, [formatted, RichContent.empty()]);
await select(tester, 0, 0, 4);
await shortcut(tester, LogicalKeyboardKey.keyC);
// Copied in another app meanwhile.
systemClipboard = 'from elsewhere';
await select(tester, 1, 0, 0);
await shortcut(tester, LogicalKeyboardKey.keyV);
expect(runsOf(latest[1]), [run('from elsewhere')]);
},
variant: TargetPlatformVariant.only(TargetPlatform.linux),
);
testWidgets(
'cutting takes the selection out, to paste with its formats',
(tester) async {
final latest = await edit(tester, [formatted, RichContent.empty()]);
await select(tester, 0, 0, 3);
await shortcut(tester, LogicalKeyboardKey.keyX);
expect(runsOf(latest[0]), [run('。')]);
await select(tester, 1, 0, 0);
await shortcut(tester, LogicalKeyboardKey.keyV);
expect(runsOf(latest[1]), [
run('学生', {rubyKey: 'がくせい'}),
run('だ', {'bold': true}),
]);
},
variant: TargetPlatformVariant.only(TargetPlatform.linux),
);
testWidgets(
'on a phone, selected text shows a bar to copy it, with its formats',
(tester) async {
final latest = await edit(tester, [formatted, RichContent.empty()]);
await select(tester, 0, 0, 4);
await tester.pump(const Duration(seconds: 1));
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsOneWidget);
await tester.tap(find.text('Copy'));
await tester.pump(const Duration(seconds: 1));
expect(systemClipboard, '学生だ。');
await select(tester, 1, 0, 0);
await EditorClipboard.paste(editorAt(tester, 1));
await tester.pump(const Duration(seconds: 1));
expect(runsOf(latest[1]), runsOf(formatted));
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
});
}

View file

@ -0,0 +1,83 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final content = RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'heading',
'data': {
'level': 2,
'delta': [
{'insert': '日本語'},
],
},
},
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert': '猫',
'attributes': {'bold': true},
},
{'insert': 'は'},
{
'insert': '動物',
'attributes': {'href': 'https://ja.wikipedia.org/wiki/動物'},
},
{'insert': 'です。'},
],
},
},
{
'type': 'bulleted_list',
'data': {
'delta': [
{'insert': 'ねこ'},
],
},
},
],
},
});
group('RichContent and the editor document', () {
test('a round trip through the editor document changes nothing', () {
expect(content.toDocument().toRichContent(), content);
});
test('empty content is a blank document', () {
expect(RichContent.empty().toDocument().root.children, isEmpty);
expect(Document.blank().toRichContent(), RichContent.empty());
});
test('edits made in the editor are kept', () async {
final editorState = EditorState(document: content.toDocument());
addTearDown(editorState.dispose);
final paragraph = editorState.document.root.children[1];
await editorState.apply(
editorState.transaction..insertText(paragraph, 0, 'ほら、'),
);
final edited = editorState.document.toRichContent();
expect(edited, isNot(content));
expect(
edited.toDocument().root.children[1].delta?.toPlainText(),
'ほら、猫は動物です。',
);
});
test('refuses content in a format it has no migration for', () {
final older = RichContent(content.editorJson, formatVersion: 0);
expect(older.toDocument, throwsUnsupportedError);
});
});
}

View file

@ -0,0 +1,48 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:material_ui/material_ui.dart';
import '../support/test_app.dart';
void main() {
testWidgets('a click in an editor shorter than its content selects there, '
'and bringing the selection into view comes to an end', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: editorTestDelegates,
home: Scaffold(
body: ListView(
children: [
SizedBox(
// As short as a card's field: shorter than the box the
// editor keeps around the selection while scrolling to it.
height: 150,
child: RichContentEditor(
initialContent: RichContent.plain(
[for (var line = 1; line <= 40; line++) 'Line $line']
.join('\n'),
),
onChanged: (_) {},
),
),
],
),
),
),
);
await tester.pump(const Duration(seconds: 1));
await tester.tap(find.text('Line 3', findRichText: true));
// The frame comes: with a box taller than the editor, the editor
// scrolled up and down for ever and never drew one.
for (var frame = 0; frame < 5; frame++) {
await tester.pump(const Duration(milliseconds: 100));
}
final editorState = tester
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor))
.editorState;
expect(editorState.selection?.start.path, [2]);
});
}

View file

@ -0,0 +1,252 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:material_ui/material_ui.dart';
import '../support/test_app.dart';
void main() {
RichContent paragraph(String text) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
});
Future<void> show(WidgetTester tester, RichContent content) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: editorTestDelegates,
home: Scaffold(
body: ListView(children: [RichContentView(content: content)]),
),
),
);
// Flushes the editor's timers.
await tester.pump(const Duration(seconds: 1));
}
testWidgets('shows a table whose sizes are whole numbers, or missing, as '
'JSON written elsewhere may hold them', (tester) async {
Map<String, Object?> cell(int column, int row, Map<String, Object?> data) =>
{
'type': 'table/cell',
'data': {'colPosition': column, 'rowPosition': row, ...data},
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': '$column$row'},
],
},
},
],
};
for (final (table, cells) in [
// As an archive from elsewhere brought it: whole numbers.
(
{
'colsLen': 2,
'rowsLen': 1,
'colsHeight': 82,
'colDefaultWidth': 160,
'rowDefaultHeight': 40,
'colMinimumWidth': 40,
},
{'width': 80, 'height': 40},
),
// Without the editor's sizes.
(<String, Object?>{'colsLen': 2, 'rowsLen': 1}, <String, Object?>{}),
]) {
await show(
tester,
RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'table',
'data': table,
'children': [cell(0, 0, cells), cell(1, 0, cells)],
},
],
},
}),
);
expect(tester.takeException(), isNull);
expect(find.textContaining('00', findRichText: true), findsOneWidget);
expect(find.textContaining('10', findRichText: true), findsOneWidget);
}
});
testWidgets('a row holding a cell of several lines grows to fit it, read '
'only as in the editor', (tester) async {
Map<String, Object?> cell(int row, String text) => {
'type': 'table/cell',
'data': {'colPosition': 0, 'rowPosition': row},
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
};
await show(
tester,
RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'table',
'data': {'colsLen': 1, 'rowsLen': 2},
'children': [cell(0, 'る\nう\nつ'), cell(1, 'す')],
},
],
},
}),
);
await tester.pump();
final cells = find.byType(TableCelBlockWidget);
expect(
tester.getSize(cells.at(0)).height,
greaterThan(2 * tester.getSize(cells.at(1)).height),
);
});
testWidgets('shows the content', (tester) async {
await show(tester, paragraph('猫は動物です。'));
expect(find.textContaining('猫は動物です。', findRichText: true), findsOneWidget);
});
testWidgets('shows new content when it changes', (tester) async {
await show(tester, paragraph('猫'));
await show(tester, paragraph('犬'));
expect(find.textContaining('犬', findRichText: true), findsOneWidget);
expect(find.textContaining('猫', findRichText: true), findsNothing);
});
testWidgets('is display only: a tap on its text goes to what surrounds it, '
'and no keyboard opens', (tester) async {
var tapped = 0;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: editorTestDelegates,
home: Scaffold(
body: ListView(
children: [
GestureDetector(
onTap: () => tapped++,
child: RichContentView(content: paragraph('猫は動物です。')),
),
],
),
),
),
);
await tester.pump(const Duration(seconds: 1));
await tester.tap(find.textContaining('猫', findRichText: true));
await tester.pump(const Duration(seconds: 1));
expect(tapped, 1);
expect(tester.testTextInput.hasAnyClients, isFalse);
}, variant: TargetPlatformVariant.all());
group('asked to centre', () {
Map<String, Object?> block(
String type,
String text, [
Map<String, Object?> data = const {},
]) => {
'type': type,
'data': {
...data,
'delta': [
{'insert': text},
],
},
};
Future<void> showCentred(
WidgetTester tester,
List<Map<String, Object?>> blocks,
) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: editorTestDelegates,
home: Scaffold(
body: ListView(
children: [
RichContentView(
content: RichContent({
'document': {'type': 'page', 'children': blocks},
}),
textAlign: TextAlign.center,
),
],
),
),
),
);
await tester.pump(const Duration(seconds: 1));
}
double centreOf(WidgetTester tester, String text) =>
tester.getCenter(find.text(text, findRichText: true)).dx;
double leftOf(WidgetTester tester, String text) =>
tester.getTopLeft(find.text(text, findRichText: true)).dx;
testWidgets('centres paragraphs and headings', (tester) async {
await showCentred(tester, [
block('heading', 'Greetings', {'level': 2}),
block('paragraph', 'hola'),
]);
expect(centreOf(tester, 'Greetings'), moreOrLessEquals(200, epsilon: 1));
expect(centreOf(tester, 'hola'), moreOrLessEquals(200, epsilon: 1));
});
testWidgets('leaves a list at the start: centred, its markers would '
'drift apart', (tester) async {
await showCentred(tester, [
block('bulleted_list', 'hola'),
block('bulleted_list', 'buenos días'),
]);
expect(leftOf(tester, 'hola'), lessThan(60));
expect(leftOf(tester, 'buenos días'), leftOf(tester, 'hola'));
});
testWidgets('keeps the alignment a paragraph was given in the editor', (
tester,
) async {
await showCentred(tester, [
block('paragraph', 'hola', {'align': 'left'}),
]);
expect(leftOf(tester, 'hola'), lessThan(40));
});
});
}