diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/extensions/text_node_extensions.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/extensions/text_node_extensions.dart index 3408546c42..315f529710 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/extensions/text_node_extensions.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/extensions/text_node_extensions.dart @@ -41,6 +41,30 @@ extension TextNodeExtension on TextNode { } return true; } + + bool allNotSatisfyInSelection(String styleKey, Selection selection) { + final ops = delta.whereType(); + final startOffset = + selection.isBackward ? selection.start.offset : selection.end.offset; + final endOffset = + selection.isBackward ? selection.end.offset : selection.start.offset; + var start = 0; + for (final op in ops) { + if (start >= endOffset) { + break; + } + final length = op.length; + if (start < endOffset && start + length > startOffset) { + if (op.attributes != null && + op.attributes!.containsKey(styleKey) && + op.attributes![styleKey] == true) { + return false; + } + } + start += length; + } + return true; + } } extension TextNodesExtension on List { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/checkbox_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/checkbox_text.dart index c7ba8607a6..bfcc938b8b 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/checkbox_text.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/checkbox_text.dart @@ -72,8 +72,8 @@ class _CheckboxNodeWidgetState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( + key: iconKey, child: FlowySvg( - key: iconKey, size: Size.square(_iconSize), padding: EdgeInsets.only( top: topPadding, right: _iconRightPadding), @@ -149,7 +149,11 @@ class _CheckboxNodeWidgetState extends State style: widget.textNode.attributes.check ? span.style?.copyWith( color: Colors.grey, - decoration: TextDecoration.lineThrough, + decoration: TextDecoration.combine([ + TextDecoration.lineThrough, + if (span.style?.decoration != null) + span.style!.decoration! + ]), ) : span.style, recognizer: span.recognizer, diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart index 0deb3d44d2..0b0b834936 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart @@ -9,6 +9,13 @@ import 'package:flowy_editor/src/operation/transaction_builder.dart'; import 'package:flowy_editor/src/render/rich_text/rich_text_style.dart'; import 'package:flowy_editor/src/service/keyboard_service.dart'; +@visibleForTesting +List get checkboxListSymbols => _checkboxListSymbols; +@visibleForTesting +List get unCheckboxListSymbols => _unCheckboxListSymbols; +@visibleForTesting +List get bulletedListSymbols => _bulletedListSymbols; + const _bulletedListSymbols = ['*', '-']; const _checkboxListSymbols = ['[x]', '-[x]']; const _unCheckboxListSymbols = ['[]', '-[]']; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/service.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/service.dart index fc2e4e3f31..158aab4615 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/service.dart @@ -1,7 +1,4 @@ -import 'package:flowy_editor/src/service/keyboard_service.dart'; -import 'package:flowy_editor/src/service/render_plugin_service.dart'; -import 'package:flowy_editor/src/service/scroll_service.dart'; -import 'package:flowy_editor/src/service/selection_service.dart'; +import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/src/service/toolbar_service.dart'; import 'package:flutter/material.dart'; @@ -26,6 +23,13 @@ class FlowyService { // input service final inputServiceKey = GlobalKey(debugLabel: 'flowy_input_service'); + FlowyInputService? get inputService { + if (inputServiceKey.currentState != null && + inputServiceKey.currentState is FlowyInputService) { + return inputServiceKey.currentState! as FlowyInputService; + } + return null; + } // render plugin service late FlowyRenderPlugin renderPluginService; 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 index 533cace586..17b0a95318 100644 --- a/frontend/app_flowy/packages/flowy_editor/test/infra/test_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/test/infra/test_editor.dart @@ -47,13 +47,11 @@ class EditorWidgetTester { insert(TextNode.empty()); } - void insertTextNode(String? text, {Attributes? attributes}) { + void insertTextNode(String? text, {Attributes? attributes, Delta? delta}) { insert( TextNode( type: 'text', - delta: Delta( - [TextInsert(text ?? 'Test')], - ), + delta: delta ?? Delta([TextInsert(text ?? 'Test')]), attributes: attributes, ), ); @@ -70,6 +68,31 @@ class EditorWidgetTester { _editorState.service.selectionService.updateSelection(selection); } await tester.pumpAndSettle(); + + expect(_editorState.service.selectionService.currentSelection.value, + selection); + } + + Future insertText(TextNode textNode, String text, int offset, + {Selection? selection}) async { + await apply([ + TextEditingDeltaInsertion( + oldText: textNode.toRawString(), + textInserted: text, + insertionOffset: offset, + selection: selection != null + ? TextSelection( + baseOffset: selection.start.offset, + extentOffset: selection.end.offset) + : TextSelection.collapsed(offset: offset), + composing: TextRange.empty, + ) + ]); + } + + Future apply(List deltas) async { + _editorState.service.inputService?.apply(deltas); + await tester.pumpAndSettle(); } Future pressLogicKey( 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 index e4eb99b60e..04b5a11789 100644 --- 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 @@ -79,6 +79,9 @@ extension on LogicalKeyboardKey { if (this == LogicalKeyboardKey.enter) { return PhysicalKeyboardKey.enter; } + if (this == LogicalKeyboardKey.space) { + return PhysicalKeyboardKey.space; + } if (this == LogicalKeyboardKey.backspace) { return PhysicalKeyboardKey.backspace; } diff --git a/frontend/app_flowy/packages/flowy_editor/test/render/rich_text/checkbox_text_test.dart b/frontend/app_flowy/packages/flowy_editor/test/render/rich_text/checkbox_text_test.dart new file mode 100644 index 0000000000..84f6b93990 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/test/render/rich_text/checkbox_text_test.dart @@ -0,0 +1,73 @@ +import 'package:flowy_editor/flowy_editor.dart'; +import 'package:flowy_editor/src/render/rich_text/default_selectable.dart'; +import 'package:flowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:flowy_editor/src/extensions/text_node_extensions.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('delete_text_handler.dart', () { + testWidgets('Presses backspace key in empty document', (tester) async { + // Before + // + // [BIUS]Welcome to Appflowy 😁[BIUS] + // + // After + // + // [checkbox]Welcome to Appflowy 😁 + // + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode( + '', + attributes: { + StyleKey.subtype: StyleKey.checkbox, + StyleKey.checkbox: false, + }, + delta: Delta([ + TextInsert(text, { + StyleKey.bold: true, + StyleKey.italic: true, + StyleKey.underline: true, + StyleKey.strikethrough: true, + }), + ]), + ); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + + final selection = + Selection.single(path: [0], startOffset: 0, endOffset: text.length); + var node = editor.nodeAtPath([0]) as TextNode; + var state = node.key?.currentState as DefaultSelectable; + var checkboxWidget = find.byKey(state.iconKey!); + await tester.tap(checkboxWidget); + await tester.pumpAndSettle(); + + expect(node.attributes.check, true); + + expect(node.allSatisfyBoldInSelection(selection), true); + expect(node.allSatisfyItalicInSelection(selection), true); + expect(node.allSatisfyUnderlineInSelection(selection), true); + expect(node.allSatisfyStrikethroughInSelection(selection), true); + + node = editor.nodeAtPath([0]) as TextNode; + state = node.key?.currentState as DefaultSelectable; + await tester.ensureVisible(find.byKey(state.iconKey!)); + await tester.tap(find.byKey(state.iconKey!)); + await tester.pump(); + + expect(node.attributes.check, false); + expect(node.allSatisfyBoldInSelection(selection), true); + expect(node.allSatisfyItalicInSelection(selection), true); + expect(node.allSatisfyUnderlineInSelection(selection), true); + expect(node.allSatisfyStrikethroughInSelection(selection), true); + }); + }); +} diff --git a/frontend/app_flowy/packages/flowy_editor/test/service/internal_key_event_handlers/update_text_style_by_command_x_handler_test.dart b/frontend/app_flowy/packages/flowy_editor/test/service/internal_key_event_handlers/update_text_style_by_command_x_handler_test.dart index 39c750a933..e91f089def 100644 --- a/frontend/app_flowy/packages/flowy_editor/test/service/internal_key_event_handlers/update_text_style_by_command_x_handler_test.dart +++ b/frontend/app_flowy/packages/flowy_editor/test/service/internal_key_event_handlers/update_text_style_by_command_x_handler_test.dart @@ -18,7 +18,6 @@ void main() async { LogicalKeyboardKey.keyB, ); }); - testWidgets('Presses Command + I to update text style', (tester) async { await _testUpdateTextStyleByCommandX( tester, @@ -26,7 +25,6 @@ void main() async { LogicalKeyboardKey.keyI, ); }); - testWidgets('Presses Command + U to update text style', (tester) async { await _testUpdateTextStyleByCommandX( tester, @@ -34,7 +32,6 @@ void main() async { LogicalKeyboardKey.keyU, ); }); - testWidgets('Presses Command + S to update text style', (tester) async { await _testUpdateTextStyleByCommandX( tester, @@ -83,5 +80,49 @@ Future _testUpdateTextStyleByCommandX( isMetaPressed: true, ); textNode = editor.nodeAtPath([1]) as TextNode; - expect(textNode.allSatisfyInSelection(matchStyle, selection), false); + expect(textNode.allNotSatisfyInSelection(matchStyle, selection), true); + + selection = Selection( + start: Position(path: [0], offset: 0), + end: Position(path: [2], offset: text.length), + ); + await editor.updateSelection(selection); + await editor.pressLogicKey( + key, + isShiftPressed: key == LogicalKeyboardKey.keyS, + isMetaPressed: true, + ); + var nodes = editor.editorState.service.selectionService.currentSelectedNodes + .whereType(); + expect(nodes.length, 3); + for (final node in nodes) { + expect( + node.allSatisfyInSelection( + matchStyle, + Selection.single( + path: node.path, startOffset: 0, endOffset: text.length), + ), + true, + ); + } + + await editor.updateSelection(selection); + await editor.pressLogicKey( + key, + isShiftPressed: key == LogicalKeyboardKey.keyS, + isMetaPressed: true, + ); + nodes = editor.editorState.service.selectionService.currentSelectedNodes + .whereType(); + expect(nodes.length, 3); + for (final node in nodes) { + expect( + node.allNotSatisfyInSelection( + matchStyle, + Selection.single( + path: node.path, startOffset: 0, endOffset: text.length), + ), + true, + ); + } } diff --git a/frontend/app_flowy/packages/flowy_editor/test/service/internal_key_event_handlers/white_space_handler_test.dart b/frontend/app_flowy/packages/flowy_editor/test/service/internal_key_event_handlers/white_space_handler_test.dart new file mode 100644 index 0000000000..fb4c187f0f --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/test/service/internal_key_event_handlers/white_space_handler_test.dart @@ -0,0 +1,178 @@ +import 'package:flowy_editor/flowy_editor.dart'; +import 'package:flowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:flowy_editor/src/service/internal_key_event_handlers/whitespace_handler.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('white_space_handler.dart', () { + // Before + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + // After + // [h1]Welcome to Appflowy 😁 + // [h2]Welcome to Appflowy 😁 + // [h3]Welcome to Appflowy 😁 + // [h4]Welcome to Appflowy 😁 + // [h5]Welcome to Appflowy 😁 + // [h6]Welcome to Appflowy 😁 + // + testWidgets('Presses whitespace key after #*', (tester) async { + const maxSignCount = 6; + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor; + for (var i = 1; i <= maxSignCount; i++) { + editor.insertTextNode('${'#' * i}$text'); + } + await editor.startTesting(); + + for (var i = 1; i <= maxSignCount; i++) { + await editor.updateSelection( + Selection.single(path: [i - 1], startOffset: i), + ); + await editor.pressLogicKey(LogicalKeyboardKey.space); + + final textNode = (editor.nodeAtPath([i - 1]) as TextNode); + + expect(textNode.subtype, StyleKey.heading); + // StyleKey.h1 ~ StyleKey.h6 + expect(textNode.attributes.heading, 'h$i'); + } + }); + + // Before + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + // After + // [h1]##Welcome to Appflowy 😁 + // [h2]##Welcome to Appflowy 😁 + // [h3]##Welcome to Appflowy 😁 + // [h4]##Welcome to Appflowy 😁 + // [h5]##Welcome to Appflowy 😁 + // [h6]##Welcome to Appflowy 😁 + // + testWidgets('Presses whitespace key inside #*', (tester) async { + const maxSignCount = 6; + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor; + for (var i = 1; i <= maxSignCount; i++) { + editor.insertTextNode('${'###' * i}$text'); + } + await editor.startTesting(); + + for (var i = 1; i <= maxSignCount; i++) { + await editor.updateSelection( + Selection.single(path: [i - 1], startOffset: i), + ); + await editor.pressLogicKey(LogicalKeyboardKey.space); + + final textNode = (editor.nodeAtPath([i - 1]) as TextNode); + + expect(textNode.subtype, StyleKey.heading); + // StyleKey.h1 ~ StyleKey.h6 + expect(textNode.attributes.heading, 'h$i'); + expect(textNode.toRawString().startsWith('##'), true); + } + }); + + // Before + // + // Welcome to Appflowy 😁 + // + // After + // [h1 ~ h6]##Welcome to Appflowy 😁 + // + testWidgets('Presses whitespace key in heading styled text', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..insertTextNode(text); + + await editor.startTesting(); + + const maxSignCount = 6; + for (var i = 1; i <= maxSignCount; i++) { + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + + final textNode = (editor.nodeAtPath([0]) as TextNode); + + await editor.insertText(textNode, '#' * i, 0); + await editor.pressLogicKey(LogicalKeyboardKey.space); + + expect(textNode.subtype, StyleKey.heading); + // StyleKey.h2 ~ StyleKey.h6 + expect(textNode.attributes.heading, 'h$i'); + } + }); + + testWidgets('Presses whitespace key after (un)checkbox symbols', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..insertTextNode(text); + await editor.startTesting(); + + final textNode = editor.nodeAtPath([0]) as TextNode; + for (final symbol in unCheckboxListSymbols) { + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + await editor.insertText(textNode, symbol, 0); + await editor.pressLogicKey(LogicalKeyboardKey.space); + expect(textNode.subtype, StyleKey.checkbox); + expect(textNode.attributes.check, false); + } + }); + + testWidgets('Presses whitespace key after checkbox symbols', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..insertTextNode(text); + await editor.startTesting(); + + final textNode = editor.nodeAtPath([0]) as TextNode; + for (final symbol in checkboxListSymbols) { + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + await editor.insertText(textNode, symbol, 0); + await editor.pressLogicKey(LogicalKeyboardKey.space); + expect(textNode.subtype, StyleKey.checkbox); + expect(textNode.attributes.check, true); + } + }); + + testWidgets('Presses whitespace key after bulleted list', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..insertTextNode(text); + await editor.startTesting(); + + final textNode = editor.nodeAtPath([0]) as TextNode; + for (final symbol in bulletedListSymbols) { + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + await editor.insertText(textNode, symbol, 0); + await editor.pressLogicKey(LogicalKeyboardKey.space); + expect(textNode.subtype, StyleKey.bulletedList); + } + }); + }); +}