I've been tasked with creating a partially editable RichTextBox
. I've seen suggestions in Xaml adding TextBlock
elements for the ReadOnly
sections, however this has the undesireable visual effect of not wrapping nicely. (It should appear as a single block of continuous text.)
I've patched together a working prototype using some reverse string formatting to restrict/allow edits and coupled that with dynamic creation of inline Run
elements for displaying purposes. Using a dictionary to store the current values of the editable sections of text, I update the Run
elements accordingly upon any TextChanged
event trigger - with the idea that if an editable section's text is completely deleted, it will be replaced back to its default value.
In the string: "Hi NAME, welcome to SPORT camp.", only NAME and SPORT are editable.
╔═══════╦════════╗ ╔═══════╦════════╗
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:
Deleting the entire text value in a particular run removes that run (and the following run) from the RichTextBox Document
. Even though I add them all back, they no longer display correctly on screen. For example, using the edited string from the above setup:
User highlights the text "John" and clicks Delete, instead of saving the empty value, it should be replaced with the default text of "NAME". Internally this happens. The dictionary gets the correct value, the
Run.Text
has the value, theDocument
contains all the correctRun
elements. But the screen displays:- Expected: "Hi NAME, welcome to Tennis camp."
- Actual: "Hi NAMETennis camp."
Sidenote: This loss-of-Run-element behavior can also be duplicated when pasting. Highlight "SPORT" and paste "Tennis" and the Run containing " camp." is lost.
Question:
How do I keep every Run
element visible even through destructive actions, once they've been replaced?
Code:
I have tried to strip down the code to a minimum example, so I've removed:
- Every
DependencyProperty
and associated binding in the xaml - Logic recalculating caret position (sorry)
- Refactored the linked string formatting extension methods from the first link to a single method contained within the class. (Note: this method will work for simple example string formats. My code for more robust formatting has been excluded. So please stick to the example provided for these testing purposes.)
- Made the editable sections clearly visible, nevermind the color scheme.
To test, drop the class into your WPF project Resource folder, fix the namespace, and add the control to a 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;
}
}
}
}