diff --git a/lib/common/style.dart b/lib/common/style.dart index 09b83ffa5..5ac3331c7 100644 --- a/lib/common/style.dart +++ b/lib/common/style.dart @@ -16,4 +16,5 @@ abstract final class Style { visualDensity: VisualDensity(horizontal: -2, vertical: -1.25), tapTargetSize: .shrinkWrap, ); + static const placeHolder = '\uFFFC'; } diff --git a/lib/common/widgets/context_menu/dyn_menu_helper.dart b/lib/common/widgets/context_menu/dyn_menu_helper.dart index 9f53fd7a5..be0ada7ad 100644 --- a/lib/common/widgets/context_menu/dyn_menu_helper.dart +++ b/lib/common/widgets/context_menu/dyn_menu_helper.dart @@ -63,7 +63,8 @@ void _showEmoteDialog(ModuleDynamicModel? moduleDynamic) { return TextSpan( children: [ if (i != 0) const TextSpan(text: '\n\n'), - WidgetSpan( + EmoteSpan( + rawText: Style.placeHolder, child: NetworkImgLayer( src: emoji.url, type: .emote, diff --git a/lib/common/widgets/context_menu/reply_menu_helper.dart b/lib/common/widgets/context_menu/reply_menu_helper.dart index 37d6a9be8..62a91e468 100644 --- a/lib/common/widgets/context_menu/reply_menu_helper.dart +++ b/lib/common/widgets/context_menu/reply_menu_helper.dart @@ -23,7 +23,8 @@ void showReplyCopyDialog( return TextSpan( children: [ if (i != 0) const TextSpan(text: '\n\n'), - WidgetSpan( + EmoteSpan( + rawText: Style.placeHolder, child: NetworkImgLayer( src: emote.url, type: .emote, diff --git a/lib/common/widgets/emote_span.dart b/lib/common/widgets/emote_span.dart new file mode 100644 index 000000000..80da4bb3f --- /dev/null +++ b/lib/common/widgets/emote_span.dart @@ -0,0 +1,15 @@ +import 'package:flutter/widgets.dart' show WidgetSpan; + +class EmoteSpan extends WidgetSpan { + const EmoteSpan({ + required super.child, + super.alignment, + super.baseline, + super.style, + this.rawText, + }); + + @override + // ignore: override_on_non_overriding_member + final String? rawText; +} diff --git a/lib/pages/common/publish/common_rich_text_pub_page.dart b/lib/pages/common/publish/common_rich_text_pub_page.dart index 33c9f51db..0415b2cd9 100644 --- a/lib/pages/common/publish/common_rich_text_pub_page.dart +++ b/lib/pages/common/publish/common_rich_text_pub_page.dart @@ -1,5 +1,6 @@ -import 'dart:io'; +import 'dart:io' show File, HttpException; +import 'package:PiliPlus/common/style.dart'; import 'package:PiliPlus/common/widgets/button/icon_button.dart'; import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; import 'package:PiliPlus/common/widgets/flutter/text_field/controller.dart'; @@ -252,8 +253,8 @@ abstract class CommonRichTextPubPageState if (emote is e.Emote) { final isTextEmote = width == null; onInsertText( - isTextEmote ? emote.text! : '\uFFFC', - RichTextType.emoji, + isTextEmote ? emote.text! : Style.placeHolder, + .emoji, rawText: emote.text!, emote: isTextEmote ? null @@ -265,8 +266,8 @@ abstract class CommonRichTextPubPageState ); } else if (emote is Emoticon) { onInsertText( - '\uFFFC', - RichTextType.emoji, + Style.placeHolder, + .emoji, rawText: emote.emoji!, emote: Emote( url: emote.url!, @@ -282,13 +283,13 @@ abstract class CommonRichTextPubPageState final list = >[]; for (final e in editController.items) { switch (e.type) { - case RichTextType.text || RichTextType.composing || RichTextType.common: + case .text || .composing || .common: list.add({ "raw_text": e.text, "type": 1, "biz_id": "", }); - case RichTextType.at: + case .at: list ..add({ "raw_text": '@${e.rawText}', @@ -300,13 +301,13 @@ abstract class CommonRichTextPubPageState "type": 1, "biz_id": "", }); - case RichTextType.emoji: + case .emoji: list.add({ "raw_text": e.rawText, "type": 9, "biz_id": "", }); - case RichTextType.vote: + case .vote: list ..add({ "raw_text": e.rawText, @@ -346,7 +347,7 @@ abstract class CommonRichTextPubPageState void _onInsertUser(MentionItem e, bool fromClick) { onInsertText( '@${e.name} ', - RichTextType.at, + .at, rawText: e.name, id: e.uid, fromClick: fromClick, @@ -374,7 +375,7 @@ abstract class CommonRichTextPubPageState TextEditingDelta delta; if (selection.isCollapsed) { - if (type == RichTextType.at && fromClick == false) { + if (type == .at && fromClick == false) { delta = RichTextEditingDeltaReplacement( oldText: oldValue.text, replacementText: text, diff --git a/lib/pages/dynamics/widgets/content_panel.dart b/lib/pages/dynamics/widgets/content_panel.dart index dc6dd1ea5..0b29aa1d5 100644 --- a/lib/pages/dynamics/widgets/content_panel.dart +++ b/lib/pages/dynamics/widgets/content_panel.dart @@ -1,5 +1,7 @@ // 内容 +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/widgets/rich_node_panel.dart b/lib/pages/dynamics/widgets/rich_node_panel.dart index 6322f2c81..26ca52e22 100644 --- a/lib/pages/dynamics/widgets/rich_node_panel.dart +++ b/lib/pages/dynamics/widgets/rich_node_panel.dart @@ -1,5 +1,6 @@ import 'dart:io' show Platform; +import 'package:PiliPlus/common/widgets/emote_span.dart'; 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'; @@ -160,7 +161,8 @@ TextSpan? richNode( case 'RICH_TEXT_NODE_TYPE_EMOJI' when (i.emoji != null): final size = i.emoji!.size * 20.0; spanChildren.add( - WidgetSpan( + EmoteSpan( + rawText: i.origText, child: NetworkImgLayer( src: i.emoji!.url, type: ImageType.emote, diff --git a/lib/pages/dynamics_detail/view.dart b/lib/pages/dynamics_detail/view.dart index 11f8cefa8..896b9e024 100644 --- a/lib/pages/dynamics_detail/view.dart +++ b/lib/pages/dynamics_detail/view.dart @@ -154,15 +154,14 @@ class _DynamicDetailPageState try { for (final e in richTextNodes) { if (e.type == 'RICH_TEXT_NODE_TYPE_EMOJI') { - const placeHolder = '\uFFFC'; items.add( RichTextItem( - text: placeHolder, + text: Style.placeHolder, rawText: e.origText, type: .emoji, range: TextRange( start: buffer.length, - end: buffer.length + placeHolder.length, + end: buffer.length + Style.placeHolder.length, ), emote: Emote( url: e.emoji!.url!, @@ -170,7 +169,7 @@ class _DynamicDetailPageState ), ), ); - buffer.write(placeHolder); + buffer.write(Style.placeHolder); continue; } final range = TextRange( diff --git a/lib/pages/video/reply/widgets/reply_item_grpc.dart b/lib/pages/video/reply/widgets/reply_item_grpc.dart index 94ee8c98a..bc9dd3f74 100644 --- a/lib/pages/video/reply/widgets/reply_item_grpc.dart +++ b/lib/pages/video/reply/widgets/reply_item_grpc.dart @@ -7,6 +7,7 @@ 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'; diff --git a/lib/pages/whisper_detail/widget/chat_item.dart b/lib/pages/whisper_detail/widget/chat_item.dart index e502d2f58..8a58848c1 100644 --- a/lib/pages/whisper_detail/widget/chat_item.dart +++ b/lib/pages/whisper_detail/widget/chat_item.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/style.dart'; import 'package:PiliPlus/common/widgets/badge.dart'; +import 'package:PiliPlus/common/widgets/emote_span.dart'; 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_viewer/hero.dart'; @@ -689,7 +690,8 @@ class ChatItem extends StatelessWidget { if (emoji != null) { final size = emoji['size']; children.add( - WidgetSpan( + EmoteSpan( + rawText: matchStr, child: NetworkImgLayer( width: size, height: size, diff --git a/lib/scripts/patch.ps1 b/lib/scripts/patch.ps1 index 0dd44b613..83b98a326 100644 --- a/lib/scripts/patch.ps1 +++ b/lib/scripts/patch.ps1 @@ -45,6 +45,8 @@ $TextFieldPatch = "lib/scripts/text_field.patch" $ScrollPositionPatch = "lib/scripts/scroll_position.patch" +$SelectionPlaceholderPatch = "lib/scripts/selection_placeholder.patch" + # TODO: remove # https://github.com/flutter/flutter/pull/183261 $SelectableRegionPatch = "lib/scripts/null_safety_for_selectable_region.patch" @@ -77,7 +79,8 @@ $reverts = @() $patches = @($ModalBarrierPatch, $TextSelectionPatch, $MouseCursorPatch, $ImageAnimPatch, $LayoutBuilderPatch, $NavigationDrawerPatch, $PopupMenuPatch, $FABPatch, $SelectableRegionPatch, $SelectableRegionSelectionPatch, - $EditableTextPatch, $TextFieldPatch, $ScrollPositionPatch) + $EditableTextPatch, $TextFieldPatch, $ScrollPositionPatch, + $SelectionPlaceholderPatch) switch ($platform.ToLower()) { "android" { diff --git a/lib/scripts/selection_placeholder.patch b/lib/scripts/selection_placeholder.patch new file mode 100644 index 000000000..ef0171b90 --- /dev/null +++ b/lib/scripts/selection_placeholder.patch @@ -0,0 +1,202 @@ +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 0be1766148e..56ff3b367ac 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)); +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) ||