Search Unity

Question Using text preprocessor on input field to preview markdown styling

Discussion in 'UGUI & TextMesh Pro' started by keenanwoodall, Apr 29, 2021.

  1. keenanwoodall

    keenanwoodall

    Joined:
    May 30, 2014
    Posts:
    598
    I'm working on a simple text preprocessor to allow for simple markdown-esque formatting
    upload_2021-4-28_16-33-24.png

    I have a "preview" mode I can choose on the component that keeps the source text, but applies the formatting (ala discord text editing)
    upload_2021-4-28_16-34-3.png

    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.

    t1RFc3exyU.gif

    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 :p
    Code (csharp):
    1.  
    2. using System;
    3. using System.Text.RegularExpressions;
    4. using UnityEngine;
    5. using UnityEngine.EventSystems;
    6. using TMPro;
    7.  
    8. [ExecuteAlways]
    9. [RequireComponent(typeof(TMP_Text))]
    10. public class MarkdownText : MonoBehaviour, IPointerClickHandler, ITextPreprocessor, ICanvasRaycastFilter
    11. {
    12.    public bool link = true;
    13.    public bool emoji = true;
    14.    public enum StyleMode { None, Preview, Replace }
    15.    public StyleMode style = StyleMode.None;
    16.    
    17.    private TMP_Text _text;
    18.    public TMP_Text text
    19.    {
    20.       get
    21.       {
    22.          if (_text == null)
    23.             TryGetComponent(out _text);
    24.          return _text;
    25.       }
    26.    }
    27.  
    28.    private void OnEnable()
    29.    {
    30.       text.textPreprocessor = this;
    31.       text.richText = true;
    32.    }
    33.  
    34.    private void OnDisable()
    35.    {
    36.       text.textPreprocessor = null;
    37.    }
    38.  
    39.    private void OnValidate()
    40.    {
    41.       text.SetAllDirty();
    42.       Canvas.ForceUpdateCanvases();
    43.    }
    44.  
    45.    private static readonly Regex superlinkRegex = new Regex("(\\[.*\\])(\\(\\S+\\))|((http:\\/\\/|https:\\/\\/|www\\.)([A-Z0-9.-:]{1,})\\.[0-9A-Z?;~&#=\\-_\\.\\/]{2,})", RegexOptions.IgnoreCase | RegexOptions.Singleline);
    46.    private static readonly Regex emojiRegex = new Regex(":[\\w|-]+:");
    47.    private static readonly Regex boldRegex = new Regex("\\*\\*(.*)\\*\\*");
    48.    private static readonly Regex itallicRegex = new Regex("\\*(.*)\\*");
    49.    private static readonly Regex strikeRegex = new Regex("\\~\\~(.*)\\~\\~");
    50.    private static readonly Regex underlineRegex = new Regex("__([^_]*)__");
    51.    public string PreprocessText(string text)
    52.    {
    53.       // LINK
    54.       if (link)
    55.       {
    56.          MatchCollection matches = superlinkRegex.Matches(text);
    57.          foreach (Match match in matches)
    58.          {
    59.             // 0th index is full match
    60.             // 1st index hyperlink name
    61.             // 2nd index is url
    62.             string name = match.Groups[1].Value;
    63.             string link = match.Groups[2].Value;
    64.             // If it's a plain link
    65.             if (string.IsNullOrEmpty(name) || string.IsNullOrWhiteSpace(name))
    66.             {
    67.                text = text.Replace(match.Value, ShortLink(match.Value));
    68.             }
    69.             // Otherwise it's a hyperlink
    70.             else
    71.             {
    72.                name = name.Trim('[', ']');
    73.                link = link.Trim('(', ')');
    74.                text = text.Replace(match.Value, $"<#{ColorUtility.ToHtmlStringRGB(Color.blue)}><u><link=\"{link}\">{name}</link></u></color>");
    75.             }
    76.          }
    77.       }
    78.  
    79.       if (emoji)
    80.       {
    81.          // EMOJI
    82.          MatchCollection matches = emojiRegex.Matches(text);
    83.          foreach (Match match in matches)
    84.          {
    85.             var emojiName = match.Value;
    86.             // remove ':' from beginning and end
    87.             emojiName = emojiName.Remove(0, 1);
    88.             emojiName = emojiName.Remove(emojiName.Length - 1, 1);
    89.  
    90.             // Make sure sprite actually exists
    91.             if (EmojiExists(emojiName))
    92.             {
    93.                // replace emoji text with sprite tag
    94.                text = text.Replace(match.Value, $"<sprite name=\"{emojiName}\">");
    95.             }
    96.          }
    97.       }
    98.  
    99.       if (style != StyleMode.None)
    100.       {
    101.          MatchCollection matches = boldRegex.Matches(text);
    102.          foreach (Match match in matches)
    103.          {
    104.             switch (style)
    105.             {
    106.                case StyleMode.Preview:
    107.                   text = text.Replace(match.Value, $"<b>{match.Value}</b>");
    108.                   break;
    109.                case StyleMode.Replace:
    110.                   var boldText = match.Value;
    111.                   boldText = boldText.Remove(0, 2);
    112.                   boldText = boldText.Remove(boldText.Length - 2, 2);
    113.                   text = text.Replace(match.Value, $"<b>{boldText}</b>");
    114.                   break;
    115.             }
    116.          }
    117.          matches = itallicRegex.Matches(text);
    118.          foreach (Match match in matches)
    119.          {
    120.             switch (style)
    121.             {
    122.                case StyleMode.Preview:
    123.                   text = text.Replace(match.Value, $"<i>{match.Value}</i>");
    124.                   break;
    125.                case StyleMode.Replace:
    126.                   var boldText = match.Value;
    127.                   boldText = boldText.Remove(0, 1);
    128.                   boldText = boldText.Remove(boldText.Length - 1, 1);
    129.                   text = text.Replace(match.Value, $"<i>{boldText}</i>");
    130.                   break;
    131.             }
    132.          }
    133.          matches = strikeRegex.Matches(text);
    134.          foreach (Match match in matches)
    135.          {
    136.             switch (style)
    137.             {
    138.                case StyleMode.Preview:
    139.                   text = text.Replace(match.Value, $"<s>{match.Value}</s>");
    140.                   break;
    141.                case StyleMode.Replace:
    142.                   var boldText = match.Value;
    143.                   boldText = boldText.Remove(0, 2);
    144.                   boldText = boldText.Remove(boldText.Length - 2, 2);
    145.                   text = text.Replace(match.Value, $"<s>{boldText}</s>");
    146.                   break;
    147.             }
    148.          }
    149.  
    150.          matches = underlineRegex.Matches(text);
    151.          foreach (Match match in matches)
    152.          {
    153.             switch (style)
    154.             {
    155.                case StyleMode.Preview:
    156.                   text = text.Replace(match.Value, $"<u>{match.Value}</u>");
    157.                   break;
    158.                case StyleMode.Replace:
    159.                   var boldText = match.Value;
    160.                   boldText = boldText.Remove(0, 2);
    161.                   boldText = boldText.Remove(boldText.Length - 2, 2);
    162.                   text = text.Replace(match.Value, $"<u>{boldText}</u>");
    163.                   break;
    164.             }
    165.          }
    166.       }
    167.  
    168.       return text;
    169.    }
    170.  
    171.    public void OnPointerClick(PointerEventData eventData)
    172.    {
    173.       if (PointerIsOverURL(eventData, out int linkIndex))
    174.       {
    175.          TMP_LinkInfo linkInfo = text.textInfo.linkInfo[linkIndex];
    176.          string selectedLink = linkInfo.GetLinkID();
    177.          if (!string.IsNullOrEmpty(selectedLink))
    178.             Application.OpenURL(selectedLink);
    179.       }
    180.    }
    181.  
    182.    public bool PointerIsOverURL(Vector2 screenPosition, Camera camera, out int linkIndex)
    183.    {
    184.       linkIndex = TMP_TextUtilities.FindIntersectingLink (text, screenPosition, camera);
    185.       return linkIndex != -1;
    186.    }
    187.    public bool PointerIsOverURL(PointerEventData eventData, out int linkIndex) => PointerIsOverURL(eventData.position, eventData.pressEventCamera, out linkIndex);
    188.  
    189.    private string ShortLink (in string link, int maxLength = 35)
    190.    {
    191.       string text = link;
    192.       // This is definitely the optimal way to do string operations!
    193.       // I am a sculptor and strings are my clay! /s
    194.       const string www = "www.";
    195.       int wwwIndex = text.IndexOf(www, StringComparison.InvariantCulture);
    196.       if (wwwIndex >= 0)
    197.          text = text.Remove(0, wwwIndex + www.Length);
    198.       else
    199.       {
    200.          const string http = "://";
    201.          int httpIndex = text.IndexOf(http, StringComparison.InvariantCulture);
    202.          if (httpIndex >= 0)
    203.             text = text.Remove(0, httpIndex + http.Length);
    204.       }
    205.      
    206.       const string ellipsis = "...";
    207.       if (text.Length > maxLength - ellipsis.Length)
    208.          text = $"{text.Substring(0, maxLength - ellipsis.Length)}{ellipsis}";
    209.       return string.Format($"<#{ColorUtility.ToHtmlStringRGB(Color.blue)}><u><link=\"{link}\">{text}</link></u></color>");
    210.    }
    211.  
    212.    public bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera)
    213.    {
    214.       return PointerIsOverURL(sp, eventCamera, out _);
    215.    }
    216.    
    217.    private bool EmojiExists(string name)
    218.    {
    219.       TMP_SpriteAsset spriteAsset = text.spriteAsset;
    220.       if (spriteAsset == null)
    221.          spriteAsset = TMP_Settings.GetSpriteAsset();
    222.       if (spriteAsset == null)
    223.          return false;
    224.      
    225.       int spriteIndex = spriteAsset.GetSpriteIndexFromName(name);
    226.       if (spriteIndex == -1)
    227.       {
    228.          foreach (var fallback in spriteAsset.fallbackSpriteAssets)
    229.          {
    230.             spriteIndex = fallback.GetSpriteIndexFromName(name);
    231.             if (spriteIndex != -1)
    232.                return true;
    233.          }
    234.       }
    235.       else
    236.          return true;
    237.  
    238.       return false;
    239.    }
    240. }
    241.  
     
  2. Loden_Heathen

    Loden_Heathen

    Joined:
    Sep 1, 2012
    Posts:
    480
    Input field seems to be a sort of processor its self
    It has its own m_Text string internally which is the raw input and that is what its using to find the length of text and what not

    So it still sees your formating marks like ** and __ and is still accounting for them when positioning the caret and all

    I think the only way to do what you want would be to create a custom InputField not simply a TextPreprocessor
     
  3. skullthug

    skullthug

    Joined:
    Oct 16, 2011
    Posts:
    202
    Well dang, I'm running into this same problem right now with trying to automatically add some color formatting to the Text Component part of a Input field.