diff --git a/examples/api/lib/material/selectable_region/selectable_region.0.dart b/examples/api/lib/material/selectable_region/selectable_region.0.dart index da20ccf6950..33bfe031b1c 100644 --- a/examples/api/lib/material/selectable_region/selectable_region.0.dart +++ b/examples/api/lib/material/selectable_region/selectable_region.0.dart @@ -115,6 +115,9 @@ class _RenderSelectableAdapter extends RenderProxyBox with Selectable, Selection // Selectable APIs. + @override + List get boundingBoxes => [paintBounds]; + // Adjust this value to enlarge or shrink the selection highlight. static const double _padding = 10.0; Rect _getSelectionHighlightRect() { diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 1dcd4942080..fb6e9934d5f 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -409,7 +409,13 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin= 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)); _setSelectionPosition(targetPosition, isEnd: isEnd); @@ -1717,16 +1731,18 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM } SelectionResult _handleSelectWord(Offset globalPosition) { - _selectableContainsOriginWord = true; - final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition)); if (_positionIsWithinCurrentSelection(position) && _textSelectionStart != _textSelectionEnd) { return SelectionResult.end; } 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; - } 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; } // 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); _textSelectionStart = wordBoundary.wordStart; _textSelectionEnd = wordBoundary.wordEnd; + _selectableContainsOriginWord = true; 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 Matrix4 getTransformTo(RenderObject? ancestor) { - return getTransformToParagraph()..multiply(paragraph.getTransformTo(ancestor)); + return paragraph.getTransformTo(ancestor); } @override @@ -1982,6 +1995,28 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM } } + List? _cachedBoundingBoxes; + @override + List get boundingBoxes { + if (_cachedBoundingBoxes == null) { + final List boxes = paragraph.getBoxesForSelection( + TextSelection(baseOffset: range.start, extentOffset: range.end), + ); + if (boxes.isNotEmpty) { + _cachedBoundingBoxes = []; + 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]; + } + } + return _cachedBoundingBoxes!; + } + + Rect? _cachedRect; Rect get _rect { if (_cachedRect == null) { final List boxes = paragraph.getBoxesForSelection( @@ -2000,7 +2035,6 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM } return _cachedRect!; } - Rect? _cachedRect; void didChangeParagraphLayout() { _cachedRect = null; @@ -2028,12 +2062,11 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM textBox.toRect().shift(offset), selectionPaint); } } - final Matrix4 transform = getTransformToParagraph(); if (_startHandleLayerLink != null && value.startSelectionPoint != null) { context.pushLayer( LeaderLayer( link: _startHandleLayerLink!, - offset: offset + MatrixUtils.transformPoint(transform, value.startSelectionPoint!.localPosition), + offset: offset + value.startSelectionPoint!.localPosition, ), (PaintingContext context, Offset offset) { }, Offset.zero, @@ -2043,7 +2076,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM context.pushLayer( LeaderLayer( link: _endHandleLayerLink!, - offset: offset + MatrixUtils.transformPoint(transform, value.endSelectionPoint!.localPosition), + offset: offset + value.endSelectionPoint!.localPosition, ), (PaintingContext context, Offset offset) { }, Offset.zero, @@ -2071,4 +2104,12 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM @override TextRange getWordBoundary(TextPosition position) => paragraph.getWordBoundary(position); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('textInsideRange', range.textInside(fullText))); + properties.add(DiagnosticsProperty('range', range)); + properties.add(DiagnosticsProperty('fullText', fullText)); + } } diff --git a/packages/flutter/lib/src/rendering/selection.dart b/packages/flutter/lib/src/rendering/selection.dart index d5434f26f9d..610432c3709 100644 --- a/packages/flutter/lib/src/rendering/selection.dart +++ b/packages/flutter/lib/src/rendering/selection.dart @@ -142,6 +142,10 @@ mixin Selectable implements SelectionHandler { /// The size of this [Selectable]. Size get size; + /// A list of [Rect]s that represent the bounding box of this [Selectable] + /// in local coordinates. + List get boundingBoxes; + /// Disposes resources held by the mixer. void dispose(); } diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index 317ed0131b2..8fbdb763a77 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -1817,6 +1817,14 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai _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 /// order of the selectables. /// @@ -1827,11 +1835,11 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai int _compareScreenOrder(Selectable a, Selectable b) { final Rect rectA = MatrixUtils.transformRect( a.getTransformTo(null), - Rect.fromLTWH(0, 0, a.size.width, a.size.height), + _getBoundingBox(a), ); final Rect rectB = MatrixUtils.transformRect( b.getTransformTo(null), - Rect.fromLTWH(0, 0, b.size.width, b.size.height), + _getBoundingBox(b), ); final int result = _compareVertically(rectA, rectB); 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 /// order can't be determine solely by their vertical position. 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) || (b.top - a.top < _kSelectableVerticalComparingThreshold && b.bottom - a.bottom > - _kSelectableVerticalComparingThreshold)) { return 0; @@ -1863,19 +1872,10 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai static int _compareHorizontally(Rect a, Rect b) { // a encloses b. 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; } - // b encloses a. 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; } if ((a.left - b.left).abs() > precisionErrorTolerance) { @@ -2140,10 +2140,17 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai SelectionResult handleSelectWord(SelectWordSelectionEvent event) { SelectionResult? lastSelectionResult; for (int index = 0; index < selectables.length; index += 1) { - final Rect localRect = Rect.fromLTWH(0, 0, selectables[index].size.width, selectables[index].size.height); - final Matrix4 transform = selectables[index].getTransformTo(null); - final Rect globalRect = MatrixUtils.transformRect(transform, localRect); - if (globalRect.contains(event.globalPosition)) { + bool globalRectsContainsPosition = false; + if (selectables[index].boundingBoxes.isNotEmpty) { + for (final Rect rect in selectables[index].boundingBoxes) { + 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; lastSelectionResult = dispatchSelectionEventToChild(selectables[index], event); if (index == selectables.length - 1 && lastSelectionResult == SelectionResult.next) { diff --git a/packages/flutter/lib/src/widgets/selection_container.dart b/packages/flutter/lib/src/widgets/selection_container.dart index b0e6c743b99..45535ab6fb1 100644 --- a/packages/flutter/lib/src/widgets/selection_container.dart +++ b/packages/flutter/lib/src/widgets/selection_container.dart @@ -200,6 +200,9 @@ class _SelectionContainerState extends State with Selectable @override Size get size => (context.findRenderObject()! as RenderBox).size; + @override + List get boundingBoxes => [(context.findRenderObject()! as RenderBox).paintBounds]; + @override void dispose() { if (!widget._disabled) { diff --git a/packages/flutter/test/material/selection_area_test.dart b/packages/flutter/test/material/selection_area_test.dart index 521a3ff66ec..5fb653127a4 100644 --- a/packages/flutter/test/material/selection_area_test.dart +++ b/packages/flutter/test/material/selection_area_test.dart @@ -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); await tester.pump(const Duration(milliseconds: 500)); await longpress.up(); diff --git a/packages/flutter/test/widgets/selectable_region_context_menu_test.dart b/packages/flutter/test/widgets/selectable_region_context_menu_test.dart index b3c075ace59..344052e5617 100644 --- a/packages/flutter/test/widgets/selectable_region_context_menu_test.dart +++ b/packages/flutter/test/widgets/selectable_region_context_menu_test.dart @@ -138,9 +138,14 @@ class RenderSelectionSpy extends RenderProxyBox Size get size => _size; Size _size = Size.zero; + @override + List get boundingBoxes => _boundingBoxes; + final List _boundingBoxes = []; + @override Size computeDryLayout(BoxConstraints constraints) { _size = Size(constraints.maxWidth, constraints.maxHeight); + _boundingBoxes.add(Rect.fromLTWH(0.0, 0.0, constraints.maxWidth, constraints.maxHeight)); return _size; } diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart index 4be55348f5d..ec8b5c2d029 100644 --- a/packages/flutter/test/widgets/selectable_region_test.dart +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -1869,11 +1869,11 @@ void main() { child: Center( child: Text.rich( const TextSpan( - children: [ - TextSpan(text: 'How are you?'), - WidgetSpan(child: Text('Good, and you?')), - TextSpan(text: 'Fine, thank you.'), - ] + children: [ + TextSpan(text: 'How are you?'), + WidgetSpan(child: Text('Good, and you?')), + TextSpan(text: 'Fine, thank you.'), + ], ), key: outerText, ), @@ -1897,6 +1897,198 @@ void main() { 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: [ + TextSpan(text: 'How are you?'), + WidgetSpan(child: Text('Good, and you?')), + TextSpan(text: 'Fine, thank you.'), + ], + ), + key: outerText, + ), + ), + ), + ), + ); + final RenderParagraph paragraph = tester.renderObject(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 clipboardData = mockClipboard.clipboardData as Map; + expect(clipboardData['text'], 'How are you?Good, and you?Fine,'); + }, + variant: const TargetPlatformVariant({ 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: [ + 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(find.descendant(of: find.byKey(outerText), matching: find.byType(RichText)).first); + final RenderParagraph innerParagraph = tester.renderObject(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.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: [ + 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(find.descendant(of: find.byKey(outerText), matching: find.byType(RichText)).first); + final RenderParagraph innerParagraph = tester.renderObject(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: [ + 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( '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. @@ -1913,14 +2105,64 @@ void main() { body: Center( child: Text.rich( const TextSpan( - children: [ - TextSpan( - text: - '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. ')), - TextSpan(text: 'Hello, world.'), - ], + children: [ + TextSpan( + text: + '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. ')), + TextSpan(text: 'Hello, world.'), + ], + ), + key: outerText, + ), + ), + ), + ), + ), + ); + final RenderParagraph paragraph = tester.renderObject(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: [ + 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, ), @@ -1963,11 +2205,11 @@ void main() { child: Center( child: Text.rich( const TextSpan( - children: [ - TextSpan(text: 'How are you?'), - WidgetSpan(child: Placeholder()), - TextSpan(text: 'Fine, thank you.'), - ] + children: [ + TextSpan(text: 'How are you?'), + WidgetSpan(child: Placeholder()), + TextSpan(text: 'Fine, thank you.'), + ], ), key: outerText, ), @@ -2006,11 +2248,11 @@ void main() { child: Center( child: Text.rich( const TextSpan( - children: [ - TextSpan(text: 'How are you?'), - WidgetSpan(child: Placeholder()), - TextSpan(text: 'Fine, thank you.'), - ] + children: [ + TextSpan(text: 'How are you?'), + WidgetSpan(child: Placeholder()), + TextSpan(text: 'Fine, thank you.'), + ], ), key: outerText, ), @@ -3542,6 +3784,9 @@ class RenderSelectionSpy extends RenderProxyBox Size get size => _size; Size _size = Size.zero; + @override + List get boundingBoxes => [paintBounds]; + @override Size computeDryLayout(BoxConstraints constraints) { _size = Size(constraints.maxWidth, constraints.maxHeight); @@ -3610,6 +3855,9 @@ class RenderSelectAll extends RenderProxyBox this.registrar = registrar; } + @override + List get boundingBoxes => [paintBounds]; + final Set listeners = {}; LayerLink? startHandle; LayerLink? endHandle;