I’m working on a simple text preprocessor to allow for simple markdown-esque formatting
I have a “preview” mode I can choose on the component that keeps the source text, but applies the formatting (ala discord text editing)
When in preview mode, the visible characters aren’t any different than without the MarkdownText component, so I was hoping I could use the “Preview” mode on an input fields text component without issue.
Unfortunately the hidden rich text tags generated by the MarkdownText component seem to mess with the InputField’s logic. It looks fine, but I can’t select all the characters. In the following gif I am unable to move the caret past the second ‘L’ in “Hello!” and when I try to delete or type characters it’s offset from the caret, and the input field often throws an argument out of range exception.
Is there something I need to do differently on my end, or does the input field class need to be extended/modified to support text preprocessing? I can see how a text preprocessor changing the visible characters from an input field would break things, but in this case I’m only adding un-rendered tag characters so I’d love to get this working if possible!
Here’s the exception:
ArgumentOutOfRangeException: Index and count must refer to a location within the string.
Parameter name: count
System.String.Remove (System.Int32 startIndex, System.Int32 count) (at <695d1cc93cca45069c528c15c9fdd749>:0)
TMPro.TMP_InputField.DeleteKey () (at Library/PackageCache/com.unity.textmeshpro@3.0.6/Scripts/Runtime/TMP_InputField.cs:2911)
TMPro.TMP_InputField.KeyPressed (UnityEngine.Event evt) (at Library/PackageCache/com.unity.textmeshpro@3.0.6/Scripts/Runtime/TMP_InputField.cs:1924)
TMPro.TMP_InputField.OnUpdateSelected (UnityEngine.EventSystems.BaseEventData eventData) (at Library/PackageCache/com.unity.textmeshpro@3.0.6/Scripts/Runtime/TMP_InputField.cs:2155)
UnityEngine.EventSystems.ExecuteEvents.Execute (UnityEngine.EventSystems.IUpdateSelectedHandler handler, UnityEngine.EventSystems.BaseEventData eventData) (at C:/Program Files/Unity/Hub/Editor/2020.3.5f1/Editor/Data/Resources/PackageManager/BuiltInPackages/com.unity.ugui/Runtime/EventSystem/ExecuteEvents.cs:99)
UnityEngine.EventSystems.ExecuteEvents.Execute[T] (UnityEngine.GameObject target, UnityEngine.EventSystems.BaseEventData eventData, UnityEngine.EventSystems.ExecuteEvents+EventFunction`1[T1] functor) (at C:/Program Files/Unity/Hub/Editor/2020.3.5f1/Editor/Data/Resources/PackageManager/BuiltInPackages/com.unity.ugui/Runtime/EventSystem/ExecuteEvents.cs:262)
UnityEngine.EventSystems.EventSystem:Update() (at C:/Program Files/Unity/Hub/Editor/2020.3.5f1/Editor/Data/Resources/PackageManager/BuiltInPackages/com.unity.ugui/Runtime/EventSystem/EventSystem.cs:385)
And here’s the markdown script if it’s helpful to look at. Just some nooby regex and string manipulation
using System;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.EventSystems;
using TMPro;
[ExecuteAlways]
[RequireComponent(typeof(TMP_Text))]
public class MarkdownText : MonoBehaviour, IPointerClickHandler, ITextPreprocessor, ICanvasRaycastFilter
{
public bool link = true;
public bool emoji = true;
public enum StyleMode { None, Preview, Replace }
public StyleMode style = StyleMode.None;
private TMP_Text _text;
public TMP_Text text
{
get
{
if (_text == null)
TryGetComponent(out _text);
return _text;
}
}
private void OnEnable()
{
text.textPreprocessor = this;
text.richText = true;
}
private void OnDisable()
{
text.textPreprocessor = null;
}
private void OnValidate()
{
text.SetAllDirty();
Canvas.ForceUpdateCanvases();
}
private static readonly Regex superlinkRegex = new Regex("(\\[.*\\])(\\(\\S+\\))|((http:\\/\\/|https:\\/\\/|www\\.)([A-Z0-9.-:]{1,})\\.[0-9A-Z?;~&#=\\-_\\.\\/]{2,})", RegexOptions.IgnoreCase | RegexOptions.Singleline);
private static readonly Regex emojiRegex = new Regex(":[\\w|-]+:");
private static readonly Regex boldRegex = new Regex("\\*\\*(.*)\\*\\*");
private static readonly Regex itallicRegex = new Regex("\\*(.*)\\*");
private static readonly Regex strikeRegex = new Regex("\\~\\~(.*)\\~\\~");
private static readonly Regex underlineRegex = new Regex("__([^_]*)__");
public string PreprocessText(string text)
{
// LINK
if (link)
{
MatchCollection matches = superlinkRegex.Matches(text);
foreach (Match match in matches)
{
// 0th index is full match
// 1st index hyperlink name
// 2nd index is url
string name = match.Groups[1].Value;
string link = match.Groups[2].Value;
// If it's a plain link
if (string.IsNullOrEmpty(name) || string.IsNullOrWhiteSpace(name))
{
text = text.Replace(match.Value, ShortLink(match.Value));
}
// Otherwise it's a hyperlink
else
{
name = name.Trim('[', ']');
link = link.Trim('(', ')');
text = text.Replace(match.Value, $"<#{ColorUtility.ToHtmlStringRGB(Color.blue)}><u><link=\"{link}\">{name}</link></u></color>");
}
}
}
if (emoji)
{
// EMOJI
MatchCollection matches = emojiRegex.Matches(text);
foreach (Match match in matches)
{
var emojiName = match.Value;
// remove ':' from beginning and end
emojiName = emojiName.Remove(0, 1);
emojiName = emojiName.Remove(emojiName.Length - 1, 1);
// Make sure sprite actually exists
if (EmojiExists(emojiName))
{
// replace emoji text with sprite tag
text = text.Replace(match.Value, $"<sprite name=\"{emojiName}\">");
}
}
}
if (style != StyleMode.None)
{
MatchCollection matches = boldRegex.Matches(text);
foreach (Match match in matches)
{
switch (style)
{
case StyleMode.Preview:
text = text.Replace(match.Value, $"<b>{match.Value}</b>");
break;
case StyleMode.Replace:
var boldText = match.Value;
boldText = boldText.Remove(0, 2);
boldText = boldText.Remove(boldText.Length - 2, 2);
text = text.Replace(match.Value, $"<b>{boldText}</b>");
break;
}
}
matches = itallicRegex.Matches(text);
foreach (Match match in matches)
{
switch (style)
{
case StyleMode.Preview:
text = text.Replace(match.Value, $"<i>{match.Value}</i>");
break;
case StyleMode.Replace:
var boldText = match.Value;
boldText = boldText.Remove(0, 1);
boldText = boldText.Remove(boldText.Length - 1, 1);
text = text.Replace(match.Value, $"<i>{boldText}</i>");
break;
}
}
matches = strikeRegex.Matches(text);
foreach (Match match in matches)
{
switch (style)
{
case StyleMode.Preview:
text = text.Replace(match.Value, $"<s>{match.Value}</s>");
break;
case StyleMode.Replace:
var boldText = match.Value;
boldText = boldText.Remove(0, 2);
boldText = boldText.Remove(boldText.Length - 2, 2);
text = text.Replace(match.Value, $"<s>{boldText}</s>");
break;
}
}
matches = underlineRegex.Matches(text);
foreach (Match match in matches)
{
switch (style)
{
case StyleMode.Preview:
text = text.Replace(match.Value, $"<u>{match.Value}</u>");
break;
case StyleMode.Replace:
var boldText = match.Value;
boldText = boldText.Remove(0, 2);
boldText = boldText.Remove(boldText.Length - 2, 2);
text = text.Replace(match.Value, $"<u>{boldText}</u>");
break;
}
}
}
return text;
}
public void OnPointerClick(PointerEventData eventData)
{
if (PointerIsOverURL(eventData, out int linkIndex))
{
TMP_LinkInfo linkInfo = text.textInfo.linkInfo[linkIndex];
string selectedLink = linkInfo.GetLinkID();
if (!string.IsNullOrEmpty(selectedLink))
Application.OpenURL(selectedLink);
}
}
public bool PointerIsOverURL(Vector2 screenPosition, Camera camera, out int linkIndex)
{
linkIndex = TMP_TextUtilities.FindIntersectingLink (text, screenPosition, camera);
return linkIndex != -1;
}
public bool PointerIsOverURL(PointerEventData eventData, out int linkIndex) => PointerIsOverURL(eventData.position, eventData.pressEventCamera, out linkIndex);
private string ShortLink (in string link, int maxLength = 35)
{
string text = link;
// This is definitely the optimal way to do string operations!
// I am a sculptor and strings are my clay! /s
const string www = "www.";
int wwwIndex = text.IndexOf(www, StringComparison.InvariantCulture);
if (wwwIndex >= 0)
text = text.Remove(0, wwwIndex + www.Length);
else
{
const string http = "://";
int httpIndex = text.IndexOf(http, StringComparison.InvariantCulture);
if (httpIndex >= 0)
text = text.Remove(0, httpIndex + http.Length);
}
const string ellipsis = "...";
if (text.Length > maxLength - ellipsis.Length)
text = $"{text.Substring(0, maxLength - ellipsis.Length)}{ellipsis}";
return string.Format($"<#{ColorUtility.ToHtmlStringRGB(Color.blue)}><u><link=\"{link}\">{text}</link></u></color>");
}
public bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera)
{
return PointerIsOverURL(sp, eventCamera, out _);
}
private bool EmojiExists(string name)
{
TMP_SpriteAsset spriteAsset = text.spriteAsset;
if (spriteAsset == null)
spriteAsset = TMP_Settings.GetSpriteAsset();
if (spriteAsset == null)
return false;
int spriteIndex = spriteAsset.GetSpriteIndexFromName(name);
if (spriteIndex == -1)
{
foreach (var fallback in spriteAsset.fallbackSpriteAssets)
{
spriteIndex = fallback.GetSpriteIndexFromName(name);
if (spriteIndex != -1)
return true;
}
}
else
return true;
return false;
}
}