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