diff --git a/super_editor/lib/src/default_editor/list_items.dart b/super_editor/lib/src/default_editor/list_items.dart index f663143c2..f701d6b55 100644 --- a/super_editor/lib/src/default_editor/list_items.dart +++ b/super_editor/lib/src/default_editor/list_items.dart @@ -9,6 +9,7 @@ import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/core/styles.dart'; import 'package:super_editor/src/default_editor/attributions.dart'; import 'package:super_editor/src/default_editor/blocks/indentation.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; @@ -161,11 +162,16 @@ class ListItemComponentBuilder implements ComponentBuilder { ordinalValue = computeListItemOrdinalValue(node, document); } + final textDirection = getParagraphDirection(node.text.toPlainText()); + final textAlignment = textDirection == TextDirection.ltr ? TextAlign.left : TextAlign.right; + return switch (node.type) { ListItemType.unordered => UnorderedListItemComponentViewModel( nodeId: node.id, indent: node.indent, text: node.text, + textDirection: textDirection, + textAlignment: textAlignment, textStyleBuilder: noStyleBuilder, selectionColor: const Color(0x00000000), ), @@ -174,6 +180,8 @@ class ListItemComponentBuilder implements ComponentBuilder { indent: node.indent, ordinalValue: ordinalValue, text: node.text, + textDirection: textDirection, + textAlignment: textAlignment, textStyleBuilder: noStyleBuilder, selectionColor: const Color(0x00000000), ), @@ -196,6 +204,8 @@ class ListItemComponentBuilder implements ComponentBuilder { indent: componentViewModel.indent, dotStyle: componentViewModel.dotStyle, textSelection: componentViewModel.selection, + textDirection: componentViewModel.textDirection, + textAlignment: componentViewModel.textAlignment, selectionColor: componentViewModel.selectionColor, highlightWhenEmpty: componentViewModel.highlightWhenEmpty, underlines: componentViewModel.createUnderlines(), @@ -206,6 +216,8 @@ class ListItemComponentBuilder implements ComponentBuilder { indent: componentViewModel.indent, listIndex: componentViewModel.ordinalValue!, text: componentViewModel.text, + textDirection: componentViewModel.textDirection, + textAlignment: componentViewModel.textAlignment, styleBuilder: componentViewModel.textStyleBuilder, numeralStyle: componentViewModel.numeralStyle, textSelection: componentViewModel.selection, @@ -281,6 +293,7 @@ abstract class ListItemComponentViewModel extends SingleColumnLayoutComponentVie indent == other.indent && text == other.text && textDirection == other.textDirection && + textAlignment == other.textAlignment && selection == other.selection && selectionColor == other.selectionColor && highlightWhenEmpty == other.highlightWhenEmpty && @@ -298,6 +311,7 @@ abstract class ListItemComponentViewModel extends SingleColumnLayoutComponentVie indent.hashCode ^ text.hashCode ^ textDirection.hashCode ^ + textAlignment.hashCode ^ selection.hashCode ^ selectionColor.hashCode ^ highlightWhenEmpty.hashCode ^ @@ -355,6 +369,7 @@ class UnorderedListItemComponentViewModel extends ListItemComponentViewModel { textStyleBuilder: textStyleBuilder, dotStyle: dotStyle, textDirection: textDirection, + textAlignment: textAlignment, selection: selection, selectionColor: selectionColor, composingRegion: composingRegion, @@ -423,6 +438,7 @@ class OrderedListItemComponentViewModel extends ListItemComponentViewModel { text: text, textStyleBuilder: textStyleBuilder, textDirection: textDirection, + textAlignment: textAlignment, selection: selection, selectionColor: selectionColor, composingRegion: composingRegion, @@ -493,6 +509,8 @@ class UnorderedListItemComponent extends StatefulWidget { Key? key, required this.componentKey, required this.text, + this.textDirection = TextDirection.ltr, + this.textAlignment = TextAlign.left, required this.styleBuilder, this.inlineWidgetBuilders = const [], this.dotBuilder = _defaultUnorderedListItemDotBuilder, @@ -510,6 +528,8 @@ class UnorderedListItemComponent extends StatefulWidget { final GlobalKey componentKey; final AttributedText text; + final TextDirection textDirection; + final TextAlign textAlignment; final AttributionStyleBuilder styleBuilder; final InlineWidgetBuilderChain inlineWidgetBuilders; final UnorderedListItemDotBuilder dotBuilder; @@ -558,34 +578,39 @@ class _UnorderedListItemComponentState extends State return ProxyTextDocumentComponent( key: widget.componentKey, textComponentKey: _innerTextComponentKey, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: indentSpace, - decoration: BoxDecoration( - border: widget.showDebugPaint ? Border.all(width: 1, color: Colors.grey) : null, - ), - child: SizedBox( - height: lineHeight, - child: widget.dotBuilder(context, widget), + child: Directionality( + textDirection: widget.textDirection, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: indentSpace, + decoration: BoxDecoration( + border: widget.showDebugPaint ? Border.all(width: 1, color: Colors.grey) : null, + ), + child: SizedBox( + height: lineHeight, + child: widget.dotBuilder(context, widget), + ), ), - ), - Expanded( - child: TextComponent( - key: _innerTextComponentKey, - text: widget.text, - textStyleBuilder: widget.styleBuilder, - inlineWidgetBuilders: widget.inlineWidgetBuilders, - textSelection: widget.textSelection, - textScaler: textScaler, - selectionColor: widget.selectionColor, - highlightWhenEmpty: widget.highlightWhenEmpty, - underlines: widget.underlines, - showDebugPaint: widget.showDebugPaint, + Expanded( + child: TextComponent( + key: _innerTextComponentKey, + text: widget.text, + textDirection: widget.textDirection, + textAlign: widget.textAlignment, + textStyleBuilder: widget.styleBuilder, + inlineWidgetBuilders: widget.inlineWidgetBuilders, + textSelection: widget.textSelection, + textScaler: textScaler, + selectionColor: widget.selectionColor, + highlightWhenEmpty: widget.highlightWhenEmpty, + underlines: widget.underlines, + showDebugPaint: widget.showDebugPaint, + ), ), - ), - ], + ], + ), ), ); } @@ -659,6 +684,8 @@ class OrderedListItemComponent extends StatefulWidget { required this.componentKey, required this.listIndex, required this.text, + this.textDirection = TextDirection.ltr, + this.textAlignment = TextAlign.left, required this.styleBuilder, this.inlineWidgetBuilders = const [], this.numeralBuilder = _defaultOrderedListItemNumeralBuilder, @@ -677,6 +704,8 @@ class OrderedListItemComponent extends StatefulWidget { final GlobalKey componentKey; final int listIndex; final AttributedText text; + final TextDirection textDirection; + final TextAlign textAlignment; final AttributionStyleBuilder styleBuilder; final InlineWidgetBuilderChain inlineWidgetBuilders; final OrderedListItemNumeralBuilder numeralBuilder; @@ -725,35 +754,40 @@ class _OrderedListItemComponentState extends State { return ProxyTextDocumentComponent( key: widget.componentKey, textComponentKey: _innerTextComponentKey, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: indentSpace, - height: lineHeight, - decoration: BoxDecoration( - border: widget.showDebugPaint ? Border.all(width: 1, color: Colors.grey) : null, - ), - child: SizedBox( + child: Directionality( + textDirection: widget.textDirection, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: indentSpace, height: lineHeight, - child: widget.numeralBuilder(context, widget), + decoration: BoxDecoration( + border: widget.showDebugPaint ? Border.all(width: 1, color: Colors.grey) : null, + ), + child: SizedBox( + height: lineHeight, + child: widget.numeralBuilder(context, widget), + ), ), - ), - Expanded( - child: TextComponent( - key: _innerTextComponentKey, - text: widget.text, - textStyleBuilder: widget.styleBuilder, - inlineWidgetBuilders: widget.inlineWidgetBuilders, - textSelection: widget.textSelection, - textScaler: textScaler, - selectionColor: widget.selectionColor, - highlightWhenEmpty: widget.highlightWhenEmpty, - underlines: widget.underlines, - showDebugPaint: widget.showDebugPaint, + Expanded( + child: TextComponent( + key: _innerTextComponentKey, + text: widget.text, + textDirection: widget.textDirection, + textAlign: widget.textAlignment, + textStyleBuilder: widget.styleBuilder, + inlineWidgetBuilders: widget.inlineWidgetBuilders, + textSelection: widget.textSelection, + textScaler: textScaler, + selectionColor: widget.selectionColor, + highlightWhenEmpty: widget.highlightWhenEmpty, + underlines: widget.underlines, + showDebugPaint: widget.showDebugPaint, + ), ), - ), - ], + ], + ), ), ); } diff --git a/super_editor/lib/src/default_editor/paragraph.dart b/super_editor/lib/src/default_editor/paragraph.dart index dfadf6113..c4b3db7ef 100644 --- a/super_editor/lib/src/default_editor/paragraph.dart +++ b/super_editor/lib/src/default_editor/paragraph.dart @@ -331,38 +331,42 @@ class _ParagraphComponentState extends State @override Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Indent spacing on left. - SizedBox( - width: widget.viewModel.indentCalculator( - widget.viewModel.textStyleBuilder({}), - widget.viewModel.indent, + return Directionality( + textDirection: widget.viewModel.textDirection, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Indent spacing on left. + SizedBox( + width: widget.viewModel.indentCalculator( + widget.viewModel.textStyleBuilder({}), + widget.viewModel.indent, + ), ), - ), - // The actual paragraph UI. - Expanded( - child: TextComponent( - key: _textKey, - text: widget.viewModel.text, - textAlign: widget.viewModel.textAlignment, - textScaler: widget.viewModel.textScaler, - textStyleBuilder: widget.viewModel.textStyleBuilder, - inlineWidgetBuilders: widget.viewModel.inlineWidgetBuilders, - metadata: widget.viewModel.blockType != null - ? { - 'blockType': widget.viewModel.blockType, - } - : {}, - textSelection: widget.viewModel.selection, - selectionColor: widget.viewModel.selectionColor, - highlightWhenEmpty: widget.viewModel.highlightWhenEmpty, - underlines: widget.viewModel.createUnderlines(), - showDebugPaint: widget.showDebugPaint, + // The actual paragraph UI. + Expanded( + child: TextComponent( + key: _textKey, + text: widget.viewModel.text, + textDirection: widget.viewModel.textDirection, + textAlign: widget.viewModel.textAlignment, + textScaler: widget.viewModel.textScaler, + textStyleBuilder: widget.viewModel.textStyleBuilder, + inlineWidgetBuilders: widget.viewModel.inlineWidgetBuilders, + metadata: widget.viewModel.blockType != null + ? { + 'blockType': widget.viewModel.blockType, + } + : {}, + textSelection: widget.viewModel.selection, + selectionColor: widget.viewModel.selectionColor, + highlightWhenEmpty: widget.viewModel.highlightWhenEmpty, + underlines: widget.viewModel.createUnderlines(), + showDebugPaint: widget.showDebugPaint, + ), ), - ), - ], + ], + ), ); } } diff --git a/super_editor/lib/src/default_editor/tasks.dart b/super_editor/lib/src/default_editor/tasks.dart index daf3116b1..180bd84f3 100644 --- a/super_editor/lib/src/default_editor/tasks.dart +++ b/super_editor/lib/src/default_editor/tasks.dart @@ -13,6 +13,7 @@ import 'package:super_editor/src/default_editor/blocks/indentation.dart'; import 'package:super_editor/src/default_editor/multi_node_editing.dart'; import 'package:super_editor/src/default_editor/paragraph.dart'; import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'package:super_editor/src/infrastructure/composable_text.dart'; @@ -163,6 +164,8 @@ class TaskComponentBuilder implements ComponentBuilder { return null; } + final textDirection = getParagraphDirection(node.text.toPlainText()); + return TaskComponentViewModel( nodeId: node.id, padding: EdgeInsets.zero, @@ -177,6 +180,8 @@ class TaskComponentBuilder implements ComponentBuilder { ]); }, text: node.text, + textDirection: textDirection, + textAlignment: textDirection == TextDirection.ltr ? TextAlign.left : TextAlign.right, textStyleBuilder: noStyleBuilder, selectionColor: const Color(0x00000000), ); @@ -273,6 +278,7 @@ class TaskComponentViewModel extends SingleColumnLayoutComponentViewModel with T textStyleBuilder: textStyleBuilder, inlineWidgetBuilders: inlineWidgetBuilders, textDirection: textDirection, + textAlignment: textAlignment, selection: selection, selectionColor: selectionColor, highlightWhenEmpty: highlightWhenEmpty, @@ -376,39 +382,44 @@ class _TaskComponentState extends State with ProxyDocumentCompone @override Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: widget.viewModel.indentCalculator( - widget.viewModel.textStyleBuilder({}), - widget.viewModel.indent, + return Directionality( + textDirection: widget.viewModel.textDirection, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: widget.viewModel.indentCalculator( + widget.viewModel.textStyleBuilder({}), + widget.viewModel.indent, + ), ), - ), - Padding( - padding: const EdgeInsets.only(left: 16, right: 4), - child: Checkbox( - visualDensity: Theme.of(context).visualDensity, - value: widget.viewModel.isComplete, - onChanged: (newValue) { - widget.viewModel.setComplete(newValue!); - }, + Padding( + padding: const EdgeInsets.only(left: 16, right: 4), + child: Checkbox( + visualDensity: Theme.of(context).visualDensity, + value: widget.viewModel.isComplete, + onChanged: (newValue) { + widget.viewModel.setComplete(newValue!); + }, + ), ), - ), - Expanded( - child: TextComponent( - key: _textKey, - text: widget.viewModel.text, - textStyleBuilder: _computeStyles, - inlineWidgetBuilders: widget.viewModel.inlineWidgetBuilders, - textSelection: widget.viewModel.selection, - selectionColor: widget.viewModel.selectionColor, - highlightWhenEmpty: widget.viewModel.highlightWhenEmpty, - underlines: widget.viewModel.createUnderlines(), - showDebugPaint: widget.showDebugPaint, + Expanded( + child: TextComponent( + key: _textKey, + text: widget.viewModel.text, + textDirection: widget.viewModel.textDirection, + textAlign: widget.viewModel.textAlignment, + textStyleBuilder: _computeStyles, + inlineWidgetBuilders: widget.viewModel.inlineWidgetBuilders, + textSelection: widget.viewModel.selection, + selectionColor: widget.viewModel.selectionColor, + highlightWhenEmpty: widget.viewModel.highlightWhenEmpty, + underlines: widget.viewModel.createUnderlines(), + showDebugPaint: widget.showDebugPaint, + ), ), - ), - ], + ], + ), ); } } diff --git a/super_editor/lib/src/super_textfield/android/android_textfield.dart b/super_editor/lib/src/super_textfield/android/android_textfield.dart index e4aefd112..6275f1fa8 100644 --- a/super_editor/lib/src/super_textfield/android/android_textfield.dart +++ b/super_editor/lib/src/super_textfield/android/android_textfield.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; @@ -32,7 +33,7 @@ class SuperAndroidTextField extends StatefulWidget { this.focusNode, this.tapRegionGroupId, this.textController, - this.textAlign = TextAlign.left, + this.textAlign, this.textStyleBuilder = defaultTextFieldStyleBuilder, this.hintBehavior = HintBehavior.displayHintUntilFocus, this.hintBuilder, @@ -63,7 +64,10 @@ class SuperAndroidTextField extends StatefulWidget { final ImeAttributedTextEditingController? textController; /// The alignment to use for text in this text field. - final TextAlign textAlign; + /// + /// If `null`, the text alignment is determined by the text direction + /// of the content. + final TextAlign? textAlign; /// Text style factory that creates styles for the content in /// [textController] based on the attributions in that content. @@ -175,6 +179,21 @@ class SuperAndroidTextFieldState extends State late ImeAttributedTextEditingController _textEditingController; + /// The text direction of the first character in the text. + /// + /// Used to align and position the caret depending on whether + /// the text is RTL or LTR. + TextDirection? _contentTextDirection; + + /// The text direction applied to the inner text. + TextDirection get _textDirection => _contentTextDirection ?? TextDirection.ltr; + + TextAlign get _textAlign => + widget.textAlign ?? + ((_textDirection == TextDirection.ltr) // + ? TextAlign.left + : TextAlign.right); + final _magnifierLayerLink = LeaderLink(); late AndroidEditingOverlayController _editingOverlayController; @@ -217,6 +236,8 @@ class SuperAndroidTextFieldState extends State magnifierFocalPoint: _magnifierLayerLink, ); + _contentTextDirection = getParagraphDirection(_textEditingController.text.toPlainText()); + WidgetsBinding.instance.addObserver(this); if (_focusNode.hasFocus) { @@ -432,6 +453,10 @@ class SuperAndroidTextFieldState extends State if (_textEditingController.selection.isCollapsed) { _editingOverlayController.hideToolbar(); } + + setState(() { + _contentTextDirection = getParagraphDirection(_textEditingController.text.toPlainText()); + }); } void _onTextScrollChange() { @@ -576,7 +601,7 @@ class SuperAndroidTextFieldState extends State textScrollController: _textScrollController, textKey: _textContentKey, textEditingController: _textEditingController, - textAlign: widget.textAlign, + textAlign: _textAlign, minLines: widget.minLines, maxLines: widget.maxLines, lineHeight: widget.lineHeight, @@ -609,61 +634,65 @@ class SuperAndroidTextFieldState extends State ? _textEditingController.text.computeTextSpan(widget.textStyleBuilder) : TextSpan(text: "", style: widget.textStyleBuilder({})); - return SuperText( - key: _textContentKey, - richText: textSpan, - textAlign: widget.textAlign, - textScaler: MediaQuery.textScalerOf(context), - layerBeneathBuilder: (context, textLayout) { - final isTextEmpty = _textEditingController.text.isEmpty; - final showHint = widget.hintBuilder != null && - ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || - (isTextEmpty && !_focusNode.hasFocus && widget.hintBehavior == HintBehavior.displayHintUntilFocus)); - - return Stack( - children: [ - if (widget.textController?.selection.isValid == true) - // Selection highlight beneath the text. - TextLayoutSelectionHighlight( - textLayout: textLayout, - style: SelectionHighlightStyle( - color: widget.selectionColor, - ), - selection: widget.textController?.selection, - ), - // Underline beneath the composing region. - if (widget.textController?.composingRegion.isValid == true && widget.showComposingUnderline) - TextUnderlineLayer( - textLayout: textLayout, - style: StraightUnderlineStyle( - color: widget.textStyleBuilder({}).color ?? // - (Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white), + return Directionality( + textDirection: _textDirection, + child: SuperText( + key: _textContentKey, + richText: textSpan, + textAlign: _textAlign, + textDirection: _textDirection, + textScaler: MediaQuery.textScalerOf(context), + layerBeneathBuilder: (context, textLayout) { + final isTextEmpty = _textEditingController.text.isEmpty; + final showHint = widget.hintBuilder != null && + ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || + (isTextEmpty && !_focusNode.hasFocus && widget.hintBehavior == HintBehavior.displayHintUntilFocus)); + + return Stack( + children: [ + if (widget.textController?.selection.isValid == true) + // Selection highlight beneath the text. + TextLayoutSelectionHighlight( + textLayout: textLayout, + style: SelectionHighlightStyle( + color: widget.selectionColor, + ), + selection: widget.textController?.selection, ), - underlines: [ - TextLayoutUnderline( - range: widget.textController!.composingRegion, + // Underline beneath the composing region. + if (widget.textController?.composingRegion.isValid == true && widget.showComposingUnderline) + TextUnderlineLayer( + textLayout: textLayout, + style: StraightUnderlineStyle( + color: widget.textStyleBuilder({}).color ?? // + (Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white), ), - ], - ), - if (showHint) // - widget.hintBuilder!(context), - ], - ); - }, - layerAboveBuilder: (context, textLayout) { - if (!_focusNode.hasFocus) { - return const SizedBox(); - } - - return TextLayoutCaret( - textLayout: textLayout, - style: widget.caretStyle, - position: _textEditingController.selection.isCollapsed // - ? _textEditingController.selection.extent - : null, - blinkController: _caretBlinkController, - ); - }, + underlines: [ + TextLayoutUnderline( + range: widget.textController!.composingRegion, + ), + ], + ), + if (showHint) // + widget.hintBuilder!(context), + ], + ); + }, + layerAboveBuilder: (context, textLayout) { + if (!_focusNode.hasFocus) { + return const SizedBox(); + } + + return TextLayoutCaret( + textLayout: textLayout, + style: widget.caretStyle, + position: _textEditingController.selection.isCollapsed // + ? _textEditingController.selection.extent + : null, + blinkController: _caretBlinkController, + ); + }, + ), ); } diff --git a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart index 1dc301d3d..c649818cb 100644 --- a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart +++ b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart @@ -1,13 +1,13 @@ import 'dart:math'; import 'dart:ui' as ui; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide SelectableText; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/actions.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; @@ -23,7 +23,6 @@ import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; import 'package:super_editor/src/infrastructure/platforms/platform.dart'; import 'package:super_editor/src/infrastructure/text_input.dart'; -import 'package:super_editor/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart'; import 'package:super_editor/src/super_textfield/infrastructure/text_field_scroller.dart'; import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -52,7 +51,7 @@ class SuperDesktopTextField extends StatefulWidget { this.tapRegionGroupId, this.textController, this.textStyleBuilder = defaultTextFieldStyleBuilder, - this.textAlign = TextAlign.left, + this.textAlign, this.hintBehavior = HintBehavior.displayHintUntilFocus, this.hintBuilder, this.selectionHighlightStyle = const SelectionHighlightStyle( @@ -102,7 +101,10 @@ class SuperDesktopTextField extends StatefulWidget { final WidgetBuilder? hintBuilder; /// The alignment to use for text in this text field. - final TextAlign textAlign; + /// + /// If `null`, the text alignment is determined by the text direction + /// of the content. + final TextAlign? textAlign; /// The visual representation of the user's selection highlight. final SelectionHighlightStyle selectionHighlightStyle; @@ -170,6 +172,22 @@ class SuperDesktopTextFieldState extends State implements late SuperTextFieldContext _textFieldContext; late ImeAttributedTextEditingController _controller; + + /// The text direction of the first character in the text. + /// + /// Used to align and position the caret depending on whether + /// the text is RTL or LTR. + TextDirection? _contentTextDirection; + + /// The text direction applied to the inner text. + TextDirection get _textDirection => _contentTextDirection ?? TextDirection.ltr; + + TextAlign get _textAlign => + widget.textAlign ?? + ((_textDirection == TextDirection.ltr) // + ? TextAlign.left + : TextAlign.right); + late ScrollController _scrollController; late TextFieldScroller _textFieldScroller; @@ -198,6 +216,8 @@ class SuperDesktopTextFieldState extends State implements // Check if we need to update the selection. _updateSelectionAndComposingRegionOnFocusChange(); + + _contentTextDirection = getParagraphDirection(_controller.text.toPlainText()); } @override @@ -316,6 +336,12 @@ class SuperDesktopTextFieldState extends State implements // so that any pending visual content changes can happen before // attempting to calculate the visual position of the selection extent. onNextFrame((_) => _updateViewportHeight()); + + // Even though we calling `onNextFrame`, it doesn't necessarily mean + // a new frame will be scheduled. Call setState to ensure the text direction is updated. + setState(() { + _contentTextDirection = getParagraphDirection(_controller.text.toPlainText()); + }); } /// Returns true if the viewport height changed, false otherwise. @@ -433,7 +459,7 @@ class SuperDesktopTextFieldState extends State implements key: _textScrollKey, textKey: _textKey, textController: _controller, - textAlign: widget.textAlign, + textAlign: _textAlign, scrollController: _scrollController, viewportHeight: _viewportHeight, estimatedLineHeight: _getEstimatedLineHeight(), @@ -495,60 +521,64 @@ class SuperDesktopTextFieldState extends State implements } Widget _buildSelectableText() { - return SuperText( - key: _textKey, - richText: _controller.text.computeTextSpan(widget.textStyleBuilder), - textAlign: widget.textAlign, - textScaler: _textScaler, - layerBeneathBuilder: (context, textLayout) { - final isTextEmpty = _controller.text.isEmpty; - final showHint = widget.hintBuilder != null && - ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || - (isTextEmpty && !_focusNode.hasFocus && widget.hintBehavior == HintBehavior.displayHintUntilFocus)); - - return Stack( - children: [ - if (widget.textController?.selection.isValid == true) - // Selection highlight beneath the text. - TextLayoutSelectionHighlight( - textLayout: textLayout, - style: widget.selectionHighlightStyle, - selection: widget.textController?.selection, - ), - // Underline beneath the composing region. - if (widget.textController?.composingRegion.isValid == true && _shouldShowComposingUnderline) - TextUnderlineLayer( - textLayout: textLayout, - style: StraightUnderlineStyle( - color: widget.textStyleBuilder({}).color ?? // - (Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white), + return Directionality( + textDirection: _textDirection, + child: SuperText( + key: _textKey, + richText: _controller.text.computeTextSpan(widget.textStyleBuilder), + textAlign: _textAlign, + textDirection: _textDirection, + textScaler: _textScaler, + layerBeneathBuilder: (context, textLayout) { + final isTextEmpty = _controller.text.isEmpty; + final showHint = widget.hintBuilder != null && + ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || + (isTextEmpty && !_focusNode.hasFocus && widget.hintBehavior == HintBehavior.displayHintUntilFocus)); + + return Stack( + children: [ + if (widget.textController?.selection.isValid == true) + // Selection highlight beneath the text. + TextLayoutSelectionHighlight( + textLayout: textLayout, + style: widget.selectionHighlightStyle, + selection: widget.textController?.selection, ), - underlines: [ - TextLayoutUnderline( - range: widget.textController!.composingRegion, + // Underline beneath the composing region. + if (widget.textController?.composingRegion.isValid == true && _shouldShowComposingUnderline) + TextUnderlineLayer( + textLayout: textLayout, + style: StraightUnderlineStyle( + color: widget.textStyleBuilder({}).color ?? // + (Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white), ), - ], - ), - if (showHint) // - Align( - alignment: Alignment.centerLeft, - child: widget.hintBuilder!(context), - ), - ], - ); - }, - layerAboveBuilder: (context, textLayout) { - if (!_focusNode.hasFocus) { - return const SizedBox(); - } - - return TextLayoutCaret( - textLayout: textLayout, - style: widget.caretStyle, - position: _controller.selection.extent, - blinkTimingMode: widget.blinkTimingMode, - ); - }, + underlines: [ + TextLayoutUnderline( + range: widget.textController!.composingRegion, + ), + ], + ), + if (showHint) // + Align( + alignment: Alignment.centerLeft, + child: widget.hintBuilder!(context), + ), + ], + ); + }, + layerAboveBuilder: (context, textLayout) { + if (!_focusNode.hasFocus) { + return const SizedBox(); + } + + return TextLayoutCaret( + textLayout: textLayout, + style: widget.caretStyle, + position: _controller.selection.extent, + blinkTimingMode: widget.blinkTimingMode, + ); + }, + ), ); } } diff --git a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart index 6cdd9baae..be00a479a 100644 --- a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart +++ b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart @@ -2,6 +2,7 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; @@ -41,7 +42,7 @@ class SuperIOSTextField extends StatefulWidget { this.tapHandlers = const [], this.textController, this.textStyleBuilder = defaultTextFieldStyleBuilder, - this.textAlign = TextAlign.left, + this.textAlign, this.padding, this.hintBehavior = HintBehavior.displayHintUntilFocus, this.hintBuilder, @@ -73,7 +74,10 @@ class SuperIOSTextField extends StatefulWidget { final ImeAttributedTextEditingController? textController; /// The alignment to use for text in this text field. - final TextAlign textAlign; + /// + /// If `null`, the text alignment is determined by the text direction + /// of the content. + final TextAlign? textAlign; /// Text style factory that creates styles for the content in /// [textController] based on the attributions in that content. @@ -181,6 +185,22 @@ class SuperIOSTextFieldState extends State late FocusNode _focusNode; late ImeAttributedTextEditingController _textEditingController; + + /// The text direction of the first character in the text. + /// + /// Used to align and position the caret depending on whether + /// the text is RTL or LTR. + TextDirection? _contentTextDirection; + + /// The text direction applied to the inner text. + TextDirection get _textDirection => _contentTextDirection ?? TextDirection.ltr; + + TextAlign get _textAlign => + widget.textAlign ?? + ((_textDirection == TextDirection.ltr) // + ? TextAlign.left + : TextAlign.right); + late FloatingCursorController _floatingCursorController; final _toolbarLeaderLink = LeaderLink(); @@ -237,6 +257,8 @@ class SuperIOSTextFieldState extends State overlayController: _overlayController, ); + _contentTextDirection = getParagraphDirection(_textEditingController.text.toPlainText()); + WidgetsBinding.instance.addObserver(this); if (_focusNode.hasFocus) { @@ -451,6 +473,10 @@ class SuperIOSTextFieldState extends State if (_textEditingController.selection.isCollapsed) { _editingOverlayController.hideToolbar(); } + + setState(() { + _contentTextDirection = getParagraphDirection(_textEditingController.text.toPlainText()); + }); } void _onTextScrollChange() { @@ -575,7 +601,7 @@ class SuperIOSTextFieldState extends State textScrollController: _textScrollController, textKey: _textContentKey, textEditingController: _textEditingController, - textAlign: widget.textAlign, + textAlign: _textAlign, minLines: widget.minLines, maxLines: widget.maxLines, lineHeight: widget.lineHeight, @@ -615,70 +641,74 @@ class SuperIOSTextFieldState extends State caretStyle = caretStyle.copyWith(color: caretColorOverride); } - return SuperText( - key: _textContentKey, - richText: textSpan, - textAlign: widget.textAlign, - textScaler: MediaQuery.textScalerOf(context), - layerBeneathBuilder: (context, textLayout) { - final isTextEmpty = _textEditingController.text.isEmpty; - final showHint = widget.hintBuilder != null && - ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || - (isTextEmpty && !_focusNode.hasFocus && widget.hintBehavior == HintBehavior.displayHintUntilFocus)); - - return Stack( - clipBehavior: Clip.none, - children: [ - if (_textEditingController.selection.isValid == true) - // Selection highlight beneath the text. - TextLayoutSelectionHighlight( - textLayout: textLayout, - style: SelectionHighlightStyle( - color: widget.selectionColor, - ), - selection: _textEditingController.selection, - ), - // Underline beneath the composing region. - if (_textEditingController.composingRegion.isValid == true && widget.showComposingUnderline) - TextUnderlineLayer( - textLayout: textLayout, - style: StraightUnderlineStyle( - color: widget.textStyleBuilder({}).color ?? // - (Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white), + return Directionality( + textDirection: _textDirection, + child: SuperText( + key: _textContentKey, + richText: textSpan, + textAlign: _textAlign, + textDirection: _textDirection, + textScaler: MediaQuery.textScalerOf(context), + layerBeneathBuilder: (context, textLayout) { + final isTextEmpty = _textEditingController.text.isEmpty; + final showHint = widget.hintBuilder != null && + ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || + (isTextEmpty && !_focusNode.hasFocus && widget.hintBehavior == HintBehavior.displayHintUntilFocus)); + + return Stack( + clipBehavior: Clip.none, + children: [ + if (_textEditingController.selection.isValid == true) + // Selection highlight beneath the text. + TextLayoutSelectionHighlight( + textLayout: textLayout, + style: SelectionHighlightStyle( + color: widget.selectionColor, + ), + selection: _textEditingController.selection, ), - underlines: [ - TextLayoutUnderline( - range: _textEditingController.composingRegion, + // Underline beneath the composing region. + if (_textEditingController.composingRegion.isValid == true && widget.showComposingUnderline) + TextUnderlineLayer( + textLayout: textLayout, + style: StraightUnderlineStyle( + color: widget.textStyleBuilder({}).color ?? // + (Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white), ), - ], + underlines: [ + TextLayoutUnderline( + range: _textEditingController.composingRegion, + ), + ], + ), + if (showHint) // + widget.hintBuilder!(context), + ], + ); + }, + layerAboveBuilder: (context, textLayout) { + if (!_focusNode.hasFocus) { + return const SizedBox(); + } + + return Stack( + clipBehavior: Clip.none, + children: [ + TextLayoutCaret( + textLayout: textLayout, + style: widget.caretStyle, + position: _textEditingController.selection.isCollapsed // + ? _textEditingController.selection.extent + : null, + blinkController: _caretBlinkController, ), - if (showHint) // - widget.hintBuilder!(context), - ], - ); - }, - layerAboveBuilder: (context, textLayout) { - if (!_focusNode.hasFocus) { - return const SizedBox(); - } - - return Stack( - clipBehavior: Clip.none, - children: [ - TextLayoutCaret( - textLayout: textLayout, - style: widget.caretStyle, - position: _textEditingController.selection.isCollapsed // - ? _textEditingController.selection.extent - : null, - blinkController: _caretBlinkController, - ), - IOSFloatingCursor( - controller: _floatingCursorController, - ), - ], - ); - }, + IOSFloatingCursor( + controller: _floatingCursorController, + ), + ], + ); + }, + ), ); } diff --git a/super_editor/lib/src/super_textfield/super_textfield.dart b/super_editor/lib/src/super_textfield/super_textfield.dart index 2e1c17217..37ff87fa8 100644 --- a/super_editor/lib/src/super_textfield/super_textfield.dart +++ b/super_editor/lib/src/super_textfield/super_textfield.dart @@ -60,7 +60,7 @@ class SuperTextField extends StatefulWidget { this.tapRegionGroupId, this.configuration, this.textController, - this.textAlign = TextAlign.left, + this.textAlign, this.textStyleBuilder = defaultTextFieldStyleBuilder, this.hintBehavior = HintBehavior.displayHintUntilFocus, this.hintBuilder, @@ -98,7 +98,10 @@ class SuperTextField extends StatefulWidget { final AttributedTextEditingController? textController; /// The alignment of the text in this text field. - final TextAlign textAlign; + /// + /// If `null`, the text alignment is determined by the text direction + /// of the content. + final TextAlign? textAlign; /// Text style factory that creates styles for the content in /// [textController] based on the attributions in that content. diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index 9399e5d83..e33d1fcfd 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -683,6 +683,7 @@ class _TestSuperEditorState extends State<_TestSuperEditor> { componentBuilders: [ ...widget.testConfiguration.addedComponents, ...(widget.testConfiguration.componentBuilders ?? defaultComponentBuilders), + if (widget.testConfiguration.componentBuilders == null) TaskComponentBuilder(widget.testDocumentContext.editor) ], scrollController: widget.testConfiguration.scrollController, documentOverlayBuilders: _createOverlayBuilders(), diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-android.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-android.png new file mode 100644 index 000000000..fb114e12c Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-iOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-iOS.png new file mode 100644 index 000000000..9429d84e9 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-iOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-linux.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-linux.png new file mode 100644 index 000000000..05b8b93d1 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-linux.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-macOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-macOS.png new file mode 100644 index 000000000..b4042d8bf Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-macOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-windows.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-windows.png new file mode 100644 index 000000000..b4042d8bf Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-windows.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-android.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-android.png new file mode 100644 index 000000000..d2e21accd Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-iOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-iOS.png new file mode 100644 index 000000000..e8f985c15 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-iOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-linux.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-linux.png new file mode 100644 index 000000000..38c198d71 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-linux.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-macOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-macOS.png new file mode 100644 index 000000000..38c198d71 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-macOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-windows.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-windows.png new file mode 100644 index 000000000..38c198d71 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-windows.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-android.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-android.png new file mode 100644 index 000000000..b250555fc Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-iOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-iOS.png new file mode 100644 index 000000000..528e32890 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-iOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-linux.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-linux.png new file mode 100644 index 000000000..2d6219ccb Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-linux.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-macOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-macOS.png new file mode 100644 index 000000000..2d6219ccb Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-macOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-windows.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-windows.png new file mode 100644 index 000000000..2d6219ccb Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-windows.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-android.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-android.png new file mode 100644 index 000000000..98afcedf8 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-iOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-iOS.png new file mode 100644 index 000000000..ec432fbeb Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-iOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-linux.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-linux.png new file mode 100644 index 000000000..3e2afda0c Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-linux.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-macOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-macOS.png new file mode 100644 index 000000000..3e2afda0c Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-macOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-windows.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-windows.png new file mode 100644 index 000000000..3e2afda0c Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-windows.png differ diff --git a/super_editor/test_goldens/editor/supereditor_rtl_test.dart b/super_editor/test_goldens/editor/supereditor_rtl_test.dart new file mode 100644 index 000000000..651dfdd13 --- /dev/null +++ b/super_editor/test_goldens/editor/supereditor_rtl_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/src/test/ime.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/super_editor.dart'; + +import '../../test/super_editor/supereditor_test_tools.dart'; +import '../test_tools_goldens.dart'; + +void main() { + group('SuperEditor > RTL mode >', () { + testGoldensOnAllPlatforms( + 'inserts text and paints caret on the left side of paragraph for downstream position', + (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Type the text "Example". + await tester.ime.typeText( + 'مثال', + getter: imeClientGetter, + ); + + await screenMatchesGolden( + tester, 'super-editor-rtl-caret-at-leftmost-character-paragraph-${defaultTargetPlatform.name}'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnAllPlatforms( + 'inserts text and paints caret on the left side of unordered list item for downstream position', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ListItemNode.unordered(id: '1', text: AttributedText()), + ], + ), + ) + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the list item. + await tester.placeCaretInParagraph('1', 0); + + // Type the text "Example". + await tester.ime.typeText( + 'مثال', + getter: imeClientGetter, + ); + + await screenMatchesGolden( + tester, 'super-editor-rtl-caret-at-leftmost-character-unordered-list-item-${defaultTargetPlatform.name}'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnAllPlatforms( + 'inserts text and paints caret on the left side of ordered list item for downstream position', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ListItemNode.ordered(id: '1', text: AttributedText()), + ], + ), + ) + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the list item. + await tester.placeCaretInParagraph('1', 0); + + // Type the text "Example". + await tester.ime.typeText( + 'مثال', + getter: imeClientGetter, + ); + + await screenMatchesGolden( + tester, 'super-editor-rtl-caret-at-leftmost-character-ordered-list-item-${defaultTargetPlatform.name}'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnAllPlatforms( + 'inserts text and paints caret on the left side of task for downstream position', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + TaskNode(id: '1', text: AttributedText(), isComplete: false), + ], + ), + ) + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the task. + await tester.placeCaretInParagraph('1', 0); + + // Type the text "Example". + await tester.ime.typeText( + 'مثال', + getter: imeClientGetter, + ); + + await screenMatchesGolden( + tester, 'super-editor-rtl-caret-at-leftmost-character-task-${defaultTargetPlatform.name}'); + }, + windowSize: goldenSizeSmall, + ); + }); +} diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-android.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-android.png new file mode 100644 index 000000000..ece7a4aa6 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-android.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-iOS.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-iOS.png new file mode 100644 index 000000000..767c5c9a3 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-iOS.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-linux.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-linux.png new file mode 100644 index 000000000..59f46310c Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-linux.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-macOS.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-macOS.png new file mode 100644 index 000000000..59f46310c Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-macOS.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-windows.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-windows.png new file mode 100644 index 000000000..59f46310c Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-windows.png differ diff --git a/super_editor/test_goldens/super_textfield/super_textfield_rtl_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_rtl_test.dart new file mode 100644 index 000000000..610d6465c --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_rtl_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_reader_test.dart'; + +import '../../test/super_textfield/super_textfield_robot.dart'; +import '../test_tools_goldens.dart'; + +void main() { + group('SuperTextfield > RTL mode >', () { + testGoldensOnAllPlatforms( + 'inserts text and paints caret on the left side for downstream position', + (tester) async { + await _pumpTestApp(tester); + + // Place the caret at the beginning of the text field. + await tester.placeCaretInSuperTextField(0); + + // Type the text "Example". + await tester.ime.typeText( + 'مثال', + getter: imeClientGetter, + ); + await tester.pumpAndSettle(); + + await screenMatchesGolden( + tester, 'super-text-field_rtl-caret-at-leftmost-character-${defaultTargetPlatform.name}'); + }, + windowSize: const Size(600, 600), + ); + }); +} + +/// Pump a widget tree with a centered multiline textfield with +/// a yellow background, so we can clearly see the bounds of the textfield. +Future _pumpTestApp(WidgetTester tester) async { + final controller = ImeAttributedTextEditingController(); + + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, + child: ColoredBox( + color: Colors.yellow, + child: SuperTextField( + textController: controller, + maxLines: 10, + lineHeight: 16, + ), + ), + ), + ), + ), + ), + ); +} diff --git a/super_text_layout/lib/src/super_text.dart b/super_text_layout/lib/src/super_text.dart index 34c4ba928..936ff8b0a 100644 --- a/super_text_layout/lib/src/super_text.dart +++ b/super_text_layout/lib/src/super_text.dart @@ -90,6 +90,7 @@ class SuperTextState extends ProseTextState with ProseTextBlock { text: LayoutAwareRichText( text: widget.richText, textAlign: widget.textAlign, + textDirection: widget.textDirection, textScaler: widget.textScaler ?? MediaQuery.textScalerOf(context), onMarkNeedsLayout: _invalidateParagraph, ), @@ -295,12 +296,14 @@ class LayoutAwareRichText extends RichText { Key? key, required InlineSpan text, TextAlign textAlign = TextAlign.left, + TextDirection textDirection = TextDirection.ltr, TextScaler textScaler = TextScaler.noScaling, required this.onMarkNeedsLayout, }) : super( key: key, text: text, textAlign: textAlign, + textDirection: textDirection, textScaler: textScaler, );