feat: emote selection

Signed-off-by: dom <githubaccount56556@proton.me>
This commit is contained in:
dom
2026-07-23 16:13:37 +08:00
parent 565a2f8754
commit 0ca091ac48
12 changed files with 250 additions and 20 deletions

View File

@@ -16,4 +16,5 @@ abstract final class Style {
visualDensity: VisualDensity(horizontal: -2, vertical: -1.25), visualDensity: VisualDensity(horizontal: -2, vertical: -1.25),
tapTargetSize: .shrinkWrap, tapTargetSize: .shrinkWrap,
); );
static const placeHolder = '\uFFFC';
} }

View File

@@ -63,7 +63,8 @@ void _showEmoteDialog(ModuleDynamicModel? moduleDynamic) {
return TextSpan( return TextSpan(
children: [ children: [
if (i != 0) const TextSpan(text: '\n\n'), if (i != 0) const TextSpan(text: '\n\n'),
WidgetSpan( EmoteSpan(
rawText: Style.placeHolder,
child: NetworkImgLayer( child: NetworkImgLayer(
src: emoji.url, src: emoji.url,
type: .emote, type: .emote,

View File

@@ -23,7 +23,8 @@ void showReplyCopyDialog(
return TextSpan( return TextSpan(
children: [ children: [
if (i != 0) const TextSpan(text: '\n\n'), if (i != 0) const TextSpan(text: '\n\n'),
WidgetSpan( EmoteSpan(
rawText: Style.placeHolder,
child: NetworkImgLayer( child: NetworkImgLayer(
src: emote.url, src: emote.url,
type: .emote, type: .emote,

View File

@@ -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;
}

View File

@@ -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/icon_button.dart';
import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart';
import 'package:PiliPlus/common/widgets/flutter/text_field/controller.dart'; import 'package:PiliPlus/common/widgets/flutter/text_field/controller.dart';
@@ -252,8 +253,8 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
if (emote is e.Emote) { if (emote is e.Emote) {
final isTextEmote = width == null; final isTextEmote = width == null;
onInsertText( onInsertText(
isTextEmote ? emote.text! : '\uFFFC', isTextEmote ? emote.text! : Style.placeHolder,
RichTextType.emoji, .emoji,
rawText: emote.text!, rawText: emote.text!,
emote: isTextEmote emote: isTextEmote
? null ? null
@@ -265,8 +266,8 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
); );
} else if (emote is Emoticon) { } else if (emote is Emoticon) {
onInsertText( onInsertText(
'\uFFFC', Style.placeHolder,
RichTextType.emoji, .emoji,
rawText: emote.emoji!, rawText: emote.emoji!,
emote: Emote( emote: Emote(
url: emote.url!, url: emote.url!,
@@ -282,13 +283,13 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
final list = <Map<String, dynamic>>[]; final list = <Map<String, dynamic>>[];
for (final e in editController.items) { for (final e in editController.items) {
switch (e.type) { switch (e.type) {
case RichTextType.text || RichTextType.composing || RichTextType.common: case .text || .composing || .common:
list.add({ list.add({
"raw_text": e.text, "raw_text": e.text,
"type": 1, "type": 1,
"biz_id": "", "biz_id": "",
}); });
case RichTextType.at: case .at:
list list
..add({ ..add({
"raw_text": '@${e.rawText}', "raw_text": '@${e.rawText}',
@@ -300,13 +301,13 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
"type": 1, "type": 1,
"biz_id": "", "biz_id": "",
}); });
case RichTextType.emoji: case .emoji:
list.add({ list.add({
"raw_text": e.rawText, "raw_text": e.rawText,
"type": 9, "type": 9,
"biz_id": "", "biz_id": "",
}); });
case RichTextType.vote: case .vote:
list list
..add({ ..add({
"raw_text": e.rawText, "raw_text": e.rawText,
@@ -346,7 +347,7 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
void _onInsertUser(MentionItem e, bool fromClick) { void _onInsertUser(MentionItem e, bool fromClick) {
onInsertText( onInsertText(
'@${e.name} ', '@${e.name} ',
RichTextType.at, .at,
rawText: e.name, rawText: e.name,
id: e.uid, id: e.uid,
fromClick: fromClick, fromClick: fromClick,
@@ -374,7 +375,7 @@ abstract class CommonRichTextPubPageState<T extends CommonRichTextPubPage>
TextEditingDelta delta; TextEditingDelta delta;
if (selection.isCollapsed) { if (selection.isCollapsed) {
if (type == RichTextType.at && fromClick == false) { if (type == .at && fromClick == false) {
delta = RichTextEditingDeltaReplacement( delta = RichTextEditingDeltaReplacement(
oldText: oldValue.text, oldText: oldValue.text,
replacementText: text, replacementText: text,

View File

@@ -1,5 +1,7 @@
// 内容 // 内容
import 'package:PiliPlus/common/style.dart';
import 'package:PiliPlus/common/widgets/custom_icon.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/flutter/text/text.dart' as custom_text;
import 'package:PiliPlus/common/widgets/image/network_img_layer.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/image_grid/image_grid_view.dart';

View File

@@ -1,5 +1,6 @@
import 'dart:io' show Platform; 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/gesture/tap_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.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/image_grid/image_grid_view.dart';
@@ -160,7 +161,8 @@ TextSpan? richNode(
case 'RICH_TEXT_NODE_TYPE_EMOJI' when (i.emoji != null): case 'RICH_TEXT_NODE_TYPE_EMOJI' when (i.emoji != null):
final size = i.emoji!.size * 20.0; final size = i.emoji!.size * 20.0;
spanChildren.add( spanChildren.add(
WidgetSpan( EmoteSpan(
rawText: i.origText,
child: NetworkImgLayer( child: NetworkImgLayer(
src: i.emoji!.url, src: i.emoji!.url,
type: ImageType.emote, type: ImageType.emote,

View File

@@ -154,15 +154,14 @@ class _DynamicDetailPageState
try { try {
for (final e in richTextNodes) { for (final e in richTextNodes) {
if (e.type == 'RICH_TEXT_NODE_TYPE_EMOJI') { if (e.type == 'RICH_TEXT_NODE_TYPE_EMOJI') {
const placeHolder = '\uFFFC';
items.add( items.add(
RichTextItem( RichTextItem(
text: placeHolder, text: Style.placeHolder,
rawText: e.origText, rawText: e.origText,
type: .emoji, type: .emoji,
range: TextRange( range: TextRange(
start: buffer.length, start: buffer.length,
end: buffer.length + placeHolder.length, end: buffer.length + Style.placeHolder.length,
), ),
emote: Emote( emote: Emote(
url: e.emoji!.url!, url: e.emoji!.url!,
@@ -170,7 +169,7 @@ class _DynamicDetailPageState
), ),
), ),
); );
buffer.write(placeHolder); buffer.write(Style.placeHolder);
continue; continue;
} }
final range = TextRange( final range = TextRange(

View File

@@ -7,6 +7,7 @@ import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/custom_icon.dart';
import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; import 'package:PiliPlus/common/widgets/dialog/dialog.dart';
import 'package:PiliPlus/common/widgets/dialog/report.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/flutter/text/text.dart' as custom_text;
import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.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/network_img_layer.dart';

View File

@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/style.dart'; import 'package:PiliPlus/common/style.dart';
import 'package:PiliPlus/common/widgets/badge.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/gesture/tap_gesture_recognizer.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/image_viewer/hero.dart'; import 'package:PiliPlus/common/widgets/image_viewer/hero.dart';
@@ -689,7 +690,8 @@ class ChatItem extends StatelessWidget {
if (emoji != null) { if (emoji != null) {
final size = emoji['size']; final size = emoji['size'];
children.add( children.add(
WidgetSpan( EmoteSpan(
rawText: matchStr,
child: NetworkImgLayer( child: NetworkImgLayer(
width: size, width: size,
height: size, height: size,

View File

@@ -45,6 +45,8 @@ $TextFieldPatch = "lib/scripts/text_field.patch"
$ScrollPositionPatch = "lib/scripts/scroll_position.patch" $ScrollPositionPatch = "lib/scripts/scroll_position.patch"
$SelectionPlaceholderPatch = "lib/scripts/selection_placeholder.patch"
# TODO: remove # TODO: remove
# https://github.com/flutter/flutter/pull/183261 # https://github.com/flutter/flutter/pull/183261
$SelectableRegionPatch = "lib/scripts/null_safety_for_selectable_region.patch" $SelectableRegionPatch = "lib/scripts/null_safety_for_selectable_region.patch"
@@ -77,7 +79,8 @@ $reverts = @()
$patches = @($ModalBarrierPatch, $TextSelectionPatch, $MouseCursorPatch, $patches = @($ModalBarrierPatch, $TextSelectionPatch, $MouseCursorPatch,
$ImageAnimPatch, $LayoutBuilderPatch, $NavigationDrawerPatch, $ImageAnimPatch, $LayoutBuilderPatch, $NavigationDrawerPatch,
$PopupMenuPatch, $FABPatch, $SelectableRegionPatch, $SelectableRegionSelectionPatch, $PopupMenuPatch, $FABPatch, $SelectableRegionPatch, $SelectableRegionSelectionPatch,
$EditableTextPatch, $TextFieldPatch, $ScrollPositionPatch) $EditableTextPatch, $TextFieldPatch, $ScrollPositionPatch,
$SelectionPlaceholderPatch)
switch ($platform.ToLower()) { switch ($platform.ToLower()) {
"android" { "android" {

View File

@@ -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<int, String>) toPlainTextV2({
+ bool includeSemanticsLabels = true,
+ bool includePlaceholders = true,
+ }) {
+ final buffer = StringBuffer();
+ final map = <int, String>{};
+ 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<int, String> 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<int, String> map, {
+ bool includeSemanticsLabels = true,
+ bool includePlaceholders = true,
+ }) {
+ if (includePlaceholders) {
+ if (rawText != null) {
+ map[buffer.length] = rawText!;
+ }
+ buffer.writeCharCode(placeholderCodeUnit);
+ }
+ }
+
@override
void computeSemanticsInformation(List<InlineSpanSemanticsInformation> 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<int, String> 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<InlineSpanSemanticsInformation> 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<int, String> 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) ||