diff --git a/packages/flutter/lib/src/painting/inline_span.dart b/packages/flutter/lib/src/painting/inline_span.dart index 0c4a0be6177..77baa589699 100644 --- a/packages/flutter/lib/src/painting/inline_span.dart +++ b/packages/flutter/lib/src/painting/inline_span.dart @@ -315,6 +315,21 @@ abstract class InlineSpan extends DiagnosticableTree { return buffer.toString(); } + (String, Map) toPlainTextV2({ + bool includeSemanticsLabels = true, + bool includePlaceholders = true, + }) { + final buffer = StringBuffer(); + final map = {}; + computeToPlainTextV2( + buffer, + map, + includeSemanticsLabels: includeSemanticsLabels, + includePlaceholders: includePlaceholders, + ); + return (buffer.toString(), map); + } + /// Flattens the [InlineSpan] tree to a list of /// [InlineSpanSemanticsInformation] objects. /// @@ -358,6 +373,14 @@ abstract class InlineSpan extends DiagnosticableTree { bool includePlaceholders = true, }); + @protected + void computeToPlainTextV2( + StringBuffer buffer, + Map map, { + bool includeSemanticsLabels = true, + bool includePlaceholders = true, + }); + /// Returns the UTF-16 code unit at the given `index` in the flattened string. /// /// This only accounts for the [TextSpan.text] values and ignores [PlaceholderSpan]s. diff --git a/packages/flutter/lib/src/painting/placeholder_span.dart b/packages/flutter/lib/src/painting/placeholder_span.dart index 110ff860310..7ad808d05bc 100644 --- a/packages/flutter/lib/src/painting/placeholder_span.dart +++ b/packages/flutter/lib/src/painting/placeholder_span.dart @@ -45,6 +45,7 @@ abstract class PlaceholderSpan extends InlineSpan { this.alignment = ui.PlaceholderAlignment.bottom, this.baseline, super.style, + this.rawText, }); /// The unicode character to represent a placeholder. @@ -61,6 +62,8 @@ abstract class PlaceholderSpan extends InlineSpan { /// This is ignored when using other alignment modes. final TextBaseline? baseline; + final String? rawText; + /// [PlaceholderSpan]s are flattened to a `0xFFFC` object replacement character in the /// plain text representation when `includePlaceholders` is true. @override @@ -74,6 +77,21 @@ abstract class PlaceholderSpan extends InlineSpan { } } + @override + void computeToPlainTextV2( + StringBuffer buffer, + Map map, { + bool includeSemanticsLabels = true, + bool includePlaceholders = true, + }) { + if (includePlaceholders) { + if (rawText != null) { + map[buffer.length] = rawText!; + } + buffer.writeCharCode(placeholderCodeUnit); + } + } + @override void computeSemanticsInformation(List collector) { collector.add(InlineSpanSemanticsInformation.placeholder); diff --git a/packages/flutter/lib/src/painting/text_span.dart b/packages/flutter/lib/src/painting/text_span.dart index 09fba7fc4fb..57ee469358e 100644 --- a/packages/flutter/lib/src/painting/text_span.dart +++ b/packages/flutter/lib/src/painting/text_span.dart @@ -398,6 +398,31 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati } } + @override + void computeToPlainTextV2( + StringBuffer buffer, + Map map, { + bool includeSemanticsLabels = true, + bool includePlaceholders = true, + }) { + assert(debugAssertIsValid()); + if (semanticsLabel != null && includeSemanticsLabels) { + buffer.write(semanticsLabel); + } else if (text != null) { + buffer.write(text); + } + if (children != null) { + for (final InlineSpan child in children!) { + child.computeToPlainTextV2( + buffer, + map, + includeSemanticsLabels: includeSemanticsLabels, + includePlaceholders: includePlaceholders, + ); + } + } + } + @override void computeSemanticsInformation( List collector, { diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 97a41dafb8d..15e7ce055e9 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -7,6 +7,7 @@ /// @docImport 'editable.dart'; library; +import 'dart:io' show Platform; import 'dart:math' as math; import 'dart:ui' as ui @@ -502,27 +503,17 @@ class RenderParagraph extends RenderBox } List<_SelectableFragment> _getSelectableFragments() { - final String plainText = text.toPlainText(includeSemanticsLabels: false); - final result = <_SelectableFragment>[]; - var start = 0; - while (start < plainText.length) { - int end = plainText.indexOf(_placeholderCharacter, start); - if (start != end) { - if (end == -1) { - end = plainText.length; - } - result.add( - _SelectableFragment( - paragraph: this, - range: TextRange(start: start, end: end), - fullText: plainText, - ), - ); - start = end; - } - start += 1; - } - return result; + final (String plainText, Map placeHolder) = text.toPlainTextV2( + includeSemanticsLabels: false, + ); + return [ + _SelectableFragment( + paragraph: this, + range: TextRange(start: 0, end: plainText.length), + fullText: plainText, + placeholder: placeHolder, + ), + ]; } /// Determines whether the given [Selectable] was created by this @@ -1467,7 +1458,7 @@ class RenderParagraph extends RenderBox class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implements TextLayoutMetrics { - _SelectableFragment({required this.paragraph, required this.fullText, required this.range}) + _SelectableFragment({required this.paragraph, required this.fullText, required this.range, required this.placeholder}) : assert(range.isValid && !range.isCollapsed && range.isNormalized) { if (kFlutterMemoryAllocationsEnabled) { ChangeNotifier.maybeDispatchObjectCreation(this); @@ -1478,6 +1469,7 @@ class _SelectableFragment final TextRange range; final RenderParagraph paragraph; final String fullText; + final Map placeholder; TextPosition? _textSelectionStart; TextPosition? _textSelectionEnd; @@ -1509,18 +1501,36 @@ class _SelectableFragment final int selectionStart = _textSelectionStart!.offset; final int selectionEnd = _textSelectionEnd!.offset; final bool isReversed = selectionStart > selectionEnd; - final Offset startOffsetInParagraphCoordinates = paragraph._getOffsetForPosition( - TextPosition(offset: selectionStart), - ); - final Offset endOffsetInParagraphCoordinates = selectionStart == selectionEnd - ? startOffsetInParagraphCoordinates - : paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd)); + final flipHandles = isReversed != (TextDirection.rtl == paragraph.textDirection); final selection = TextSelection(baseOffset: selectionStart, extentOffset: selectionEnd); + final List boxes = paragraph.getBoxesForSelection(selection, boxHeightStyle: .max); final selectionRects = []; - for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) { + for (final textBox in boxes) { selectionRects.add(textBox.toRect()); } + + final Offset startOffsetInParagraphCoordinates; + final Offset endOffsetInParagraphCoordinates; + if (boxes.isEmpty) { + final Offset offset = + paragraph._textPainter.getOffsetForCaret(selection.extent, Rect.zero) + + Offset(0, paragraph._textPainter.getFullHeightForCaret(selection.extent, Rect.zero)); + startOffsetInParagraphCoordinates = endOffsetInParagraphCoordinates = Offset( + clampDouble(offset.dx, 0, paragraph._textPainter.width), + clampDouble(offset.dy, 0, paragraph._textPainter.height), + ); + } else { + startOffsetInParagraphCoordinates = Offset( + clampDouble(boxes.first.start, 0, paragraph._textPainter.width), + clampDouble(boxes.first.bottom, 0, paragraph._textPainter.height), + ); + endOffsetInParagraphCoordinates = Offset( + clampDouble(boxes.last.end, 0, paragraph._textPainter.width), + clampDouble(boxes.last.bottom, 0, paragraph._textPainter.height), + ); + } + final selectionCollapsed = selectionStart == selectionEnd; final ( TextSelectionHandleType startSelectionHandleType, @@ -1564,12 +1574,15 @@ class _SelectableFragment result = _updateSelectionEdge( edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate, + isSingle: edgeUpdate.isSingle, + fromDrag: edgeUpdate.fromDrag, ); case TextGranularity.word: result = _updateSelectionEdgeByTextBoundary( edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate, getTextBoundary: _getWordBoundaryAtPosition, + isSingle: edgeUpdate.isSingle, ); case TextGranularity.paragraph: result = _updateSelectionEdgeByMultiSelectableTextBoundary( @@ -1626,9 +1639,33 @@ class _SelectableFragment if (_textSelectionStart == null || _textSelectionEnd == null) { return null; } - final int start = math.min(_textSelectionStart!.offset, _textSelectionEnd!.offset); + int start = math.min(_textSelectionStart!.offset, _textSelectionEnd!.offset); final int end = math.max(_textSelectionStart!.offset, _textSelectionEnd!.offset); - return SelectedContent(plainText: fullText.substring(start, end)); + final String selectedText; + if (placeholder.isEmpty) { + selectedText = fullText.substring(start, end); + } else { + final buffer = StringBuffer(); + for (final e in placeholder.entries) { + final i = e.key; + if (i < start) { + continue; + } + if (i >= end) { + break; + } + if (i != start) { + buffer.write(fullText.substring(start, i)); + } + buffer.write(e.value); + start = i + 1; + } + if (start != end) { + buffer.write(fullText.substring(start, end)); + } + selectedText = buffer.toString(); + } + return SelectedContent(plainText: selectedText); } @override @@ -1848,6 +1885,7 @@ class _SelectableFragment Offset globalPosition, { required bool isEnd, required _TextBoundaryAtPosition getTextBoundary, + bool isSingle = false, }) { // When the start/end edges are swapped, i.e. the start is after the end, and // the scrollable synthesizes an event for the opposite edge, this will potentially @@ -1866,6 +1904,7 @@ class _SelectableFragment _rect, localPosition, direction: paragraph.textDirection, + isSingle: isSingle, ); final TextPosition position = paragraph.getPositionForOffset(adjustedOffset); @@ -1920,7 +1959,7 @@ class _SelectableFragment return SelectionUtils.getResultBasedOnRect(_rect, localPosition); } - SelectionResult _updateSelectionEdge(Offset globalPosition, {required bool isEnd}) { + SelectionResult _updateSelectionEdge(Offset globalPosition, {required bool isEnd, bool isSingle = false, bool fromDrag = false}) { _setSelectionPosition(null, isEnd: isEnd); final Matrix4 transform = paragraph.getTransformTo(null); transform.invert(); @@ -1932,11 +1971,30 @@ class _SelectableFragment _rect, localPosition, direction: paragraph.textDirection, + isSingle: isSingle, ); - final TextPosition position = _clampTextPosition( - paragraph.getPositionForOffset(adjustedOffset), - ); + TextPosition position = _clampTextPosition(paragraph.getPositionForOffset(adjustedOffset)); + // if (rawText != null) { + // position = _clampTextPosition( + // adjustedOffset.dx * 2 > _rect.left + _rect.right + // ? TextPosition(offset: range.end) + // : TextPosition(offset: range.start), + // ); + // } else { + // position = _clampTextPosition(paragraph.getPositionForOffset(adjustedOffset)); + // } + if (fromDrag && isSingle && !Platform.isIOS) { + if (isEnd) { + if (position.offset <= _textSelectionStart!.offset) { + position = TextPosition(offset: _textSelectionStart!.offset + 1); + } + } else { + if (position.offset >= _textSelectionEnd!.offset) { + position = TextPosition(offset: _textSelectionEnd!.offset - 1); + } + } + } _setSelectionPosition(position, isEnd: isEnd); if (position.offset == range.end) { return SelectionResult.next; @@ -3568,7 +3626,10 @@ class _SelectableFragment final selectionPaint = Paint() ..style = PaintingStyle.fill ..color = paragraph.selectionColor!; - for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) { + for (final TextBox textBox in paragraph.getBoxesForSelection( + selection, + boxHeightStyle: ui.BoxHeightStyle.max, + )) { context.canvas.drawRect(textBox.toRect().shift(offset), selectionPaint); } } diff --git a/packages/flutter/lib/src/rendering/selection.dart b/packages/flutter/lib/src/rendering/selection.dart index a813e141dc4..d045236d031 100644 --- a/packages/flutter/lib/src/rendering/selection.dart +++ b/packages/flutter/lib/src/rendering/selection.dart @@ -6,6 +6,8 @@ /// @docImport 'package:flutter/material.dart'; library; +import 'dart:math' as math; + import 'package:flutter/foundation.dart'; import 'package:vector_math/vector_math_64.dart'; @@ -114,6 +116,8 @@ abstract class SelectionHandler implements ValueListenable { /// The length of the content in this object. int get contentLength; + + Object? get separator; } /// This class stores the range information of the selection under a [Selectable] @@ -236,6 +240,8 @@ class SelectedContent with Diagnosticable { /// See also: /// * [SelectableRegion], which provides an overview of selection system. mixin Selectable implements SelectionHandler { + Object? get separator => null; + /// {@macro flutter.rendering.RenderObject.getTransformTo} Matrix4 getTransformTo(RenderObject? ancestor); @@ -356,10 +362,19 @@ abstract final class SelectionUtils { Rect targetRect, Offset point, { TextDirection direction = TextDirection.ltr, + bool isSingle = false, }) { if (targetRect.contains(point)) { return point; } + + if (isSingle || (point.dy >= targetRect.top && point.dy <= targetRect.bottom)) { + return Offset( + math.min(point.dx, targetRect.right), + math.min(point.dy, targetRect.bottom - .1), + ); + } + if (point.dy <= targetRect.top || point.dy <= targetRect.bottom && point.dx <= targetRect.left) { // Area 1 @@ -520,9 +535,10 @@ class SelectionEdgeUpdateEvent extends SelectionEvent { /// /// The [granularity] contains the granularity which the selection edge should move by. /// This value defaults to [TextGranularity.character]. - const SelectionEdgeUpdateEvent.forStart({ + SelectionEdgeUpdateEvent.forStart({ required this.globalPosition, TextGranularity? granularity, + this.fromDrag = false, }) : granularity = granularity ?? TextGranularity.character, super._(SelectionEventType.startEdgeUpdate); @@ -532,9 +548,10 @@ class SelectionEdgeUpdateEvent extends SelectionEvent { /// /// The [granularity] contains the granularity which the selection edge should move by. /// This value defaults to [TextGranularity.character]. - const SelectionEdgeUpdateEvent.forEnd({ + SelectionEdgeUpdateEvent.forEnd({ required this.globalPosition, TextGranularity? granularity, + this.fromDrag = false, }) : granularity = granularity ?? TextGranularity.character, super._(SelectionEventType.endEdgeUpdate); @@ -547,6 +564,10 @@ class SelectionEdgeUpdateEvent extends SelectionEvent { /// /// Defaults to [TextGranularity.character]. final TextGranularity granularity; + + bool isSingle = false; + + final bool fromDrag; } /// Extends the start or end of the selection by a given [TextGranularity]. diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 9df2d633f16..c6a43cc97a3 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -1177,6 +1177,9 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont _position.addListener(_scheduleLayoutChange); } + @override + Object separator = '\n'; + // Pointer drag is a single point, it should not have a size. static const double _kDefaultDragTargetSize = 0; @@ -1276,6 +1279,7 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont event = SelectionEdgeUpdateEvent.forEnd( globalPosition: endOffset, granularity: event.granularity, + fromDrag: event.fromDrag, ); } else { _currentDragStartRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition); @@ -1286,6 +1290,7 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont event = SelectionEdgeUpdateEvent.forStart( globalPosition: startOffset, granularity: event.granularity, + fromDrag: event.fromDrag, ); } final SelectionResult result = super.handleSelectionEdgeUpdate(event); diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index 59de8bae20b..2084a40b24d 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -401,9 +401,11 @@ class SelectableRegionState extends State final LayerLink _startHandleLayerLink = LayerLink(); final LayerLink _endHandleLayerLink = LayerLink(); final LayerLink _toolbarLayerLink = LayerLink(); - final StaticSelectionContainerDelegate _selectionDelegate = StaticSelectionContainerDelegate(); + final StaticSelectionContainerDelegate _selectionDelegate = StaticSelectionContainerDelegate(separator: '\n'); + StaticSelectionContainerDelegate get selectionDelegate => _selectionDelegate; // there should only ever be one selectable, which is the SelectionContainer. Selectable? _selectable; + Selectable? get selectable => _selectable; bool get _hasSelectionOverlayGeometry => _selectionDelegate.value.startSelectionPoint != null || @@ -599,48 +601,32 @@ class SelectableRegionState extends State // // This method should be used in all instances when details.consecutiveTapCount // would be used. - int _getEffectiveConsecutiveTapCount(int rawCount) { - var maxConsecutiveTap = 3; + static int _getEffectiveConsecutiveTapCount(int rawCount) { switch (defaultTargetPlatform) { case TargetPlatform.android: case TargetPlatform.fuchsia: - if (_lastPointerDeviceKind != null && _lastPointerDeviceKind != PointerDeviceKind.mouse) { - // When the pointer device kind is not precise like a mouse, native - // Android resets the tap count at 2. For example, this is so the - // selection can collapse on the third tap. - maxConsecutiveTap = 2; - } - // From observation, these platforms reset their tap count to 0 when - // the number of consecutive taps exceeds the max consecutive tap supported. - // For example on native Android, when going past a triple click, - // on the fourth click the selection is moved to the precise click - // position, on the fifth click the word at the position is selected, and - // on the sixth click the paragraph at the position is selected. - return rawCount <= maxConsecutiveTap - ? rawCount - : (rawCount % maxConsecutiveTap == 0 - ? maxConsecutiveTap - : rawCount % maxConsecutiveTap); case TargetPlatform.linux: - // From observation, these platforms reset their tap count to 0 when - // the number of consecutive taps exceeds the max consecutive tap supported. - // For example on Debian Linux with GTK, when going past a triple click, - // on the fourth click the selection is moved to the precise click - // position, on the fifth click the word at the position is selected, and - // on the sixth click the paragraph at the position is selected. - return rawCount <= maxConsecutiveTap - ? rawCount - : (rawCount % maxConsecutiveTap == 0 - ? maxConsecutiveTap - : rawCount % maxConsecutiveTap); + // From observation, these platform's reset their tap count to 0 when + // the number of consecutive taps exceeds 3. For example on Debian Linux + // with GTK, when going past a triple click, on the fourth click the + // selection is moved to the precise click position, on the fifth click + // the word at the position is selected, and on the sixth click the + // paragraph at the position is selected. + return rawCount <= 3 ? rawCount : (rawCount % 3 == 0 ? 3 : rawCount % 3); case TargetPlatform.iOS: case TargetPlatform.macOS: + // From observation, these platform's either hold their tap count at 3. + // For example on macOS, when going past a triple click, the selection + // should be retained at the paragraph that was first selected on triple + // click. + return min(rawCount, 3); case TargetPlatform.windows: - // From observation, these platforms hold their tap count at the max - // consecutive tap supported. For example on macOS, when going past a triple - // click, the selection should be retained at the paragraph that was first - // selected on triple click. - return min(rawCount, maxConsecutiveTap); + // From observation, this platform's consecutive tap actions alternate + // between double click and triple click actions. For example, after a + // triple click has selected a paragraph, on the next click the word at + // the clicked position will be selected, and on the next click the + // paragraph at the position is selected. + return rawCount < 2 ? rawCount : 2 + rawCount % 2; } } @@ -782,22 +768,8 @@ class SelectableRegionState extends State _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } case 3: - switch (defaultTargetPlatform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.iOS: - if (details.kind != null && _isPrecisePointerDevice(details.kind!)) { - // Triple tap on static text is only supported on mobile - // platforms using a precise pointer device. - _selectParagraphAt(offset: details.globalPosition); - _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; - } - case TargetPlatform.macOS: - case TargetPlatform.linux: - case TargetPlatform.windows: - _selectParagraphAt(offset: details.globalPosition); - _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; - } + _selectParagraphAt(offset: details.globalPosition); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } _updateSelectedContentIfNeeded(); } @@ -1049,10 +1021,10 @@ class SelectableRegionState extends State case TargetPlatform.windows: // If _lastSecondaryTapDownPosition is within the current selection then // keep the current selection, if not then collapse it. - final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection( - globalPosition: details.globalPosition, - ); - if (lastSecondaryTapDownPositionWasOnActiveSelection) { + // final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection( + // globalPosition: details.globalPosition, + // ); + if (_selectionDelegate.value.selectionRects.isNotEmpty) { // Restore _lastSecondaryTapDownPosition since it may be cleared if a user // accesses contextMenuAnchors. _lastSecondaryTapDownPosition = details.globalPosition; @@ -1105,7 +1077,7 @@ class SelectableRegionState extends State /// If the selectable subtree returns a [SelectionResult.pending], this method /// continues to send [SelectionEdgeUpdateEvent]s every frame until the result /// is not pending or users end their gestures. - void _triggerSelectionEndEdgeUpdate({TextGranularity? textGranularity}) { + void _triggerSelectionEndEdgeUpdate({TextGranularity? textGranularity, bool fromDrag = false}) { // This method can be called when the drag is not in progress. This can // happen if the child scrollable returns SelectionResult.pending, and // the selection area scheduled a selection update for the next frame, but @@ -1117,6 +1089,7 @@ class SelectableRegionState extends State SelectionEdgeUpdateEvent.forEnd( globalPosition: _selectionEndPosition!, granularity: textGranularity, + fromDrag: fromDrag, ), ) == SelectionResult.pending) { @@ -1159,7 +1132,7 @@ class SelectableRegionState extends State /// If the selectable subtree returns a [SelectionResult.pending], this method /// continues to send [SelectionEdgeUpdateEvent]s every frame until the result /// is not pending or users end their gestures. - void _triggerSelectionStartEdgeUpdate({TextGranularity? textGranularity}) { + void _triggerSelectionStartEdgeUpdate({TextGranularity? textGranularity, bool fromDrag = false}) { // This method can be called when the drag is not in progress. This can // happen if the child scrollable returns SelectionResult.pending, and // the selection area scheduled a selection update for the next frame, but @@ -1171,6 +1144,7 @@ class SelectableRegionState extends State SelectionEdgeUpdateEvent.forStart( globalPosition: _selectionStartPosition!, granularity: textGranularity, + fromDrag: fromDrag, ), ) == SelectionResult.pending) { @@ -1216,7 +1190,7 @@ class SelectableRegionState extends State _selectionStartPosition = _selectionStartHandleDragPosition - Offset(0, _selectionDelegate.value.startSelectionPoint!.lineHeight / 2); - _triggerSelectionStartEdgeUpdate(); + _triggerSelectionStartEdgeUpdate(fromDrag: true); _selectionOverlay!.updateMagnifier( _buildInfoForMagnifier(details.globalPosition, _selectionDelegate.value.startSelectionPoint!), @@ -1244,7 +1218,7 @@ class SelectableRegionState extends State _selectionEndPosition = _selectionEndHandleDragPosition - Offset(0, _selectionDelegate.value.endSelectionPoint!.lineHeight / 2); - _triggerSelectionEndEdgeUpdate(); + _triggerSelectionEndEdgeUpdate(fromDrag: true); _selectionOverlay!.updateMagnifier( _buildInfoForMagnifier(details.globalPosition, _selectionDelegate.value.endSelectionPoint!), @@ -1957,6 +1931,8 @@ class SelectableRegionState extends State // the region on non-web platforms. if (kIsWeb) { _focusNode.unfocus(); + } else { + clearSelection(); } }, child: CompositedTransformTarget( @@ -2087,6 +2063,11 @@ class _DirectionallyExtendCaretSelectionAction _hasReceivedStartEvent = {}; @@ -2367,6 +2348,8 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai /// Gets the list of [Selectable]s this delegate is managing. List selectables = []; + bool get isSingle => selectables.length == 1; + /// The number of additional pixels added to the selection handle drawable /// area. /// @@ -2822,8 +2805,17 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai return null; } final buffer = StringBuffer(); - for (final selection in selections) { - buffer.write(selection.plainText); + if (separator != null) { + for (final (i, selection) in selections.indexed) { + if (i != 0) { + buffer.write(separator); + } + buffer.write(selection.plainText); + } + } else { + for (final selection in selections) { + buffer.write(selection.plainText); + } } return SelectedContent(plainText: buffer.toString()); } @@ -3129,6 +3121,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai /// Updates the selection edges. @protected SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { + handleSelectionEdgeUpdateEvent(event); if (event.type == SelectionEventType.endEdgeUpdate) { return currentSelectionEndIndex == -1 ? _initSelection(event, isEnd: true) @@ -3139,6 +3132,10 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai : _adjustSelection(event, isEnd: false); } + void handleSelectionEdgeUpdateEvent(SelectionEdgeUpdateEvent event) { + event.isSingle = isSingle; + } + @override SelectionResult dispatchSelectionEvent(SelectionEvent event) { final selectionWillBeInProgress = event is! ClearSelectionEvent; diff --git a/packages/flutter/lib/src/widgets/selection_container.dart b/packages/flutter/lib/src/widgets/selection_container.dart index 5d5a2386a7a..92e7d1eb70a 100644 --- a/packages/flutter/lib/src/widgets/selection_container.dart +++ b/packages/flutter/lib/src/widgets/selection_container.dart @@ -270,6 +270,8 @@ class SelectionRegistrarScope extends InheritedWidget { /// This delegate needs to implement [SelectionRegistrar] to register /// [Selectable]s in the [SelectionContainer] subtree. abstract class SelectionContainerDelegate implements SelectionHandler, SelectionRegistrar { + Object? get separator => null; + BuildContext? _selectionContainerContext; /// Gets the paint transform from the [Selectable] child to diff --git a/packages/flutter/lib/src/widgets/text.dart b/packages/flutter/lib/src/widgets/text.dart index a3aa5fde862..8fbf6a1b16d 100644 --- a/packages/flutter/lib/src/widgets/text.dart +++ b/packages/flutter/lib/src/widgets/text.dart @@ -992,6 +992,9 @@ class _SelectableTextContainerDelegate extends StaticSelectionContainerDelegate final GlobalKey _textKey; RenderParagraph get paragraph => _textKey.currentContext!.findRenderObject()! as RenderParagraph; + @override + void handleSelectionEdgeUpdateEvent(SelectionEdgeUpdateEvent event) {} + @override SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) { final SelectionResult result = _handleSelectParagraph(event); diff --git a/packages/flutter/lib/src/widgets/widget_span.dart b/packages/flutter/lib/src/widgets/widget_span.dart index 09da3b76525..4a83efb0392 100644 --- a/packages/flutter/lib/src/widgets/widget_span.dart +++ b/packages/flutter/lib/src/widgets/widget_span.dart @@ -78,7 +78,7 @@ class WidgetSpan extends PlaceholderSpan { /// /// A [TextStyle] may be provided with the [style] property, but only the /// decoration, foreground, background, and spacing options will be used. - const WidgetSpan({required this.child, super.alignment, super.baseline, super.style}) + const WidgetSpan({required this.child, super.alignment, super.baseline, super.style, super.rawText}) : assert( baseline != null || !(identical(alignment, ui.PlaceholderAlignment.aboveBaseline) ||