diff --git a/lib/src/commands/command_extension.dart b/lib/src/commands/command_extension.dart index 8a9648f6a55e..d1a4d865850e 100644 --- a/lib/src/commands/command_extension.dart +++ b/lib/src/commands/command_extension.dart @@ -25,6 +25,7 @@ extension CommandExtension on EditorState { } else if (path != null) { return document.nodeAtPath(path)!; } + throw Exception('path and node cannot be null at the same time'); } @@ -37,19 +38,25 @@ extension CommandExtension on EditorState { } else if (path != null) { return document.nodeAtPath(path)! as TextNode; } + throw Exception('path and node cannot be null at the same time'); } Selection getSelection( Selection? selection, ) { - final currentSelection = service.selectionService.currentSelection.value; if (selection != null) { return selection; - } else if (currentSelection != null) { + } + + final currentSelection = service.selectionService.currentSelection.value; + if (currentSelection != null) { return currentSelection; } - throw Exception('path and textNode cannot be null at the same time'); + + throw Exception( + 'selection and selectionService.currentSelection cannot be null at the same time', + ); } String getTextInSelection( @@ -57,6 +64,7 @@ extension CommandExtension on EditorState { Selection selection, ) { List res = []; + if (selection.isSingle) { final plainText = textNodes.first.toPlainText(); res.add(plainText.substring(selection.startIndex, selection.endIndex)); @@ -77,6 +85,7 @@ extension CommandExtension on EditorState { } } } + return res.join('\n'); } } diff --git a/lib/src/commands/text/text_commands.dart b/lib/src/commands/text/text_commands.dart index 3a6c62aa62b9..8b2f520025bc 100644 --- a/lib/src/commands/text/text_commands.dart +++ b/lib/src/commands/text/text_commands.dart @@ -29,7 +29,6 @@ extension TextCommands on EditorState { } Future formatText( - EditorState editorState, Selection? selection, Attributes attributes, { Path? path, @@ -43,7 +42,6 @@ extension TextCommands on EditorState { } Future formatTextWithBuiltInAttribute( - EditorState editorState, String key, Attributes attributes, { Selection? selection, @@ -71,13 +69,11 @@ extension TextCommands on EditorState { } Future formatTextToCheckbox( - EditorState editorState, bool check, { Path? path, TextNode? textNode, }) async { return formatTextWithBuiltInAttribute( - editorState, BuiltInAttributeKey.checkbox, {BuiltInAttributeKey.checkbox: check}, path: path, @@ -86,13 +82,11 @@ extension TextCommands on EditorState { } Future formatLinkInText( - EditorState editorState, String? link, { Path? path, TextNode? textNode, }) async { return formatTextWithBuiltInAttribute( - editorState, BuiltInAttributeKey.href, {BuiltInAttributeKey.href: link}, path: path, diff --git a/lib/src/render/rich_text/checkbox_text.dart b/lib/src/render/rich_text/checkbox_text.dart index d2b9cbbda2ff..c8682272c81f 100644 --- a/lib/src/render/rich_text/checkbox_text.dart +++ b/lib/src/render/rich_text/checkbox_text.dart @@ -85,7 +85,6 @@ class _CheckboxNodeWidgetState extends State behavior: HitTestBehavior.opaque, onTap: () async { await widget.editorState.formatTextToCheckbox( - widget.editorState, !check, textNode: widget.textNode, ); diff --git a/lib/src/render/toolbar/toolbar_item.dart b/lib/src/render/toolbar/toolbar_item.dart index 844ab4b2b67e..f71fd96f7d44 100644 --- a/lib/src/render/toolbar/toolbar_item.dart +++ b/lib/src/render/toolbar/toolbar_item.dart @@ -401,7 +401,6 @@ void showLinkMenu( }, onSubmitted: (text) async { await editorState.formatLinkInText( - editorState, text, textNode: textNode, ); diff --git a/test/command/command_extension_test.dart b/test/command/command_extension_test.dart deleted file mode 100644 index 1c7325987b46..000000000000 --- a/test/command/command_extension_test.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../infra/test_editor.dart'; - -void main() { - group('command_extension.dart', () { - testWidgets('insert a new checkbox after an exsiting checkbox', - (tester) async { - final editor = tester.editor - ..insertTextNode( - 'Welcome', - ) - ..insertTextNode( - 'to', - ) - ..insertTextNode( - 'Appflowy 😁', - ); - await editor.startTesting(); - final selection = Selection( - start: Position(path: [2], offset: 5), - end: Position(path: [0], offset: 5), - ); - await editor.updateSelection(selection); - final textNodes = editor - .editorState.service.selectionService.currentSelectedNodes - .whereType() - .toList(growable: false); - final text = editor.editorState.getTextInSelection( - textNodes.normalized, - selection.normalized, - ); - expect(text, 'me\nto\nAppfl'); - }); - }); -} diff --git a/test/commands/command_extension_test.dart b/test/commands/command_extension_test.dart new file mode 100644 index 000000000000..76564115c43e --- /dev/null +++ b/test/commands/command_extension_test.dart @@ -0,0 +1,142 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../infra/test_editor.dart'; + +void main() { + group('command_extension.dart', () { + testWidgets('getTextInSelection w/ multiple nodes', (tester) async { + final editor = tester.editor + ..insertTextNode( + 'Welcome', + ) + ..insertTextNode( + 'to', + ) + ..insertTextNode( + 'Appflowy 😁', + ); + + await editor.startTesting(); + + final selection = Selection( + start: Position(path: [2], offset: 5), + end: Position(path: [0], offset: 5), + ); + + await editor.updateSelection(selection); + + final textNodes = editor + .editorState.service.selectionService.currentSelectedNodes + .whereType() + .toList(growable: false); + + final text = editor.editorState.getTextInSelection( + textNodes.normalized, + selection.normalized, + ); + + expect(text, 'me\nto\nAppfl'); + }); + + testWidgets('getTextInSelection where selection.isSingle', (tester) async { + final editor = tester.editor + ..insertTextNode( + 'Welcome', + ) + ..insertTextNode( + 'to', + ) + ..insertTextNode( + 'Appflowy 😁', + ); + + await editor.startTesting(); + + final selection = Selection( + start: Position(path: [0], offset: 3), + end: Position(path: [0]), + ); + + await editor.updateSelection(selection); + + final textNodes = editor + .editorState.service.selectionService.currentSelectedNodes + .whereType() + .toList(growable: false); + + final text = editor.editorState.getTextInSelection( + textNodes.normalized, + selection.normalized, + ); + + expect(text, 'Wel'); + }); + + testWidgets('getNode throws if node and path are null', (tester) async { + final editor = tester.editor; + await editor.startTesting(); + + expect(() => editor.editorState.getNode(), throwsA(isA())); + }); + + testWidgets('getNode by path', (tester) async { + final editor = tester.editor + ..insertTextNode( + 'Welcome', + ) + ..insertTextNode( + 'to', + ) + ..insertTextNode( + 'Appflowy 😁', + ); + + await editor.startTesting(); + + final node = editor.editorState.getNode(path: [0]) as TextNode; + + expect(node.type, 'text'); + expect((node.delta.first as TextInsert).text, 'Welcome'); + }); + + testWidgets('getTextNode throws if textNode and path are null', + (tester) async { + final editor = tester.editor; + await editor.startTesting(); + + expect(() => editor.editorState.getTextNode(), throwsA(isA())); + }); + + testWidgets('getTextNode by path', (tester) async { + final editor = tester.editor + ..insertTextNode( + 'Welcome', + ) + ..insertTextNode( + 'to', + ) + ..insertTextNode( + 'Appflowy 😁', + ); + + await editor.startTesting(); + + final node = editor.editorState.getTextNode(path: [1]); + + expect(node.type, 'text'); + expect((node.delta.first as TextInsert).text, 'to'); + }); + + testWidgets( + 'getSelection throws if selection and selectionService.currentSelection are null', + (tester) async { + final editor = tester.editor; + await editor.startTesting(); + + expect( + () => editor.editorState.getSelection(null), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/commands/text_commands_test.dart b/test/commands/text_commands_test.dart new file mode 100644 index 000000000000..ad369eeaf7e0 --- /dev/null +++ b/test/commands/text_commands_test.dart @@ -0,0 +1,219 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../infra/test_editor.dart'; + +void main() { + group('TextCommands extension tests', () { + testWidgets('insertText', (tester) async { + final editor = tester.editor + ..insertEmptyTextNode() + ..insertTextNode('World'); + await editor.startTesting(); + + editor.editorState.insertText(0, 'Hello', path: [0]); + await tester.pumpAndSettle(); + + expect( + (editor.editorState.getTextNode(path: [0]).delta.first as TextInsert) + .text, + 'Hello', + ); + }); + + testWidgets('insertTextAtCurrentSelection', (tester) async { + final editor = tester.editor..insertTextNode('Helrld!'); + await editor.startTesting(); + + final selection = Selection( + start: Position(path: [0], offset: 3), + end: Position(path: [0], offset: 3), + ); + + await editor.updateSelection(selection); + + editor.editorState.insertTextAtCurrentSelection('lo Wo'); + await tester.pumpAndSettle(); + + expect( + (editor.editorState.getTextNode(path: [0]).delta.first as TextInsert) + .text, + 'Hello World!', + ); + }); + + testWidgets('formatText', (tester) async { + final editor = tester.editor..insertTextNode('Hello'); + await editor.startTesting(); + + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: 5), + ); + + await editor.updateSelection(selection); + + editor.editorState + .formatText(null, {BuiltInAttributeKey.bold: true}, path: [0]); + await tester.pumpAndSettle(); + + final textNode = editor.editorState.getTextNode(path: [0]); + final textInsert = textNode.delta.first as TextInsert; + + expect(textInsert.text, 'Hello'); + expect(textInsert.attributes?[BuiltInAttributeKey.bold], true); + }); + + testWidgets('formatTextWithBuiltInAttribute w/ Partial Style Key', + (tester) async { + final editor = tester.editor..insertTextNode('Hello'); + await editor.startTesting(); + + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: 5), + ); + + await editor.updateSelection(selection); + + editor.editorState.formatTextWithBuiltInAttribute( + BuiltInAttributeKey.underline, + {BuiltInAttributeKey.underline: true}, + path: [0], + ); + await tester.pumpAndSettle(); + + final textNode = editor.editorState.getTextNode(path: [0]); + final textInsert = textNode.delta.first as TextInsert; + + expect(textInsert.text, 'Hello'); + expect(textInsert.attributes?[BuiltInAttributeKey.underline], true); + }); + + testWidgets('formatTextWithBuiltInAttribute w/ Global Style Key', + (tester) async { + final editor = tester.editor + ..insertTextNode( + 'Hello', + + /// Formatting global style over another global style, + /// will remove the existing one before adding the new one + attributes: {BuiltInAttributeKey.checkbox: true}, + ); + await editor.startTesting(); + + editor.editorState.formatTextWithBuiltInAttribute( + BuiltInAttributeKey.heading, + {BuiltInAttributeKey.heading: BuiltInAttributeKey.h1}, + path: [0], + ); + await tester.pumpAndSettle(); + + final textNode = editor.editorState.getTextNode(path: [0]); + final textInsert = textNode.delta.first as TextInsert; + + expect(textInsert.text, 'Hello'); + expect(textNode.attributes.heading, BuiltInAttributeKey.h1); + expect(textNode.attributes['subtype'], BuiltInAttributeKey.heading); + }); + + testWidgets('formatTextToCheckbox', (tester) async { + final editor = tester.editor..insertTextNode('TextNode to Checkbox'); + await editor.startTesting(); + + editor.editorState.formatTextToCheckbox(false, path: [0]); + await tester.pumpAndSettle(); + + final checkboxNode = editor.editorState.getNode(path: [0]); + + expect(checkboxNode.attributes.check, false); + expect(checkboxNode.attributes['subtype'], BuiltInAttributeKey.checkbox); + }); + + testWidgets('formatLinkInText', (tester) async { + const href = "https://appflowy.io/"; + + final editor = tester.editor..insertTextNode('TextNode to Link'); + await editor.startTesting(); + + final selection = Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: 5), + ); + + await editor.updateSelection(selection); + + editor.editorState.formatLinkInText(href, path: [0]); + await tester.pumpAndSettle(); + + final textNode = editor.editorState.getTextNode(path: [0]); + final textInsert = textNode.delta.first as TextInsert; + + expect(textInsert.attributes?[BuiltInAttributeKey.href], href); + }); + + testWidgets('insertNewLine', (tester) async { + final editor = tester.editor; + await editor.startTesting(); + + expect(editor.documentLength, 0); + + editor.editorState.insertNewLine(path: [0]); + await tester.pumpAndSettle(); + + expect(editor.documentLength, 1); + }); + + testWidgets('insertNewLine without path', (tester) async { + final editor = tester.editor..insertTextNode('Hello World'); + await editor.startTesting(); + + expect(editor.documentLength, 1); + + final selection = Selection( + start: Position(path: [0], offset: 5), + end: Position(path: [0], offset: 5), + ); + + await editor.updateSelection(selection); + + editor.editorState.insertNewLine(path: null); + await tester.pumpAndSettle(); + + expect(editor.documentLength, 2); + + final textNode = editor.editorState.getTextNode(path: [0]); + final textInsert = textNode.delta.first as TextInsert; + + expect(textInsert.text, 'Hello World'); + }); + + testWidgets('insertNewLineAtCurrentSelection', (tester) async { + final editor = tester.editor..insertTextNode('HelloWorld'); + await editor.startTesting(); + + final selection = Selection( + start: Position(path: [0], offset: 5), + end: Position(path: [0], offset: 5), + ); + + await editor.updateSelection(selection); + + expect(editor.documentLength, 1); + + editor.editorState.insertNewLineAtCurrentSelection(); + await tester.pumpAndSettle(); + + expect(editor.documentLength, 2); + + final firstTextNode = editor.editorState.getTextNode(path: [0]); + final firstTextInsert = firstTextNode.delta.first as TextInsert; + expect(firstTextInsert.text, 'Hello'); + + final secondTextNode = editor.editorState.getTextNode(path: [1]); + final secondTextInsert = secondTextNode.delta.first as TextInsert; + + expect(secondTextInsert.text, 'World'); + }); + }); +}