WPF RichTextBox - 设置 Caret.RenderTransform 中断拼写检查

WPF RichTextBox - Setting Caret.RenderTransform Breaks Spell Check

我有一个 C# WPF RichTextBox,它允许通过 Slider 进行 ScaleXScaleY LayoutTransform 调整。不幸的是,这种缩放会导致插入符号停止渲染,这个错误可以根据代码 at this SO post here. More unfortunately, setting the caret's RenderTransform causes the spell check red squiggly lines to stop showing up as you type. It seems as though unfocusing the RichTextBox and focusing it again by clicking on the Slider will cause all of the red squiggly lines to reappear. You can view a demo of this bug on my GitHub here.

修复

问题:如何在用户键入时显示红色波浪拼写检查线,同时仍允许 RichTextBox 缩放和 fully-rendered-at-all-scale-levels 插入符? 我试过手动调用 GetSpellingError(TextPointer),这很有效……有点。它不是完全可靠的,除非我在 RichTextBox 的每个 单词上调用 GetSpellingError,当有大量内容时计算速度非常慢。我还尝试在 Speller and related internal classes, such as Highlights, SpellerStatusTable, and SpellerHighlightLayer 中的项目上使用反射等。在查看 SpellerStatusTable 的运行列表时(其中似乎包含有关运行是干净还是脏的信息),在单击滑块之前,运行不会更新以包含错误,这意味着 RichTextBox不是 re-checking 拼写错误。

CustomRichTextBox.cs "fixes" 中注释掉 caretSubElement.RenderTransform = scaleTransform; 问题,但随后又破坏了插入符渲染。

代码--

MainWindow.xaml:

<Window x:Class="BrokenRichTextBox.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:BrokenRichTextBox"
        mc:Ignorable="d"
        Title="Rich Text Box Testing" Height="350" Width="525">
    <Grid Background="LightGray">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"></RowDefinition>
            <RowDefinition Height="*"></RowDefinition>
        </Grid.RowDefinitions>
        <Slider Name="FontZoomSlider" Grid.Row="0" Width="150" Value="2" Minimum="0.3" Maximum="10" HorizontalAlignment="Right" VerticalAlignment="Center"/>
        <local:CustomRichTextBox x:Name="richTextBox" 
                                 Grid.Row="1" 
                                 SpellCheck.IsEnabled="True"
                                 ScaleX="{Binding ElementName=FontZoomSlider, Path=Value}" 
                                 ScaleY="{Binding ElementName=FontZoomSlider, Path=Value}"
                                 AcceptsTab="True">
            <local:CustomRichTextBox.LayoutTransform>
                <ScaleTransform ScaleX="{Binding ElementName=richTextBox, Path=ScaleX, Mode=TwoWay}" 
                                ScaleY="{Binding ElementName=richTextBox, Path=ScaleY, Mode=TwoWay}"/>
            </local:CustomRichTextBox.LayoutTransform>
            <FlowDocument>
                <Paragraph>
                    <Run>I am some sample text withhh typooos</Run>
                </Paragraph>
                <Paragraph>
                    <Run FontStyle="Italic">I am some more sample text in italic</Run>
                </Paragraph>
            </FlowDocument>
        </local:CustomRichTextBox>
    </Grid>
</Window>

CustomRichTextBox.cs:

using System;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Threading;

namespace BrokenRichTextBox
{
    class CustomRichTextBox : RichTextBox
    {
        private bool _didAddLayoutUpdatedEvent = false;

        public CustomRichTextBox() : base()
        {
            UpdateAdorner();
            if (!_didAddLayoutUpdatedEvent)
            {
                _didAddLayoutUpdatedEvent = true;
                LayoutUpdated += updateAdorner;
            }
        }

        public void UpdateAdorner()
        {
            updateAdorner(null, null);
        }

        // Fixing missing caret bug code adjusted from: 
        private void updateAdorner(object sender, EventArgs e)
        {
            Dispatcher.BeginInvoke(new Action(() =>
            {
                Selection.GetType().GetMethod("System.Windows.Documents.ITextSelection.UpdateCaretAndHighlight", BindingFlags.NonPublic | BindingFlags.Instance).Invoke(
                    Selection, null);
                var caretElement = Selection.GetType().GetProperty("CaretElement", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(Selection, null);
                if (caretElement == null)
                    return;
                var caretSubElement = caretElement.GetType().GetField("_caretElement", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(caretElement) as UIElement;
                if (caretSubElement == null) return;
                // Scale slightly differently if in italic just so it looks a little bit nicer
                bool isItalic = (bool)caretElement.GetType().GetField("_italic", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(caretElement);
                double scaleX = 1;
                if (!isItalic)
                    scaleX = (1 / ScaleX);
                else
                    scaleX = 0.685;// output;
                double scaleY = 1;
                var scaleTransform = new ScaleTransform(scaleX, scaleY);
                caretSubElement.RenderTransform = scaleTransform; // The line of trouble
            }), DispatcherPriority.ContextIdle);
        }

        public double ScaleX
        {
            get { return (double)GetValue(ScaleXProperty); }
            set { SetValue(ScaleXProperty, value); }
        }
        public static readonly DependencyProperty ScaleXProperty =
            DependencyProperty.Register("ScaleX", typeof(double), typeof(CustomRichTextBox), new UIPropertyMetadata(1.0));

        public double ScaleY
        {
            get { return (double)GetValue(ScaleYProperty); }
            set { SetValue(ScaleYProperty, value); }
        }
        public static readonly DependencyProperty ScaleYProperty =
            DependencyProperty.Register("ScaleY", typeof(double), typeof(CustomRichTextBox), new UIPropertyMetadata(1.0));

    }
}

我设法让事情运转起来,至少从表面上看是这样。 tl;dr 修复是对 previous/next 单词以及 previous/next Paragraphs (Blocks) 的第一个和最后一个单词进行手动 GetSpellingError 调用.仅仅检查周围的词是行不通的,因为一些奇怪的原因,如果我在行尾点击 'enter/return' 并且该段的最后一个词拼写错误,拼写检查器就不会启动。如果上一段的第一个单词在点击 'enter/return' 后拼写错误,红色波浪线将 消失 !无论如何,手动检查单词,但不检查 all 个单词,似乎工作正常。

我的个人项目有一些额外的 "please check spelling on surrounding words" 调用一些 OnPreviewKeyDown 实例以防未及时调用 UpdateAdorner,但我将把它作为练习留给 reader. :)

我猜有更好的答案。

代码(在 Github here 上很容易查看):

MainWindow.xaml:

<Window x:Class="BrokenRichTextBox.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:BrokenRichTextBox"
        mc:Ignorable="d"
        Title="Rich Text Box Testing" Height="480" Width="640">
    <Grid Background="LightGray">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"></RowDefinition>
            <RowDefinition Height="*"></RowDefinition>
            <RowDefinition Height="Auto"></RowDefinition>
            <RowDefinition Height="*"></RowDefinition>
        </Grid.RowDefinitions>
        <!--CheckBox Content="Enable Extra" Grid.Row="0" VerticalAlignment="Center"/-->
        <Label Content="Broken RichTextBox" Grid.Row="0"/>
        <Slider Name="FontZoomSlider" Grid.Row="0" Width="150" Value="2" Minimum="0.3" Maximum="10" HorizontalAlignment="Right" VerticalAlignment="Center"/>
        <local:CustomRichTextBox x:Name="RichTextBox" 
                                 Grid.Row="1" 
                                 SpellCheck.IsEnabled="True"
                                 ScaleX="{Binding ElementName=FontZoomSlider, Path=Value}" 
                                 ScaleY="{Binding ElementName=FontZoomSlider, Path=Value}"
                                 AcceptsTab="True">
            <local:CustomRichTextBox.LayoutTransform>
                <ScaleTransform ScaleX="{Binding ElementName=RichTextBox, Path=ScaleX, Mode=TwoWay}" 
                                ScaleY="{Binding ElementName=RichTextBox, Path=ScaleY, Mode=TwoWay}"/>
            </local:CustomRichTextBox.LayoutTransform>
            <FlowDocument>
                <Paragraph>
                    <Run>I am some sample text withhh typooos</Run>
                </Paragraph>
                <Paragraph>
                    <Run FontStyle="Italic">I am some more sample text in italic</Run>
                </Paragraph>
            </FlowDocument>
        </local:CustomRichTextBox>
        <Label Content="Better/Fixed RichTextBox" Grid.Row="2"/>
        <local:FixedCustomRichTextBox x:Name="FixedRichTextBox" 
                                 Grid.Row="3" 
                                 SpellCheck.IsEnabled="True"
                                 ScaleX="{Binding ElementName=FontZoomSlider, Path=Value}" 
                                 ScaleY="{Binding ElementName=FontZoomSlider, Path=Value}"
                                 AcceptsTab="True">
            <local:FixedCustomRichTextBox.LayoutTransform>
                <ScaleTransform ScaleX="{Binding ElementName=FixedRichTextBox, Path=ScaleX, Mode=TwoWay}" 
                                ScaleY="{Binding ElementName=FixedRichTextBox, Path=ScaleY, Mode=TwoWay}"/>
            </local:FixedCustomRichTextBox.LayoutTransform>
            <FlowDocument>
                <Paragraph>
                    <Run>I am some sample text withhh typooos</Run>
                </Paragraph>
                <Paragraph>
                    <Run FontStyle="Italic">I am some more sample text in italic</Run>
                </Paragraph>
            </FlowDocument>
        </local:FixedCustomRichTextBox>
    </Grid>
</Window>

FixedCustomRichTextBox.cs:

using System;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Threading;

namespace BrokenRichTextBox
{
    class FixedCustomRichTextBox : RichTextBox
    {
        private bool _didAddLayoutUpdatedEvent = false;

        public FixedCustomRichTextBox() : base()
        {
            UpdateAdorner();
            if (!_didAddLayoutUpdatedEvent)
            {
                _didAddLayoutUpdatedEvent = true;
                LayoutUpdated += updateAdorner;
            }
        }

        public void UpdateAdorner()
        {
            updateAdorner(null, null);
        }

        // Fixing missing caret bug code adjusted from: 
        private void updateAdorner(object sender, EventArgs e)
        {
            Dispatcher.BeginInvoke(new Action(() =>
            {
                Selection.GetType().GetMethod("System.Windows.Documents.ITextSelection.UpdateCaretAndHighlight", BindingFlags.NonPublic | BindingFlags.Instance).Invoke(
                    Selection, null);
                var caretElement = Selection.GetType().GetProperty("CaretElement", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(Selection, null);
                if (caretElement == null)
                    return;
                var caretSubElement = caretElement.GetType().GetField("_caretElement", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(caretElement) as UIElement;
                if (caretSubElement == null) return;
                // Scale slightly differently if in italic just so it looks a little bit nicer
                bool isItalic = (bool)caretElement.GetType().GetField("_italic", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(caretElement);
                double scaleX = 1;
                if (!isItalic)
                    scaleX = (1 / ScaleX);
                else
                    scaleX = 0.685;// output;
                double scaleY = 1;
                var scaleTransform = new ScaleTransform(scaleX, scaleY);
                caretSubElement.RenderTransform = scaleTransform; // The line of trouble
                updateSpellingErrors(CaretPosition);
            }), DispatcherPriority.ContextIdle);
        }

        private void checkSpelling(TextPointer pointer, string currentWord)
        {
            if (pointer != null)
            {
                string otherText = WordBreaker.GetWordRange(pointer).Text;
                if (currentWord != otherText || currentWord == "" || otherText == "")
                {
                    GetSpellingError(pointer);
                }
            }
        }

        private void checkSpelling(Paragraph paragraph, string currentWord)
        {
            if (paragraph != null)
            {
                checkSpelling(paragraph.ContentStart.GetPositionAtOffset(3, LogicalDirection.Forward), currentWord);
                checkSpelling(paragraph.ContentEnd.GetPositionAtOffset(-3, LogicalDirection.Backward), currentWord);
            }
        }

        private void updateSpellingErrors(TextPointer position)
        {
            string currentWord = GetCurrentWord();

            // Update first and last words of previous and next paragraphs
            var previousParagraph = position.Paragraph?.PreviousBlock as Paragraph;
            checkSpelling(previousParagraph, currentWord);
            var nextParagraph = position.Paragraph?.NextBlock as Paragraph;
            checkSpelling(nextParagraph, currentWord);

            // Update surrounding words next to current caret
            checkSpelling(position.GetPositionAtOffset(-3), currentWord);
            checkSpelling(position.GetPositionAtOffset(3), currentWord);
        }

        // Modified from: 
        private string GetCurrentWord()
        {
            TextPointer start = CaretPosition;  // this is the variable we will advance to the left until a non-letter character is found
            TextPointer end = CaretPosition;    // this is the variable we will advance to the right until a non-letter character is found
            string stringBeforeCaret = start.GetTextInRun(LogicalDirection.Backward);   // extract the text in the current run from the caret to the left
            string stringAfterCaret = start.GetTextInRun(LogicalDirection.Forward);     // extract the text in the current run from the caret to the left
            int countToMoveLeft = 0;  // we record how many positions we move to the left until a non-letter character is found
            int countToMoveRight = 0; // we record how many positions we move to the right until a non-letter character is found
            for (int i = stringBeforeCaret.Length - 1; i >= 0; --i)
            {
                // if the character at the location CaretPosition-LeftOffset is a letter, we move more to the left
                if (!char.IsWhiteSpace(stringBeforeCaret[i]))
                    ++countToMoveLeft;
                else break; // otherwise we have found the beginning of the word
            }
            for (int i = 0; i < stringAfterCaret.Length; ++i)
            {
                // if the character at the location CaretPosition+RightOffset is a letter, we move more to the right
                if (!char.IsWhiteSpace(stringAfterCaret[i]))
                    ++countToMoveRight;
                else break; // otherwise we have found the end of the word
            }
            start = start.GetPositionAtOffset(-countToMoveLeft);    // modify the start pointer by the offset we have calculated
            end = end.GetPositionAtOffset(countToMoveRight);        // modify the end pointer by the offset we have calculated
            // extract the text between those two pointers
            TextRange r = new TextRange(start, end);
            string text = r.Text;
            // check the result
            return text;
        }

        public double ScaleX
        {
            get { return (double)GetValue(ScaleXProperty); }
            set { SetValue(ScaleXProperty, value); }
        }
        public static readonly DependencyProperty ScaleXProperty =
            DependencyProperty.Register("ScaleX", typeof(double), typeof(FixedCustomRichTextBox), new UIPropertyMetadata(1.0));

        public double ScaleY
        {
            get { return (double)GetValue(ScaleYProperty); }
            set { SetValue(ScaleYProperty, value); }
        }
        public static readonly DependencyProperty ScaleYProperty =
            DependencyProperty.Register("ScaleY", typeof(double), typeof(FixedCustomRichTextBox), new UIPropertyMetadata(1.0));

    }
}

WordBreaker.cs(来自 MSDN):

using System.Windows.Documents;

namespace BrokenRichTextBox
{
    // https://blogs.msdn.microsoft.com/prajakta/2006/11/01/navigate-words-in-richtextbox/
    public static class WordBreaker
    {
        /// <summary>
        /// Returns a TextRange covering a word containing or following this TextPointer.
        /// </summary>
        /// <remarks>
        /// If this TextPointer is within a word or at start of word, the containing word range is returned.
        /// If this TextPointer is between two words, the following word range is returned.
        /// If this TextPointer is at trailing word boundary, the following word range is returned.
        /// </remarks>
        public static TextRange GetWordRange(TextPointer position)
        {
            TextRange wordRange = null;
            TextPointer wordStartPosition = null;
            TextPointer wordEndPosition = null;
            // Go forward first, to find word end position.
            wordEndPosition = GetPositionAtWordBoundary(position, /*wordBreakDirection*/LogicalDirection.Forward);
            if (wordEndPosition != null)
            {
                // Then travel backwards, to find word start position.
                wordStartPosition = GetPositionAtWordBoundary(wordEndPosition, /*wordBreakDirection*/LogicalDirection.Backward);
            }
            if (wordStartPosition != null && wordEndPosition != null)
            {
                wordRange = new TextRange(wordStartPosition, wordEndPosition);
            }
            return wordRange;
        }

        /// <summary>
        /// 1.  When wordBreakDirection = Forward, returns a position at the end of the word,
        ///     i.e. a position with a wordBreak character (space) following it.
        /// 2.  When wordBreakDirection = Backward, returns a position at the start of the word,
        ///     i.e. a position with a wordBreak character (space) preceeding it.
        /// 3.  Returns null when there is no workbreak in the requested direction.
        /// </summary>
        private static TextPointer GetPositionAtWordBoundary(TextPointer position, LogicalDirection wordBreakDirection)
        {
            if (!position.IsAtInsertionPosition)
            {
                position = position.GetInsertionPosition(wordBreakDirection);
            }
            TextPointer navigator = position;
            while (navigator != null && !IsPositionNextToWordBreak(navigator, wordBreakDirection))
            {
                navigator = navigator.GetNextInsertionPosition(wordBreakDirection);
            }
            return navigator;
        }
        // Helper for GetPositionAtWordBoundary.
        // Returns true when passed TextPointer is next to a wordBreak in requested direction.
        private static bool IsPositionNextToWordBreak(TextPointer position, LogicalDirection wordBreakDirection)
        {
            bool isAtWordBoundary = false;
            // Skip over any formatting.
            if (position.GetPointerContext(wordBreakDirection) != TextPointerContext.Text)
            {
                position = position.GetInsertionPosition(wordBreakDirection);
            }
            if (position.GetPointerContext(wordBreakDirection) == TextPointerContext.Text)
            {
                LogicalDirection oppositeDirection = (wordBreakDirection == LogicalDirection.Forward) ?
                    LogicalDirection.Backward : LogicalDirection.Forward;
                char[] runBuffer = new char[1];
                char[] oppositeRunBuffer = new char[1];
                position.GetTextInRun(wordBreakDirection, runBuffer, /*startIndex*/0, /*count*/1);
                position.GetTextInRun(oppositeDirection, oppositeRunBuffer, /*startIndex*/0, /*count*/1);
                if (runBuffer[0] == ' ' && !(oppositeRunBuffer[0] == ' '))
                {
                    isAtWordBoundary = true;
                }
            }
            else
            {
                // If we’re not adjacent to text then we always want to consider this position a “word break”. 
                // In practice, we’re most likely next to an embedded object or a block boundary.
                isAtWordBoundary = true;
            }
            return isAtWordBoundary;
        }
    }
}

CustomRichTextBox.cs保持不变