feat: cross row select (#2437)

* feat(wip): cross row select

* fix patch

Signed-off-by: dom <githubaccount56556@proton.me>

* update

Signed-off-by: dom <githubaccount56556@proton.me>

---------

Co-authored-by: dom <githubaccount56556@proton.me>
This commit is contained in:
My-Responsitories
2026-07-03 13:37:45 +00:00
committed by GitHub
parent 204715266f
commit b540647222
6 changed files with 472 additions and 226 deletions

View File

@@ -74,21 +74,27 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
if (isPortrait) { if (isPortrait) {
return Padding( return Padding(
padding: EdgeInsets.symmetric(horizontal: padding), padding: EdgeInsets.symmetric(horizontal: padding),
child: CustomScrollView( child: SelectionArea(
physics: const AlwaysScrollableScrollPhysics(), child: CustomScrollView(
slivers: [ physics: const AlwaysScrollableScrollPhysics(),
_buildContent( slivers: [
maxWidth - this.padding.horizontal - 2 * padding - 24, _buildContent(
), maxWidth - this.padding.horizontal - 2 * padding - 24,
SliverToBoxAdapter(
child: Divider(
thickness: 8,
color: theme.dividerColor.withValues(alpha: 0.05),
), ),
), SelectionContainer.disabled(
buildReplyHeader(), child: SliverToBoxAdapter(
Obx(() => replyList(controller.loadingState.value)), child: Divider(
], thickness: 8,
color: theme.dividerColor.withValues(alpha: 0.05),
),
),
),
SelectionContainer.disabled(child: buildReplyHeader()),
SelectionContainer.disabled(
child: Obx(() => replyList(controller.loadingState.value)),
),
],
),
), ),
); );
} }
@@ -101,21 +107,25 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
children: [ children: [
Expanded( Expanded(
flex: flex, flex: flex,
child: CustomScrollView( child: SelectionArea(
physics: const AlwaysScrollableScrollPhysics(), child: CustomScrollView(
slivers: [ physics: const AlwaysScrollableScrollPhysics(),
SliverPadding( slivers: [
padding: EdgeInsets.only( SliverPadding(
left: padding, padding: EdgeInsets.only(
bottom: this.padding.bottom + 100, left: padding,
bottom: this.padding.bottom + 100,
),
sliver: _buildContent(
(maxWidth - this.padding.horizontal) *
flex /
(flex + flex1) -
padding -
32,
),
), ),
sliver: _buildContent( ],
(maxWidth - this.padding.horizontal) * flex / (flex + flex1) - ),
padding -
32,
),
),
],
), ),
), ),
VerticalDivider( VerticalDivider(
@@ -151,7 +161,7 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
sliver: Obx( sliver: Obx(
() { () {
if (controller.isLoaded.value) { if (controller.isLoaded.value) {
late Widget content; final Widget content;
if (controller.opus != null) { if (controller.opus != null) {
// if (kDebugMode) debugPrint('json page'); // if (kDebugMode) debugPrint('json page');
content = OpusContent( content = OpusContent(
@@ -159,9 +169,9 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
images: controller.images, images: controller.images,
maxWidth: maxWidth, maxWidth: maxWidth,
); );
} else if (controller.opusData?.modules.moduleBlocked != null) { } else if (controller.opusData?.modules.moduleBlocked
case final moduleBlocked?) {
// if (kDebugMode) debugPrint('moduleBlocked'); // if (kDebugMode) debugPrint('moduleBlocked');
final moduleBlocked = controller.opusData!.modules.moduleBlocked!;
content = SliverToBoxAdapter( content = SliverToBoxAdapter(
child: moduleBlockedItem(context, theme, moduleBlocked), child: moduleBlockedItem(context, theme, moduleBlocked),
); );
@@ -201,128 +211,119 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
content = const SliverToBoxAdapter(child: Text('NULL')); content = const SliverToBoxAdapter(child: Text('NULL'));
} }
int? pubTime = final pubTime =
controller.opusData?.modules.moduleAuthor?.pubTs ?? controller.opusData?.modules.moduleAuthor?.pubTs ??
controller.articleData?.publishTime; controller.articleData?.publishTime;
return SliverMainAxisGroup( return SliverMainAxisGroup(
slivers: [ slivers: [
if (controller.type != 'read' && if (controller.type != 'read')
controller if (controller.opusData?.modules.moduleTop?.display?.album?.pics
.opusData case final pics? when pics.isNotEmpty)
?.modules SliverToBoxAdapter(
.moduleTop child: Builder(
?.display builder: (context) {
?.album final length = pics.length;
?.pics final first = pics.first;
?.isNotEmpty == double height;
true) if (first.height != null && first.width != null) {
SliverToBoxAdapter( final ratio = first.height! / first.width!;
child: Builder( height = min(maxWidth * ratio, maxHeight * 0.55);
builder: (context) { } else {
final pics = controller height = maxHeight * 0.55;
.opusData! }
.modules return Stack(
.moduleTop! clipBehavior: Clip.none,
.display! children: [
.album! Container(
.pics!; height: height,
final length = pics.length; width: maxWidth,
final first = pics.first; margin: const EdgeInsets.only(bottom: 10),
double height; child: PageView.builder(
if (first.height != null && first.width != null) { physics: clampingScrollPhysics,
final ratio = first.height! / first.width!; onPageChanged: (value) =>
height = min(maxWidth * ratio, maxHeight * 0.55); controller.topIndex.value = value,
} else { itemCount: length,
height = maxHeight * 0.55; itemBuilder: (context, index) {
} final pic = pics[index];
return Stack( int? memCacheWidth, memCacheHeight;
clipBehavior: Clip.none, if (pic.isLongPic ?? false) {
children: [
Container(
height: height,
width: maxWidth,
margin: const EdgeInsets.only(bottom: 10),
child: PageView.builder(
physics: clampingScrollPhysics,
onPageChanged: (value) =>
controller.topIndex.value = value,
itemCount: length,
itemBuilder: (context, index) {
final pic = pics[index];
int? memCacheWidth, memCacheHeight;
if (pic.isLongPic ?? false) {
memCacheWidth = maxWidth.cacheSize(context);
} else if (pic.width != null &&
pic.height != null) {
if (pic.width! > pic.height!) {
memCacheWidth = maxWidth.cacheSize(context); memCacheWidth = maxWidth.cacheSize(context);
} else { } else if (pic.width != null &&
memCacheHeight = height.cacheSize(context); pic.height != null) {
if (pic.width! > pic.height!) {
memCacheWidth = maxWidth.cacheSize(
context,
);
} else {
memCacheHeight = height.cacheSize(
context,
);
}
} }
} return GestureDetector(
return GestureDetector( behavior: HitTestBehavior.opaque,
behavior: HitTestBehavior.opaque, onTap: () => PageUtils.imageView(
onTap: () => PageUtils.imageView( quality: 60,
quality: 60, imgList: pics
imgList: pics .map((e) => SourceModel(url: e.url!))
.map((e) => SourceModel(url: e.url!)) .toList(),
.toList(), initialPage: index,
initialPage: index,
),
child: Hero(
tag: pic.url!,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
CachedNetworkImage(
height: height,
width: maxWidth,
memCacheWidth: memCacheWidth,
memCacheHeight: memCacheHeight,
fit: pic.isLongPic == true
? BoxFit.cover
: null,
imageUrl: ImageUtils.thumbnailUrl(
pic.url,
60,
),
fadeInDuration: const Duration(
milliseconds: 120,
),
fadeOutDuration: const Duration(
milliseconds: 120,
),
placeholder: (_, _) =>
const SizedBox.shrink(),
),
if (pic.isLongPic == true)
const PBadge(
right: 12,
bottom: 12,
text: '长图',
type: .primary,
),
],
), ),
), child: Hero(
); tag: pic.url!,
}, child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
CachedNetworkImage(
height: height,
width: maxWidth,
memCacheWidth: memCacheWidth,
memCacheHeight: memCacheHeight,
fit: pic.isLongPic == true
? BoxFit.cover
: null,
imageUrl: ImageUtils.thumbnailUrl(
pic.url,
60,
),
fadeInDuration: const Duration(
milliseconds: 120,
),
fadeOutDuration: const Duration(
milliseconds: 120,
),
placeholder: (_, _) =>
const SizedBox.shrink(),
),
if (pic.isLongPic == true)
const PBadge(
right: 12,
bottom: 12,
text: '长图',
type: .primary,
),
],
),
),
);
},
),
), ),
), Obx(
Obx( () => PBadge(
() => PBadge( top: 12,
top: 12, right: 12,
right: 12, type: PBadgeType.gray,
type: PBadgeType.gray, text:
text: '${controller.topIndex.value + 1}/$length', '${controller.topIndex.value + 1}/$length',
),
), ),
), ],
], );
); },
}, ),
), ),
),
if (controller.summary.title != null) if (controller.summary.title != null)
SliverToBoxWithVisibilityAdapter( SliverToBoxWithVisibilityAdapter(
onVisibilityChanged: (bool visible) => onVisibilityChanged: (bool visible) =>
@@ -342,39 +343,41 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
onTap: () => Get.toNamed( onTap: () => Get.toNamed(
'/member?mid=${controller.summary.author?.mid}', '/member?mid=${controller.summary.author?.mid}',
), ),
child: Row( child: SelectionContainer.disabled(
children: [ child: Row(
NetworkImgLayer( children: [
width: 40, NetworkImgLayer(
height: 40, width: 40,
type: ImageType.avatar, height: 40,
src: controller.summary.author?.face, type: ImageType.avatar,
), src: controller.summary.author?.face,
const SizedBox(width: 10), ),
Flexible( const SizedBox(width: 10),
child: Column( Flexible(
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Text( children: [
controller.summary.author?.name ?? '',
style: TextStyle(
fontSize:
theme.textTheme.titleSmall!.fontSize,
),
),
if (pubTime != null)
Text( Text(
DateFormatUtils.format(pubTime), controller.summary.author?.name ?? '',
style: TextStyle( style: TextStyle(
color: theme.colorScheme.outline,
fontSize: fontSize:
theme.textTheme.labelSmall!.fontSize, theme.textTheme.titleSmall!.fontSize,
), ),
), ),
], if (pubTime != null)
Text(
DateFormatUtils.format(pubTime),
style: TextStyle(
color: theme.colorScheme.outline,
fontSize:
theme.textTheme.labelSmall!.fontSize,
),
),
],
),
), ),
), ],
], ),
), ),
), ),
), ),
@@ -382,9 +385,11 @@ class _ArticlePageState extends CommonDynPageState<ArticlePage> {
if (controller.type != 'read' && if (controller.type != 'read' &&
controller.opusData?.modules.moduleCollection != null) controller.opusData?.modules.moduleCollection != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: opusCollection( child: SelectionContainer.disabled(
theme, child: opusCollection(
controller.opusData!.modules.moduleCollection!, theme,
controller.opusData!.modules.moduleCollection!,
),
), ),
), ),
content, content,

View File

@@ -33,7 +33,7 @@ class ArticleOpus extends StatelessWidget {
final item = _ops[index]; final item = _ops[index];
switch (item.insert) { switch (item.insert) {
case String e: case String e:
return SelectableText(e); return Text(e);
case Insert(:final card): case Insert(:final card):
if (card != null) { if (card != null) {
if (card.url?.isNotEmpty == true) { if (card.url?.isNotEmpty == true) {

View File

@@ -125,17 +125,15 @@ Widget htmlRender({
margin: Margins.zero, margin: Margins.zero,
), ),
}; };
return SelectionArea( return element != null
child: element != null ? Html.fromElement(
? Html.fromElement( documentElement: element,
documentElement: element, extensions: extensions,
extensions: extensions, style: style,
style: style, )
) : Html(
: Html( data: html,
data: html, extensions: extensions,
extensions: extensions, style: style,
style: style, );
),
);
} }

View File

@@ -178,7 +178,7 @@ class OpusContent extends StatelessWidget {
switch (element.paraType) { switch (element.paraType) {
case 1 || 4: case 1 || 4:
final isQuote = element.paraType == 4; final isQuote = element.paraType == 4;
Widget widget = SelectableText.rich( Widget widget = Text.rich(
textAlign: element.align == 1 ? TextAlign.center : null, textAlign: element.align == 1 ? TextAlign.center : null,
TextSpan( TextSpan(
children: element.text?.nodes children: element.text?.nodes
@@ -194,12 +194,7 @@ class OpusContent extends StatelessWidget {
); );
if (isQuote) { if (isQuote) {
widget = Container( widget = Container(
padding: const EdgeInsets.only( padding: const .only(left: 8, top: 4, right: 4, bottom: 4),
left: 8,
top: 4,
right: 4,
bottom: 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
left: BorderSide( left: BorderSide(
@@ -215,8 +210,9 @@ class OpusContent extends StatelessWidget {
} }
return widget; return widget;
case 2 when (element.pic != null): case 2 when (element.pic != null):
if (element.pic!.pics!.length == 1) { final pics = element.pic!.pics!;
final pic = element.pic!.pics!.first; if (pics.length == 1) {
final pic = pics.first;
double? width = pic.width == null double? width = pic.width == null
? null ? null
: math.min(maxWidth, pic.width!); : math.min(maxWidth, pic.width!);
@@ -250,7 +246,7 @@ class OpusContent extends StatelessWidget {
); );
} else { } else {
return ImageGridView( return ImageGridView(
picArr: element.pic!.pics! picArr: pics
.map( .map(
(e) => ImageModel( (e) => ImageModel(
width: e.width, width: e.width,
@@ -262,19 +258,20 @@ class OpusContent extends StatelessWidget {
); );
} }
case 3 when (element.line?.pic != null): case 3 when (element.line?.pic != null):
final height = element.line!.pic!.height?.toDouble(); final pic = element.line!.pic!;
final height = pic.height?.toDouble();
return CachedNetworkImage( return CachedNetworkImage(
fit: .contain, fit: .contain,
height: height, height: height,
width: maxWidth, width: maxWidth,
memCacheWidth: maxWidth.cacheSize(context), memCacheWidth: maxWidth.cacheSize(context),
imageUrl: ImageUtils.thumbnailUrl(element.line!.pic!.url!), imageUrl: ImageUtils.thumbnailUrl(pic.url!),
placeholder: (_, _) => const SizedBox.shrink(), placeholder: (_, _) => const SizedBox.shrink(),
); );
case 5 when (element.list != null): case 5 when (element.list?.items?.isNotEmpty == true):
return SelectableText.rich( return Text.rich(
TextSpan( TextSpan(
children: element.list!.items?.mapIndexed((i, entry) { children: element.list!.items!.mapIndexed((i, entry) {
return TextSpan( return TextSpan(
children: [ children: [
const WidgetSpan( const WidgetSpan(
@@ -589,39 +586,32 @@ class OpusContent extends StatelessWidget {
? null ? null
: () { : () {
try { try {
final card = element.linkCard!.card!;
if (type == 'LINK_CARD_TYPE_VOTE') { if (type == 'LINK_CARD_TYPE_VOTE') {
showVoteDialog( showVoteDialog(
context, context,
element.linkCard!.card!.vote?.voteId ?? card.vote?.voteId ?? int.parse(card.oid!),
int.parse(element.linkCard!.card!.oid!),
); );
return; return;
} }
if (type == 'LINK_CARD_TYPE_ITEM_NULL') { if (type == 'LINK_CARD_TYPE_ITEM_NULL') {
switch (element.linkCard?.card?.itemNull?.text) { switch (card.itemNull?.text) {
case '视频': case '视频':
PiliScheme.videoPush( PiliScheme.videoPush(
int.parse(element.linkCard!.card!.oid!), int.parse(card.oid!),
null, null,
); );
default: default:
PageUtils.pushDynFromId( PageUtils.pushDynFromId(id: card.oid!);
id: element.linkCard!.card!.oid!,
);
} }
return; return;
} }
String? url = switch (type) { final url = switch (type) {
'LINK_CARD_TYPE_UGC' => 'LINK_CARD_TYPE_UGC' => card.ugc!.jumpUrl,
element.linkCard!.card!.ugc!.jumpUrl, 'LINK_CARD_TYPE_COMMON' => card.common!.jumpUrl,
'LINK_CARD_TYPE_COMMON' => 'LINK_CARD_TYPE_LIVE' => card.live!.jumpUrl,
element.linkCard!.card!.common!.jumpUrl, 'LINK_CARD_TYPE_OPUS' => card.opus!.jumpUrl,
'LINK_CARD_TYPE_LIVE' => 'LINK_CARD_TYPE_MUSIC' => card.music!.jumpUrl,
element.linkCard!.card!.live!.jumpUrl,
'LINK_CARD_TYPE_OPUS' =>
element.linkCard!.card!.opus!.jumpUrl,
'LINK_CARD_TYPE_MUSIC' =>
element.linkCard!.card!.music!.jumpUrl,
_ => null, _ => null,
}; };
if (url != null && url.isNotEmpty) { if (url != null && url.isNotEmpty) {
@@ -660,10 +650,10 @@ class OpusContent extends StatelessWidget {
color: colorScheme.onInverseSurface, color: colorScheme.onInverseSurface,
), ),
width: .infinity, width: .infinity,
child: SelectableText.rich(renderer.span!), child: Text.rich(renderer.span!),
); );
case 8 when (element.heading?.nodes?.isNotEmpty == true): case 8 when (element.heading?.nodes?.isNotEmpty == true):
return SelectableText.rich( return Text.rich(
TextSpan( TextSpan(
children: element.heading!.nodes! children: element.heading!.nodes!
.map( .map(
@@ -679,7 +669,7 @@ class OpusContent extends StatelessWidget {
default: default:
if (kDebugMode) debugPrint('unknown type ${element.paraType}'); if (kDebugMode) debugPrint('unknown type ${element.paraType}');
if (element.text?.nodes?.isNotEmpty == true) { if (element.text?.nodes?.isNotEmpty == true) {
return SelectableText.rich( return Text.rich(
textAlign: element.align == 1 ? TextAlign.center : null, textAlign: element.align == 1 ? TextAlign.center : null,
TextSpan( TextSpan(
children: element.text!.nodes! children: element.text!.nodes!
@@ -694,7 +684,7 @@ class OpusContent extends StatelessWidget {
); );
} }
return SelectableText( return Text(
'不支持的类型 (${element.paraType})', '不支持的类型 (${element.paraType})',
style: const TextStyle( style: const TextStyle(
fontWeight: .bold, fontWeight: .bold,
@@ -703,7 +693,7 @@ class OpusContent extends StatelessWidget {
); );
} }
} catch (e, s) { } catch (e, s) {
return SelectableText( return Text(
'错误的类型 $e${kDebugMode ? '\n$s' : ''}', '错误的类型 $e${kDebugMode ? '\n$s' : ''}',
style: const TextStyle( style: const TextStyle(
fontWeight: .bold, fontWeight: .bold,

View File

@@ -0,0 +1,249 @@
diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart
index 59de8bae20b..5a6923f3a6a 100644
--- a/packages/flutter/lib/src/widgets/selectable_region.dart
+++ b/packages/flutter/lib/src/widgets/selectable_region.dart
@@ -29,8 +29,10 @@ import 'framework.dart';
import 'gesture_detector.dart';
import 'magnifier.dart';
import 'media_query.dart';
+import 'notification_listener.dart';
import 'overlay.dart';
import 'platform_selectable_region_context_menu.dart';
+import 'scroll_notification.dart';
import 'selection_container.dart';
import 'tap_region.dart';
import 'text_editing_intents.dart';
@@ -1775,12 +1777,12 @@ class SelectableRegionState extends State<SelectableRegion>
/// The line height at the start of the current selection.
double get startGlyphHeight {
- return _selectionDelegate.value.startSelectionPoint!.lineHeight;
+ return _selectionDelegate.value.startSelectionPoint?.lineHeight ?? 0;
}
/// The line height at the end of the current selection.
double get endGlyphHeight {
- return _selectionDelegate.value.endSelectionPoint!.lineHeight;
+ return _selectionDelegate.value.endSelectionPoint?.lineHeight ?? 0;
}
/// Returns the local coordinates of the endpoints of the current selection.
@@ -1946,7 +1948,7 @@ class SelectableRegionState extends State<SelectableRegion>
if (_webContextMenuEnabled) {
result = PlatformSelectableRegionContextMenu(child: result);
}
- return TapRegion(
+ final tapRegion = TapRegion(
groupId: SelectableRegion,
onTapOutside: (PointerDownEvent event) {
// To match the native web behavior, this selectable region is
@@ -1976,6 +1978,22 @@ class SelectableRegionState extends State<SelectableRegion>
),
),
);
+ return NotificationListener<ScrollEndNotification>(
+ onNotification: (notification) {
+ _selectionDelegate.layoutDidChange();
+ _updateSelectedContentIfNeeded();
+ final SelectedContent? lastSelection = _lastSelectedContent;
+ if (lastSelection != null && lastSelection.plainText.isNotEmpty) {
+ _showHandles();
+ final SelectionOverlay? selectionOverlay = _selectionOverlay;
+ if (selectionOverlay == null || !selectionOverlay.toolbarIsVisible) {
+ _showToolbar();
+ }
+ }
+ return false;
+ },
+ child: tapRegion,
+ );
}
}
diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart
index f7bd4071c8a..a82a9448120 100644
--- a/packages/flutter/test/widgets/selectable_region_test.dart
+++ b/packages/flutter/test/widgets/selectable_region_test.dart
@@ -841,6 +841,125 @@ void main() {
expect(renderSelectionSpy.events.length, 0);
});
+ testWidgets(
+ 'SelectionArea + ListView: select across multiple lines and scroll does not crash',
+ (WidgetTester tester) async {
+ // Regression test: when selection spans across ListView items and user scrolls,
+ // startSelectionPoint/endSelectionPoint may be null (off-screen). Verifies that
+ // startGlyphHeight/endGlyphHeight return 0 and do not crash.
+ await tester.pumpWidget(
+ MaterialApp(
+ home: SelectableRegion(
+ selectionControls: materialTextSelectionControls,
+ child: SizedBox(
+ height: 400,
+ child: ListView(
+ children: List<Widget>.generate(
+ 15,
+ (int i) => Text(
+ 'Line $i: Selectable text content for testing',
+ style: const TextStyle(fontSize: 20),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ final RenderParagraph firstParagraph = tester.renderObject<RenderParagraph>(
+ find.descendant(
+ of: find.text('Line 0: Selectable text content for testing'),
+ matching: find.byType(RichText),
+ ),
+ );
+ final RenderParagraph thirdParagraph = tester.renderObject<RenderParagraph>(
+ find.descendant(
+ of: find.text('Line 2: Selectable text content for testing'),
+ matching: find.byType(RichText),
+ ),
+ );
+
+ // Select from first line to third line (cross-line selection).
+ final TestGesture selectGesture = await tester.startGesture(
+ textOffsetToPosition(firstParagraph, 2),
+ kind: PointerDeviceKind.mouse,
+ );
+ addTearDown(selectGesture.removePointer);
+ await tester.pump();
+ await selectGesture.moveTo(textOffsetToPosition(thirdParagraph, 15));
+ await tester.pump();
+ await selectGesture.up();
+ await tester.pumpAndSettle();
+
+ // Verify selection exists.
+ expect(firstParagraph.selections.isNotEmpty, isTrue);
+ expect(thirdParagraph.selections.isNotEmpty, isTrue);
+
+ // Simulate scroll - this may cause selection points to become null when off-screen.
+ await tester.drag(find.byType(ListView), const Offset(0, -150));
+ await tester.pumpAndSettle();
+
+ // Should not throw - startGlyphHeight/endGlyphHeight return 0 when points are null.
+ expect(tester.takeException(), isNull);
+ },
+ skip: kIsWeb, // [intended] Web uses its native context menu.
+ );
+
+ testWidgets(
+ 'startGlyphHeight and endGlyphHeight return 0 when startSelectionPoint/endSelectionPoint are null',
+ (WidgetTester tester) async {
+ // Directly tests the fix: when SelectionGeometry has null start/end points
+ // (e.g. selection off-screen in scrollable), startGlyphHeight and
+ // endGlyphHeight must not crash and must return 0.
+ final delegateKey = GlobalKey<NullSelectionPointDelegateWidgetState>();
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: SelectableRegion(
+ selectionControls: materialTextSelectionControls,
+ child: NullSelectionPointDelegateWidget(
+ key: delegateKey,
+ child: const Column(
+ children: <Widget>[Text('First'), Text('Second'), Text('Third')],
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(
+ find.descendant(of: find.text('Second'), matching: find.byType(RichText)),
+ );
+ final TestGesture gesture = await tester.startGesture(
+ textOffsetToPosition(paragraph, 2),
+ kind: PointerDeviceKind.mouse,
+ );
+ addTearDown(gesture.removePointer);
+ await gesture.moveTo(textOffsetToPosition(paragraph, 5));
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ // Switch delegate to return null selection points (simulates off-screen scenario).
+ final NullSelectionPointDelegate delegate = delegateKey.currentState!.delegate;
+ delegate.useNullSelectionPoints = true;
+ delegate.notifyListeners();
+ await tester.pump();
+
+ final SelectableRegionState state = tester.state<SelectableRegionState>(
+ find.byType(SelectableRegion),
+ );
+ // Access the getters - should not throw, must return 0 when points are null.
+ expect(() => state.startGlyphHeight, returnsNormally);
+ expect(() => state.endGlyphHeight, returnsNormally);
+ expect(state.startGlyphHeight, 0);
+ expect(state.endGlyphHeight, 0);
+ },
+ skip: kIsWeb, // [intended] Web uses its native context menu.
+ );
+
testWidgets('mouse long press does not send select-word event', (WidgetTester tester) async {
final spy = UniqueKey();
@@ -6690,6 +6809,56 @@ void main() {
});
}
+/// A [SelectionContainerDelegate] that can return [SelectionGeometry] with null
+/// [startSelectionPoint] and [endSelectionPoint] to test off-screen selection handling.
+class NullSelectionPointDelegate extends StaticSelectionContainerDelegate {
+ bool useNullSelectionPoints = false;
+
+ @override
+ SelectionGeometry get value {
+ final SelectionGeometry geometry = super.value;
+ if (useNullSelectionPoints && geometry.hasSelection) {
+ return SelectionGeometry(
+ status: geometry.status,
+ hasContent: geometry.hasContent,
+ selectionRects: geometry.selectionRects,
+ );
+ }
+ return geometry;
+ }
+}
+
+/// Widget that holds [NullSelectionPointDelegate] for testing.
+class NullSelectionPointDelegateWidget extends StatefulWidget {
+ const NullSelectionPointDelegateWidget({super.key, required this.child});
+
+ final Widget child;
+
+ @override
+ State<NullSelectionPointDelegateWidget> createState() => NullSelectionPointDelegateWidgetState();
+}
+
+class NullSelectionPointDelegateWidgetState extends State<NullSelectionPointDelegateWidget> {
+ late final NullSelectionPointDelegate delegate;
+
+ @override
+ void initState() {
+ super.initState();
+ delegate = NullSelectionPointDelegate();
+ }
+
+ @override
+ void dispose() {
+ delegate.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SelectionContainer(delegate: delegate, child: widget.child);
+ }
+}
+
class ColumnSelectionContainerDelegate extends StaticSelectionContainerDelegate {
/// Copies the selected contents of all [Selectable]s, separating their
/// contents with a new line.

View File

@@ -33,6 +33,10 @@ $PopupMenuPatch = "lib/scripts/popup_menu.patch"
$FABPatch = "lib/scripts/fab.patch" $FABPatch = "lib/scripts/fab.patch"
# TODO: remove
# https://github.com/flutter/flutter/pull/183261
$SelectableRegionPatch = "lib/scripts/null_safety_for_selectable_region.patch"
# TODO: remove # TODO: remove
# https://github.com/flutter/flutter/issues/90223 # https://github.com/flutter/flutter/issues/90223
$ModalBarrierPatch = "lib/scripts/modal_barrier.patch" $ModalBarrierPatch = "lib/scripts/modal_barrier.patch"
@@ -60,7 +64,7 @@ $picks = @()
$reverts = @() $reverts = @()
$patches = @($ModalBarrierPatch, $TextSelectionPatch, $MouseCursorPatch, $patches = @($ModalBarrierPatch, $TextSelectionPatch, $MouseCursorPatch,
$ImageAnimPatch, $LayoutBuilderPatch, $NavigationDrawerPatch, $ImageAnimPatch, $LayoutBuilderPatch, $NavigationDrawerPatch,
$PopupMenuPatch, $FABPatch) $PopupMenuPatch, $FABPatch, $SelectableRegionPatch)
switch ($platform.ToLower()) { switch ($platform.ToLower()) {
"android" { "android" {