From bfa603d55a291494623f88017401e57e5313e329 Mon Sep 17 00:00:00 2001 From: Anna Clemens Date: Sun, 7 Feb 2021 00:17:12 -0500 Subject: [PATCH] refactor: use fork of MdXaml for Markdown --- .gitmodules | 3 + MdXaml | 1 + XIVChat Desktop/MainWindow.xaml | 6 + XIVChat Desktop/MainWindow.xaml.cs | 30 ++ XIVChat Desktop/Markdown.cs | 448 ------------------------- XIVChat Desktop/MessageFormatter.cs | 8 +- XIVChat Desktop/XIVChat Desktop.csproj | 1 + XIVChat.sln | 6 + 8 files changed, 53 insertions(+), 450 deletions(-) create mode 100644 .gitmodules create mode 160000 MdXaml delete mode 100644 XIVChat Desktop/Markdown.cs diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..474dce1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "MdXaml"] + path = MdXaml + url = https://github.com/ascclemens/MdXaml diff --git a/MdXaml b/MdXaml new file mode 160000 index 0000000..e2b314e --- /dev/null +++ b/MdXaml @@ -0,0 +1 @@ +Subproject commit e2b314e3ab37d7ba492d65653538f5e622380da1 diff --git a/XIVChat Desktop/MainWindow.xaml b/XIVChat Desktop/MainWindow.xaml index ab1074f..a5d3b7c 100644 --- a/XIVChat Desktop/MainWindow.xaml +++ b/XIVChat Desktop/MainWindow.xaml @@ -22,6 +22,12 @@ SourceInitialized="MainWindow_OnSourceInitialized" Closing="MainWindow_OnClosing" d:DataContext="{d:DesignInstance local:MainWindow}"> + + + + diff --git a/XIVChat Desktop/MainWindow.xaml.cs b/XIVChat Desktop/MainWindow.xaml.cs index 53d5bbf..ff33e10 100644 --- a/XIVChat Desktop/MainWindow.xaml.cs +++ b/XIVChat Desktop/MainWindow.xaml.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; +using System.Diagnostics; +using System.Net; using System.Runtime.CompilerServices; using System.Text; using System.Windows; @@ -123,6 +125,34 @@ namespace XIVChat_Desktop { this.App.Connection?.ChangeChannel(param); } + public static readonly RoutedUICommand OpenLink = new( + "OpenLink", + "OpenLink", + typeof(MainWindow) + ); + + private void OpenLink_CanExecute(object sender, CanExecuteRoutedEventArgs e) { + e.CanExecute = true; + } + + private void OpenLink_Execute(object sender, ExecutedRoutedEventArgs e) { + if (!(e.Parameter is string param)) { + return; + } + + try { + var uri = new Uri(param); + if (uri.Scheme == "http" || uri.Scheme == "https") { + Process.Start(new ProcessStartInfo { + FileName = uri.ToString(), + UseShellExecute = true, + }); + } + } catch (Exception ex) { + Console.WriteLine(ex.ToString()); + } + } + #endregion public App App => (App) Application.Current; diff --git a/XIVChat Desktop/Markdown.cs b/XIVChat Desktop/Markdown.cs deleted file mode 100644 index 97d43ad..0000000 --- a/XIVChat Desktop/Markdown.cs +++ /dev/null @@ -1,448 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Windows; -using System.Windows.Documents; - -namespace XIVChat_Desktop { - public static class Markdown { - private readonly static char[] Delimiters = { - '*', '_', '~', - }; - - public static IEnumerable MarkdownToInlines(string input) { - var nodes = ParseNodes(input); - return NodesToInlines(nodes); - } - - private static ContainerNode ParseNodes(string input) { - var root = new ContainerNode(); - DelimiterNode? run = null; - char? last = null; - var escaping = false; - - var segment = new StringBuilder(); - - void AddTextRun() { - if (segment.Length == 0) { - return; - } - - root.AddChild(new TextNode(root, segment.ToString())); - segment.Clear(); - } - - void ProcessExistingRun() { - // determine the flank - run.DetermineFlank(); - - // if flank is still unknown, this is an invalid run - var isUnknown = run.flank == DelimiterNode.Flank.Unknown; - // also if strikethrough does not contain two delimiters, this is an invalid run - var notLongEnough = run.character == '~' && run.length < 2; - if (isUnknown || notLongEnough) { - // return these characters to the string - for (var i = 0; i < run.length; i++) { - segment.Append(run.character); - } - - // remove the run - goto RemoveRun; - } - - // at this point, we're going to add the run, so also add the text if necessary - AddTextRun(); - - // add the run to the list - root.AddChild(run); - - RemoveRun: - // remove the run from working area - run = null; - } - - var chars = input.ToCharArray(); - foreach (var c in chars) { - var append = true; - - if (c == '\\' && !escaping) { - escaping = true; - append = false; - } - - // these characters can form delimiter runs - if (Delimiters.Contains(c) && !escaping) { - // don't add this character to the text segment - append = false; - - if (run != null && run.character != c) { - run.following = c; - ProcessExistingRun(); - } - - // create a run if necessary - run ??= new DelimiterNode(root, c) { - preceding = last, - length = 0, - originalLength = 0, - }; - - // increase the run's length - run.length += 1; - run.originalLength += 1; - - // skip to the end - goto Finish; - } - - // the last character was a delimiter but this character is not - // we know that this character is not a delimiter because of the goto - if (run != null && last != null && Delimiters.Contains((char)last)) { - // set the following character to this character - run.following = c; - - ProcessExistingRun(); - } - - Finish: - last = c; - if (!append) { - continue; - } - - if (escaping) { - escaping = false; - - if (!c.IsAsciiPunctuation()) { - segment.Append('\\'); - } - } - - segment.Append(c); - } - - // re-add backslashes as final character - if (escaping) { - segment.Append('\\'); - } - - // if we ended on a run, process it - if (run != null) { - ProcessExistingRun(); - } - - AddTextRun(); - - return root; - } - - private static IEnumerable NodesToInlines(MarkdownNodeWithChildren root) { - var openersBottom = new Dictionary?>>(); - for (var i = 0; i < 3; i++) { - openersBottom[i] = new Dictionary?>(); - foreach (var c in Delimiters) { - openersBottom[i][c] = null; - } - } - - var delims = new LinkedList(); - var node = root.Children.First; - while (node != null) { - if (node.Value is DelimiterNode dRun) { - delims.AddLast(dRun); - } - - node = node.Next; - } - - // no emphasis to process, so just return all nodes as-is - if (delims.Count == 0) { - return root.Children.Select(child => child.ToInline()); - } - - // move forward looking for closers - var closer = delims.First; - while (closer != null) { - if (!closer.Value.CanClose) { - closer = closer.Next; - continue; - } - - // found first emphasis closer. now look back for first matching opener - var opener = closer.Previous; - var openerFound = false; - var bottom = openersBottom[closer.Value.originalLength % 3][closer.Value.character]; - while (opener != null && opener != bottom) { - var oddMatch = - (closer.Value.CanOpen || opener.Value.CanClose) && - closer.Value.originalLength % 3 != 0 && - (opener.Value.originalLength + closer.Value.originalLength) % 3 == 0; - if (opener.Value.character == closer.Value.character && opener.Value.CanOpen && !oddMatch) { - openerFound = true; - break; - } - - opener = opener.Previous; - } - - var oldCloser = closer; - - // emphasis and strikethrough - // https://spec.commonmark.org/0.29/#emphasis-and-strong-emphasis - // https://github.github.com/gfm/#strikethrough-extension- - if (Delimiters.Contains(closer.Value.character)) { - if (!openerFound) { - closer = closer.Next; - } else { - if (opener == null) { - throw new InvalidOperationException(); - } - - MarkdownNodeWithChildren emph; - - var isStrike = closer.Value.character == '~'; - if (isStrike) { - opener.Value.length -= 2; - closer.Value.length -= 2; - - emph = new StrikethroughNode(root); - } else { - var useDelims = closer.Value.length >= 2 && opener.Value.length >= 2 ? 2 : 1; - - opener.Value.length -= useDelims; - closer.Value.length -= useDelims; - - emph = useDelims == 1 ? (MarkdownNodeWithChildren)new EmphasisNode(root) : new StrongNode(root); - } - - var runsOpener = root.Children.Find(opener.Value); - var runsCloser = root.Children.Find(closer.Value); - var tmp = runsOpener!.Next; - while (tmp != null && tmp != runsCloser) { - emph.AddChild(tmp.Value); - - var oldTmp = tmp; - tmp = tmp.Next; - root.Children.Remove(oldTmp); - if (oldTmp.Value is DelimiterNode dNode) { - delims.Remove(dNode); - } - } - - root.Children.AddAfter(runsOpener, emph); - - if (opener.Value.length == 0) { - delims.Remove(opener); - root.Children.Remove(runsOpener); - } - - if (closer.Value.length == 0) { - closer = closer.Next; - delims.Remove(oldCloser); - root.Children.Remove(runsCloser!); - } - } - } - - if (openerFound) { - continue; - } - - openersBottom[oldCloser.Value.originalLength % 3][oldCloser.Value.character] = oldCloser.Previous; - - if (!oldCloser.Value.CanOpen) { - delims.Remove(oldCloser); - } - } - - return root.Children.Select(child => child.ToInline()); - } - } - - public abstract class MarkdownNode { - public MarkdownNode? Parent { get; set; } - - protected MarkdownNode(MarkdownNode? parent = null) { - this.Parent = parent; - } - - public abstract Inline ToInline(); - } - - public class TextNode : MarkdownNode { - public string Text { get; set; } - - public TextNode(MarkdownNode parent, string text) : base(parent) { - this.Text = text; - } - - public override Inline ToInline() => new Run(this.Text); - } - - public class DelimiterNode : MarkdownNode { - public enum Flank { - Unknown, - Left, - Right, - Both, - } - - public readonly char character; - public char? preceding; - public char? following; - public int originalLength; - public int length; - public Flank flank = Flank.Unknown; - - public bool CanOpen => this.CalcCanOpen(); - public bool CanClose => this.CalcCanClose(); - - public DelimiterNode(MarkdownNode parent, char character) : base(parent) { - this.character = character; - } - - public override Inline ToInline() { - var text = new StringBuilder(this.length); - - for (var i = 0; i < this.length; i++) { - text.Append(this.character); - } - - return new Run(text.ToString()); - } - - private bool CalcCanOpen() { - switch (this.character) { - case '*' when (this.flank == Flank.Left || this.flank == Flank.Both): - case '_' when (this.flank == Flank.Left || (this.flank == Flank.Both && this.preceding?.IsAsciiPunctuation() == true)): - case '~' when (this.length >= 2 && (this.flank == Flank.Left || this.flank == Flank.Both)): - return true; - default: - return false; - } - } - - private bool CalcCanClose() { - switch (this.character) { - case '*' when (this.flank == Flank.Right || this.flank == Flank.Both): - case '_' when (this.flank == Flank.Right || (this.flank == Flank.Both && this.following?.IsAsciiPunctuation() == true)): - case '~' when (this.length >= 2 && (this.flank == Flank.Right || this.flank == Flank.Both)): - return true; - default: - return false; - } - } - - public void DetermineFlank() { - var followedByWhitespace = this.following?.IsWhitespace() ?? true; - var followedByPunctuation = this.following?.IsAsciiPunctuation() ?? false; - var precededByWhitespace = this.preceding?.IsWhitespace() ?? true; - var precededByPunctuation = this.preceding?.IsAsciiPunctuation() ?? false; - - var isLeft = false; - var isRight = false; - - // A left-flanking delimiter run is a delimiter run that is (1) not followed by Unicode whitespace, and - // either (2a) not followed by a punctuation character, or (2b) followed by a punctuation character and - // preceded by Unicode whitespace or a punctuation character. For purposes of this definition, the beginning - // and the end of the line count as Unicode whitespace. - if (!followedByWhitespace && (!followedByPunctuation || (followedByPunctuation && (precededByWhitespace || precededByPunctuation)))) { - isLeft = true; - } - - // A right-flanking delimiter run is a delimiter run that is (1) not preceded by Unicode whitespace, and - // either (2a) not preceded by a punctuation character, or (2b) preceded by a punctuation character and - // followed by Unicode whitespace or a punctuation character. For purposes of this definition, the beginning - // and the end of the line count as Unicode whitespace. - if (!precededByWhitespace && (!precededByPunctuation || (precededByPunctuation && (followedByWhitespace || followedByPunctuation)))) { - isRight = true; - } - - if (isLeft && isRight) { - this.flank = Flank.Both; - } else if (isLeft) { - this.flank = Flank.Left; - } else if (isRight) { - this.flank = Flank.Right; - } - } - } - - public class EmphasisNode : MarkdownNodeWithChildren { - public EmphasisNode(MarkdownNode parent) : base(parent) { } - - public override Inline ToInline() { - var span = new Span { - FontStyle = FontStyles.Italic, - }; - span.Inlines.AddRange(this.Children.Select(child => child.ToInline())); - return span; - } - } - - public class StrongNode : MarkdownNodeWithChildren { - public StrongNode(MarkdownNode parent) : base(parent) { } - - public override Inline ToInline() { - var span = new Span { - FontWeight = FontWeights.Bold, - }; - span.Inlines.AddRange(this.Children.Select(child => child.ToInline())); - return span; - } - } - - public class StrikethroughNode : MarkdownNodeWithChildren { - public StrikethroughNode(MarkdownNode parent) : base(parent) { } - - public override Inline ToInline() { - var span = new Span(); - span.TextDecorations.Add(TextDecorations.Strikethrough); - span.Inlines.AddRange(this.Children.Select(child => child.ToInline())); - return span; - } - } - - public abstract class MarkdownNodeWithChildren : MarkdownNode { - public LinkedList Children { get; } = new LinkedList(); - - protected MarkdownNodeWithChildren(MarkdownNode? parent) : base(parent) { } - - public void AddChild(MarkdownNode node) { - node.Parent = this; - this.Children.AddLast(node); - } - - /// - /// Replace this node with its children in its parent's children. - /// - public void PopUp() { - if (!(this.Parent is MarkdownNodeWithChildren parent)) { - return; - } - - var us = parent.Children.Find(this); - - if (us == null) { - return; - } - - foreach (var child in this.Children) { - child.Parent = parent; - parent.Children.AddBefore(us, child); - parent.Children.Remove(us); - } - } - } - - public class ContainerNode : MarkdownNodeWithChildren { - public ContainerNode(MarkdownNode? parent = null) : base(parent) { } - - public override Inline ToInline() { - var span = new Span(); - span.Inlines.AddRange(this.Children.Select(child => child.ToInline())); - return span; - } - } -} diff --git a/XIVChat Desktop/MessageFormatter.cs b/XIVChat Desktop/MessageFormatter.cs index cb3de7d..9d5368f 100644 --- a/XIVChat Desktop/MessageFormatter.cs +++ b/XIVChat Desktop/MessageFormatter.cs @@ -6,12 +6,16 @@ using System.Windows.Controls; using System.Windows.Documents; using System.Windows.Media; using System.Windows.Media.Imaging; +using MdXaml; using XIVChatCommon.Message; using XIVChatCommon.Message.Server; namespace XIVChat_Desktop { - public class MessageFormatter { + public static class MessageFormatter { private static readonly BitmapFrame FontIcon = BitmapFrame.Create(new Uri("pack://application:,,,/Resources/fonticon_ps4.tex.png")); + private static readonly Markdown Markdown = new() { + HyperlinkCommand = MainWindow.OpenLink, + }; public static IEnumerable ChunksToTextBlock(ServerMessage message, double lineHeight, bool processMarkdown, bool showTimestamp) { var elements = new List(); @@ -37,7 +41,7 @@ namespace XIVChat_Desktop { var style = textChunk.Italic ? FontStyles.Italic : FontStyles.Normal; if (processMarkdown) { - var inlines = Markdown.MarkdownToInlines(textChunk.Content); + var inlines = Markdown.RunSpanGamut(textChunk.Content); foreach (var inline in inlines) { inline.Foreground = brush; diff --git a/XIVChat Desktop/XIVChat Desktop.csproj b/XIVChat Desktop/XIVChat Desktop.csproj index 3ec5e00..6791c88 100644 --- a/XIVChat Desktop/XIVChat Desktop.csproj +++ b/XIVChat Desktop/XIVChat Desktop.csproj @@ -70,6 +70,7 @@ + diff --git a/XIVChat.sln b/XIVChat.sln index 06aa7c4..6eeab5e 100644 --- a/XIVChat.sln +++ b/XIVChat.sln @@ -13,6 +13,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XIVChat Desktop", "XIVChat EndProject Project("{930C7802-8A8C-48F9-8165-68863BCCD9DD}") = "XIVChat Desktop Installer", "XIVChat Desktop Installer\XIVChat Desktop Installer.wixproj", "{6017ABE2-E82F-476D-B3DA-18B634E092F2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MdXaml", "MdXaml\MdXaml\MdXaml.csproj", "{084B2C7D-8C0B-4774-A6E1-D804EE1A6D24}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {D2773EAE-6B9E-4017-9681-585AAB5A99F4}.Release|Any CPU.Build.0 = Release|Any CPU {6017ABE2-E82F-476D-B3DA-18B634E092F2}.Debug|Any CPU.ActiveCfg = Debug|x64 {6017ABE2-E82F-476D-B3DA-18B634E092F2}.Release|Any CPU.ActiveCfg = Release|x64 + {084B2C7D-8C0B-4774-A6E1-D804EE1A6D24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {084B2C7D-8C0B-4774-A6E1-D804EE1A6D24}.Debug|Any CPU.Build.0 = Debug|Any CPU + {084B2C7D-8C0B-4774-A6E1-D804EE1A6D24}.Release|Any CPU.ActiveCfg = Release|Any CPU + {084B2C7D-8C0B-4774-A6E1-D804EE1A6D24}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE