A Razor UI component that renders rich text by parsing an input string into components. It finds types annotated with RichTextPanelAttribute via TypeLibrary, uses regex patterns to locate matches, and builds child panels or Label elements, handling line breaks and simple word splitting for labels.
@using Sandbox
@using Sandbox.UI
@using System.Collections.Generic
@using System.Text.RegularExpressions
@using System
<root>
</root>
@code
{
public string Text { get; set; } = "";
public float? ImageSize { get; set; } = null;
protected override void OnParametersSet ()
{
base.OnParametersSet();
DeleteChildren( true );
var components = ParseText( Text );
foreach ( var (panelType, innerText) in components )
{
if ( panelType == typeof( Label ) )
{
// First split by newlines to handle line breaks
// Handle both \n and \r\n line endings
var lines = innerText.Split( new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None );
for ( int lineIndex = 0; lineIndex < lines.Length; lineIndex++ )
{
// Add line break element before each line after the first
if ( lineIndex > 0 )
{
var lineBreak = new Panel();
lineBreak.Style.FlexBasis = Length.Percent( 100 );
lineBreak.Style.Width = Length.Percent( 100 );
lineBreak.Style.Height = 0;
lineBreak.Style.FlexShrink = 0;
lineBreak.Style.FlexGrow = 0;
lineBreak.Style.MinWidth = Length.Percent( 100 );
AddChild( lineBreak );
}
// Now split the line by spaces and process words
var split = lines[lineIndex].Split( " " );
foreach ( var s in split )
{
if(string.IsNullOrWhiteSpace(s))
{
continue;
}
var label = new Label( s );
// Add class for punctuation to control spacing
if ( s.Length == 1 && char.IsPunctuation( s[0] ) )
{
label.AddClass( "punctuation" );
}
// Add class for "s" or "s)" to control spacing (for times like "5s" or "5s)")
else if ( s == "s" || s == "s)" )
{
label.AddClass( "suffix-s" );
}
AddChild( label );
}
}
}
else
{
var panel = TypeLibrary.Create<Panel>( panelType );
AddChild( panel );
if ( panel is IRichTextPanel richTextPanel )
{
richTextPanel.ImageSize = ImageSize;
richTextPanel.ParseRichText( innerText );
}
else
{
panel.AddChild( new Label( innerText ) );
}
}
}
}
/// <summary>
/// Parses rich text and returns a list of components to create
/// </summary>
/// <param name="text">The text to parse</param>
/// <returns>List of (ComponentType, InnerText) pairs where ComponentType is either a Panel type or typeof(Label)</returns>
public static List<(System.Type Type, string Text)> ParseText ( string text )
{
var result = new List<(System.Type Type, string Text)>();
if ( string.IsNullOrEmpty( text ) )
return result;
var allPanels = TypeLibrary.GetTypesWithAttribute<RichTextPanelAttribute>();
// Create a list of all matches with their positions
var matches = new List<(Match Match, System.Type PanelType, int Priority, bool useCapture, string Pattern)>();
foreach ( var panelType in allPanels )
{
var pattern = panelType.Attribute.Pattern;
if ( string.IsNullOrEmpty( pattern ) )
continue;
var regex = new Regex( pattern );
var regexMatches = regex.Matches( text );
// Priority based on specificity - longer patterns get higher priority
// Also give higher priority to patterns with more complex regex features
var priority = pattern.Length;
if ( pattern.Contains( "(?<!" ) || pattern.Contains( "(?!" ) )
priority += 100; // Boost priority for lookahead/lookbehind patterns
foreach ( Match match in regexMatches )
{
matches.Add( (match, panelType.Type.TargetType, priority, panelType.Attribute.UseCaptureGroup, pattern) );
}
}
// Sort matches by length (longer matches first for proper nesting),
// then by priority (higher priority first), then by position
matches.Sort( ( a, b ) =>
{
// Pre-priority: If we have a manually input ExtraPriority, use that first
// if ( a.Priority == b.Priority && a.ExtraPriority != b.ExtraPriority )
// return b.ExtraPriority.CompareTo( -a.ExtraPriority );
// First priority: longer matches first (this handles nesting properly)
var lengthComparison = b.Match.Length.CompareTo( a.Match.Length );
if ( lengthComparison != 0 )
return lengthComparison;
// Second priority: higher pattern priority first
var priorityComparison = b.Priority.CompareTo( a.Priority );
if ( priorityComparison != 0 )
return priorityComparison;
// Third priority: earlier position first
return a.Match.Index.CompareTo( b.Match.Index );
} );
// Remove overlapping matches (keep higher priority ones)
var finalMatches = new List<(Match Match, System.Type PanelType, bool useCapture)>();
foreach ( var (match, panelType, priority, useCapture, pattern) in matches )
{
var overlaps = false;
foreach ( var (existingMatch, _, _) in finalMatches )
{
if ( match.Index < existingMatch.Index + existingMatch.Length &&
match.Index + match.Length > existingMatch.Index )
{
overlaps = true;
break;
}
}
if ( !overlaps )
{
finalMatches.Add( (match, panelType, useCapture) );
}
}
// Sort final matches by position
finalMatches.Sort( ( a, b ) => a.Match.Index.CompareTo( b.Match.Index ) );
// Build the component list by processing text segments and matches
var lastIndex = 0;
foreach ( var (match, panelType, useCapture) in finalMatches )
{
// Add any text before this match as Label components
if ( match.Index > lastIndex )
{
var beforeText = text.Substring( lastIndex, match.Index - lastIndex );
if ( !string.IsNullOrEmpty( beforeText ) )
{
result.Add( (typeof( Label ), beforeText) );
}
}
// Add the panel component
// Use full match by default. Only use first capture group if it's clearly extracting content from wrapper syntax
var innerText = match.Value;
if ( useCapture && match.Groups.Count > 1 && !string.IsNullOrEmpty( match.Groups[1].Value ) )
{
var captureGroup = match.Groups[1].Value;
var fullMatch = match.Value;
// Only use capture group if the full match starts and ends with wrapper characters
// that are NOT part of the capture group (indicating wrapper syntax like **text** or <i>text</i>)
var beforeCapture = fullMatch.Substring( 0, match.Groups[1].Index - match.Index );
var afterCapture = fullMatch.Substring( match.Groups[1].Index - match.Index + match.Groups[1].Length );
// Use capture group only if there are clear wrapper characters before AND after
if ( !string.IsNullOrEmpty( beforeCapture ) && !string.IsNullOrEmpty( afterCapture ) )
{
innerText = captureGroup;
}
}
result.Add( (panelType, innerText) );
lastIndex = match.Index + match.Length;
}
// Add any remaining text as a Label component
if ( lastIndex < text.Length )
{
var remainingText = text.Substring( lastIndex );
if ( !string.IsNullOrEmpty( remainingText ) )
{
result.Add( (typeof( Label ), remainingText) );
}
}
return result;
}
}