TextBlock с подсветкой текста (WPF)

в 15:05, , рубрики: .net, C#, highlighting, usercontrol, wpf

Привет! Я создал контрол на основе TextBlock с возможностью подсветки текста. Для начала приведу пример его использования, затем опишу, как он создавался.

Пример использования контрола

<local:HighlightTextBlock TextWrapping="Wrap">
    <local:HighlightTextBlock.HighlightRules>
        <local:HighlightRule HightlightedText="{Binding Filter, Source={x:Reference thisWindow}}">
            <local:HighlightRule.Highlights>
                <local:HighlightBackgroung Brush="Yellow"/>
                <local:HighlightForeground Brush="Black"/>
            </local:HighlightRule.Highlights>
        </local:HighlightRule>
    </local:HighlightTextBlock.HighlightRules>
    <Run FontWeight="Bold">Property:</Run>
    <Run Text="{Binding Property}"/>
</local:HighlightTextBlock>

Начало разработки

Потребовалось мне подсветить текст в TextBlock, введенный в строку поиска. На первый взгляд задача показалась простой. Пришло в голову разделить текст на 3 элемента Run, которые бы передавали в конвертер весь текст, строку поиска и свое положение (1/2/3). Средний Run имеет Backgroung.

Не успел я приступить к реализации, как пришла в голову мысль, что совпадений может быть несколько. А значит такой подход не подходит.

Была еще мысль формировать Xaml «на лету», парсить его при помощи XamlReader и кидать в TextBlock. Но эта мысль тоже сразу отвалилась, потому что попахивает.

Следующей (и окончательной) идеей стало создать систему правил подсветки и прикрутить ее к TextBlock. Тут 2 варианта: свой контрол с блэкджеком и девочками на основе TextBlock или AttachedProperty. После недолгих раздумий, я решил, что все таки лучше создать отдельный контрол, потому что функционал подсветки может наложить некоторые ограничения на функциональность самого TextBlock, а разруливать это проще, если от него унаследоваться.

Исходники готового контрола

Итак, приступим. Сразу предупрежу, что контрол я делал в том же проекте, где собирался тестировать первую идею, поэтому не обращайте внимание на неймспейсы. До ума такие вещи я доведу уже, когда буду включать контрол в основной проект (или буду выкладывать на гитхаб).

В Xaml разметке контрола все чисто, за исключением обработчика события Loaded

<TextBlock x:Class="WpfApplication18.HighlightTextBlock"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             Loaded="TextBlock_Loaded">
</TextBlock>

Переходим к коду:

Заголовок спойлера

    public partial class HighlightTextBlock : TextBlock
    {
        // Здесь сохраняется сериализованное оригинальное наполнение TextBlock 
        // (подсветка накладывается на оригинал и потом уже подставляется в TextBlock)
        string _content;

        // Это словарь для правил подсветки и соответствующих им очередей задач
        Dictionary<HighlightRule, TaskQueue> _ruleTasks;

        /// <summary>
        /// Коллекция правил подсветки
        /// </summary>
        public HighlightRulesCollection HighlightRules
        {
            get
            {
                return (HighlightRulesCollection)GetValue(HighlightRulesProperty);
            }
            set
            {
                SetValue(HighlightRulesProperty, value);
            }
        }

        public static readonly DependencyProperty HighlightRulesProperty =
            DependencyProperty.Register("HighlightRules", typeof(HighlightRulesCollection), typeof(HighlightTextBlock), new FrameworkPropertyMetadata(null) { PropertyChangedCallback = HighlightRulesChanged });


        static void HighlightRulesChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
        {
            var col = e.NewValue as HighlightRulesCollection;
            var tb = sender as HighlightTextBlock;
            if (col != null && tb != null)
            {
                col.CollectionChanged += tb.HighlightRules_CollectionChanged;
                foreach (var rule in col)
                {
                    rule.HighlightTextChanged += tb.Rule_HighlightTextChanged;
                }
            }
        }

        public HighlightTextBlock()
        {
            _ruleTasks = new Dictionary<HighlightRule, TaskQueue>();
            HighlightRules = new HighlightRulesCollection();
            InitializeComponent();
        }

        // Обработчик события на изменение коллекции правил подсветки
        void HighlightRules_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
        {
            switch (e.Action)
            {
                case System.Collections.Specialized.NotifyCollectionChangedAction.Add:
                    foreach (HighlightRule rule in e.NewItems)
                    {
                        _ruleTasks.Add(rule, new TaskQueue(1));
                        SubscribeRuleNotifies(rule);
                        BeginHighlight(rule);
                    }
                    break;
                case System.Collections.Specialized.NotifyCollectionChangedAction.Remove:
                    foreach (HighlightRule rule in e.OldItems)
                    {
                        rule.HightlightedText = string.Empty;
                        _ruleTasks.Remove(rule);
                        UnsubscribeRuleNotifies(rule);
                    }
                    break;
                case System.Collections.Specialized.NotifyCollectionChangedAction.Reset:
                    foreach (HighlightRule rule in e.OldItems)
                    {
                        rule.HightlightedText = string.Empty;
                        _ruleTasks.Remove(rule);
                        UnsubscribeRuleNotifies(rule);
                    }
                    break;
            }
        }

        // Подписка на события правила подсветки
        void SubscribeRuleNotifies(HighlightRule rule)
        {
            rule.HighlightTextChanged += Rule_HighlightTextChanged;
        }

        // Отписка от событий правила подсветки
        void UnsubscribeRuleNotifies(HighlightRule rule)
        {
            rule.HighlightTextChanged -= Rule_HighlightTextChanged;
        }

        // Обработчик события, которое срабатывает, когда текст для подсветки изменился
        void Rule_HighlightTextChanged(object sender, HighlightTextChangedEventArgs e)
        {
            BeginHighlight((HighlightRule)sender);
        }

        // Здесь запускается механизм подсвечивания в созданном мною диспетчере задач.
        // Смысл в том, что если текст вводится/стирается слишком быстро,
        // предыдущая подсветка не успеет закончить работу, поэтому новая подсветка
        // добавляется в очередь. Если в очереди уже что то есть, то это удаляется из очереди
        // и вставляется новая задача. Для каждого правила очередь своя.
        void BeginHighlight(HighlightRule rule)
        {
            _ruleTasks[rule].Add(new Action(() => Highlight(rule)));
        }

        // Механизм подсветки
        void Highlight(HighlightRule rule)
        {
            // Если передали не существующее правило, покидаем процедуру
            if (rule == null)
                return;

            // Так как правила у нас задаются в Xaml коде, они будут принадлежать основному потоку, в котором крутится форма,
            // поэтому некоторые свойства можно достать/положить только таким образом
            ObservableCollection<Highlight> highlights = null;
            Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
            {
                highlights = rule.Highlights;
            }));

            // Даже если существует правило, но в нем не задано, чем подсвечивать, покидаем процедуру подсветки
            if (highlights.Count == 0)
                return;

            // Еще ряд условий для выхода из процедуры подсветки
            var exitFlag = false;
            exitFlag = exitFlag || string.IsNullOrWhiteSpace(_content);
            Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
            {
                exitFlag = exitFlag || Inlines.IsReadOnly || Inlines.Count == 0 || 
                HighlightRules == null || HighlightRules.Count == 0;
            }));

            if (exitFlag)
                return;

            // Создадим параграф. Все манипуляции будем проводить внутри него, потому что выделить что либо
            // непосредственно в TextBlock нельзя, если это выделение затрагивает несколько элементов
            var par = new Paragraph();

            // Парсим _content, в котором у нас сериализованный Span с оригинальным содержимым TextBlock'a.
            var parsedSp = (Span)XamlReader.Parse(_content);

            // Сам Span нам не нужен, поэтому сливаем все его содержимое в параграф
            par.Inlines.AddRange(parsedSp.Inlines.ToArray());

            // Обозначаем стартовую позицию (просто для удобства) и выдергиваем из TextBlock'a голый текст. 
            // Искать вхождения искомой строки будем именно в нем
            var firstPos = par.ContentStart;
            var curText = string.Empty;
            Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
            {
                curText = Text;
            }));

            // Выдергиваем из основного потока текст для подсветки
            var hlText = string.Empty;
            Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
            {
                hlText = rule.HightlightedText;
            }));

            // Если текст для подсветки не пустой и его длина не превышает длину текста, в котором ищем, 
            // то продолжим, иначе просто выведем в конце оригинал
            if (!string.IsNullOrEmpty(hlText) && hlText.Length <= curText.Length)
            {
                // Выдергиваем в основном потоке из правила свойство IgnoreCase.
                // Решил логику оставиьт в основном потоке, потому что нагрузка операции очень низкая
                // и не стоит моего пота :)
                var comparison = StringComparison.CurrentCulture;
                Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
                {
                    comparison = rule.IgnoreCase ? StringComparison.CurrentCultureIgnoreCase : StringComparison.CurrentCulture;
                }));

                // Формируем список индексов, откуда начинаются вхождения искомой строки в тексте
                var indexes = new List<int>();
                var ind = curText.IndexOf(hlText, comparison);
                while (ind > -1)
                {
                    indexes.Add(ind);
                    ind = curText.IndexOf(hlText, ind + hlText.Length, StringComparison.CurrentCultureIgnoreCase);
                }

                TextPointer lastEndPosition = null;
                // Проходим по всем индексам начала вхождения строки поиска в текст
                foreach (var index in indexes)
                {
                    // Эта переменная нужна была в моих соисканиях наилучшего места для начала поиска,
                    // ведь индекс положения в string не соответствует реальному положению TextPointer'a.
                    // Поиск продолжается, поэтому переменную я оставил.
                    var curIndex = index;

                    // Начинаем поиск с последней найденной позиции либо перемещаем TextPointer вперед 
                    // на значение, равное индексу вхождения подстроки в текст
                    var pstart = lastEndPosition ?? firstPos.GetInsertionPosition(LogicalDirection.Forward).GetPositionAtOffset(curIndex);

                    // startInd является длиной текста между начальным TextPointer и текущей точкой начала подсветки
                    var startInd = new TextRange(pstart, firstPos.GetInsertionPosition(LogicalDirection.Forward)).Text.Length;

                    // В результате нам нужно, чтобы startInd был равен curIndex
                    while (startInd != curIndex)
                    {
                        // Если честно, мне неще не встречались случаи, когда я обгонял startInd обгонял curIndex, однако
                        // решил оставить продвижение назад на случай более оптимизированного алгоритма поиска
                        if (startInd < curIndex)
                        {
                            // Смещаем точку начала подсветки на разницу curIndex - startInd
                            var newpstart = pstart.GetPositionAtOffset(curIndex - startInd);

                            // Иногда TextPointer оказывается между r и n, в этом случае начало подсветки
                            // сдвигается вперед. Чтобы этого избежать, двигаем его в следующую позицию для вставки
                            if (newpstart.GetPointerContext(LogicalDirection.Forward) == TextPointerContext.ElementEnd)
                                newpstart = newpstart.GetInsertionPosition(LogicalDirection.Forward);

                            var len = new TextRange(pstart, newpstart).Text.Length;
                            startInd += len;
                            pstart = newpstart;
                        }
                        else
                        {
                            var newpstart = pstart.GetPositionAtOffset(curIndex - startInd);
                            var len = new TextRange(pstart, newpstart).Text.Length;
                            startInd -= len;
                            pstart = newpstart;
                        }
                    }

                    // Ищем конечную точку подсветки аналогичным способом, как для начальной
                    var pend = pstart.GetPositionAtOffset(hlText.Length);
                    var delta = new TextRange(pstart, pend).Text.Length;
                    while (delta != hlText.Length)
                    {
                        if (delta < hlText.Length)
                        {
                            var newpend = pend.GetPositionAtOffset(hlText.Length - delta);
                            var len = new TextRange(pend, newpend).Text.Length;
                            delta += len;
                            pend = newpend;
                        }
                        else
                        {
                            var newpend = pend.GetPositionAtOffset(hlText.Length - delta);
                            var len = new TextRange(pend, newpend).Text.Length;
                            delta -= len;
                            pend = newpend;
                        }
                    }

                    // К сожалению, предложенным способом не получается разделить Hyperlink.
                    // Скорее всего это придется делать вручную, но пока такой необходимости нет, 
                    // поэтому, если начальной или конечной частью подсветки мы режем гиперссылку,
                    // то просто сдвигаем эти позиции. В общем ссылка либо полностью попадает в подсветку,
                    // либо не попадает совсем
                    var sHyp = (pstart?.Parent as Inline)?.Parent as Hyperlink;
                    var eHyp = (pend?.Parent as Inline)?.Parent as Hyperlink;
                    if (sHyp != null)
                        pstart = pstart.GetNextContextPosition(LogicalDirection.Forward);

                    if (eHyp != null)
                        pend = pend.GetNextContextPosition(LogicalDirection.Backward);

                    // Ну а тут применяем к выделению подсветки.
                    if (pstart.GetOffsetToPosition(pend) > 0)
                    {
                        var sp = new Span(pstart, pend);
                        foreach (var hl in highlights)
                            hl.SetHighlight(sp);
                    }
                    lastEndPosition = pend;
                }
            }

            // Здесь сериализуем получившийся параграф и в основном потоке помещаем его содержимое в TextBlock
            var parStr = XamlWriter.Save(par);
            Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
            {
                Inlines.Clear();
                Inlines.AddRange(((Paragraph)XamlReader.Parse(parStr)).Inlines.ToArray());
            })).Wait();
        }

        void TextBlock_Loaded(object sender, RoutedEventArgs e)
        {
            // Здесь дергаем наполнение TextBlock'a и сериализуем его в строку,
            // чтобы накатывать подсветку всегда на оригинал.
            // Это лучше вынести в отдельный поток, но пока и так сойдет.
            var sp = new Span();
            sp.Inlines.AddRange(Inlines.ToArray());
            var tr = new TextRange(sp.ContentStart, sp.ContentEnd);
            using (var stream = new MemoryStream())
            {
                tr.Save(stream, DataFormats.Xaml);
                stream.Position = 0;
                using(var reader = new StreamReader(stream))
                {
                    _content = reader.ReadToEnd();
                }
            }
            Inlines.AddRange(sp.Inlines.ToArray());

            // Запускаем подсветку для всех правил
            foreach (var rule in HighlightRules)
                BeginHighlight(rule);
        }
    }

Я не буду здесь описывать код, потому что комментарии, на мой взгляд, избыточны.

Вот код очереди задач:

Заголовок спойлера

    public class TaskQueue
    {
        Task _worker;
        Queue<Action> _queue;
        int _maxTasks;
        bool _deleteOld;
        object _lock = new object();

        public TaskQueue(int maxTasks, bool deleteOld = true)
        {
            if (maxTasks < 1)
                throw new ArgumentException("TaskQueue: максимальное число задач должно быть больше 0");
            _maxTasks = maxTasks;
            _deleteOld = deleteOld;
            _queue = new Queue<Action>(maxTasks);
        }

        public bool Add(Action action)
        {
            if (_queue.Count() < _maxTasks)
            {
                _queue.Enqueue(action);
                DoWorkAsync();
                return true;
            }
            if (_deleteOld)
            {
                _queue.Dequeue();
                return Add(action);
            }
            return false;
        }

        void DoWorkAsync()
        {
            if(_queue.Count>0)
                _worker = Task.Factory.StartNew(DoWork);
        }

        void DoWork()
        {
            lock (_lock)
            {
                if (_queue.Count > 0)
                {
                    var currentTask = Task.Factory.StartNew(_queue.Dequeue());
                    currentTask.Wait();
                    DoWorkAsync();
                }
            }
        }
    }

Здесь все довольно просто. Поступает новая задача. Если в очереди есть место, то она помещается в очередь. Иначе, если поле _deleteOld == true, то удаляем следующую задачу (наиболее позднюю) и помещаем новую, иначе возвращаем false (задача не добавлена).

Вот код коллекции правил. По идее, можно было обойтись ObservableCollection, но от этой коллекции в дальнейшем может потребоваться дополнительный функционал.

Заголовок спойлера

    public class HighlightRulesCollection : DependencyObject, INotifyCollectionChanged, ICollectionViewFactory, IList, IList<HighlightRule>
    {
        ObservableCollection<HighlightRule> _items;

        public HighlightRulesCollection()
        {
            _items = new ObservableCollection<HighlightRule>();
            _items.CollectionChanged += _items_CollectionChanged;
        }

        public HighlightRule this[int index]
        {
            get
            {
                return ((IList<HighlightRule>)_items)[index];
            }

            set
            {
                ((IList<HighlightRule>)_items)[index] = value;
            }
        }

        object IList.this[int index]
        {
            get
            {
                return ((IList)_items)[index];
            }

            set
            {
                ((IList)_items)[index] = value;
            }
        }

        public int Count
        {
            get
            {
                return ((IList<HighlightRule>)_items).Count;
            }
        }

        public bool IsFixedSize
        {
            get
            {
                return ((IList)_items).IsFixedSize;
            }
        }

        public bool IsReadOnly
        {
            get
            {
                return ((IList<HighlightRule>)_items).IsReadOnly;
            }
        }

        public bool IsSynchronized
        {
            get
            {
                return ((IList)_items).IsSynchronized;
            }
        }

        public object SyncRoot
        {
            get
            {
                return ((IList)_items).SyncRoot;
            }
        }

        public event NotifyCollectionChangedEventHandler CollectionChanged;

        public int Add(object value)
        {
            return ((IList)_items).Add(value);
        }

        public void Add(HighlightRule item)
        {
            ((IList<HighlightRule>)_items).Add(item);
        }

        public void Clear()
        {
            ((IList<HighlightRule>)_items).Clear();
        }

        public bool Contains(object value)
        {
            return ((IList)_items).Contains(value);
        }

        public bool Contains(HighlightRule item)
        {
            return ((IList<HighlightRule>)_items).Contains(item);
        }

        public void CopyTo(Array array, int index)
        {
            ((IList)_items).CopyTo(array, index);
        }

        public void CopyTo(HighlightRule[] array, int arrayIndex)
        {
            ((IList<HighlightRule>)_items).CopyTo(array, arrayIndex);
        }

        public ICollectionView CreateView()
        {
            return new CollectionView(_items);
        }

        public IEnumerator<HighlightRule> GetEnumerator()
        {
            return ((IList<HighlightRule>)_items).GetEnumerator();
        }

        public int IndexOf(object value)
        {
            return ((IList)_items).IndexOf(value);
        }

        public int IndexOf(HighlightRule item)
        {
            return ((IList<HighlightRule>)_items).IndexOf(item);
        }

        public void Insert(int index, object value)
        {
            ((IList)_items).Insert(index, value);
        }

        public void Insert(int index, HighlightRule item)
        {
            ((IList<HighlightRule>)_items).Insert(index, item);
        }

        public void Remove(object value)
        {
            ((IList)_items).Remove(value);
        }

        public bool Remove(HighlightRule item)
        {
            return ((IList<HighlightRule>)_items).Remove(item);
        }

        public void RemoveAt(int index)
        {
            ((IList<HighlightRule>)_items).RemoveAt(index);
        }

        IEnumerator IEnumerable.GetEnumerator()
        {
            return ((IList<HighlightRule>)_items).GetEnumerator();
        }

        void _items_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            CollectionChanged?.Invoke(this, e);
        }

    }

Вот код правила подсветки:

Заголовок спойлера

    public class HighlightRule : DependencyObject
    {
        public delegate void HighlightTextChangedEventHandler(object sender, HighlightTextChangedEventArgs e);

        public event HighlightTextChangedEventHandler HighlightTextChanged;

        public HighlightRule()
        {
            Highlights = new ObservableCollection<Highlight>();
        }

        /// <summary>
        /// Текст, который нужно подсветить
        /// </summary>
        public string HightlightedText
        {
            get { return (string)GetValue(HightlightedTextProperty); }
            set { SetValue(HightlightedTextProperty, value); }
        }

        public static readonly DependencyProperty HightlightedTextProperty =
            DependencyProperty.Register("HightlightedText", typeof(string), typeof(HighlightRule), new FrameworkPropertyMetadata(string.Empty, HighlightPropertyChanged));

        public static void HighlightPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var me = d as HighlightRule;
            if (me != null)
                me.HighlightTextChanged?.Invoke(me, new HighlightTextChangedEventArgs((string)e.OldValue, (string)e.NewValue));
        }

        /// <summary>
        /// Игнорировать регистр? 
        /// </summary>
        public bool IgnoreCase
        {
            get { return (bool)GetValue(IgnoreCaseProperty); }
            set { SetValue(IgnoreCaseProperty, value); }
        }

        public static readonly DependencyProperty IgnoreCaseProperty =
            DependencyProperty.Register("IgnoreCase", typeof(bool), typeof(HighlightRule), new PropertyMetadata(true));


        /// <summary>
        /// Коллекция подсветок
        /// </summary>
        public ObservableCollection<Highlight> Highlights
        {
            get
            {
                return (ObservableCollection<Highlight>)GetValue(HighlightsProperty);
            }
            set { SetValue(HighlightsProperty, value); }
        }

        public static readonly DependencyProperty HighlightsProperty =
            DependencyProperty.Register("Highlights", typeof(ObservableCollection<Highlight>), typeof(HighlightRule), new PropertyMetadata(null));


    }

    public class HighlightTextChangedEventArgs : EventArgs
    {
        public string OldText { get; }

        public string NewText { get; }

        public HighlightTextChangedEventArgs(string oldText,string newText)
        {
            OldText = oldText;
            NewText = newText;
        }
    }

Никакой логики тут нет почти, поэтому без комментариев.

Вот абстрактный класс для подсветки:

    public abstract class Highlight : DependencyObject
    {
        public abstract void SetHighlight(Span span);

        public abstract void SetHighlight(TextRange range);
    }

Мне на данный момент известно два способа подсветить фрагмент. Через Span и через TextRange. Пока что выбранный способ железно прописан в коде в процедуре подсветки, но в дальнейшем я планирую сделать это опционально.

Вот наследник для подсветки фона

    public class HighlightBackgroung : Highlight
    {
        public override void SetHighlight(Span span)
        {
            Brush brush = null;
            Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
            {
                brush = Brush;
            })).Wait();
            span.Background = brush;
        }

        public override void SetHighlight(TextRange range)
        {
            Brush brush = null;
            Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
            {
                brush = Brush;
            })).Wait();
            range.ApplyPropertyValue(TextElement.BackgroundProperty, brush);
        }

        /// <summary>
        /// Кисть для подсветки фона
        /// </summary>
        public Brush Brush
        {
            get
            {
                return (Brush)GetValue(BrushProperty);
            }
            set { SetValue(BrushProperty, value); }
        }

        public static readonly DependencyProperty BrushProperty =
            DependencyProperty.Register("Brush", typeof(Brush), typeof(HighlightBackgroung), new PropertyMetadata(Brushes.Transparent));


    }

Ну тут нечего комментировать, кроме безопасности потоков. Дело в том, что экземпляр должен крутиться в основном потоке, а метод может быть вызван откуда угодно.

А это код подсветки цветом текста

    public class HighlightForeground : Highlight
    {
        public override void SetHighlight(Span span)
        {
            Brush brush = null;
            Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
            {
                brush = Brush;
            })).Wait();
            span.Foreground = brush;
        }

        public override void SetHighlight(TextRange range)
        {
            Brush brush = null;
            Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
            {
                brush = Brush;
            })).Wait();
            range.ApplyPropertyValue(TextElement.ForegroundProperty, brush);
        }

        /// <summary>
        /// Кисть для цвета текста
        /// </summary>
        public Brush Brush
        {
            get { return (Brush)GetValue(BrushProperty); }
            set { SetValue(BrushProperty, value); }
        }

        public static readonly DependencyProperty BrushProperty =
            DependencyProperty.Register("Brush", typeof(Brush), typeof(HighlightForeground), new PropertyMetadata(Brushes.Black));
    }

Заключение

Ну вот пожалуй и все. Хотелось бы услышать ваше мнение.

Автор: MoreBeauty

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js