Skip to content

Instantly share code, notes, and snippets.

@Twilight-Dream-Of-Magic
Last active June 21, 2023 17:22
Show Gist options
  • Save Twilight-Dream-Of-Magic/97787db37a66ab77e74ebc31ad57f745 to your computer and use it in GitHub Desktop.
Save Twilight-Dream-Of-Magic/97787db37a66ab77e74ebc31ad57f745 to your computer and use it in GitHub Desktop.
MiniHyperTextNew
/*
Unity Component Requirements: HyperText Mini (Candlelight)
Summary:
This component is based on the Candlelight HyperText version for Unity 2018 or later.
It should allow for the creation and management of interactive and non-interactive texts within the Unity engine.
The component should not rely on TextMeshPro and should be accessible, usable, or editable within the Unity Inspector through the generated code.
Do not use TextMeshPro or any other third-party plugins.
Features:
1. Text Division:
- Normal text: Non-interactive text.
- Interactive text: Interactive text that has two sub-categories:
a) Directly open the browser or other protocols (http, https, ftp, mailto).
b) Trigger events related to the interactive text.
2. Interactive Text Style:
Interactive text should have a style composed of text mixed with continuous underscores, similar to how websites display hyperlinks.
3. Multiple Gradient Colors for Text Renderer:
The component should support rendering text with multiple gradient colors.
4. Inspector Functionality:
The component must provide the following functionality within the Unity editor inspector:
1. Interactable : bool
2. Link Hitbox Padding : bool
Styles:
Link Keyword Collections (List is Empty) [+] [-]
Tag Keyword Collections (List is Empty) [+] [-]
Quad Keyword Collections (List is Empty) [+] [-]
Text Box Content Editor:
Override Text Source : [ None (Object) ]
3. Character:
3-1. Font : [ Font (Object) ]
3-2. FontStyle : Enum
3-3. FontSize : int
3-4. LineSpacing
3-5. RichText : bool
4. Paragraph:
4-1. Alignment
4-2. HorizontalOverflow : enum
4-3. VerticalOverflow : enum
4-4. BestFit
4-5. Color (Can Gradient)
4-6. Material : [ None (Material) ]
5. Events:
5-1. OnClick (HyperText, LinkInfo) (List is Empty) [+] [-]
5-2. OnEnter (HyperText, LinkInfo) (List is Empty) [+] [-]
5-3. OnExit (HyperText, LinkInfo) (List is Empty) [+] [-]
5-4. OnPress (HyperText, LinkInfo) (List is Empty) [+] [-]
5-5. OnRelease (HyperText, LinkInfo) (List is Empty) [+] [-]
*/
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.Events;
using UnityEngine.UI;
using TextFormatChecker = Twilight_Dream.Utilities.TextFormatChecker;
namespace Twilight_Dream.Utilities.HyperTexts.Version2
{
public enum InteractiveElementType
{
None,
EventFunction,
URL
}
public class InteractiveElementInfo
{
public InteractiveElementType Type { get; private set; }
//Starting vertex index of the text in the hyperlink
public int VertexStartIndex = 0;
//Ending vertex index of the text in the hyperlink
public int VertexEndIndex = 0;
public string Data { get; private set; }
public readonly List<Rect> Boxes = new List<Rect>();
public InteractiveElementInfo(InteractiveElementType type, string data)
{
Type = type;
Data = data;
}
}
public enum ColorMode
{
SingleColor,
GradientColor
}
public class MiniHyperTextNew : UnityEngine.UI.Text, IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler, IPointerDownHandler, IPointerUpHandler
{
[Header("Interactable Settings")]
public bool interactable = false;
public float linkHitboxPaddingFactor = 0.5f;
[Header("Font Setting")]
public bool freeTextSize = false;
[Header("Color Settings")]
public ColorMode colorMode = ColorMode.SingleColor;
public Gradient colorGradient;
[Header("Text Box Content")]
public TextAsset overrideTextSource;
[System.Serializable]
public class LinkClickEvent : UnityEvent<string>
{
}
[Header("Events")]
public LinkClickEvent onEventFunctionClick; // Event for EventFunction clicks
/// <summary>
/// List of hyperlinked information
/// </summary>
private readonly List<InteractiveElementInfo> interactiveElementInfos = new List<InteractiveElementInfo>();
private bool TextDirty = false;
[TextArea(3, 10), SerializeField]
protected string OriginText;
public override string text
{
get => ParsedTextString;
set
{
if (string.IsNullOrEmpty(value))
{
if (string.IsNullOrEmpty(ParsedTextString))
{
return;
}
OriginText = string.Empty;
TextDirty = true;
SetVerticesDirty();
}
else
{
if (OriginText == value)
{
return;
}
OriginText = value;
TextDirty = true;
SetVerticesDirty();
}
}
}
private string ParsedTextString = string.Empty;
protected override void Awake()
{
base.Awake();
// Load the text from the overrideTextSource if provided
if (overrideTextSource != null)
{
OriginText = overrideTextSource.text;
}
}
protected override void OnEnable()
{
base.OnEnable();
}
protected override void OnDisable()
{
base.OnDisable();
}
public override void SetVerticesDirty()
{
base.SetVerticesDirty();
TextDirty = true;
// Parse the regex text or text and create the interactive elements
ParsedTextString = ParseRegexText(OriginText);
}
private string ParseRegexText(string OriginText)
{
// Check if the text is marked as interactable
if (interactable && (OriginText.Contains("<EventFunctionName>") || OriginText.Contains("<URLName>")))
{
interactiveElementInfos.Clear();
if (string.IsNullOrEmpty(OriginText))
return "";
string temporaryText = OriginText;
/*
Use regex to find matches for event functions and URLs
*/
{
/*
The offset variable is used to keep track of how many characters are added or removed from the original text.
For example, if you have a function name like foo, and you replace it with <u>foo</u>, you are adding 7 characters to the text.
This means that the next match will have a different index than the original text.
To account for this, you need to add 7 to the offset variable, and use it to adjust the match index and the vertex index.
*/
int offset = 0; // this variable will store the number of characters added or removed from the original text
MatchCollection FunctionNamePartMatches = Regex.Matches(temporaryText, @"<EventFunctionName>(.*?)</EventFunctionName>", RegexOptions.Singleline);
// Process the event function matches and store them in the eventTextData dictionary
foreach (Match match in FunctionNamePartMatches)
{
// Extract the function name and remove the surrounding tags
string functionName = match.Groups[1].Value;
if (functionName != null && TextFormatChecker.IsValidFunctionIdentifierName(functionName))
{
var info = new InteractiveElementInfo(InteractiveElementType.EventFunction, functionName);
info.VertexStartIndex = (match.Index + offset) * 4; // start vertex index, adjusted by offset
info.VertexEndIndex = (match.Index + offset + functionName.Length - 1) * 4 + 3; // end vertex index, adjusted by offset
// Add the box area and the function name to the list of interactive elements
interactiveElementInfos.Add(info);
// Replace the function name in the original text with an underscored version
temporaryText = temporaryText.Remove(match.Index, match.Length).Insert(match.Index, $"<u>{functionName}</u>");
offset += 7; // update the offset by adding 7 characters
}
}
}
{
int offset = 0; // this variable will store the number of characters added or removed from the original text
MatchCollection URLNamePartMatches = Regex.Matches(temporaryText, @"<URLName>(.*?)</URLName>", RegexOptions.Singleline);
// Process the URL matches and store them in the linkTextData dictionary
foreach (Match match in URLNamePartMatches)
{
// Extract the URL and remove the surrounding tags
string urlName = match.Groups[1].Value;
if (urlName != null && TextFormatChecker.IsValidURL(urlName))
{
temporaryText = urlName;
var info = new InteractiveElementInfo(InteractiveElementType.URL, urlName);
info.VertexStartIndex = (match.Index + offset) * 4; // start vertex index, adjusted by offset
info.VertexEndIndex = (match.Index + offset + urlName.Length - 1) * 4 + 3; // end vertex index, adjusted by offset
// Add the box area and the URL to the list of interactive elements
interactiveElementInfos.Add(info);
// Replace the URL in the original text with an underscored version
temporaryText = temporaryText.Remove(match.Index, match.Length).Insert(match.Index, $"<u>{urlName}</u>");
offset += 7; // update the offset by adding 7 characters
}
}
}
TextDirty = false;
return temporaryText;
}
return OriginText;
}
public void OnPointerClick(PointerEventData eventData)
{
HandleInteractiveElementAction(eventData);
}
public void OnPointerEnter(PointerEventData eventData)
{
HandleInteractiveElementAction(eventData);
}
public void OnPointerExit(PointerEventData eventData)
{
HandleInteractiveElementAction(eventData);
}
public void OnPointerDown(PointerEventData eventData)
{
HandleInteractiveElementAction(eventData);
}
public void OnPointerUp(PointerEventData eventData)
{
HandleInteractiveElementAction(eventData);
}
private void HandleInteractiveElementAction(PointerEventData eventData)
{
if (interactable && !string.IsNullOrEmpty(OriginText))
{
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, eventData.pressEventCamera, out var localPosition);
// Iterate through each interactive element
foreach (var info in interactiveElementInfos)
{
var type = info.Type;
var data = info.Data;
// Check if the click point lies within this interactive element's box
var boxes = info.Boxes; // Assuming Boxes is a List<Rect> property of InteractiveElementInfo
for (var i = 0; i < boxes.Count; ++i)
{
if (!boxes[i].Contains(localPosition)) continue;
// If it does, invoke the corresponding action
if (type == InteractiveElementType.EventFunction)
{
onEventFunctionClick.Invoke(data); // Assuming onEventFunctionClick is an event for EventFunction clicks
}
else if (type == InteractiveElementType.URL)
{
Application.OpenURL(data);
}
return;
}
}
}
}
protected override void OnPopulateMesh(VertexHelper toFill)
{
base.m_DisableFontTextureRebuiltCallback = true;
// Call the base method to generate the mesh data
base.OnPopulateMesh(toFill);
UIVertex vertex = new UIVertex();
// Handling hyperlink enclosing boxes
foreach (var info in interactiveElementInfos)
{
info.Boxes.Clear();
if (info.VertexStartIndex >= toFill.currentVertCount)
{
continue;
}
// Add the index coordinates of the text vertices inside the hyperlink to the enclosing box
toFill.PopulateUIVertex(ref vertex, info.VertexStartIndex);
var vertexPosition = vertex.position;
var bounds = new Bounds(vertexPosition, Vector3.zero);
for (int index = info.VertexStartIndex, size = info.VertexEndIndex; index < size; index++)
{
if (index >= toFill.currentVertCount)
{
break;
}
toFill.PopulateUIVertex(ref vertex, index);
vertexPosition = vertex.position;
if (vertexPosition.x < bounds.min.x) // Wrap-around box re-added
{
info.Boxes.Add(new Rect(bounds.min, bounds.size));
bounds = new Bounds(vertexPosition, Vector3.zero);
}
else
{
bounds.Encapsulate(vertexPosition); // Extended Wraparound Box
}
}
info.Boxes.Add(new Rect(bounds.min, bounds.size));
}
// Call this custom setting apply to base class data
ApplyFreeTextSize();
ApplyColorMode(toFill);
base.m_DisableFontTextureRebuiltCallback = false;
}
private void ApplyColorMode(VertexHelper toFill)
{
// UGUI的网格处理类
// Get the vertices in the generated mesh
List<UIVertex> vertices = new List<UIVertex>();
toFill.GetUIVertexStream(vertices);
// Ensure that there are at least three vertices in the mesh
if (vertices.Count < 3)
{
return;
}
// Calculate the min and max Y positions of the text (for gradient color mode)
float minY = vertices[0].position.y;
float maxY = minY;
if (colorMode == ColorMode.GradientColor)
{
for (int i = 1; i < vertices.Count; i++)
{
float y = vertices[i].position.y;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
// Apply the selected color mode to the vertices
for (int i = 0; i < vertices.Count; i++)
{
UIVertex vertex = vertices[i];
if (colorMode == ColorMode.SingleColor)
{
vertex.color = base.color;
}
else if (colorMode == ColorMode.GradientColor)
{
//Reset this vertex color is white
vertex.color = Color.white;
float time = Mathf.InverseLerp(minY, maxY, vertex.position.y);
//Overlay color to the current vertex(using multiplication)
vertex.color *= colorGradient.Evaluate(time);
}
vertices[i] = vertex;
}
// Update the mesh with the modified vertices
toFill.Clear();
toFill.AddUIVertexTriangleStream(vertices);
}
private void ApplyFreeTextSize()
{
RectTransform parentRectTransform = transform.parent.GetComponent<RectTransform>();
ContentSizeFitter contentSizeFitter = parentRectTransform.GetComponent<ContentSizeFitter>();
LayoutElement layoutElement = GetComponent<LayoutElement>();
if (freeTextSize)
{
if (contentSizeFitter == null)
{
contentSizeFitter = parentRectTransform.gameObject.AddComponent<ContentSizeFitter>();
}
contentSizeFitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
contentSizeFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
if (layoutElement == null)
{
layoutElement = gameObject.AddComponent<LayoutElement>();
}
layoutElement.flexibleWidth = 1;
layoutElement.flexibleHeight = 1;
}
else
{
if (contentSizeFitter != null)
{
#if UNITY_EDITOR
DestroyImmediate(contentSizeFitter);
#else
Destroy(contentSizeFitter);
#endif
}
if (layoutElement != null)
{
#if UNITY_EDITOR
DestroyImmediate(layoutElement);
#else
Destroy(layoutElement);
#endif
}
}
}
}
}
using Twilight_Dream.Utilities.HyperTexts.Version2;
using UnityEditor;
using UnityEngine.UI;
[CustomEditor(typeof(MiniHyperTextNew))]
[CanEditMultipleObjects]
public class MiniHyperTextNewEditor : UnityEditor.UI.TextEditor
{
private SerializedProperty OriginText;
private SerializedProperty FontData;
protected override void OnEnable()
{
base.OnEnable();
OriginText = serializedObject.FindProperty("OriginText");
FontData = serializedObject.FindProperty("m_FontData");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
MiniHyperTextNew miniHyperText = (MiniHyperTextNew)target;
serializedObject.Update();
EditorGUILayout.PropertyField(OriginText);
EditorGUILayout.PropertyField(FontData);
// Draw the properties for MiniHyperTextNew
SerializedProperty colorMode = serializedObject.FindProperty("colorMode");
EditorGUILayout.PropertyField(colorMode, true);
if (miniHyperText.colorMode == ColorMode.GradientColor)
{
SerializedProperty colorGradient = serializedObject.FindProperty("colorGradient");
EditorGUILayout.PropertyField(colorGradient, true);
}
else if (miniHyperText.colorMode == ColorMode.SingleColor)
{
SerializedProperty color = serializedObject.FindProperty("m_Color");
EditorGUILayout.PropertyField(color, true);
}
// Display other properties
SerializedProperty interactable = serializedObject.FindProperty("interactable");
EditorGUILayout.PropertyField(interactable, true);
SerializedProperty linkHitboxPaddingFactor = serializedObject.FindProperty("linkHitboxPaddingFactor");
EditorGUILayout.PropertyField(linkHitboxPaddingFactor, true);
SerializedProperty freeTextSize = serializedObject.FindProperty("freeTextSize");
EditorGUILayout.PropertyField(freeTextSize, true);
SerializedProperty overrideTextSource = serializedObject.FindProperty("overrideTextSource");
EditorGUILayout.PropertyField(overrideTextSource, true);
SerializedProperty onEventFunctionClick = serializedObject.FindProperty("onEventFunctionClick");
EditorGUILayout.PropertyField(onEventFunctionClick, true);
serializedObject.ApplyModifiedProperties();
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Twilight_Dream.Utilities
{
internal class TextFormatChecker
{
private static bool IsValidEmailAddress(string value)
{
// Check if the value is null or empty
if (string.IsNullOrEmpty(value))
{
return false;
}
// Split the value by the @ sign
string[] parts = value.Split('@');
// Check if there are exactly two parts
if (parts.Length != 2)
{
return false;
}
// Check the local part of the email address
string local = parts[0];
// Define a regular expression pattern for the local part
string localPattern = @"^\w+([-+.']\w+)*$";
// Use the Regex class to match the local part against the pattern
if (!Regex.IsMatch(local, localPattern))
{
return false;
}
// Check the domain part of the email address
string domain = parts[1];
// Define a regular expression pattern for the domain part
string domainPattern = @"^\w+([-.]\w+)*\.\w+([-.]\w+)*$";
// Use the Regex class to match the domain part against the pattern
if (!Regex.IsMatch(domain, domainPattern))
{
return false;
}
// If all checks passed, return true
return true;
}
public static bool IsValidFTPURL(string value)
{
// Check if the value is null or empty
if (string.IsNullOrEmpty(value))
{
return false;
}
// Define a regular expression pattern for the FTP protocol
string protocolPattern = @"^ftp://";
// Use the Regex class to match the value against the pattern
if (!Regex.IsMatch(value, protocolPattern))
{
return false;
}
// Remove the protocol part from the value
value = value.Substring(protocolPattern.Length);
// Split the value by the / sign
string[] parts = value.Split('/');
// Check if there is at least one part
if (parts.Length < 1)
{
return false;
}
// Check the host part of the FTP URL
string host = parts[0];
// Define a regular expression pattern for the host part
string hostPattern = @"^\w+([-.]\w+)*\.\w+([-.]\w+)*$";
// Use the Regex class to match the host part against the pattern
if (!Regex.IsMatch(host, hostPattern))
{
return false;
}
// Check the path part of the FTP URL, if any
if (parts.Length > 1)
{
string path = string.Join("/", parts.Skip(1));
// Define a regular expression pattern for the path part
string pathPattern = @"^[\w-./]+$";
// Use the Regex class to match the path part against the pattern
if (!Regex.IsMatch(path, pathPattern))
{
return false;
}
}
// If all checks passed, return true
return true;
}
public static bool IsValidURL(string value)
{
bool IsMatchPattern = false;
if (string.IsNullOrEmpty(value))
return false;
else
{
//This is HTTP/HTTPS URL pattern
Regex pattern = new Regex(@"^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$", RegexOptions.Singleline);
//Remove prefix white space and suffix white space
string ValueNotHaveWhiteSpace = value.Trim();
IsMatchPattern = pattern.IsMatch(ValueNotHaveWhiteSpace) || IsValidFTPURL(ValueNotHaveWhiteSpace);
if (!IsMatchPattern)
{
if (IsValidEmailAddress(ValueNotHaveWhiteSpace) && !ValueNotHaveWhiteSpace.Contains("mailto:"))
{
StringBuilder stringBuilder = new StringBuilder(ValueNotHaveWhiteSpace);
stringBuilder.Insert(0, "mailto:");
ValueNotHaveWhiteSpace = stringBuilder.ToString();
}
}
}
return IsMatchPattern;
}
public static bool IsValidFunctionIdentifierName(string value)
{
if (string.IsNullOrEmpty(value))
return false;
// Define a regular expression pattern for a valid function name
string pattern = @"^[A-Za-z_][A-Za-z0-9_]{0,126}$";
// Use the Regex class to match the value against the pattern
return Regex.IsMatch(value, pattern);
}
}
}
I need the interactive text to be interactive or not, and also the graphic layout. And then in because you UNITY top display it
I think this requirement is a big headache. Because the interactive text involves events or hyperlinks. Because, you know, I'm going to give a chat system to use. It's not only in the online lobby system of the game that you can see the interactive text with links.
Players can also send images and text through the Private chat UI to send pictures and text. And then you can also send a link.
That's why I keep mentioning that component at the beginning, because I can't think of a design solution anymore.
This means that the chat system acts as a bridge between a hypertext link event and the interface. However hypertext is what is being designed.
-----------------
I see, you're looking to create a rich chat system that supports text, images, and interactive elements like hyperlinks and events. This is indeed a complex task, but it's definitely achievable with Unity.
The MiniHyperTextNew component you've created is a good start. It already supports interactive text and events. Here's how you can extend it to meet your requirements:
Images: To support images in chat, you could use Unity's Rich Text system, which allows you to embed images in text using the <quad> tag. You would need to add a Sprite Asset to your project containing the images you want to use, and then you can reference these images by their name in the Sprite Asset. You would also need to extend your MiniHyperTextNew component to parse these <quad> tags and replace them with the appropriate image references.
Hyperlinks: Your MiniHyperTextNew component already supports hyperlinks. You just need to make sure that the text of the hyperlink is formatted correctly in the chat message. For example, if you want to create a hyperlink to https://www.example.com, the chat message should include the text <URLName>https://www.example.com</URLName>.
Events: Your MiniHyperTextNew component already supports events. You just need to make sure that the event function name is formatted correctly in the chat message. For example, if you want to trigger an event called MyEvent, the chat message should include the text <EventFunctionName>MyEvent</EventFunctionName>.
Non-interactive text: Any text that is not enclosed in <URLName> or <EventFunctionName> tags will be treated as non-interactive text.
Chat System: The chat system would be responsible for taking input from the user, formatting it correctly (e.g., replacing URLs and event function names with the appropriate tags), and then sending it to the MiniHyperTextNew component to be displayed. It would also need to handle incoming chat messages from other users and display them in the same way.
Private Chat UI: The Private Chat UI would be a separate interface that uses the same underlying chat system. It would have its own instance of the MiniHyperTextNew component to display chat messages.
This is a high-level overview of how you could design your system. The exact details would depend on your specific requirements and constraints.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment