How to stop RichTextBox Inline from being deleted in TextChanged?

I was tasked with creating a partially editable RichTextBox . I saw sentences in Xaml adding TextBlock elements for ReadOnly sections, however this one has an undesirable visual effect that doesn't wrap beautifully. (It should appear as a single block of continuous text.)

I processed the prototype of the working file using reverse string formatting in order to restrict / enable editing and associate it with the dynamic creation of the built-in Run elements for displaying goals. Using the dictionary to store the current values ​​of editable sections of the text, I update the Run elements accordingly with any trigger of the TextChanged event - with the idea that if the text of the edited section is completely deleted, it will be replaced with its default value.

On the line: "Hello, NAME, welcome to SPORT Camp," only NAME and SPORT are available.

  ╔═══════╦════════╗ ╔═══════╦════════╗ 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." 

Problem

Removing the entire text value in a specific session removes this run (and the next run) from the RichTextBox Document . Even though I am adding them back, they no longer display correctly on the screen. For example, using the edited line from the above setting:

  • The user selects the text "John" and clicks Delete , instead of saving an empty value, it should be replaced by the default text "NAME". Inside it happens. The dictionary gets the correct value, the value of Run.Text matters, the Document contains all the correct Run elements. But the screen displays:

    • Expected: "Hi NAME, welcome to the tennis camp."
    • In fact: "Hello, NAMETennis camp."

Actual vs Expected Screenshot

Sidenote: This lost-work behavior can also be duplicated on insertion. Highlight SPORT and paste Tennis and Run containing the camp. lost.

Question

How to keep each Run element visible even through destructive actions after replacing them?

Code

I tried breaking the code into a minimal example, so I deleted:

  • Every DependencyProperty and related binding in xaml
  • Logical recalculation of the carriage position (sorry)
  • Implemented a method for expanding the formatting of related strings from the first link to a single method contained within the class. (Note: this method will work for simple sample string formats. My code has been excluded for more reliable formatting. Therefore, please follow the example provided for these testing purposes.)
  • Made editable sections clearly visible, regardless of the color scheme.

To verify, drop the class into the resource folder of the WPF project, correct the namespace and add the control to the view.

 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; } } } } 
+5
source share
2 answers

The problem with your code is that when you change text through the user interface, the internal Run objects change, create, delete, and all the crazy stuff happens behind the scenes. The internal structure is very complex. For example, here is a method called deep inside an innocent single line 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; } 

You can see the source code here if you are interested.

The solution does not use Run objects created for comparison directly in FlowDocument . Always make a copy before adding them:

 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; } 
+2
source

I would suggest moving code to create runs in 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) 
+1
source

All Articles