ui/richtext/bases/RichTextBase.razor

A Blazor-style Razor UI component for sandboxed UI, defines a RichTextBase panel that renders an icon/image/label and exposes many styling properties and animations. It parses properties in ParseRichText to apply them to the Panel Style and computes a build hash combining text, icon and image path.

@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@implements IRichTextPanel

<root class="richtext">
	@* @if ( Stats.Length > 0 && Player.Local is Player localPlayer )
	{
		<RazorTooltip>
			<Content class="richtext">
				<label>@Text</label>
				@if ( !string.IsNullOrEmpty( Input ) )
				{
					<Image [email protected](Input, InputGlyphSize.Small, false) />
				}
				else if ( !string.IsNullOrEmpty( ImagePath ) )
				{
					<img src="@ImagePath" />
				}
				@if ( ShowIcon && !string.IsNullOrEmpty( Icon ) )
				{
					<i>@Icon</i>
				}
			</Content>
		</RazorTooltip>
	}
	else
	{ *@
		@if ( PrependIcon )
		{
			@if ( !string.IsNullOrEmpty( Input ) )
			{
				<Image [email protected](Input, InputGlyphSize.Small, false) />
			}
			else if ( !string.IsNullOrEmpty( ImagePath ) )
			{
				var imgSize = ImageSize ?? 20f;
				<img src="@ImagePath" style="width: @(imgSize)px; height: @(imgSize)px;" />
			}
			@if ( ShowIcon && !string.IsNullOrEmpty( Icon ) )
			{
				<i>@Icon</i>
			}
		}
		@if ( !HideText )
		{
			<label>@Text</label>
		}
		@if ( !PrependIcon )
		{
			@if ( !string.IsNullOrEmpty( Input ) )
			{
				<Image [email protected](Input, InputGlyphSize.Small, false) />
			}
			else if ( !string.IsNullOrEmpty( ImagePath ) )
			{
				var imgSize = ImageSize ?? 20f;
				<img src="@ImagePath" style="width: @(imgSize)px; height: @(imgSize)px;" />
			}
			@if ( ShowIcon && !string.IsNullOrEmpty( Icon ) )
			{
				<i>@Icon</i>
			}
		}
	@* } *@
</root>

<style>
	.richtext {
		align-items: center;
		gap: 2px;
		color: white;
		overflow: visible;

		label {
			overflow: visible;
		}
	}


	img, Image {
		width: 16px;
		height: 16px;
	}

	i {
		position: relative;
		top: 2px;
	}

	@@keyframes pulse {
		0% { transform: scale(1); }
		50% { transform: scale(1.15); }
		100% { transform: scale(1); }
	}

	@@keyframes wiggle {
		0%, 100% { transform: rotate(-2deg); }
		50% { transform: rotate(2deg); }
	}

	@@keyframes bounce {
		0%, 100% { transform: translateY(0); }
		50% { transform: translateY(-5px); }
	}

	@@keyframes hop {
		0% { transform: translateY(-4px); }
		100% { transform: translateY(1px); }
	}

	@@keyframes jump {
		0% { transform: translateY(0) scaleX(1); }
		1% { transform: translateY(-3px) scaleX(1); }
		4% { transform: translateY(-7px) scaleX(1); }
		8% { transform: translateY(-9px) scaleX(1); }
		12% { transform: translateY(-7px) scaleX(1); }
		16% { transform: translateY(-2px) scaleX(1); }
		19% { transform: translateY(0) scaleX(1.1); }
		21%, 100% { transform: translateY(0) scaleX(1); }
	}

	@@keyframes shake {
		0%, 100% { transform: translateX(0); }
		25% { transform: translateX(-2px); }
		75% { transform: translateX(2px); }
	}

	@@keyframes glow {
		0%, 100% { filter: brightness(1); }
		50% { filter: brightness(1.3); }
	}

	@@keyframes float {
		0%, 100% { transform: translateY(0); }
		50% { transform: translateY(-3px); }
	}

	@@keyframes turn {
		0% { transform: rotate(-8deg); }
		100% { transform: rotate(8deg); }
	}

	@@keyframes heartbeat {
		0% { transform: scale(1); }
		25% { transform: scale(1.1); }
		50% { transform: scale(1); }
		75% { transform: scale(1.1); }
		100% { transform: scale(1); }
	}

	@@keyframes tada {
		0%, 100% { transform: scale(1) rotate(0deg); }
		10%, 20% { transform: scale(0.9) rotate(-3deg); }
		30%, 50%, 70%, 90% { transform: scale(1.1) rotate(3deg); }
		40%, 60%, 80% { transform: scale(1.1) rotate(-3deg); }
	}

	@@keyframes rainbow {
		0% { filter: hue-rotate(0deg); }
		100% { filter: hue-rotate(360deg); }
	}

	@@keyframes god {
		0%, { transform: scale(1); }
		100% { transform: scale(1.05); }
	}

	@@keyframes stretch-y {
		0%, 100% { transform: scaleY(1); }
		50% { transform: scaleY(1.2); }
	}

	@@keyframes recoil {
		0% { transform: translateX(0) scale(1); }
		15% { transform: translateX(-6px) scale(0.95); }
		30% { transform: translateX(1px) scale(1.02); }
		45% { transform: translateX(0) scale(1); }
		100% { transform: translateX(0) scale(1); }
	}

	@@keyframes wave-up {
		0%, 100% { transform: translateY(5px); }
		50% { transform: translateY(-5px); }
	}

	@@keyframes wave-down {
		0%, 100% { transform: translateY(-5px); }
		50% { transform: translateY(5px); }
	}

	@@keyframes jitter {
		0% { transform: translate(0, 0); }
		10% { transform: translate(-1px, 1px); }
		20% { transform: translate(1px, -1px); }
		30% { transform: translate(-1px, -1px); }
		40% { transform: translate(1px, 1px); }
		50% { transform: translate(-1px, 0); }
		60% { transform: translate(1px, 0); }
		70% { transform: translate(0, -1px); }
		80% { transform: translate(0, 1px); }
		90% { transform: translate(-1px, 1px); }
		100% { transform: translate(0, 0); }
	}

	@@keyframes throb {
		0%, 100% { transform: scale(0.95); opacity: 0.4; }
		50% { transform: scale(1.05); opacity: 1; }
	}

	@@keyframes curse {
		0% { transform: translate(0, 0) rotate(0deg) scale(1); }
		10% { transform: translate(-0.5px, -0.5px) rotate(-0.5deg) scale(1.02); }
		20% { transform: translate(0.5px, 0.5px) rotate(0.5deg) scale(0.98); }
		30% { transform: translate(-0.5px, 0.5px) rotate(-0.3deg) scale(1.01); }
		40% { transform: translate(0.5px, -0.5px) rotate(0.3deg) scale(0.99); }
		50% { transform: translate(-0.3px, -0.3px) rotate(-0.2deg) scale(1.02); }
		60% { transform: translate(0.3px, 0.3px) rotate(0.2deg) scale(0.98); }
		70% { transform: translate(-0.5px, 0.3px) rotate(-0.4deg) scale(1.01); }
		80% { transform: translate(0.5px, -0.3px) rotate(0.4deg) scale(0.99); }
		90% { transform: translate(-0.3px, 0.5px) rotate(-0.1deg) scale(1); }
		100% { transform: translate(0, 0) rotate(0deg) scale(1); }
	}

	@@keyframes track {
		0% { transform: translate(0, 0) rotate(0deg); }
		15% { transform: translate(2px, -1.5px) rotate(3deg); }
		16% { transform: translate(2px, -1.5px) rotate(3deg); }
		35% { transform: translate(-1.5px, 1px) rotate(-4deg); }
		36% { transform: translate(-1.5px, 1px) rotate(-4deg); }
		55% { transform: translate(0.5px, -2px) rotate(2deg); }
		56% { transform: translate(0.5px, -2px) rotate(2deg); }
		75% { transform: translate(1px, 1.5px) rotate(-2deg); }
		76% { transform: translate(1px, 1.5px) rotate(-2deg); }
		95% { transform: translate(-0.5px, -0.5px) rotate(1deg); }
		100% { transform: translate(0, 0) rotate(0deg); }
	}

	@@keyframes echo-clone {
		0% { transform: translateX(0); opacity: 1; }
		10% { transform: translateX(2px); opacity: 0.5; }
		15% { transform: translateX(4px); opacity: 0.25; }
		20% { transform: translateX(6px); opacity: 0; }
		25% { transform: translateX(0); opacity: 0; }
		100% { transform: translateX(0); opacity: 1; }
	}

	@@keyframes slide-return {
		0% { transform: translateX(-12px); }
		100% { transform: translateX(12px); }
	}

	@@keyframes wobble {
		0% { transform: rotate(0deg); }
		20% { transform: rotate(-8deg); }
		21% { transform: rotate(-8deg); }
		40% { transform: rotate(12deg); }
		41% { transform: rotate(12deg); }
		60% { transform: rotate(-5deg); }
		61% { transform: rotate(-5deg); }
		80% { transform: rotate(6deg); }
		81% { transform: rotate(6deg); }
		100% { transform: rotate(0deg); }
	}

	@@keyframes steer {
		0% { transform: rotate(-12deg); }
		100% { transform: rotate(12deg); }
	}

	@@keyframes zoom {
		0% { transform: scale(1.3); }
		100% { transform: scale(0.7); }
	}

	@@keyframes oscillate {
		0% { transform: scale(0.85); }
		100% { transform: scale(1.15); }
	}

	@@keyframes dvd {
		0% { transform: translate(-3px, -2px); }
		20% { transform: translate(0px, 1px); }
		40% { transform: translate(2px, -3px); }
		60% { transform: translate(-1px, 2px); }
		80% { transform: translate(-3px, -3px); }
		100% { transform: translate(0px, 2px); }
	}
</style>

@code
{
	public virtual string Icon => "";
	public virtual string Input => "";
	public virtual string ImagePath => "";
	public virtual string TextOverride => null;
	public virtual Color Color => Color.Transparent;
	public virtual bool HideText => false;
	public virtual bool PrependIcon => false;
	public virtual RichTextStatEntry[] Stats => [];
	public virtual int? FontWeight => null;
	public virtual float? FontSize => null;
	public virtual bool Italic => false;
	public virtual bool Underline => false;
	public virtual Sandbox.UI.TextDecorationStyle? UnderlineStyle => null;
	public virtual Color? UnderlineColor => null;
	public virtual float? UnderlineWidth => null;
	public virtual float? TextShadowBlur => null;
	public virtual float? TextShadowOffsetY => null;
	public virtual Color? TextShadowColor => null;
	public virtual Color? BackgroundColor => null;
	public virtual float? BorderRadius => null;
	public virtual float? BorderWidth => null;
	public virtual Color? BorderColor => null;
	public virtual string BorderStyle => null;
	public virtual float? SkewX => null;
	public virtual float? SkewY => null;
	public virtual float? TextStrokeWidth => null;
	public virtual Color? TextStrokeColor => null;
	public virtual Color? BackgroundGradientStart => null;
	public virtual Color? BackgroundGradientEnd => null;
	public virtual float? BackgroundGradientAngle => null;
	public virtual string AnimationName => null;
	public virtual float? AnimationDuration => null;
	public virtual string AnimationTimingFunction => null;

	/// <summary>
	/// normal, reverse, alternate, alternate-reverse
	/// </summary>
	public virtual string AnimationDirection => null;
	public virtual float? MarginLeft => null;
	public virtual float? MarginRight => null;
	public virtual float? MarginTop => null;
	public virtual float? MarginBottom => null;
	public float? ImageSize { get; set; }
	// "normal" - plays forward(0% → 100%), then jumps back to 0% and repeats
	// "reverse" - plays backward(100% → 0%), then jumps to 100% and repeats
	// "alternate" - plays forward then backward(0% → 100% → 0%), creating a smooth loop
	// "alternate-reverse" - plays backward then forward(100% → 0% → 100%)

	bool ShowIcon = true;
	string Text;

	public void ParseRichText(string text)
	{
		Text = !string.IsNullOrEmpty(TextOverride) ? TextOverride : text;
		if(Color != Color.Transparent)
		{
			Style.FontColor = Color;
		}

		if(FontWeight.HasValue)
			Style.FontWeight = FontWeight.Value;
		if(FontSize.HasValue)
			Style.FontSize = Length.Pixels(FontSize.Value);
		if(Italic)
			Style.FontStyle = Sandbox.UI.FontStyle.Italic;
		if(Underline)
			Style.TextDecorationLine = Sandbox.UI.TextDecoration.Underline;
		if(UnderlineStyle.HasValue)
			Style.TextDecorationStyle = UnderlineStyle.Value;
		if(UnderlineColor.HasValue)
			Style.TextDecorationColor = UnderlineColor.Value;
		if(UnderlineWidth.HasValue)
			Style.Set("text-decoration-thickness", $"{UnderlineWidth.Value}px");
		if(TextShadowBlur.HasValue && TextShadowOffsetY.HasValue && TextShadowColor.HasValue)
		{
			Style.TextShadow.Clear();
			Style.TextShadow.Add(new Shadow
			{
				OffsetX = 0,
				OffsetY = TextShadowOffsetY.Value,
				Blur = TextShadowBlur.Value,
				Color = TextShadowColor.Value
			});
		}
		if(BackgroundColor.HasValue)
			Style.BackgroundColor = BackgroundColor.Value;
		if(BorderRadius.HasValue)
		{
			var radius = Length.Pixels(BorderRadius.Value);
			Style.BorderTopLeftRadius = radius;
			Style.BorderTopRightRadius = radius;
			Style.BorderBottomRightRadius = radius;
			Style.BorderBottomLeftRadius = radius;
		}
		if(BorderWidth.HasValue)
		{
			var width = Length.Pixels(BorderWidth.Value);
			Style.BorderTopWidth = width;
			Style.BorderRightWidth = width;
			Style.BorderBottomWidth = width;
			Style.BorderLeftWidth = width;
		}
		if(BorderColor.HasValue)
		{
			Style.BorderTopColor = BorderColor.Value;
			Style.BorderRightColor = BorderColor.Value;
			Style.BorderBottomColor = BorderColor.Value;
			Style.BorderLeftColor = BorderColor.Value;
		}
		if(!string.IsNullOrEmpty(BorderStyle))
		{
			Style.Set("border-style", BorderStyle);
		}
		if(SkewX.HasValue || SkewY.HasValue)
		{
			var transform = new PanelTransform();
			transform.AddSkew(SkewX ?? 0, SkewY ?? 0, 0);
			Style.Transform = transform;
		}
		if(TextStrokeWidth.HasValue)
			Style.TextStrokeWidth = Length.Pixels(TextStrokeWidth.Value);
		if(TextStrokeColor.HasValue)
			Style.TextStrokeColor = TextStrokeColor.Value;
		if(MarginLeft.HasValue)
			Style.MarginLeft = Length.Pixels(MarginLeft.Value);
		if(MarginRight.HasValue)
			Style.MarginRight = Length.Pixels(MarginRight.Value);
		if(MarginTop.HasValue)
			Style.MarginTop = Length.Pixels(MarginTop.Value);
		if(MarginBottom.HasValue)
			Style.MarginBottom = Length.Pixels(MarginBottom.Value);
		if(BackgroundGradientStart.HasValue && BackgroundGradientEnd.HasValue)
		{
			var angle = BackgroundGradientAngle ?? 90f;
			Style.Set("background-image", $"linear-gradient({angle}deg, {BackgroundGradientStart.Value.Hex}, {BackgroundGradientEnd.Value.Hex})");
		}
		if(BackgroundColor.HasValue || (BackgroundGradientStart.HasValue && BackgroundGradientEnd.HasValue))
		{
			Style.PaddingLeft = Length.Pixels(3);
			Style.PaddingRight = Length.Pixels(3);
		}
		if(!string.IsNullOrEmpty(AnimationName))
		{
			Style.AnimationName = AnimationName;
			Style.AnimationIterationCount = float.PositiveInfinity;

			if(AnimationDuration.HasValue)
				Style.AnimationDuration = AnimationDuration.Value;
			if(!string.IsNullOrEmpty(AnimationTimingFunction))
				Style.AnimationTimingFunction = AnimationTimingFunction;
			if(!string.IsNullOrEmpty(AnimationDirection))
				Style.AnimationDirection = AnimationDirection;
		}
		if(HideText)
		{
			Style.PaddingLeft = Length.Pixels(3);
			Style.PaddingRight = Length.Pixels(3);
		}
		if(!string.IsNullOrEmpty(ImagePath))
		{
			Style.PaddingLeft = Length.Pixels(0);
			Style.PaddingRight = Length.Pixels(0);
		}
	}

	protected override int BuildHash()
	{
		return System.HashCode.Combine( Text, Icon, ImagePath );
	}
}