【问题标题】:Create guitar chords editor in WPF (from RichTextBox?)在 WPF 中创建吉他和弦编辑器(来自 RichTextBox?)
【发布时间】:2011-08-13 09:05:39
【问题描述】:

我在 WPF 中开发的应用程序的主要目的是允许编辑并打印带有吉他和弦的歌词。

即使您不演奏任何乐器,您也可能已经看过和弦。为了给你一个想法,它看起来像这样:

E                 E6
I know I stand in line until you
E                  E6               F#m            B F#m B
think you have the time to spend an evening with me

但是,我想要Times New Roman 字体,而不是这种丑陋的单间距字体,歌词和和弦都带有字距调整(粗体字体的和弦)。我希望用户能够编辑它。

RichTextBox 似乎不支持这种情况。以下是一些我不知道如何解决的问题:

  • 和弦的位置固定在歌词文本中的某个字符上(或者更普遍的歌词行的TextPointer)。当用户编辑歌词时,我希望和弦停留在正确的字符上。示例:

.

E                                       E6
I know !!!SOME TEXT REPLACED HERE!!! in line until you
  • 换行:在换行方面,2 行(第 1 行是和弦,第 2 行是歌词)在逻辑上是一行。当一个单词换行到下一行时,它上面的所有和弦也应该换行。同样,当和弦包裹它上面的单词时,它也会包裹。示例:

.

E                  E6
think you have the time to spend an
F#m            B F#m B
evening with me
  • 即使和弦距离太近,和弦也应该停留在正确的字符上。在这种情况下,一些额外的空间会自动插入到歌词行中。示例:

.

                  F#m E6
  ...you have the ti  me to spend... 
  • 假设我有歌词行Ta VA 和和弦A。我希望歌词看起来像 而不是 。第二张图片在VA 之间没有紧缩。橙色线仅用于可视化效果(但它们标记了放置和弦的 x 偏移量)。用于生成第一个样本的代码是<TextBlock FontFamily="Times New Roman" FontSize="60">Ta VA</TextBlock>,第二个样本是<TextBlock FontFamily="Times New Roman" FontSize="60"><Span>Ta V<Floater />A</Span></TextBlock>

关于如何让RichTextBox 做到这一点的任何想法?或者有没有更好的方法在 WPF 中做到这一点?我对InlineRun 有帮助吗?欢迎任何想法、技巧、TextPointer 魔术、代码或相关主题的链接。


编辑:

我正在探索解决这个问题的 2 个主要方向,但都导致了另一个问题,所以我提出了新问题:

  1. 尝试将 RichTextBox 变成和弦编辑器 - 看看 How can I create subclass of class Inline?
  2. 按照H.B. answer 中的建议,从Panels TextBoxes 等独立组件构建新编辑器。这将需要大量编码,并且还会导致以下(未解决的)问题:


编辑#2

Markus Hütter's high quality answer 向我展示了使用RichTextBox 可以完成更多工作,而当我尝试根据自己的需要对其进行调整时,我就预料到了。我现在才有时间详细探索答案。 Markus 可能是 RichTextBox 魔术师,我需要帮助我解决这个问题,但他的解决方案也存在一些未解决的问题:

  1. 这个应用程序将是关于“漂亮”的印刷歌词。主要目标是从排版的角度来看,文本看起来很完美。当和弦彼此太靠近甚至重叠时,Markus 建议我在其位置之前迭代地添加附加空格,直到它们的距离足够。实际上要求用户可以设置两个和弦之间的最小距离。应遵守该最小距离,除非必要,否则不得超过该距离。空格不够细——一旦我添加了最后一个需要的空间,我可能会在必要时使间隙变大——这会使文档看起来“糟糕”,我认为它不能被接受。 我需要插入自定义宽度的空间
  2. 可能有没有和弦的行(只有文本),甚至可能有没有文本的行(只有和弦)。当LineHeight 设置为25 或整个文档的其他固定值时,它将导致没有和弦的行上方有“空行”。当只有和弦而没有文字时,它们就没有空间了。

还有其他一些小问题,但我认为我可以解决它们,或者我认为它们不重要。无论如何,我认为马库斯的回答真的很有价值——不仅是为了向我展示可能的方式,而且作为一个使用 RichTextBox 和装饰器的一般模式的演示。

【问题讨论】:

  • 注意:用户不必能够直接从编辑器编辑和弦(我可以添加一些上下文菜单来插入或更改和弦),甚至可以放置插入符号,但这会很好。所以用户唯一能做的就是编辑歌词行。当用户删除有和弦的字符时,和弦也可能被删除或只是转移到下一个字符。
  • +1 非常好的问题。我对 WPF 的 RichTextBox 有很多经验。我会考虑一下,看看我能不能想出什么办法。不过要先提出一个问题:是否允许反射?

标签: .net wpf user-interface richtextbox music-notation


【解决方案1】:

太好了,我在这里玩得很开心。看起来是这样的:

歌词是完全可编辑的,和弦目前不可编辑(但这将是一个简单的扩展)。

这是 xaml:

<Window ...>
    <AdornerDecorator>
        <!-- setting the LineHeight enables us to position the Adorner on top of the text -->
        <RichTextBox TextBlock.LineHeight="25" Padding="0,25,0,0" Name="RTB"/>
    </AdornerDecorator>    
</Window>

这是代码:

public partial class MainWindow
{
    public MainWindow()
    {
        InitializeComponent();
        const string input = "E                 E6\nI know I stand in line until you\nE                  E6               F#m            B F#m B\nthink you have the time to spend an evening with me                ";
        var lines = input.Split('\n');

        var paragraph = new Paragraph{Margin = new Thickness(0),Padding = new Thickness(0)}; // Paragraph sets default margins, don't want those

        RTB.Document = new FlowDocument(paragraph);

        // this is getting the AdornerLayer, we explicitly included in the xaml.
        // in it's visual tree the RTB actually has an AdornerLayer, that would rather
        // be the AdornerLayer we want to get
        // for that you will either want to subclass RichTextBox to expose the Child of
        // GetTemplateChild("ContentElement") (which supposedly is the ScrollViewer
        // that hosts the FlowDocument as of http://msdn.microsoft.com/en-us/library/ff457769(v=vs.95).aspx 
        // , I hope this holds true for WPF as well, I rather remember this being something
        // called "PART_ScrollSomething", but I'm sure you will find that out)
        //
        // another option would be to not subclass from RTB and just traverse the VisualTree
        // with the VisualTreeHelper to find the UIElement that you can use for GetAdornerLayer
        var adornerLayer = AdornerLayer.GetAdornerLayer(RTB);

        for (var i = 1; i < lines.Length; i += 2)
        {
            var run = new Run(lines[i]);
            paragraph.Inlines.Add(run);
            paragraph.Inlines.Add(new LineBreak());

            var chordpos = lines[i - 1].Split(' ');
            var pos = 0;
            foreach (string t in chordpos)
            {
                if (!string.IsNullOrEmpty(t))
                {
                    var position = run.ContentStart.GetPositionAtOffset(pos);
                    adornerLayer.Add(new ChordAdorner(RTB,t,position));
                }
                pos += t.Length + 1;
            }
        }

    }
}

使用这个装饰器:

public class ChordAdorner : Adorner
{
    private readonly TextPointer _position;

    private static readonly PropertyInfo TextViewProperty = typeof(TextSelection).GetProperty("TextView", BindingFlags.Instance | BindingFlags.NonPublic);
    private static readonly EventInfo TextViewUpdateEvent = TextViewProperty.PropertyType.GetEvent("Updated");

    private readonly FormattedText _formattedText;

    public ChordAdorner(RichTextBox adornedElement, string chord, TextPointer position) : base(adornedElement)
    {
        _position = position;
        // I'm in no way associated with the font used, nor recommend it, it's just the first example I found of FormattedText
        _formattedText = new FormattedText(chord, CultureInfo.GetCultureInfo("en-us"),FlowDirection.LeftToRight,new Typeface(new FontFamily("Arial").ToString()),12,Brushes.Black);

        // this is where the magic starts
        // you would otherwise not know when to actually reposition the drawn Chords
        // you could otherwise only subscribe to TextChanged and schedule a Dispatcher
        // call to update this Adorner, which either fires too often or not often enough
        // that's why you're using the RichTextBox.Selection.TextView.Updated event
        // (you're then basically updating the same time that the Caret-Adorner
        // updates it's position)
        Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() =>
        {
            object textView = TextViewProperty.GetValue(adornedElement.Selection, null);
            TextViewUpdateEvent.AddEventHandler(textView, Delegate.CreateDelegate(TextViewUpdateEvent.EventHandlerType, ((Action<object, EventArgs>)TextViewUpdated).Target, ((Action<object, EventArgs>)TextViewUpdated).Method));
            InvalidateVisual(); //call here an event that triggers the update, if 
                                //you later decide you want to include a whole VisualTree
                                //you will have to change this as well as this ----------.
        }));                                                                          // |
    }                                                                                 // |
                                                                                      // |
    public void TextViewUpdated(object sender, EventArgs e)                           // |
    {                                                                                 // V
        Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(InvalidateVisual));
    }

    protected override void OnRender(DrawingContext drawingContext)
    {
        if(!_position.HasValidLayout) return; // with the current setup this *should* always be true. check anyway
        var pos = _position.GetCharacterRect(LogicalDirection.Forward).TopLeft;
        pos += new Vector(0, -10); //reposition so it's on top of the line
        drawingContext.DrawText(_formattedText,pos);
    }
}

这是使用大卫建议的装饰器,但我知道很难找到如何使用。那可能是因为没有。之前我在反射器中花了几个小时试图找到表明流文档的布局已被弄清楚的确切事件。

我不确定构造函数中的调度程序调用是否真的需要,但为了防弹,我把它留了下来。 (我需要这个,因为在我的设置中 RichTextBox 尚未显示)。

显然这需要更多的编码,但这会给你一个开始。你会想玩定位等。

如果两个装饰器太靠近并且重叠,为了获得正确的定位,我建议您以某种方式跟踪哪个装饰器之前出现,看看当前的装饰器是否会重叠。那么您可以例如在_position-TextPointer 之前迭代地插入一个空格。

如果您稍后决定,您也希望和弦可编辑,您可以在 OnRender 中绘制一个完整的 VisualTree,而不是仅在装饰器下绘制文本。 (here 是下面带有 ContentControl 的装饰器示例)。请注意,您必须处理 ArrangeOveride,然后才能通过 _position CharacterRect 正确定位 Adorner。

【讨论】:

  • @drasto 更新了我的代码,对正在发生的事情进行了更深入的解释
  • +1 非常好的答案。非常感谢你写这篇文章!这周我真的很忙,所以我现在才有时间好好看看它,一旦赏金结束:(。它向我展示了很多,但当我玩一点时,我发现 2 认为我不能解决。有描述问题的编辑#2。
  • @drasto,我会看看我能对剩下的问题做些什么,我现在没有时间,但我会坚持下去。如果您想知道为什么我建议使用空格来分隔两个太近的和弦:为了使整个解决方案保持简单,否则我会建议使用来自H.B.s AnswerInlineUIContainer 但我很确定你d 然后必须自己处理所有编辑功能(键入退格键或 del 或使用上下文菜单剪切)
  • @drasto ... (续) 再次阅读您的问题后,我想知道“漂亮的打印”是否可以(因为这意味着您会在这些和弦下的歌词之间有很大的空间)
  • @Markus 请慢慢来!不用着急,即使这样也很棒-如果您愿意或根本不回答,您可以半年回答我。你的建议已经非常有用了。我又回到了编辑工作中——当有 2 个和弦太近时,我已经在使用 InlineContainer 插入自定义宽度空间......它似乎工作得很好。 InlineContainer 在自动按退格键或 del 后被删除。移动插入符号就好像它只是一个常规空间更加棘手:我现在要做的是:当InlineContainer 中的组件获取..
【解决方案2】:

我不能给你任何具体的帮助,但在架构方面你需要改变你的布局

到这里

其他一切都是 hack。您的单位/字形必须成为单词和弦对。


编辑:我一直在玩弄模板化的 ItemsControl,它甚至在某种程度上也能奏效,所以它可能很有趣。

<ItemsControl Grid.IsSharedSizeScope="True" ItemsSource="{Binding SheetData}"
              Name="_chordEditor">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <WrapPanel/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition SharedSizeGroup="A" Height="Auto"/>
                    <RowDefinition SharedSizeGroup="B" Height="Auto"/>
                </Grid.RowDefinitions>
                <Grid.Children>
                    <TextBox Name="chordTB" Grid.Row="0" Text="{Binding Chord}"/>
                    <TextBox Name="wordTB"  Grid.Row="1" Text="{Binding Word}"
                             PreviewKeyDown="Glyph_Word_KeyDown" TextChanged="Glyph_Word_TextChanged"/>
                </Grid.Children>
            </Grid>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>
private readonly ObservableCollection<ChordWordPair> _sheetData = new ObservableCollection<ChordWordPair>();
public ObservableCollection<ChordWordPair> SheetData
{
    get { return _sheetData; }
}
public class ChordWordPair: INotifyPropertyChanged
{
    private string _chord = String.Empty;
    public string Chord
    {
        get { return _chord; }
        set
        {
            if (_chord != value)
            {
                _chord = value;
                // This uses some reflection extension method,
                // a normal event raising method would do just fine.
                PropertyChanged.Notify(() => this.Chord);
            }
        }
    }

    private string _word = String.Empty;
    public string Word
    {
        get { return _word; }
        set
        {
            if (_word != value)
            {
                _word = value;
                PropertyChanged.Notify(() => this.Word);
            }
        }
    }

    public ChordWordPair() { }
    public ChordWordPair(string word, string chord)
    {
        Word = word;
        Chord = chord;
    }

    public event PropertyChangedEventHandler PropertyChanged;
}
private void AddNewGlyph(string text, int index)
{
    var glyph = new ChordWordPair(text, String.Empty);
    SheetData.Insert(index, glyph);
    FocusGlyphTextBox(glyph, false);
}

private void FocusGlyphTextBox(ChordWordPair glyph, bool moveCaretToEnd)
{
    var cp = _chordEditor.ItemContainerGenerator.ContainerFromItem(glyph) as ContentPresenter;
    Action focusAction = () =>
    {
        var grid = VisualTreeHelper.GetChild(cp, 0) as Grid;
        var wordTB = grid.Children[1] as TextBox;
        Keyboard.Focus(wordTB);
        if (moveCaretToEnd)
        {
            wordTB.CaretIndex = int.MaxValue;
        }
    };
    if (!cp.IsLoaded)
    {
        cp.Loaded += (s, e) => focusAction.Invoke();
    }
    else
    {
        focusAction.Invoke();
    }
}

private void Glyph_Word_TextChanged(object sender, TextChangedEventArgs e)
{
    var glyph = (sender as FrameworkElement).DataContext as ChordWordPair;
    var tb = sender as TextBox;

    string[] glyphs = tb.Text.Split(' ');
    if (glyphs.Length > 1)
    {
        glyph.Word = glyphs[0];
        for (int i = 1; i < glyphs.Length; i++)
        {
            AddNewGlyph(glyphs[i], SheetData.IndexOf(glyph) + i);
        }
    }
}

private void Glyph_Word_KeyDown(object sender, KeyEventArgs e)
{
    var tb = sender as TextBox;
    var glyph = (sender as FrameworkElement).DataContext as ChordWordPair;

    if (e.Key == Key.Left && tb.CaretIndex == 0 || e.Key == Key.Back && tb.Text == String.Empty)
    {
        int i = SheetData.IndexOf(glyph);
        if (i > 0)
        {
            var leftGlyph = SheetData[i - 1];
            FocusGlyphTextBox(leftGlyph, true);
            e.Handled = true;
            if (e.Key == Key.Back) SheetData.Remove(glyph);
        }
    }
    if (e.Key == Key.Right && tb.CaretIndex == tb.Text.Length)
    {
        int i = SheetData.IndexOf(glyph);
        if (i < SheetData.Count - 1)
        {
            var rightGlyph = SheetData[i + 1];
            FocusGlyphTextBox(rightGlyph, false);
            e.Handled = true;
        }
    }
}

最初应该将一些字形添加到集合中,否则将没有输入字段(这可以通过进一步的模板来避免,例如,如果集合为空,则使用显示字段的数据触发器)。

完善这一点需要大量额外的工作,例如设置文本框的样式、添加书面换行符(现在它只在换行面板生成时才中断)、支持跨多个文本框的选择等。

【讨论】:

  • H.B. +1。感谢您提供漂亮的可视化效果,它可能对其他人有用。我非常了解我需要这样做,但我只是不知道如何使用FlowDocument 来做。我什至不知道从哪里开始。如何创建那些双线块?好吧,我可以将这些块创建为Figures,它将包含Paragraph 2 行。但是当同一个词有和弦时,这不起作用。您必须在 2 个块之间吐出单词,并且边界处的 2 个字母之间不会有字距调整。这是原始问题中提出的字距调整问题。
  • H.B.:所以这通常对我来说意味着缺少我需要实现的元素。在这种情况下,实现自定义Inline。但是没有关于如何做到这一点的文档/块/教程。至少我找不到。是否完全支持场景?
  • 我为如何使用模板化的 ItemsControl 和重要的导航逻辑来近似事物的方法添加了一个草稿。
  • 多么痛苦,幸好我不必编写这样的应用程序:P
  • 前段时间我已经看到并支持了这个问题。不过,弄乱字体很痛苦,我不能仅凭经验回答这个问题,也许其他人可以帮助你。
猜你喜欢
  • 2016-05-07
  • 2011-07-24
  • 2011-12-23
  • 2010-10-08
  • 2011-06-22
  • 2010-11-05
  • 2019-03-01
  • 2018-10-10
相关资源
最近更新 更多