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]; } }