Skip to content

[Bug report] 特殊字符影响手动拖拽把手时候被选中的字符的判断 #266

@5ybdvnrnmc-crypto

Description

@5ybdvnrnmc-crypto

Version

16.0.2

Platforms

Android

Device Model

荣耀 MAG AN00

flutter info

[✓] Flutter (Channel stable, 3.32.7, on macOS 15.6.1 24G90 darwin-arm64, locale zh-Hans-CN)
[!] Android toolchain - develop for Android devices (Android SDK version 36.0.0)
    ! Some Android licenses not accepted. To resolve this, run: flutter doctor --android-licenses
[✓] Xcode - develop for iOS and macOS (Xcode 16.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2025.2)
[✓] Android Studio (version 2024.1)
[✓] VS Code (version 1.105.1)
[✓] VS Code (version 1.106.0)

How to reproduce?

你好 我这边遇到如下问题,请教一下如何解决。感谢。
特殊字符影响手动拖拽把手时候被选中的字符的判断。 例如 把 #***^算作一个特殊字符^不显示。
实际字符:你#123456^好#abcdef^啊
显示字符: 你#123456好#abcdef啊
手动拖动把手 选中 : 456好#abc
然后按下删除键,实际被删掉的是 : 56好#abcd,

实际字符:你#123456^好#abcdef^啊
显示字符: 你#123456好#abcdef啊
UI上 选中的是

Image 实际 输出选中的字符为:>>>当前文本选中区域:起始位置=7, 结束位置=16, 选中字符='56^好#abcd'

builder构建代码如下:

Logs

Example code (optional)

class KyyPublishTextSpanBuilder extends SpecialTextSpanBuilder {
  final TextStyle? atStyle;
  final TextStyle? topicStyle;
  final BuildContext? context;

  KyyPublishTextSpanBuilder({
    this.context,
    this.atStyle,
    this.topicStyle,
  });

  @override
  TextSpan build(String data,
      {TextStyle? textStyle, SpecialTextGestureTapCallback? onTap}) {
    var textSpan = super.build(data, textStyle: textStyle, onTap: onTap);
    List<TextSpan> result = [];
    // 这里额外维护一个全局起始下标,用于构造 SpecialTextSpan 的 textRange,
    // 保证后续删除、光标移动等能力能够基于原始文本正确计算位置
    int globalStart = 0;

    textSpan.children?.forEach((span) {
      if (span is SpecialTextSpan) {
        // 1、如果已经识别成了特殊文本,那么直接放入result
        result.add(span);
      } else {
        // 2、没有被识别成特殊文本的在这里来提取 特殊文本
        // 话题字符:中文/英文/数字
        // 话题字符识别有 两种模式,  可以编辑的:#连续话题字符(长度<=15)^ 或者  不可编辑的:#连续话题字符(长度<=15)~
        // 使用正则表达式 提取出 span 里面的 话题字符 构建SpecialTextSpan 放入result中,其余字符构建普通TextSpan放入result中,确保其顺序不变
        if (span is TextSpan && (span.text?.isNotEmpty ?? false)) {
          final String rawText = span.text!;

          // 设计说明:
          // - 为了兼容两种话题编码形式(可编辑 ^ / 不可编辑 ~),统一用一个正则抽取;
          // - 仅允许中英文和数字,长度限制在 1~15 之间,避免出现过长或包含特殊符号的话题。
          final RegExp topicReg =
          RegExp(r'#([A-Za-z0-9\u4e00-\u9fa5]{0,15})([\^~])');

          final Iterable<RegExpMatch> matches = topicReg.allMatches(rawText);
          // 如果当前 span 内不存在符合规范的话题串,则直接原样放回,避免多余拆分
          if (matches.isEmpty) {
            result.add(span);
          } else {
            int lastIndex = 0;

            for (final RegExpMatch match in matches) {
              // 先放入话题前面的普通文本
              if (match.start > lastIndex) {
                result.add(TextSpan(
                  text: rawText.substring(lastIndex, match.start),
                  // 优先使用原 span 的样式,其次退回到 builder 传入的 textStyle
                  style: span.style ?? textStyle,
                ));
              }

              final String topicName = match.group(1) ?? '';
              // endFlag 目前仅区分是否可编辑,展示内容保持一致,后续如有差异可在这里分支
              final String endFlag = match.group(2) ?? '';

              // 展示给用户看的文本:不包含结束标记,仅保留 #话题,本地展示不再额外补空格,避免影响排版
              final String displayText = '#$topicName';

              // 实际文本:保留原始编码(#话题^ 或 #话题~),方便后续服务端/本地解析
              final String actualText = '#$topicName$endFlag';

              final TextStyle? effectiveStyle =
                  topicStyle ?? span.style ?? textStyle;

              result.add(SpecialTextSpan(
                text: displayText,
                actualText: actualText,
                // 使用全局起点 + 当前匹配在该 span 内的偏移,映射回原始字符串中的位置
                start: globalStart + match.start,
                style: effectiveStyle,
                deleteAll: false,
              ));

              lastIndex = match.end;
            }

            // 处理最后一个话题之后剩余的普通文本
            if (lastIndex < rawText.length) {
              result.add(TextSpan(
                text: rawText.substring(lastIndex),
                style: span.style ?? textStyle,
              ));
            }
          }
        } else {
          // 对于无法识别为 TextSpan 的情况(极少出现),直接保持原样避免破坏结构
          result.add(span as TextSpan);
        }
      }

      // 累积当前 span 的纯文本长度,为后续 SpecialTextSpan 提供正确的起点位置
      globalStart += span
          .toPlainText()
          .length;
    });
    textSpan.children?.clear();
    textSpan.children?.addAll(result);
    return textSpan;
  }

  @override
  SpecialText? createSpecialText(String flag,
      {TextStyle? textStyle,
        SpecialTextGestureTapCallback? onTap,
        required int index}) {
    try {
      if (isStart(flag, AtText.atStartFlag)) {
        TextStyle tmp = atStyle ?? textStyle!;
        return AtText(tmp, (dynamic paramete) {},
            start: index - (AtText.atStartFlag.length - 1));
      }
    } catch (e) {}
    return null;
  }
}

Contact

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions