【问题标题】:How to stop RichTextBox Inline from being deleted in TextChanged?如何阻止 RichTextBox Inline 在 TextChanged 中被删除?
【发布时间】:2016-07-09 05:32:50
【问题描述】:

我的任务是创建一个部分可编辑的RichTextBox。我在 Xaml 中看到了为 ReadOnly 部分添加 TextBlock 元素的建议,但是这会产生不理想的视觉效果,即不能很好地包装。 (它应该显示为一个连续的文本块。

我已经使用一些reverse string formatting 将一个工作原型拼凑在一起以限制/允许编辑,并将其与inline Run elements 的动态创建相结合以用于显示目的。使用字典来存储文本的可编辑部分的当前值,我在任何 TextChanged 事件触发时相应地更新 Run 元素 - 其想法是,如果可编辑部分的文本被完全删除,它将被替换回它的默认值。

在字符串中:“Hi NAME,欢迎来到 SPORT camp。”,只有 NAMESPORT 是可编辑的。

                ╔═══════╦════════╗                    ╔═══════╦════════╗
Default values: ║ Key   ║ Value  ║    Edited values:  ║ Key   ║ Value  ║
                ╠═══════╬════════╣                    ╠═══════╬════════╣
                ║ NAME  ║ NAME   ║                    ║ NAME  ║ John   ║
                ║ SPORT ║ SPORT  ║                    ║ SPORT ║ Tennis ║
                ╚═══════╩════════╝                    ╚═══════╩════════╝

 "Hi NAME, welcome to SPORT camp."    "Hi John, welcome to Tennis camp."

问题

删除特定运行中的整个文本值会从RichTextBox Document 中删除该运行(以及后续运行)。即使我将它们全部添加回来,它们也不再正确显示在屏幕上。例如,使用上述设置中的编辑字符串:

  • 用户突出显示文本“John”并点击删除,而不是保存空值,应将其替换为“NAME”的默认文本。在内部发生这种情况。字典得到正确的值,Run.Text 有值,Document 包含所有正确的Run 元素。但屏幕显示:

    • 预期:“嗨,NAME,欢迎来到网球训练营。”
    • 实际:“Hi NAME网球训练营。”

旁注:粘贴时也可以复制这种丢失运行元素的行为。突出显示 "SPORT" 并粘贴 "Tennis",包含 "camp." 的 Run 将丢失。

问题

如何让每个 Run 元素在被替换后即使通过破坏性操作也可见?

代码

我试图将代码精简到一个最小的例子,所以我删除了:

  • xaml 中的每个 DependencyProperty 和关联的绑定
  • 逻辑重新计算插入符号位置(抱歉
  • 将链接的字符串格式化扩展方法从第一个链接重构为包含在类的单个方法。 (注意:此方法适用于简单的示例字符串格式。我的更健壮格式的代码已被排除在外。因此请坚持为这些测试目的提供的示例。)
  • 让可编辑的部分清晰可见,不管配色方案。

要进行测试,请将类拖放到 WPF 项目资源文件夹中,修复命名空间,然后将控件添加到视图。

using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;

namespace WPFTest.Resources
{
  public class MyRichTextBox : RichTextBox
  {
    public MyRichTextBox()
    {
      this.TextChanged += MyRichTextBox_TextChanged;
      this.Background = Brushes.LightGray;

      this.Parameters = new Dictionary<string, string>();
      this.Parameters.Add("NAME", "NAME");
      this.Parameters.Add("SPORT", "SPORT");

      this.Format = "Hi {0}, welcome to {1} camp.";
      this.Text = string.Format(this.Format, this.Parameters.Values.ToArray<string>());

      this.Runs = new List<Run>()
      {
        new Run() { Background = Brushes.LightGray, Tag = "Hi " },
        new Run() { Background = Brushes.Black, Foreground = Brushes.White, Tag = "NAME" },
        new Run() { Background = Brushes.LightGray, Tag = ", welcome to " },
        new Run() { Background = Brushes.Black, Foreground = Brushes.White, Tag = "SPORT" },
        new Run() { Background = Brushes.LightGray, Tag = " camp." },
      };

      this.UpdateRuns();
    }

    public Dictionary<string, string> Parameters { get; set; }
    public List<Run> Runs { get; set; }
    public string Text { get; set; }
    public string Format { get; set; }

    private void MyRichTextBox_TextChanged(object sender, TextChangedEventArgs e)
    {
      string richText = new TextRange(this.Document.Blocks.FirstBlock.ContentStart, this.Document.Blocks.FirstBlock.ContentEnd).Text;
      string[] oldValues = this.Parameters.Values.ToArray<string>();
      string[] newValues = null;

      bool extracted = this.TryParseExact(richText, this.Format, out newValues);

      if (extracted)
      {
        var changed = newValues.Select((x, i) => new { NewVal = x, Index = i }).Where(x => x.NewVal != oldValues[x.Index]).FirstOrDefault();
        string key = this.Parameters.Keys.ElementAt(changed.Index);
        this.Parameters[key] = string.IsNullOrWhiteSpace(newValues[changed.Index]) ? key : newValues[changed.Index];

        this.Text = richText;
      }
      else
      {
        e.Handled = true;
      }

      this.UpdateRuns();
    }

    private void UpdateRuns()
    {
      this.TextChanged -= this.MyRichTextBox_TextChanged;

      foreach (Run run in this.Runs)
      {
        string value = run.Tag.ToString();

        if (this.Parameters.ContainsKey(value))
        {
          run.Text = this.Parameters[value];
        }
        else
        {
          run.Text = value;
        }
      }

      Paragraph p = this.Document.Blocks.FirstBlock as Paragraph;
      p.Inlines.Clear();
      p.Inlines.AddRange(this.Runs);

      this.TextChanged += this.MyRichTextBox_TextChanged;
    }

    public bool TryParseExact(string data, string format, out string[] values)
    {
      int tokenCount = 0;
      format = Regex.Escape(format).Replace("\\{", "{");
      format = string.Format("^{0}$", format);

      while (true)
      {
        string token = string.Format("{{{0}}}", tokenCount);

        if (!format.Contains(token))
        {
          break;
        }

        format = format.Replace(token, string.Format("(?'group{0}'.*)", tokenCount++));
      }

      RegexOptions options = RegexOptions.None;

      Match match = new Regex(format, options).Match(data);

      if (tokenCount != (match.Groups.Count - 1))
      {
        values = new string[] { };
        return false;
      }
      else
      {
        values = new string[tokenCount];

        for (int index = 0; index < tokenCount; index++)
        {
          values[index] = match.Groups[string.Format("group{0}", index)].Value;
        }

        return true;
      }
    }
  }
}

【问题讨论】:

  • RichTextBox 通过FlowDocument 类提供的不仅仅是文本编辑器。这使它成为显示格式化文本、图像、表格甚至UIElements 的便捷控件。然而,所有这些都伴随着复杂性的代价。因此,处理RichTextBox 可能会相当混乱。您是否有机会切换到更方便的控件,例如更适合文本处理的 AvalonEdit
  • 据我所知,问题是TryParseExact(richText 失败了。开始修复它是否合理?
  • @qqww2 我不熟悉AvalonEdit 或者它的许可要求,但我会考虑。
  • @OhBeWise 对不起,也许我错了,或者我误解了您的要求。我正在尝试运行您的代码,我看到了 - 如果我删除“SPORT”(例如) - 那么bool extracted 是错误的......如果有意义的话,我会继续解决这个问题......
  • @MachineLearning 啊,您可能是 dbl-click 突出显示,所以当您尝试删除“SPORT”时,它会检测到您尝试使用空格删除“SPORT” - 并且该空间不可编辑。由于这个错误,我实际上已经覆盖了突出显示的行为。我只是没有在这个例子中包含它 - 很抱歉。

标签: c# wpf richtextbox


【解决方案1】:

您的代码的问题在于,当您通过用户界面更改文本时,内部Run 对象被修改、创建、删除,所有疯狂的事情都发生在幕后。内部结构非常复杂。例如,这是一个在无辜单行p.Inlines.Clear(); 深处调用的方法:

private int DeleteContentFromSiblingTree(SplayTreeNode containingNode, TextPointer startPosition, TextPointer endPosition, bool newFirstIMEVisibleNode, out int charCount)
{
    SplayTreeNode leftSubTree;
    SplayTreeNode middleSubTree;
    SplayTreeNode rightSubTree;
    SplayTreeNode rootNode;
    TextTreeNode previousNode;
    ElementEdge previousEdge;
    TextTreeNode nextNode;
    ElementEdge nextEdge;
    int symbolCount;
    int symbolOffset;

    // Early out in the no-op case. CutContent can't handle an empty content span.
    if (startPosition.CompareTo(endPosition) == 0)
    {
        if (newFirstIMEVisibleNode)
        {
            UpdateContainerSymbolCount(containingNode, /* symbolCount */ 0, /* charCount */ -1);
        }
        charCount = 0;
        return 0;
    }

    // Get the symbol offset now before the CutContent call invalidates startPosition.
    symbolOffset = startPosition.GetSymbolOffset();

    // Do the cut.  middleSubTree is what we want to remove.
    symbolCount = CutContent(startPosition, endPosition, out charCount, out leftSubTree, out middleSubTree, out rightSubTree);

    // We need to remember the original previous/next node for the span
    // we're about to drop, so any orphaned positions can find their way
    // back.
    if (middleSubTree != null)
    {
        if (leftSubTree != null)
        {
            previousNode = (TextTreeNode)leftSubTree.GetMaxSibling();
            previousEdge = ElementEdge.AfterEnd;
        }
        else
        {
            previousNode = (TextTreeNode)containingNode;
            previousEdge = ElementEdge.AfterStart;
        }
        if (rightSubTree != null)
        {
            nextNode = (TextTreeNode)rightSubTree.GetMinSibling();
            nextEdge = ElementEdge.BeforeStart;
        }
        else
        {
            nextNode = (TextTreeNode)containingNode;
            nextEdge = ElementEdge.BeforeEnd;
        }

        // Increment previous/nextNode reference counts. This may involve
        // splitting a text node, so we use refs.
        AdjustRefCountsForContentDelete(ref previousNode, previousEdge, ref nextNode, nextEdge, (TextTreeNode)middleSubTree);

        // Make sure left/rightSubTree stay local roots, we might
        // have inserted new elements in the AdjustRefCountsForContentDelete call.
        if (leftSubTree != null)
        {
            leftSubTree.Splay();
        }
        if (rightSubTree != null)
        {
            rightSubTree.Splay();
        }
        // Similarly, middleSubtree might not be a local root any more,
        // so splay it too.
        middleSubTree.Splay();

        // Note TextContainer now has no references to middleSubTree, if there are
        // no orphaned positions this allocation won't be kept around.
        Invariant.Assert(middleSubTree.ParentNode == null, "Assigning fixup node to parented child!");
        middleSubTree.ParentNode = new TextTreeFixupNode(previousNode, previousEdge, nextNode, nextEdge);
    }

    // Put left/right sub trees back into the TextContainer.
    rootNode = TextTreeNode.Join(leftSubTree, rightSubTree);
    containingNode.ContainedNode = rootNode;
    if (rootNode != null)
    {
        rootNode.ParentNode = containingNode;
    }

    if (symbolCount > 0)
    {
        int nextNodeCharDelta = 0;
        if (newFirstIMEVisibleNode)
        {
            // The following node is the new first ime visible sibling.
            // It just moved, and loses an edge character.
            nextNodeCharDelta = -1;
        }

        UpdateContainerSymbolCount(containingNode, -symbolCount, -charCount + nextNodeCharDelta);
        TextTreeText.RemoveText(_rootNode.RootTextBlock, symbolOffset, symbolCount);
        NextGeneration(true /* deletedContent */);

        // Notify the TextElement of a content change. Note that any full TextElements
        // between startPosition and endPosition will be handled by CutTopLevelLogicalNodes,
        // which will move them from this tree to their own private trees without changing
        // their contents.
        Invariant.Assert(startPosition.Parent == endPosition.Parent);
        TextElement textElement = startPosition.Parent as TextElement;
        if (textElement != null)
        {               
            textElement.OnTextUpdated();                    
        }
    }

    return symbolCount;
}

有兴趣的可以看here的源码。

解决方案是不要直接在FlowDocument 中使用您创建的用于比较目的的Run 对象。在添加它们之前,请务必对其进行复制:

private void UpdateRuns()
{
    TextChanged -= MyRichTextBox_TextChanged;

    List<Run> runs = new List<Run>();
    foreach (Run run in Runs)
    {
        Run newRun;
        string value = run.Tag.ToString();

        if (Parameters.ContainsKey(value))
        {
            newRun = new Run(Parameters[value]);
        }
        else
        {
            newRun = new Run(value);
        }

        newRun.Background = run.Background;
        newRun.Foreground = run.Foreground;

        runs.Add(newRun);
    }

    Paragraph p = Document.Blocks.FirstBlock as Paragraph;
    p.Inlines.Clear();
    p.Inlines.AddRange(runs);

    TextChanged += MyRichTextBox_TextChanged;
}

【讨论】:

  • 这几乎正是我为解决这个问题所做的,但我真的不明白为什么这是一个解决方案,但我怀疑引擎盖下的某些东西是 remembering 重新添加的 Run 被删除。我非常同意“幕后发生的疯狂事情。”例如,试图修复插入符号的位置......我试图简化的算法噩梦 - 如此多的边缘情况。感谢您提供源链接。
【解决方案2】:

我建议移动代码以在 UpdateRuns 中创建运行

    private void UpdateRuns()
    {
        this.TextChanged -= this.MyRichTextBox_TextChanged;

        this.Runs = new List<Run>()
  {
    new Run() { Background = Brushes.LightGray, Tag = "Hi " },
    new Run() { Background = Brushes.Black, Foreground = Brushes.White, Tag = "NAME" },
    new Run() { Background = Brushes.LightGray, Tag = ", welcome to " },
    new Run() { Background = Brushes.Black, Foreground = Brushes.White, Tag = "SPORT" },
    new Run() { Background = Brushes.LightGray, Tag = " camp." },
  };

        foreach (Run run in this.Runs)

【讨论】:

  • 感谢您的回答。这与我所做的相似,qqww2 为我重申了这一点。当绑定和DependencyProperty 是数据源时,另一种方法更适合在这个简单示例之外,它有助于提供更多我需要的原因。不过,请为您的麻烦 +1,谢谢!
猜你喜欢
  • 1970-01-01
  • 2012-04-03
  • 1970-01-01
  • 2017-09-17
  • 2023-03-30
  • 2023-03-25
  • 1970-01-01
  • 2019-01-16
  • 2023-01-12
相关资源
最近更新 更多