feat: add ToolbarScope parameter to editors for scoped toolbar contributions

- Introduce a new ToolbarScope parameter in SbMarkdownEditor, SbMarkEditor, and SbRichTextEditor to filter toolbar contributors based on context.
- Update related services and interfaces to support scoped contributions, enhancing the flexibility of toolbar item management.
- Add disposal handling in SbSelectOption to unregister options properly.
- Implement CSS coverage fixes for various components to ensure consistent styling across the application.
This commit is contained in:
2026-06-30 01:44:24 +03:30
parent 6b9eb5c8fe
commit b7830a3e32
14 changed files with 1234 additions and 23 deletions
+1
View File
@@ -13,5 +13,6 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);CS8601;CS8602;CS8603;CS8604;CS8613;CS8618;CS8619;CS8620;CS8625;CS8629;CS8632;CS8669;CS8609;CS8767</NoWarn>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
@@ -19,6 +19,7 @@
SourceLanguage="@ResolveSourceLanguage()" SourceLanguage="@ResolveSourceLanguage()"
UseToolbarContributors="@UseToolbarContributors" UseToolbarContributors="@UseToolbarContributors"
IncludeDefaultToolbarItems="@IncludeDefaultToolbarItems" IncludeDefaultToolbarItems="@IncludeDefaultToolbarItems"
ToolbarScope="@ToolbarScope"
HideToolbar="@HideToolbar" HideToolbar="@HideToolbar"
ToolbarItems="@ToolbarItems" ToolbarItems="@ToolbarItems"
OnShortcut="@OnShortcut" OnShortcut="@OnShortcut"
@@ -23,6 +23,11 @@ public partial class SbMarkEditor
[Parameter] public string? SourceLanguage { get; set; } [Parameter] public string? SourceLanguage { get; set; }
[Parameter] public bool UseToolbarContributors { get; set; } [Parameter] public bool UseToolbarContributors { get; set; }
[Parameter] public bool IncludeDefaultToolbarItems { get; set; } = true; [Parameter] public bool IncludeDefaultToolbarItems { get; set; } = true;
/// <summary>
/// Optional toolbar scope forwarded to <see cref="SbMarkdownEditor"/> to
/// filter which registered <see cref="IMdToolbarContributor"/> instances run.
/// </summary>
[Parameter] public string? ToolbarScope { get; set; }
[Parameter] public bool HideToolbar { get; set; } [Parameter] public bool HideToolbar { get; set; }
[Parameter] public IReadOnlyList<SbMarkdownToolbarItem>? ToolbarItems { get; set; } [Parameter] public IReadOnlyList<SbMarkdownToolbarItem>? ToolbarItems { get; set; }
[Parameter] public EventCallback<string> OnShortcut { get; set; } [Parameter] public EventCallback<string> OnShortcut { get; set; }
@@ -28,6 +28,14 @@ public partial class SbMarkdownEditor : ComponentBase, IAsyncDisposable
[Parameter] public string? SourceLanguage { get; set; } [Parameter] public string? SourceLanguage { get; set; }
[Parameter] public bool UseToolbarContributors { get; set; } [Parameter] public bool UseToolbarContributors { get; set; }
[Parameter] public bool IncludeDefaultToolbarItems { get; set; } = true; [Parameter] public bool IncludeDefaultToolbarItems { get; set; } = true;
/// <summary>
/// Optional toolbar scope that filters which <see cref="IMdToolbarContributor"/>
/// instances run for this editor. Contributors whose <c>Scope</c> is non-null
/// only execute when it matches this value. Contributors with a null scope
/// always run. This prevents page-specific toolbar items from leaking across
/// navigations within the same Blazor circuit.
/// </summary>
[Parameter] public string? ToolbarScope { get; set; }
[Parameter] public bool HideToolbar { get; set; } [Parameter] public bool HideToolbar { get; set; }
[Parameter] public IReadOnlyList<SbMarkdownToolbarItem>? ToolbarItems { get; set; } [Parameter] public IReadOnlyList<SbMarkdownToolbarItem>? ToolbarItems { get; set; }
[Parameter] public EventCallback<string> OnShortcut { get; set; } [Parameter] public EventCallback<string> OnShortcut { get; set; }
@@ -56,6 +64,7 @@ public partial class SbMarkdownEditor : ComponentBase, IAsyncDisposable
private SbMarkdownEditorMode _lastEditorMode; private SbMarkdownEditorMode _lastEditorMode;
private string? _lastSourceLanguage; private string? _lastSourceLanguage;
private bool _lastIncludeDefaultToolbarItems; private bool _lastIncludeDefaultToolbarItems;
private string? _lastToolbarScope;
private List<SbMarkdownToolbarItem> _toolbarItems = new(); private List<SbMarkdownToolbarItem> _toolbarItems = new();
private bool _useFallback; private bool _useFallback;
private bool _disposed; private bool _disposed;
@@ -88,6 +97,7 @@ public partial class SbMarkdownEditor : ComponentBase, IAsyncDisposable
_lastEditorMode = EditorMode; _lastEditorMode = EditorMode;
_lastSourceLanguage = SourceLanguage; _lastSourceLanguage = SourceLanguage;
_lastIncludeDefaultToolbarItems = IncludeDefaultToolbarItems; _lastIncludeDefaultToolbarItems = IncludeDefaultToolbarItems;
_lastToolbarScope = ToolbarScope;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
return; return;
} }
@@ -115,11 +125,13 @@ public partial class SbMarkdownEditor : ComponentBase, IAsyncDisposable
else if (Value != _lastRenderedValue || else if (Value != _lastRenderedValue ||
EditorMode != _lastEditorMode || EditorMode != _lastEditorMode ||
SourceLanguage != _lastSourceLanguage || SourceLanguage != _lastSourceLanguage ||
IncludeDefaultToolbarItems != _lastIncludeDefaultToolbarItems) IncludeDefaultToolbarItems != _lastIncludeDefaultToolbarItems ||
ToolbarScope != _lastToolbarScope)
{ {
if (EditorMode != _lastEditorMode || if (EditorMode != _lastEditorMode ||
SourceLanguage != _lastSourceLanguage || SourceLanguage != _lastSourceLanguage ||
IncludeDefaultToolbarItems != _lastIncludeDefaultToolbarItems) IncludeDefaultToolbarItems != _lastIncludeDefaultToolbarItems ||
ToolbarScope != _lastToolbarScope)
{ {
await ReinitializeEditorAsync(); await ReinitializeEditorAsync();
} }
@@ -145,7 +157,8 @@ public partial class SbMarkdownEditor : ComponentBase, IAsyncDisposable
_toolbarItems = await ToolbarService.GetToolbarItemsAsync( _toolbarItems = await ToolbarService.GetToolbarItemsAsync(
editorId, editorId,
includeDefaults: IncludeDefaultToolbarItems, includeDefaults: IncludeDefaultToolbarItems,
includeContributors: true); includeContributors: true,
scope: ToolbarScope);
} }
else if (ToolbarItems != null) else if (ToolbarItems != null)
{ {
@@ -261,6 +274,7 @@ public partial class SbMarkdownEditor : ComponentBase, IAsyncDisposable
_lastEditorMode = EditorMode; _lastEditorMode = EditorMode;
_lastSourceLanguage = SourceLanguage; _lastSourceLanguage = SourceLanguage;
_lastIncludeDefaultToolbarItems = IncludeDefaultToolbarItems; _lastIncludeDefaultToolbarItems = IncludeDefaultToolbarItems;
_lastToolbarScope = ToolbarScope;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -382,6 +382,16 @@
[Parameter] [Parameter]
public bool IncludeDefaultToolbarItems { get; set; } = true; public bool IncludeDefaultToolbarItems { get; set; } = true;
/// <summary>
/// Optional toolbar scope that filters which <see cref="IRteToolbarContributor"/>
/// instances run for this editor. Contributors whose <c>Scope</c> is non-null
/// only execute when it matches this value. Contributors with a null scope
/// always run. This prevents page-specific toolbar items from leaking across
/// navigations within the same Blazor circuit.
/// </summary>
[Parameter]
public string? ToolbarScope { get; set; }
#region Localization Parameters #region Localization Parameters
/// <summary> /// <summary>
@@ -568,10 +578,11 @@
// Get contributed items // Get contributed items
_contributedToolbarItems = await _toolbarService.GetToolbarItemsAsync( _contributedToolbarItems = await _toolbarService.GetToolbarItemsAsync(
_editorId, _editorId,
IncludeDefaultToolbarItems); IncludeDefaultToolbarItems,
ToolbarScope);
// Build a map of contributed items for click handling // Build a map of contributed items for click handling
var contributedItems = await _toolbarService.GetContributedItemsAsync(_editorId); var contributedItems = await _toolbarService.GetContributedItemsAsync(_editorId, ToolbarScope);
_contributedItemsMap = contributedItems _contributedItemsMap = contributedItems
.Where(item => item.OnClickAsync != null) .Where(item => item.OnClickAsync != null)
.ToDictionary(item => item.Id, item => item); .ToDictionary(item => item.Id, item => item);
@@ -1,5 +1,6 @@
@namespace SufiChain.SufiBlazor.Components.Forms @namespace SufiChain.SufiBlazor.Components.Forms
@typeparam TValue @typeparam TValue
@implements IDisposable
@* This component is used as a child of SbSelect to define options declaratively. *@ @* This component is used as a child of SbSelect to define options declaratively. *@
@* It registers itself with the parent SbSelect via cascading parameter. *@ @* It registers itself with the parent SbSelect via cascading parameter. *@
@@ -54,4 +55,9 @@
Disabled = Disabled Disabled = Disabled
}); });
} }
public void Dispose()
{
Context?.UnregisterOption(Value);
}
} }
@@ -220,15 +220,36 @@
void ISbSelectOptionContext<TValue>.RegisterOption(SbSelectOptionInfo<TValue> option) void ISbSelectOptionContext<TValue>.RegisterOption(SbSelectOptionInfo<TValue> option)
{ {
if (!_options.Any(o => EqualityComparer<TValue>.Default.Equals(o.Value, option.Value))) // Update existing entry in place so Text/ChildContent changes propagate;
// otherwise add. We must NOT clear _options here or in OnParametersSet,
// because Blazor skips calling OnParametersSet on SbSelectOption children
// whose parameters haven't changed, which would leave _options empty and
// cause GetDisplayText to fall back to Value.ToString() (e.g. a Guid).
var existingIndex = _options.FindIndex(o =>
EqualityComparer<TValue>.Default.Equals(o.Value, option.Value));
if (existingIndex >= 0)
{
_options[existingIndex] = option;
}
else
{ {
_options.Add(option); _options.Add(option);
} }
} }
void ISbSelectOptionContext<TValue>.UnregisterOption(TValue? value)
{
var index = _options.FindIndex(o =>
EqualityComparer<TValue>.Default.Equals(o.Value, value));
if (index >= 0)
{
_options.RemoveAt(index);
}
}
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
_options.Clear(); // Intentionally do NOT clear _options here. See RegisterOption note above.
} }
private IEnumerable<SbSelectOptionInfo<TValue>> FilteredOptions private IEnumerable<SbSelectOptionInfo<TValue>> FilteredOptions
@@ -15,6 +15,15 @@ public interface IMdToolbarContributor
/// </summary> /// </summary>
int Order => 100; int Order => 100;
/// <summary>
/// The toolbar scope this contributor belongs to. When non-null, the
/// contributor only runs on editors whose <c>ToolbarScope</c> parameter
/// matches this value. When null (default), the contributor runs on every
/// editor instance — use this for self-filtering contributors (e.g. ones
/// that check a host registration) or for globally-applicable items.
/// </summary>
string? Scope => null;
/// <summary> /// <summary>
/// Configure the toolbar by adding custom items to the context. /// Configure the toolbar by adding custom items to the context.
/// </summary> /// </summary>
@@ -30,6 +39,13 @@ public class MdToolbarContext
public IServiceProvider ServiceProvider { get; } public IServiceProvider ServiceProvider { get; }
public string? EditorId { get; set; } public string? EditorId { get; set; }
/// <summary>
/// The scope declared by the editor instance via its <c>ToolbarScope</c>
/// parameter. Contributors whose <see cref="IMdToolbarContributor.Scope"/>
/// is non-null only run when this value matches.
/// </summary>
public string? Scope { get; set; }
public MdToolbarContext(IServiceProvider serviceProvider) public MdToolbarContext(IServiceProvider serviceProvider)
{ {
ServiceProvider = serviceProvider; ServiceProvider = serviceProvider;
@@ -16,6 +16,15 @@ public interface IRteToolbarContributor
/// </summary> /// </summary>
int Order => 100; int Order => 100;
/// <summary>
/// The toolbar scope this contributor belongs to. When non-null, the
/// contributor only runs on editors whose <c>ToolbarScope</c> parameter
/// matches this value. When null (default), the contributor runs on every
/// editor instance — use this for self-filtering contributors or for
/// globally-applicable items (e.g. culture-aware font selectors).
/// </summary>
string? Scope => null;
/// <summary> /// <summary>
/// Configure the toolbar by adding custom items to the context. /// Configure the toolbar by adding custom items to the context.
/// </summary> /// </summary>
@@ -43,6 +52,13 @@ public class RteToolbarContext
/// </summary> /// </summary>
public string? EditorId { get; set; } public string? EditorId { get; set; }
/// <summary>
/// The scope declared by the editor instance via its <c>ToolbarScope</c>
/// parameter. Contributors whose <see cref="IRteToolbarContributor.Scope"/>
/// is non-null only run when this value matches.
/// </summary>
public string? Scope { get; set; }
public RteToolbarContext(IServiceProvider serviceProvider) public RteToolbarContext(IServiceProvider serviceProvider)
{ {
ServiceProvider = serviceProvider; ServiceProvider = serviceProvider;
@@ -24,7 +24,8 @@ public class MdToolbarService : IMdToolbarService
public async Task<List<SbMarkdownToolbarItem>> GetToolbarItemsAsync( public async Task<List<SbMarkdownToolbarItem>> GetToolbarItemsAsync(
string? editorId = null, string? editorId = null,
bool? includeDefaults = null, bool? includeDefaults = null,
bool includeContributors = true) bool includeContributors = true,
string? scope = null)
{ {
var shouldIncludeDefaults = includeDefaults ?? _options.IncludeDefaultItems; var shouldIncludeDefaults = includeDefaults ?? _options.IncludeDefaultItems;
var allItems = new List<MdToolbarContributedItem>(); var allItems = new List<MdToolbarContributedItem>();
@@ -36,8 +37,12 @@ public class MdToolbarService : IMdToolbarService
if (includeContributors) if (includeContributors)
{ {
var context = new MdToolbarContext(_serviceProvider) { EditorId = editorId }; var context = new MdToolbarContext(_serviceProvider)
foreach (var contributor in GetContributors().OrderBy(c => c.Order)) {
EditorId = editorId,
Scope = scope
};
foreach (var contributor in GetContributors(scope).OrderBy(c => c.Order))
{ {
await contributor.ConfigureToolbarAsync(context); await contributor.ConfigureToolbarAsync(context);
} }
@@ -80,11 +85,12 @@ public class MdToolbarService : IMdToolbarService
} }
} }
private IEnumerable<IMdToolbarContributor> GetContributors() private IEnumerable<IMdToolbarContributor> GetContributors(string? scope)
{ {
foreach (var contributorType in _options.Contributors) foreach (var contributorType in _options.Contributors)
{ {
if (_serviceProvider.GetService(contributorType) is IMdToolbarContributor contributor) if (_serviceProvider.GetService(contributorType) is IMdToolbarContributor contributor
&& (contributor.Scope == null || string.Equals(contributor.Scope, scope, StringComparison.Ordinal)))
{ {
yield return contributor; yield return contributor;
} }
@@ -122,6 +128,7 @@ public interface IMdToolbarService
Task<List<SbMarkdownToolbarItem>> GetToolbarItemsAsync( Task<List<SbMarkdownToolbarItem>> GetToolbarItemsAsync(
string? editorId = null, string? editorId = null,
bool? includeDefaults = null, bool? includeDefaults = null,
bool includeContributors = true); bool includeContributors = true,
string? scope = null);
Task ExecuteItemActionAsync(MdToolbarContributedItem item, MdToolbarActionContext actionContext); Task ExecuteItemActionAsync(MdToolbarContributedItem item, MdToolbarActionContext actionContext);
} }
@@ -28,9 +28,11 @@ public class RteToolbarService : IRteToolbarService
/// </summary> /// </summary>
/// <param name="editorId">The editor instance ID.</param> /// <param name="editorId">The editor instance ID.</param>
/// <param name="includeDefaults">Whether to include default toolbar items.</param> /// <param name="includeDefaults">Whether to include default toolbar items.</param>
/// <param name="scope">Optional toolbar scope that filters which contributors run.</param>
public async Task<List<SbEditorToolbarItem>> GetToolbarItemsAsync( public async Task<List<SbEditorToolbarItem>> GetToolbarItemsAsync(
string? editorId = null, string? editorId = null,
bool? includeDefaults = null) bool? includeDefaults = null,
string? scope = null)
{ {
var shouldIncludeDefaults = includeDefaults ?? _options.IncludeDefaultItems; var shouldIncludeDefaults = includeDefaults ?? _options.IncludeDefaultItems;
var allItems = new List<RteToolbarContributedItem>(); var allItems = new List<RteToolbarContributedItem>();
@@ -42,9 +44,13 @@ public class RteToolbarService : IRteToolbarService
} }
// Get contributed items from all registered contributors // Get contributed items from all registered contributors
var context = new RteToolbarContext(_serviceProvider) { EditorId = editorId }; var context = new RteToolbarContext(_serviceProvider)
{
EditorId = editorId,
Scope = scope
};
var contributors = GetContributors(); var contributors = GetContributors(scope);
foreach (var contributor in contributors.OrderBy(c => c.Order)) foreach (var contributor in contributors.OrderBy(c => c.Order))
{ {
await contributor.ConfigureToolbarAsync(context); await contributor.ConfigureToolbarAsync(context);
@@ -84,11 +90,17 @@ public class RteToolbarService : IRteToolbarService
/// <summary> /// <summary>
/// Get the contributed items only (without defaults). /// Get the contributed items only (without defaults).
/// </summary> /// </summary>
public async Task<List<RteToolbarContributedItem>> GetContributedItemsAsync(string? editorId = null) public async Task<List<RteToolbarContributedItem>> GetContributedItemsAsync(
string? editorId = null,
string? scope = null)
{ {
var context = new RteToolbarContext(_serviceProvider) { EditorId = editorId }; var context = new RteToolbarContext(_serviceProvider)
{
EditorId = editorId,
Scope = scope
};
var contributors = GetContributors(); var contributors = GetContributors(scope);
foreach (var contributor in contributors.OrderBy(c => c.Order)) foreach (var contributor in contributors.OrderBy(c => c.Order))
{ {
await contributor.ConfigureToolbarAsync(context); await contributor.ConfigureToolbarAsync(context);
@@ -110,12 +122,13 @@ public class RteToolbarService : IRteToolbarService
} }
} }
private IEnumerable<IRteToolbarContributor> GetContributors() private IEnumerable<IRteToolbarContributor> GetContributors(string? scope)
{ {
foreach (var contributorType in _options.Contributors) foreach (var contributorType in _options.Contributors)
{ {
var contributor = _serviceProvider.GetService(contributorType) as IRteToolbarContributor; var contributor = _serviceProvider.GetService(contributorType) as IRteToolbarContributor;
if (contributor != null) if (contributor != null
&& (contributor.Scope == null || string.Equals(contributor.Scope, scope, StringComparison.Ordinal)))
{ {
yield return contributor; yield return contributor;
} }
@@ -201,12 +214,17 @@ public interface IRteToolbarService
/// <summary> /// <summary>
/// Get all toolbar items including default and contributed items. /// Get all toolbar items including default and contributed items.
/// </summary> /// </summary>
Task<List<SbEditorToolbarItem>> GetToolbarItemsAsync(string? editorId = null, bool? includeDefaults = null); Task<List<SbEditorToolbarItem>> GetToolbarItemsAsync(
string? editorId = null,
bool? includeDefaults = null,
string? scope = null);
/// <summary> /// <summary>
/// Get the contributed items only (without defaults). /// Get the contributed items only (without defaults).
/// </summary> /// </summary>
Task<List<RteToolbarContributedItem>> GetContributedItemsAsync(string? editorId = null); Task<List<RteToolbarContributedItem>> GetContributedItemsAsync(
string? editorId = null,
string? scope = null);
/// <summary> /// <summary>
/// Execute a contributed toolbar item's click handler. /// Execute a contributed toolbar item's click handler.
@@ -13,6 +13,12 @@ internal interface ISbSelectOptionContext<TValue>
/// </summary> /// </summary>
/// <param name="option">The option information to register.</param> /// <param name="option">The option information to register.</param>
void RegisterOption(SbSelectOptionInfo<TValue> option); void RegisterOption(SbSelectOptionInfo<TValue> option);
/// <summary>
/// Removes a previously registered option (e.g. when its SbSelectOption is disposed).
/// </summary>
/// <param name="value">The value of the option to unregister.</param>
void UnregisterOption(TValue? value);
} }
/// <summary> /// <summary>
@@ -15,6 +15,7 @@
<!-- Generate XML documentation --> <!-- Generate XML documentation -->
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'"> <PropertyGroup Condition="'$(Configuration)' == 'Release'">
File diff suppressed because it is too large Load Diff