diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart index 418b4d7ce0..f134d309b0 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -6,6 +6,7 @@ export 'src/document/position.dart'; export 'src/document/selection.dart'; export 'src/document/state_tree.dart'; export 'src/document/text_delta.dart'; +export 'src/document/attributes.dart'; export 'src/editor_state.dart'; export 'src/operation/operation.dart'; export 'src/operation/transaction.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/document/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/document/node.dart index 6bfc877524..5bf63e63a3 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/document/node.dart @@ -111,6 +111,27 @@ class Node extends ChangeNotifier with LinkedListEntry { return childAtIndex(path.first)?.childAtPath(path.sublist(1)); } + void insert(Node entry, {int? index}) { + index ??= children.length; + + if (children.isEmpty) { + entry.parent = this; + children.add(entry); + notifyListeners(); + return; + } + + final length = children.length; + + if (index >= length) { + children.last.insertAfter(entry); + } else if (index <= 0) { + children.first.insertBefore(entry); + } else { + childAtIndex(index)?.insertBefore(entry); + } + } + @override void insertAfter(Node entry) { entry.parent = parent; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/document/state_tree.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/document/state_tree.dart index b356880310..f1437fbaef 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/document/state_tree.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/document/state_tree.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flowy_editor/src/document/node.dart'; import 'package:flowy_editor/src/document/path.dart'; import 'package:flowy_editor/src/document/text_delta.dart'; @@ -27,9 +29,18 @@ class StateTree { return false; } Node? insertedNode = root.childAtPath( - path.sublist(0, path.length - 1) + [path.last - 1], + path.sublist(0, path.length - 1) + [max(0, path.last - 1)], ); if (insertedNode == null) { + final insertedNode = root.childAtPath( + path.sublist(0, path.length - 1), + ); + if (insertedNode != null) { + for (final node in nodes) { + insertedNode.insert(node); + } + return true; + } return false; } for (var i = 0; i < nodes.length; i++) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/editor_state.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/editor_state.dart index 6b7210cd7a..4452e92ed2 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/editor_state.dart @@ -51,6 +51,9 @@ class EditorState { final UndoManager undoManager = UndoManager(); Selection? _cursorSelection; + /// TODO: only for testing. + bool disableSealTimer = false; + Selection? get cursorSelection { return _cursorSelection; } @@ -106,6 +109,9 @@ class EditorState { } _debouncedSealHistoryItem() { + if (disableSealTimer) { + return; + } _debouncedSealHistoryItemTimer?.cancel(); _debouncedSealHistoryItemTimer = Timer(const Duration(milliseconds: 1000), () { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/keyboard_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/keyboard_service.dart index ef8165049b..b7573e4ed6 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/keyboard_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/keyboard_service.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/material.dart'; abstract class FlowyKeyboardService { + KeyEventResult onKey(RawKeyEvent event); void enable(); void disable(); } @@ -65,15 +66,8 @@ class _FlowyKeyboardState extends State _focusNode.unfocus(); } - void _onFocusChange(bool value) { - debugPrint('[KeyBoard Service] focus change $value'); - } - - KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { - if (!isFocus) { - return KeyEventResult.ignored; - } - + @override + KeyEventResult onKey(RawKeyEvent event) { debugPrint('on keyboard event $event'); if (event is! RawKeyDownEvent) { @@ -97,4 +91,16 @@ class _FlowyKeyboardState extends State return KeyEventResult.ignored; } + + void _onFocusChange(bool value) { + debugPrint('[KeyBoard Service] focus change $value'); + } + + KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { + if (!isFocus) { + return KeyEventResult.ignored; + } + + return onKey(event); + } } diff --git a/frontend/app_flowy/packages/flowy_editor/test/editor/key_event_tests/enter_without_shift_in_text_node_handler_test.dart b/frontend/app_flowy/packages/flowy_editor/test/editor/key_event_tests/enter_without_shift_in_text_node_handler_test.dart new file mode 100644 index 0000000000..5dd8c3dad0 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/test/editor/key_event_tests/enter_without_shift_in_text_node_handler_test.dart @@ -0,0 +1,69 @@ +import 'package:flowy_editor/flowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('Enter key without shift handler', () { + testWidgets('Pressing enter key in empty document', (tester) async { + final editor = tester.editor + ..initialize() + ..insertEmptyTextNode(); + await editor.startTesting(); + await editor.updateSelection( + Selection.collapsed( + Position(path: [0], offset: 0), + ), + ); + // Pressing the enter key continuously. + for (int i = 1; i <= 10; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + ); + expect(editor.documentLength, i + 1); + expect(editor.documentSelection, + Selection.collapsed(Position(path: [i], offset: 0))); + } + }); + + testWidgets('Pressing enter key in non-empty document', (tester) async { + const text = 'Welcome to Appflowy 😁'; + var lines = 5; + + final editor = tester.editor..initialize(); + for (var i = 1; i <= lines; i++) { + editor.insertTextNode(text: text); + } + await editor.startTesting(); + + expect(editor.documentLength, lines); + + // Pressing the enter key in last line. + await editor.updateSelection( + Selection.collapsed( + Position(path: [lines - 1], offset: 0), + ), + ); + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + ); + lines += 1; + + expect(editor.documentLength, lines); + expect(editor.documentSelection, + Selection.collapsed(Position(path: [lines - 1], offset: 0))); + var lastNode = editor.nodeAtPath([lines - 1]); + expect(lastNode != null, true); + expect(lastNode is TextNode, true); + lastNode = lastNode as TextNode; + expect(lastNode.delta.toRawString(), text); + expect((lastNode.previous as TextNode).delta.toRawString(), ''); + expect( + (lastNode.previous!.previous as TextNode).delta.toRawString(), text); + }); + }); +} diff --git a/frontend/app_flowy/packages/flowy_editor/test/editor/widget_test.dart b/frontend/app_flowy/packages/flowy_editor/test/editor/widget_test.dart new file mode 100644 index 0000000000..1291778a56 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/test/editor/widget_test.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flowy_editor/flowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../infra/test_raw_key_event.dart'; + +void main() async { + final file = File('test_assets/example.json'); + final json = jsonDecode(await file.readAsString()); + print(json); + + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + testWidgets('init FlowyEditor ', (tester) async { + final editorState = EditorState( + document: StateTree.fromJson(json), + ); + final flowyEditor = FlowyEditor(editorState: editorState); + await tester.pumpWidget(MaterialApp( + home: flowyEditor, + )); + editorState.service.selectionService + .updateSelection(Selection.collapsed(Position(path: [0], offset: 1))); + await tester.pumpAndSettle(); + final key = const TestRawKeyEventData( + logicalKey: LogicalKeyboardKey.enter, + physicalKey: PhysicalKeyboardKey.enter, + ).toKeyEvent; + editorState.service.keyboardService!.onKey(key); + await tester.pumpAndSettle(); + expect(editorState.document.root.children.length, 2); + }); +} diff --git a/frontend/app_flowy/packages/flowy_editor/test/infra/test_editor.dart b/frontend/app_flowy/packages/flowy_editor/test/infra/test_editor.dart new file mode 100644 index 0000000000..19570ee0f8 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/test/infra/test_editor.dart @@ -0,0 +1,107 @@ +import 'dart:collection'; + +import 'package:flowy_editor/flowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_raw_key_event.dart'; + +class EditorWidgetTester { + EditorWidgetTester({ + required this.tester, + }); + + final WidgetTester tester; + late EditorState _editorState; + + EditorState get editorState => _editorState; + Node get root => _editorState.document.root; + + int get documentLength => _editorState.document.root.children.length; + Selection? get documentSelection => + _editorState.service.selectionService.currentSelection.value; + + Future startTesting() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FlowyEditor( + editorState: _editorState, + ), + ), + ), + ); + return this; + } + + void initialize() { + _editorState = _createEmptyDocument(); + } + + insert(T node) { + _editorState.document.root.insert(node); + } + + insertEmptyTextNode() { + insert(TextNode.empty()); + } + + insertTextNode({String? text, Attributes? attributes}) { + insert( + TextNode( + type: 'text', + delta: Delta( + [TextInsert(text ?? 'Test')], + ), + attributes: attributes, + ), + ); + } + + Node? nodeAtPath(Path path) { + return root.childAtPath(path); + } + + Future updateSelection(Selection? selection) async { + if (selection == null) { + _editorState.service.selectionService.clearSelection(); + } else { + _editorState.service.selectionService.updateSelection(selection); + } + await tester.pumpAndSettle(); + } + + Future pressLogicKey(LogicalKeyboardKey key) async { + late RawKeyEvent testRawKeyEventData; + if (key == LogicalKeyboardKey.enter) { + testRawKeyEventData = const TestRawKeyEventData( + logicalKey: LogicalKeyboardKey.enter, + physicalKey: PhysicalKeyboardKey.enter, + ).toKeyEvent; + } + _editorState.service.keyboardService!.onKey(testRawKeyEventData); + await tester.pumpAndSettle(); + } + + Node _createEmptyEditorRoot() { + return Node( + type: 'editor', + children: LinkedList(), + attributes: {}, + ); + } + + EditorState _createEmptyDocument() { + return EditorState( + document: StateTree( + root: _createEmptyEditorRoot(), + ), + )..disableSealTimer = true; + } +} + +extension TestEditorExtension on WidgetTester { + EditorWidgetTester get editor => EditorWidgetTester(tester: this); + EditorState get editorState => editor.editorState; +} diff --git a/frontend/app_flowy/packages/flowy_editor/test/infra/test_raw_key_event.dart b/frontend/app_flowy/packages/flowy_editor/test/infra/test_raw_key_event.dart new file mode 100644 index 0000000000..2cb3fa0fd9 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/test/infra/test_raw_key_event.dart @@ -0,0 +1,52 @@ +import 'package:flutter/services.dart'; + +class TestRawKeyEvent extends RawKeyDownEvent { + const TestRawKeyEvent({required super.data}); +} + +class TestRawKeyEventData extends RawKeyEventData { + const TestRawKeyEventData({ + required this.logicalKey, + required this.physicalKey, + this.isControlPressed = false, + this.isShiftPressed = false, + this.isAltPressed = false, + this.isMetaPressed = false, + }); + + @override + final bool isControlPressed; + + @override + final bool isShiftPressed; + + @override + final bool isAltPressed; + + @override + final bool isMetaPressed; + + @override + final LogicalKeyboardKey logicalKey; + + @override + final PhysicalKeyboardKey physicalKey; + + @override + KeyboardSide? getModifierSide(ModifierKey key) { + throw UnimplementedError(); + } + + @override + bool isModifierPressed(ModifierKey key, + {KeyboardSide side = KeyboardSide.any}) { + throw UnimplementedError(); + } + + @override + String get keyLabel => throw UnimplementedError(); + + RawKeyEvent get toKeyEvent { + return TestRawKeyEvent(data: this); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart b/frontend/app_flowy/packages/flowy_editor/test/legacy/delta_test.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/test/delta_test.dart rename to frontend/app_flowy/packages/flowy_editor/test/legacy/delta_test.dart diff --git a/frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart b/frontend/app_flowy/packages/flowy_editor/test/legacy/flowy_editor_test.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart rename to frontend/app_flowy/packages/flowy_editor/test/legacy/flowy_editor_test.dart diff --git a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart b/frontend/app_flowy/packages/flowy_editor/test/legacy/operation_test.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/test/operation_test.dart rename to frontend/app_flowy/packages/flowy_editor/test/legacy/operation_test.dart