mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-05 00:47:21 +00:00
Highlighted code blocks inside the app (#1153)
This commit is contained in:
1 parent
0b42c0288e
commit
f4a86c2fb8
60 files changed
+2550
-41
No files matched your search
@@ -0,0 +1,97 @@
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// The copy button of a code block which AI Studio highlights itself, made like the one of
|
||||
/// MudBlazor.Markdown, which is internal: the same classes, so that it appears at the same place while
|
||||
/// the pointer is on the block, and the same confirmation for a moment after a click.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It copies through the clipboard service of the app, the native clipboard of the runtime, which the
|
||||
/// button of the library falls back to as well.
|
||||
/// </remarks>
|
||||
public sealed class CodeBlockCopyButton : ComponentBase
|
||||
{
|
||||
private const string CSS_CLASS = "ma-2 mud-markdown-code-highlight-copybtn";
|
||||
|
||||
private static readonly TimeSpan CONFIRMATION_DURATION = TimeSpan.FromSeconds(1.25);
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(CodeBlockCopyButton).Namespace, nameof(CodeBlockCopyButton));
|
||||
|
||||
/// <summary>
|
||||
/// The code to copy.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
[Inject]
|
||||
private IMudMarkdownClipboardService ClipboardService { get; init; } = null!;
|
||||
|
||||
private bool isCopied;
|
||||
|
||||
/// <summary>
|
||||
/// What a copy button of a code block confirms with, those of MudBlazor.Markdown included.
|
||||
/// </summary>
|
||||
internal static string ConfirmationText => TB("Copied!");
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override void BuildRenderTree(RenderTreeBuilder builder)
|
||||
{
|
||||
if (!this.isCopied)
|
||||
{
|
||||
builder.OpenComponent<MudIconButton>(0);
|
||||
builder.AddComponentParameter(1, nameof(MudIconButton.OnClick), EventCallback.Factory.Create<MouseEventArgs>(this, this.CopyAsync));
|
||||
builder.AddComponentParameter(2, nameof(MudIconButton.Class), CSS_CLASS);
|
||||
builder.AddComponentParameter(3, nameof(MudIconButton.Icon), Icons.Material.Rounded.ContentCopy);
|
||||
builder.AddComponentParameter(4, nameof(MudIconButton.Color), Color.Primary);
|
||||
builder.AddComponentParameter(5, nameof(MudIconButton.Variant), Variant.Filled);
|
||||
builder.AddComponentParameter(6, nameof(MudIconButton.Size), Size.Medium);
|
||||
builder.CloseComponent();
|
||||
return;
|
||||
}
|
||||
|
||||
builder.OpenComponent<MudTooltip>(10);
|
||||
builder.AddComponentParameter(11, nameof(MudTooltip.Text), ConfirmationText);
|
||||
builder.AddComponentParameter(12, nameof(MudTooltip.Arrow), true);
|
||||
builder.AddComponentParameter(13, nameof(MudTooltip.Placement), Placement.Left);
|
||||
builder.AddComponentParameter(14, nameof(MudTooltip.Visible), true);
|
||||
builder.AddComponentParameter(15, nameof(MudTooltip.ShowOnClick), false);
|
||||
builder.AddComponentParameter(16, nameof(MudTooltip.ShowOnFocus), false);
|
||||
builder.AddComponentParameter(17, nameof(MudTooltip.ShowOnHover), false);
|
||||
builder.AddComponentParameter(18, nameof(MudTooltip.RootClass), CSS_CLASS);
|
||||
builder.AddComponentParameter(19, nameof(MudTooltip.ChildContent), (RenderFragment)(content =>
|
||||
{
|
||||
content.OpenComponent<MudIconButton>(0);
|
||||
content.AddComponentParameter(1, nameof(MudIconButton.Icon), Icons.Material.Rounded.DoneAll);
|
||||
content.AddComponentParameter(2, nameof(MudIconButton.Color), Color.Success);
|
||||
content.AddComponentParameter(3, nameof(MudIconButton.Variant), Variant.Filled);
|
||||
content.AddComponentParameter(4, nameof(MudIconButton.Size), Size.Medium);
|
||||
content.CloseComponent();
|
||||
}));
|
||||
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task CopyAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this.Text))
|
||||
return;
|
||||
|
||||
await this.ClipboardService.CopyToClipboardAsync(this.Text);
|
||||
|
||||
this.isCopied = true;
|
||||
this.StateHasChanged();
|
||||
await Task.Delay(CONFIRMATION_DURATION);
|
||||
|
||||
this.isCopied = false;
|
||||
this.StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
break;
|
||||
|
||||
case ContentView.FORMATTED:
|
||||
<MudMarkdown Value="@this.shownText" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.CONTENT_MARKDOWN_PIPELINE"/>
|
||||
<HighlightedMarkdown Value="@this.shownText" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.CONTENT_MARKDOWN_PIPELINE"/>
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -4,14 +4,14 @@ using Microsoft.AspNetCore.Components.Rendering;
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Renders Markdown the way MudMarkdown does, and shows actions next to every code block which is
|
||||
/// one of the files of its message, such as a button to save that block.
|
||||
/// Renders Markdown the way HighlightedMarkdown does, and shows actions next to every code block which
|
||||
/// is one of the files of its message, such as a button to save that block.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In a long answer, the export menu at the top of the message lies far away from the code block
|
||||
/// the user is reading. MudMarkdown renders every code block through RenderCodeBlock, the highlighted
|
||||
/// code together with its copy button, and lets us override that. We wrap what it renders and put our
|
||||
/// actions beside it, so the copy button stays exactly as the library made it.
|
||||
/// code together with its copy button, and lets us override that. We wrap what HighlightedMarkdown
|
||||
/// renders and put our actions beside it, so the copy button stays exactly as it was made.
|
||||
///
|
||||
/// A block finds its file by where it starts. The chat renders an answer in parts, cut around its
|
||||
/// formulas, and Markdig reads each part on its own: a block knows where it starts within its part
|
||||
@@ -19,7 +19,7 @@ namespace AIStudio.Components;
|
||||
/// A block without a file, such as a formula or a block the model never closed, gets no actions,
|
||||
/// just as the export menu does not offer it.
|
||||
/// </remarks>
|
||||
public sealed class ExportableMarkdown : MudMarkdown
|
||||
public sealed class ExportableMarkdown : HighlightedMarkdown
|
||||
{
|
||||
/// <summary>
|
||||
/// The files of the message, as PlainFileExport.ExtractFiles read them out of its text. Without
|
||||
@@ -48,7 +48,7 @@ public sealed class ExportableMarkdown : MudMarkdown
|
||||
/// <returns>The file, or null when no file starts there.</returns>
|
||||
internal static MessageFile? FileAt(IReadOnlyList<MessageFile> files, int position) => files.FirstOrDefault(file => file.Position == position);
|
||||
|
||||
#region Overrides of MudMarkdown
|
||||
#region Overrides of HighlightedMarkdown
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void RenderCodeBlock(in RenderTreeBuilder builder, ref int elementIndex, in Markdig.Syntax.CodeBlock code, in string? info)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using AIStudio.Tools.CodeHighlighting;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// A code block in a language which AI Studio highlights itself, see CodeHighlighters. It is rendered
|
||||
/// in the markup of MudBlazor.Markdown, so that it looks like the blocks the library highlights: the
|
||||
/// same classes, the same colors of the theme, and the same copy button.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its parameters are strings only. Blazor then renders it again only when the code or the language
|
||||
/// change, so while an answer streams in, only the block which grows is highlighted again.
|
||||
/// </remarks>
|
||||
public sealed class HighlightedCodeBlock : ComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The code of the block.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The language behind the opening fence, as the model wrote it, for example py.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Language { get; set; } = string.Empty;
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override void BuildRenderTree(RenderTreeBuilder builder)
|
||||
{
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "hljs mud-markdown-code-highlight");
|
||||
|
||||
builder.OpenComponent<CodeBlockCopyButton>(2);
|
||||
builder.AddComponentParameter(3, nameof(CodeBlockCopyButton.Text), this.Code);
|
||||
builder.CloseComponent();
|
||||
|
||||
//
|
||||
// MudMarkdown leaves the typography of its code blocks at the default value, and so do we:
|
||||
//
|
||||
builder.OpenComponent<MudText>(4);
|
||||
builder.AddComponentParameter(5, nameof(MudText.HtmlTag), "pre");
|
||||
builder.AddComponentParameter(6, nameof(MudText.Typo), default(Typo));
|
||||
builder.AddComponentParameter(7, nameof(MudText.ChildContent), (RenderFragment)this.RenderCode);
|
||||
builder.CloseComponent();
|
||||
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void RenderCode(RenderTreeBuilder builder)
|
||||
{
|
||||
builder.OpenElement(0, "code");
|
||||
builder.AddAttribute(1, "class", $"hljs language-{this.Language}");
|
||||
if (CodeHighlighters.TryGetRules(this.Language, out var rules))
|
||||
{
|
||||
var position = 0;
|
||||
foreach (var token in CodeTokenizer.Tokenize(this.Code, rules))
|
||||
{
|
||||
if (token.Start > position)
|
||||
builder.AddContent(2, this.Code[position..token.Start]);
|
||||
|
||||
builder.OpenElement(3, "span");
|
||||
builder.AddAttribute(4, "class", token.Kind.ToCssClass());
|
||||
builder.AddContent(5, this.Code.Substring(token.Start, token.Length));
|
||||
builder.CloseElement();
|
||||
position = token.Start + token.Length;
|
||||
}
|
||||
|
||||
if (position < this.Code.Length)
|
||||
builder.AddContent(6, this.Code[position..]);
|
||||
}
|
||||
else
|
||||
builder.AddContent(7, this.Code);
|
||||
|
||||
builder.CloseElement();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using AIStudio.Tools.CodeHighlighting;
|
||||
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Renders Markdown the way MudMarkdown does, and highlights the code blocks of more languages than
|
||||
/// the library does, such as Python or SQL.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// MudBlazor.Markdown highlights only a few languages itself, and its highlighter is internal.
|
||||
/// MudMarkdown renders every code block through RenderCodeBlock, though, and lets us override that.
|
||||
/// A block in one of the languages of CodeHighlighters becomes a HighlightedCodeBlock; any other block
|
||||
/// stays as the library renders it. A block which names no language gets the one its code is written in,
|
||||
/// see CodeLanguageDetector, the same as when it is saved as a file.
|
||||
/// </remarks>
|
||||
public class HighlightedMarkdown : MudMarkdown
|
||||
{
|
||||
/// <summary>
|
||||
/// The text of a code block: its lines, each with the line break it came with.
|
||||
/// </summary>
|
||||
/// <param name="code">The code block, as Markdig read it.</param>
|
||||
/// <returns>The text of the block.</returns>
|
||||
internal static string TextOf(Markdig.Syntax.CodeBlock code) => code.Lines.ToString();
|
||||
|
||||
#region Overrides of MudMarkdown
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void RenderCodeBlock(in RenderTreeBuilder builder, ref int elementIndex, in Markdig.Syntax.CodeBlock code, in string? info)
|
||||
{
|
||||
var text = TextOf(code);
|
||||
var language = CodeLanguageDetector.FenceLanguageOf(info, text);
|
||||
if (!CodeHighlighters.TryGetRules(language, out _))
|
||||
{
|
||||
//
|
||||
// The copy button of the library confirms in the language of the app, as ours does:
|
||||
//
|
||||
this.Styling.CodeBlock.CopyButtonText = CodeBlockCopyButton.ConfirmationText;
|
||||
base.RenderCodeBlock(builder, ref elementIndex, code, language);
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Our frames are numbered by their place in this method, as Blazor asks for. The frames of
|
||||
// the library keep the running count it hands through every render method instead:
|
||||
//
|
||||
builder.OpenComponent<HighlightedCodeBlock>(0);
|
||||
builder.AddComponentParameter(1, nameof(HighlightedCodeBlock.Code), text);
|
||||
builder.AddComponentParameter(2, nameof(HighlightedCodeBlock.Language), language);
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -23,19 +23,6 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDispo
|
||||
|
||||
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
|
||||
|
||||
/// <summary>
|
||||
/// How Markdown rendered by this component looks: its code blocks follow the color theme of the app.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every component which renders Markdown wants the same, so it is written down once here instead
|
||||
/// of in each of them. Read on every render: a change of the color theme renders every component
|
||||
/// again, cf. ProcessMessage, and the code blocks change along with it.
|
||||
/// </remarks>
|
||||
protected MudMarkdownStyling MarkdownStyling => new()
|
||||
{
|
||||
CodeBlock = { Theme = this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default },
|
||||
};
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
|
||||
Reference in new issue
Block a user