using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
///
/// Shows a piece of content for the user to check: text formatted and as plain text, and an image
/// as itself.
///
///
/// Every place which lets the user see what AI Studio reads or hands to the AI shows it the same
/// way, which is why this lives here instead of in each of them. A slim bar above the content says
/// what it is, and switches between the ways to see it; the caller fills its start.
/// The content scrolls inside its frame, so a dialog around it does not have to scroll as well. In a
/// dialog which fits the window, the frame gives way down to the room left over.
/// The text is shown in full unless the caller sets a limit. Whether the beginning of a text is
/// enough depends on what the user wants to check: how a whole document was read, or exactly what
/// the AI received. Only the caller knows which, and how to explain a cut.
///
public partial class ContentViewer : MSGComponentBase
{
///
/// The ways to see the content.
///
///
/// Internal rather than private: Razor infers the type of the toggle items in a class of its own.
///
internal enum ContentView
{
MEDIA,
FORMATTED,
PLAIN,
}
///
/// What kind of content this is.
///
[Parameter]
public ContentViewerKind Kind { get; set; } = ContentViewerKind.TEXT;
///
/// The text to show. For an image, the text which belongs to it, or nothing.
///
[Parameter]
public string Text { get; set; } = string.Empty;
///
/// The address an image is loaded from. Not used for text.
///
[Parameter]
public string MediaUrl { get; set; } = string.Empty;
///
/// What the bar above the content shows at its start, e.g., what the content is, where it comes
/// from, and a button to open it there.
///
[Parameter]
public RenderFragment? Toolbar { get; set; }
///
/// The hint below the text, or nothing.
///
[Parameter]
public string HelperText { get; set; } = string.Empty;
///
/// How many characters of the text we show at most, or null to show all of it.
///
[Parameter]
public int? CharacterLimit { get; set; }
///
/// Explains that the text was cut, given the number of characters left out. Null shows no explanation.
///
[Parameter]
public Func? CutOffNotice { get; set; }
///
/// The height which does not limit the content: it may take all the room its place leaves over,
/// e.g., in a dialog which fits the window, see dialog-content-fit in app.css.
///
public const string NO_HEIGHT_LIMIT = "none";
///
/// How high the content may grow before it scrolls, unless its place says otherwise.
///
public const string DEFAULT_MAX_HEIGHT = "40vh";
///
/// How high the content may grow before it scrolls, as a CSS length.
///
[Parameter]
public string MaxHeight { get; set; } = DEFAULT_MAX_HEIGHT;
///
/// The ways to see this content, in the order they are offered; the first one is shown first.
///
private readonly List views = [];
///
/// The way the content is shown right now.
///
private ContentView view;
///
/// The text and the limit the current cut was made of, so that we cut once per text instead of once per render.
///
private string? cutText;
private int? cutLimit;
///
/// What we show of the text: all of it, or its beginning.
///
private string shownText = string.Empty;
///
/// How many characters we left out. Zero when we show the entire text.
///
private int cutOffCharacters;
#region Overrides of ComponentBase
protected override void OnParametersSet()
{
if (!ReferenceEquals(this.cutText, this.Text) || this.cutLimit != this.CharacterLimit)
{
this.cutText = this.Text;
this.cutLimit = this.CharacterLimit;
this.shownText = CutAtLineBreak(this.Text, this.CharacterLimit ?? int.MaxValue, out this.cutOffCharacters);
}
//
// An image is seen as itself first. Text is seen formatted and as plain text, for text and
// for an image which brings some along. The way the user chose stays as long as it is offered:
//
this.views.Clear();
if (this.Kind is not ContentViewerKind.TEXT)
this.views.Add(ContentView.MEDIA);
if (this.Kind is ContentViewerKind.TEXT || !string.IsNullOrWhiteSpace(this.Text))
this.views.AddRange([ContentView.FORMATTED, ContentView.PLAIN]);
if (!this.views.Contains(this.view))
this.view = this.views[0];
base.OnParametersSet();
}
#endregion
private string GetViewName(ContentView contentView) => contentView switch
{
ContentView.MEDIA => T("Image"),
ContentView.FORMATTED => T("Formatted"),
_ => T("Plain text"),
};
///
/// Takes the beginning of a text which is too long to show.
///
///
/// We cut at the last line break before the limit. Otherwise, we might tear apart a Markdown
/// construct like a table row or a code fence in the middle of a line. A text without any line
/// break before the limit is cut at the limit itself.
///
/// The text.
/// How many characters to keep at most.
/// How many characters were left out; zero when nothing was.
/// The text itself when it fits, otherwise its beginning.
internal static string CutAtLineBreak(string text, int characterLimit, out int cutOffCharacters)
{
if (text.Length <= characterLimit)
{
cutOffCharacters = 0;
return text;
}
var cutIndex = text.LastIndexOf('\n', characterLimit - 1) + 1;
if (cutIndex < 1)
cutIndex = characterLimit;
cutOffCharacters = text.Length - cutIndex;
return text[..cutIndex];
}
}