mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
Fix SelectionArea select-word edge cases (#136920)
This change fixes issues with screen order comparison logic when rects are encompassed within each other. This was causing issues when trying to select text that includes inline `WidgetSpan`s inside of a `SelectionArea`. * Adds `boundingBoxes` to `Selectable` for a more precise hit testing region. Fixes #132821 Fixes updating selection edge by word boundary when widget spans are involved. Fixes crash when sending select word selection event to an unselectable element.
This commit is contained in:
parent
67edaef9b4
commit
f60e54b24c
@ -115,6 +115,9 @@ class _RenderSelectableAdapter extends RenderProxyBox with Selectable, Selection
|
|||||||
|
|
||||||
// Selectable APIs.
|
// Selectable APIs.
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Rect> get boundingBoxes => <Rect>[paintBounds];
|
||||||
|
|
||||||
// Adjust this value to enlarge or shrink the selection highlight.
|
// Adjust this value to enlarge or shrink the selection highlight.
|
||||||
static const double _padding = 10.0;
|
static const double _padding = 10.0;
|
||||||
Rect _getSelectionHighlightRect() {
|
Rect _getSelectionHighlightRect() {
|
||||||
|
@ -409,7 +409,13 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBo
|
|||||||
if (end == -1) {
|
if (end == -1) {
|
||||||
end = plainText.length;
|
end = plainText.length;
|
||||||
}
|
}
|
||||||
result.add(_SelectableFragment(paragraph: this, range: TextRange(start: start, end: end), fullText: plainText));
|
result.add(
|
||||||
|
_SelectableFragment(
|
||||||
|
paragraph: this,
|
||||||
|
range: TextRange(start: start, end: end),
|
||||||
|
fullText: plainText,
|
||||||
|
),
|
||||||
|
);
|
||||||
start = end;
|
start = end;
|
||||||
}
|
}
|
||||||
start += 1;
|
start += 1;
|
||||||
@ -1314,7 +1320,7 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBo
|
|||||||
/// [PlaceholderSpan]. The [RenderParagraph] splits itself on [PlaceholderSpan]
|
/// [PlaceholderSpan]. The [RenderParagraph] splits itself on [PlaceholderSpan]
|
||||||
/// to create multiple `_SelectableFragment`s so that they can be selected
|
/// to create multiple `_SelectableFragment`s so that they can be selected
|
||||||
/// separately.
|
/// separately.
|
||||||
class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutMetrics {
|
class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implements TextLayoutMetrics {
|
||||||
_SelectableFragment({
|
_SelectableFragment({
|
||||||
required this.paragraph,
|
required this.paragraph,
|
||||||
required this.fullText,
|
required this.fullText,
|
||||||
@ -1366,7 +1372,6 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
? startOffsetInParagraphCoordinates
|
? startOffsetInParagraphCoordinates
|
||||||
: paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd));
|
: paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd));
|
||||||
final bool flipHandles = isReversed != (TextDirection.rtl == paragraph.textDirection);
|
final bool flipHandles = isReversed != (TextDirection.rtl == paragraph.textDirection);
|
||||||
final Matrix4 paragraphToFragmentTransform = getTransformToParagraph()..invert();
|
|
||||||
final TextSelection selection = TextSelection(
|
final TextSelection selection = TextSelection(
|
||||||
baseOffset: selectionStart,
|
baseOffset: selectionStart,
|
||||||
extentOffset: selectionEnd,
|
extentOffset: selectionEnd,
|
||||||
@ -1377,12 +1382,12 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
}
|
}
|
||||||
return SelectionGeometry(
|
return SelectionGeometry(
|
||||||
startSelectionPoint: SelectionPoint(
|
startSelectionPoint: SelectionPoint(
|
||||||
localPosition: MatrixUtils.transformPoint(paragraphToFragmentTransform, startOffsetInParagraphCoordinates),
|
localPosition: startOffsetInParagraphCoordinates,
|
||||||
lineHeight: paragraph._textPainter.preferredLineHeight,
|
lineHeight: paragraph._textPainter.preferredLineHeight,
|
||||||
handleType: flipHandles ? TextSelectionHandleType.right : TextSelectionHandleType.left
|
handleType: flipHandles ? TextSelectionHandleType.right : TextSelectionHandleType.left
|
||||||
),
|
),
|
||||||
endSelectionPoint: SelectionPoint(
|
endSelectionPoint: SelectionPoint(
|
||||||
localPosition: MatrixUtils.transformPoint(paragraphToFragmentTransform, endOffsetInParagraphCoordinates),
|
localPosition: endOffsetInParagraphCoordinates,
|
||||||
lineHeight: paragraph._textPainter.preferredLineHeight,
|
lineHeight: paragraph._textPainter.preferredLineHeight,
|
||||||
handleType: flipHandles ? TextSelectionHandleType.left : TextSelectionHandleType.right,
|
handleType: flipHandles ? TextSelectionHandleType.left : TextSelectionHandleType.right,
|
||||||
),
|
),
|
||||||
@ -1665,7 +1670,16 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
// we do not need to look up the word boundary for that position. This is to
|
// we do not need to look up the word boundary for that position. This is to
|
||||||
// maintain a selectables selection collapsed at 0 when the local position is
|
// maintain a selectables selection collapsed at 0 when the local position is
|
||||||
// not located inside its rect.
|
// not located inside its rect.
|
||||||
final _WordBoundaryRecord? wordBoundary = !_rect.contains(localPosition) ? null : _getWordBoundaryAtPosition(position);
|
_WordBoundaryRecord? wordBoundary = _rect.contains(localPosition) ? _getWordBoundaryAtPosition(position) : null;
|
||||||
|
if (wordBoundary != null
|
||||||
|
&& (wordBoundary.wordStart.offset < range.start && wordBoundary.wordEnd.offset <= range.start
|
||||||
|
|| wordBoundary.wordStart.offset >= range.end && wordBoundary.wordEnd.offset > range.end)) {
|
||||||
|
// When the position is located at a placeholder inside of the text, then we may compute
|
||||||
|
// a word boundary that does not belong to the current selectable fragment. In this case
|
||||||
|
// we should invalidate the word boundary so that it is not taken into account when
|
||||||
|
// computing the target position.
|
||||||
|
wordBoundary = null;
|
||||||
|
}
|
||||||
final TextPosition targetPosition = _clampTextPosition(isEnd ? _updateSelectionEndEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd) : _updateSelectionStartEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd));
|
final TextPosition targetPosition = _clampTextPosition(isEnd ? _updateSelectionEndEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd) : _updateSelectionStartEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd));
|
||||||
|
|
||||||
_setSelectionPosition(targetPosition, isEnd: isEnd);
|
_setSelectionPosition(targetPosition, isEnd: isEnd);
|
||||||
@ -1717,16 +1731,18 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
}
|
}
|
||||||
|
|
||||||
SelectionResult _handleSelectWord(Offset globalPosition) {
|
SelectionResult _handleSelectWord(Offset globalPosition) {
|
||||||
_selectableContainsOriginWord = true;
|
|
||||||
|
|
||||||
final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition));
|
final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition));
|
||||||
if (_positionIsWithinCurrentSelection(position) && _textSelectionStart != _textSelectionEnd) {
|
if (_positionIsWithinCurrentSelection(position) && _textSelectionStart != _textSelectionEnd) {
|
||||||
return SelectionResult.end;
|
return SelectionResult.end;
|
||||||
}
|
}
|
||||||
final _WordBoundaryRecord wordBoundary = _getWordBoundaryAtPosition(position);
|
final _WordBoundaryRecord wordBoundary = _getWordBoundaryAtPosition(position);
|
||||||
if (wordBoundary.wordStart.offset < range.start && wordBoundary.wordEnd.offset < range.start) {
|
// This fragment may not contain the word, decide what direction the target
|
||||||
|
// fragment is located in. Because fragments are separated by placeholder
|
||||||
|
// spans, we also check if the beginning or end of the word is touching
|
||||||
|
// either edge of this fragment.
|
||||||
|
if (wordBoundary.wordStart.offset < range.start && wordBoundary.wordEnd.offset <= range.start) {
|
||||||
return SelectionResult.previous;
|
return SelectionResult.previous;
|
||||||
} else if (wordBoundary.wordStart.offset > range.end && wordBoundary.wordEnd.offset > range.end) {
|
} else if (wordBoundary.wordStart.offset >= range.end && wordBoundary.wordEnd.offset > range.end) {
|
||||||
return SelectionResult.next;
|
return SelectionResult.next;
|
||||||
}
|
}
|
||||||
// Fragments are separated by placeholder span, the word boundary shouldn't
|
// Fragments are separated by placeholder span, the word boundary shouldn't
|
||||||
@ -1734,6 +1750,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end);
|
assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end);
|
||||||
_textSelectionStart = wordBoundary.wordStart;
|
_textSelectionStart = wordBoundary.wordStart;
|
||||||
_textSelectionEnd = wordBoundary.wordEnd;
|
_textSelectionEnd = wordBoundary.wordEnd;
|
||||||
|
_selectableContainsOriginWord = true;
|
||||||
return SelectionResult.end;
|
return SelectionResult.end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1957,13 +1974,9 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Matrix4 getTransformToParagraph() {
|
|
||||||
return Matrix4.translationValues(_rect.left, _rect.top, 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Matrix4 getTransformTo(RenderObject? ancestor) {
|
Matrix4 getTransformTo(RenderObject? ancestor) {
|
||||||
return getTransformToParagraph()..multiply(paragraph.getTransformTo(ancestor));
|
return paragraph.getTransformTo(ancestor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -1982,6 +1995,28 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Rect>? _cachedBoundingBoxes;
|
||||||
|
@override
|
||||||
|
List<Rect> get boundingBoxes {
|
||||||
|
if (_cachedBoundingBoxes == null) {
|
||||||
|
final List<TextBox> boxes = paragraph.getBoxesForSelection(
|
||||||
|
TextSelection(baseOffset: range.start, extentOffset: range.end),
|
||||||
|
);
|
||||||
|
if (boxes.isNotEmpty) {
|
||||||
|
_cachedBoundingBoxes = <Rect>[];
|
||||||
|
for (final TextBox textBox in boxes) {
|
||||||
|
_cachedBoundingBoxes!.add(textBox.toRect());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final Offset offset = paragraph._getOffsetForPosition(TextPosition(offset: range.start));
|
||||||
|
final Rect rect = Rect.fromPoints(offset, offset.translate(0, - paragraph._textPainter.preferredLineHeight));
|
||||||
|
_cachedBoundingBoxes = <Rect>[rect];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _cachedBoundingBoxes!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect? _cachedRect;
|
||||||
Rect get _rect {
|
Rect get _rect {
|
||||||
if (_cachedRect == null) {
|
if (_cachedRect == null) {
|
||||||
final List<TextBox> boxes = paragraph.getBoxesForSelection(
|
final List<TextBox> boxes = paragraph.getBoxesForSelection(
|
||||||
@ -2000,7 +2035,6 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
}
|
}
|
||||||
return _cachedRect!;
|
return _cachedRect!;
|
||||||
}
|
}
|
||||||
Rect? _cachedRect;
|
|
||||||
|
|
||||||
void didChangeParagraphLayout() {
|
void didChangeParagraphLayout() {
|
||||||
_cachedRect = null;
|
_cachedRect = null;
|
||||||
@ -2028,12 +2062,11 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
textBox.toRect().shift(offset), selectionPaint);
|
textBox.toRect().shift(offset), selectionPaint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final Matrix4 transform = getTransformToParagraph();
|
|
||||||
if (_startHandleLayerLink != null && value.startSelectionPoint != null) {
|
if (_startHandleLayerLink != null && value.startSelectionPoint != null) {
|
||||||
context.pushLayer(
|
context.pushLayer(
|
||||||
LeaderLayer(
|
LeaderLayer(
|
||||||
link: _startHandleLayerLink!,
|
link: _startHandleLayerLink!,
|
||||||
offset: offset + MatrixUtils.transformPoint(transform, value.startSelectionPoint!.localPosition),
|
offset: offset + value.startSelectionPoint!.localPosition,
|
||||||
),
|
),
|
||||||
(PaintingContext context, Offset offset) { },
|
(PaintingContext context, Offset offset) { },
|
||||||
Offset.zero,
|
Offset.zero,
|
||||||
@ -2043,7 +2076,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
context.pushLayer(
|
context.pushLayer(
|
||||||
LeaderLayer(
|
LeaderLayer(
|
||||||
link: _endHandleLayerLink!,
|
link: _endHandleLayerLink!,
|
||||||
offset: offset + MatrixUtils.transformPoint(transform, value.endSelectionPoint!.localPosition),
|
offset: offset + value.endSelectionPoint!.localPosition,
|
||||||
),
|
),
|
||||||
(PaintingContext context, Offset offset) { },
|
(PaintingContext context, Offset offset) { },
|
||||||
Offset.zero,
|
Offset.zero,
|
||||||
@ -2071,4 +2104,12 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
TextRange getWordBoundary(TextPosition position) => paragraph.getWordBoundary(position);
|
TextRange getWordBoundary(TextPosition position) => paragraph.getWordBoundary(position);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
|
super.debugFillProperties(properties);
|
||||||
|
properties.add(DiagnosticsProperty<String>('textInsideRange', range.textInside(fullText)));
|
||||||
|
properties.add(DiagnosticsProperty<TextRange>('range', range));
|
||||||
|
properties.add(DiagnosticsProperty<String>('fullText', fullText));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -142,6 +142,10 @@ mixin Selectable implements SelectionHandler {
|
|||||||
/// The size of this [Selectable].
|
/// The size of this [Selectable].
|
||||||
Size get size;
|
Size get size;
|
||||||
|
|
||||||
|
/// A list of [Rect]s that represent the bounding box of this [Selectable]
|
||||||
|
/// in local coordinates.
|
||||||
|
List<Rect> get boundingBoxes;
|
||||||
|
|
||||||
/// Disposes resources held by the mixer.
|
/// Disposes resources held by the mixer.
|
||||||
void dispose();
|
void dispose();
|
||||||
}
|
}
|
||||||
|
@ -1817,6 +1817,14 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
_updateHandleLayersAndOwners();
|
_updateHandleLayersAndOwners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rect _getBoundingBox(Selectable selectable) {
|
||||||
|
Rect result = selectable.boundingBoxes.first;
|
||||||
|
for (int index = 1; index < selectable.boundingBoxes.length; index += 1) {
|
||||||
|
result = result.expandToInclude(selectable.boundingBoxes[index]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// The compare function this delegate used for determining the selection
|
/// The compare function this delegate used for determining the selection
|
||||||
/// order of the selectables.
|
/// order of the selectables.
|
||||||
///
|
///
|
||||||
@ -1827,11 +1835,11 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
int _compareScreenOrder(Selectable a, Selectable b) {
|
int _compareScreenOrder(Selectable a, Selectable b) {
|
||||||
final Rect rectA = MatrixUtils.transformRect(
|
final Rect rectA = MatrixUtils.transformRect(
|
||||||
a.getTransformTo(null),
|
a.getTransformTo(null),
|
||||||
Rect.fromLTWH(0, 0, a.size.width, a.size.height),
|
_getBoundingBox(a),
|
||||||
);
|
);
|
||||||
final Rect rectB = MatrixUtils.transformRect(
|
final Rect rectB = MatrixUtils.transformRect(
|
||||||
b.getTransformTo(null),
|
b.getTransformTo(null),
|
||||||
Rect.fromLTWH(0, 0, b.size.width, b.size.height),
|
_getBoundingBox(b),
|
||||||
);
|
);
|
||||||
final int result = _compareVertically(rectA, rectB);
|
final int result = _compareVertically(rectA, rectB);
|
||||||
if (result != 0) {
|
if (result != 0) {
|
||||||
@ -1846,6 +1854,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
/// Returns positive if a is lower, negative if a is higher, 0 if their
|
/// Returns positive if a is lower, negative if a is higher, 0 if their
|
||||||
/// order can't be determine solely by their vertical position.
|
/// order can't be determine solely by their vertical position.
|
||||||
static int _compareVertically(Rect a, Rect b) {
|
static int _compareVertically(Rect a, Rect b) {
|
||||||
|
// The rectangles overlap so defer to horizontal comparison.
|
||||||
if ((a.top - b.top < _kSelectableVerticalComparingThreshold && a.bottom - b.bottom > - _kSelectableVerticalComparingThreshold) ||
|
if ((a.top - b.top < _kSelectableVerticalComparingThreshold && a.bottom - b.bottom > - _kSelectableVerticalComparingThreshold) ||
|
||||||
(b.top - a.top < _kSelectableVerticalComparingThreshold && b.bottom - a.bottom > - _kSelectableVerticalComparingThreshold)) {
|
(b.top - a.top < _kSelectableVerticalComparingThreshold && b.bottom - a.bottom > - _kSelectableVerticalComparingThreshold)) {
|
||||||
return 0;
|
return 0;
|
||||||
@ -1863,19 +1872,10 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
static int _compareHorizontally(Rect a, Rect b) {
|
static int _compareHorizontally(Rect a, Rect b) {
|
||||||
// a encloses b.
|
// a encloses b.
|
||||||
if (a.left - b.left < precisionErrorTolerance && a.right - b.right > - precisionErrorTolerance) {
|
if (a.left - b.left < precisionErrorTolerance && a.right - b.right > - precisionErrorTolerance) {
|
||||||
// b ends before a.
|
|
||||||
if (a.right - b.right > precisionErrorTolerance) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// b encloses a.
|
// b encloses a.
|
||||||
if (b.left - a.left < precisionErrorTolerance && b.right - a.right > - precisionErrorTolerance) {
|
if (b.left - a.left < precisionErrorTolerance && b.right - a.right > - precisionErrorTolerance) {
|
||||||
// a ends before b.
|
|
||||||
if (b.right - a.right > precisionErrorTolerance) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if ((a.left - b.left).abs() > precisionErrorTolerance) {
|
if ((a.left - b.left).abs() > precisionErrorTolerance) {
|
||||||
@ -2140,10 +2140,17 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
|
|||||||
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
|
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
|
||||||
SelectionResult? lastSelectionResult;
|
SelectionResult? lastSelectionResult;
|
||||||
for (int index = 0; index < selectables.length; index += 1) {
|
for (int index = 0; index < selectables.length; index += 1) {
|
||||||
final Rect localRect = Rect.fromLTWH(0, 0, selectables[index].size.width, selectables[index].size.height);
|
bool globalRectsContainsPosition = false;
|
||||||
final Matrix4 transform = selectables[index].getTransformTo(null);
|
if (selectables[index].boundingBoxes.isNotEmpty) {
|
||||||
final Rect globalRect = MatrixUtils.transformRect(transform, localRect);
|
for (final Rect rect in selectables[index].boundingBoxes) {
|
||||||
if (globalRect.contains(event.globalPosition)) {
|
final Rect globalRect = MatrixUtils.transformRect(selectables[index].getTransformTo(null), rect);
|
||||||
|
if (globalRect.contains(event.globalPosition)) {
|
||||||
|
globalRectsContainsPosition = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (globalRectsContainsPosition) {
|
||||||
final SelectionGeometry existingGeometry = selectables[index].value;
|
final SelectionGeometry existingGeometry = selectables[index].value;
|
||||||
lastSelectionResult = dispatchSelectionEventToChild(selectables[index], event);
|
lastSelectionResult = dispatchSelectionEventToChild(selectables[index], event);
|
||||||
if (index == selectables.length - 1 && lastSelectionResult == SelectionResult.next) {
|
if (index == selectables.length - 1 && lastSelectionResult == SelectionResult.next) {
|
||||||
|
@ -200,6 +200,9 @@ class _SelectionContainerState extends State<SelectionContainer> with Selectable
|
|||||||
@override
|
@override
|
||||||
Size get size => (context.findRenderObject()! as RenderBox).size;
|
Size get size => (context.findRenderObject()! as RenderBox).size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Rect> get boundingBoxes => <Rect>[(context.findRenderObject()! as RenderBox).paintBounds];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
if (!widget._disabled) {
|
if (!widget._disabled) {
|
||||||
|
@ -90,7 +90,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final TestGesture longpress = await tester.startGesture(const Offset(10, 10));
|
final TestGesture longpress = await tester.startGesture(tester.getCenter(find.byType(Text)));
|
||||||
addTearDown(longpress.removePointer);
|
addTearDown(longpress.removePointer);
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
await longpress.up();
|
await longpress.up();
|
||||||
|
@ -138,9 +138,14 @@ class RenderSelectionSpy extends RenderProxyBox
|
|||||||
Size get size => _size;
|
Size get size => _size;
|
||||||
Size _size = Size.zero;
|
Size _size = Size.zero;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Rect> get boundingBoxes => _boundingBoxes;
|
||||||
|
final List<Rect> _boundingBoxes = <Rect>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size computeDryLayout(BoxConstraints constraints) {
|
Size computeDryLayout(BoxConstraints constraints) {
|
||||||
_size = Size(constraints.maxWidth, constraints.maxHeight);
|
_size = Size(constraints.maxWidth, constraints.maxHeight);
|
||||||
|
_boundingBoxes.add(Rect.fromLTWH(0.0, 0.0, constraints.maxWidth, constraints.maxHeight));
|
||||||
return _size;
|
return _size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1869,11 +1869,11 @@ void main() {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Text.rich(
|
child: Text.rich(
|
||||||
const TextSpan(
|
const TextSpan(
|
||||||
children: <InlineSpan>[
|
children: <InlineSpan>[
|
||||||
TextSpan(text: 'How are you?'),
|
TextSpan(text: 'How are you?'),
|
||||||
WidgetSpan(child: Text('Good, and you?')),
|
WidgetSpan(child: Text('Good, and you?')),
|
||||||
TextSpan(text: 'Fine, thank you.'),
|
TextSpan(text: 'Fine, thank you.'),
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
key: outerText,
|
key: outerText,
|
||||||
),
|
),
|
||||||
@ -1897,6 +1897,198 @@ void main() {
|
|||||||
skip: isBrowser, // https://github.com/flutter/flutter/issues/61020
|
skip: isBrowser, // https://github.com/flutter/flutter/issues/61020
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgetsWithLeakTracking(
|
||||||
|
'double click + drag mouse selection can handle widget span', (WidgetTester tester) async {
|
||||||
|
final UniqueKey outerText = UniqueKey();
|
||||||
|
final FocusNode focusNode = FocusNode();
|
||||||
|
addTearDown(focusNode.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: SelectableRegion(
|
||||||
|
focusNode: focusNode,
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: Center(
|
||||||
|
child: Text.rich(
|
||||||
|
const TextSpan(
|
||||||
|
children: <InlineSpan>[
|
||||||
|
TextSpan(text: 'How are you?'),
|
||||||
|
WidgetSpan(child: Text('Good, and you?')),
|
||||||
|
TextSpan(text: 'Fine, thank you.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
key: outerText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.byKey(outerText), matching: find.byType(RichText)).first);
|
||||||
|
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 0), kind: PointerDeviceKind.mouse);
|
||||||
|
addTearDown(gesture.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await gesture.down(textOffsetToPosition(paragraph, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph, 17)); // right after `Fine`.
|
||||||
|
await gesture.up();
|
||||||
|
|
||||||
|
// keyboard copy.
|
||||||
|
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyC, control: true));
|
||||||
|
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
|
||||||
|
expect(clipboardData['text'], 'How are you?Good, and you?Fine,');
|
||||||
|
},
|
||||||
|
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia }),
|
||||||
|
skip: isBrowser, // https://github.com/flutter/flutter/issues/61020
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgetsWithLeakTracking(
|
||||||
|
'double click + drag mouse selection can handle widget span - multiline', (WidgetTester tester) async {
|
||||||
|
final UniqueKey outerText = UniqueKey();
|
||||||
|
final UniqueKey innerText = UniqueKey();
|
||||||
|
final FocusNode focusNode = FocusNode();
|
||||||
|
addTearDown(focusNode.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: SelectableRegion(
|
||||||
|
focusNode: focusNode,
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: Center(
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: <InlineSpan>[
|
||||||
|
const TextSpan(text: 'How are you\n?'),
|
||||||
|
WidgetSpan(
|
||||||
|
child: Text(
|
||||||
|
'Good, and you?',
|
||||||
|
key: innerText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const TextSpan(text: 'Fine, thank you.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
key: outerText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.byKey(outerText), matching: find.byType(RichText)).first);
|
||||||
|
final RenderParagraph innerParagraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.byKey(innerText), matching: find.byType(RichText)).first);
|
||||||
|
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 0), kind: PointerDeviceKind.mouse);
|
||||||
|
addTearDown(gesture.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await gesture.down(textOffsetToPosition(paragraph, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.moveTo(textOffsetToPosition(innerParagraph, 2)); // on `Good`.
|
||||||
|
|
||||||
|
// Should not crash.
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia }),
|
||||||
|
skip: isBrowser, // https://github.com/flutter/flutter/issues/61020
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgetsWithLeakTracking(
|
||||||
|
'select word event can select inline widget', (WidgetTester tester) async {
|
||||||
|
final UniqueKey outerText = UniqueKey();
|
||||||
|
final UniqueKey innerText = UniqueKey();
|
||||||
|
final FocusNode focusNode = FocusNode();
|
||||||
|
addTearDown(focusNode.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: SelectableRegion(
|
||||||
|
focusNode: focusNode,
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: Center(
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: <InlineSpan>[
|
||||||
|
const TextSpan(text: 'How are\n you?'),
|
||||||
|
WidgetSpan(
|
||||||
|
child: Text(
|
||||||
|
'Good, and you?',
|
||||||
|
key: innerText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const TextSpan(text: 'Fine, thank you.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
key: outerText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.byKey(outerText), matching: find.byType(RichText)).first);
|
||||||
|
final RenderParagraph innerParagraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.byKey(innerText), matching: find.byType(RichText)).first);
|
||||||
|
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byKey(innerText)), kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton);
|
||||||
|
addTearDown(gesture.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Should select "and".
|
||||||
|
expect(paragraph.selections.isEmpty, isTrue);
|
||||||
|
expect(innerParagraph.selections[0], const TextSelection(baseOffset: 6, extentOffset: 9));
|
||||||
|
},
|
||||||
|
variant: TargetPlatformVariant.only(TargetPlatform.macOS),
|
||||||
|
skip: isBrowser, // https://github.com/flutter/flutter/issues/61020
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgetsWithLeakTracking(
|
||||||
|
'select word event should not crash when its position is at an unselectable inline element', (WidgetTester tester) async {
|
||||||
|
final FocusNode focusNode = FocusNode();
|
||||||
|
final UniqueKey flutterLogo = UniqueKey();
|
||||||
|
addTearDown(focusNode.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: SelectableRegion(
|
||||||
|
focusNode: focusNode,
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: <InlineSpan>[
|
||||||
|
const TextSpan(
|
||||||
|
text:
|
||||||
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||||
|
),
|
||||||
|
WidgetSpan(child: FlutterLogo(key: flutterLogo)),
|
||||||
|
const TextSpan(text: 'Hello, world.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final Offset gestureOffset = tester.getCenter(find.byKey(flutterLogo).first);
|
||||||
|
|
||||||
|
// Right click on unseletable element.
|
||||||
|
final TestGesture gesture = await tester.startGesture(gestureOffset, kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton);
|
||||||
|
addTearDown(gesture.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Should not crash.
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
variant: TargetPlatformVariant.only(TargetPlatform.macOS),
|
||||||
|
skip: isBrowser, // https://github.com/flutter/flutter/issues/61020
|
||||||
|
);
|
||||||
|
|
||||||
testWidgetsWithLeakTracking(
|
testWidgetsWithLeakTracking(
|
||||||
'can select word when a selectables rect is completely inside of another selectables rect', (WidgetTester tester) async {
|
'can select word when a selectables rect is completely inside of another selectables rect', (WidgetTester tester) async {
|
||||||
// Regression test for https://github.com/flutter/flutter/issues/127076.
|
// Regression test for https://github.com/flutter/flutter/issues/127076.
|
||||||
@ -1913,14 +2105,64 @@ void main() {
|
|||||||
body: Center(
|
body: Center(
|
||||||
child: Text.rich(
|
child: Text.rich(
|
||||||
const TextSpan(
|
const TextSpan(
|
||||||
children: <InlineSpan>[
|
children: <InlineSpan>[
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text:
|
text:
|
||||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||||
),
|
),
|
||||||
WidgetSpan(child: Text('Some text in a WidgetSpan. ')),
|
WidgetSpan(child: Text('Some text in a WidgetSpan. ')),
|
||||||
TextSpan(text: 'Hello, world.'),
|
TextSpan(text: 'Hello, world.'),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
key: outerText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.byKey(outerText), matching: find.byType(RichText)).first);
|
||||||
|
|
||||||
|
// Adjust `textOffsetToPosition` result because it returns the wrong vertical position (wrong line).
|
||||||
|
// TODO(bleroux): Remove when https://github.com/flutter/flutter/issues/133637 is fixed.
|
||||||
|
final Offset gestureOffset = textOffsetToPosition(paragraph, 125).translate(0, 10);
|
||||||
|
|
||||||
|
// Right click to select word at position.
|
||||||
|
final TestGesture gesture = await tester.startGesture(gestureOffset, kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton);
|
||||||
|
addTearDown(gesture.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
// Should select "Hello".
|
||||||
|
expect(paragraph.selections[0], const TextSelection(baseOffset: 124, extentOffset: 129));
|
||||||
|
},
|
||||||
|
variant: TargetPlatformVariant.only(TargetPlatform.macOS),
|
||||||
|
skip: isBrowser, // https://github.com/flutter/flutter/issues/61020
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgetsWithLeakTracking(
|
||||||
|
'can select word when selectable is broken up by an unselectable WidgetSpan', (WidgetTester tester) async {
|
||||||
|
final UniqueKey outerText = UniqueKey();
|
||||||
|
final FocusNode focusNode = FocusNode();
|
||||||
|
addTearDown(focusNode.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: SelectableRegion(
|
||||||
|
focusNode: focusNode,
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Text.rich(
|
||||||
|
const TextSpan(
|
||||||
|
children: <InlineSpan>[
|
||||||
|
TextSpan(
|
||||||
|
text:
|
||||||
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||||
|
),
|
||||||
|
WidgetSpan(child: SizedBox.shrink()),
|
||||||
|
TextSpan(text: 'Hello, world.'),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
key: outerText,
|
key: outerText,
|
||||||
),
|
),
|
||||||
@ -1963,11 +2205,11 @@ void main() {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Text.rich(
|
child: Text.rich(
|
||||||
const TextSpan(
|
const TextSpan(
|
||||||
children: <InlineSpan>[
|
children: <InlineSpan>[
|
||||||
TextSpan(text: 'How are you?'),
|
TextSpan(text: 'How are you?'),
|
||||||
WidgetSpan(child: Placeholder()),
|
WidgetSpan(child: Placeholder()),
|
||||||
TextSpan(text: 'Fine, thank you.'),
|
TextSpan(text: 'Fine, thank you.'),
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
key: outerText,
|
key: outerText,
|
||||||
),
|
),
|
||||||
@ -2006,11 +2248,11 @@ void main() {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Text.rich(
|
child: Text.rich(
|
||||||
const TextSpan(
|
const TextSpan(
|
||||||
children: <InlineSpan>[
|
children: <InlineSpan>[
|
||||||
TextSpan(text: 'How are you?'),
|
TextSpan(text: 'How are you?'),
|
||||||
WidgetSpan(child: Placeholder()),
|
WidgetSpan(child: Placeholder()),
|
||||||
TextSpan(text: 'Fine, thank you.'),
|
TextSpan(text: 'Fine, thank you.'),
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
key: outerText,
|
key: outerText,
|
||||||
),
|
),
|
||||||
@ -3542,6 +3784,9 @@ class RenderSelectionSpy extends RenderProxyBox
|
|||||||
Size get size => _size;
|
Size get size => _size;
|
||||||
Size _size = Size.zero;
|
Size _size = Size.zero;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Rect> get boundingBoxes => <Rect>[paintBounds];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size computeDryLayout(BoxConstraints constraints) {
|
Size computeDryLayout(BoxConstraints constraints) {
|
||||||
_size = Size(constraints.maxWidth, constraints.maxHeight);
|
_size = Size(constraints.maxWidth, constraints.maxHeight);
|
||||||
@ -3610,6 +3855,9 @@ class RenderSelectAll extends RenderProxyBox
|
|||||||
this.registrar = registrar;
|
this.registrar = registrar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Rect> get boundingBoxes => <Rect>[paintBounds];
|
||||||
|
|
||||||
final Set<VoidCallback> listeners = <VoidCallback>{};
|
final Set<VoidCallback> listeners = <VoidCallback>{};
|
||||||
LayerLink? startHandle;
|
LayerLink? startHandle;
|
||||||
LayerLink? endHandle;
|
LayerLink? endHandle;
|
||||||
|
Loading…
Reference in New Issue
Block a user