diff --git a/lib/common/widgets/context_menu/dyn_menu_helper.dart b/lib/common/widgets/context_menu/dyn_menu_helper.dart index be08b37a6..8ceaff32c 100644 --- a/lib/common/widgets/context_menu/dyn_menu_helper.dart +++ b/lib/common/widgets/context_menu/dyn_menu_helper.dart @@ -54,33 +54,34 @@ void _showEmoteDialog(ModuleDynamicModel? moduleDynamic) { builder: (context) => Dialog( child: Padding( padding: const .symmetric(horizontal: 20, vertical: 16), - child: SingleChildScrollView( - child: SelectionText.rich( - TextSpan( - children: emotes!.entries.mapIndexed( - (i, e) { - final emoji = e.value; - final size = emoji.size * 25.0; - return TextSpan( - children: [ - if (i != 0) const TextSpan(text: '\n\n'), - EmoteSpan( - rawText: Style.placeHolder, - child: NetworkImgLayer( - src: emoji.url, - type: .emote, - width: size, - height: size, + child: SelectionArea( + contextMenuBuilder: openUrlMenuBuilder, + child: SingleChildScrollView( + child: Text.rich( + TextSpan( + children: emotes!.entries.mapIndexed( + (i, e) { + final emoji = e.value; + final size = emoji.size * 25.0; + return TextSpan( + children: [ + if (i != 0) const TextSpan(text: '\n\n'), + WidgetSpan( + child: NetworkImgLayer( + src: emoji.url, + type: .emote, + width: size, + height: size, + ), ), - ), - TextSpan(text: '\n${e.key}\n${emoji.url}'), - ], - ); - }, - ).toList(), + TextSpan(text: '\n${e.key}\n${emoji.url}'), + ], + ); + }, + ).toList(), + ), + style: const TextStyle(fontSize: 15, height: 1.7), ), - contextMenuBuilder: openUrlMenuBuilder, - style: const TextStyle(fontSize: 15, height: 1.7), ), ), ), @@ -94,11 +95,13 @@ void _showTextDialog(String text) { builder: (context) => Dialog( child: Padding( padding: const .symmetric(horizontal: 20, vertical: 16), - child: SingleChildScrollView( - child: SelectionText( - text, - contextMenuBuilder: openUrlMenuBuilder, - style: const TextStyle(fontSize: 15, height: 1.7), + child: SelectionArea( + contextMenuBuilder: openUrlMenuBuilder, + child: SingleChildScrollView( + child: Text( + text, + style: const TextStyle(fontSize: 15, height: 1.7), + ), ), ), ), diff --git a/lib/common/widgets/context_menu/reply_menu_helper.dart b/lib/common/widgets/context_menu/reply_menu_helper.dart index 3f0091a51..a942dc0af 100644 --- a/lib/common/widgets/context_menu/reply_menu_helper.dart +++ b/lib/common/widgets/context_menu/reply_menu_helper.dart @@ -12,98 +12,99 @@ void showReplyCopyDialog( constraints: const BoxConstraints.tightFor(width: 380), child: Padding( padding: const .symmetric(horizontal: 20, vertical: 16), - child: SingleChildScrollView( - child: SelectionText.rich( - showEmote - ? TextSpan( - children: emotes.entries.mapIndexed( - (i, e) { - final emote = e.value; - final size = emote.size.toInt() * 25.0; - return TextSpan( + child: SelectionArea( + contextMenuBuilder: (_, state) { + final buttonItems = state.contextMenuButtonItems; + if (emotes.isNotEmpty) { + buttonItems.insertOrAdd( + 3, + ContextMenuButtonItem( + label: showEmote ? '文本' : '表情', + onPressed: () { + state.hideAndClear(); + showEmote = !showEmote; + (context as Element).markNeedsBuild(); + }, + ), + ); + state.addLaunchMenuIfNeeded(buttonItems, index: 4); + } + if (state.isUncollapsed) { + buttonItems.add( + ContextMenuButtonItem( + onPressed: () { + String text = RegExp.escape(state.selectedText!); + if (ReplyGrpc.enableFilter) text = '|$text'; + + showConfirmDialog( + context: context, + title: const Text('是否确认评论过滤的变更:'), + content: Text.rich( + TextSpan( + text: ReplyGrpc.replyRegExp.pattern, children: [ - if (i != 0) const TextSpan(text: '\n\n'), - EmoteSpan( - rawText: Style.placeHolder, - child: NetworkImgLayer( - src: emote.url, - type: .emote, - width: size, - height: size, + TextSpan( + text: text, + style: const TextStyle( + color: Colors.green, + fontWeight: .bold, ), ), - TextSpan(text: '\n${e.key}\n${emote.url}'), ], + ), + ), + onConfirm: () { + final filter = ReplyGrpc.replyRegExp.pattern + text; + ReplyGrpc.replyRegExp = RegExp( + filter, + caseSensitive: true, ); + ReplyGrpc.enableFilter = true; + GStorage.setting.put( + SettingBoxKey.banWordForReply, + filter, + ); + SmartDialog.showToast('已保存'); }, - ).toList(), - ) - : TextSpan(text: message), - contextMenuBuilder: (_, state) { - final buttonItems = state.contextMenuButtonItems; - if (emotes.isNotEmpty) { - buttonItems.insertOrAdd( - 3, - ContextMenuButtonItem( - label: showEmote ? '文本' : '表情', - onPressed: () { - state.hideAndClear(); - showEmote = !showEmote; - (context as Element).markNeedsBuild(); - }, - ), - ); - state.addLaunchMenuIfNeeded(buttonItems, index: 4); - } - if (state.isUncollapsed) { - buttonItems.add( - ContextMenuButtonItem( - onPressed: () { - String text = RegExp.escape(state.selectedText!); - if (ReplyGrpc.enableFilter) text = '|$text'; - - showConfirmDialog( - context: context, - title: const Text('是否确认评论过滤的变更:'), - content: Text.rich( - TextSpan( - text: ReplyGrpc.replyRegExp.pattern, + ); + }, + label: '加入过滤', + ), + ); + } + return AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: buttonItems, + anchors: state.contextMenuAnchors, + ); + }, + child: SingleChildScrollView( + child: Text.rich( + showEmote + ? TextSpan( + children: emotes.entries.mapIndexed( + (i, e) { + final emote = e.value; + final size = emote.size.toInt() * 25.0; + return TextSpan( children: [ - TextSpan( - text: text, - style: const TextStyle( - color: Colors.green, - fontWeight: .bold, + if (i != 0) const TextSpan(text: '\n\n'), + WidgetSpan( + child: NetworkImgLayer( + src: emote.url, + type: .emote, + width: size, + height: size, ), ), + TextSpan(text: '\n${e.key}\n${emote.url}'), ], - ), - ), - onConfirm: () { - final filter = ReplyGrpc.replyRegExp.pattern + text; - ReplyGrpc.replyRegExp = RegExp( - filter, - caseSensitive: true, ); - ReplyGrpc.enableFilter = true; - GStorage.setting.put( - SettingBoxKey.banWordForReply, - filter, - ); - SmartDialog.showToast('已保存'); }, - ); - }, - label: '加入过滤', - ), - ); - } - return AdaptiveTextSelectionToolbar.buttonItems( - buttonItems: buttonItems, - anchors: state.contextMenuAnchors, - ); - }, - style: const TextStyle(fontSize: 15, height: 1.7), + ).toList(), + ) + : TextSpan(text: message), + style: const TextStyle(fontSize: 15, height: 1.7), + ), ), ), ), diff --git a/lib/common/widgets/tap_region_surface.dart b/lib/common/widgets/tap_region_surface.dart new file mode 100644 index 000000000..71ee72472 --- /dev/null +++ b/lib/common/widgets/tap_region_surface.dart @@ -0,0 +1,46 @@ +import 'package:flutter/rendering.dart' show BoxHitTestResult, HitTestEntry; +import 'package:flutter/widgets.dart'; + +class SelectionTapRegionSurface extends TapRegionSurface { + const SelectionTapRegionSurface({ + super.key, + required super.child, + required this.isScrolling, + }); + + final ValueGetter isScrolling; + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderSelectionTapRegionSurface(isScrolling); + } + + @override + void updateRenderObject( + BuildContext context, + RenderSelectionTapRegionSurface renderObject, + ) { + renderObject.isScrolling = isScrolling; + } +} + +class RenderSelectionTapRegionSurface extends RenderTapRegionSurface { + RenderSelectionTapRegionSurface(this.isScrolling); + + ValueGetter isScrolling; + bool _isScrolling = false; + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + _isScrolling = isScrolling(); + return super.hitTest(result, position: position); + } + + @override + void handleEvent(PointerEvent event, HitTestEntry entry) { + if (_isScrolling) { + return; + } + super.handleEvent(event, entry); + } +} diff --git a/lib/pages/dynamics/widgets/content_panel.dart b/lib/pages/dynamics/widgets/content_panel.dart index 0b29aa1d5..dc6dd1ea5 100644 --- a/lib/pages/dynamics/widgets/content_panel.dart +++ b/lib/pages/dynamics/widgets/content_panel.dart @@ -1,7 +1,5 @@ // 内容 -import 'package:PiliPlus/common/style.dart'; import 'package:PiliPlus/common/widgets/custom_icon.dart'; -import 'package:PiliPlus/common/widgets/emote_span.dart'; import 'package:PiliPlus/common/widgets/flutter/text/text.dart' as custom_text; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/image_grid/image_grid_view.dart'; diff --git a/lib/pages/dynamics_detail/view.dart b/lib/pages/dynamics_detail/view.dart index e44e24a92..e00420cfe 100644 --- a/lib/pages/dynamics_detail/view.dart +++ b/lib/pages/dynamics_detail/view.dart @@ -10,6 +10,7 @@ import 'package:PiliPlus/common/widgets/scroll_behavior.dart' import 'package:PiliPlus/common/widgets/scroll_physics.dart'; import 'package:PiliPlus/common/widgets/sliver/sliver_floating_header.dart'; import 'package:PiliPlus/common/widgets/sliver/sliver_to_box_adapter.dart'; +import 'package:PiliPlus/common/widgets/tap_region_surface.dart'; import 'package:PiliPlus/http/constants.dart'; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/http/loading_state.dart'; @@ -105,15 +106,18 @@ class _DynamicDetailPageState ); } + dynamic _scrollable; + @override void dispose() { + _scrollable = null; refreshController?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return Scaffold( + final child = Scaffold( resizeToAvoidBottomInset: false, appBar: _buildAppBar(), body: Padding( @@ -131,6 +135,10 @@ class _DynamicDetailPageState child: _buildBottom(), ), ); + return SelectionTapRegionSurface( + isScrolling: () => _scrollable?.shouldIgnorePointer ?? false, + child: child, + ); } void _onEdit() { @@ -410,26 +418,33 @@ class _DynamicDetailPageState return child; } + Widget _buildDynPanel() { + return SliverToBoxWithOffsetAdapter( + offset: 55, + onVisibilityChanged: controller.showTitle.call, + child: Builder( + builder: (context) { + _scrollable = Scrollable.maybeOf(context); + return DynamicPanel( + item: controller.dynItem, + isDetail: true, + isDetailPortraitW: isPortrait, + onSetPubSetting: controller.onSetPubSetting, + onEdit: _onEdit, + onSetReplySubject: controller.onSetReplySubject, + ); + }, + ), + ); + } + Widget _buildPortrait(double padding) { return Padding( padding: EdgeInsets.symmetric(horizontal: padding), child: NestedScrollView( scrollBehavior: const NoOverscrollIndicator(), headerSliverBuilder: (context, innerBoxIsScrolled) { - return [ - SliverToBoxWithOffsetAdapter( - offset: 55, - onVisibilityChanged: controller.showTitle.call, - child: DynamicPanel( - item: controller.dynItem, - isDetail: true, - isDetailPortraitW: isPortrait, - onSetPubSetting: controller.onSetPubSetting, - onEdit: _onEdit, - onSetReplySubject: controller.onSetReplySubject, - ), - ), - ]; + return [_buildDynPanel()]; }, body: Column( children: [ @@ -456,18 +471,7 @@ class _DynamicDetailPageState left: padding, bottom: this.padding.bottom + 100, ), - sliver: SliverToBoxWithOffsetAdapter( - offset: 55, - onVisibilityChanged: controller.showTitle.call, - child: DynamicPanel( - item: controller.dynItem, - isDetail: true, - isDetailPortraitW: isPortrait, - onSetPubSetting: controller.onSetPubSetting, - onEdit: _onEdit, - onSetReplySubject: controller.onSetReplySubject, - ), - ), + sliver: _buildDynPanel(), ), ], ), diff --git a/lib/pages/setting/models/privacy_settings.dart b/lib/pages/setting/models/privacy_settings.dart index 0c505d3e2..3ebb5b42e 100644 --- a/lib/pages/setting/models/privacy_settings.dart +++ b/lib/pages/setting/models/privacy_settings.dart @@ -25,7 +25,11 @@ List get privacySettings => [ context: context, builder: (context) => AlertDialog( title: const Text('账号模式详情'), - content: SingleChildScrollView(child: _getAccountDetail(context)), + content: SelectionArea( + child: SingleChildScrollView( + child: _getAccountDetail(context), + ), + ), actions: [ TextButton( onPressed: Get.back, @@ -52,12 +56,10 @@ Widget _getAccountDetail(BuildContext context) { ..add(Center(child: Text(i.title, style: theme.titleMedium))) ..add(Text(url.join('\n'))); } - return SelectionArea( - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - spacing: 8, - children: children, - ), + return Column( + spacing: 8, + mainAxisSize: .min, + crossAxisAlignment: .start, + children: children, ); } diff --git a/lib/pages/video/reply/widgets/reply_item_grpc.dart b/lib/pages/video/reply/widgets/reply_item_grpc.dart index 105c9ad05..5f3b40ef1 100644 --- a/lib/pages/video/reply/widgets/reply_item_grpc.dart +++ b/lib/pages/video/reply/widgets/reply_item_grpc.dart @@ -7,13 +7,11 @@ import 'package:PiliPlus/common/widgets/badge.dart'; import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; import 'package:PiliPlus/common/widgets/dialog/report.dart'; -import 'package:PiliPlus/common/widgets/emote_span.dart'; import 'package:PiliPlus/common/widgets/flutter/text/text.dart' as custom_text; import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/image_grid/image_grid_view.dart'; import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; -import 'package:PiliPlus/common/widgets/selection_text.dart'; import 'package:PiliPlus/common/widgets/translucent_row.dart'; import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' show ReplyInfo, ReplyControl, Content, Url, ReplyControl_VoteOption, Emote; diff --git a/lib/scripts/patch.ps1 b/lib/scripts/patch.ps1 index d5dfbed71..24f087906 100644 --- a/lib/scripts/patch.ps1 +++ b/lib/scripts/patch.ps1 @@ -45,7 +45,7 @@ $TextFieldPatch = "lib/scripts/text_field.patch" $ScrollPositionPatch = "lib/scripts/scroll_position.patch" -$SelectionPlaceholderPatch = "lib/scripts/selection_placeholder.patch" +$ScrollablePatch = "lib/scripts/scrollable.patch" # TODO: remove # https://github.com/flutter/flutter/pull/183261 @@ -80,7 +80,7 @@ $patches = @($ModalBarrierPatch, $TextSelectionPatch, $MouseCursorPatch, $ImageAnimPatch, $LayoutBuilderPatch, $NavigationDrawerPatch, $PopupMenuPatch, $FABPatch, $SelectableRegionPatch, $SelectableRegionSelectionPatch, $EditableTextPatch, $TextFieldPatch, $ScrollPositionPatch, - $SelectionPlaceholderPatch) + $ScrollablePatch) switch ($platform.ToLower()) { "android" { diff --git a/lib/scripts/scrollable.patch b/lib/scripts/scrollable.patch new file mode 100644 index 000000000..efd56e788 --- /dev/null +++ b/lib/scripts/scrollable.patch @@ -0,0 +1,12 @@ +diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart +index 9df2d633f16..f39e6760df9 100644 +--- a/packages/flutter/lib/src/widgets/scrollable.dart ++++ b/packages/flutter/lib/src/widgets/scrollable.dart +@@ -766,6 +766,7 @@ class ScrollableState extends State + Map _gestureRecognizers = + const {}; + bool _shouldIgnorePointer = false; ++ bool get shouldIgnorePointer => _shouldIgnorePointer; + + bool? _lastCanDrag; + Axis? _lastAxisDirection; diff --git a/lib/scripts/selectable_region.patch b/lib/scripts/selectable_region.patch index 2c8729085..b0abb9bc1 100644 --- a/lib/scripts/selectable_region.patch +++ b/lib/scripts/selectable_region.patch @@ -1,21 +1,326 @@ +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..0be1766148e 100644 +index 97a41dafb8d..266dd6f0283 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart -@@ -1925,9 +1925,9 @@ class _SelectableFragment - final Matrix4 transform = paragraph.getTransformTo(null); - transform.invert(); - final Offset localPosition = MatrixUtils.transformPoint(transform, globalPosition); -- if (_rect.isEmpty) { -- return SelectionUtils.getResultBasedOnRect(_rect, localPosition); +@@ -502,27 +502,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; - } -+ // if (_rect.isEmpty) { -+ // return SelectionUtils.getResultBasedOnRect(_rect, localPosition); -+ // } - final Offset adjustedOffset = SelectionUtils.adjustDragOffset( +- 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 +1457,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 +1468,7 @@ class _SelectableFragment + final TextRange range; + final RenderParagraph paragraph; + final String fullText; ++ final Map placeholder; + + TextPosition? _textSelectionStart; + TextPosition? _textSelectionEnd; +@@ -1509,18 +1500,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 +1573,14 @@ class _SelectableFragment + result = _updateSelectionEdge( + edgeUpdate.globalPosition, + isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate, ++ isSingle: edgeUpdate.isSingle, + ); + case TextGranularity.word: + result = _updateSelectionEdgeByTextBoundary( + edgeUpdate.globalPosition, + isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate, + getTextBoundary: _getWordBoundaryAtPosition, ++ isSingle: edgeUpdate.isSingle, + ); + case TextGranularity.paragraph: + result = _updateSelectionEdgeByMultiSelectableTextBoundary( +@@ -1626,9 +1637,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 +1883,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 +1902,7 @@ class _SelectableFragment _rect, localPosition, -@@ -3568,7 +3568,10 @@ class _SelectableFragment + direction: paragraph.textDirection, ++ isSingle: isSingle, + ); + + final TextPosition position = paragraph.getPositionForOffset(adjustedOffset); +@@ -1920,7 +1957,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}) { + _setSelectionPosition(null, isEnd: isEnd); + final Matrix4 transform = paragraph.getTransformTo(null); + transform.invert(); +@@ -1932,11 +1969,21 @@ class _SelectableFragment + _rect, + localPosition, + direction: paragraph.textDirection, ++ isSingle: isSingle, + ); + + final 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)); ++ // } + _setSelectionPosition(position, isEnd: isEnd); + if (position.offset == range.end) { + return SelectionResult.next; +@@ -3568,7 +3615,10 @@ class _SelectableFragment final selectionPaint = Paint() ..style = PaintingStyle.fill ..color = paragraph.selectionColor!; @@ -28,10 +333,19 @@ index 97a41dafb8d..0be1766148e 100644 } } diff --git a/packages/flutter/lib/src/rendering/selection.dart b/packages/flutter/lib/src/rendering/selection.dart -index a813e141dc4..78b9bb84c0e 100644 +index a813e141dc4..e5ef2103645 100644 --- a/packages/flutter/lib/src/rendering/selection.dart +++ b/packages/flutter/lib/src/rendering/selection.dart -@@ -114,6 +114,8 @@ abstract class SelectionHandler implements ValueListenable { +@@ -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; @@ -40,7 +354,7 @@ index a813e141dc4..78b9bb84c0e 100644 } /// This class stores the range information of the selection under a [Selectable] -@@ -236,6 +238,8 @@ class SelectedContent with Diagnosticable { +@@ -236,6 +240,8 @@ class SelectedContent with Diagnosticable { /// See also: /// * [SelectableRegion], which provides an overview of selection system. mixin Selectable implements SelectionHandler { @@ -49,6 +363,53 @@ index a813e141dc4..78b9bb84c0e 100644 /// {@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,7 +535,7 @@ 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, + }) : granularity = granularity ?? TextGranularity.character, +@@ -532,7 +547,7 @@ 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, + }) : granularity = granularity ?? TextGranularity.character, +@@ -547,6 +562,8 @@ class SelectionEdgeUpdateEvent extends SelectionEvent { + /// + /// Defaults to [TextGranularity.character]. + final TextGranularity granularity; ++ ++ bool isSingle = false; + } + + /// 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..54d30883245 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart @@ -64,7 +425,7 @@ index 9df2d633f16..54d30883245 100644 static const double _kDefaultDragTargetSize = 0; diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart -index 59de8bae20b..39bfeb2b4f0 100644 +index 59de8bae20b..9ce4810aa68 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 @@ -209,7 +570,35 @@ index 59de8bae20b..39bfeb2b4f0 100644 /// The set of [Selectable]s that have received start events. final Set _hasReceivedStartEvent = {}; -@@ -2822,8 +2801,17 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai +@@ -2271,6 +2250,18 @@ class StaticSelectionContainerDelegate extends MultiSelectableSelectionContainer + super.dispose(); + } + ++ void handleSelectionEdgeUpdateEvent(SelectionEdgeUpdateEvent event) { ++ event.isSingle = isSingle; ++ } ++ ++ @override ++ SelectionResult dispatchSelectionEvent(SelectionEvent event) { ++ if (event is SelectionEdgeUpdateEvent) { ++ handleSelectionEdgeUpdateEvent(event); ++ } ++ return super.dispatchSelectionEvent(event); ++ } ++ + @override + SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) { + switch (event.type) { +@@ -2367,6 +2358,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 +2815,17 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai return null; } final buffer = StringBuffer(); @@ -242,3 +631,30 @@ index 5d5a2386a7a..92e7d1eb70a 100644 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) || diff --git a/lib/scripts/selection_placeholder.patch b/lib/scripts/selection_placeholder.patch deleted file mode 100644 index 857e12928..000000000 --- a/lib/scripts/selection_placeholder.patch +++ /dev/null @@ -1,222 +0,0 @@ -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..00672540c54 100644 ---- a/packages/flutter/lib/src/rendering/paragraph.dart -+++ b/packages/flutter/lib/src/rendering/paragraph.dart -@@ -502,13 +502,16 @@ class RenderParagraph extends RenderBox - } - - List<_SelectableFragment> _getSelectableFragments() { -- final String plainText = text.toPlainText(includeSemanticsLabels: false); -+ final (String plainText, Map placeHolder) = text.toPlainTextV2( -+ includeSemanticsLabels: false, -+ ); - final result = <_SelectableFragment>[]; - var start = 0; - while (start < plainText.length) { - int end = plainText.indexOf(_placeholderCharacter, start); -+ final hasPlaceholder = end != -1; - if (start != end) { -- if (end == -1) { -+ if (!hasPlaceholder) { - end = plainText.length; - } - result.add( -@@ -521,6 +524,18 @@ class RenderParagraph extends RenderBox - start = end; - } - start += 1; -+ if (hasPlaceholder) { -+ if (placeHolder[end] case final rawText?) { -+ result.add( -+ _SelectableFragment( -+ paragraph: this, -+ range: TextRange(start: end, end: end + 1), -+ fullText: plainText, -+ rawText: rawText, -+ ), -+ ); -+ } -+ } - } - return result; - } -@@ -1467,7 +1482,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, this.rawText}) - : assert(range.isValid && !range.isCollapsed && range.isNormalized) { - if (kFlutterMemoryAllocationsEnabled) { - ChangeNotifier.maybeDispatchObjectCreation(this); -@@ -1478,6 +1493,7 @@ class _SelectableFragment - final TextRange range; - final RenderParagraph paragraph; - final String fullText; -+ final String? rawText; - - TextPosition? _textSelectionStart; - TextPosition? _textSelectionEnd; -@@ -1626,6 +1642,9 @@ class _SelectableFragment - if (_textSelectionStart == null || _textSelectionEnd == null) { - return null; - } -+ if (rawText != null) { -+ return SelectedContent(plainText: rawText!); -+ } - final int start = math.min(_textSelectionStart!.offset, _textSelectionEnd!.offset); - final int end = math.max(_textSelectionStart!.offset, _textSelectionEnd!.offset); - return SelectedContent(plainText: fullText.substring(start, end)); -@@ -1934,9 +1953,16 @@ class _SelectableFragment - direction: paragraph.textDirection, - ); - -- final TextPosition position = _clampTextPosition( -- paragraph.getPositionForOffset(adjustedOffset), -- ); -+ final TextPosition position; -+ 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)); -+ } - _setSelectionPosition(position, isEnd: isEnd); - if (position.offset == range.end) { - return SelectionResult.next; -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) ||