diff --git a/lib/src/editor/block_component/base_component/text_direction_mixin.dart b/lib/src/editor/block_component/base_component/text_direction_mixin.dart index c6cdcc12c..3023902e0 100644 --- a/lib/src/editor/block_component/base_component/text_direction_mixin.dart +++ b/lib/src/editor/block_component/base_component/text_direction_mixin.dart @@ -11,50 +11,103 @@ final _regex = RegExp( mixin BlockComponentTextDirectionMixin { Node get node; + TextDirection? lastDirection; + /// Calculate the text direction of a block component. + // defaultTextDirection will be ltr if caller hasn't passed any value. TextDirection calculateTextDirection({ TextDirection? defaultTextDirection, }) { defaultTextDirection = defaultTextDirection ?? TextDirection.ltr; - // if the block component has a text direction attribute, use it - final value = node.attributes[blockComponentTextDirection] as String?; - if (value != null && value != blockComponentTextDirectionAuto) { - return value.toTextDirection(fallback: defaultTextDirection); - } - // if the block component doesn't has a text direction attribute, but has a - // parent, use the text direction of the parent - final previousNodeContainsTextDirection = node.previousNodeWhere( - (element) => element.attributes.containsKey(blockComponentTextDirection), + final direction = calculateNodeDirection( + node: node, + defaultTextDirection: defaultTextDirection, + lastDirection: lastDirection, ); - if (value == blockComponentTextDirectionAuto && - previousNodeContainsTextDirection != null) { - final String previousValue = previousNodeContainsTextDirection - .attributes[blockComponentTextDirection]; - defaultTextDirection = - previousValue.toTextDirection(fallback: defaultTextDirection); - } - // if the value is null or the text is null or empty, - // use the default text direction - final text = node.delta?.toPlainText(); - if (value == null || text == null || text.isEmpty) { - return defaultTextDirection; + // node indent padding is added by parent node and the padding direction + // is equal to the node text direction. when the node direction is auto + // there is a special case which on typing text, the node direction could + // change without any change to parent node, because no attribute of the + // node changes as the direction attribute is auto but the calculated can + // change to rtl or ltr. in this cases we should notify parent node to + // recalculate the indent padding. + if (node.level > 1 && + direction != lastDirection && + node.attributes[blockComponentTextDirection] == + blockComponentTextDirectionAuto) { + WidgetsBinding.instance + .addPostFrameCallback((_) => node.parent?.notify()); } + lastDirection = direction; + + return direction; + } +} + +/// Calculate the text direction of a node. +// If the textDirection attribute is not set we will use defaultTextDirection. +// If the textDirection is ltr or rtl we will apply that. +// If the textDirection is auto we go by these priorities: +// 1. Determine the direction by first charachter with strong directionality +// 2. lastDirection which is the node last determined direction +// 3. previous line direction +// 4. defaultTextDirection +// We will move from first priority when for example the node text is empty or +// it only has charachters without strong directionality e.g. '@'. +TextDirection calculateNodeDirection({ + required Node node, + required TextDirection defaultTextDirection, + TextDirection? lastDirection, +}) { + defaultTextDirection = lastDirection ?? defaultTextDirection; + + // if the block component has a text direction attribute which is not auto, + // use it + final value = node.attributes[blockComponentTextDirection] as String?; + if (value != null && value != blockComponentTextDirectionAuto) { + return value.toTextDirection(fallback: defaultTextDirection); + } - // if the value is auto and the text isn't null or empty, - // calculate the text direction by the text - final matches = _regex.firstMatch(text); - if (matches != null) { - if (matches.group(1) != null) { - return TextDirection.rtl; - } else if (matches.group(2) != null) { - return TextDirection.ltr; - } + // previous line direction + final previousNodeContainsTextDirection = node.previousNodeWhere( + (element) => element.attributes.containsKey(blockComponentTextDirection), + ); + if (lastDirection == null && + value == blockComponentTextDirectionAuto && + previousNodeContainsTextDirection != null) { + final String previousValue = previousNodeContainsTextDirection + .attributes[blockComponentTextDirection]; + if (previousValue == blockComponentTextDirectionAuto) { + defaultTextDirection = + previousNodeContainsTextDirection.selectable?.textDirection() ?? + defaultTextDirection; + } else { + defaultTextDirection = + previousValue.toTextDirection(fallback: defaultTextDirection); } + } + // if the value is null or the text is null or empty, + // use the default text direction + final text = node.delta?.toPlainText(); + if (value == null || text == null || text.isEmpty) { return defaultTextDirection; } + + // if the value is auto and the text isn't null or empty, + // calculate the text direction by the text + final matches = _regex.firstMatch(text); + if (matches != null) { + if (matches.group(1) != null) { + return TextDirection.rtl; + } else if (matches.group(2) != null) { + return TextDirection.ltr; + } + } + + return defaultTextDirection; } extension on String { diff --git a/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_block_component.dart b/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_block_component.dart index 23f5268fc..b606958d5 100644 --- a/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_block_component.dart +++ b/lib/src/editor/block_component/bulleted_list_block_component/bulleted_list_block_component.dart @@ -109,12 +109,17 @@ class _BulletedListBlockComponentWidgetState Node get node => widget.node; @override - EdgeInsets get indentPadding => configuration.indentPadding( - node, - calculateTextDirection( - defaultTextDirection: Directionality.maybeOf(context), - ), + EdgeInsets get indentPadding { + TextDirection direction = + Directionality.maybeOf(context) ?? TextDirection.ltr; + if (node.children.isNotEmpty) { + direction = calculateNodeDirection( + node: node.children.first, + defaultTextDirection: direction, ); + } + return configuration.indentPadding(node, direction); + } @override Widget buildComponent(BuildContext context) { diff --git a/lib/src/editor/block_component/numbered_list_block_component/numbered_list_block_component.dart b/lib/src/editor/block_component/numbered_list_block_component/numbered_list_block_component.dart index 816f12a0d..53a240ea7 100644 --- a/lib/src/editor/block_component/numbered_list_block_component/numbered_list_block_component.dart +++ b/lib/src/editor/block_component/numbered_list_block_component/numbered_list_block_component.dart @@ -114,12 +114,17 @@ class _NumberedListBlockComponentWidgetState Node get node => widget.node; @override - EdgeInsets get indentPadding => configuration.indentPadding( - node, - calculateTextDirection( - defaultTextDirection: Directionality.maybeOf(context), - ), + EdgeInsets get indentPadding { + TextDirection direction = + Directionality.maybeOf(context) ?? TextDirection.ltr; + if (node.children.isNotEmpty) { + direction = calculateNodeDirection( + node: node.children.first, + defaultTextDirection: direction, ); + } + return configuration.indentPadding(node, direction); + } @override Widget buildComponent(BuildContext context) { diff --git a/lib/src/editor/block_component/text_block_component/text_block_component.dart b/lib/src/editor/block_component/text_block_component/text_block_component.dart index 321f8ef2d..098648c29 100644 --- a/lib/src/editor/block_component/text_block_component/text_block_component.dart +++ b/lib/src/editor/block_component/text_block_component/text_block_component.dart @@ -102,12 +102,17 @@ class _TextBlockComponentWidgetState extends State Node get node => widget.node; @override - EdgeInsets get indentPadding => configuration.indentPadding( - node, - calculateTextDirection( - defaultTextDirection: Directionality.maybeOf(context), - ), + EdgeInsets get indentPadding { + TextDirection direction = + Directionality.maybeOf(context) ?? TextDirection.ltr; + if (node.children.isNotEmpty) { + direction = calculateNodeDirection( + node: node.children.first, + defaultTextDirection: direction, ); + } + return configuration.indentPadding(node, direction); + } @override Widget buildComponent(BuildContext context) { diff --git a/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart b/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart index bcc9d6dfd..c36a2cc24 100644 --- a/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart +++ b/lib/src/editor/block_component/todo_list_block_component/todo_list_block_component.dart @@ -123,12 +123,17 @@ class _TodoListBlockComponentWidgetState Node get node => widget.node; @override - EdgeInsets get indentPadding => configuration.indentPadding( - node, - calculateTextDirection( - defaultTextDirection: Directionality.maybeOf(context), - ), + EdgeInsets get indentPadding { + TextDirection direction = + Directionality.maybeOf(context) ?? TextDirection.ltr; + if (node.children.isNotEmpty) { + direction = calculateNodeDirection( + node: node.children.first, + defaultTextDirection: direction, ); + } + return configuration.indentPadding(node, direction); + } bool get checked => widget.node.attributes[TodoListBlockKeys.checked]; diff --git a/test/new/block_component/text_direction_mixin_test.dart b/test/new/block_component/text_direction_mixin_test.dart index 72df35858..abd8f5757 100644 --- a/test/new/block_component/text_direction_mixin_test.dart +++ b/test/new/block_component/text_direction_mixin_test.dart @@ -2,6 +2,8 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../infra/testable_editor.dart'; + class TextDirectionTest with BlockComponentTextDirectionMixin { TextDirectionTest({ required this.node, @@ -40,13 +42,15 @@ void main() { expect(direction, TextDirection.ltr); }); - test('auto empty text with lastDirection', () { + test('auto empty text with last direction', () { final node = paragraphNode( text: '', textDirection: blockComponentTextDirectionAuto, ); - final direction = TextDirectionTest(node: node).calculateTextDirection( - defaultTextDirection: TextDirection.rtl, + final textDirectionTest = TextDirectionTest(node: node); + textDirectionTest.lastDirection = TextDirection.rtl; + final direction = textDirectionTest.calculateTextDirection( + defaultTextDirection: TextDirection.ltr, ); expect(direction, TextDirection.rtl); }); @@ -115,6 +119,25 @@ void main() { expect(direction, TextDirection.rtl); }); + test('use previous node direction (rtl) only when current is auto', () { + final node = pageNode( + children: [ + paragraphNode( + text: 'سلام', + textDirection: blockComponentTextDirectionRTL, + ), + paragraphNode( + text: '\$', + ), + ], + ); + final direction = + TextDirectionTest(node: node.children.last).calculateTextDirection( + defaultTextDirection: TextDirection.ltr, + ); + expect(direction, TextDirection.ltr); + }); + test( 'auto empty text don\'t use previous node direction because we can determine by the node text', () { @@ -136,5 +159,55 @@ void main() { ); expect(direction, TextDirection.ltr); }); + + test( + 'auto empty text don\'t use previous node direction when we have last direction', + () { + final node = pageNode( + children: [ + paragraphNode( + text: 'سلام', + textDirection: blockComponentTextDirectionRTL, + ), + paragraphNode( + text: '', + textDirection: blockComponentTextDirectionAuto, + ), + ], + ); + + final textDirectionTest = TextDirectionTest(node: node.children.last); + textDirectionTest.lastDirection = TextDirection.ltr; + + final direction = textDirectionTest.calculateTextDirection( + defaultTextDirection: TextDirection.rtl, + ); + expect(direction, TextDirection.ltr); + }); + }); + + group('text_direction_mixin - widget test', () { + testWidgets('use previous node direction (auto) calculated value (rtl)', + (tester) async { + final editor = tester.editor + ..addNode( + paragraphNode( + text: 'سلام', + textDirection: blockComponentTextDirectionAuto, + ), + ) + ..addNode( + paragraphNode( + text: '\$', + textDirection: blockComponentTextDirectionAuto, + ), + ); + await editor.startTesting(); + + final node = editor.nodeAtPath([1])!; + expect(node.selectable?.textDirection(), TextDirection.rtl); + + await editor.dispose(); + }); }); } diff --git a/test/new/service/shortcuts/command_shortcut_events/backspace_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/backspace_command_test.dart index 7021ce776..9e5d31720 100644 --- a/test/new/service/shortcuts/command_shortcut_events/backspace_command_test.dart +++ b/test/new/service/shortcuts/command_shortcut_events/backspace_command_test.dart @@ -467,5 +467,48 @@ void main() async { await editor.dispose(); }); + + testWidgets("clear text but keep the old direction", (tester) async { + final editor = tester.editor + ..addNode( + paragraphNode( + text: 'Hello', + textDirection: blockComponentTextDirectionLTR, + ), + ) + ..addNode( + paragraphNode( + text: 'س', + textDirection: blockComponentTextDirectionAuto, + ), + ); + await editor.startTesting(); + + Node node = editor.nodeAtPath([1])!; + expect( + node.selectable?.textDirection().name, + blockComponentTextDirectionRTL, + ); + + final selection = Selection.collapsed( + Position(path: [1], offset: 1), + ); + await editor.updateSelection(selection); + + await simulateKeyDownEvent(LogicalKeyboardKey.backspace); + await tester.pumpAndSettle(); + + node = editor.nodeAtPath([1])!; + expect( + node.delta?.toPlainText().isEmpty, + true, + ); + expect( + node.selectable?.textDirection().name, + blockComponentTextDirectionRTL, + ); + + await editor.dispose(); + }); }); } diff --git a/test/new/service/shortcuts/command_shortcut_events/indent_command_test.dart b/test/new/service/shortcuts/command_shortcut_events/indent_command_test.dart new file mode 100644 index 000000000..13fc16e47 --- /dev/null +++ b/test/new/service/shortcuts/command_shortcut_events/indent_command_test.dart @@ -0,0 +1,170 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../infra/testable_editor.dart'; + +void main() async { + group('indentCommand - widget test indent padding', () { + testWidgets("indent LTR line under LTR line", (tester) async { + final editor = await indentTestHelper( + tester, + ('Hello', blockComponentTextDirectionLTR), + ('Will indent this', blockComponentTextDirectionLTR), + ); + + final node = editor.nodeAtPath([0])!; + final nestedBlock = node.key.currentState! + .unwrapOrNull(); + + expect(nestedBlock?.indentPadding.left, 30); + expect(nestedBlock?.indentPadding.right, 0); + + await editor.dispose(); + }); + + testWidgets("indent LTR line under RTL line", (tester) async { + final editor = await indentTestHelper( + tester, + ('سلام', blockComponentTextDirectionRTL), + ('Will indent this', blockComponentTextDirectionLTR), + ); + + final node = editor.nodeAtPath([0])!; + final nestedBlock = node.key.currentState! + .unwrapOrNull(); + + expect(nestedBlock?.indentPadding.left, 30); + expect(nestedBlock?.indentPadding.right, 0); + + await editor.dispose(); + }); + + testWidgets("indent RTL line under RTL line", (tester) async { + final editor = await indentTestHelper( + tester, + ('سلام', blockComponentTextDirectionRTL), + ('خط دوم', blockComponentTextDirectionRTL), + ); + + await simulateKeyDownEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + final node = editor.nodeAtPath([0])!; + final nestedBlock = node.key.currentState! + .unwrapOrNull(); + + expect(nestedBlock?.indentPadding.left, 0); + expect(nestedBlock?.indentPadding.right, 30); + + await editor.dispose(); + }); + + testWidgets("indent RTL line under LTR line", (tester) async { + final editor = await indentTestHelper( + tester, + ('Hello', blockComponentTextDirectionLTR), + ('خط دوم', blockComponentTextDirectionRTL), + ); + + await simulateKeyDownEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + final node = editor.nodeAtPath([0])!; + final nestedBlock = node.key.currentState! + .unwrapOrNull(); + + expect(nestedBlock?.indentPadding.left, 0); + expect(nestedBlock?.indentPadding.right, 30); + + await editor.dispose(); + }); + + testWidgets("indent AUTO line under AUTO line", (tester) async { + final editor = await indentTestHelper( + tester, + ('سلام', blockComponentTextDirectionAuto), + ('خط دوم', blockComponentTextDirectionAuto), + ); + + await simulateKeyDownEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + final node = editor.nodeAtPath([0])!; + final nestedBlock = node.key.currentState! + .unwrapOrNull(); + + expect(nestedBlock?.indentPadding.left, 0); + expect(nestedBlock?.indentPadding.right, 30); + + await editor.dispose(); + }); + + // TODO(.): The purpose of this test is to catch addPostFrameCallback from + // calculateTextDirection but it doesn't catch it. Commenting the callback + // out doesn't make this test fail. + testWidgets( + "indent AUTO line under AUTO line changing the second line calculated direction", + (tester) async { + final editor = await indentTestHelper( + tester, + ('سلام', blockComponentTextDirectionAuto), + ('س', blockComponentTextDirectionAuto), + ); + + await simulateKeyDownEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + Node node = editor.nodeAtPath([0])!; + final nestedBlock = node.key.currentState! + .unwrapOrNull(); + + expect(nestedBlock?.indentPadding.left, 0); + expect(nestedBlock?.indentPadding.right, 30); + + final selection = Selection.single( + path: [0, 0], + startOffset: 0, + endOffset: 1, + ); + await editor.updateSelection(selection); + await editor.ime.typeText('a'); + + node = editor.nodeAtPath([0])!; + final nestedBlockAfter = node.key.currentState! + .unwrapOrNull(); + + expect(nestedBlockAfter?.indentPadding.left, 30); + expect(nestedBlockAfter?.indentPadding.right, 0); + + await editor.dispose(); + }); + }); +} + +typedef TestLine = (String, String); + +Future indentTestHelper( + WidgetTester tester, + TestLine firstLine, + TestLine secondLine, +) async { + final editor = tester.editor + ..addNode(paragraphNode(text: firstLine.$1, textDirection: firstLine.$2)) + ..addNode(paragraphNode(text: secondLine.$1, textDirection: secondLine.$2)); + await editor.startTesting(); + + final selection = Selection.collapsed( + Position(path: [1], offset: 1), + ); + await editor.updateSelection(selection); + + await simulateKeyDownEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + final node = editor.nodeAtPath([0])!; + expect(node.delta?.toPlainText(), firstLine.$1); + expect(node.children.first.level, 2); + + return editor; +}