From 4078d98f57984a88e3a23efb3d95b55f90439f0a Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Thu, 13 Aug 2026 00:04:07 +0530 Subject: [PATCH 01/53] Add dependency-free RichTextEditor component Introduced a new RichTextEditor to BlazorBootstrap with no third-party dependencies. Includes grouped toolbars, dialogs for links/images/tables, image upload support, strong client-side HTML sanitization, and accessibility features. Added JS interop, DI registration, supporting types, component-specific CSS, documentation, and a demo page. All HTML input/output is sanitized on the client, with guidance for server-side sanitization. --- .../RichTextEditorDocumentation.razor | 37 +++ .../Form/RichTextEditor/RichTextEditor.razor | 31 ++ .../RichTextEditor/RichTextEditor.razor.cs | 281 ++++++++++++++++++ .../RichTextEditor/RichTextEditor.razor.css | 69 +++++ .../RichTextEditorEditableArea.razor | 20 ++ .../RichTextEditorImageDialog.razor | 19 ++ .../RichTextEditor/RichTextEditorJsInterop.cs | 22 ++ .../RichTextEditorLinkDialog.razor | 17 ++ .../RichTextEditorTableDialog.razor | 19 ++ .../RichTextEditorToolbar.razor | 61 ++++ .../RichTextEditorToolbarButton.razor | 52 ++++ .../RichTextEditorToolbarGroup.razor | 11 + .../RichTextEditorToolbarSelect.razor | 30 ++ blazorbootstrap/Config.cs | 3 +- .../Enums/RichTextEditorToolbarItem.cs | 31 ++ .../RichTextEditorImageUploadDelegate.cs | 6 + .../RichTextEditorImageUploadRequest.cs | 23 ++ .../Models/RichTextEditorImageUploadResult.cs | 12 + .../blazor.bootstrap.rich-text-editor.js | 263 ++++++++++++++++ docs/docs/04-forms/rich-text-editor.mdx | 24 ++ 20 files changed, 1030 insertions(+), 1 deletion(-) create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.css create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorEditableArea.razor create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorImageDialog.razor create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorLinkDialog.razor create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorTableDialog.razor create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarGroup.razor create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarSelect.razor create mode 100644 blazorbootstrap/Enums/RichTextEditorToolbarItem.cs create mode 100644 blazorbootstrap/Models/RichTextEditorImageUploadDelegate.cs create mode 100644 blazorbootstrap/Models/RichTextEditorImageUploadRequest.cs create mode 100644 blazorbootstrap/Models/RichTextEditorImageUploadResult.cs create mode 100644 blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js create mode 100644 docs/docs/04-forms/rich-text-editor.mdx diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor new file mode 100644 index 000000000..524867fa3 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor @@ -0,0 +1,37 @@ +@page "/rich-text-editor" +@layout DemosMainLayout + + + +
+

The default ribbon exposes every supported History, Text, Paragraph, Color, and Insert command. Select content before using a formatting command.

+ +
@value
+
+ +
+

ValueChanged is raised after the configured debounce period. The buttons exercise FocusAsync() and ClearAsync().

+ + + +
ValueChanged calls: @valueChangedCount
+
+ +
+

The editor removes scripts, event attributes, inline styles, unsafe URLs, data URLs, and unsupported tags from pasted or supplied HTML. Server-side sanitization is still required before storing or rendering rich HTML. Use normal Razor encoding when the value must be displayed as plain text.

+

Image URL insertion accepts HTTPS only. Upload callbacks must validate, authorize, scan, and store the file, then return a trusted HTTPS URL; this demo intentionally cancels uploads.

+
+ +@code { + private RichTextEditor? editor; + private RichTextEditor? eventEditor; + private string value = "

Select this text and use the ribbon toolbar.

"; + private string eventValue = string.Empty; + private int valueChangedCount; + private readonly RichTextEditorToolbarItem[] restrictedToolbarItems = { RichTextEditorToolbarItem.Bold, RichTextEditorToolbarItem.Italic, RichTextEditorToolbarItem.Underline, RichTextEditorToolbarItem.Undo, RichTextEditorToolbarItem.Redo }; + + private Task ClearAsync() => eventEditor?.ClearAsync() ?? Task.CompletedTask; + private Task FocusAsync() => eventEditor?.FocusAsync() ?? Task.CompletedTask; + private void OnValueChanged(string html) { eventValue = html; valueChangedCount++; } + private Task UploadImageAsync(RichTextEditorImageUploadRequest request) => Task.FromResult(null); +} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor new file mode 100644 index 000000000..3e4fea16f --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor @@ -0,0 +1,31 @@ +@namespace BlazorBootstrap +@inherits BlazorBootstrapComponentBase + +
+
+ +
+ + + + + + + + + + @if (!string.IsNullOrWhiteSpace(imageUploadError)) + { + + } +
diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs new file mode 100644 index 000000000..88d226838 --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs @@ -0,0 +1,281 @@ +namespace BlazorBootstrap; + +/// +/// Provides a dependency-free rich-text editing control with a grouped toolbar. +/// +public partial class RichTextEditor : BlazorBootstrapComponentBase +{ + private static readonly string[] defaultAllowedImageFileTypes = { "jpg", "jpeg", "png", "gif", "webp" }; + private CancellationTokenSource uploadCancellationTokenSource = new(); + private FieldIdentifier fieldIdentifier; + private string? imageUploadError; + private string? lastRenderedValue; + private DotNetObjectReference? objRef; + + private string Accept => string.Join(',', NormalizedAllowedImageFileTypes.Select(fileType => $".{fileType}")); + + private string EditorId => $"{Id}-editor"; + + internal IReadOnlyCollection EnabledToolbarItems + { + get + { + var items = ToolbarItems?.Distinct().ToHashSet() ?? Enum.GetValues().ToHashSet(); + + if (ImageUploadHandler is null) + items.Remove(RichTextEditorToolbarItem.UploadImage); + + return items; + } + } + + private string fieldCssClasses => ValueExpression is null ? string.Empty : EditContext?.FieldCssClass(fieldIdentifier) ?? string.Empty; + + private string ImageInputId => $"{Id}-image-input"; + + private HashSet NormalizedAllowedImageFileTypes => + (AllowedImageFileTypes ?? defaultAllowedImageFileTypes) + .Select(fileType => fileType.Trim().TrimStart('.').ToLowerInvariant()) + .Where(fileType => !string.IsNullOrWhiteSpace(fileType)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + protected override string? ClassNames => BuildClassNames(Class, ("bb-rich-text-editor", true)); + + protected override async ValueTask DisposeAsyncCore(bool disposing) + { + if (disposing) + { + uploadCancellationTokenSource.Cancel(); + uploadCancellationTokenSource.Dispose(); + + if (Id is not null) + await RichTextEditorJsInterop.DisposeEditorAsync(Id); + + objRef?.Dispose(); + } + + await base.DisposeAsyncCore(disposing); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + objRef ??= DotNetObjectReference.Create(this); + await RichTextEditorJsInterop.InitializeAsync(Id!, objRef, DebounceInterval, MaxLength, ReadOnly, Disabled); + lastRenderedValue = Value; + } + else if (lastRenderedValue != Value) + { + await RichTextEditorJsInterop.SetValueAsync(Id!, Value); + lastRenderedValue = Value; + } + + await base.OnAfterRenderAsync(firstRender); + } + + protected override void OnInitialized() + { + AdditionalAttributes ??= new Dictionary(); + + if (ValueExpression is not null) + fieldIdentifier = FieldIdentifier.Create(ValueExpression); + + base.OnInitialized(); + } + + /// + /// Clears the editor content. + /// + [AddedVersion("4.0.0")] + [Description("Clears the editor content.")] + public async Task ClearAsync() + { + await RichTextEditorJsInterop.ClearAsync(Id!); + } + + /// + /// Focuses the editable area. + /// + [AddedVersion("4.0.0")] + [Description("Focuses the editable area.")] + public Task FocusAsync() => RichTextEditorJsInterop.FocusAsync(Id!); + + [JSInvokable] + public async Task OnEditorValueChangedAsync(string html) + { + imageUploadError = null; + lastRenderedValue = html; + await ValueChanged.InvokeAsync(html); + + if (ValueExpression is not null) + EditContext?.NotifyFieldChanged(fieldIdentifier); + } + + private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) + { + imageUploadError = null; + var file = e.File; + + if (ImageUploadHandler is null) + { + imageUploadError = "An image upload handler is not configured."; + return; + } + + var extension = Path.GetExtension(file.Name).TrimStart('.'); + if (!NormalizedAllowedImageFileTypes.Contains(extension)) + { + imageUploadError = "The selected image type is not allowed."; + return; + } + + if (file.Size > MaxImageFileSize) + { + imageUploadError = "The selected image exceeds the maximum allowed size."; + return; + } + + uploadCancellationTokenSource.Cancel(); + uploadCancellationTokenSource.Dispose(); + uploadCancellationTokenSource = new CancellationTokenSource(); + + try + { + var request = new RichTextEditorImageUploadRequest(file, string.Empty, uploadCancellationTokenSource.Token); + var result = await ImageUploadHandler.Invoke(request); + + if (result is null || !Uri.TryCreate(result.Url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) + { + imageUploadError = "The upload did not return a valid HTTPS image URL."; + return; + } + + await RichTextEditorJsInterop.InsertImageAsync(Id!, result.Url, string.Empty); + } + catch (OperationCanceledException) + { + // A newer upload or disposal cancelled this request. + } + catch (Exception) + { + imageUploadError = "The image could not be uploaded."; + } + } + + /// + /// Gets or sets the accessible label for the editor. + /// + [AddedVersion("4.0.0")] + [DefaultValue("Rich text editor")] + [Description("Gets or sets the accessible label for the editor.")] + [Parameter] + public string AriaLabel { get; set; } = "Rich text editor"; + + /// + /// Gets or sets the permitted image file extensions. + /// + [AddedVersion("4.0.0")] + [Description("Gets or sets the permitted image file extensions.")] + [Parameter] + public IEnumerable? AllowedImageFileTypes { get; set; } + + /// + /// Gets or sets the delay, in milliseconds, before editor changes are raised. + /// + [AddedVersion("4.0.0")] + [DefaultValue(300)] + [Description("Gets or sets the delay, in milliseconds, before editor changes are raised.")] + [Parameter] + public int DebounceInterval { get; set; } = 300; + + /// + /// Gets or sets whether the editor is disabled. + /// + [AddedVersion("4.0.0")] + [DefaultValue(false)] + [Description("Gets or sets whether the editor is disabled.")] + [Parameter] + public bool Disabled { get; set; } + + /// + /// Gets or sets the image upload handler. + /// + [AddedVersion("4.0.0")] + [DefaultValue(null)] + [Description("Gets or sets the image upload handler.")] + [Parameter] + public RichTextEditorImageUploadDelegate? ImageUploadHandler { get; set; } + + /// + /// Gets or sets the maximum allowed image size in bytes. + /// + [AddedVersion("4.0.0")] + [DefaultValue(5242880)] + [Description("Gets or sets the maximum allowed image size in bytes.")] + [Parameter] + public long MaxImageFileSize { get; set; } = 5 * 1024 * 1024; + + /// + /// Gets or sets the maximum plain-text character count. + /// + [AddedVersion("4.0.0")] + [DefaultValue(null)] + [Description("Gets or sets the maximum plain-text character count.")] + [Parameter] + public int? MaxLength { get; set; } + + /// + /// Gets or sets the placeholder text. + /// + [AddedVersion("4.0.0")] + [DefaultValue(null)] + [Description("Gets or sets the placeholder text.")] + [Parameter] + public string? Placeholder { get; set; } + + /// + /// Gets or sets whether the editor is read-only. + /// + [AddedVersion("4.0.0")] + [DefaultValue(false)] + [Description("Gets or sets whether the editor is read-only.")] + [Parameter] + public bool ReadOnly { get; set; } + + /// + /// Gets or sets the enabled toolbar commands. + /// + [AddedVersion("4.0.0")] + [DefaultValue(null)] + [Description("Gets or sets the enabled toolbar commands.")] + [Parameter] + public IEnumerable? ToolbarItems { get; set; } + + /// + /// Gets or sets the HTML value. + /// + [AddedVersion("4.0.0")] + [DefaultValue(null)] + [Description("Gets or sets the HTML value.")] + [Parameter] + public string Value { get; set; } = string.Empty; + + /// + /// Fires when the HTML value changes. + /// + [AddedVersion("4.0.0")] + [Description("Fires when the HTML value changes.")] + [Parameter] + public EventCallback ValueChanged { get; set; } + + /// + /// Gets or sets the expression that identifies the bound value. + /// + [Parameter] + public Expression>? ValueExpression { get; set; } + + [CascadingParameter] private EditContext? EditContext { get; set; } + + [Inject] private RichTextEditorJsInterop RichTextEditorJsInterop { get; set; } = default!; +} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.css b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.css new file mode 100644 index 000000000..3d1e5d3cc --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.css @@ -0,0 +1,69 @@ +.bb-rich-text-editor { + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + background-color: var(--bs-body-bg); +} + +.bb-rich-text-editor-toolbar { + display: flex; + flex-wrap: wrap; + border-bottom: 1px solid var(--bs-border-color); + background-color: var(--bs-tertiary-bg); +} + +.bb-rich-text-editor-toolbar-group { + display: flex; + flex-direction: column; + align-items: center; + padding: .25rem .5rem; + border-right: 1px solid var(--bs-border-color); +} + +.bb-rich-text-editor-toolbar-group-items { + display: flex; + align-items: center; + gap: .2rem; +} + +.bb-rich-text-editor-toolbar-group-label { + margin-top: .2rem; + font-size: .7rem; + color: var(--bs-secondary-color); +} + +.bb-rich-text-editor-toolbar-button { + min-width: 2rem; +} + +.bb-rich-text-editor-toolbar-select { + width: auto; + min-width: 7rem; +} + +.bb-rich-text-editor-editable { + min-height: 12rem; + border: 0; + border-radius: 0; + overflow: auto; +} + +.bb-rich-text-editor-editable:focus { + box-shadow: none; +} + +.bb-rich-text-editor-editable:empty::before { + content: attr(data-placeholder); + color: var(--bs-secondary-color); + pointer-events: none; +} + +.bb-rich-text-editor-dialog { + width: min(30rem, calc(100vw - 2rem)); + border: 0; + border-radius: var(--bs-border-radius); + box-shadow: var(--bs-box-shadow-lg); +} + +.bb-rich-text-editor-dialog::backdrop { + background: rgba(0, 0, 0, .5); +} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorEditableArea.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorEditableArea.razor new file mode 100644 index 000000000..23749ab1b --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorEditableArea.razor @@ -0,0 +1,20 @@ +@namespace BlazorBootstrap + +
@((MarkupString)Value)
+ +@code { + [Parameter, EditorRequired] public string EditorId { get; set; } = default!; + [Parameter] public string FieldCssClasses { get; set; } = string.Empty; + [Parameter] public string? Placeholder { get; set; } + [Parameter] public string AriaLabel { get; set; } = "Rich text editor"; + [Parameter] public bool Disabled { get; set; } + [Parameter] public bool ReadOnly { get; set; } + [Parameter] public string Value { get; set; } = string.Empty; +} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorImageDialog.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorImageDialog.razor new file mode 100644 index 000000000..b2fef5a38 --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorImageDialog.razor @@ -0,0 +1,19 @@ +@namespace BlazorBootstrap + + +
+

Insert image

+ + + + +
+ + +
+
+
+ +@code { + [Parameter, EditorRequired] public string EditorId { get; set; } = default!; +} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs new file mode 100644 index 000000000..331a069e1 --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs @@ -0,0 +1,22 @@ +namespace BlazorBootstrap; + +internal sealed class RichTextEditorJsInterop : JsInteropBase +{ + public RichTextEditorJsInterop(IJSRuntime jsRuntime) + : base(jsRuntime, "./_content/Blazor.Bootstrap/blazor.bootstrap.rich-text-editor.js") + { + } + + public Task ClearAsync(string id) => SafeInvokeVoidAsync("clear", id); + + public Task DisposeEditorAsync(string id) => SafeInvokeVoidAsync("dispose", id); + + public Task FocusAsync(string id) => SafeInvokeVoidAsync("focus", id); + + public Task InitializeAsync(string id, DotNetObjectReference objRef, int debounceInterval, int? maxLength, bool readOnly, bool disabled) => + SafeInvokeVoidAsync("initialize", id, objRef, debounceInterval, maxLength, readOnly, disabled); + + public Task InsertImageAsync(string id, string url, string altText) => SafeInvokeVoidAsync("insertImage", id, url, altText); + + public Task SetValueAsync(string id, string value) => SafeInvokeVoidAsync("setValue", id, value); +} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorLinkDialog.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorLinkDialog.razor new file mode 100644 index 000000000..6461bc687 --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorLinkDialog.razor @@ -0,0 +1,17 @@ +@namespace BlazorBootstrap + + +
+

Insert link

+ + +
+ + +
+
+
+ +@code { + [Parameter, EditorRequired] public string EditorId { get; set; } = default!; +} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorTableDialog.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorTableDialog.razor new file mode 100644 index 000000000..57cf9d055 --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorTableDialog.razor @@ -0,0 +1,19 @@ +@namespace BlazorBootstrap + + +
+

Insert table

+ + + + +
+ + +
+
+
+ +@code { + [Parameter, EditorRequired] public string EditorId { get; set; } = default!; +} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor new file mode 100644 index 000000000..2039f12a4 --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor @@ -0,0 +1,61 @@ +@namespace BlazorBootstrap + +@if (HasAny(historyItems)) +{ + + @foreach (var item in historyItems.Where(Items.Contains)) + { + + } + +} + +@if (HasAny(textItems)) +{ + + + @foreach (var item in textItems.Where(Items.Contains).Where(item => item is not RichTextEditorToolbarItem.Paragraph and not RichTextEditorToolbarItem.Heading1 and not RichTextEditorToolbarItem.Heading2 and not RichTextEditorToolbarItem.Heading3)) + { + + } + +} + +@if (HasAny(paragraphItems)) +{ + + @foreach (var item in paragraphItems.Where(Items.Contains)) + { + + } + +} + +@if (Items.Contains(RichTextEditorToolbarItem.TextColor)) +{ + + + +} + +@if (HasAny(insertItems)) +{ + + @foreach (var item in insertItems.Where(Items.Contains)) + { + + } + +} + +@code { + private static readonly RichTextEditorToolbarItem[] historyItems = { RichTextEditorToolbarItem.Undo, RichTextEditorToolbarItem.Redo }; + private static readonly RichTextEditorToolbarItem[] textItems = { RichTextEditorToolbarItem.Paragraph, RichTextEditorToolbarItem.Heading1, RichTextEditorToolbarItem.Heading2, RichTextEditorToolbarItem.Heading3, RichTextEditorToolbarItem.Bold, RichTextEditorToolbarItem.Italic, RichTextEditorToolbarItem.Underline, RichTextEditorToolbarItem.Strikethrough, RichTextEditorToolbarItem.ClearFormatting }; + private static readonly RichTextEditorToolbarItem[] paragraphItems = { RichTextEditorToolbarItem.AlignStart, RichTextEditorToolbarItem.AlignCenter, RichTextEditorToolbarItem.AlignEnd, RichTextEditorToolbarItem.OrderedList, RichTextEditorToolbarItem.UnorderedList, RichTextEditorToolbarItem.Blockquote, RichTextEditorToolbarItem.CodeBlock }; + private static readonly RichTextEditorToolbarItem[] insertItems = { RichTextEditorToolbarItem.Link, RichTextEditorToolbarItem.Image, RichTextEditorToolbarItem.UploadImage, RichTextEditorToolbarItem.Table }; + + private bool HasAny(IEnumerable toolbarItems) => toolbarItems.Any(Items.Contains); + + [Parameter, EditorRequired] public IReadOnlyCollection Items { get; set; } = Array.Empty(); + [Parameter] public bool Disabled { get; set; } +} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor new file mode 100644 index 000000000..105043fb0 --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor @@ -0,0 +1,52 @@ +@namespace BlazorBootstrap + + + +@code { + private IconName Icon => Item switch + { + RichTextEditorToolbarItem.Undo => IconName.ArrowCounterclockwise, + RichTextEditorToolbarItem.Redo => IconName.ArrowClockwise, + RichTextEditorToolbarItem.Bold => IconName.TypeBold, + RichTextEditorToolbarItem.Italic => IconName.TypeItalic, + RichTextEditorToolbarItem.Underline => IconName.TypeUnderline, + RichTextEditorToolbarItem.Strikethrough => IconName.TypeStrikethrough, + RichTextEditorToolbarItem.ClearFormatting => IconName.Eraser, + RichTextEditorToolbarItem.AlignStart => IconName.TextLeft, + RichTextEditorToolbarItem.AlignCenter => IconName.TextCenter, + RichTextEditorToolbarItem.AlignEnd => IconName.TextRight, + RichTextEditorToolbarItem.OrderedList => IconName.ListOl, + RichTextEditorToolbarItem.UnorderedList => IconName.ListUl, + RichTextEditorToolbarItem.Blockquote => IconName.Quote, + RichTextEditorToolbarItem.CodeBlock => IconName.Code, + RichTextEditorToolbarItem.Link => IconName.Link45Deg, + RichTextEditorToolbarItem.Image => IconName.Image, + RichTextEditorToolbarItem.UploadImage => IconName.Upload, + RichTextEditorToolbarItem.Table => IconName.Table, + _ => IconName.Type + }; + + private string Label => Item switch + { + RichTextEditorToolbarItem.AlignStart => "Align left", + RichTextEditorToolbarItem.AlignCenter => "Align center", + RichTextEditorToolbarItem.AlignEnd => "Align right", + RichTextEditorToolbarItem.OrderedList => "Ordered list", + RichTextEditorToolbarItem.UnorderedList => "Unordered list", + RichTextEditorToolbarItem.ClearFormatting => "Clear formatting", + RichTextEditorToolbarItem.CodeBlock => "Code block", + RichTextEditorToolbarItem.UploadImage => "Upload image", + _ => System.Text.RegularExpressions.Regex.Replace(Item.ToString(), "([a-z])([A-Z])", "$1 $2") + }; + + [Parameter] public bool Disabled { get; set; } + [Parameter, EditorRequired] public RichTextEditorToolbarItem Item { get; set; } +} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarGroup.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarGroup.razor new file mode 100644 index 000000000..03e2931dd --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarGroup.razor @@ -0,0 +1,11 @@ +@namespace BlazorBootstrap + +
+
@ChildContent
+
@Label
+
+ +@code { + [Parameter, EditorRequired] public RenderFragment? ChildContent { get; set; } + [Parameter, EditorRequired] public string Label { get; set; } = default!; +} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarSelect.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarSelect.razor new file mode 100644 index 000000000..44ada4f87 --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarSelect.razor @@ -0,0 +1,30 @@ +@namespace BlazorBootstrap + +@if (IsColorSelector) +{ + +} +else if (Items.Any(item => item is RichTextEditorToolbarItem.Paragraph or RichTextEditorToolbarItem.Heading1 or RichTextEditorToolbarItem.Heading2 or RichTextEditorToolbarItem.Heading3)) +{ + +} + +@code { + [Parameter] public bool Disabled { get; set; } + [Parameter] public bool IsColorSelector { get; set; } + [Parameter] public IReadOnlyCollection Items { get; set; } = Array.Empty(); +} diff --git a/blazorbootstrap/Config.cs b/blazorbootstrap/Config.cs index 32198bbb2..6f336776e 100644 --- a/blazorbootstrap/Config.cs +++ b/blazorbootstrap/Config.cs @@ -1,4 +1,4 @@ -using BlazorBootstrap; +using BlazorBootstrap; namespace Microsoft.Extensions.DependencyInjection; @@ -19,6 +19,7 @@ public static IServiceCollection AddBlazorBootstrap(this IServiceCollection serv services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/blazorbootstrap/Enums/RichTextEditorToolbarItem.cs b/blazorbootstrap/Enums/RichTextEditorToolbarItem.cs new file mode 100644 index 000000000..c231a21b7 --- /dev/null +++ b/blazorbootstrap/Enums/RichTextEditorToolbarItem.cs @@ -0,0 +1,31 @@ +namespace BlazorBootstrap; + +/// +/// Defines the commands available in a toolbar. +/// +public enum RichTextEditorToolbarItem +{ + Undo, + Redo, + Paragraph, + Heading1, + Heading2, + Heading3, + Bold, + Italic, + Underline, + Strikethrough, + ClearFormatting, + AlignStart, + AlignCenter, + AlignEnd, + OrderedList, + UnorderedList, + Blockquote, + CodeBlock, + TextColor, + Link, + Image, + UploadImage, + Table +} diff --git a/blazorbootstrap/Models/RichTextEditorImageUploadDelegate.cs b/blazorbootstrap/Models/RichTextEditorImageUploadDelegate.cs new file mode 100644 index 000000000..1177e2d06 --- /dev/null +++ b/blazorbootstrap/Models/RichTextEditorImageUploadDelegate.cs @@ -0,0 +1,6 @@ +namespace BlazorBootstrap; + +/// +/// Uploads an image selected in a . +/// +public delegate Task RichTextEditorImageUploadDelegate(RichTextEditorImageUploadRequest request); diff --git a/blazorbootstrap/Models/RichTextEditorImageUploadRequest.cs b/blazorbootstrap/Models/RichTextEditorImageUploadRequest.cs new file mode 100644 index 000000000..78ab37fd5 --- /dev/null +++ b/blazorbootstrap/Models/RichTextEditorImageUploadRequest.cs @@ -0,0 +1,23 @@ +namespace BlazorBootstrap; + +/// +/// Contains an image selected for upload from a . +/// +public sealed class RichTextEditorImageUploadRequest +{ + public RichTextEditorImageUploadRequest(IBrowserFile file, string altText, CancellationToken cancellationToken) + { + File = file; + AltText = altText; + CancellationToken = cancellationToken; + } + + /// Gets the selected image file. + public IBrowserFile File { get; } + + /// Gets the alternate text supplied for the image. + public string AltText { get; } + + /// Gets a token cancelled when the editor is disposed or another upload starts. + public CancellationToken CancellationToken { get; } +} diff --git a/blazorbootstrap/Models/RichTextEditorImageUploadResult.cs b/blazorbootstrap/Models/RichTextEditorImageUploadResult.cs new file mode 100644 index 000000000..7f4d88b91 --- /dev/null +++ b/blazorbootstrap/Models/RichTextEditorImageUploadResult.cs @@ -0,0 +1,12 @@ +namespace BlazorBootstrap; + +/// +/// Represents the result of a image upload. +/// +public sealed class RichTextEditorImageUploadResult +{ + public RichTextEditorImageUploadResult(string url) => Url = url; + + /// Gets the URL to insert into the editor. + public string Url { get; } +} diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js new file mode 100644 index 000000000..1d774424b --- /dev/null +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -0,0 +1,263 @@ +const instances = new Map(); +const allowedTags = new Set(["P", "BR", "H1", "H2", "H3", "STRONG", "EM", "U", "S", "UL", "OL", "LI", "BLOCKQUOTE", "PRE", "CODE", "A", "IMG", "TABLE", "THEAD", "TBODY", "TR", "TH", "TD", "SPAN"]); +const blockedTags = new Set(["SCRIPT", "STYLE", "TEMPLATE", "IFRAME", "OBJECT", "EMBED", "FORM", "INPUT", "BUTTON", "SVG", "MATH", "META", "LINK"]); +const allowedClasses = new Set(["text-start", "text-center", "text-end", "text-primary", "text-secondary", "text-success", "text-danger", "text-warning", "text-info", "text-dark", "img-fluid", "table", "table-bordered"]); + +function isSafeLink(value) { + if (!value) return false; + const trimmed = value.trim(); + return trimmed.startsWith("/") || trimmed.startsWith("#") || /^https:\/\//i.test(trimmed); +} + +function isSafeImage(value) { + return !!value && /^https:\/\//i.test(value.trim()); +} + +function sanitize(html) { + const documentFragment = new DOMParser().parseFromString(html || "", "text/html"); + const nodes = Array.from(documentFragment.body.querySelectorAll("*")); + + for (const node of nodes) { + if (blockedTags.has(node.tagName)) { + node.remove(); + continue; + } + + if (!allowedTags.has(node.tagName)) { + node.replaceWith(...Array.from(node.childNodes)); + continue; + } + + for (const attribute of Array.from(node.attributes)) { + const name = attribute.name.toLowerCase(); + if (name.startsWith("on") || name === "style" || name === "id") { + node.removeAttribute(attribute.name); + continue; + } + + if (name === "class") { + const safeClasses = attribute.value.split(/\s+/).filter(value => allowedClasses.has(value)); + if (safeClasses.length) node.setAttribute("class", safeClasses.join(" ")); + else node.removeAttribute("class"); + continue; + } + + const allowed = (node.tagName === "A" && name === "href") + || (node.tagName === "IMG" && (name === "src" || name === "alt")) + || (node.tagName === "TH" && name === "scope"); + if (!allowed) node.removeAttribute(attribute.name); + } + + if (node.tagName === "A" && !isSafeLink(node.getAttribute("href"))) node.removeAttribute("href"); + if (node.tagName === "IMG" && !isSafeImage(node.getAttribute("src"))) node.remove(); + } + + return documentFragment.body.innerHTML; +} + +function getRange(instance) { + const selection = window.getSelection(); + if (selection && selection.rangeCount && instance.editor.contains(selection.anchorNode)) return selection.getRangeAt(0); + return instance.range; +} + +function saveRange(instance) { + const range = getRange(instance); + if (range) instance.range = range.cloneRange(); +} + +function restoreRange(instance) { + if (!instance.range) return; + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(instance.range); +} + +function notify(instance, immediate = false) { + window.clearTimeout(instance.timer); + const raise = () => { + const html = sanitize(instance.editor.innerHTML); + if (instance.editor.innerHTML !== html) instance.editor.innerHTML = html; + instance.dotNetRef.invokeMethodAsync("OnEditorValueChangedAsync", html); + }; + if (immediate) raise(); + else instance.timer = window.setTimeout(raise, instance.debounceInterval); +} + +function enforceLength(instance) { + if (!instance.maxLength || instance.editor.innerText.length <= instance.maxLength) return; + instance.editor.innerText = instance.editor.innerText.substring(0, instance.maxLength); +} + +function insertNode(instance, node) { + restoreRange(instance); + const range = getRange(instance) || document.createRange(); + if (!range.startContainer.isConnected) range.selectNodeContents(instance.editor); + range.collapse(false); + range.deleteContents(); + range.insertNode(node); + range.setStartAfter(node); + range.collapse(true); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + saveRange(instance); +} + +function applyBlockClass(instance, className) { + restoreRange(instance); + const range = getRange(instance); + let node = range?.commonAncestorContainer; + if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; + const block = node?.closest?.("p,h1,h2,h3,blockquote,pre,li") || instance.editor; + [...block.classList].filter(value => value.startsWith("text-")).forEach(value => block.classList.remove(value)); + if (className) block.classList.add(className); +} + +function execute(instance, command, value) { + if (instance.disabled || instance.readOnly) return; + restoreRange(instance); + instance.editor.focus(); + + const commands = { + Undo: ["undo"], Redo: ["redo"], Bold: ["bold"], Italic: ["italic"], Underline: ["underline"], Strikethrough: ["strikeThrough"], + OrderedList: ["insertOrderedList"], UnorderedList: ["insertUnorderedList"], Blockquote: ["formatBlock", "blockquote"], CodeBlock: ["formatBlock", "pre"], + AlignStart: ["justifyLeft"], AlignCenter: ["justifyCenter"], AlignEnd: ["justifyRight"], ClearFormatting: ["removeFormat"] + }; + + if (command === "BlockFormat") document.execCommand("formatBlock", false, value || "p"); + else if (command === "TextColor") applyBlockClass(instance, value); + else if (commands[command]) document.execCommand(commands[command][0], false, commands[command][1]); + + enforceLength(instance); + saveRange(instance); + notify(instance, true); +} + +function openDialog(instance, type) { + saveRange(instance); + instance.dialogMode = type; + const dialog = document.getElementById(`${instance.id}-${type === "table" ? "table" : type === "link" ? "link" : "image"}-dialog`); + if (dialog?.showModal) dialog.showModal(); +} + +function insertTable(instance, rows, columns) { + const table = document.createElement("table"); + table.className = "table table-bordered"; + const body = document.createElement("tbody"); + for (let row = 0; row < rows; row++) { + const tr = document.createElement("tr"); + for (let column = 0; column < columns; column++) { + const cell = document.createElement(row === 0 ? "th" : "td"); + if (row === 0) cell.scope = "col"; + cell.appendChild(document.createElement("br")); + tr.appendChild(cell); + } + body.appendChild(tr); + } + table.appendChild(body); + insertNode(instance, table); +} + +function wireDialog(instance, type) { + const dialog = document.getElementById(`${instance.id}-${type}-dialog`); + if (!dialog) return; + dialog.addEventListener("close", () => { + if (dialog.returnValue !== "save") return; + if (type === "link") { + const url = dialog.querySelector("[data-bb-rte-link-url]").value; + if (!isSafeLink(url)) return; + const anchor = document.createElement("a"); + anchor.href = url; + anchor.textContent = window.getSelection()?.toString() || url; + insertNode(instance, anchor); + } else if (type === "image") { + const url = dialog.querySelector("[data-bb-rte-image-url]").value; + const alt = dialog.querySelector("[data-bb-rte-image-alt]").value.trim(); + if (!isSafeImage(url) || !alt) return; + const image = document.createElement("img"); + image.src = url; + image.alt = alt; + image.className = "img-fluid"; + insertNode(instance, image); + } else { + const rows = Number(dialog.querySelector("[data-bb-rte-table-rows]").value); + const columns = Number(dialog.querySelector("[data-bb-rte-table-columns]").value); + if (Number.isInteger(rows) && Number.isInteger(columns) && rows > 0 && rows <= 20 && columns > 0 && columns <= 10) insertTable(instance, rows, columns); + } + notify(instance, true); + }); +} + +export function initialize(id, dotNetRef, debounceInterval, maxLength, readOnly, disabled) { + dispose(id); + const root = document.getElementById(id); + const editor = document.getElementById(`${id}-editor`); + if (!root || !editor) return; + const instance = { id, root, editor, dotNetRef, debounceInterval: Math.max(0, debounceInterval || 300), maxLength, readOnly, disabled, range: null, timer: 0 }; + instances.set(id, instance); + editor.innerHTML = sanitize(editor.innerHTML); + editor.addEventListener("input", () => { enforceLength(instance); saveRange(instance); notify(instance); }); + editor.addEventListener("keyup", () => saveRange(instance)); + editor.addEventListener("mouseup", () => saveRange(instance)); + editor.addEventListener("paste", event => { + event.preventDefault(); + const html = event.clipboardData.getData("text/html"); + const text = event.clipboardData.getData("text/plain"); + const wrapper = document.createElement("div"); + wrapper.innerHTML = sanitize(html || text.replace(/&/g, "&").replace(//g, ">").replace(/\n/g, "
")); + insertNode(instance, wrapper); + enforceLength(instance); + notify(instance, true); + }); + root.addEventListener("click", event => { + const button = event.target.closest("[data-bb-rte-command]"); + if (!button) return; + const command = button.dataset.bbRteCommand; + if (command === "Link") openDialog(instance, "link"); + else if (command === "Image") openDialog(instance, "image"); + else if (command === "UploadImage") document.getElementById(`${id}-image-input`)?.click(); + else if (command === "Table") openDialog(instance, "table"); + else execute(instance, command); + }); + root.addEventListener("change", event => { + const select = event.target.closest("[data-bb-rte-command]"); + if (select) execute(instance, select.dataset.bbRteCommand, select.value); + }); + wireDialog(instance, "link"); + wireDialog(instance, "image"); + wireDialog(instance, "table"); +} + +export function clear(id) { + const instance = instances.get(id); + if (!instance) return; + instance.editor.innerHTML = ""; + notify(instance, true); +} + +export function dispose(id) { + const instance = instances.get(id); + if (instance) window.clearTimeout(instance.timer); + instances.delete(id); +} + +export function focus(id) { instances.get(id)?.editor.focus(); } + +export function insertImage(id, url, altText) { + const instance = instances.get(id); + if (!instance || !isSafeImage(url)) return; + const image = document.createElement("img"); + image.src = url; + image.alt = altText || ""; + image.className = "img-fluid"; + insertNode(instance, image); + notify(instance, true); +} + +export function setValue(id, value) { + const instance = instances.get(id); + if (!instance) return; + const html = sanitize(value); + if (instance.editor.innerHTML !== html) instance.editor.innerHTML = html; +} diff --git a/docs/docs/04-forms/rich-text-editor.mdx b/docs/docs/04-forms/rich-text-editor.mdx new file mode 100644 index 000000000..2dc146ddc --- /dev/null +++ b/docs/docs/04-forms/rich-text-editor.mdx @@ -0,0 +1,24 @@ +--- +title: Blazor RichTextEditor Component +description: A dependency-free rich text editor with Bootstrap ribbon groups and safe client-side HTML filtering. +sidebar_label: RichTextEditor +sidebar_position: 13 +--- + +# Blazor RichTextEditor + +`RichTextEditor` provides grouped History, Text, Paragraph, Color, and Insert controls using the library's Bootstrap Icons. It has no third-party package, script, CDN, or copied editor dependency. + +```cshtml + +``` + +## Security + +The component removes unsafe elements, event handlers, inline styles, unsupported classes, and unsafe URL schemes. It only permits HTTPS image URLs and HTTPS, relative, or fragment links. This browser-side filtering is defense in depth: validate uploaded files and sanitize HTML again on the server before persistence or markup rendering. Razor encoding displays the HTML as text, not formatted content. + +## Toolbar and API coverage + +The demo covers every toolbar command, `ValueChanged`, two-way binding, `FocusAsync()`, `ClearAsync()`, restricted `ToolbarItems`, debounce, upload cancellation, and safe-image behavior. See [the demo](https://demos.blazorbootstrap.com/rich-text-editor). From 80b9e156ea9ef5e823e802be102930de78dd50c1 Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Thu, 13 Aug 2026 00:29:48 +0530 Subject: [PATCH 02/53] Add RichTextEditor demo, docs, and navigation entries Added RichTextEditor component demo and documentation to BlazorBootstrap.Demo.RCL. Registered navigation entries and defined route constants for demos and docs. Added 23 demo files for individual toolbar commands. Created a comprehensive documentation page. Refactored related code files for better organization. No breaking changes; all additions are backward compatible. --- .../Layout/DemosMainLayout.razor.cs | 3 +- .../Components/Layout/DocsMainLayout.razor.cs | 3 +- .../Components/Layout/MainLayout.razor.cs | 3 +- .../RichTextEditorDocumentation.razor | 10 +++---- .../RichTextEditor_Demo_01_Undo.razor | 10 +++++++ .../RichTextEditor_Demo_02_Redo.razor | 10 +++++++ .../RichTextEditor_Demo_03_Paragraph.razor | 10 +++++++ .../RichTextEditor_Demo_04_Heading1.razor | 10 +++++++ .../RichTextEditor_Demo_05_Heading2.razor | 10 +++++++ .../RichTextEditor_Demo_06_Heading3.razor | 10 +++++++ .../RichTextEditor_Demo_07_Bold.razor | 10 +++++++ .../RichTextEditor_Demo_08_Italic.razor | 10 +++++++ .../RichTextEditor_Demo_09_Underline.razor | 10 +++++++ ...RichTextEditor_Demo_10_Strikethrough.razor | 10 +++++++ ...chTextEditor_Demo_11_ClearFormatting.razor | 10 +++++++ .../RichTextEditor_Demo_12_AlignStart.razor | 10 +++++++ .../RichTextEditor_Demo_13_AlignCenter.razor | 10 +++++++ .../RichTextEditor_Demo_14_AlignEnd.razor | 10 +++++++ .../RichTextEditor_Demo_15_OrderedList.razor | 10 +++++++ ...RichTextEditor_Demo_16_UnorderedList.razor | 10 +++++++ .../RichTextEditor_Demo_17_Blockquote.razor | 10 +++++++ .../RichTextEditor_Demo_18_CodeBlock.razor | 10 +++++++ .../RichTextEditor_Demo_19_TextColor.razor | 10 +++++++ .../RichTextEditor_Demo_20_Link.razor | 10 +++++++ .../RichTextEditor_Demo_21_Image.razor | 10 +++++++ .../RichTextEditor_Demo_22_UploadImage.razor | 10 +++++++ .../RichTextEditor_Demo_23_Table.razor | 10 +++++++ .../RichTextEditor_Doc_01_Documentation.razor | 29 +++++++++++++++++++ .../Components/Pages/Home/Index.razor | 10 ++++++- .../Constants/DemoRouteConstants.cs | 4 ++- .../RichTextEditor/RichTextEditor.razor.cs | 16 ++++++++++ .../RichTextEditor/RichTextEditorJsInterop.cs | 8 +++++ 32 files changed, 306 insertions(+), 10 deletions(-) create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_01_Undo.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_02_Redo.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_03_Paragraph.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_04_Heading1.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_05_Heading2.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_06_Heading3.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_07_Bold.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_08_Italic.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_09_Underline.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_10_Strikethrough.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_11_ClearFormatting.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_12_AlignStart.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_13_AlignCenter.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_14_AlignEnd.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_15_OrderedList.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_16_UnorderedList.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_17_Blockquote.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_18_CodeBlock.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_19_TextColor.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_20_Link.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_21_Image.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_22_UploadImage.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_23_Table.razor create mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Docs/Form/RichTextEditor/RichTextEditor_Doc_01_Documentation.razor diff --git a/BlazorBootstrap.Demo.RCL/Components/Layout/DemosMainLayout.razor.cs b/BlazorBootstrap.Demo.RCL/Components/Layout/DemosMainLayout.razor.cs index e6b838d5b..7da552f8e 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Layout/DemosMainLayout.razor.cs +++ b/BlazorBootstrap.Demo.RCL/Components/Layout/DemosMainLayout.razor.cs @@ -1,4 +1,4 @@ -namespace BlazorBootstrap.Demo.RCL; +namespace BlazorBootstrap.Demo.RCL; public partial class DemosMainLayout : MainLayoutBase { @@ -34,6 +34,7 @@ internal override IEnumerable GetNavItems() new (){ Id = "411", Text = "Text Input", Href = DemoRouteConstants.Demos_URL_TextInput, IconName = IconName.InputCursorText, ParentId = "4" }, new (){ Id = "412", Text = "Text Area Input", Href = DemoRouteConstants.Demos_URL_TextAreaInput, IconName = IconName.InputCursorText, ParentId = "4" }, new (){ Id = "413", Text = "Time Input", Href = DemoRouteConstants.Demos_URL_TimeInput, IconName = IconName.ClockFill, ParentId = "4" }, + new (){ Id = "414", Text = "RichTextEditor", Href = DemoRouteConstants.Demos_URL_RichTextEditor, IconName = IconName.Type, ParentId = "4" }, new (){ Id = "5", Text = "Components", IconName = IconName.GearFill, IconColor = IconColor.Danger }, new (){ Id = "500", Text = "Accordion", Href = DemoRouteConstants.Demos_URL_Accordion, IconName = IconName.ChevronBarExpand, ParentId = "5" }, diff --git a/BlazorBootstrap.Demo.RCL/Components/Layout/DocsMainLayout.razor.cs b/BlazorBootstrap.Demo.RCL/Components/Layout/DocsMainLayout.razor.cs index 093b41748..74991703c 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Layout/DocsMainLayout.razor.cs +++ b/BlazorBootstrap.Demo.RCL/Components/Layout/DocsMainLayout.razor.cs @@ -1,4 +1,4 @@ -namespace BlazorBootstrap.Demo.RCL; +namespace BlazorBootstrap.Demo.RCL; public partial class DocsMainLayout : MainLayoutBase { @@ -33,6 +33,7 @@ internal override IEnumerable GetNavItems() new (){ Id = "411", Text = "Text Input", Href = DemoRouteConstants.Docs_URL_TextInput, IconName = IconName.InputCursorText, ParentId = "4" }, new (){ Id = "412", Text = "Text Area Input", Href = DemoRouteConstants.Docs_URL_TextAreaInput, IconName = IconName.InputCursorText, ParentId = "4" }, new (){ Id = "413", Text = "Time Input", Href = DemoRouteConstants.Docs_URL_TimeInput, IconName = IconName.ClockFill, ParentId = "4" }, + new (){ Id = "414", Text = "RichTextEditor", Href = DemoRouteConstants.Docs_URL_RichTextEditor, IconName = IconName.Type, ParentId = "4" }, new (){ Id = "5", Text = "Components", IconName = IconName.GearFill, IconColor = IconColor.Danger }, new (){ Id = "500", Text = "Accordion", Href = DemoRouteConstants.Docs_URL_Accordion, IconName = IconName.ChevronBarExpand, ParentId = "5" }, diff --git a/BlazorBootstrap.Demo.RCL/Components/Layout/MainLayout.razor.cs b/BlazorBootstrap.Demo.RCL/Components/Layout/MainLayout.razor.cs index 1402be6ea..21f266da8 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Layout/MainLayout.razor.cs +++ b/BlazorBootstrap.Demo.RCL/Components/Layout/MainLayout.razor.cs @@ -1,4 +1,4 @@ -namespace BlazorBootstrap.Demo.RCL; +namespace BlazorBootstrap.Demo.RCL; public partial class MainLayout : MainLayoutBase { @@ -33,6 +33,7 @@ internal override IEnumerable GetNavItems() new (){ Id = "410", Text = "Text Input", Href = DemoRouteConstants.Demos_URL_TextInput, IconName = IconName.InputCursorText, ParentId = "4" }, new (){ Id = "411", Text = "Text Area Input", Href = DemoRouteConstants.Demos_URL_TextAreaInput, IconName = IconName.InputCursorText, ParentId = "4" }, new (){ Id = "412", Text = "Time Input", Href = DemoRouteConstants.Demos_URL_TimeInput, IconName = IconName.ClockFill, ParentId = "4" }, + new (){ Id = "413", Text = "RichTextEditor", Href = DemoRouteConstants.Demos_URL_RichTextEditor, IconName = IconName.Type, ParentId = "4" }, new (){ Id = "5", Text = "Components", IconName = IconName.GearFill, IconColor = IconColor.Danger }, new (){ Id = "500", Text = "Accordion", Href = DemoRouteConstants.Demos_URL_Accordion, IconName = IconName.ChevronBarExpand, ParentId = "5" }, diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor index 524867fa3..e008b52d1 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor @@ -1,15 +1,15 @@ -@page "/rich-text-editor" +@attribute [Route(DemoRouteConstants.Demos_URL_RichTextEditor)] @layout DemosMainLayout - + -
+

The default ribbon exposes every supported History, Text, Paragraph, Color, and Insert command. Select content before using a formatting command.

@value
-
+

ValueChanged is raised after the configured debounce period. The buttons exercise FocusAsync() and ClearAsync().

@@ -17,7 +17,7 @@
ValueChanged calls: @valueChangedCount
-
+

The editor removes scripts, event attributes, inline styles, unsafe URLs, data URLs, and unsupported tags from pasted or supplied HTML. Server-side sanitization is still required before storing or rendering rich HTML. Use normal Razor encoding when the value must be displayed as plain text.

Image URL insertion accepts HTTPS only. Upload callbacks must validate, authorize, scan, and store the file, then return a trusted HTTPS URL; this demo intentionally cancels uploads.

diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_01_Undo.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_01_Undo.razor new file mode 100644 index 000000000..ef9247977 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_01_Undo.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Undo }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_02_Redo.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_02_Redo.razor new file mode 100644 index 000000000..213a01dfb --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_02_Redo.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Redo }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_03_Paragraph.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_03_Paragraph.razor new file mode 100644 index 000000000..c83a3bb96 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_03_Paragraph.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Paragraph }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_04_Heading1.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_04_Heading1.razor new file mode 100644 index 000000000..e363ffae2 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_04_Heading1.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Heading1 }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_05_Heading2.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_05_Heading2.razor new file mode 100644 index 000000000..f5ed62a6c --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_05_Heading2.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Heading2 }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_06_Heading3.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_06_Heading3.razor new file mode 100644 index 000000000..7f2fd58e1 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_06_Heading3.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Heading3 }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_07_Bold.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_07_Bold.razor new file mode 100644 index 000000000..3549cf127 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_07_Bold.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Bold }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_08_Italic.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_08_Italic.razor new file mode 100644 index 000000000..bbd424799 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_08_Italic.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Italic }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_09_Underline.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_09_Underline.razor new file mode 100644 index 000000000..301dd78a4 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_09_Underline.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Underline }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_10_Strikethrough.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_10_Strikethrough.razor new file mode 100644 index 000000000..de1c7ca57 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_10_Strikethrough.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Strikethrough }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_11_ClearFormatting.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_11_ClearFormatting.razor new file mode 100644 index 000000000..25eea46e3 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_11_ClearFormatting.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.ClearFormatting }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_12_AlignStart.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_12_AlignStart.razor new file mode 100644 index 000000000..898b5fe55 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_12_AlignStart.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.AlignStart }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_13_AlignCenter.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_13_AlignCenter.razor new file mode 100644 index 000000000..d73554288 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_13_AlignCenter.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.AlignCenter }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_14_AlignEnd.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_14_AlignEnd.razor new file mode 100644 index 000000000..1e5459fa3 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_14_AlignEnd.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.AlignEnd }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_15_OrderedList.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_15_OrderedList.razor new file mode 100644 index 000000000..3b7aa5833 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_15_OrderedList.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.OrderedList }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_16_UnorderedList.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_16_UnorderedList.razor new file mode 100644 index 000000000..ac17f467c --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_16_UnorderedList.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.UnorderedList }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_17_Blockquote.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_17_Blockquote.razor new file mode 100644 index 000000000..cf63656b3 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_17_Blockquote.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Blockquote }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_18_CodeBlock.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_18_CodeBlock.razor new file mode 100644 index 000000000..3a6a8c42b --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_18_CodeBlock.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.CodeBlock }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_19_TextColor.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_19_TextColor.razor new file mode 100644 index 000000000..bfb776fb8 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_19_TextColor.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.TextColor }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_20_Link.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_20_Link.razor new file mode 100644 index 000000000..b92eb1e21 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_20_Link.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Link }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_21_Image.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_21_Image.razor new file mode 100644 index 000000000..4654a3dc9 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_21_Image.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Image }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_22_UploadImage.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_22_UploadImage.razor new file mode 100644 index 000000000..c6d873cf7 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_22_UploadImage.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.UploadImage }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_23_Table.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_23_Table.razor new file mode 100644 index 000000000..0f050ea00 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_23_Table.razor @@ -0,0 +1,10 @@ +
+ +
+
@content
+ +@code { + private string content = "

Sample rich text content

"; + + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.Table }; +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Docs/Form/RichTextEditor/RichTextEditor_Doc_01_Documentation.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Docs/Form/RichTextEditor/RichTextEditor_Doc_01_Documentation.razor new file mode 100644 index 000000000..7341e8ee5 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Docs/Form/RichTextEditor/RichTextEditor_Doc_01_Documentation.razor @@ -0,0 +1,29 @@ +@attribute [Route(DemoRouteConstants.Docs_URL_RichTextEditor)] +@layout DocsMainLayout + + + + + +
+ +
+ +
+ +
+ +
+ +
+ +@code { + private const string componentName = nameof(RichTextEditor); + private const string pageDescription = $"This documentation provides a comprehensive reference for the {componentName} component, guiding you through its configuration options."; + private const string metaTitle = $"Blazor {componentName} Component"; + private const string metaDescription = $"This documentation provides a comprehensive reference for the {componentName} component, guiding you through its configuration options."; +} diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Home/Index.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Home/Index.razor index a785c2d7c..4669ca62e 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Home/Index.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Home/Index.razor @@ -1,4 +1,4 @@ -@page "/" +@page "/" @layout EmptyLayout Blazor Bootstrap Components Examples & Demos | Blazor Bootstrap @@ -214,6 +214,10 @@

Range Input

+ @*
diff --git a/BlazorBootstrap.Demo.RCL/Constants/DemoRouteConstants.cs b/BlazorBootstrap.Demo.RCL/Constants/DemoRouteConstants.cs index 32f5491c6..65ab47f61 100644 --- a/BlazorBootstrap.Demo.RCL/Constants/DemoRouteConstants.cs +++ b/BlazorBootstrap.Demo.RCL/Constants/DemoRouteConstants.cs @@ -1,4 +1,4 @@ -namespace BlazorBootstrap.Demo.RCL; +namespace BlazorBootstrap.Demo.RCL; public static class DemoRouteConstants { @@ -38,6 +38,7 @@ public static class DemoRouteConstants public const string Demos_URL_PasswordInput = Demos_URL_Forms_Prefix + "/password-input"; public const string Demos_URL_RadioInput = Demos_URL_Forms_Prefix + "/radio-input"; public const string Demos_URL_RangeInput = Demos_URL_Forms_Prefix + "/range-input"; + public const string Demos_URL_RichTextEditor = Demos_URL_Forms_Prefix + "/rich-text-editor"; public const string Demos_URL_SelectInput = Demos_URL_Forms_Prefix + "/select-input"; public const string Demos_URL_Switch = Demos_URL_Forms_Prefix + "/switch"; public const string Demos_URL_TextInput = Demos_URL_Forms_Prefix + "/text-input"; @@ -154,6 +155,7 @@ public static class DemoRouteConstants public const string Docs_URL_PasswordInput = Docs_URL_Forms_Prefix + "/password-input"; public const string Docs_URL_RadioInput = Docs_URL_Forms_Prefix + "/radio-input"; public const string Docs_URL_RangeInput = Docs_URL_Forms_Prefix + "/range-input"; + public const string Docs_URL_RichTextEditor = Docs_URL_Forms_Prefix + "/rich-text-editor"; public const string Docs_URL_SelectInput = Docs_URL_Forms_Prefix + "/select-input"; public const string Docs_URL_Switch = Docs_URL_Forms_Prefix + "/switch"; public const string Docs_URL_TextInput = Docs_URL_Forms_Prefix + "/text-input"; diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs index 88d226838..fa71c05f3 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs @@ -5,6 +5,8 @@ namespace BlazorBootstrap; /// public partial class RichTextEditor : BlazorBootstrapComponentBase { + #region Fields and Constants + private static readonly string[] defaultAllowedImageFileTypes = { "jpg", "jpeg", "png", "gif", "webp" }; private CancellationTokenSource uploadCancellationTokenSource = new(); private FieldIdentifier fieldIdentifier; @@ -12,6 +14,10 @@ public partial class RichTextEditor : BlazorBootstrapComponentBase private string? lastRenderedValue; private DotNetObjectReference? objRef; + #endregion + + #region Properties, Indexers + private string Accept => string.Join(',', NormalizedAllowedImageFileTypes.Select(fileType => $".{fileType}")); private string EditorId => $"{Id}-editor"; @@ -41,6 +47,10 @@ internal IReadOnlyCollection EnabledToolbarItems protected override string? ClassNames => BuildClassNames(Class, ("bb-rich-text-editor", true)); + #endregion + + #region Methods + protected override async ValueTask DisposeAsyncCore(bool disposing) { if (disposing) @@ -163,6 +173,10 @@ private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) } } + #endregion + + #region Properties, Indexers + /// /// Gets or sets the accessible label for the editor. /// @@ -278,4 +292,6 @@ private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) [CascadingParameter] private EditContext? EditContext { get; set; } [Inject] private RichTextEditorJsInterop RichTextEditorJsInterop { get; set; } = default!; + + #endregion } diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs index 331a069e1..91146552e 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs @@ -2,11 +2,17 @@ namespace BlazorBootstrap; internal sealed class RichTextEditorJsInterop : JsInteropBase { + #region Constructors + public RichTextEditorJsInterop(IJSRuntime jsRuntime) : base(jsRuntime, "./_content/Blazor.Bootstrap/blazor.bootstrap.rich-text-editor.js") { } + #endregion + + #region Methods + public Task ClearAsync(string id) => SafeInvokeVoidAsync("clear", id); public Task DisposeEditorAsync(string id) => SafeInvokeVoidAsync("dispose", id); @@ -19,4 +25,6 @@ public Task InitializeAsync(string id, DotNetObjectReference obj public Task InsertImageAsync(string id, string url, string altText) => SafeInvokeVoidAsync("insertImage", id, url, altText); public Task SetValueAsync(string id, string value) => SafeInvokeVoidAsync("setValue", id, value); + + #endregion } From f55d7003355a98955d77c9222bab909a2ec2d70d Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Thu, 13 Aug 2026 00:31:53 +0530 Subject: [PATCH 03/53] Refactor RichTextEditor image upload classes Added explicit constructors and read-only properties to RichTextEditorImageUploadRequest and RichTextEditorImageUploadResult. Introduced #region directives to organize constructors and properties/indexers. --- .../Models/RichTextEditorImageUploadRequest.cs | 8 ++++++++ blazorbootstrap/Models/RichTextEditorImageUploadResult.cs | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/blazorbootstrap/Models/RichTextEditorImageUploadRequest.cs b/blazorbootstrap/Models/RichTextEditorImageUploadRequest.cs index 78ab37fd5..b9aae98ed 100644 --- a/blazorbootstrap/Models/RichTextEditorImageUploadRequest.cs +++ b/blazorbootstrap/Models/RichTextEditorImageUploadRequest.cs @@ -5,6 +5,8 @@ namespace BlazorBootstrap; /// public sealed class RichTextEditorImageUploadRequest { + #region Constructors + public RichTextEditorImageUploadRequest(IBrowserFile file, string altText, CancellationToken cancellationToken) { File = file; @@ -12,6 +14,10 @@ public RichTextEditorImageUploadRequest(IBrowserFile file, string altText, Cance CancellationToken = cancellationToken; } + #endregion + + #region Properties, Indexers + /// Gets the selected image file. public IBrowserFile File { get; } @@ -20,4 +26,6 @@ public RichTextEditorImageUploadRequest(IBrowserFile file, string altText, Cance /// Gets a token cancelled when the editor is disposed or another upload starts. public CancellationToken CancellationToken { get; } + + #endregion } diff --git a/blazorbootstrap/Models/RichTextEditorImageUploadResult.cs b/blazorbootstrap/Models/RichTextEditorImageUploadResult.cs index 7f4d88b91..f573a026b 100644 --- a/blazorbootstrap/Models/RichTextEditorImageUploadResult.cs +++ b/blazorbootstrap/Models/RichTextEditorImageUploadResult.cs @@ -5,8 +5,16 @@ namespace BlazorBootstrap; /// public sealed class RichTextEditorImageUploadResult { + #region Constructors + public RichTextEditorImageUploadResult(string url) => Url = url; + #endregion + + #region Properties, Indexers + /// Gets the URL to insert into the editor. public string Url { get; } + + #endregion } From 0897a628e0f06e0bdfb74497152d79538321b175 Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Thu, 13 Aug 2026 23:24:51 +0530 Subject: [PATCH 04/53] Refactor toolbar layout to use Bootstrap flex utilities Replaced custom toolbar container and button classes in RichTextEditor components with Bootstrap flex row and margin utilities for improved layout and scrolling. Simplified toolbar group structure by removing group label and using Bootstrap border-end for separation. --- .../Components/Form/RichTextEditor/RichTextEditor.razor | 2 +- .../Form/RichTextEditor/RichTextEditorToolbarButton.razor | 2 +- .../Form/RichTextEditor/RichTextEditorToolbarGroup.razor | 7 ++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor index 3e4fea16f..f6e793ec7 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor @@ -2,7 +2,7 @@ @inherits BlazorBootstrapComponentBase
-
+
diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor index 105043fb0..f90303171 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor @@ -1,7 +1,7 @@ @namespace BlazorBootstrap
- + diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor index f6e793ec7..573d6f3b7 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor @@ -1,7 +1,197 @@ @namespace BlazorBootstrap @inherits BlazorBootstrapComponentBase -
+
+
+
+ + +
+

Project brief

+

Designing a clearer editorial workflow

+

Our next release should make routine publishing feel faster and more deliberate. This draft brings writing, structure, and review notes into one focused workspace.

+

Use the editor to shape the story first, then refine the details. The team can review the content guidelines before the final approval pass.

+ +

Release goals

+
    +
  • Give authors a consistent set of formatting choices.
  • +
  • Keep complex documents readable across screen sizes.
  • +
  • Make important review notes easy to spot.
  • +
+ +
+

“Good editing is not about adding more controls; it is about making the right controls easy to find.”

+
Content design principle
+
+ +

Implementation note

+
publish(document, { reviewRequired: true });
+ +

Milestones

+
+ + + + + + + + + + + + + + + + + + + + + + + +
StageOwnerTargetStatus
Content draftEditorialAug 20Complete
Review passDesignAug 22In progress
+
+

Review note: Confirm the accessibility copy before publishing.

+
+
+ +
+
+ Paragraph + Inter, 14 px + Left aligned +
+
+ All changes saved + 1,248 characters + 214 words + 4 min read + English (US) +
+
+
+
+ +@*
@@ -29,3 +219,4 @@ }
+ *@ \ No newline at end of file diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.css b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.css deleted file mode 100644 index 3d1e5d3cc..000000000 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.css +++ /dev/null @@ -1,69 +0,0 @@ -.bb-rich-text-editor { - border: 1px solid var(--bs-border-color); - border-radius: var(--bs-border-radius); - background-color: var(--bs-body-bg); -} - -.bb-rich-text-editor-toolbar { - display: flex; - flex-wrap: wrap; - border-bottom: 1px solid var(--bs-border-color); - background-color: var(--bs-tertiary-bg); -} - -.bb-rich-text-editor-toolbar-group { - display: flex; - flex-direction: column; - align-items: center; - padding: .25rem .5rem; - border-right: 1px solid var(--bs-border-color); -} - -.bb-rich-text-editor-toolbar-group-items { - display: flex; - align-items: center; - gap: .2rem; -} - -.bb-rich-text-editor-toolbar-group-label { - margin-top: .2rem; - font-size: .7rem; - color: var(--bs-secondary-color); -} - -.bb-rich-text-editor-toolbar-button { - min-width: 2rem; -} - -.bb-rich-text-editor-toolbar-select { - width: auto; - min-width: 7rem; -} - -.bb-rich-text-editor-editable { - min-height: 12rem; - border: 0; - border-radius: 0; - overflow: auto; -} - -.bb-rich-text-editor-editable:focus { - box-shadow: none; -} - -.bb-rich-text-editor-editable:empty::before { - content: attr(data-placeholder); - color: var(--bs-secondary-color); - pointer-events: none; -} - -.bb-rich-text-editor-dialog { - width: min(30rem, calc(100vw - 2rem)); - border: 0; - border-radius: var(--bs-border-radius); - box-shadow: var(--bs-box-shadow-lg); -} - -.bb-rich-text-editor-dialog::backdrop { - background: rgba(0, 0, 0, .5); -} From 50580ebaf0cd172ae43c0a0a6d45a2a6c6113651 Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Sat, 15 Aug 2026 01:41:18 +0530 Subject: [PATCH 06/53] Refactor RichTextEditor: unify toolbar, improve events Refactored RichTextEditor to use a single grouped Bootstrap toolbar and inline modal dialogs in the .razor file. Updated toolbar button and dropdown command handling. Replaced modular toolbar/dialog components. Changed ValueChanged to emit a new RichTextEditorChange record with HTML, plain text, and counts. Added StatusChanged event for error/status reporting. Simplified ClearAsync and improved code clarity. Added RichTextEditorChange.cs for change event data. Updated documentation comments. --- .../Form/RichTextEditor/RichTextEditor.razor | 288 +++++++----------- .../RichTextEditor/RichTextEditor.razor.cs | 119 +++----- .../Models/RichTextEditorChange.cs | 6 + 3 files changed, 142 insertions(+), 271 deletions(-) create mode 100644 blazorbootstrap/Models/RichTextEditorChange.cs diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor index 573d6f3b7..fcf35fe82 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor @@ -1,222 +1,138 @@ -@namespace BlazorBootstrap +@namespace BlazorBootstrap @inherits BlazorBootstrapComponentBase -
-
-
-
- + - - - + - @if (!string.IsNullOrWhiteSpace(imageUploadError)) - { - - } + - *@ \ No newline at end of file diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs index fa71c05f3..88452032c 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs @@ -1,12 +1,10 @@ -namespace BlazorBootstrap; +namespace BlazorBootstrap; /// -/// Provides a dependency-free rich-text editing control with a grouped toolbar. +/// Provides a dependency-free rich-text editing control with a grouped Bootstrap toolbar. /// public partial class RichTextEditor : BlazorBootstrapComponentBase { - #region Fields and Constants - private static readonly string[] defaultAllowedImageFileTypes = { "jpg", "jpeg", "png", "gif", "webp" }; private CancellationTokenSource uploadCancellationTokenSource = new(); private FieldIdentifier fieldIdentifier; @@ -14,29 +12,10 @@ public partial class RichTextEditor : BlazorBootstrapComponentBase private string? lastRenderedValue; private DotNetObjectReference? objRef; - #endregion - - #region Properties, Indexers - private string Accept => string.Join(',', NormalizedAllowedImageFileTypes.Select(fileType => $".{fileType}")); private string EditorId => $"{Id}-editor"; - internal IReadOnlyCollection EnabledToolbarItems - { - get - { - var items = ToolbarItems?.Distinct().ToHashSet() ?? Enum.GetValues().ToHashSet(); - - if (ImageUploadHandler is null) - items.Remove(RichTextEditorToolbarItem.UploadImage); - - return items; - } - } - - private string fieldCssClasses => ValueExpression is null ? string.Empty : EditContext?.FieldCssClass(fieldIdentifier) ?? string.Empty; - private string ImageInputId => $"{Id}-image-input"; private HashSet NormalizedAllowedImageFileTypes => @@ -45,12 +24,6 @@ internal IReadOnlyCollection EnabledToolbarItems .Where(fileType => !string.IsNullOrWhiteSpace(fileType)) .ToHashSet(StringComparer.OrdinalIgnoreCase); - protected override string? ClassNames => BuildClassNames(Class, ("bb-rich-text-editor", true)); - - #endregion - - #region Methods - protected override async ValueTask DisposeAsyncCore(bool disposing) { if (disposing) @@ -99,10 +72,7 @@ protected override void OnInitialized() /// [AddedVersion("4.0.0")] [Description("Clears the editor content.")] - public async Task ClearAsync() - { - await RichTextEditorJsInterop.ClearAsync(Id!); - } + public Task ClearAsync() => RichTextEditorJsInterop.ClearAsync(Id!); /// /// Focuses the editable area. @@ -116,12 +86,16 @@ public async Task OnEditorValueChangedAsync(string html) { imageUploadError = null; lastRenderedValue = html; - await ValueChanged.InvokeAsync(html); + var text = ToPlainText(html); + await ValueChanged.InvokeAsync(new RichTextEditorChange(html, text, text.Length, CountWords(text))); if (ValueExpression is not null) EditContext?.NotifyFieldChanged(fieldIdentifier); } + [JSInvokable] + public Task OnEditorStatusChangedAsync(string status) => StatusChanged.InvokeAsync(status); + private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) { imageUploadError = null; @@ -130,6 +104,7 @@ private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) if (ImageUploadHandler is null) { imageUploadError = "An image upload handler is not configured."; + await OnEditorStatusChangedAsync(imageUploadError); return; } @@ -137,12 +112,14 @@ private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) if (!NormalizedAllowedImageFileTypes.Contains(extension)) { imageUploadError = "The selected image type is not allowed."; + await OnEditorStatusChangedAsync(imageUploadError); return; } if (file.Size > MaxImageFileSize) { imageUploadError = "The selected image exceeds the maximum allowed size."; + await OnEditorStatusChangedAsync(imageUploadError); return; } @@ -158,6 +135,7 @@ private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) if (result is null || !Uri.TryCreate(result.Url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) { imageUploadError = "The upload did not return a valid HTTPS image URL."; + await OnEditorStatusChangedAsync(imageUploadError); return; } @@ -170,128 +148,99 @@ private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) catch (Exception) { imageUploadError = "The image could not be uploaded."; + await OnEditorStatusChangedAsync(imageUploadError); } } - #endregion + private static int CountWords(string text) => string.IsNullOrWhiteSpace(text) ? 0 : text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; - #region Properties, Indexers + private static string ToPlainText(string html) => System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ")).Trim(); - /// - /// Gets or sets the accessible label for the editor. - /// + /// Gets or sets the accessible label for the editor. [AddedVersion("4.0.0")] [DefaultValue("Rich text editor")] [Description("Gets or sets the accessible label for the editor.")] [Parameter] public string AriaLabel { get; set; } = "Rich text editor"; - /// - /// Gets or sets the permitted image file extensions. - /// + /// Gets or sets the permitted image file extensions. [AddedVersion("4.0.0")] [Description("Gets or sets the permitted image file extensions.")] [Parameter] public IEnumerable? AllowedImageFileTypes { get; set; } - /// - /// Gets or sets the delay, in milliseconds, before editor changes are raised. - /// + /// Gets or sets the delay, in milliseconds, before editor changes are raised. [AddedVersion("4.0.0")] [DefaultValue(300)] [Description("Gets or sets the delay, in milliseconds, before editor changes are raised.")] [Parameter] public int DebounceInterval { get; set; } = 300; - /// - /// Gets or sets whether the editor is disabled. - /// + /// Gets or sets whether the editor is disabled. [AddedVersion("4.0.0")] [DefaultValue(false)] [Description("Gets or sets whether the editor is disabled.")] [Parameter] public bool Disabled { get; set; } - /// - /// Gets or sets the image upload handler. - /// + /// Gets or sets the image upload handler. [AddedVersion("4.0.0")] - [DefaultValue(null)] [Description("Gets or sets the image upload handler.")] [Parameter] public RichTextEditorImageUploadDelegate? ImageUploadHandler { get; set; } - /// - /// Gets or sets the maximum allowed image size in bytes. - /// + /// Gets or sets the maximum allowed image size in bytes. [AddedVersion("4.0.0")] [DefaultValue(5242880)] [Description("Gets or sets the maximum allowed image size in bytes.")] [Parameter] public long MaxImageFileSize { get; set; } = 5 * 1024 * 1024; - /// - /// Gets or sets the maximum plain-text character count. - /// + /// Gets or sets the maximum plain-text character count. [AddedVersion("4.0.0")] [DefaultValue(null)] [Description("Gets or sets the maximum plain-text character count.")] [Parameter] public int? MaxLength { get; set; } - /// - /// Gets or sets the placeholder text. - /// + /// Gets or sets the placeholder text. [AddedVersion("4.0.0")] [DefaultValue(null)] [Description("Gets or sets the placeholder text.")] [Parameter] public string? Placeholder { get; set; } - /// - /// Gets or sets whether the editor is read-only. - /// + /// Gets or sets whether the editor is read-only. [AddedVersion("4.0.0")] [DefaultValue(false)] [Description("Gets or sets whether the editor is read-only.")] [Parameter] public bool ReadOnly { get; set; } - /// - /// Gets or sets the enabled toolbar commands. - /// + /// Gets or sets the HTML value. [AddedVersion("4.0.0")] [DefaultValue(null)] - [Description("Gets or sets the enabled toolbar commands.")] + [Description("Gets or sets the HTML value.")] [Parameter] - public IEnumerable? ToolbarItems { get; set; } + public string Value { get; set; } = string.Empty; - /// - /// Gets or sets the HTML value. - /// + /// Fires after the editor commits a content change. [AddedVersion("4.0.0")] - [DefaultValue(null)] - [Description("Gets or sets the HTML value.")] + [Description("Fires after the editor commits a content change.")] [Parameter] - public string Value { get; set; } = string.Empty; + public EventCallback ValueChanged { get; set; } - /// - /// Fires when the HTML value changes. - /// + /// Fires for transient editor activity and error status. [AddedVersion("4.0.0")] - [Description("Fires when the HTML value changes.")] + [Description("Fires for transient editor activity and error status.")] [Parameter] - public EventCallback ValueChanged { get; set; } + public EventCallback StatusChanged { get; set; } - /// - /// Gets or sets the expression that identifies the bound value. - /// + /// Gets or sets the expression that identifies the bound value. [Parameter] public Expression>? ValueExpression { get; set; } [CascadingParameter] private EditContext? EditContext { get; set; } [Inject] private RichTextEditorJsInterop RichTextEditorJsInterop { get; set; } = default!; - - #endregion } diff --git a/blazorbootstrap/Models/RichTextEditorChange.cs b/blazorbootstrap/Models/RichTextEditorChange.cs new file mode 100644 index 000000000..ceabb71dc --- /dev/null +++ b/blazorbootstrap/Models/RichTextEditorChange.cs @@ -0,0 +1,6 @@ +namespace BlazorBootstrap; + +/// +/// Represents a committed RichTextEditor content change. +/// +public sealed record RichTextEditorChange(string Html, string Text, int CharacterCount, int WordCount); From dc82e0a5c8dc6bd841df16f7e907c1e4c6374dea Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Sat, 15 Aug 2026 02:04:06 +0530 Subject: [PATCH 07/53] Refactor RichTextEditor.razor.cs for clarity & docs Reorganize code with region blocks, group related members, and add XML documentation to public methods. Add [DefaultValue] and [Description] attributes for metadata. No functional changes. --- .../RichTextEditor/RichTextEditor.razor.cs | 61 +++++++++++++++---- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs index 88452032c..e6f0bb627 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs @@ -1,10 +1,12 @@ -namespace BlazorBootstrap; +namespace BlazorBootstrap; /// /// Provides a dependency-free rich-text editing control with a grouped Bootstrap toolbar. /// public partial class RichTextEditor : BlazorBootstrapComponentBase { + #region Fields and Constants + private static readonly string[] defaultAllowedImageFileTypes = { "jpg", "jpeg", "png", "gif", "webp" }; private CancellationTokenSource uploadCancellationTokenSource = new(); private FieldIdentifier fieldIdentifier; @@ -12,17 +14,11 @@ public partial class RichTextEditor : BlazorBootstrapComponentBase private string? lastRenderedValue; private DotNetObjectReference? objRef; - private string Accept => string.Join(',', NormalizedAllowedImageFileTypes.Select(fileType => $".{fileType}")); + #endregion - private string EditorId => $"{Id}-editor"; + #region Methods - private string ImageInputId => $"{Id}-image-input"; - - private HashSet NormalizedAllowedImageFileTypes => - (AllowedImageFileTypes ?? defaultAllowedImageFileTypes) - .Select(fileType => fileType.Trim().TrimStart('.').ToLowerInvariant()) - .Where(fileType => !string.IsNullOrWhiteSpace(fileType)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + #region Protected Override Methods protected override async ValueTask DisposeAsyncCore(bool disposing) { @@ -67,6 +63,10 @@ protected override void OnInitialized() base.OnInitialized(); } + #endregion + + #region Public Methods + /// /// Clears the editor content. /// @@ -81,6 +81,11 @@ protected override void OnInitialized() [Description("Focuses the editable area.")] public Task FocusAsync() => RichTextEditorJsInterop.FocusAsync(Id!); + /// + /// Raises the committed editor value callback. + /// + [AddedVersion("4.0.0")] + [Description("Raises the committed editor value callback.")] [JSInvokable] public async Task OnEditorValueChangedAsync(string html) { @@ -93,9 +98,20 @@ public async Task OnEditorValueChangedAsync(string html) EditContext?.NotifyFieldChanged(fieldIdentifier); } + /// + /// Raises the transient editor status callback. + /// + [AddedVersion("4.0.0")] + [Description("Raises the transient editor status callback.")] [JSInvokable] public Task OnEditorStatusChangedAsync(string status) => StatusChanged.InvokeAsync(status); + #endregion + + #region Private Methods + + private static int CountWords(string text) => string.IsNullOrWhiteSpace(text) ? 0 : text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; + private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) { imageUploadError = null; @@ -152,10 +168,26 @@ private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) } } - private static int CountWords(string text) => string.IsNullOrWhiteSpace(text) ? 0 : text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; - private static string ToPlainText(string html) => System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ")).Trim(); + #endregion + + #endregion + + #region Properties, Indexers + + private string Accept => string.Join(',', NormalizedAllowedImageFileTypes.Select(fileType => $".{fileType}")); + + private string EditorId => $"{Id}-editor"; + + private string ImageInputId => $"{Id}-image-input"; + + private HashSet NormalizedAllowedImageFileTypes => + (AllowedImageFileTypes ?? defaultAllowedImageFileTypes) + .Select(fileType => fileType.Trim().TrimStart('.').ToLowerInvariant()) + .Where(fileType => !string.IsNullOrWhiteSpace(fileType)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + /// Gets or sets the accessible label for the editor. [AddedVersion("4.0.0")] [DefaultValue("Rich text editor")] @@ -185,6 +217,7 @@ private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) /// Gets or sets the image upload handler. [AddedVersion("4.0.0")] + [DefaultValue(null)] [Description("Gets or sets the image upload handler.")] [Parameter] public RichTextEditorImageUploadDelegate? ImageUploadHandler { get; set; } @@ -237,10 +270,14 @@ private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) public EventCallback StatusChanged { get; set; } /// Gets or sets the expression that identifies the bound value. + [AddedVersion("4.0.0")] + [Description("Gets or sets the expression that identifies the bound value.")] [Parameter] public Expression>? ValueExpression { get; set; } [CascadingParameter] private EditContext? EditContext { get; set; } [Inject] private RichTextEditorJsInterop RichTextEditorJsInterop { get; set; } = default!; + + #endregion } From 1787bb0700dc820ed5d40a7c2584971f84ac12de Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Sat, 15 Aug 2026 02:10:00 +0530 Subject: [PATCH 08/53] Refactor rich text editor: security, history, commands Refactors and enhances the rich text editor's JS logic: - Expands allowed HTML tags/attributes and tightens CSS style checks for improved security. - Unifies link/image URL validation with isSafeUrl. - Adds draft persistence via sessionStorage and draftKey. - Implements undo/redo history stack (100 entries). - Adds text metrics and live footer updates. - Switches to semantic HTML for formatting commands; expands formatting options. - Improves sanitization of styles, classes, and attributes. - Refactors event handling and updates core editor functions. - Cleans up code, improves naming, and removes dialog/table logic. --- .../blazor.bootstrap.rich-text-editor.js | 297 ++++-------------- 1 file changed, 56 insertions(+), 241 deletions(-) diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index 1d774424b..553d71184 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -1,263 +1,78 @@ const instances = new Map(); -const allowedTags = new Set(["P", "BR", "H1", "H2", "H3", "STRONG", "EM", "U", "S", "UL", "OL", "LI", "BLOCKQUOTE", "PRE", "CODE", "A", "IMG", "TABLE", "THEAD", "TBODY", "TR", "TH", "TD", "SPAN"]); +const draftKey = "advanced-rich-text-editor-document"; +const allowedTags = new Set(["P", "BR", "H1", "H2", "H3", "FIGCAPTION", "STRONG", "EM", "U", "S", "SPAN", "UL", "OL", "LI", "BLOCKQUOTE", "PRE", "CODE", "HR", "A", "IMG", "FIGURE", "TABLE", "CAPTION", "THEAD", "TBODY", "TR", "TH", "TD"]); const blockedTags = new Set(["SCRIPT", "STYLE", "TEMPLATE", "IFRAME", "OBJECT", "EMBED", "FORM", "INPUT", "BUTTON", "SVG", "MATH", "META", "LINK"]); -const allowedClasses = new Set(["text-start", "text-center", "text-end", "text-primary", "text-secondary", "text-success", "text-danger", "text-warning", "text-info", "text-dark", "img-fluid", "table", "table-bordered"]); +const allowedClasses = new Set(["text-start", "text-center", "text-end", "img-fluid", "table", "table-bordered"]); -function isSafeLink(value) { +function isSafeUrl(value, image = false) { if (!value) return false; const trimmed = value.trim(); - return trimmed.startsWith("/") || trimmed.startsWith("#") || /^https:\/\//i.test(trimmed); -} - -function isSafeImage(value) { - return !!value && /^https:\/\//i.test(value.trim()); + if (trimmed.startsWith("/") || trimmed.startsWith("#")) return !image; + try { + const url = new URL(trimmed, window.location.origin); + return ["https:", "mailto:", "tel:"].includes(url.protocol) && (!image || url.protocol === "https:"); + } catch { return false; } } function sanitize(html) { const documentFragment = new DOMParser().parseFromString(html || "", "text/html"); - const nodes = Array.from(documentFragment.body.querySelectorAll("*")); - - for (const node of nodes) { - if (blockedTags.has(node.tagName)) { - node.remove(); - continue; - } - - if (!allowedTags.has(node.tagName)) { - node.replaceWith(...Array.from(node.childNodes)); - continue; - } - - for (const attribute of Array.from(node.attributes)) { + for (const node of [...documentFragment.body.querySelectorAll("*")]) { + if (blockedTags.has(node.tagName)) { node.remove(); continue; } + if (!allowedTags.has(node.tagName)) { node.replaceWith(...node.childNodes); continue; } + for (const attribute of [...node.attributes]) { const name = attribute.name.toLowerCase(); - if (name.startsWith("on") || name === "style" || name === "id") { - node.removeAttribute(attribute.name); - continue; - } - - if (name === "class") { - const safeClasses = attribute.value.split(/\s+/).filter(value => allowedClasses.has(value)); - if (safeClasses.length) node.setAttribute("class", safeClasses.join(" ")); - else node.removeAttribute("class"); - continue; - } - - const allowed = (node.tagName === "A" && name === "href") - || (node.tagName === "IMG" && (name === "src" || name === "alt")) - || (node.tagName === "TH" && name === "scope"); - if (!allowed) node.removeAttribute(attribute.name); + const allowed = (node.tagName === "A" && ["href", "target", "rel"].includes(name)) + || (node.tagName === "IMG" && ["src", "alt"].includes(name)) + || (node.tagName === "TH" && name === "scope") + || (node.tagName === "SPAN" && ["data-bb-rte-color", "style"].includes(name)) + || (name === "class" && [...attribute.value.split(/\s+/)].every(value => allowedClasses.has(value))); + if (name === "style" && !/^(color|background-color|font-family|font-size|text-align|margin-left):\s*[-#(),.%\w\s]+;?$/i.test(attribute.value)) node.removeAttribute(attribute.name); else if (name.startsWith("on") || !allowed) node.removeAttribute(attribute.name); } - - if (node.tagName === "A" && !isSafeLink(node.getAttribute("href"))) node.removeAttribute("href"); - if (node.tagName === "IMG" && !isSafeImage(node.getAttribute("src"))) node.remove(); + if (node.tagName === "A" && !isSafeUrl(node.getAttribute("href"))) node.remove(); + if (node.tagName === "A" && node.target === "_blank") node.rel = "noopener noreferrer"; + if (node.tagName === "IMG" && !isSafeUrl(node.getAttribute("src"), true)) node.remove(); } - return documentFragment.body.innerHTML; } -function getRange(instance) { +function currentRange(instance) { const selection = window.getSelection(); - if (selection && selection.rangeCount && instance.editor.contains(selection.anchorNode)) return selection.getRangeAt(0); - return instance.range; -} - -function saveRange(instance) { - const range = getRange(instance); - if (range) instance.range = range.cloneRange(); -} - -function restoreRange(instance) { - if (!instance.range) return; - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(instance.range); -} - -function notify(instance, immediate = false) { - window.clearTimeout(instance.timer); - const raise = () => { - const html = sanitize(instance.editor.innerHTML); - if (instance.editor.innerHTML !== html) instance.editor.innerHTML = html; - instance.dotNetRef.invokeMethodAsync("OnEditorValueChangedAsync", html); - }; - if (immediate) raise(); - else instance.timer = window.setTimeout(raise, instance.debounceInterval); -} - -function enforceLength(instance) { - if (!instance.maxLength || instance.editor.innerText.length <= instance.maxLength) return; - instance.editor.innerText = instance.editor.innerText.substring(0, instance.maxLength); -} - -function insertNode(instance, node) { - restoreRange(instance); - const range = getRange(instance) || document.createRange(); - if (!range.startContainer.isConnected) range.selectNodeContents(instance.editor); - range.collapse(false); - range.deleteContents(); - range.insertNode(node); - range.setStartAfter(node); - range.collapse(true); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - saveRange(instance); -} - -function applyBlockClass(instance, className) { - restoreRange(instance); - const range = getRange(instance); - let node = range?.commonAncestorContainer; - if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; - const block = node?.closest?.("p,h1,h2,h3,blockquote,pre,li") || instance.editor; - [...block.classList].filter(value => value.startsWith("text-")).forEach(value => block.classList.remove(value)); - if (className) block.classList.add(className); -} - + return selection?.rangeCount && instance.editor.contains(selection.anchorNode) ? selection.getRangeAt(0) : instance.range; +} +function saveRange(instance) { const range = currentRange(instance); if (range) instance.range = range.cloneRange(); } +function restoreRange(instance) { if (!instance.range) return false; const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(instance.range); return true; } +function textMetrics(instance) { const text = instance.editor.innerText.trim(); return { text, characters: text.length, words: text ? text.split(/\s+/).length : 0 }; } +function updateFooter(instance) { const metrics = textMetrics(instance); instance.root.querySelector("[data-bb-rte-character-count]").textContent = `${metrics.characters} characters`; instance.root.querySelector("[data-bb-rte-word-count]").textContent = `${metrics.words} words`; const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest?.("p,h1,h2,h3,figcaption,blockquote,pre,li"); const context = block?.tagName === "P" || !block ? "Paragraph" : block.tagName.replace("H", "Heading "); instance.root.querySelector("[data-bb-rte-context]").textContent = context; instance.root.querySelector("[data-bb-rte-alignment-context]").textContent = block?.style.textAlign ? `${block.style.textAlign[0].toUpperCase()}${block.style.textAlign.slice(1)} aligned` : "Left aligned"; } +function pushHistory(instance) { const html = sanitize(instance.editor.innerHTML); if (instance.history[instance.historyIndex] === html) return; instance.history.splice(instance.historyIndex + 1); instance.history.push(html); if (instance.history.length > 100) instance.history.shift(); instance.historyIndex = instance.history.length - 1; } +function commit(instance, status = "") { const html = sanitize(instance.editor.innerHTML); if (instance.editor.innerHTML !== html) instance.editor.innerHTML = html; pushHistory(instance); updateFooter(instance); try { sessionStorage.setItem(draftKey, html); } catch { } instance.dotNetRef.invokeMethodAsync("OnEditorValueChangedAsync", html); if (status) instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", status); } +function insertNode(instance, node) { restoreRange(instance); const range = currentRange(instance) || document.createRange(); if (!range.startContainer.isConnected) range.selectNodeContents(instance.editor); range.collapse(false); range.deleteContents(); range.insertNode(node); range.setStartAfter(node); range.collapse(true); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); saveRange(instance); } +function wrapSelection(instance, tagName, attributes = {}) { restoreRange(instance); const range = currentRange(instance); if (!range || range.collapsed) return false; const element = document.createElement(tagName); Object.entries(attributes).forEach(([name, value]) => element.setAttribute(name, value)); try { range.surroundContents(element); } catch { const fragment = range.extractContents(); element.append(fragment); range.insertNode(element); } saveRange(instance); return true; } +function blockFormat(instance, tagName) { restoreRange(instance); const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest("p,h1,h2,h3,figcaption,blockquote,pre,li") || null; if (!block) return false; const replacement = document.createElement(tagName); replacement.append(...block.childNodes); block.replaceWith(replacement); const selection = window.getSelection(); const nextRange = document.createRange(); nextRange.selectNodeContents(replacement); nextRange.collapse(false); selection.removeAllRanges(); selection.addRange(nextRange); saveRange(instance); return true; } function execute(instance, command, value) { if (instance.disabled || instance.readOnly) return; - restoreRange(instance); - instance.editor.focus(); - - const commands = { - Undo: ["undo"], Redo: ["redo"], Bold: ["bold"], Italic: ["italic"], Underline: ["underline"], Strikethrough: ["strikeThrough"], - OrderedList: ["insertOrderedList"], UnorderedList: ["insertUnorderedList"], Blockquote: ["formatBlock", "blockquote"], CodeBlock: ["formatBlock", "pre"], - AlignStart: ["justifyLeft"], AlignCenter: ["justifyCenter"], AlignEnd: ["justifyRight"], ClearFormatting: ["removeFormat"] - }; - - if (command === "BlockFormat") document.execCommand("formatBlock", false, value || "p"); - else if (command === "TextColor") applyBlockClass(instance, value); - else if (commands[command]) document.execCommand(commands[command][0], false, commands[command][1]); - - enforceLength(instance); - saveRange(instance); - notify(instance, true); -} - -function openDialog(instance, type) { - saveRange(instance); - instance.dialogMode = type; - const dialog = document.getElementById(`${instance.id}-${type === "table" ? "table" : type === "link" ? "link" : "image"}-dialog`); - if (dialog?.showModal) dialog.showModal(); -} - -function insertTable(instance, rows, columns) { - const table = document.createElement("table"); - table.className = "table table-bordered"; - const body = document.createElement("tbody"); - for (let row = 0; row < rows; row++) { - const tr = document.createElement("tr"); - for (let column = 0; column < columns; column++) { - const cell = document.createElement(row === 0 ? "th" : "td"); - if (row === 0) cell.scope = "col"; - cell.appendChild(document.createElement("br")); - tr.appendChild(cell); - } - body.appendChild(tr); - } - table.appendChild(body); - insertNode(instance, table); -} - -function wireDialog(instance, type) { - const dialog = document.getElementById(`${instance.id}-${type}-dialog`); - if (!dialog) return; - dialog.addEventListener("close", () => { - if (dialog.returnValue !== "save") return; - if (type === "link") { - const url = dialog.querySelector("[data-bb-rte-link-url]").value; - if (!isSafeLink(url)) return; - const anchor = document.createElement("a"); - anchor.href = url; - anchor.textContent = window.getSelection()?.toString() || url; - insertNode(instance, anchor); - } else if (type === "image") { - const url = dialog.querySelector("[data-bb-rte-image-url]").value; - const alt = dialog.querySelector("[data-bb-rte-image-alt]").value.trim(); - if (!isSafeImage(url) || !alt) return; - const image = document.createElement("img"); - image.src = url; - image.alt = alt; - image.className = "img-fluid"; - insertNode(instance, image); - } else { - const rows = Number(dialog.querySelector("[data-bb-rte-table-rows]").value); - const columns = Number(dialog.querySelector("[data-bb-rte-table-columns]").value); - if (Number.isInteger(rows) && Number.isInteger(columns) && rows > 0 && rows <= 20 && columns > 0 && columns <= 10) insertTable(instance, rows, columns); - } - notify(instance, true); - }); + instance.editor.focus(); restoreRange(instance); + let changed = false; + if (["Bold", "Italic", "Underline", "Strikethrough"].includes(command)) changed = wrapSelection(instance, { Bold: "strong", Italic: "em", Underline: "u", Strikethrough: "s" }[command]); + else if (command === "BlockFormat") changed = blockFormat(instance, value || "p"); + else if (command === "HorizontalRule") { insertNode(instance, document.createElement("hr")); changed = true; } + else if (command === "Undo" || command === "Redo") { const next = instance.historyIndex + (command === "Undo" ? -1 : 1); if (next >= 0 && next < instance.history.length) { instance.historyIndex = next; instance.editor.innerHTML = instance.history[next]; changed = true; } } + else if (command === "ClearFormatting") { restoreRange(instance); const range = currentRange(instance); if (range && !range.collapsed) { const text = range.toString(); range.deleteContents(); range.insertNode(document.createTextNode(text)); changed = true; } } + else if (["TextColor", "HighlightColor", "FontFamily", "FontSize"].includes(command)) { changed = wrapSelection(instance, "span", { "data-bb-rte-color": value || "" }); const range = currentRange(instance); const span = range?.commonAncestorContainer?.parentElement?.closest?.("span"); if (span) { if (command === "TextColor") span.style.color = value; if (command === "HighlightColor") span.style.backgroundColor = value; if (command === "FontFamily") span.style.fontFamily = value; if (command === "FontSize") span.style.fontSize = `${value}px`; } } else if (["AlignStart", "AlignCenter", "AlignEnd", "Indent", "Outdent"].includes(command)) { const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest?.("p,h1,h2,h3,figcaption,blockquote,pre,li"); if (block) { if (command.startsWith("Align")) block.style.textAlign = { AlignStart: "left", AlignCenter: "center", AlignEnd: "right" }[command]; else { const margin = Number.parseInt(block.style.marginLeft || "0", 10) || 0; block.style.marginLeft = `${Math.max(0, margin + (command === "Indent" ? 24 : -24))}px`; } changed = true; } + if (changed) commit(instance); + else if (!["Link", "Image", "Table", "Fullscreen", "Print"].includes(command)) instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Select content before applying this command."); } export function initialize(id, dotNetRef, debounceInterval, maxLength, readOnly, disabled) { - dispose(id); - const root = document.getElementById(id); - const editor = document.getElementById(`${id}-editor`); - if (!root || !editor) return; - const instance = { id, root, editor, dotNetRef, debounceInterval: Math.max(0, debounceInterval || 300), maxLength, readOnly, disabled, range: null, timer: 0 }; - instances.set(id, instance); - editor.innerHTML = sanitize(editor.innerHTML); - editor.addEventListener("input", () => { enforceLength(instance); saveRange(instance); notify(instance); }); - editor.addEventListener("keyup", () => saveRange(instance)); - editor.addEventListener("mouseup", () => saveRange(instance)); - editor.addEventListener("paste", event => { - event.preventDefault(); - const html = event.clipboardData.getData("text/html"); - const text = event.clipboardData.getData("text/plain"); - const wrapper = document.createElement("div"); - wrapper.innerHTML = sanitize(html || text.replace(/&/g, "&").replace(//g, ">").replace(/\n/g, "
")); - insertNode(instance, wrapper); - enforceLength(instance); - notify(instance, true); - }); - root.addEventListener("click", event => { - const button = event.target.closest("[data-bb-rte-command]"); - if (!button) return; - const command = button.dataset.bbRteCommand; - if (command === "Link") openDialog(instance, "link"); - else if (command === "Image") openDialog(instance, "image"); - else if (command === "UploadImage") document.getElementById(`${id}-image-input`)?.click(); - else if (command === "Table") openDialog(instance, "table"); - else execute(instance, command); - }); - root.addEventListener("change", event => { - const select = event.target.closest("[data-bb-rte-command]"); - if (select) execute(instance, select.dataset.bbRteCommand, select.value); - }); - wireDialog(instance, "link"); - wireDialog(instance, "image"); - wireDialog(instance, "table"); -} - -export function clear(id) { - const instance = instances.get(id); - if (!instance) return; - instance.editor.innerHTML = ""; - notify(instance, true); -} - -export function dispose(id) { - const instance = instances.get(id); - if (instance) window.clearTimeout(instance.timer); - instances.delete(id); -} - + dispose(id); const root = document.getElementById(id); const editor = document.getElementById(`${id}-editor`); if (!root || !editor) return; + const instance = { id, root, editor, dotNetRef, debounceInterval: Math.max(0, debounceInterval || 300), maxLength, readOnly, disabled, range: null, timer: 0, history: [], historyIndex: -1 }; + instances.set(id, instance); let stored = null; try { stored = sessionStorage.getItem(draftKey); } catch { } editor.innerHTML = sanitize(stored || editor.innerHTML); pushHistory(instance); updateFooter(instance); + editor.addEventListener("input", () => { if (instance.maxLength && editor.innerText.length > instance.maxLength) editor.innerText = editor.innerText.slice(0, instance.maxLength); saveRange(instance); clearTimeout(instance.timer); instance.timer = setTimeout(() => commit(instance), instance.debounceInterval); }); + ["keyup", "mouseup", "focusout"].forEach(eventName => editor.addEventListener(eventName, () => saveRange(instance))); + editor.addEventListener("paste", event => { event.preventDefault(); const text = event.clipboardData.getData("text/plain"); insertNode(instance, document.createTextNode(text)); commit(instance); }); + root.addEventListener("click", event => { const button = event.target.closest("[data-bb-rte-command]"); if (!button) return; saveRange(instance); execute(instance, button.dataset.bbRteCommand, button.dataset.bbRteValue); }); +} +export function clear(id) { const instance = instances.get(id); if (!instance) return; instance.editor.innerHTML = ""; commit(instance); } +export function dispose(id) { const instance = instances.get(id); if (instance) clearTimeout(instance.timer); instances.delete(id); } export function focus(id) { instances.get(id)?.editor.focus(); } - -export function insertImage(id, url, altText) { - const instance = instances.get(id); - if (!instance || !isSafeImage(url)) return; - const image = document.createElement("img"); - image.src = url; - image.alt = altText || ""; - image.className = "img-fluid"; - insertNode(instance, image); - notify(instance, true); -} - -export function setValue(id, value) { - const instance = instances.get(id); - if (!instance) return; - const html = sanitize(value); - if (instance.editor.innerHTML !== html) instance.editor.innerHTML = html; -} +export function insertImage(id, url, altText) { const instance = instances.get(id); if (!instance || !isSafeUrl(url, true)) return; const image = document.createElement("img"); image.src = url; image.alt = altText || ""; image.className = "img-fluid"; insertNode(instance, image); commit(instance); } +export function setValue(id, value) { const instance = instances.get(id); if (!instance) return; instance.editor.innerHTML = sanitize(value); pushHistory(instance); updateFooter(instance); } \ No newline at end of file From 5625534db16591824009d1570dce7ec24421ecbd Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Sat, 15 Aug 2026 02:26:24 +0530 Subject: [PATCH 09/53] Add modal support for links, images, tables, fullscreen, print Added modal dialog functions for inserting links, images, and tables. Updated the execute function to handle new commands by showing modals or performing actions. Modified event handling for modal save actions. Refactored code for readability; no changes to core text formatting logic. --- .../blazor.bootstrap.rich-text-editor.js | 83 ++++++++++++++----- 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index 553d71184..503162996 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -48,31 +48,76 @@ function commit(instance, status = "") { const html = sanitize(instance.editor.i function insertNode(instance, node) { restoreRange(instance); const range = currentRange(instance) || document.createRange(); if (!range.startContainer.isConnected) range.selectNodeContents(instance.editor); range.collapse(false); range.deleteContents(); range.insertNode(node); range.setStartAfter(node); range.collapse(true); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); saveRange(instance); } function wrapSelection(instance, tagName, attributes = {}) { restoreRange(instance); const range = currentRange(instance); if (!range || range.collapsed) return false; const element = document.createElement(tagName); Object.entries(attributes).forEach(([name, value]) => element.setAttribute(name, value)); try { range.surroundContents(element); } catch { const fragment = range.extractContents(); element.append(fragment); range.insertNode(element); } saveRange(instance); return true; } function blockFormat(instance, tagName) { restoreRange(instance); const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest("p,h1,h2,h3,figcaption,blockquote,pre,li") || null; if (!block) return false; const replacement = document.createElement(tagName); replacement.append(...block.childNodes); block.replaceWith(replacement); const selection = window.getSelection(); const nextRange = document.createRange(); nextRange.selectNodeContents(replacement); nextRange.collapse(false); selection.removeAllRanges(); selection.addRange(nextRange); saveRange(instance); return true; } +function showModal(instance, name) { + saveRange(instance); + const element = document.getElementById(`${instance.id}-${name}-modal`); + if (!element || !window.bootstrap?.Modal) { instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "This dialog is unavailable."); return; } + window.bootstrap.Modal.getOrCreateInstance(element).show(); +} +function insertLink(instance) { + const modal = document.getElementById(`${instance.id}-link-modal`); + const url = modal?.querySelector("[data-bb-rte-link-url]")?.value?.trim(); + const text = modal?.querySelector("[data-bb-rte-link-text]")?.value?.trim(); + const newTab = modal?.querySelector("[data-bb-rte-link-new-tab]")?.checked; + if (!isSafeUrl(url)) { instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Enter an HTTPS, relative, mailto, or telephone URL."); return; } + const link = document.createElement("a"); link.href = url; link.textContent = text || currentRange(instance)?.toString() || url; + if (newTab) { link.target = "_blank"; link.rel = "noopener noreferrer"; } + insertNode(instance, link); window.bootstrap.Modal.getInstance(modal)?.hide(); commit(instance); +} +function insertImageFromModal(instance) { + const modal = document.getElementById(`${instance.id}-image-modal`); + const url = modal?.querySelector("[data-bb-rte-image-url]")?.value?.trim(); + const alt = modal?.querySelector("[data-bb-rte-image-alt]")?.value?.trim(); + if (!isSafeUrl(url, true) || !alt) { instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Provide an HTTPS image URL and alternative text."); return; } + const image = document.createElement("img"); image.src = url; image.alt = alt; image.className = "img-fluid"; + insertNode(instance, image); window.bootstrap.Modal.getInstance(modal)?.hide(); commit(instance); +} +function toggleFullscreen(instance) { + if (document.fullscreenElement === instance.root) document.exitFullscreen?.(); + else instance.root.requestFullscreen?.().catch(() => instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Fullscreen is unavailable.")); +} +function printEditor(instance) { + const previousTitle = document.title; document.title = instance.root.getAttribute("aria-label") || "Rich text document"; + window.addEventListener("afterprint", () => { document.title = previousTitle; }, { once: true }); window.print(); +} +function insertTable(instance) { + const modal = document.getElementById(`${instance.id}-table-modal`); + const rows = Number(modal?.querySelector("[data-bb-rte-table-rows]")?.value); + const columns = Number(modal?.querySelector("[data-bb-rte-table-columns]")?.value); + if (!Number.isInteger(rows) || !Number.isInteger(columns) || rows < 1 || rows > 20 || columns < 1 || columns > 10) { instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Choose between 1–20 rows and 1–10 columns."); return; } + const table = document.createElement("table"); table.className = "table table-bordered"; + const thead = document.createElement("thead"); const header = document.createElement("tr"); + for (let column = 0; column < columns; column++) { const cell = document.createElement("th"); cell.scope = "col"; cell.append(document.createElement("br")); header.append(cell); } + thead.append(header); table.append(thead); const body = document.createElement("tbody"); + for (let row = 1; row < rows; row++) { const tr = document.createElement("tr"); for (let column = 0; column < columns; column++) { const cell = document.createElement("td"); cell.append(document.createElement("br")); tr.append(cell); } body.append(tr); } + table.append(body); insertNode(instance, table); window.bootstrap.Modal.getInstance(modal)?.hide(); commit(instance); +} function execute(instance, command, value) { if (instance.disabled || instance.readOnly) return; instance.editor.focus(); restoreRange(instance); let changed = false; - if (["Bold", "Italic", "Underline", "Strikethrough"].includes(command)) changed = wrapSelection(instance, { Bold: "strong", Italic: "em", Underline: "u", Strikethrough: "s" }[command]); + if (command === "Link") { showModal(instance, "link"); return; } else if (command === "Image") { showModal(instance, "image"); return; } else if (command === "Table") { showModal(instance, "table"); return; } else if (command === "Fullscreen") { toggleFullscreen(instance); return; } else if (command === "Print") { printEditor(instance); return; } else if (["Bold", "Italic", "Underline", "Strikethrough"].includes(command)) changed = wrapSelection(instance, { Bold: "strong", Italic: "em", Underline: "u", Strikethrough: "s" }[command]); else if (command === "BlockFormat") changed = blockFormat(instance, value || "p"); else if (command === "HorizontalRule") { insertNode(instance, document.createElement("hr")); changed = true; } else if (command === "Undo" || command === "Redo") { const next = instance.historyIndex + (command === "Undo" ? -1 : 1); if (next >= 0 && next < instance.history.length) { instance.historyIndex = next; instance.editor.innerHTML = instance.history[next]; changed = true; } } else if (command === "ClearFormatting") { restoreRange(instance); const range = currentRange(instance); if (range && !range.collapsed) { const text = range.toString(); range.deleteContents(); range.insertNode(document.createTextNode(text)); changed = true; } } - else if (["TextColor", "HighlightColor", "FontFamily", "FontSize"].includes(command)) { changed = wrapSelection(instance, "span", { "data-bb-rte-color": value || "" }); const range = currentRange(instance); const span = range?.commonAncestorContainer?.parentElement?.closest?.("span"); if (span) { if (command === "TextColor") span.style.color = value; if (command === "HighlightColor") span.style.backgroundColor = value; if (command === "FontFamily") span.style.fontFamily = value; if (command === "FontSize") span.style.fontSize = `${value}px`; } } else if (["AlignStart", "AlignCenter", "AlignEnd", "Indent", "Outdent"].includes(command)) { const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest?.("p,h1,h2,h3,figcaption,blockquote,pre,li"); if (block) { if (command.startsWith("Align")) block.style.textAlign = { AlignStart: "left", AlignCenter: "center", AlignEnd: "right" }[command]; else { const margin = Number.parseInt(block.style.marginLeft || "0", 10) || 0; block.style.marginLeft = `${Math.max(0, margin + (command === "Indent" ? 24 : -24))}px`; } changed = true; } - if (changed) commit(instance); - else if (!["Link", "Image", "Table", "Fullscreen", "Print"].includes(command)) instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Select content before applying this command."); -} + else if (["TextColor", "HighlightColor", "FontFamily", "FontSize"].includes(command)) { changed = wrapSelection(instance, "span", { "data-bb-rte-color": value || "" }); const range = currentRange(instance); const span = range?.commonAncestorContainer?.parentElement?.closest?.("span"); if (span) { if (command === "TextColor") span.style.color = value; if (command === "HighlightColor") span.style.backgroundColor = value; if (command === "FontFamily") span.style.fontFamily = value; if (command === "FontSize") span.style.fontSize = `${value}px`; } } else if (["AlignStart", "AlignCenter", "AlignEnd", "Indent", "Outdent"].includes(command)) { + const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest?.("p,h1,h2,h3,figcaption,blockquote,pre,li"); if (block) { if (command.startsWith("Align")) block.style.textAlign = { AlignStart: "left", AlignCenter: "center", AlignEnd: "right" }[command]; else { const margin = Number.parseInt(block.style.marginLeft || "0", 10) || 0; block.style.marginLeft = `${Math.max(0, margin + (command === "Indent" ? 24 : -24))}px`; } changed = true; } + if (changed) commit(instance); + else if (!["Link", "Image", "Table", "Fullscreen", "Print"].includes(command)) instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Select content before applying this command."); + } -export function initialize(id, dotNetRef, debounceInterval, maxLength, readOnly, disabled) { - dispose(id); const root = document.getElementById(id); const editor = document.getElementById(`${id}-editor`); if (!root || !editor) return; - const instance = { id, root, editor, dotNetRef, debounceInterval: Math.max(0, debounceInterval || 300), maxLength, readOnly, disabled, range: null, timer: 0, history: [], historyIndex: -1 }; - instances.set(id, instance); let stored = null; try { stored = sessionStorage.getItem(draftKey); } catch { } editor.innerHTML = sanitize(stored || editor.innerHTML); pushHistory(instance); updateFooter(instance); - editor.addEventListener("input", () => { if (instance.maxLength && editor.innerText.length > instance.maxLength) editor.innerText = editor.innerText.slice(0, instance.maxLength); saveRange(instance); clearTimeout(instance.timer); instance.timer = setTimeout(() => commit(instance), instance.debounceInterval); }); - ["keyup", "mouseup", "focusout"].forEach(eventName => editor.addEventListener(eventName, () => saveRange(instance))); - editor.addEventListener("paste", event => { event.preventDefault(); const text = event.clipboardData.getData("text/plain"); insertNode(instance, document.createTextNode(text)); commit(instance); }); - root.addEventListener("click", event => { const button = event.target.closest("[data-bb-rte-command]"); if (!button) return; saveRange(instance); execute(instance, button.dataset.bbRteCommand, button.dataset.bbRteValue); }); -} -export function clear(id) { const instance = instances.get(id); if (!instance) return; instance.editor.innerHTML = ""; commit(instance); } -export function dispose(id) { const instance = instances.get(id); if (instance) clearTimeout(instance.timer); instances.delete(id); } -export function focus(id) { instances.get(id)?.editor.focus(); } -export function insertImage(id, url, altText) { const instance = instances.get(id); if (!instance || !isSafeUrl(url, true)) return; const image = document.createElement("img"); image.src = url; image.alt = altText || ""; image.className = "img-fluid"; insertNode(instance, image); commit(instance); } -export function setValue(id, value) { const instance = instances.get(id); if (!instance) return; instance.editor.innerHTML = sanitize(value); pushHistory(instance); updateFooter(instance); } \ No newline at end of file + export function initialize(id, dotNetRef, debounceInterval, maxLength, readOnly, disabled) { + dispose(id); const root = document.getElementById(id); const editor = document.getElementById(`${id}-editor`); if (!root || !editor) return; + const instance = { id, root, editor, dotNetRef, debounceInterval: Math.max(0, debounceInterval || 300), maxLength, readOnly, disabled, range: null, timer: 0, history: [], historyIndex: -1 }; + instances.set(id, instance); let stored = null; try { stored = sessionStorage.getItem(draftKey); } catch { } editor.innerHTML = sanitize(stored || editor.innerHTML); pushHistory(instance); updateFooter(instance); + editor.addEventListener("input", () => { if (instance.maxLength && editor.innerText.length > instance.maxLength) editor.innerText = editor.innerText.slice(0, instance.maxLength); saveRange(instance); clearTimeout(instance.timer); instance.timer = setTimeout(() => commit(instance), instance.debounceInterval); }); + ["keyup", "mouseup", "focusout"].forEach(eventName => editor.addEventListener(eventName, () => saveRange(instance))); + editor.addEventListener("paste", event => { event.preventDefault(); const text = event.clipboardData.getData("text/plain"); insertNode(instance, document.createTextNode(text)); commit(instance); }); + root.addEventListener("click", event => { const button = event.target.closest("[data-bb-rte-command]"); if (button) { saveRange(instance); execute(instance, button.dataset.bbRteCommand, button.dataset.bbRteValue); return; } if (event.target.closest("[data-bb-rte-save-link]")) insertLink(instance); if (event.target.closest("[data-bb-rte-save-image]")) insertImageFromModal(instance); if (event.target.closest("[data-bb-rte-save-table]")) insertTable(instance); }); + } + export function clear(id) { const instance = instances.get(id); if (!instance) return; instance.editor.innerHTML = ""; commit(instance); } + export function dispose(id) { const instance = instances.get(id); if (instance) clearTimeout(instance.timer); instances.delete(id); } + export function focus(id) { instances.get(id)?.editor.focus(); } + export function insertImage(id, url, altText) { const instance = instances.get(id); if (!instance || !isSafeUrl(url, true)) return; const image = document.createElement("img"); image.src = url; image.alt = altText || ""; image.className = "img-fluid"; insertNode(instance, image); commit(instance); } + export function setValue(id, value) { const instance = instances.get(id); if (!instance) return; instance.editor.innerHTML = sanitize(value); pushHistory(instance); updateFooter(instance); } \ No newline at end of file From 2a3ff64aeef2a60ca625ce88c7c552733c7ecbc8 Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Sat, 15 Aug 2026 02:33:49 +0530 Subject: [PATCH 10/53] Refactor RichTextEditor API, docs, and JS for clarity & security Updated docs and demos to use explicit Value/ValueChanged and StatusChanged handlers instead of @bind-Value, reflecting a new event-based API with RichTextEditorChange. Enhanced JS module with style sanitization, table normalization, and improved list handling for safer HTML. Expanded toolbar commands for list toggling and accessible table insertion. Updated security docs to clarify sanitization and draft storage. Modernizes API, improves security, and enhances features and documentation. --- .../RichTextEditorDocumentation.razor | 28 ++++++++++------- .../RichTextEditor_Demo_01_Undo.razor | 2 +- .../RichTextEditor_Demo_02_Redo.razor | 2 +- .../RichTextEditor_Demo_03_Paragraph.razor | 2 +- .../RichTextEditor_Demo_04_Heading1.razor | 2 +- .../RichTextEditor_Demo_05_Heading2.razor | 2 +- .../RichTextEditor_Demo_06_Heading3.razor | 2 +- .../RichTextEditor_Demo_07_Bold.razor | 2 +- .../RichTextEditor_Demo_08_Italic.razor | 2 +- .../RichTextEditor_Demo_09_Underline.razor | 2 +- ...RichTextEditor_Demo_10_Strikethrough.razor | 2 +- ...chTextEditor_Demo_11_ClearFormatting.razor | 2 +- .../RichTextEditor_Demo_12_AlignStart.razor | 2 +- .../RichTextEditor_Demo_13_AlignCenter.razor | 2 +- .../RichTextEditor_Demo_14_AlignEnd.razor | 2 +- .../RichTextEditor_Demo_15_OrderedList.razor | 2 +- ...RichTextEditor_Demo_16_UnorderedList.razor | 2 +- .../RichTextEditor_Demo_17_Blockquote.razor | 2 +- .../RichTextEditor_Demo_18_CodeBlock.razor | 2 +- .../RichTextEditor_Demo_19_TextColor.razor | 2 +- .../RichTextEditor_Demo_20_Link.razor | 2 +- .../RichTextEditor_Demo_21_Image.razor | 2 +- .../RichTextEditor_Demo_22_UploadImage.razor | 2 +- .../RichTextEditor_Demo_23_Table.razor | 2 +- .../blazor.bootstrap.rich-text-editor.js | 30 +++++++++++++++---- docs/docs/04-forms/rich-text-editor.mdx | 25 +++++++++++----- 26 files changed, 83 insertions(+), 46 deletions(-) diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor index e008b52d1..056f4eba8 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor @@ -3,35 +3,41 @@ -
-

The default ribbon exposes every supported History, Text, Paragraph, Color, and Insert command. Select content before using a formatting command.

- +
+

The default toolbar supports formatted document authoring. Drafts use this browser tab's session storage only.

+
@value
-
-

ValueChanged is raised after the configured debounce period. The buttons exercise FocusAsync() and ClearAsync().

- +
+

ValueChanged receives sanitized HTML, plain text, and counts after a content commit. StatusChanged is for transient feedback.

+
ValueChanged calls: @valueChangedCount
+ @if (!string.IsNullOrWhiteSpace(status)) + { +
@status
+ }
-

The editor removes scripts, event attributes, inline styles, unsafe URLs, data URLs, and unsupported tags from pasted or supplied HTML. Server-side sanitization is still required before storing or rendering rich HTML. Use normal Razor encoding when the value must be displayed as plain text.

-

Image URL insertion accepts HTTPS only. Upload callbacks must validate, authorize, scan, and store the file, then return a trusted HTTPS URL; this demo intentionally cancels uploads.

+

The browser module sanitizes untrusted rich HTML before rendering and before callbacks. Validate uploads and sanitize again on the server before persistence or markup rendering.

+

Image URL insertion accepts HTTPS only. Upload callbacks must validate, authorize, scan, and store files before returning a trusted HTTPS URL.

@code { private RichTextEditor? editor; private RichTextEditor? eventEditor; + private string status = string.Empty; private string value = "

Select this text and use the ribbon toolbar.

"; private string eventValue = string.Empty; private int valueChangedCount; - private readonly RichTextEditorToolbarItem[] restrictedToolbarItems = { RichTextEditorToolbarItem.Bold, RichTextEditorToolbarItem.Italic, RichTextEditorToolbarItem.Underline, RichTextEditorToolbarItem.Undo, RichTextEditorToolbarItem.Redo }; private Task ClearAsync() => eventEditor?.ClearAsync() ?? Task.CompletedTask; private Task FocusAsync() => eventEditor?.FocusAsync() ?? Task.CompletedTask; - private void OnValueChanged(string html) { eventValue = html; valueChangedCount++; } + private void OnDocumentChanged(RichTextEditorChange change) => value = change.Html; + private void OnStatusChanged(string message) => status = message; + private void OnValueChanged(RichTextEditorChange change) { eventValue = change.Html; valueChangedCount++; } private Task UploadImageAsync(RichTextEditorImageUploadRequest request) => Task.FromResult(null); -} +} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_01_Undo.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_01_Undo.razor index ef9247977..dce740f9f 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_01_Undo.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_01_Undo.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_02_Redo.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_02_Redo.razor index 213a01dfb..95174cb63 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_02_Redo.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_02_Redo.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_03_Paragraph.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_03_Paragraph.razor index c83a3bb96..4a0cf4d57 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_03_Paragraph.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_03_Paragraph.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_04_Heading1.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_04_Heading1.razor index e363ffae2..966725321 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_04_Heading1.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_04_Heading1.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_05_Heading2.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_05_Heading2.razor index f5ed62a6c..032d96485 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_05_Heading2.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_05_Heading2.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_06_Heading3.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_06_Heading3.razor index 7f2fd58e1..8cb68c24b 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_06_Heading3.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_06_Heading3.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_07_Bold.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_07_Bold.razor index 3549cf127..846b6814d 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_07_Bold.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_07_Bold.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_08_Italic.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_08_Italic.razor index bbd424799..e00dc22fe 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_08_Italic.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_08_Italic.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_09_Underline.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_09_Underline.razor index 301dd78a4..ced8461b4 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_09_Underline.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_09_Underline.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_10_Strikethrough.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_10_Strikethrough.razor index de1c7ca57..4523e3986 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_10_Strikethrough.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_10_Strikethrough.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_11_ClearFormatting.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_11_ClearFormatting.razor index 25eea46e3..e7e55f370 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_11_ClearFormatting.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_11_ClearFormatting.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_12_AlignStart.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_12_AlignStart.razor index 898b5fe55..a34e43f3a 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_12_AlignStart.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_12_AlignStart.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_13_AlignCenter.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_13_AlignCenter.razor index d73554288..51c315b8e 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_13_AlignCenter.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_13_AlignCenter.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_14_AlignEnd.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_14_AlignEnd.razor index 1e5459fa3..e39128a30 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_14_AlignEnd.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_14_AlignEnd.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_15_OrderedList.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_15_OrderedList.razor index 3b7aa5833..a3f7eb187 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_15_OrderedList.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_15_OrderedList.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_16_UnorderedList.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_16_UnorderedList.razor index ac17f467c..9f812aaf2 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_16_UnorderedList.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_16_UnorderedList.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_17_Blockquote.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_17_Blockquote.razor index cf63656b3..2cd39fa88 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_17_Blockquote.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_17_Blockquote.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_18_CodeBlock.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_18_CodeBlock.razor index 3a6a8c42b..bad0f546d 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_18_CodeBlock.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_18_CodeBlock.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_19_TextColor.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_19_TextColor.razor index bfb776fb8..feea2bbb3 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_19_TextColor.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_19_TextColor.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_20_Link.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_20_Link.razor index b92eb1e21..f5518cec1 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_20_Link.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_20_Link.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_21_Image.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_21_Image.razor index 4654a3dc9..28f0ed3fb 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_21_Image.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_21_Image.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_22_UploadImage.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_22_UploadImage.razor index c6d873cf7..5d1ac2b28 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_22_UploadImage.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_22_UploadImage.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_23_Table.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_23_Table.razor index 0f050ea00..8c3b62f32 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_23_Table.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_23_Table.razor @@ -1,5 +1,5 @@
- +
@content
diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index 503162996..37d905d11 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -14,7 +14,7 @@ function isSafeUrl(value, image = false) { } catch { return false; } } -function sanitize(html) { +function sanitizeStyle(value) { const style = document.createElement("span").style; style.cssText = value; const allowed = new Map([["color", /^#[0-9a-f]{6}$/i], ["background-color", /^#[0-9a-f]{6}$/i], ["font-family", /^(Inter|Arial|Georgia|Courier New)$/], ["font-size", /^(12|14|16|18|24)px$/], ["text-align", /^(left|center|right)$/], ["margin-left", /^(0|24|48|72|96)px$/]]); const safe = []; for (const [name, pattern] of allowed) { const value = style.getPropertyValue(name).trim(); if (value && pattern.test(value)) safe.push(`${name}: ${value}`); } return safe.join("; "); }\n\nfunction sanitize(html) { const documentFragment = new DOMParser().parseFromString(html || "", "text/html"); for (const node of [...documentFragment.body.querySelectorAll("*")]) { if (blockedTags.has(node.tagName)) { node.remove(); continue; } @@ -44,10 +44,30 @@ function restoreRange(instance) { if (!instance.range) return false; const selec function textMetrics(instance) { const text = instance.editor.innerText.trim(); return { text, characters: text.length, words: text ? text.split(/\s+/).length : 0 }; } function updateFooter(instance) { const metrics = textMetrics(instance); instance.root.querySelector("[data-bb-rte-character-count]").textContent = `${metrics.characters} characters`; instance.root.querySelector("[data-bb-rte-word-count]").textContent = `${metrics.words} words`; const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest?.("p,h1,h2,h3,figcaption,blockquote,pre,li"); const context = block?.tagName === "P" || !block ? "Paragraph" : block.tagName.replace("H", "Heading "); instance.root.querySelector("[data-bb-rte-context]").textContent = context; instance.root.querySelector("[data-bb-rte-alignment-context]").textContent = block?.style.textAlign ? `${block.style.textAlign[0].toUpperCase()}${block.style.textAlign.slice(1)} aligned` : "Left aligned"; } function pushHistory(instance) { const html = sanitize(instance.editor.innerHTML); if (instance.history[instance.historyIndex] === html) return; instance.history.splice(instance.historyIndex + 1); instance.history.push(html); if (instance.history.length > 100) instance.history.shift(); instance.historyIndex = instance.history.length - 1; } -function commit(instance, status = "") { const html = sanitize(instance.editor.innerHTML); if (instance.editor.innerHTML !== html) instance.editor.innerHTML = html; pushHistory(instance); updateFooter(instance); try { sessionStorage.setItem(draftKey, html); } catch { } instance.dotNetRef.invokeMethodAsync("OnEditorValueChangedAsync", html); if (status) instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", status); } +function commit(instance, status = "") { normalizeTables(instance); const html = sanitize(instance.editor.innerHTML); if (instance.editor.innerHTML !== html) instance.editor.innerHTML = html; pushHistory(instance); updateFooter(instance); try { sessionStorage.setItem(draftKey, html); } catch { } instance.dotNetRef.invokeMethodAsync("OnEditorValueChangedAsync", html); if (status) instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", status); } function insertNode(instance, node) { restoreRange(instance); const range = currentRange(instance) || document.createRange(); if (!range.startContainer.isConnected) range.selectNodeContents(instance.editor); range.collapse(false); range.deleteContents(); range.insertNode(node); range.setStartAfter(node); range.collapse(true); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); saveRange(instance); } function wrapSelection(instance, tagName, attributes = {}) { restoreRange(instance); const range = currentRange(instance); if (!range || range.collapsed) return false; const element = document.createElement(tagName); Object.entries(attributes).forEach(([name, value]) => element.setAttribute(name, value)); try { range.surroundContents(element); } catch { const fragment = range.extractContents(); element.append(fragment); range.insertNode(element); } saveRange(instance); return true; } function blockFormat(instance, tagName) { restoreRange(instance); const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest("p,h1,h2,h3,figcaption,blockquote,pre,li") || null; if (!block) return false; const replacement = document.createElement(tagName); replacement.append(...block.childNodes); block.replaceWith(replacement); const selection = window.getSelection(); const nextRange = document.createRange(); nextRange.selectNodeContents(replacement); nextRange.collapse(false); selection.removeAllRanges(); selection.addRange(nextRange); saveRange(instance); return true; } +function selectedElement(instance, selector) { + const range = currentRange(instance); let node = range?.commonAncestorContainer; + if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; + return node?.closest?.(selector) || null; +} +function normalizeTables(instance) { + for (const table of instance.editor.querySelectorAll("table")) { + table.classList.add("table", "table-bordered"); + let rows = [...table.querySelectorAll(":scope > tr, :scope > thead > tr, :scope > tbody > tr")]; + if (!rows.length) continue; + let thead = table.querySelector(":scope > thead"); + let tbody = table.querySelector(":scope > tbody"); + if (!thead) { thead = document.createElement("thead"); table.prepend(thead); } + if (!tbody) { tbody = document.createElement("tbody"); table.append(tbody); } + const header = rows.shift(); + for (const cell of [...header.children]) { const th = document.createElement("th"); th.scope = "col"; th.append(...cell.childNodes); cell.replaceWith(th); } + thead.replaceChildren(header); + for (const row of rows) { for (const cell of [...row.children]) { const td = document.createElement("td"); td.append(...cell.childNodes); cell.replaceWith(td); } tbody.append(row); } + } +} function showModal(instance, name) { saveRange(instance); const element = document.getElementById(`${instance.id}-${name}-modal`); @@ -60,7 +80,7 @@ function insertLink(instance) { const text = modal?.querySelector("[data-bb-rte-link-text]")?.value?.trim(); const newTab = modal?.querySelector("[data-bb-rte-link-new-tab]")?.checked; if (!isSafeUrl(url)) { instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Enter an HTTPS, relative, mailto, or telephone URL."); return; } - const link = document.createElement("a"); link.href = url; link.textContent = text || currentRange(instance)?.toString() || url; + const link = instance.editingLink || document.createElement("a"); link.href = url; link.textContent = text || currentRange(instance)?.toString() || url; if (newTab) { link.target = "_blank"; link.rel = "noopener noreferrer"; } insertNode(instance, link); window.bootstrap.Modal.getInstance(modal)?.hide(); commit(instance); } @@ -92,12 +112,12 @@ function insertTable(instance) { for (let row = 1; row < rows; row++) { const tr = document.createElement("tr"); for (let column = 0; column < columns; column++) { const cell = document.createElement("td"); cell.append(document.createElement("br")); tr.append(cell); } body.append(tr); } table.append(body); insertNode(instance, table); window.bootstrap.Modal.getInstance(modal)?.hide(); commit(instance); } -function execute(instance, command, value) { +function toggleList(instance, tagName) { restoreRange(instance); const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest?.("p,h1,h2,h3,figcaption,blockquote,pre,li"); if (!block) return false; const list = document.createElement(tagName); const item = document.createElement("li"); item.append(...block.childNodes); list.append(item); block.replaceWith(list); const selection = window.getSelection(); const nextRange = document.createRange(); nextRange.selectNodeContents(item); nextRange.collapse(false); selection.removeAllRanges(); selection.addRange(nextRange); saveRange(instance); return true; }\n\nfunction execute(instance, command, value) { if (instance.disabled || instance.readOnly) return; instance.editor.focus(); restoreRange(instance); let changed = false; if (command === "Link") { showModal(instance, "link"); return; } else if (command === "Image") { showModal(instance, "image"); return; } else if (command === "Table") { showModal(instance, "table"); return; } else if (command === "Fullscreen") { toggleFullscreen(instance); return; } else if (command === "Print") { printEditor(instance); return; } else if (["Bold", "Italic", "Underline", "Strikethrough"].includes(command)) changed = wrapSelection(instance, { Bold: "strong", Italic: "em", Underline: "u", Strikethrough: "s" }[command]); - else if (command === "BlockFormat") changed = blockFormat(instance, value || "p"); + else if (command === "BlockFormat") changed = blockFormat(instance, value || "p");\n else if (command === "OrderedList") changed = toggleList(instance, "ol");\n else if (command === "UnorderedList") changed = toggleList(instance, "ul");\n else if (command === "Blockquote") changed = blockFormat(instance, "blockquote");\n else if (command === "CodeBlock") changed = blockFormat(instance, "pre"); else if (command === "HorizontalRule") { insertNode(instance, document.createElement("hr")); changed = true; } else if (command === "Undo" || command === "Redo") { const next = instance.historyIndex + (command === "Undo" ? -1 : 1); if (next >= 0 && next < instance.history.length) { instance.historyIndex = next; instance.editor.innerHTML = instance.history[next]; changed = true; } } else if (command === "ClearFormatting") { restoreRange(instance); const range = currentRange(instance); if (range && !range.collapsed) { const text = range.toString(); range.deleteContents(); range.insertNode(document.createTextNode(text)); changed = true; } } diff --git a/docs/docs/04-forms/rich-text-editor.mdx b/docs/docs/04-forms/rich-text-editor.mdx index 2dc146ddc..7f9047596 100644 --- a/docs/docs/04-forms/rich-text-editor.mdx +++ b/docs/docs/04-forms/rich-text-editor.mdx @@ -1,24 +1,35 @@ --- title: Blazor RichTextEditor Component -description: A dependency-free rich text editor with Bootstrap ribbon groups and safe client-side HTML filtering. +description: A dependency-free rich-text editor with Bootstrap toolbar groups and safe client-side HTML filtering. sidebar_label: RichTextEditor sidebar_position: 13 --- # Blazor RichTextEditor -`RichTextEditor` provides grouped History, Text, Paragraph, Color, and Insert controls using the library's Bootstrap Icons. It has no third-party package, script, CDN, or copied editor dependency. +`RichTextEditor` provides document, style, color, alignment, list, link, image, table, block, fullscreen, and print controls without a third-party editor dependency. ```cshtml - + +@code { + private string content = "

Draft

"; + private void OnContentChanged(RichTextEditorChange change) => content = change.Html; + private void OnEditorStatus(string status) { } +} ``` -## Security +`ValueChanged` receives `RichTextEditorChange`, containing sanitized HTML, plain text, character count, and word count. `StatusChanged` is reserved for transient activity and error feedback. + +## Security and drafts + +The browser module removes unsafe elements, event handlers, unsupported attributes, and unsafe URL schemes before content renders or is emitted. HTTPS image URLs are required. Browser filtering is defense in depth: validate uploads and sanitize HTML again on the server before persistence or markup rendering. -The component removes unsafe elements, event handlers, inline styles, unsupported classes, and unsafe URL schemes. It only permits HTTPS image URLs and HTTPS, relative, or fragment links. This browser-side filtering is defense in depth: validate uploaded files and sanitize HTML again on the server before persistence or markup rendering. Razor encoding displays the HTML as text, not formatted content. +The demo stores a draft under `advanced-rich-text-editor-document` in `sessionStorage`; it does not use local storage. ## Toolbar and API coverage -The demo covers every toolbar command, `ValueChanged`, two-way binding, `FocusAsync()`, `ClearAsync()`, restricted `ToolbarItems`, debounce, upload cancellation, and safe-image behavior. See [the demo](https://demos.blazorbootstrap.com/rich-text-editor). +See [the demo](https://demos.blazorbootstrap.com/rich-text-editor) for selection, history, formatting, links, tables, images, fullscreen, print, callbacks, and session-only draft behavior. \ No newline at end of file From 10c05eaf4ac6162b05c18c97a313fc8501f352fc Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Sat, 15 Aug 2026 02:42:26 +0530 Subject: [PATCH 11/53] Refactor execute function and selection handling Refactored the execute function for better readability by splitting chained else-if statements. Introduced a selectedElement helper to streamline retrieval of selected elements for style and alignment commands. Ensured commit(instance) is always called on changes and unified unsupported command status messages. Applied minor formatting improvements for clarity and maintainability. --- .../blazor.bootstrap.rich-text-editor.js | 59 +++++++++++++++---- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index 37d905d11..5a136c96f 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -14,7 +14,9 @@ function isSafeUrl(value, image = false) { } catch { return false; } } -function sanitizeStyle(value) { const style = document.createElement("span").style; style.cssText = value; const allowed = new Map([["color", /^#[0-9a-f]{6}$/i], ["background-color", /^#[0-9a-f]{6}$/i], ["font-family", /^(Inter|Arial|Georgia|Courier New)$/], ["font-size", /^(12|14|16|18|24)px$/], ["text-align", /^(left|center|right)$/], ["margin-left", /^(0|24|48|72|96)px$/]]); const safe = []; for (const [name, pattern] of allowed) { const value = style.getPropertyValue(name).trim(); if (value && pattern.test(value)) safe.push(`${name}: ${value}`); } return safe.join("; "); }\n\nfunction sanitize(html) { +function sanitizeStyle(value) { const style = document.createElement("span").style; style.cssText = value; const allowed = new Map([["color", /^#[0-9a-f]{6}$/i], ["background-color", /^#[0-9a-f]{6}$/i], ["font-family", /^(Inter|Arial|Georgia|Courier New)$/], ["font-size", /^(12|14|16|18|24)px$/], ["text-align", /^(left|center|right)$/], ["margin-left", /^(0|24|48|72|96)px$/]]); const safe = []; for (const [name, pattern] of allowed) { const value = style.getPropertyValue(name).trim(); if (value && pattern.test(value)) safe.push(`${name}: ${value}`); } return safe.join("; "); } + +function sanitize(html) { const documentFragment = new DOMParser().parseFromString(html || "", "text/html"); for (const node of [...documentFragment.body.querySelectorAll("*")]) { if (blockedTags.has(node.tagName)) { node.remove(); continue; } @@ -112,20 +114,55 @@ function insertTable(instance) { for (let row = 1; row < rows; row++) { const tr = document.createElement("tr"); for (let column = 0; column < columns; column++) { const cell = document.createElement("td"); cell.append(document.createElement("br")); tr.append(cell); } body.append(tr); } table.append(body); insertNode(instance, table); window.bootstrap.Modal.getInstance(modal)?.hide(); commit(instance); } -function toggleList(instance, tagName) { restoreRange(instance); const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest?.("p,h1,h2,h3,figcaption,blockquote,pre,li"); if (!block) return false; const list = document.createElement(tagName); const item = document.createElement("li"); item.append(...block.childNodes); list.append(item); block.replaceWith(list); const selection = window.getSelection(); const nextRange = document.createRange(); nextRange.selectNodeContents(item); nextRange.collapse(false); selection.removeAllRanges(); selection.addRange(nextRange); saveRange(instance); return true; }\n\nfunction execute(instance, command, value) { +function toggleList(instance, tagName) { restoreRange(instance); const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest?.("p,h1,h2,h3,figcaption,blockquote,pre,li"); if (!block) return false; const list = document.createElement(tagName); const item = document.createElement("li"); item.append(...block.childNodes); list.append(item); block.replaceWith(list); const selection = window.getSelection(); const nextRange = document.createRange(); nextRange.selectNodeContents(item); nextRange.collapse(false); selection.removeAllRanges(); selection.addRange(nextRange); saveRange(instance); return true; } + +function execute(instance, command, value) { if (instance.disabled || instance.readOnly) return; - instance.editor.focus(); restoreRange(instance); + instance.editor.focus(); + restoreRange(instance); let changed = false; - if (command === "Link") { showModal(instance, "link"); return; } else if (command === "Image") { showModal(instance, "image"); return; } else if (command === "Table") { showModal(instance, "table"); return; } else if (command === "Fullscreen") { toggleFullscreen(instance); return; } else if (command === "Print") { printEditor(instance); return; } else if (["Bold", "Italic", "Underline", "Strikethrough"].includes(command)) changed = wrapSelection(instance, { Bold: "strong", Italic: "em", Underline: "u", Strikethrough: "s" }[command]); - else if (command === "BlockFormat") changed = blockFormat(instance, value || "p");\n else if (command === "OrderedList") changed = toggleList(instance, "ol");\n else if (command === "UnorderedList") changed = toggleList(instance, "ul");\n else if (command === "Blockquote") changed = blockFormat(instance, "blockquote");\n else if (command === "CodeBlock") changed = blockFormat(instance, "pre"); + + if (command === "Link") { showModal(instance, "link"); return; } + if (command === "Image") { showModal(instance, "image"); return; } + if (command === "Table") { showModal(instance, "table"); return; } + if (command === "Fullscreen") { toggleFullscreen(instance); return; } + if (command === "Print") { printEditor(instance); return; } + + if (["Bold", "Italic", "Underline", "Strikethrough"].includes(command)) + changed = wrapSelection(instance, { Bold: "strong", Italic: "em", Underline: "u", Strikethrough: "s" }[command]); + else if (command === "BlockFormat") + changed = blockFormat(instance, value || "p"); + else if (command === "OrderedList") + changed = toggleList(instance, "ol"); + else if (command === "UnorderedList") + changed = toggleList(instance, "ul"); + else if (command === "Blockquote") + changed = blockFormat(instance, "blockquote"); + else if (command === "CodeBlock") + changed = blockFormat(instance, "pre"); else if (command === "HorizontalRule") { insertNode(instance, document.createElement("hr")); changed = true; } - else if (command === "Undo" || command === "Redo") { const next = instance.historyIndex + (command === "Undo" ? -1 : 1); if (next >= 0 && next < instance.history.length) { instance.historyIndex = next; instance.editor.innerHTML = instance.history[next]; changed = true; } } - else if (command === "ClearFormatting") { restoreRange(instance); const range = currentRange(instance); if (range && !range.collapsed) { const text = range.toString(); range.deleteContents(); range.insertNode(document.createTextNode(text)); changed = true; } } - else if (["TextColor", "HighlightColor", "FontFamily", "FontSize"].includes(command)) { changed = wrapSelection(instance, "span", { "data-bb-rte-color": value || "" }); const range = currentRange(instance); const span = range?.commonAncestorContainer?.parentElement?.closest?.("span"); if (span) { if (command === "TextColor") span.style.color = value; if (command === "HighlightColor") span.style.backgroundColor = value; if (command === "FontFamily") span.style.fontFamily = value; if (command === "FontSize") span.style.fontSize = `${value}px`; } } else if (["AlignStart", "AlignCenter", "AlignEnd", "Indent", "Outdent"].includes(command)) { - const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest?.("p,h1,h2,h3,figcaption,blockquote,pre,li"); if (block) { if (command.startsWith("Align")) block.style.textAlign = { AlignStart: "left", AlignCenter: "center", AlignEnd: "right" }[command]; else { const margin = Number.parseInt(block.style.marginLeft || "0", 10) || 0; block.style.marginLeft = `${Math.max(0, margin + (command === "Indent" ? 24 : -24))}px`; } changed = true; } - if (changed) commit(instance); - else if (!["Link", "Image", "Table", "Fullscreen", "Print"].includes(command)) instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Select content before applying this command."); + else if (command === "Undo" || command === "Redo") { + const next = instance.historyIndex + (command === "Undo" ? -1 : 1); + if (next >= 0 && next < instance.history.length) { instance.historyIndex = next; instance.editor.innerHTML = instance.history[next]; changed = true; } + } + else if (command === "ClearFormatting") { + const range = currentRange(instance); + if (range && !range.collapsed) { const text = range.toString(); range.deleteContents(); range.insertNode(document.createTextNode(text)); changed = true; } } + else if (["TextColor", "HighlightColor", "FontFamily", "FontSize"].includes(command)) { + changed = wrapSelection(instance, "span", { "data-bb-rte-color": value || "" }); + const span = selectedElement(instance, "span"); + if (span) { if (command === "TextColor") span.style.color = value; if (command === "HighlightColor") span.style.backgroundColor = value; if (command === "FontFamily") span.style.fontFamily = value; if (command === "FontSize") span.style.fontSize = `${value}px`; } + } + else if (["AlignStart", "AlignCenter", "AlignEnd", "Indent", "Outdent"].includes(command)) { + const block = selectedElement(instance, "p,h1,h2,h3,figcaption,blockquote,pre,li"); + if (block) { if (command.startsWith("Align")) block.style.textAlign = { AlignStart: "left", AlignCenter: "center", AlignEnd: "right" }[command]; else { const margin = Number.parseInt(block.style.marginLeft || "0", 10) || 0; block.style.marginLeft = `${Math.max(0, margin + (command === "Indent" ? 24 : -24))}px`; } changed = true; } + } + + if (changed) commit(instance); + else instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Select content before applying this command."); +} + export function initialize(id, dotNetRef, debounceInterval, maxLength, readOnly, disabled) { dispose(id); const root = document.getElementById(id); const editor = document.getElementById(`${id}-editor`); if (!root || !editor) return; From b103a4d229a111b76fc8addf955133c68d5c8267 Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Sat, 15 Aug 2026 16:58:59 +0530 Subject: [PATCH 12/53] Refactor toolbar and footer in RichTextEditor.razor Refactored toolbar button attributes to use new data-editor-* attributes, replacing data-bb-rte-command/value/action. Reorganized and expanded button groups and dropdowns, adding granular table actions, enhanced color pickers, and more formatting options. Redesigned footer with new IDs for context display and improved layout for clarity and responsiveness. Refactor RichTextEditor toolbar and footer structure Refactored toolbar button attributes for consistency, replacing old data-bb-rte-* attributes with new data-editor-* attributes. Simplified dropdowns for block format, font family, and font size. Redesigned color and highlight pickers with indicator spans and dropdowns. Expanded table options into a comprehensive dropdown. Restyled footer with new IDs for context display and improved layout for clarity. --- .../Form/RichTextEditor/RichTextEditor.razor | 204 ++++++++++-------- 1 file changed, 120 insertions(+), 84 deletions(-) diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor index fcf35fe82..1c6c3c8a6 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor @@ -5,97 +5,132 @@
From cee65f02bc9de4d3cde98ac657d2086aaa4e1092 Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Sat, 15 Aug 2026 17:32:36 +0530 Subject: [PATCH 13/53] Refactor: move Blazor toolbar logic to code-behind files Refactored RichTextEditorToolbar and RichTextEditorToolbarGroup components by moving their logic from .razor files to new .razor.cs partial classes. Updated .razor files to use @inherits BlazorBootstrapComponentBase. Improves code organization and maintainability. --- .../RichTextEditor/RichTextEditorToolbar.razor | 13 +------------ .../RichTextEditorToolbar.razor.cs | 17 +++++++++++++++++ .../RichTextEditorToolbarButton.razor | 1 + .../RichTextEditorToolbarGroup.razor | 9 ++------- .../RichTextEditorToolbarGroup.razor.cs | 10 ++++++++++ 5 files changed, 31 insertions(+), 19 deletions(-) create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor.cs create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarGroup.razor.cs diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor index 2039f12a4..4d367103f 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor @@ -1,4 +1,5 @@ @namespace BlazorBootstrap +@inherits BlazorBootstrapComponentBase @if (HasAny(historyItems)) { @@ -47,15 +48,3 @@ } } - -@code { - private static readonly RichTextEditorToolbarItem[] historyItems = { RichTextEditorToolbarItem.Undo, RichTextEditorToolbarItem.Redo }; - private static readonly RichTextEditorToolbarItem[] textItems = { RichTextEditorToolbarItem.Paragraph, RichTextEditorToolbarItem.Heading1, RichTextEditorToolbarItem.Heading2, RichTextEditorToolbarItem.Heading3, RichTextEditorToolbarItem.Bold, RichTextEditorToolbarItem.Italic, RichTextEditorToolbarItem.Underline, RichTextEditorToolbarItem.Strikethrough, RichTextEditorToolbarItem.ClearFormatting }; - private static readonly RichTextEditorToolbarItem[] paragraphItems = { RichTextEditorToolbarItem.AlignStart, RichTextEditorToolbarItem.AlignCenter, RichTextEditorToolbarItem.AlignEnd, RichTextEditorToolbarItem.OrderedList, RichTextEditorToolbarItem.UnorderedList, RichTextEditorToolbarItem.Blockquote, RichTextEditorToolbarItem.CodeBlock }; - private static readonly RichTextEditorToolbarItem[] insertItems = { RichTextEditorToolbarItem.Link, RichTextEditorToolbarItem.Image, RichTextEditorToolbarItem.UploadImage, RichTextEditorToolbarItem.Table }; - - private bool HasAny(IEnumerable toolbarItems) => toolbarItems.Any(Items.Contains); - - [Parameter, EditorRequired] public IReadOnlyCollection Items { get; set; } = Array.Empty(); - [Parameter] public bool Disabled { get; set; } -} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor.cs new file mode 100644 index 000000000..7dc11b064 --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor.cs @@ -0,0 +1,17 @@ +namespace BlazorBootstrap; + +public partial class RichTextEditorToolbar : BlazorBootstrapComponentBase +{ + private static readonly RichTextEditorToolbarItem[] historyItems = { RichTextEditorToolbarItem.Undo, RichTextEditorToolbarItem.Redo }; + private static readonly RichTextEditorToolbarItem[] textItems = { RichTextEditorToolbarItem.Paragraph, RichTextEditorToolbarItem.Heading1, RichTextEditorToolbarItem.Heading2, RichTextEditorToolbarItem.Heading3, RichTextEditorToolbarItem.Bold, RichTextEditorToolbarItem.Italic, RichTextEditorToolbarItem.Underline, RichTextEditorToolbarItem.Strikethrough, RichTextEditorToolbarItem.ClearFormatting }; + private static readonly RichTextEditorToolbarItem[] paragraphItems = { RichTextEditorToolbarItem.AlignStart, RichTextEditorToolbarItem.AlignCenter, RichTextEditorToolbarItem.AlignEnd, RichTextEditorToolbarItem.OrderedList, RichTextEditorToolbarItem.UnorderedList, RichTextEditorToolbarItem.Blockquote, RichTextEditorToolbarItem.CodeBlock }; + private static readonly RichTextEditorToolbarItem[] insertItems = { RichTextEditorToolbarItem.Link, RichTextEditorToolbarItem.Image, RichTextEditorToolbarItem.UploadImage, RichTextEditorToolbarItem.Table }; + + private bool HasAny(IEnumerable toolbarItems) => toolbarItems.Any(Items.Contains); + + [Parameter, EditorRequired] + public IReadOnlyCollection Items { get; set; } = Array.Empty(); + + [Parameter] + public bool Disabled { get; set; } +} diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor index f90303171..033fef68e 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor @@ -1,4 +1,5 @@ @namespace BlazorBootstrap +@inherits BlazorBootstrapComponentBase
\ No newline at end of file diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarGroup.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarGroup.razor.cs new file mode 100644 index 000000000..5056c6698 --- /dev/null +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarGroup.razor.cs @@ -0,0 +1,10 @@ +namespace BlazorBootstrap; + +public partial class RichTextEditorToolbarGroup : BlazorBootstrapComponentBase +{ + [Parameter, EditorRequired] + public RenderFragment? ChildContent { get; set; } + + [Parameter, EditorRequired] + public string Label { get; set; } = default!; +} From a5491032e32d3739243c88514ef7de64df2bde1c Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Sat, 15 Aug 2026 20:39:56 +0530 Subject: [PATCH 14/53] Refactor RichTextEditor toolbar and add new features Refactored toolbar to use RichTextEditorToolbarButton for improved maintainability. Expanded and renamed RichTextEditorToolbarItem enum with new items (e.g., Print, AlignLeft, Fullscreen). Updated toolbar arrays and added ToolbarItems parameter for customization. Moved toolbar button logic to code-behind. Updated demos and documentation to match new structure. --- .../RichTextEditorDocumentation.razor | 44 ++++------------- .../RichTextEditor_Demo_12_AlignStart.razor | 2 +- .../RichTextEditor_Demo_14_AlignEnd.razor | 2 +- .../RichTextEditor_Demo_22_UploadImage.razor | 10 ---- .../RichTextEditor_Demo_23_Table.razor | 1 - .../Form/RichTextEditor/RichTextEditor.razor | 6 +-- .../RichTextEditor/RichTextEditor.razor.cs | 6 +++ .../RichTextEditorToolbar.razor.cs | 4 +- .../RichTextEditorToolbarButton.razor | 47 ++----------------- .../RichTextEditorToolbarButton.razor.cs | 45 ++++++++++++++++++ .../Enums/RichTextEditorToolbarItem.cs | 25 ++++++---- 11 files changed, 88 insertions(+), 104 deletions(-) delete mode 100644 BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_22_UploadImage.razor create mode 100644 blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor index 056f4eba8..2ae0057e3 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditorDocumentation.razor @@ -1,43 +1,19 @@ +@page "/rich-text-editor" + @attribute [Route(DemoRouteConstants.Demos_URL_RichTextEditor)] @layout DemosMainLayout -
-

The default toolbar supports formatted document authoring. Drafts use this browser tab's session storage only.

- -
@value
-
- -
-

ValueChanged receives sanitized HTML, plain text, and counts after a content commit. StatusChanged is for transient feedback.

- - - -
ValueChanged calls: @valueChangedCount
- @if (!string.IsNullOrWhiteSpace(status)) - { -
@status
- } -
- -
-

The browser module sanitizes untrusted rich HTML before rendering and before callbacks. Validate uploads and sanitize again on the server before persistence or markup rendering.

-

Image URL insertion accepts HTTPS only. Upload callbacks must validate, authorize, scan, and store files before returning a trusted HTTPS URL.

+
+
@code { - private RichTextEditor? editor; - private RichTextEditor? eventEditor; - private string status = string.Empty; - private string value = "

Select this text and use the ribbon toolbar.

"; - private string eventValue = string.Empty; - private int valueChangedCount; - - private Task ClearAsync() => eventEditor?.ClearAsync() ?? Task.CompletedTask; - private Task FocusAsync() => eventEditor?.FocusAsync() ?? Task.CompletedTask; - private void OnDocumentChanged(RichTextEditorChange change) => value = change.Html; - private void OnStatusChanged(string message) => status = message; - private void OnValueChanged(RichTextEditorChange change) { eventValue = change.Html; valueChangedCount++; } - private Task UploadImageAsync(RichTextEditorImageUploadRequest request) => Task.FromResult(null); + private const string pageUrl = DemoRouteConstants.Demos_URL_RichTextEditor; + private const string pageTitle = "Blazor RichTextEditor"; + private const string pageDescription = "Rich text editing component with a grouped Bootstrap toolbar. It is dependency-free and outputs safe HTML."; + private const string metaTitle = "Blazor RichTextEditor Component"; + private const string metaDescription = "Rich text editing with safe HTML output."; + private const string imageUrl = "https://i.imgur.com/N2VUHIn.png"; // TODO: Update the image URL to a relevant image for RichTextEditor } \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_12_AlignStart.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_12_AlignStart.razor index a34e43f3a..9c2b28003 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_12_AlignStart.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_12_AlignStart.razor @@ -6,5 +6,5 @@ @code { private string content = "

Sample rich text content

"; - private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.AlignStart }; + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.AlignLeft }; } \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_14_AlignEnd.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_14_AlignEnd.razor index e39128a30..c3bb752a6 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_14_AlignEnd.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_14_AlignEnd.razor @@ -6,5 +6,5 @@ @code { private string content = "

Sample rich text content

"; - private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.AlignEnd }; + private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.AlignRight }; } \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_22_UploadImage.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_22_UploadImage.razor deleted file mode 100644 index 5d1ac2b28..000000000 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_22_UploadImage.razor +++ /dev/null @@ -1,10 +0,0 @@ -
- -
-
@content
- -@code { - private string content = "

Sample rich text content

"; - - private readonly RichTextEditorToolbarItem[] toolbarItems = { RichTextEditorToolbarItem.UploadImage }; -} \ No newline at end of file diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_23_Table.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_23_Table.razor index 8c3b62f32..42fb14d50 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_23_Table.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RichTextEditor/RichTextEditor_Demo_23_Table.razor @@ -1,7 +1,6 @@
-
@content
@code { private string content = "

Sample rich text content

"; diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor index 1c6c3c8a6..771dc97cd 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor @@ -6,9 +6,9 @@ diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs index 4ede234ea..c13d0094a 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs @@ -36,22 +36,22 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) await base.DisposeAsyncCore(disposing); } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - objRef ??= DotNetObjectReference.Create(this); - await RichTextEditorJsInterop.InitializeAsync(Id!, objRef, DebounceInterval, MaxLength, ReadOnly, Disabled); - lastRenderedValue = Value; - } - else if (lastRenderedValue != Value) - { - await RichTextEditorJsInterop.SetValueAsync(Id!, Value); - lastRenderedValue = Value; - } - - await base.OnAfterRenderAsync(firstRender); - } + //protected override async Task OnAfterRenderAsync(bool firstRender) + //{ + // if (firstRender) + // { + // objRef ??= DotNetObjectReference.Create(this); + // await RichTextEditorJsInterop.InitializeAsync(Id!, objRef, DebounceInterval, MaxLength, ReadOnly, Disabled); + // lastRenderedValue = Value; + // } + // else if (lastRenderedValue != Value) + // { + // await RichTextEditorJsInterop.SetValueAsync(Id!, Value); + // lastRenderedValue = Value; + // } + + // await base.OnAfterRenderAsync(firstRender); + //} protected override void OnInitialized() { diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor.cs index 127720215..5e2e965c9 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbar.razor.cs @@ -4,7 +4,7 @@ public partial class RichTextEditorToolbar : BlazorBootstrapComponentBase { private static readonly RichTextEditorToolbarItem[] historyItems = { RichTextEditorToolbarItem.Undo, RichTextEditorToolbarItem.Redo }; private static readonly RichTextEditorToolbarItem[] textItems = { RichTextEditorToolbarItem.Paragraph, RichTextEditorToolbarItem.Heading1, RichTextEditorToolbarItem.Heading2, RichTextEditorToolbarItem.Heading3, RichTextEditorToolbarItem.Bold, RichTextEditorToolbarItem.Italic, RichTextEditorToolbarItem.Underline, RichTextEditorToolbarItem.Strikethrough, RichTextEditorToolbarItem.ClearFormatting }; - private static readonly RichTextEditorToolbarItem[] paragraphItems = { RichTextEditorToolbarItem.AlignLeft, RichTextEditorToolbarItem.AlignCenter, RichTextEditorToolbarItem.AlignRight, RichTextEditorToolbarItem.Justify, RichTextEditorToolbarItem.Indent, RichTextEditorToolbarItem.Outdent, RichTextEditorToolbarItem.OrderedList, RichTextEditorToolbarItem.UnorderedList, RichTextEditorToolbarItem.Blockquote, RichTextEditorToolbarItem.CodeBlock }; + private static readonly RichTextEditorToolbarItem[] paragraphItems = { RichTextEditorToolbarItem.AlignLeft, RichTextEditorToolbarItem.AlignCenter, RichTextEditorToolbarItem.AlignRight, RichTextEditorToolbarItem.AlignJustify, RichTextEditorToolbarItem.Indent, RichTextEditorToolbarItem.Outdent, RichTextEditorToolbarItem.OrderedList, RichTextEditorToolbarItem.UnorderedList, RichTextEditorToolbarItem.Blockquote, RichTextEditorToolbarItem.CodeBlock }; private static readonly RichTextEditorToolbarItem[] insertItems = { RichTextEditorToolbarItem.Link, RichTextEditorToolbarItem.Image, RichTextEditorToolbarItem.Table, RichTextEditorToolbarItem.HorizontalRule }; private bool HasAny(IEnumerable toolbarItems) => toolbarItems.Any(Items.Contains); diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor index b61378e03..e058a3644 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor @@ -1,12 +1,6 @@ @namespace BlazorBootstrap @inherits BlazorBootstrapComponentBase - \ No newline at end of file + \ No newline at end of file diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs index bd0720463..6d459ea17 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs @@ -2,44 +2,53 @@ public partial class RichTextEditorToolbarButton : BlazorBootstrapComponentBase { - private IconName Icon => Item switch - { - RichTextEditorToolbarItem.Print => IconName.Printer, - RichTextEditorToolbarItem.Undo => IconName.ArrowCounterclockwise, - RichTextEditorToolbarItem.Redo => IconName.ArrowClockwise, - RichTextEditorToolbarItem.Bold => IconName.TypeBold, - RichTextEditorToolbarItem.Italic => IconName.TypeItalic, - RichTextEditorToolbarItem.Underline => IconName.TypeUnderline, - RichTextEditorToolbarItem.Strikethrough => IconName.TypeStrikethrough, - RichTextEditorToolbarItem.ClearFormatting => IconName.Eraser, - RichTextEditorToolbarItem.AlignLeft => IconName.TextLeft, - RichTextEditorToolbarItem.AlignCenter => IconName.TextCenter, - RichTextEditorToolbarItem.AlignRight => IconName.TextRight, - RichTextEditorToolbarItem.OrderedList => IconName.ListOl, - RichTextEditorToolbarItem.UnorderedList => IconName.ListUl, - RichTextEditorToolbarItem.Blockquote => IconName.Quote, - RichTextEditorToolbarItem.CodeBlock => IconName.Code, - RichTextEditorToolbarItem.Link => IconName.Link45Deg, - RichTextEditorToolbarItem.Image => IconName.Image, - RichTextEditorToolbarItem.Table => IconName.Table, - _ => IconName.Type - }; - - private string Label => Item switch + #region Fields and Constants + + private Button saveButton1 = default!; + + private DotNetObjectReference? objRef; + + #endregion + + #region Methods + + private async Task OnClickAsync() { - RichTextEditorToolbarItem.AlignLeft => "Align left", - RichTextEditorToolbarItem.AlignCenter => "Align center", - RichTextEditorToolbarItem.AlignRight => "Align right", - RichTextEditorToolbarItem.OrderedList => "Ordered list", - RichTextEditorToolbarItem.UnorderedList => "Unordered list", - RichTextEditorToolbarItem.ClearFormatting => "Clear formatting", - RichTextEditorToolbarItem.CodeBlock => "Code block", - _ => System.Text.RegularExpressions.Regex.Replace(Item.ToString(), "([a-z])([A-Z])", "$1 $2") - }; - - [Parameter] + Console.WriteLine($"RichTextEditorToolbarButton.OnClickAsync: {Item}"); + if (Disabled) + { + return; + } + //await RichTextEditor?.OnToolbarButtonClickAsync(Item); + } + + #endregion + + #region Properties, Indexers + + /// + /// Gets or sets a value indicating whether the button is disabled. + /// + /// Default is false. + /// + /// + [AddedVersion("4.0.0")] + [DefaultValue(false)] + [Description("Gets or sets a value indicating whether the button is disabled.")] + [Parameter] public bool Disabled { get; set; } - [Parameter, EditorRequired] - public RichTextEditorToolbarItem Item { get; set; } + /// + /// Gets or sets the toolbar item associated with the button. + /// + /// Default is . + /// + /// + [AddedVersion("4.0.0")] + [DefaultValue(RichTextEditorToolbarItem.None)] + [Description("Gets or sets the toolbar item associated with the button.")] + [Parameter, EditorRequired] + public RichTextEditorToolbarItem Item { get; set; } = RichTextEditorToolbarItem.None; + + #endregion } diff --git a/blazorbootstrap/Enums/RichTextEditorToolbarItem.cs b/blazorbootstrap/Enums/RichTextEditorToolbarItem.cs index 7fe258465..fdfcf2a5d 100644 --- a/blazorbootstrap/Enums/RichTextEditorToolbarItem.cs +++ b/blazorbootstrap/Enums/RichTextEditorToolbarItem.cs @@ -5,6 +5,7 @@ namespace BlazorBootstrap; /// public enum RichTextEditorToolbarItem { + None, Print, Undo, Redo, @@ -24,7 +25,7 @@ public enum RichTextEditorToolbarItem AlignLeft, AlignCenter, AlignRight, - Justify, + AlignJustify, Indent, Outdent, OrderedList, diff --git a/blazorbootstrap/Extensions/EnumExtensions.cs b/blazorbootstrap/Extensions/EnumExtensions.cs index b65115569..dfa65eafe 100644 --- a/blazorbootstrap/Extensions/EnumExtensions.cs +++ b/blazorbootstrap/Extensions/EnumExtensions.cs @@ -340,6 +340,62 @@ public static string ToIconColorClass(this IconColor iconColor) => _ => "" }; + public static IconName ToIconName(this RichTextEditorToolbarItem toolbarItem) => + toolbarItem switch + { + RichTextEditorToolbarItem.Print => IconName.Printer, + RichTextEditorToolbarItem.Undo => IconName.ArrowCounterclockwise, + RichTextEditorToolbarItem.Redo => IconName.ArrowClockwise, + RichTextEditorToolbarItem.Bold => IconName.TypeBold, + RichTextEditorToolbarItem.Italic => IconName.TypeItalic, + RichTextEditorToolbarItem.Underline => IconName.TypeUnderline, + RichTextEditorToolbarItem.Strikethrough => IconName.TypeStrikethrough, + RichTextEditorToolbarItem.ClearFormatting => IconName.Eraser, + RichTextEditorToolbarItem.AlignLeft => IconName.TextLeft, + RichTextEditorToolbarItem.AlignCenter => IconName.TextCenter, + RichTextEditorToolbarItem.AlignRight => IconName.TextRight, + RichTextEditorToolbarItem.AlignJustify => IconName.Justify, + RichTextEditorToolbarItem.Indent => IconName.TextIndentLeft, + RichTextEditorToolbarItem.Outdent => IconName.TextIndentRight, + RichTextEditorToolbarItem.OrderedList => IconName.ListOl, + RichTextEditorToolbarItem.UnorderedList => IconName.ListUl, + RichTextEditorToolbarItem.Blockquote => IconName.Quote, + RichTextEditorToolbarItem.CodeBlock => IconName.Code, + RichTextEditorToolbarItem.Link => IconName.Link45Deg, + RichTextEditorToolbarItem.Image => IconName.Image, + RichTextEditorToolbarItem.HorizontalRule => IconName.Hr, + RichTextEditorToolbarItem.Table => IconName.Table, + _ => IconName.Type + }; + + public static string ToIconLabel(this RichTextEditorToolbarItem toolbarItem) => + toolbarItem switch + { + RichTextEditorToolbarItem.Print => "Print", + RichTextEditorToolbarItem.Undo => "Undo", + RichTextEditorToolbarItem.Redo => "Redo", + RichTextEditorToolbarItem.Bold => "Bold", + RichTextEditorToolbarItem.Italic => "Italic", + RichTextEditorToolbarItem.Underline => "Underline", + RichTextEditorToolbarItem.Strikethrough => "Strikethrough", + RichTextEditorToolbarItem.ClearFormatting => "Clear formatting", + RichTextEditorToolbarItem.AlignLeft => "Align left", + RichTextEditorToolbarItem.AlignCenter => "Align center", + RichTextEditorToolbarItem.AlignRight => "Align right", + RichTextEditorToolbarItem.AlignJustify => "Align justify", + RichTextEditorToolbarItem.Indent => "Indent", + RichTextEditorToolbarItem.Outdent => "Outdent", + RichTextEditorToolbarItem.OrderedList => "Numbered list", + RichTextEditorToolbarItem.UnorderedList => "Bulleted list", + RichTextEditorToolbarItem.Blockquote => "Blockquote", + RichTextEditorToolbarItem.CodeBlock => "Code block", + RichTextEditorToolbarItem.Link => "Insert link", + RichTextEditorToolbarItem.Image => "Insert image", + RichTextEditorToolbarItem.HorizontalRule => "Insert horizontal rule", + RichTextEditorToolbarItem.Table => "Insert table", + _ => Regex.Replace(toolbarItem.ToString(), "([a-z])([A-Z])", "$1 $2") + }; + public static string ToModalFullscreenClass(this ModalFullscreen modalFullscreen) => modalFullscreen switch { From 843056174f629b756339937f4b1e64deebd1fa4c Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Sun, 16 Aug 2026 23:10:12 +0530 Subject: [PATCH 16/53] Refactor RichTextEditor: new parent-child & JS interop Refactored RichTextEditor and toolbar buttons to use explicit parent-child communication via CascadingValue/CascadingParameter. Updated RichTextEditorJsInterop to use command-based methods requiring .NET object reference and editor ID. Heavily refactored JS file with stubbed command functions and removed legacy logic. Razor markup restructured for clarity. ToolbarButton now uses computed properties and calls parent handler. Commented out obsolete code with TODOs for future work. --- .../Form/RichTextEditor/RichTextEditor.razor | 314 +++++++++--------- .../RichTextEditor/RichTextEditor.razor.cs | 23 +- .../RichTextEditor/RichTextEditorJsInterop.cs | 34 +- .../RichTextEditorToolbarButton.razor | 4 +- .../RichTextEditorToolbarButton.razor.cs | 14 +- .../blazor.bootstrap.rich-text-editor.js | 195 ++--------- 6 files changed, 244 insertions(+), 340 deletions(-) diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor index 78dadfdb3..ddc4b0274 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor @@ -1,174 +1,180 @@ @namespace BlazorBootstrap @inherits BlazorBootstrapComponentBase -
-
-
+ +
- + - + + + - + \ No newline at end of file diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs index c13d0094a..61536fdc2 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs @@ -28,7 +28,7 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) uploadCancellationTokenSource.Dispose(); if (Id is not null) - await RichTextEditorJsInterop.DisposeEditorAsync(Id); + await RichTextEditorJsInterop.DisposeAsync(objRef, Id); objRef?.Dispose(); } @@ -72,14 +72,22 @@ protected override void OnInitialized() /// [AddedVersion("4.0.0")] [Description("Clears the editor content.")] - public Task ClearAsync() => RichTextEditorJsInterop.ClearAsync(Id!); + public Task ClearAsync() => RichTextEditorJsInterop.ClearAsync(objRef!, Id!); /// /// Focuses the editable area. /// [AddedVersion("4.0.0")] [Description("Focuses the editable area.")] - public Task FocusAsync() => RichTextEditorJsInterop.FocusAsync(Id!); + public Task FocusAsync() => RichTextEditorJsInterop.FocusAsync(objRef!, Id!); + + public async Task OnToolbarButtonClickAsync(string toolbarElementId, RichTextEditorToolbarItem item) + { + if (Disabled || ReadOnly) + return; + + await RichTextEditorJsInterop.ExecuteAsync(objRef!, Id!, toolbarElementId, item.ToString()!, string.Empty); + } /// /// Raises the committed editor value callback. @@ -155,7 +163,8 @@ private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) return; } - await RichTextEditorJsInterop.InsertImageAsync(Id!, result.Url, string.Empty); + //await RichTextEditorJsInterop.InsertImageAsync(Id!, result.Url, string.Empty); + //TODO: Insert the uploaded image into the editor } catch (OperationCanceledException) { @@ -281,9 +290,11 @@ private async Task OnImageFileChangedAsync(InputFileChangeEventArgs e) [Parameter] public Expression>? ValueExpression { get; set; } - [CascadingParameter] private EditContext? EditContext { get; set; } + [CascadingParameter] + private EditContext? EditContext { get; set; } - [Inject] private RichTextEditorJsInterop RichTextEditorJsInterop { get; set; } = default!; + [Inject] + private RichTextEditorJsInterop RichTextEditorJsInterop { get; set; } = default!; #endregion } diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs index 91146552e..9575da92f 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs @@ -2,6 +2,15 @@ namespace BlazorBootstrap; internal sealed class RichTextEditorJsInterop : JsInteropBase { + #region Fields and Constants + + public const string Clear = "clear"; + public const string Dispose = "dispose"; + public const string Execute = "execute"; + public const string Focus = "focus"; + + #endregion + #region Constructors public RichTextEditorJsInterop(IJSRuntime jsRuntime) @@ -13,18 +22,25 @@ public RichTextEditorJsInterop(IJSRuntime jsRuntime) #region Methods - public Task ClearAsync(string id) => SafeInvokeVoidAsync("clear", id); - - public Task DisposeEditorAsync(string id) => SafeInvokeVoidAsync("dispose", id); - - public Task FocusAsync(string id) => SafeInvokeVoidAsync("focus", id); + public async Task ClearAsync(object objRef, string editorId) + { + await SafeInvokeVoidAsync(Execute, objRef, editorId, null, "clear", null); + } - public Task InitializeAsync(string id, DotNetObjectReference objRef, int debounceInterval, int? maxLength, bool readOnly, bool disabled) => - SafeInvokeVoidAsync("initialize", id, objRef, debounceInterval, maxLength, readOnly, disabled); + public async Task DisposeAsync(object objRef, string editorId) + { + await SafeInvokeVoidAsync(Dispose, objRef, editorId); + } - public Task InsertImageAsync(string id, string url, string altText) => SafeInvokeVoidAsync("insertImage", id, url, altText); + public async Task ExecuteAsync(object objRef, string editorId, string elementId, string command, string value) + { + await SafeInvokeVoidAsync(Execute, objRef, editorId, elementId, command, value); + } - public Task SetValueAsync(string id, string value) => SafeInvokeVoidAsync("setValue", id, value); + public async Task FocusAsync(object objRef, string editorId) + { + await SafeInvokeVoidAsync(Focus, objRef, editorId); + } #endregion } diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor index e058a3644..e575b207c 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor @@ -1,6 +1,6 @@ @namespace BlazorBootstrap @inherits BlazorBootstrapComponentBase - \ No newline at end of file diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs index 6d459ea17..d18a26a19 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs @@ -4,7 +4,8 @@ public partial class RichTextEditorToolbarButton : BlazorBootstrapComponentBase { #region Fields and Constants - private Button saveButton1 = default!; + private string? label => Item.ToIconLabel(); + private IconName iconName => Item.ToIconName(); private DotNetObjectReference? objRef; @@ -12,6 +13,12 @@ public partial class RichTextEditorToolbarButton : BlazorBootstrapComponentBase #region Methods + protected override async Task OnInitializedAsync() + { + objRef ??= DotNetObjectReference.Create(this); + await base.OnInitializedAsync(); + } + private async Task OnClickAsync() { Console.WriteLine($"RichTextEditorToolbarButton.OnClickAsync: {Item}"); @@ -19,7 +26,7 @@ private async Task OnClickAsync() { return; } - //await RichTextEditor?.OnToolbarButtonClickAsync(Item); + await Parent?.OnToolbarButtonClickAsync(Id!, Item)!; } #endregion @@ -50,5 +57,8 @@ private async Task OnClickAsync() [Parameter, EditorRequired] public RichTextEditorToolbarItem Item { get; set; } = RichTextEditorToolbarItem.None; + [CascadingParameter(Name = "RichTextEditor")] + private RichTextEditor? Parent { get; set; } + #endregion } diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index 5a136c96f..f6636a1d4 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -1,180 +1,41 @@ -const instances = new Map(); -const draftKey = "advanced-rich-text-editor-document"; -const allowedTags = new Set(["P", "BR", "H1", "H2", "H3", "FIGCAPTION", "STRONG", "EM", "U", "S", "SPAN", "UL", "OL", "LI", "BLOCKQUOTE", "PRE", "CODE", "HR", "A", "IMG", "FIGURE", "TABLE", "CAPTION", "THEAD", "TBODY", "TR", "TH", "TD"]); -const blockedTags = new Set(["SCRIPT", "STYLE", "TEMPLATE", "IFRAME", "OBJECT", "EMBED", "FORM", "INPUT", "BUTTON", "SVG", "MATH", "META", "LINK"]); -const allowedClasses = new Set(["text-start", "text-center", "text-end", "img-fluid", "table", "table-bordered"]); +window.blzorBootstrap = window.blazorBootstrap || {}; +window.blazorBootstrap.richTextEditor = window.blazorBootstrap.richTextEditor || {}; -function isSafeUrl(value, image = false) { - if (!value) return false; - const trimmed = value.trim(); - if (trimmed.startsWith("/") || trimmed.startsWith("#")) return !image; - try { - const url = new URL(trimmed, window.location.origin); - return ["https:", "mailto:", "tel:"].includes(url.protocol) && (!image || url.protocol === "https:"); - } catch { return false; } +function getEditor(editorId) { + return window.blazorBootstrap.richTextEditor[editorId]; } -function sanitizeStyle(value) { const style = document.createElement("span").style; style.cssText = value; const allowed = new Map([["color", /^#[0-9a-f]{6}$/i], ["background-color", /^#[0-9a-f]{6}$/i], ["font-family", /^(Inter|Arial|Georgia|Courier New)$/], ["font-size", /^(12|14|16|18|24)px$/], ["text-align", /^(left|center|right)$/], ["margin-left", /^(0|24|48|72|96)px$/]]); const safe = []; for (const [name, pattern] of allowed) { const value = style.getPropertyValue(name).trim(); if (value && pattern.test(value)) safe.push(`${name}: ${value}`); } return safe.join("; "); } +function getOrCreate(editorId) { + let editorEl = window.blazorBootstrap.richTextEditor[editorId]; + if (!editorEl) + editorEl = create(editorId); + return editorEl; -function sanitize(html) { - const documentFragment = new DOMParser().parseFromString(html || "", "text/html"); - for (const node of [...documentFragment.body.querySelectorAll("*")]) { - if (blockedTags.has(node.tagName)) { node.remove(); continue; } - if (!allowedTags.has(node.tagName)) { node.replaceWith(...node.childNodes); continue; } - for (const attribute of [...node.attributes]) { - const name = attribute.name.toLowerCase(); - const allowed = (node.tagName === "A" && ["href", "target", "rel"].includes(name)) - || (node.tagName === "IMG" && ["src", "alt"].includes(name)) - || (node.tagName === "TH" && name === "scope") - || (node.tagName === "SPAN" && ["data-bb-rte-color", "style"].includes(name)) - || (name === "class" && [...attribute.value.split(/\s+/)].every(value => allowedClasses.has(value))); - if (name === "style" && !/^(color|background-color|font-family|font-size|text-align|margin-left):\s*[-#(),.%\w\s]+;?$/i.test(attribute.value)) node.removeAttribute(attribute.name); else if (name.startsWith("on") || !allowed) node.removeAttribute(attribute.name); - } - if (node.tagName === "A" && !isSafeUrl(node.getAttribute("href"))) node.remove(); - if (node.tagName === "A" && node.target === "_blank") node.rel = "noopener noreferrer"; - if (node.tagName === "IMG" && !isSafeUrl(node.getAttribute("src"), true)) node.remove(); + function create(_editorId) { + let editorEl = document.getElementById(_editorId); + window.blazorBootstrap.richTextEditor[_editorId] = { + editor: editorEl + }; + return window.blazorBootstrap.richTextEditor[_editorId]; } - return documentFragment.body.innerHTML; } -function currentRange(instance) { - const selection = window.getSelection(); - return selection?.rangeCount && instance.editor.contains(selection.anchorNode) ? selection.getRangeAt(0) : instance.range; +export function dispose(dotNetHelper, editorId) { + console.log("blazor.bootstrap.rich-text-editor.js disposed"); } -function saveRange(instance) { const range = currentRange(instance); if (range) instance.range = range.cloneRange(); } -function restoreRange(instance) { if (!instance.range) return false; const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(instance.range); return true; } -function textMetrics(instance) { const text = instance.editor.innerText.trim(); return { text, characters: text.length, words: text ? text.split(/\s+/).length : 0 }; } -function updateFooter(instance) { const metrics = textMetrics(instance); instance.root.querySelector("[data-bb-rte-character-count]").textContent = `${metrics.characters} characters`; instance.root.querySelector("[data-bb-rte-word-count]").textContent = `${metrics.words} words`; const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest?.("p,h1,h2,h3,figcaption,blockquote,pre,li"); const context = block?.tagName === "P" || !block ? "Paragraph" : block.tagName.replace("H", "Heading "); instance.root.querySelector("[data-bb-rte-context]").textContent = context; instance.root.querySelector("[data-bb-rte-alignment-context]").textContent = block?.style.textAlign ? `${block.style.textAlign[0].toUpperCase()}${block.style.textAlign.slice(1)} aligned` : "Left aligned"; } -function pushHistory(instance) { const html = sanitize(instance.editor.innerHTML); if (instance.history[instance.historyIndex] === html) return; instance.history.splice(instance.historyIndex + 1); instance.history.push(html); if (instance.history.length > 100) instance.history.shift(); instance.historyIndex = instance.history.length - 1; } -function commit(instance, status = "") { normalizeTables(instance); const html = sanitize(instance.editor.innerHTML); if (instance.editor.innerHTML !== html) instance.editor.innerHTML = html; pushHistory(instance); updateFooter(instance); try { sessionStorage.setItem(draftKey, html); } catch { } instance.dotNetRef.invokeMethodAsync("OnEditorValueChangedAsync", html); if (status) instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", status); } -function insertNode(instance, node) { restoreRange(instance); const range = currentRange(instance) || document.createRange(); if (!range.startContainer.isConnected) range.selectNodeContents(instance.editor); range.collapse(false); range.deleteContents(); range.insertNode(node); range.setStartAfter(node); range.collapse(true); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); saveRange(instance); } -function wrapSelection(instance, tagName, attributes = {}) { restoreRange(instance); const range = currentRange(instance); if (!range || range.collapsed) return false; const element = document.createElement(tagName); Object.entries(attributes).forEach(([name, value]) => element.setAttribute(name, value)); try { range.surroundContents(element); } catch { const fragment = range.extractContents(); element.append(fragment); range.insertNode(element); } saveRange(instance); return true; } -function blockFormat(instance, tagName) { restoreRange(instance); const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest("p,h1,h2,h3,figcaption,blockquote,pre,li") || null; if (!block) return false; const replacement = document.createElement(tagName); replacement.append(...block.childNodes); block.replaceWith(replacement); const selection = window.getSelection(); const nextRange = document.createRange(); nextRange.selectNodeContents(replacement); nextRange.collapse(false); selection.removeAllRanges(); selection.addRange(nextRange); saveRange(instance); return true; } -function selectedElement(instance, selector) { - const range = currentRange(instance); let node = range?.commonAncestorContainer; - if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; - return node?.closest?.(selector) || null; -} -function normalizeTables(instance) { - for (const table of instance.editor.querySelectorAll("table")) { - table.classList.add("table", "table-bordered"); - let rows = [...table.querySelectorAll(":scope > tr, :scope > thead > tr, :scope > tbody > tr")]; - if (!rows.length) continue; - let thead = table.querySelector(":scope > thead"); - let tbody = table.querySelector(":scope > tbody"); - if (!thead) { thead = document.createElement("thead"); table.prepend(thead); } - if (!tbody) { tbody = document.createElement("tbody"); table.append(tbody); } - const header = rows.shift(); - for (const cell of [...header.children]) { const th = document.createElement("th"); th.scope = "col"; th.append(...cell.childNodes); cell.replaceWith(th); } - thead.replaceChildren(header); - for (const row of rows) { for (const cell of [...row.children]) { const td = document.createElement("td"); td.append(...cell.childNodes); cell.replaceWith(td); } tbody.append(row); } - } -} -function showModal(instance, name) { - saveRange(instance); - const element = document.getElementById(`${instance.id}-${name}-modal`); - if (!element || !window.bootstrap?.Modal) { instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "This dialog is unavailable."); return; } - window.bootstrap.Modal.getOrCreateInstance(element).show(); -} -function insertLink(instance) { - const modal = document.getElementById(`${instance.id}-link-modal`); - const url = modal?.querySelector("[data-bb-rte-link-url]")?.value?.trim(); - const text = modal?.querySelector("[data-bb-rte-link-text]")?.value?.trim(); - const newTab = modal?.querySelector("[data-bb-rte-link-new-tab]")?.checked; - if (!isSafeUrl(url)) { instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Enter an HTTPS, relative, mailto, or telephone URL."); return; } - const link = instance.editingLink || document.createElement("a"); link.href = url; link.textContent = text || currentRange(instance)?.toString() || url; - if (newTab) { link.target = "_blank"; link.rel = "noopener noreferrer"; } - insertNode(instance, link); window.bootstrap.Modal.getInstance(modal)?.hide(); commit(instance); -} -function insertImageFromModal(instance) { - const modal = document.getElementById(`${instance.id}-image-modal`); - const url = modal?.querySelector("[data-bb-rte-image-url]")?.value?.trim(); - const alt = modal?.querySelector("[data-bb-rte-image-alt]")?.value?.trim(); - if (!isSafeUrl(url, true) || !alt) { instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Provide an HTTPS image URL and alternative text."); return; } - const image = document.createElement("img"); image.src = url; image.alt = alt; image.className = "img-fluid"; - insertNode(instance, image); window.bootstrap.Modal.getInstance(modal)?.hide(); commit(instance); -} -function toggleFullscreen(instance) { - if (document.fullscreenElement === instance.root) document.exitFullscreen?.(); - else instance.root.requestFullscreen?.().catch(() => instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Fullscreen is unavailable.")); -} -function printEditor(instance) { - const previousTitle = document.title; document.title = instance.root.getAttribute("aria-label") || "Rich text document"; - window.addEventListener("afterprint", () => { document.title = previousTitle; }, { once: true }); window.print(); -} -function insertTable(instance) { - const modal = document.getElementById(`${instance.id}-table-modal`); - const rows = Number(modal?.querySelector("[data-bb-rte-table-rows]")?.value); - const columns = Number(modal?.querySelector("[data-bb-rte-table-columns]")?.value); - if (!Number.isInteger(rows) || !Number.isInteger(columns) || rows < 1 || rows > 20 || columns < 1 || columns > 10) { instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Choose between 1–20 rows and 1–10 columns."); return; } - const table = document.createElement("table"); table.className = "table table-bordered"; - const thead = document.createElement("thead"); const header = document.createElement("tr"); - for (let column = 0; column < columns; column++) { const cell = document.createElement("th"); cell.scope = "col"; cell.append(document.createElement("br")); header.append(cell); } - thead.append(header); table.append(thead); const body = document.createElement("tbody"); - for (let row = 1; row < rows; row++) { const tr = document.createElement("tr"); for (let column = 0; column < columns; column++) { const cell = document.createElement("td"); cell.append(document.createElement("br")); tr.append(cell); } body.append(tr); } - table.append(body); insertNode(instance, table); window.bootstrap.Modal.getInstance(modal)?.hide(); commit(instance); -} -function toggleList(instance, tagName) { restoreRange(instance); const range = currentRange(instance); let node = range?.commonAncestorContainer; if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; const block = node?.closest?.("p,h1,h2,h3,figcaption,blockquote,pre,li"); if (!block) return false; const list = document.createElement(tagName); const item = document.createElement("li"); item.append(...block.childNodes); list.append(item); block.replaceWith(list); const selection = window.getSelection(); const nextRange = document.createRange(); nextRange.selectNodeContents(item); nextRange.collapse(false); selection.removeAllRanges(); selection.addRange(nextRange); saveRange(instance); return true; } -function execute(instance, command, value) { - if (instance.disabled || instance.readOnly) return; - instance.editor.focus(); - restoreRange(instance); - let changed = false; - - if (command === "Link") { showModal(instance, "link"); return; } - if (command === "Image") { showModal(instance, "image"); return; } - if (command === "Table") { showModal(instance, "table"); return; } - if (command === "Fullscreen") { toggleFullscreen(instance); return; } - if (command === "Print") { printEditor(instance); return; } - - if (["Bold", "Italic", "Underline", "Strikethrough"].includes(command)) - changed = wrapSelection(instance, { Bold: "strong", Italic: "em", Underline: "u", Strikethrough: "s" }[command]); - else if (command === "BlockFormat") - changed = blockFormat(instance, value || "p"); - else if (command === "OrderedList") - changed = toggleList(instance, "ol"); - else if (command === "UnorderedList") - changed = toggleList(instance, "ul"); - else if (command === "Blockquote") - changed = blockFormat(instance, "blockquote"); - else if (command === "CodeBlock") - changed = blockFormat(instance, "pre"); - else if (command === "HorizontalRule") { insertNode(instance, document.createElement("hr")); changed = true; } - else if (command === "Undo" || command === "Redo") { - const next = instance.historyIndex + (command === "Undo" ? -1 : 1); - if (next >= 0 && next < instance.history.length) { instance.historyIndex = next; instance.editor.innerHTML = instance.history[next]; changed = true; } - } - else if (command === "ClearFormatting") { - const range = currentRange(instance); - if (range && !range.collapsed) { const text = range.toString(); range.deleteContents(); range.insertNode(document.createTextNode(text)); changed = true; } - } - else if (["TextColor", "HighlightColor", "FontFamily", "FontSize"].includes(command)) { - changed = wrapSelection(instance, "span", { "data-bb-rte-color": value || "" }); - const span = selectedElement(instance, "span"); - if (span) { if (command === "TextColor") span.style.color = value; if (command === "HighlightColor") span.style.backgroundColor = value; if (command === "FontFamily") span.style.fontFamily = value; if (command === "FontSize") span.style.fontSize = `${value}px`; } - } - else if (["AlignStart", "AlignCenter", "AlignEnd", "Indent", "Outdent"].includes(command)) { - const block = selectedElement(instance, "p,h1,h2,h3,figcaption,blockquote,pre,li"); - if (block) { if (command.startsWith("Align")) block.style.textAlign = { AlignStart: "left", AlignCenter: "center", AlignEnd: "right" }[command]; else { const margin = Number.parseInt(block.style.marginLeft || "0", 10) || 0; block.style.marginLeft = `${Math.max(0, margin + (command === "Indent" ? 24 : -24))}px`; } changed = true; } - } - - if (changed) commit(instance); - else instance.dotNetRef.invokeMethodAsync("OnEditorStatusChangedAsync", "Select content before applying this command."); +export function execute(dotNetHelper, editorId, elementId, command, value) { + console.log("blazor.bootstrap.rich-text-editor.js executed"); } +export function focus(dotNetHelper, editorId) { + console.log("blazor.bootstrap.rich-text-editor.js focused"); +} - export function initialize(id, dotNetRef, debounceInterval, maxLength, readOnly, disabled) { - dispose(id); const root = document.getElementById(id); const editor = document.getElementById(`${id}-editor`); if (!root || !editor) return; - const instance = { id, root, editor, dotNetRef, debounceInterval: Math.max(0, debounceInterval || 300), maxLength, readOnly, disabled, range: null, timer: 0, history: [], historyIndex: -1 }; - instances.set(id, instance); let stored = null; try { stored = sessionStorage.getItem(draftKey); } catch { } editor.innerHTML = sanitize(stored || editor.innerHTML); pushHistory(instance); updateFooter(instance); - editor.addEventListener("input", () => { if (instance.maxLength && editor.innerText.length > instance.maxLength) editor.innerText = editor.innerText.slice(0, instance.maxLength); saveRange(instance); clearTimeout(instance.timer); instance.timer = setTimeout(() => commit(instance), instance.debounceInterval); }); - ["keyup", "mouseup", "focusout"].forEach(eventName => editor.addEventListener(eventName, () => saveRange(instance))); - editor.addEventListener("paste", event => { event.preventDefault(); const text = event.clipboardData.getData("text/plain"); insertNode(instance, document.createTextNode(text)); commit(instance); }); - root.addEventListener("click", event => { const button = event.target.closest("[data-bb-rte-command]"); if (button) { saveRange(instance); execute(instance, button.dataset.bbRteCommand, button.dataset.bbRteValue); return; } if (event.target.closest("[data-bb-rte-save-link]")) insertLink(instance); if (event.target.closest("[data-bb-rte-save-image]")) insertImageFromModal(instance); if (event.target.closest("[data-bb-rte-save-table]")) insertTable(instance); }); +export function initialize(dotNetHelper, editorId) { + let editorEl = getOrCreate(editorId); + if (!editorEl && !editorEl.editor) { + dotNetHelper.invokeMethodAsync('OnEditorValueChangedAsync', "Password prompt canceled."); + return; } - export function clear(id) { const instance = instances.get(id); if (!instance) return; instance.editor.innerHTML = ""; commit(instance); } - export function dispose(id) { const instance = instances.get(id); if (instance) clearTimeout(instance.timer); instances.delete(id); } - export function focus(id) { instances.get(id)?.editor.focus(); } - export function insertImage(id, url, altText) { const instance = instances.get(id); if (!instance || !isSafeUrl(url, true)) return; const image = document.createElement("img"); image.src = url; image.alt = altText || ""; image.className = "img-fluid"; insertNode(instance, image); commit(instance); } - export function setValue(id, value) { const instance = instances.get(id); if (!instance) return; instance.editor.innerHTML = sanitize(value); pushHistory(instance); updateFooter(instance); } \ No newline at end of file +} \ No newline at end of file From 3f88846ec4f36b0b983a8fd6c87142a010525852 Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Sun, 16 Aug 2026 23:35:33 +0530 Subject: [PATCH 17/53] Replace editor value callback with TODO in JS The initialize function in blazor.bootstrap.rich-text-editor.js now uses a placeholder instead of sending "Password prompt canceled." to .NET. A comment was added to indicate that the actual editor value should be sent in the future. --- blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index f6636a1d4..6ed90ff57 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -35,7 +35,7 @@ export function focus(dotNetHelper, editorId) { export function initialize(dotNetHelper, editorId) { let editorEl = getOrCreate(editorId); if (!editorEl && !editorEl.editor) { - dotNetHelper.invokeMethodAsync('OnEditorValueChangedAsync', "Password prompt canceled."); + dotNetHelper.invokeMethodAsync('OnEditorValueChangedAsync', ""); // TODO: Send the editor's value to the .NET side return; } } \ No newline at end of file From d836ec032290fc89c6bbe99d5c0469c071ad25bb Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Mon, 17 Aug 2026 00:09:17 +0530 Subject: [PATCH 18/53] Refactor and enhance Blazor Rich Text Editor interop Major overhaul of JS and .NET interop for the Rich Text Editor: - Introduced per-editor state objects for advanced features and cleaner event handling. - Replaced execCommand with custom DOM-based formatting logic. - Added comprehensive link, table, and image support with modals and validation. - Implemented undo/redo stack, custom print, and improved selection management. - Updated .NET interop with new initialization and command routing. - Added ToCommandName extension for toolbar command mapping. - Improved toolbar handling, debugging, disposal, and accessibility. - Removed obsolete code and modernized editor for maintainability and new features. --- .../RichTextEditor/RichTextEditor.razor.cs | 34 +- .../RichTextEditor/RichTextEditorJsInterop.cs | 6 + .../RichTextEditorToolbarButton.razor.cs | 1 - blazorbootstrap/Extensions/EnumExtensions.cs | 25 + .../blazor.bootstrap.rich-text-editor.js | 1630 ++++++++++++++++- 5 files changed, 1659 insertions(+), 37 deletions(-) diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs index 61536fdc2..5b92e68cf 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs @@ -36,22 +36,22 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) await base.DisposeAsyncCore(disposing); } - //protected override async Task OnAfterRenderAsync(bool firstRender) - //{ - // if (firstRender) - // { - // objRef ??= DotNetObjectReference.Create(this); - // await RichTextEditorJsInterop.InitializeAsync(Id!, objRef, DebounceInterval, MaxLength, ReadOnly, Disabled); - // lastRenderedValue = Value; - // } - // else if (lastRenderedValue != Value) - // { - // await RichTextEditorJsInterop.SetValueAsync(Id!, Value); - // lastRenderedValue = Value; - // } - - // await base.OnAfterRenderAsync(firstRender); - //} + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + objRef ??= DotNetObjectReference.Create(this); + await RichTextEditorJsInterop.InitializeAsync(objRef, Id!); + lastRenderedValue = Value; + } + //else if (lastRenderedValue != Value) + //{ + // await RichTextEditorJsInterop.SetValueAsync(Id!, Value); + // lastRenderedValue = Value; + //} + + await base.OnAfterRenderAsync(firstRender); + } protected override void OnInitialized() { @@ -86,7 +86,7 @@ public async Task OnToolbarButtonClickAsync(string toolbarElementId, RichTextEdi if (Disabled || ReadOnly) return; - await RichTextEditorJsInterop.ExecuteAsync(objRef!, Id!, toolbarElementId, item.ToString()!, string.Empty); + await RichTextEditorJsInterop.ExecuteAsync(objRef!, Id!, toolbarElementId, item.ToCommandName()!, string.Empty); } /// diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs index 9575da92f..249250fe3 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs @@ -8,6 +8,7 @@ internal sealed class RichTextEditorJsInterop : JsInteropBase public const string Dispose = "dispose"; public const string Execute = "execute"; public const string Focus = "focus"; + public const string Initialize = "initialize"; #endregion @@ -42,5 +43,10 @@ public async Task FocusAsync(object objRef, string editorId) await SafeInvokeVoidAsync(Focus, objRef, editorId); } + public async Task InitializeAsync(object objRef, string editorId) + { + await SafeInvokeVoidAsync(Initialize, objRef, editorId); + } + #endregion } diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs index d18a26a19..780288c24 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs @@ -21,7 +21,6 @@ protected override async Task OnInitializedAsync() private async Task OnClickAsync() { - Console.WriteLine($"RichTextEditorToolbarButton.OnClickAsync: {Item}"); if (Disabled) { return; diff --git a/blazorbootstrap/Extensions/EnumExtensions.cs b/blazorbootstrap/Extensions/EnumExtensions.cs index dfa65eafe..22c8443ec 100644 --- a/blazorbootstrap/Extensions/EnumExtensions.cs +++ b/blazorbootstrap/Extensions/EnumExtensions.cs @@ -249,6 +249,31 @@ public static string ToCardColorClass(this CardColor cardColor) => _ => null }; + public static string? ToCommandName(this RichTextEditorToolbarItem toolbarItem) => + toolbarItem switch + { + RichTextEditorToolbarItem.Print => "print", + RichTextEditorToolbarItem.Undo => "undo", + RichTextEditorToolbarItem.Redo => "redo", + RichTextEditorToolbarItem.Bold => "bold", + RichTextEditorToolbarItem.Italic => "italic", + RichTextEditorToolbarItem.Underline => "underline", + RichTextEditorToolbarItem.Strikethrough => "strikeThrough", + RichTextEditorToolbarItem.ClearFormatting => "removeFormat", + RichTextEditorToolbarItem.AlignLeft => "justifyLeft", + RichTextEditorToolbarItem.AlignCenter => "justifyCenter", + RichTextEditorToolbarItem.AlignRight => "justifyRight", + RichTextEditorToolbarItem.AlignJustify => "justifyFull", + RichTextEditorToolbarItem.Indent => "indent", + RichTextEditorToolbarItem.Outdent => "outdent", + RichTextEditorToolbarItem.OrderedList => "insertOrderedList", + RichTextEditorToolbarItem.UnorderedList => "insertUnorderedList", + RichTextEditorToolbarItem.HorizontalRule => "insertHorizontalRule", + RichTextEditorToolbarItem.Blockquote => "formatBlock", // TODO: Check if this is correct, as there is no command for blockquote in execCommand + RichTextEditorToolbarItem.CodeBlock => "formatBlock", // TODO: Check if this is correct, as there is no command for code block in execCommand + _ => null + }; + public static string ToCssString(this Unit unit) => unit switch { diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index 6ed90ff57..b2cbb17fb 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -1,41 +1,1633 @@ -window.blzorBootstrap = window.blazorBootstrap || {}; +window.blazorBootstrap = window.blazorBootstrap || {}; window.blazorBootstrap.richTextEditor = window.blazorBootstrap.richTextEditor || {}; -function getEditor(editorId) { +// Font size label map shared across all editor instances +const _fontSizeLabels = { 1: '10 px', 2: '12 px', 3: '14 px', 4: '16 px', 5: '18 px', 6: '24 px', 7: '32 px' }; + +// ─── Per-editor state ───────────────────────────────────────────────────────── + +function getEditorState(editorId) { return window.blazorBootstrap.richTextEditor[editorId]; } -function getOrCreate(editorId) { - let editorEl = window.blazorBootstrap.richTextEditor[editorId]; - if (!editorEl) - editorEl = create(editorId); - return editorEl; +function createEditorState(editorId, dotNetHelper) { + const editor = document.getElementById(editorId); + if (!editor) return null; + + // The toolbar is expected to be a sibling/ancestor element with role="toolbar" + // inside the nearest section or data-rte-root container. + const container = editor.closest('[data-rte-root]') || editor.closest('section') || editor.parentElement; + const toolbar = container && container.querySelector('[role="toolbar"]'); + + const state = { + editorId, + editor, + toolbar, + container, + dotNetHelper, + savedRange: null, + activeTableCell: null, + activeEditorImage: null, + selectedTextColor: '#212529', + selectedHighlightColor: '#ffc107', + linkBeingEdited: null, + preparedImage: null, + imageBeingEdited: null, + editorUndoStates: [], + editorRedoStates: [], + // Stored handlers for cleanup + _toolbarPointerHandler: null, + _toolbarClickHandler: null, + _editorBeforeInputHandler: null, + _editorInputHandler: null, + _editorSelectionHandler: null, + _imageClickHandler: null, + }; + + window.blazorBootstrap.richTextEditor[editorId] = state; + return state; +} + +// ─── DOM lookup helpers ─────────────────────────────────────────────────────── + +/** Finds an element with ID = editorId + '-' + suffix. */ +function el(state, suffix) { + return document.getElementById(state.editorId + '-' + suffix); +} + +/** Returns a Bootstrap Modal instance for a modal whose ID is editorId + '-' + suffix. */ +function getModal(state, suffix) { + const modalEl = el(state, suffix); + if (!modalEl || typeof bootstrap === 'undefined') return null; + return { instance: bootstrap.Modal.getOrCreateInstance(modalEl), element: modalEl }; +} + +// ─── Selection helpers ──────────────────────────────────────────────────────── + +function getRangeElement(container) { + return container.nodeType === Node.TEXT_NODE ? container.parentElement : container; +} + +function getSelectionElement(state) { + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) return null; + let node = selection.anchorNode; + if (node && node.nodeType === Node.TEXT_NODE) node = node.parentElement; + return node instanceof Element && state.editor.contains(node) ? node : null; +} + +/** Stores the editor selection before a toolbar interaction can remove it. */ +function rememberSelection(state) { + const selection = window.getSelection(); + if (selection && selection.rangeCount && state.editor.contains(selection.anchorNode)) { + state.savedRange = selection.getRangeAt(0).cloneRange(); + } +} + +/** Returns focus and the saved selection to the editable area. */ +function restoreSelection(state) { + state.editor.focus(); + if (state.savedRange) { + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(state.savedRange); + } +} + +/** Returns the saved range only when it still belongs to this editor. */ +function getSavedEditorRange(state) { + if (!state.savedRange || !state.editor.contains(state.savedRange.commonAncestorContainer)) { + return null; + } + return state.savedRange.cloneRange(); +} + +/** Moves the browser selection to a range and keeps the editor cache in sync. */ +function setEditorRange(state, range) { + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + state.savedRange = range.cloneRange(); +} + +/** Finds all editable blocks touched by a range, or the current block for a collapsed range. */ +function getRangeBlocks(state, range) { + const blockSelector = 'p, h1, h2, h3, h4, h5, h6, li, td, th, blockquote, pre'; + if (range.collapsed) { + const element = getRangeElement(range.startContainer); + const block = element && element.closest(blockSelector); + return block && state.editor.contains(block) ? [block] : []; + } + return Array.from(state.editor.querySelectorAll(blockSelector)).filter((block) => { + try { + return range.intersectsNode(block) && !block.querySelector(blockSelector); + } catch (e) { + return false; + } + }); +} + +// ─── Undo / Redo ────────────────────────────────────────────────────────────── + +/** Saves the current document state for Undo before a user-visible edit. */ +function recordEditorState(state) { + if (!state.editorUndoStates.length || state.editorUndoStates[state.editorUndoStates.length - 1] !== state.editor.innerHTML) { + state.editorUndoStates.push(state.editor.innerHTML); + if (state.editorUndoStates.length > 100) state.editorUndoStates.shift(); + } + state.editorRedoStates = []; +} + +/** Restores one saved state and moves the current state to the opposite stack. */ +function restoreEditorHistory(state, fromStates, toStates) { + if (!fromStates.length) return false; + toStates.push(state.editor.innerHTML); + state.editor.innerHTML = fromStates.pop(); + state.activeTableCell = null; + const range = document.createRange(); + range.selectNodeContents(state.editor); + range.collapse(true); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + state.savedRange = range.cloneRange(); + notifyEditorChange(state); + return true; +} + +// ─── Change notification ────────────────────────────────────────────────────── + +/** Notifies the .NET side whenever the editor document changes. */ +function notifyEditorChange(state) { + if (state.dotNetHelper) { + const html = state.editor.innerHTML.replace(/\u200B/g, ''); + state.dotNetHelper.invokeMethodAsync('OnEditorValueChangedAsync', html); + } +} + +// ─── Inline formatting ──────────────────────────────────────────────────────── + +function unwrapElement(element) { + const fragment = document.createDocumentFragment(); + while (element.firstChild) fragment.appendChild(element.firstChild); + element.replaceWith(fragment); +} + +/** Tests whether the selection is fully inside one matching inline wrapper. */ +function getMatchingInlineWrapper(state, range, matcher) { + const startElement = getRangeElement(range.startContainer); + const endElement = getRangeElement(range.endContainer); + const startWrapper = startElement && startElement.closest('strong, em, span, s, strike, b, i, u'); + const endWrapper = endElement && endElement.closest('strong, em, span, s, strike, b, i, u'); + return startWrapper && startWrapper === endWrapper && state.editor.contains(startWrapper) && matcher(startWrapper) + ? startWrapper + : null; +} + +/** Inserts an inline wrapper using Selection/Range APIs. */ +function applyInlineFormat(state, createWrapper, matcher) { + restoreSelection(state); + const range = getSavedEditorRange(state); + if (!range) return; + const matchingWrapper = getMatchingInlineWrapper(state, range, matcher); + recordEditorState(state); + if (matchingWrapper) { + const afterWrapper = document.createRange(); + afterWrapper.setStartAfter(matchingWrapper); + afterWrapper.collapse(true); + unwrapElement(matchingWrapper); + setEditorRange(state, afterWrapper); + } else { + const wrapper = createWrapper(); + if (range.collapsed) { + const placeholder = document.createTextNode('\u200B'); + wrapper.dataset.rtePending = 'true'; + wrapper.appendChild(placeholder); + range.insertNode(wrapper); + const caret = document.createRange(); + caret.setStart(placeholder, 1); + caret.collapse(true); + setEditorRange(state, caret); + } else { + const contents = range.extractContents(); + wrapper.appendChild(contents); + range.insertNode(wrapper); + const selectedWrapper = document.createRange(); + selectedWrapper.selectNodeContents(wrapper); + setEditorRange(state, selectedWrapper); + } + } + rememberSelection(state); + notifyEditorChange(state); +} + +/** Converts a hex color to its DOM-style rgb() serialization for state checks. */ +function rgbFromHex(value) { + if (!/^#[0-9a-f]{6}$/i.test(value || '')) return value; + const n = Number.parseInt(value.slice(1), 16); + return 'rgb(' + ((n >> 16) & 255) + ', ' + ((n >> 8) & 255) + ', ' + (n & 255) + ')'; +} + +/** Applies a semantic tag or standard inline style to the selected text. */ +function applyInlineCommand(state, command, value) { + const semanticCommands = { + bold: { tag: 'strong', match: (e) => e.tagName === 'STRONG' || e.tagName === 'B' }, + italic: { tag: 'em', match: (e) => e.tagName === 'EM' || e.tagName === 'I' } + }; + if (semanticCommands[command]) { + const def = semanticCommands[command]; + applyInlineFormat(state, () => document.createElement(def.tag), def.match); + return; + } + const styles = { + underline: { + property: 'textDecoration', value: 'underline', + match: (e) => e.style.textDecoration.includes('underline') + }, + strikeThrough: { + property: 'textDecoration', value: 'line-through', + match: (e) => e.style.textDecoration.includes('line-through') + }, + fontName: { + property: 'fontFamily', value, + attribute: 'data-rte-font', attributeValue: value, + match: (e) => e.dataset.rteFont === value + }, + fontSize: { + property: 'fontSize', value: _fontSizeLabels[value] || '14 px', + attribute: 'data-rte-size', attributeValue: _fontSizeLabels[value] || '14 px', + match: (e) => e.dataset.rteSize === (_fontSizeLabels[value] || '14 px') + }, + foreColor: { + property: 'color', value, + match: (e) => e.style.color === value || e.style.color === rgbFromHex(value) + }, + hiliteColor: { + property: 'backgroundColor', value, + match: (e) => e.style.backgroundColor === value || e.style.backgroundColor === rgbFromHex(value) + } + }; + const def = styles[command]; + if (!def) return; + applyInlineFormat(state, () => { + const span = document.createElement('span'); + span.style[def.property] = def.value; + if (def.attribute) span.setAttribute(def.attribute, def.attributeValue); + return span; + }, def.match); +} + +/** Removes known inline formatting within the selection. */ +function clearInlineFormatting(state) { + restoreSelection(state); + const range = getSavedEditorRange(state); + if (!range) return; + recordEditorState(state); + const formattingSelector = 'strong, b, em, i, u, s, strike, font, span'; + if (range.collapsed) { + const wrapper = getRangeElement(range.startContainer).closest(formattingSelector); + if (wrapper && state.editor.contains(wrapper)) unwrapElement(wrapper); + } else { + const contents = range.extractContents(); + Array.from(contents.querySelectorAll(formattingSelector)).reverse().forEach(unwrapElement); + range.insertNode(contents); + } + rememberSelection(state); + notifyEditorChange(state); +} + +// ─── Block-level commands ───────────────────────────────────────────────────── + +/** Applies paragraph alignment to all blocks in the selection. */ +function applyAlignment(state, value) { + restoreSelection(state); + const range = getSavedEditorRange(state); + const blocks = range && getRangeBlocks(state, range); + if (!blocks || !blocks.length) return; + recordEditorState(state); + blocks.forEach((block) => { block.style.textAlign = value; }); + rememberSelection(state); + notifyEditorChange(state); +} + +/** Adjusts block indentation through its standard inline style. */ +function changeIndent(state, direction) { + restoreSelection(state); + const range = getSavedEditorRange(state); + const blocks = range && getRangeBlocks(state, range); + if (!blocks || !blocks.length) return; + recordEditorState(state); + blocks.forEach((block) => { + const current = Number.parseFloat(block.style.marginInlineStart || '0') || 0; + block.style.marginInlineStart = Math.max(0, current + direction * 2) + 'rem'; + if (block.style.marginInlineStart === '0rem') block.style.removeProperty('margin-inline-start'); + }); + rememberSelection(state); + notifyEditorChange(state); +} + +/** Turns the current block into a list item, or removes the existing list type. */ +function toggleList(state, listTag) { + restoreSelection(state); + const range = getSavedEditorRange(state); + const blocks = range && getRangeBlocks(state, range).filter((b) => !b.closest('table, pre, code')); + if (!blocks || !blocks.length) return; + const block = blocks[0]; + const existingList = block.tagName === 'LI' && block.parentElement; + recordEditorState(state); + if (existingList && existingList.tagName === listTag.toUpperCase()) { + const paragraph = document.createElement('p'); + paragraph.innerHTML = block.innerHTML; + const itemIndex = Array.from(existingList.children).indexOf(block); + const beforeList = existingList.cloneNode(false); + const afterList = existingList.cloneNode(false); + Array.from(existingList.children).forEach((item, index) => { + if (index < itemIndex) beforeList.appendChild(item); + else if (index > itemIndex) afterList.appendChild(item); + }); + const replacement = document.createDocumentFragment(); + if (beforeList.children.length) replacement.appendChild(beforeList); + replacement.appendChild(paragraph); + if (afterList.children.length) replacement.appendChild(afterList); + existingList.replaceWith(replacement); + const caret = document.createRange(); + caret.selectNodeContents(paragraph); + caret.collapse(true); + setEditorRange(state, caret); + } else if (existingList) { + const replacementList = document.createElement(listTag); + while (existingList.firstChild) replacementList.appendChild(existingList.firstChild); + existingList.replaceWith(replacementList); + const caret = document.createRange(); + caret.selectNodeContents(block); + caret.collapse(true); + setEditorRange(state, caret); + } else { + const list = document.createElement(listTag); + const item = document.createElement('li'); + item.innerHTML = block.innerHTML; + list.appendChild(item); + block.replaceWith(list); + const caret = document.createRange(); + caret.selectNodeContents(item); + caret.collapse(true); + setEditorRange(state, caret); + } + rememberSelection(state); + notifyEditorChange(state); +} + +/** Inserts a horizontal rule and a following paragraph at the selection. */ +function insertHorizontalRule(state) { + restoreSelection(state); + const range = getSavedEditorRange(state); + if (!range) return; + recordEditorState(state); + range.deleteContents(); + const fragment = document.createDocumentFragment(); + const rule = document.createElement('hr'); + const paragraph = document.createElement('p'); + paragraph.appendChild(document.createElement('br')); + fragment.append(rule, paragraph); + range.insertNode(fragment); + const caret = document.createRange(); + caret.selectNodeContents(paragraph); + caret.collapse(true); + setEditorRange(state, caret); + rememberSelection(state); + notifyEditorChange(state); +} + +/** Toggles blockquote wrapping on the current paragraph or heading. */ +function toggleBlockQuote(state) { + if (!state.savedRange || !state.editor.contains(state.savedRange.commonAncestorContainer)) return; + const startElement = getRangeElement(state.savedRange.startContainer); + const endElement = getRangeElement(state.savedRange.endContainer); + if (state.activeTableCell + || startElement.closest('table, pre, code, ul, ol, li') + || endElement.closest('table, pre, code, ul, ol, li')) return; + const quote = startElement.closest('blockquote'); + const endQuote = endElement.closest('blockquote'); + if (quote && quote === endQuote && state.editor.contains(quote)) { + recordEditorState(state); + const quoteContents = document.createDocumentFragment(); + while (quote.firstChild) quoteContents.appendChild(quote.firstChild); + quote.replaceWith(quoteContents); + state.activeTableCell = null; + rememberSelection(state); + notifyEditorChange(state); + return; + } + const startBlock = startElement.closest('p, h1, h2, h3, h4, h5, h6'); + const endBlock = endElement.closest('p, h1, h2, h3, h4, h5, h6'); + if (!startBlock || startBlock !== endBlock || !state.editor.contains(startBlock)) return; + recordEditorState(state); + const newQuote = document.createElement('blockquote'); + newQuote.className = 'blockquote border-start border-4 border-primary ps-3 my-4'; + startBlock.replaceWith(newQuote); + newQuote.appendChild(startBlock); + state.activeTableCell = null; + rememberSelection(state); + notifyEditorChange(state); +} + +/** Replaces the current text block with a semantic block element. */ +function selectBlock(state, block) { + if (block === 'blockquote') { + toggleBlockQuote(state); + return; + } + restoreSelection(state); + const range = getSavedEditorRange(state); + const blocks = range && getRangeBlocks(state, range).filter((item) => !item.closest('table, ul, ol')); + if (!blocks || blocks.length !== 1) return; + const currentBlock = blocks[0]; + const replacement = document.createElement(block === 'small' ? 'p' : block); + replacement.innerHTML = currentBlock.innerHTML; + replacement.className = currentBlock.className; + if (block === 'small') { + replacement.classList.add('small'); + } else { + replacement.classList.remove('small'); + } + recordEditorState(state); + currentBlock.replaceWith(replacement); + const updatedRange = document.createRange(); + updatedRange.selectNodeContents(replacement); + updatedRange.collapse(true); + setEditorRange(state, updatedRange); + rememberSelection(state); + notifyEditorChange(state); +} + +// ─── Command router ─────────────────────────────────────────────────────────── + +/** Routes toolbar commands to the appropriate inline or block implementation. */ +function executeCommand(state, command, value = null) { + if (['bold', 'italic', 'underline', 'strikeThrough', 'fontName', 'fontSize', 'foreColor', 'hiliteColor'].includes(command)) { + applyInlineCommand(state, command, value); + } else if (command === 'justifyLeft') { + applyAlignment(state, 'left'); + } else if (command === 'justifyCenter') { + applyAlignment(state, 'center'); + } else if (command === 'justifyRight') { + applyAlignment(state, 'right'); + } else if (command === 'justifyFull') { + applyAlignment(state, 'justify'); + } else if (command === 'indent') { + changeIndent(state, 1); + } else if (command === 'outdent') { + changeIndent(state, -1); + } else if (command === 'insertOrderedList') { + toggleList(state, 'ol'); + } else if (command === 'insertUnorderedList') { + toggleList(state, 'ul'); + } else if (command === 'insertHorizontalRule') { + insertHorizontalRule(state); + } else if (command === 'removeFormat') { + clearInlineFormatting(state); + } +} + +// ─── Link helpers ───────────────────────────────────────────────────────────── + +/** Normalizes allowed link formats and rejects unsafe or malformed URLs. */ +function normalizeLinkUrl(value) { + const rawValue = value.trim(); + if (!rawValue) return null; + if (rawValue.startsWith('#')) return rawValue.length > 1 && !/\s/.test(rawValue) ? rawValue : null; + if (/^mailto:/i.test(rawValue)) return /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/i.test(rawValue) ? rawValue : null; + if (/^tel:/i.test(rawValue)) return /^tel:\+?[0-9(). -]+$/i.test(rawValue) ? rawValue : null; + if (/\s/.test(rawValue)) return null; + const candidate = /^[a-z][a-z0-9+.-]*:/i.test(rawValue) ? rawValue : 'https://' + rawValue; + try { + const parsedUrl = new URL(candidate); + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' ? parsedUrl.href : null; + } catch (e) { + return null; + } +} + +/** Confirms that a link selection stays within one editable text block. */ +function isLinkSelectionSafe(state, range) { + const startElement = getRangeElement(range.startContainer); + const endElement = getRangeElement(range.endContainer); + const startBlock = startElement.closest('p, h1, h2, h3, h4, h5, h6, li, td, th, blockquote'); + const endBlock = endElement.closest('p, h1, h2, h3, h4, h5, h6, li, td, th, blockquote'); + return startBlock && startBlock === endBlock + && !startElement.closest('pre, code') + && !endElement.closest('pre, code'); +} + +/** Finds one existing editor link when the stored selection is inside it. */ +function getLinkAtSelection(state) { + if (!state.savedRange || !state.editor.contains(state.savedRange.commonAncestorContainer)) return null; + const startLink = getRangeElement(state.savedRange.startContainer).closest('a'); + const endLink = getRangeElement(state.savedRange.endContainer).closest('a'); + return startLink && startLink === endLink && state.editor.contains(startLink) ? startLink : null; +} + +/** Inserts a safe link at the saved selection. */ +function insertLink(state, url, text, openInNewTab) { + restoreSelection(state); + const range = getSavedEditorRange(state); + if (!range) return; + recordEditorState(state); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.textContent = text; + if (openInNewTab) { + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + } + range.deleteContents(); + range.insertNode(link); + const selection = window.getSelection(); + const linkRange = document.createRange(); + linkRange.setStartAfter(link); + linkRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(linkRange); + state.savedRange = linkRange.cloneRange(); + rememberSelection(state); + notifyEditorChange(state); +} + +/** Saves edits to an existing link in the editor. */ +function updateLink(state, link, url, text, openInNewTab) { + recordEditorState(state); + link.setAttribute('href', url); + link.textContent = text; + if (openInNewTab) { + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + } else { + link.removeAttribute('target'); + link.removeAttribute('rel'); + } + const selection = window.getSelection(); + const linkRange = document.createRange(); + linkRange.setStartAfter(link); + linkRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(linkRange); + state.savedRange = linkRange.cloneRange(); + rememberSelection(state); + notifyEditorChange(state); +} + +// ─── Link modal ─────────────────────────────────────────────────────────────── + +/** + * Opens the link modal for inserting or editing a link. + * Modal element ID convention: {editorId}-insert-link-modal + * Form field IDs: {editorId}-insert-link-url + * {editorId}-insert-link-text + * {editorId}-insert-link-new-tab + */ +function openInsertLinkModal(state) { + if (!state.savedRange || !state.editor.contains(state.savedRange.commonAncestorContainer)) return; + if (!isLinkSelectionSafe(state, state.savedRange)) return; + + const modal = getModal(state, 'insert-link-modal'); + if (!modal) return; + + const urlInput = el(state, 'insert-link-url'); + const textInput = el(state, 'insert-link-text'); + const newTabInput = el(state, 'insert-link-new-tab'); + const form = modal.element.querySelector('form'); + if (!form || !urlInput || !textInput) return; + + form.reset(); + form.classList.remove('was-validated'); + urlInput.setCustomValidity(''); + textInput.setCustomValidity(''); + + state.linkBeingEdited = getLinkAtSelection(state); + if (state.linkBeingEdited) { + textInput.value = state.linkBeingEdited.textContent; + urlInput.value = state.linkBeingEdited.getAttribute('href') || ''; + if (newTabInput) newTabInput.checked = state.linkBeingEdited.target === '_blank'; + } else { + textInput.value = state.savedRange.toString().trim(); + } + + if (!modal.element._rteHandlersAttached) { + modal.element._rteHandlersAttached = true; + + form.addEventListener('submit', (event) => { + event.preventDefault(); + const text = textInput.value.trim(); + const url = normalizeLinkUrl(urlInput.value); + textInput.setCustomValidity(text ? '' : 'Link text is required.'); + urlInput.setCustomValidity(url ? '' : 'Enter a valid link URL.'); + form.classList.add('was-validated'); + if (!form.checkValidity()) return; + if (state.linkBeingEdited && state.editor.contains(state.linkBeingEdited)) { + updateLink(state, state.linkBeingEdited, url, text, newTabInput ? newTabInput.checked : false); + } else { + insertLink(state, url, text, newTabInput ? newTabInput.checked : false); + } + state.linkBeingEdited = null; + modal.instance.hide(); + }); + + urlInput.addEventListener('input', () => urlInput.setCustomValidity('')); + textInput.addEventListener('input', () => textInput.setCustomValidity('')); + modal.element.addEventListener('hidden.bs.modal', () => { state.linkBeingEdited = null; }); + } + + modal.instance.show(); +} + +// ─── Table helpers ──────────────────────────────────────────────────────────── + +/** Resolves the active table cell from the live selection or the preserved cell. */ +function getTableContext(state) { + restoreSelection(state); + const element = getSelectionElement(state); + const selectedCell = element && element.closest('td, th'); + const preservedCell = state.activeTableCell && state.editor.contains(state.activeTableCell) ? state.activeTableCell : null; + const cell = selectedCell || preservedCell; + if (!cell) return null; + const row = cell.parentElement; + const table = cell.closest('table'); + if (!row || !table || !state.editor.contains(table)) return null; + state.activeTableCell = cell; + return { table, row, cell }; +} + +/** Moves the selection into a specific table cell. */ +function selectTableCell(state, cell) { + if (!cell || !state.editor.contains(cell)) { + state.activeTableCell = null; + return; + } + const range = document.createRange(); + range.selectNodeContents(cell); + range.collapse(true); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + state.savedRange = range.cloneRange(); + state.activeTableCell = cell; +} + +/** Repairs empty or incomplete rows so table actions always work with a rectangular grid. */ +function normalizeTableRows(table) { + const rows = Array.from(table.rows); + const columnCount = Math.max(0, ...rows.map((r) => r.cells.length)); + if (!columnCount) return; + rows.forEach((tableRow) => { + while (tableRow.cells.length < columnCount) { + const newCell = document.createElement(tableRow.parentElement.tagName === 'THEAD' ? 'th' : 'td'); + if (newCell.tagName === 'TH') newCell.scope = 'col'; + newCell.innerHTML = ' '; + tableRow.appendChild(newCell); + } + }); +} + +function applyTableAction(state, action) { + const context = getTableContext(state); + if (!context) return; + recordEditorState(state); + const { table, row, cell } = context; + normalizeTableRows(table); + const column = cell.cellIndex; + + if (action === 'add-column-right' || action === 'add-column-left') { + const insertionIndex = action === 'add-column-right' ? column + 1 : column; + Array.from(table.rows).forEach((tableRow) => { + const newCell = document.createElement(tableRow.parentElement.tagName === 'THEAD' ? 'th' : 'td'); + if (newCell.tagName === 'TH') newCell.scope = 'col'; + newCell.innerHTML = ' '; + tableRow.insertBefore(newCell, tableRow.cells[Math.min(insertionIndex, tableRow.cells.length)] || null); + }); + } else if (action === 'add-row-below' || action === 'add-row-above') { + const rowSection = row.parentElement; + const body = table.tBodies[0] || table.createTBody(); + const targetSection = rowSection.tagName === 'TBODY' ? rowSection : body; + const newRow = document.createElement('tr'); + const insertionPoint = rowSection.tagName === 'TBODY' + ? (action === 'add-row-below' ? row.nextSibling : row) + : body.firstChild; + const columns = Math.max(1, row.cells.length); + for (let index = 0; index < columns; index++) { + const newCell = document.createElement('td'); + newCell.innerHTML = ' '; + newRow.appendChild(newCell); + } + targetSection.insertBefore(newRow, insertionPoint); + selectTableCell(state, newRow.cells[Math.min(column, newRow.cells.length - 1)]); + } else if (action === 'delete-column') { + Array.from(table.rows).forEach((tableRow) => { + if (tableRow.cells.length > column) tableRow.deleteCell(column); + }); + } else if (action === 'delete-row') { + table.deleteRow(row.rowIndex); + } else if (action === 'delete-table') { + table.remove(); + } + + if (action !== 'add-row-below' && action !== 'add-row-above') { + selectTableCell(state, action === 'delete-table' + ? null + : (state.editor.contains(cell) ? cell : row.cells[Math.min(column, row.cells.length - 1)])); + } + + notifyEditorChange(state); + rememberSelection(state); +} + +function applyTableStyle(state, style) { + const context = getTableContext(state); + if (!context) return; + recordEditorState(state); + const { table, cell } = context; + if (style === 'header-row') { + const headerSection = table.tHead; + const firstRow = (headerSection && headerSection.rows[0]) || table.rows[0]; + const hasHeaderCells = Boolean(headerSection && firstRow && Array.from(firstRow.cells).some((c) => c.tagName === 'TH')); + const useHeaders = firstRow && !hasHeaderCells; + const selectionWasInFirstRow = firstRow && firstRow.contains(cell); + const selectedColumn = cell.cellIndex; + if (firstRow) { + Array.from(firstRow.cells).forEach((tableCell) => { + const replacement = document.createElement(useHeaders ? 'th' : 'td'); + replacement.innerHTML = tableCell.innerHTML; + replacement.className = tableCell.className; + if (useHeaders) replacement.scope = 'col'; + tableCell.replaceWith(replacement); + }); + if (useHeaders) { + let dest = headerSection; + if (!dest) { + const newHead = document.createElement('thead'); + table.insertBefore(newHead, table.firstChild); + dest = newHead; + } + if (!dest.contains(firstRow)) dest.appendChild(firstRow); + } + selectTableCell(state, selectionWasInFirstRow + ? firstRow.cells[Math.min(selectedColumn, firstRow.cells.length - 1)] + : cell); + } + } else { + table.classList.toggle(style); + selectTableCell(state, cell); + } + notifyEditorChange(state); + rememberSelection(state); +} + +function alignTableCell(state, alignment) { + const context = getTableContext(state); + if (!context) return; + recordEditorState(state); + context.cell.classList.remove('text-start', 'text-center', 'text-end'); + context.cell.classList.add(alignment); + notifyEditorChange(state); + rememberSelection(state); +} + +/** Inserts a bordered data table with a semantic header row. */ +function insertTable(state, rows, columns) { + restoreSelection(state); + const range = getSavedEditorRange(state); + if (!range) return; + recordEditorState(state); + const table = document.createElement('table'); + table.className = 'table table-sm table-bordered align-middle'; + const header = table.createTHead(); + const headerRow = header.insertRow(); + for (let i = 0; i < columns; i++) { + const th = document.createElement('th'); + th.scope = 'col'; + th.textContent = 'Header ' + (i + 1); + headerRow.appendChild(th); + } + if (rows > 1) { + const body = table.createTBody(); + for (let r = 1; r < rows; r++) { + const bodyRow = body.insertRow(); + for (let c = 0; c < columns; c++) { + const bodyCell = bodyRow.insertCell(); + bodyCell.textContent = 'Cell ' + (c + 1); + } + } + } + const paragraph = document.createElement('p'); + paragraph.appendChild(document.createElement('br')); + range.deleteContents(); + const fragment = document.createDocumentFragment(); + fragment.append(table, paragraph); + range.insertNode(fragment); + const caret = document.createRange(); + caret.selectNodeContents(headerRow.cells[0]); + caret.collapse(true); + setEditorRange(state, caret); + state.activeTableCell = headerRow.cells[0]; + rememberSelection(state); + notifyEditorChange(state); +} + +// ─── Table modal ────────────────────────────────────────────────────────────── + +/** + * Opens the table-dimensions modal. + * Modal element ID convention: {editorId}-insert-table-modal + * Form field IDs: {editorId}-insert-table-rows + * {editorId}-insert-table-columns + */ +function openInsertTableModal(state) { + const modal = getModal(state, 'insert-table-modal'); + if (!modal) return; + + const rowsInput = el(state, 'insert-table-rows'); + const columnsInput = el(state, 'insert-table-columns'); + const form = modal.element.querySelector('form'); + if (!form || !rowsInput || !columnsInput) return; + + form.reset(); + form.classList.remove('was-validated'); + rowsInput.setCustomValidity(''); + columnsInput.setCustomValidity(''); + + if (!modal.element._rteHandlersAttached) { + modal.element._rteHandlersAttached = true; + + form.addEventListener('submit', (event) => { + event.preventDefault(); + const rows = Number(rowsInput.value); + const columns = Number(columnsInput.value); + const rowsValid = Number.isInteger(rows) && rows >= 1 && rows <= 20; + const columnsValid = Number.isInteger(columns) && columns >= 1 && columns <= 20; + rowsInput.setCustomValidity(rowsValid ? '' : 'Enter a whole number from 1 to 20.'); + columnsInput.setCustomValidity(columnsValid ? '' : 'Enter a whole number from 1 to 20.'); + form.classList.add('was-validated'); + if (!form.checkValidity()) return; + insertTable(state, rows, columns); + modal.instance.hide(); + }); + + rowsInput.addEventListener('input', () => rowsInput.setCustomValidity('')); + columnsInput.addEventListener('input', () => columnsInput.setCustomValidity('')); + } + + modal.instance.show(); +} + +// ─── Image helpers ──────────────────────────────────────────────────────────── + +function normalizeHttpUrl(value) { + try { + const parsedUrl = new URL(value.trim()); + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' ? parsedUrl.href : null; + } catch (e) { + return null; + } +} + +function getImageUrlExtension(url) { + try { + const filename = new URL(url).pathname.split('/').pop() || ''; + const match = filename.match(/\.([a-z0-9]+)$/i); + return match ? match[1].toLowerCase() : ''; + } catch (e) { + return ''; + } +} + +function getAllowedImageExtensions(state) { + const input = el(state, 'image-extension-whitelist'); + const value = input ? input.value : 'png, jpg, jpeg, gif, webp'; + return value.toLowerCase().split(',').map((ext) => ext.trim().replace(/^\./, '')).filter(Boolean); +} + +function normalizeImageUrl(state, value) { + const url = normalizeHttpUrl(value); + const extension = url ? getImageUrlExtension(url) : ''; + return url && (!extension || getAllowedImageExtensions(state).includes(extension)) ? url : null; +} + +function loadImageDetails(url) { + return new Promise((resolve, reject) => { + const probe = new Image(); + probe.onload = () => resolve({ url, width: probe.naturalWidth, height: probe.naturalHeight }); + probe.onerror = () => reject(new Error('The image could not be loaded from this URL.')); + probe.src = url; + }); +} + +function showImageFeedback(state, message) { + const feedback = el(state, 'image-feedback'); + if (!feedback) return; + feedback.textContent = message; + feedback.classList.remove('d-none'); +} + +function clearImageFeedback(state) { + const feedback = el(state, 'image-feedback'); + if (!feedback) return; + feedback.textContent = ''; + feedback.classList.add('d-none'); +} + +async function prepareImagePreview(state, value) { + const url = normalizeImageUrl(state, value); + if (!url) throw new Error('Enter a valid HTTP or HTTPS image URL with an allowed extension.'); + const details = await loadImageDetails(url); + state.preparedImage = details; + const preview = el(state, 'image-preview'); + const widthInput = el(state, 'image-width'); + const heightInput = el(state, 'image-height'); + const imageOptions = el(state, 'image-options'); + const submitBtn = el(state, 'insert-image-submit'); + if (preview) preview.src = details.url; + if (widthInput) widthInput.value = details.width; + if (heightInput) heightInput.value = details.height; + if (imageOptions) imageOptions.classList.remove('d-none'); + if (submitBtn) submitBtn.disabled = false; + clearImageFeedback(state); +} + +function updateImageAspectRatio(state, changedDimension) { + if (!state.preparedImage) return; + const aspectLock = el(state, 'image-aspect-lock'); + if (!aspectLock || !aspectLock.checked) return; + const widthInput = el(state, 'image-width'); + const heightInput = el(state, 'image-height'); + const width = Number(widthInput && widthInput.value); + const height = Number(heightInput && heightInput.value); + if (changedDimension === 'width' && Number.isFinite(width) && width > 0) { + if (heightInput) heightInput.value = Math.max(1, Math.round(width * state.preparedImage.height / state.preparedImage.width)); + } else if (changedDimension === 'height' && Number.isFinite(height) && height > 0) { + if (widthInput) widthInput.value = Math.max(1, Math.round(height * state.preparedImage.width / state.preparedImage.height)); + } +} + +function parseJsonObject(value, label) { + if (!value.trim()) return {}; + try { + const parsed = JSON.parse(value); + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error(); + return parsed; + } catch (e) { + throw new Error(label + ' must be a valid JSON object.'); + } +} + +function getImageUploadConfiguration(state, requireEndpoint) { + const allowedExtensions = getAllowedImageExtensions(state); + const maxSizeInput = el(state, 'image-max-size'); + const maximumSize = Number(maxSizeInput ? maxSizeInput.value : 5); + const endpointInput = el(state, 'image-upload-endpoint'); + const fileFieldInput = el(state, 'image-upload-file-field'); + const responseModeInput = el(state, 'image-response-mode'); + const responsePathInput = el(state, 'image-response-path'); + const sizeMsgInput = el(state, 'image-size-message'); + const fieldsInput = el(state, 'image-upload-fields'); + const headersInput = el(state, 'image-upload-headers'); + const configuration = { + endpoint: endpointInput ? endpointInput.value.trim() : '', + fileField: fileFieldInput ? fileFieldInput.value.trim() : 'file', + responseMode: responseModeInput ? responseModeInput.value : 'json', + responsePath: responsePathInput ? responsePathInput.value.trim() : 'data.url', + allowedExtensions, + maximumSize, + sizeMessage: sizeMsgInput ? sizeMsgInput.value.trim() : '', + fields: parseJsonObject(fieldsInput ? fieldsInput.value : '', 'Additional multipart fields'), + headers: parseJsonObject(headersInput ? headersInput.value : '', 'Request headers') + }; + if (!allowedExtensions.length) throw new Error('Add at least one allowed image extension.'); + if (!Number.isFinite(maximumSize) || maximumSize <= 0) throw new Error('Enter a maximum upload size greater than zero.'); + if (!configuration.fileField) throw new Error('Enter the multipart file field name.'); + if (configuration.responseMode === 'json' && !configuration.responsePath) throw new Error('Enter the JSON path that contains the uploaded image URL.'); + if (Object.keys(configuration.headers).some((h) => h.toLowerCase() === 'content-type')) throw new Error('Do not set Content-Type; the browser supplies the multipart boundary.'); + if (requireEndpoint && !normalizeHttpUrl(configuration.endpoint)) throw new Error('Enter a valid HTTP or HTTPS POST endpoint.'); + return configuration; +} + +function getResponseValue(response, path) { + return path.replace(/\[(\d+)\]/g, '.$1').split('.').filter(Boolean) + .reduce((value, key) => value == null ? undefined : value[key], response); +} + +function getUploadedImageUrl(responseText, configuration) { + let responseUrl = ''; + if (configuration.responseMode === 'text') { + responseUrl = responseText.trim(); + } else { + try { + const response = JSON.parse(responseText); + const value = getResponseValue(response, configuration.responsePath); + responseUrl = typeof value === 'string' ? value.trim() : ''; + } catch (e) { + return ''; + } + } + try { + return responseUrl ? new URL(responseUrl, configuration.endpoint).href : ''; + } catch (e) { + return ''; + } +} + +function validateImageFile(file, configuration) { + if (!file) return 'Choose an image file to upload.'; + const extension = file.name.split('.').pop().toLowerCase(); + if (!configuration.allowedExtensions.includes(extension)) { + return 'Choose a file with one of these extensions: ' + configuration.allowedExtensions.join(', ') + '.'; + } + if (file.type && !file.type.startsWith('image/')) return 'Choose a valid image file.'; + if (file.size > configuration.maximumSize * 1024 * 1024) { + return (configuration.sizeMessage || 'Choose an image smaller than {max} MB.').replace('{max}', configuration.maximumSize); + } + return ''; +} + +function setUploadProgress(state, value) { + const progressEl = el(state, 'image-upload-progress'); + const barEl = el(state, 'image-upload-progress-bar'); + if (!progressEl || !barEl) return; + const percent = Math.max(0, Math.min(100, Math.round(value))); + progressEl.classList.remove('d-none'); + progressEl.setAttribute('aria-valuenow', String(percent)); + barEl.style.width = percent + '%'; + barEl.textContent = percent + '%'; +} + +function uploadSelectedImage(state) { + let configuration; + try { + configuration = getImageUploadConfiguration(state, true); + } catch (err) { + showImageFeedback(state, err.message); + return; + } + const fileInput = el(state, 'image-upload-file'); + const fileError = validateImageFile(fileInput && fileInput.files[0], configuration); + if (fileError) { + showImageFeedback(state, fileError); + return; + } + clearImageFeedback(state); + const uploadBtn = el(state, 'upload-image-file'); + if (uploadBtn) uploadBtn.disabled = true; + setUploadProgress(state, 0); + const formData = new FormData(); + formData.append(configuration.fileField, fileInput.files[0], fileInput.files[0].name); + Object.entries(configuration.fields).forEach(([key, val]) => + formData.append(key, val && typeof val === 'object' ? JSON.stringify(val) : String(val))); + const request = new XMLHttpRequest(); + request.open('POST', configuration.endpoint, true); + Object.entries(configuration.headers).forEach(([header, val]) => request.setRequestHeader(header, String(val))); + request.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) setUploadProgress(state, event.loaded / event.total * 100); + }); + request.addEventListener('load', async () => { + if (uploadBtn) uploadBtn.disabled = false; + if (request.status < 200 || request.status >= 300) { + showImageFeedback(state, 'Upload failed with status ' + request.status + '.'); + return; + } + const uploadedUrl = getUploadedImageUrl(request.responseText, configuration); + if (!uploadedUrl) { + showImageFeedback(state, 'The upload response did not contain an image URL at the configured location.'); + return; + } + try { + await prepareImagePreview(state, uploadedUrl); + setUploadProgress(state, 100); + } catch (err) { + showImageFeedback(state, err.message); + } + }); + request.addEventListener('error', () => { + if (uploadBtn) uploadBtn.disabled = false; + showImageFeedback(state, 'The upload request failed. Check the endpoint, CORS policy, and network connection.'); + }); + request.send(formData); +} + +function resetImageModal(state) { + const form = el(state, 'insert-image-form'); + if (form) form.reset(); + state.preparedImage = null; + state.imageBeingEdited = null; + const imageOptions = el(state, 'image-options'); + const preview = el(state, 'image-preview'); + const submitBtn = el(state, 'insert-image-submit'); + const progressEl = el(state, 'image-upload-progress'); + const barEl = el(state, 'image-upload-progress-bar'); + const renderType = el(state, 'image-render-type'); + const alignment = el(state, 'image-alignment'); + const aspectLock = el(state, 'image-aspect-lock'); + const responsive = el(state, 'image-responsive'); + const captionGroup = el(state, 'image-caption-group'); + const altText = el(state, 'image-alt-text'); + const titleEl = el(state, 'insert-image-title'); + if (imageOptions) imageOptions.classList.add('d-none'); + if (preview) preview.removeAttribute('src'); + if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Insert image'; } + if (progressEl) progressEl.classList.add('d-none'); + if (barEl) { barEl.style.width = '0%'; barEl.textContent = '0%'; } + if (renderType) renderType.value = 'figure'; + if (alignment) alignment.value = 'center'; + if (aspectLock) aspectLock.checked = true; + if (responsive) responsive.checked = true; + if (captionGroup) captionGroup.classList.remove('d-none'); + if (altText) altText.disabled = false; + if (titleEl) titleEl.textContent = 'Insert image'; + clearImageFeedback(state); +} + +/** Finds an editor image from a click or a saved selection, when available. */ +function getImageForEditing(state) { + if (state.activeEditorImage && state.editor.contains(state.activeEditorImage)) return state.activeEditorImage; + if (!state.savedRange || !state.editor.contains(state.savedRange.commonAncestorContainer)) return null; + const startElement = getRangeElement(state.savedRange.startContainer); + if (startElement && startElement.closest('img')) return startElement.closest('img'); + if (state.savedRange.startContainer.nodeType === Node.ELEMENT_NODE) { + const adjacentNode = state.savedRange.startContainer.childNodes[state.savedRange.startOffset] + || state.savedRange.startContainer.childNodes[state.savedRange.startOffset - 1]; + if (adjacentNode instanceof Element) { + return adjacentNode.matches('img') ? adjacentNode : adjacentNode.querySelector('img'); + } + } + return null; +} - function create(_editorId) { - let editorEl = document.getElementById(_editorId); - window.blazorBootstrap.richTextEditor[_editorId] = { - editor: editorEl - }; - return window.blazorBootstrap.richTextEditor[_editorId]; +/** Pre-fills the image dialog from an existing editor image without changing its source. */ +function loadImageForEditing(state, image) { + const figure = image.closest('figure'); + const imageWidthValue = Number(image.getAttribute('width')) || image.naturalWidth || image.width || 1; + const imageHeightValue = Number(image.getAttribute('height')) || image.naturalHeight || image.height || 1; + state.preparedImage = { + url: image.currentSrc || image.src, + width: image.naturalWidth || imageWidthValue, + height: image.naturalHeight || imageHeightValue + }; + const directUrl = el(state, 'image-direct-url'); + const preview = el(state, 'image-preview'); + const imageOptions = el(state, 'image-options'); + const renderType = el(state, 'image-render-type'); + const captionGroup = el(state, 'image-caption-group'); + const alignmentEl = el(state, 'image-alignment'); + const altText = el(state, 'image-alt-text'); + const decorativeEl = el(state, 'image-decorative'); + const captionEl = el(state, 'image-caption'); + const widthInput = el(state, 'image-width'); + const heightInput = el(state, 'image-height'); + const aspectLock = el(state, 'image-aspect-lock'); + const titleText = el(state, 'image-title-text'); + const responsive = el(state, 'image-responsive'); + const submitBtn = el(state, 'insert-image-submit'); + if (directUrl) directUrl.value = state.preparedImage.url; + if (preview) preview.src = state.preparedImage.url; + if (imageOptions) imageOptions.classList.remove('d-none'); + if (renderType) renderType.value = figure ? 'figure' : 'img'; + if (captionGroup) captionGroup.classList.toggle('d-none', !figure); + if (alignmentEl) { + alignmentEl.value = figure + ? (figure.classList.contains('text-end') ? 'end' : figure.classList.contains('text-start') ? 'start' : figure.classList.contains('d-inline-block') ? 'inline' : 'center') + : (image.classList.contains('ms-auto') ? 'end' : image.classList.contains('mx-auto') ? 'center' : image.classList.contains('d-inline-block') ? 'inline' : 'start'); } + if (altText) altText.value = image.alt; + if (decorativeEl) decorativeEl.checked = !image.alt; + if (altText) altText.disabled = !image.alt; + if (captionEl) captionEl.value = figure && figure.querySelector('figcaption') ? figure.querySelector('figcaption').textContent : ''; + if (widthInput) widthInput.value = imageWidthValue; + if (heightInput) heightInput.value = imageHeightValue; + if (aspectLock) aspectLock.checked = true; + if (titleText) titleText.value = image.title || ''; + if (responsive) responsive.checked = image.classList.contains('img-fluid'); + if (submitBtn) submitBtn.disabled = false; } +/** Creates the requested image or figure element and inserts it at the saved editor range. */ +function insertPreparedImage(state) { + const widthInput = el(state, 'image-width'); + const heightInput = el(state, 'image-height'); + const width = Number(widthInput && widthInput.value); + const height = Number(heightInput && heightInput.value); + const decorativeEl = el(state, 'image-decorative'); + const decorative = decorativeEl ? decorativeEl.checked : false; + const altText = el(state, 'image-alt-text'); + const renderType = el(state, 'image-render-type'); + const alignmentEl = el(state, 'image-alignment'); + const captionEl = el(state, 'image-caption'); + const titleText = el(state, 'image-title-text'); + const responsive = el(state, 'image-responsive'); + + if (!state.preparedImage) { + showImageFeedback(state, 'Preview an image before inserting it.'); + return; + } + if ((!state.imageBeingEdited || !state.editor.contains(state.imageBeingEdited)) + && (!state.savedRange || !state.editor.contains(state.savedRange.commonAncestorContainer))) { + showImageFeedback(state, 'Close this dialog, place the cursor where the image belongs, then open Image again.'); + return; + } + if (!decorative && altText && !altText.value.trim()) { + altText.classList.add('is-invalid'); + return; + } + if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) { + showImageFeedback(state, 'Enter whole-number width and height values greater than zero.'); + return; + } + + const image = document.createElement('img'); + image.src = state.preparedImage.url; + image.alt = decorative ? '' : (altText ? altText.value.trim() : ''); + image.width = width; + image.height = height; + if (titleText && titleText.value.trim()) image.title = titleText.value.trim(); + if (responsive && responsive.checked) image.classList.add('img-fluid'); + + let content = image; + const renderTypeValue = renderType ? renderType.value : 'figure'; + const alignmentValue = alignmentEl ? alignmentEl.value : 'center'; + + if (renderTypeValue === 'figure') { + const figure = document.createElement('figure'); + figure.className = 'figure ' + (alignmentValue === 'center' ? 'd-block text-center' + : alignmentValue === 'start' ? 'd-block text-start' + : alignmentValue === 'end' ? 'd-block text-end' + : 'd-inline-block'); + image.classList.add('figure-img', 'mb-2'); + figure.appendChild(image); + if (captionEl && captionEl.value.trim()) { + const caption = document.createElement('figcaption'); + caption.className = 'figure-caption'; + caption.textContent = captionEl.value.trim(); + figure.appendChild(caption); + } + content = figure; + } else if (alignmentValue === 'center') { + image.classList.add('d-block', 'mx-auto'); + } else if (alignmentValue === 'start') { + image.classList.add('d-block'); + } else if (alignmentValue === 'end') { + image.classList.add('d-block', 'ms-auto'); + } else { + image.classList.add('d-inline-block'); + } + + recordEditorState(state); + const selection = window.getSelection(); + const afterRange = document.createRange(); + + if (state.imageBeingEdited && state.editor.contains(state.imageBeingEdited)) { + const replacedContent = state.imageBeingEdited.closest('figure') || state.imageBeingEdited; + replacedContent.replaceWith(content); + } else { + restoreSelection(state); + const range = state.savedRange.cloneRange(); + range.deleteContents(); + range.insertNode(content); + } + + afterRange.setStartAfter(content); + afterRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(afterRange); + state.savedRange = afterRange.cloneRange(); + rememberSelection(state); + notifyEditorChange(state); + + const modal = getModal(state, 'insert-image-modal'); + if (modal) modal.instance.hide(); +} + +// ─── Image modal ────────────────────────────────────────────────────────────── + +/** + * Opens the image modal in insert or edit mode. + * Modal element ID convention: {editorId}-insert-image-modal + * All image-related element IDs follow the pattern {editorId}-{field-name}, e.g.: + * {editorId}-image-direct-url {editorId}-image-alt-text + * {editorId}-image-upload-file {editorId}-image-width / -image-height + * {editorId}-image-render-type {editorId}-image-alignment + * {editorId}-image-aspect-lock {editorId}-image-responsive + * {editorId}-image-caption {editorId}-image-title-text + * {editorId}-image-decorative {editorId}-image-feedback + * {editorId}-image-preview {editorId}-image-options + * {editorId}-image-caption-group {editorId}-insert-image-submit + * {editorId}-insert-image-title {editorId}-image-url-tab + * {editorId}-upload-image-file {editorId}-validate-image-url + * {editorId}-image-upload-progress {editorId}-image-upload-progress-bar + * {editorId}-image-response-mode {editorId}-image-response-path-group + * {editorId}-image-extension-whitelist {editorId}-image-upload-file-field + * {editorId}-image-upload-endpoint {editorId}-image-response-path + * {editorId}-image-max-size {editorId}-image-size-message + * {editorId}-image-upload-fields {editorId}-image-upload-headers + * {editorId}-insert-image-form + */ +function openInsertImageModal(state) { + const modal = getModal(state, 'insert-image-modal'); + if (!modal) return; + + const imageToEdit = getImageForEditing(state); + resetImageModal(state); + + if (imageToEdit) { + state.imageBeingEdited = imageToEdit; + const titleEl = el(state, 'insert-image-title'); + const submitBtn = el(state, 'insert-image-submit'); + if (titleEl) titleEl.textContent = 'Edit image'; + if (submitBtn) submitBtn.textContent = 'Save image'; + loadImageForEditing(state, imageToEdit); + const imageUrlTab = el(state, 'image-url-tab'); + if (imageUrlTab && typeof bootstrap !== 'undefined') { + bootstrap.Tab.getOrCreateInstance(imageUrlTab).show(); + } + } + + if (!modal.element._rteHandlersAttached) { + modal.element._rteHandlersAttached = true; + + const validateUrlBtn = el(state, 'validate-image-url'); + const uploadFileBtn = el(state, 'upload-image-file'); + const responseModeEl = el(state, 'image-response-mode'); + const responsePathGroupEl = el(state, 'image-response-path-group'); + const extensionWhitelistEl = el(state, 'image-extension-whitelist'); + const decorativeEl = el(state, 'image-decorative'); + const altTextEl = el(state, 'image-alt-text'); + const renderTypeEl = el(state, 'image-render-type'); + const captionGroupEl = el(state, 'image-caption-group'); + const widthEl = el(state, 'image-width'); + const heightEl = el(state, 'image-height'); + const form = modal.element.querySelector('form'); + + if (validateUrlBtn) { + validateUrlBtn.addEventListener('click', async () => { + try { + const directUrl = el(state, 'image-direct-url'); + await prepareImagePreview(state, directUrl ? directUrl.value : ''); + } catch (err) { + showImageFeedback(state, err.message); + } + }); + } + if (uploadFileBtn) { + uploadFileBtn.addEventListener('click', () => uploadSelectedImage(state)); + } + if (responseModeEl && responsePathGroupEl) { + responseModeEl.addEventListener('change', () => { + responsePathGroupEl.classList.toggle('d-none', responseModeEl.value !== 'json'); + }); + } + if (extensionWhitelistEl) { + extensionWhitelistEl.addEventListener('input', () => { + const fileInput = el(state, 'image-upload-file'); + if (fileInput) fileInput.accept = getAllowedImageExtensions(state).map((ext) => '.' + ext).join(','); + }); + } + if (decorativeEl && altTextEl) { + decorativeEl.addEventListener('change', () => { + altTextEl.disabled = decorativeEl.checked; + altTextEl.classList.remove('is-invalid'); + }); + } + if (altTextEl) { + altTextEl.addEventListener('input', () => altTextEl.classList.remove('is-invalid')); + } + if (renderTypeEl && captionGroupEl) { + renderTypeEl.addEventListener('change', () => { + captionGroupEl.classList.toggle('d-none', renderTypeEl.value !== 'figure'); + }); + } + if (widthEl) widthEl.addEventListener('input', () => updateImageAspectRatio(state, 'width')); + if (heightEl) heightEl.addEventListener('input', () => updateImageAspectRatio(state, 'height')); + + if (form) { + form.addEventListener('submit', (event) => { + event.preventDefault(); + insertPreparedImage(state); + }); + } + + modal.element.addEventListener('hidden.bs.modal', () => { + state.imageBeingEdited = null; + state.activeEditorImage = null; + }); + } + + modal.instance.show(); +} + +// ─── Print ──────────────────────────────────────────────────────────────────── + +/** Uses the native print dialog while temporarily showing only the editor content. */ +function printEditorDocument(state) { + const editor = state.editor; + const editorSurface = editor.closest('section'); + const toolbar = editorSurface && editorSurface.querySelector('[role="toolbar"]'); + const footer = editorSurface && editorSurface.querySelector('.card-footer'); + const hiddenElements = [toolbar, footer].filter(Boolean).map((element) => ({ element, wasHidden: element.hidden })); + const removedClasses = editorSurface ? ['card', 'shadow-sm'].filter((cls) => editorSurface.classList.contains(cls)) : []; + const classChanges = []; + const colorAdjustmentChanges = []; + const editorAttributes = { + contenteditable: editor.getAttribute('contenteditable'), + role: editor.getAttribute('role'), + ariaMultiline: editor.getAttribute('aria-multiline') + }; + const adjustPrintClasses = (element, addClasses, removeClasses = []) => { + if (!element) return; + const managedClasses = [...new Set([...addClasses, ...removeClasses])]; + classChanges.push({ element, managedClasses, originalClasses: managedClasses.filter((cls) => element.classList.contains(cls)) }); + element.classList.remove(...removeClasses); + element.classList.add(...addClasses); + }; + adjustPrintClasses(editor, ['p-2'], ['p-4']); + editor.querySelectorAll('.table-responsive').forEach((e) => adjustPrintClasses(e, [], ['table-responsive'])); + editor.querySelectorAll('table').forEach((e) => adjustPrintClasses(e, ['table-sm', 'small', 'text-break'])); + editor.querySelectorAll('pre').forEach((e) => adjustPrintClasses(e, ['text-wrap', 'text-break'])); + editor.querySelectorAll('img').forEach((e) => adjustPrintClasses(e, ['img-fluid'])); + editor.removeAttribute('contenteditable'); + editor.removeAttribute('role'); + editor.removeAttribute('aria-multiline'); + [editor, ...editor.querySelectorAll('*')].forEach((element) => { + colorAdjustmentChanges.push({ + element, + printColorAdjust: element.style.getPropertyValue('print-color-adjust'), + printColorAdjustPriority: element.style.getPropertyPriority('print-color-adjust'), + webkitPrintColorAdjust: element.style.getPropertyValue('-webkit-print-color-adjust'), + webkitPrintColorAdjustPriority: element.style.getPropertyPriority('-webkit-print-color-adjust') + }); + element.style.setProperty('print-color-adjust', 'exact', 'important'); + element.style.setProperty('-webkit-print-color-adjust', 'exact', 'important'); + }); + let restored = false; + const restoreEditorView = () => { + if (restored) return; + restored = true; + hiddenElements.forEach(({ element, wasHidden }) => { element.hidden = wasHidden; }); + if (editorSurface) editorSurface.classList.add(...removedClasses); + classChanges.forEach(({ element, managedClasses, originalClasses }) => { + element.classList.remove(...managedClasses); + element.classList.add(...originalClasses); + }); + colorAdjustmentChanges.forEach(({ element, printColorAdjust, printColorAdjustPriority, webkitPrintColorAdjust, webkitPrintColorAdjustPriority }) => { + element.style.setProperty('print-color-adjust', printColorAdjust, printColorAdjustPriority); + element.style.setProperty('-webkit-print-color-adjust', webkitPrintColorAdjust, webkitPrintColorAdjustPriority); + }); + if (editorAttributes.contenteditable !== null) editor.setAttribute('contenteditable', editorAttributes.contenteditable); + if (editorAttributes.role !== null) editor.setAttribute('role', editorAttributes.role); + if (editorAttributes.ariaMultiline !== null) editor.setAttribute('aria-multiline', editorAttributes.ariaMultiline); + }; + hiddenElements.forEach(({ element }) => { element.hidden = true; }); + if (editorSurface) editorSurface.classList.remove(...removedClasses); + window.addEventListener('afterprint', restoreEditorView, { once: true }); + try { + window.focus(); + window.print(); + window.setTimeout(restoreEditorView, 1000); + } catch (err) { + restoreEditorView(); + } +} + +// ─── Toolbar click handler ──────────────────────────────────────────────────── + +function handleToolbarClick(state, event) { + const button = event.target.closest('button'); + if (!button) return; + + if (button.dataset.editorCommand) { + if (button.dataset.editorCommand === 'undo') { + restoreEditorHistory(state, state.editorUndoStates, state.editorRedoStates); + return; + } + if (button.dataset.editorCommand === 'redo') { + restoreEditorHistory(state, state.editorRedoStates, state.editorUndoStates); + return; + } + executeCommand(state, button.dataset.editorCommand); + } else if (button.dataset.editorBlock) { + selectBlock(state, button.dataset.editorBlock); + } else if (button.dataset.editorFont) { + executeCommand(state, 'fontName', button.dataset.editorFont); + } else if (button.dataset.editorSize) { + executeCommand(state, 'fontSize', button.dataset.editorSize); + } else if (button.dataset.editorColor) { + state.selectedTextColor = button.dataset.editorColor; + const indicator = state.container && state.container.querySelector('[data-rte-text-color-indicator]'); + if (indicator && button.dataset.editorIndicator) { + indicator.className = 'position-absolute bottom-0 start-0 end-0 border-bottom border-3 ' + button.dataset.editorIndicator; + } + executeCommand(state, 'foreColor', button.dataset.editorColor); + } else if (button.dataset.editorHighlight) { + state.selectedHighlightColor = button.dataset.editorHighlight; + const indicator = state.container && state.container.querySelector('[data-rte-highlight-color-indicator]'); + if (indicator && button.dataset.editorIndicator) { + indicator.className = 'position-absolute bottom-0 start-0 end-0 border-bottom border-3 ' + button.dataset.editorIndicator; + } + executeCommand(state, 'hiliteColor', button.dataset.editorHighlight); + } else if (button.dataset.editorTable) { + const [rows, columns] = button.dataset.editorTable.split('x').map(Number); + insertTable(state, rows, columns); + } else if (button.dataset.editorTableAction) { + applyTableAction(state, button.dataset.editorTableAction); + } else if (button.dataset.editorTableStyle) { + applyTableStyle(state, button.dataset.editorTableStyle); + } else if (button.dataset.editorTableAlign) { + alignTableCell(state, button.dataset.editorTableAlign); + } else if (button.dataset.editorAction === 'apply-text-color') { + executeCommand(state, 'foreColor', state.selectedTextColor); + } else if (button.dataset.editorAction === 'apply-highlight-color') { + executeCommand(state, 'hiliteColor', state.selectedHighlightColor); + } else if (button.dataset.editorAction === 'print') { + printEditorDocument(state); + } else if (button.dataset.editorAction === 'link') { + openInsertLinkModal(state); + } else if (button.dataset.editorAction === 'image') { + openInsertImageModal(state); + } else if (button.dataset.editorAction === 'insert-table') { + openInsertTableModal(state); + } else if (button.dataset.editorAction === 'fullscreen') { + const surface = state.editor.closest('section'); + if (document.fullscreenElement) { + document.exitFullscreen(); + } else if (surface && surface.requestFullscreen) { + surface.requestFullscreen(); + } + } +} + +// ─── Exported module API ────────────────────────────────────────────────────── + export function dispose(dotNetHelper, editorId) { - console.log("blazor.bootstrap.rich-text-editor.js disposed"); + const state = getEditorState(editorId); + if (!state) return; + + if (state.toolbar) { + if (state._toolbarPointerHandler) state.toolbar.removeEventListener('pointerdown', state._toolbarPointerHandler); + if (state._toolbarClickHandler) state.toolbar.removeEventListener('click', state._toolbarClickHandler); + } + if (state.editor) { + if (state._editorBeforeInputHandler) state.editor.removeEventListener('beforeinput', state._editorBeforeInputHandler); + if (state._editorInputHandler) state.editor.removeEventListener('input', state._editorInputHandler); + if (state._editorSelectionHandler) { + state.editor.removeEventListener('mouseup', state._editorSelectionHandler); + state.editor.removeEventListener('keyup', state._editorSelectionHandler); + } + if (state._imageClickHandler) state.editor.removeEventListener('click', state._imageClickHandler); + } + + delete window.blazorBootstrap.richTextEditor[editorId]; } export function execute(dotNetHelper, editorId, elementId, command, value) { - console.log("blazor.bootstrap.rich-text-editor.js executed"); + const state = getEditorState(editorId); + if (!state) return; + executeCommand(state, command, value || null); } export function focus(dotNetHelper, editorId) { - console.log("blazor.bootstrap.rich-text-editor.js focused"); + const state = getEditorState(editorId); + if (!state) return; + state.editor.focus(); } export function initialize(dotNetHelper, editorId) { - let editorEl = getOrCreate(editorId); - if (!editorEl && !editorEl.editor) { - dotNetHelper.invokeMethodAsync('OnEditorValueChangedAsync', ""); // TODO: Send the editor's value to the .NET side + const state = createEditorState(editorId, dotNetHelper); + if (!state || !state.editor) { + dotNetHelper.invokeMethodAsync('OnEditorValueChangedAsync', ''); return; } + + // Toolbar listeners + if (state.toolbar) { + state._toolbarPointerHandler = () => rememberSelection(state); + state._toolbarClickHandler = (event) => handleToolbarClick(state, event); + state.toolbar.addEventListener('pointerdown', state._toolbarPointerHandler); + state.toolbar.addEventListener('click', state._toolbarClickHandler); + } + + // Editor content listeners + state._editorBeforeInputHandler = () => recordEditorState(state); + state._editorInputHandler = () => { + rememberSelection(state); + notifyEditorChange(state); + }; + state._editorSelectionHandler = () => rememberSelection(state); + state.editor.addEventListener('beforeinput', state._editorBeforeInputHandler); + state.editor.addEventListener('input', state._editorInputHandler); + state.editor.addEventListener('mouseup', state._editorSelectionHandler); + state.editor.addEventListener('keyup', state._editorSelectionHandler); + + // Track the active image for the image modal (edit mode) + state._imageClickHandler = (event) => { + const img = event.target.closest('img'); + state.activeEditorImage = img && state.editor.contains(img) ? img : null; + }; + state.editor.addEventListener('click', state._imageClickHandler); + + // Send the initial editor value to .NET + dotNetHelper.invokeMethodAsync('OnEditorValueChangedAsync', state.editor.innerHTML.replace(/\u200B/g, '')); } \ No newline at end of file From c4ddd3760f081fe2ac1ba38cb8868f85c6e135cf Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Mon, 17 Aug 2026 00:19:55 +0530 Subject: [PATCH 19/53] Standardize and simplify JS comments for consistency Refactored comments in blazor.bootstrap.rich-text-editor.js to use all-caps section headers and concise, single-line function descriptions. Replaced stylized and JSDoc-style comments for improved readability and consistency. No changes to code logic or functionality. --- .../blazor.bootstrap.rich-text-editor.js | 157 ++++++++---------- 1 file changed, 68 insertions(+), 89 deletions(-) diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index b2cbb17fb..5d9ddf847 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -1,10 +1,10 @@ -window.blazorBootstrap = window.blazorBootstrap || {}; +window.blazorBootstrap = window.blazorBootstrap || {}; window.blazorBootstrap.richTextEditor = window.blazorBootstrap.richTextEditor || {}; // Font size label map shared across all editor instances const _fontSizeLabels = { 1: '10 px', 2: '12 px', 3: '14 px', 4: '16 px', 5: '18 px', 6: '24 px', 7: '32 px' }; -// ─── Per-editor state ───────────────────────────────────────────────────────── +// PER-EDITOR STATE function getEditorState(editorId) { return window.blazorBootstrap.richTextEditor[editorId]; @@ -48,21 +48,21 @@ function createEditorState(editorId, dotNetHelper) { return state; } -// ─── DOM lookup helpers ─────────────────────────────────────────────────────── +// DOM LOOKUP HELPERS -/** Finds an element with ID = editorId + '-' + suffix. */ +// Finds an element with ID = editorId + '-' + suffix. function el(state, suffix) { return document.getElementById(state.editorId + '-' + suffix); } -/** Returns a Bootstrap Modal instance for a modal whose ID is editorId + '-' + suffix. */ +// Returns a Bootstrap Modal instance for a modal whose ID is editorId + '-' + suffix. function getModal(state, suffix) { const modalEl = el(state, suffix); if (!modalEl || typeof bootstrap === 'undefined') return null; return { instance: bootstrap.Modal.getOrCreateInstance(modalEl), element: modalEl }; } -// ─── Selection helpers ──────────────────────────────────────────────────────── +// SELECTION HELPERS function getRangeElement(container) { return container.nodeType === Node.TEXT_NODE ? container.parentElement : container; @@ -76,7 +76,7 @@ function getSelectionElement(state) { return node instanceof Element && state.editor.contains(node) ? node : null; } -/** Stores the editor selection before a toolbar interaction can remove it. */ +// Stores the editor selection before a toolbar interaction can remove it. function rememberSelection(state) { const selection = window.getSelection(); if (selection && selection.rangeCount && state.editor.contains(selection.anchorNode)) { @@ -84,7 +84,7 @@ function rememberSelection(state) { } } -/** Returns focus and the saved selection to the editable area. */ +// Returns focus and the saved selection to the editable area. function restoreSelection(state) { state.editor.focus(); if (state.savedRange) { @@ -94,7 +94,7 @@ function restoreSelection(state) { } } -/** Returns the saved range only when it still belongs to this editor. */ +// Returns the saved range only when it still belongs to this editor. function getSavedEditorRange(state) { if (!state.savedRange || !state.editor.contains(state.savedRange.commonAncestorContainer)) { return null; @@ -102,7 +102,7 @@ function getSavedEditorRange(state) { return state.savedRange.cloneRange(); } -/** Moves the browser selection to a range and keeps the editor cache in sync. */ +// Moves the browser selection to a range and keeps the editor cache in sync. function setEditorRange(state, range) { const selection = window.getSelection(); selection.removeAllRanges(); @@ -110,7 +110,7 @@ function setEditorRange(state, range) { state.savedRange = range.cloneRange(); } -/** Finds all editable blocks touched by a range, or the current block for a collapsed range. */ +// Finds all editable blocks touched by a range, or the current block for a collapsed range. function getRangeBlocks(state, range) { const blockSelector = 'p, h1, h2, h3, h4, h5, h6, li, td, th, blockquote, pre'; if (range.collapsed) { @@ -127,9 +127,9 @@ function getRangeBlocks(state, range) { }); } -// ─── Undo / Redo ────────────────────────────────────────────────────────────── +// UNDO / REDO -/** Saves the current document state for Undo before a user-visible edit. */ +// Saves the current document state for Undo before a user-visible edit. function recordEditorState(state) { if (!state.editorUndoStates.length || state.editorUndoStates[state.editorUndoStates.length - 1] !== state.editor.innerHTML) { state.editorUndoStates.push(state.editor.innerHTML); @@ -138,7 +138,7 @@ function recordEditorState(state) { state.editorRedoStates = []; } -/** Restores one saved state and moves the current state to the opposite stack. */ +// Restores one saved state and moves the current state to the opposite stack. function restoreEditorHistory(state, fromStates, toStates) { if (!fromStates.length) return false; toStates.push(state.editor.innerHTML); @@ -155,9 +155,9 @@ function restoreEditorHistory(state, fromStates, toStates) { return true; } -// ─── Change notification ────────────────────────────────────────────────────── +// CHANGE NOTIFICATION -/** Notifies the .NET side whenever the editor document changes. */ +// Notifies the .NET side whenever the editor document changes. function notifyEditorChange(state) { if (state.dotNetHelper) { const html = state.editor.innerHTML.replace(/\u200B/g, ''); @@ -165,7 +165,7 @@ function notifyEditorChange(state) { } } -// ─── Inline formatting ──────────────────────────────────────────────────────── +// INLINE FORMATTING function unwrapElement(element) { const fragment = document.createDocumentFragment(); @@ -173,7 +173,7 @@ function unwrapElement(element) { element.replaceWith(fragment); } -/** Tests whether the selection is fully inside one matching inline wrapper. */ +// Tests whether the selection is fully inside one matching inline wrapper. function getMatchingInlineWrapper(state, range, matcher) { const startElement = getRangeElement(range.startContainer); const endElement = getRangeElement(range.endContainer); @@ -184,7 +184,7 @@ function getMatchingInlineWrapper(state, range, matcher) { : null; } -/** Inserts an inline wrapper using Selection/Range APIs. */ +// Inserts an inline wrapper using Selection/Range APIs. function applyInlineFormat(state, createWrapper, matcher) { restoreSelection(state); const range = getSavedEditorRange(state); @@ -221,14 +221,14 @@ function applyInlineFormat(state, createWrapper, matcher) { notifyEditorChange(state); } -/** Converts a hex color to its DOM-style rgb() serialization for state checks. */ +// Converts a hex color to its DOM-style rgb() serialization for state checks. function rgbFromHex(value) { if (!/^#[0-9a-f]{6}$/i.test(value || '')) return value; const n = Number.parseInt(value.slice(1), 16); return 'rgb(' + ((n >> 16) & 255) + ', ' + ((n >> 8) & 255) + ', ' + (n & 255) + ')'; } -/** Applies a semantic tag or standard inline style to the selected text. */ +// Applies a semantic tag or standard inline style to the selected text. function applyInlineCommand(state, command, value) { const semanticCommands = { bold: { tag: 'strong', match: (e) => e.tagName === 'STRONG' || e.tagName === 'B' }, @@ -277,7 +277,7 @@ function applyInlineCommand(state, command, value) { }, def.match); } -/** Removes known inline formatting within the selection. */ +// Removes known inline formatting within the selection. function clearInlineFormatting(state) { restoreSelection(state); const range = getSavedEditorRange(state); @@ -296,9 +296,9 @@ function clearInlineFormatting(state) { notifyEditorChange(state); } -// ─── Block-level commands ───────────────────────────────────────────────────── +// BLOCK-LEVEL COMMANDS -/** Applies paragraph alignment to all blocks in the selection. */ +// Applies paragraph alignment to all blocks in the selection. function applyAlignment(state, value) { restoreSelection(state); const range = getSavedEditorRange(state); @@ -310,7 +310,7 @@ function applyAlignment(state, value) { notifyEditorChange(state); } -/** Adjusts block indentation through its standard inline style. */ +// Adjusts block indentation through its standard inline style. function changeIndent(state, direction) { restoreSelection(state); const range = getSavedEditorRange(state); @@ -326,7 +326,7 @@ function changeIndent(state, direction) { notifyEditorChange(state); } -/** Turns the current block into a list item, or removes the existing list type. */ +// Turns the current block into a list item, or removes the existing list type. function toggleList(state, listTag) { restoreSelection(state); const range = getSavedEditorRange(state); @@ -377,7 +377,7 @@ function toggleList(state, listTag) { notifyEditorChange(state); } -/** Inserts a horizontal rule and a following paragraph at the selection. */ +// Inserts a horizontal rule and a following paragraph at the selection. function insertHorizontalRule(state) { restoreSelection(state); const range = getSavedEditorRange(state); @@ -398,7 +398,7 @@ function insertHorizontalRule(state) { notifyEditorChange(state); } -/** Toggles blockquote wrapping on the current paragraph or heading. */ +// Toggles blockquote wrapping on the current paragraph or heading. function toggleBlockQuote(state) { if (!state.savedRange || !state.editor.contains(state.savedRange.commonAncestorContainer)) return; const startElement = getRangeElement(state.savedRange.startContainer); @@ -431,7 +431,7 @@ function toggleBlockQuote(state) { notifyEditorChange(state); } -/** Replaces the current text block with a semantic block element. */ +// Replaces the current text block with a semantic block element. function selectBlock(state, block) { if (block === 'blockquote') { toggleBlockQuote(state); @@ -460,9 +460,9 @@ function selectBlock(state, block) { notifyEditorChange(state); } -// ─── Command router ─────────────────────────────────────────────────────────── +// COMMAND ROUTER -/** Routes toolbar commands to the appropriate inline or block implementation. */ +// Routes toolbar commands to the appropriate inline or block implementation. function executeCommand(state, command, value = null) { if (['bold', 'italic', 'underline', 'strikeThrough', 'fontName', 'fontSize', 'foreColor', 'hiliteColor'].includes(command)) { applyInlineCommand(state, command, value); @@ -489,9 +489,9 @@ function executeCommand(state, command, value = null) { } } -// ─── Link helpers ───────────────────────────────────────────────────────────── +// LINK HELPERS -/** Normalizes allowed link formats and rejects unsafe or malformed URLs. */ +// Normalizes allowed link formats and rejects unsafe or malformed URLs. function normalizeLinkUrl(value) { const rawValue = value.trim(); if (!rawValue) return null; @@ -508,7 +508,7 @@ function normalizeLinkUrl(value) { } } -/** Confirms that a link selection stays within one editable text block. */ +// Confirms that a link selection stays within one editable text block. function isLinkSelectionSafe(state, range) { const startElement = getRangeElement(range.startContainer); const endElement = getRangeElement(range.endContainer); @@ -519,7 +519,7 @@ function isLinkSelectionSafe(state, range) { && !endElement.closest('pre, code'); } -/** Finds one existing editor link when the stored selection is inside it. */ +// Finds one existing editor link when the stored selection is inside it. function getLinkAtSelection(state) { if (!state.savedRange || !state.editor.contains(state.savedRange.commonAncestorContainer)) return null; const startLink = getRangeElement(state.savedRange.startContainer).closest('a'); @@ -527,7 +527,7 @@ function getLinkAtSelection(state) { return startLink && startLink === endLink && state.editor.contains(startLink) ? startLink : null; } -/** Inserts a safe link at the saved selection. */ +// Inserts a safe link at the saved selection. function insertLink(state, url, text, openInNewTab) { restoreSelection(state); const range = getSavedEditorRange(state); @@ -553,7 +553,7 @@ function insertLink(state, url, text, openInNewTab) { notifyEditorChange(state); } -/** Saves edits to an existing link in the editor. */ +// Saves edits to an existing link in the editor. function updateLink(state, link, url, text, openInNewTab) { recordEditorState(state); link.setAttribute('href', url); @@ -576,15 +576,11 @@ function updateLink(state, link, url, text, openInNewTab) { notifyEditorChange(state); } -// ─── Link modal ─────────────────────────────────────────────────────────────── +// LINK MODAL -/** - * Opens the link modal for inserting or editing a link. - * Modal element ID convention: {editorId}-insert-link-modal - * Form field IDs: {editorId}-insert-link-url - * {editorId}-insert-link-text - * {editorId}-insert-link-new-tab - */ +// Opens the link modal for inserting or editing a link. +// Modal ID: {editorId}-insert-link-modal +// Fields: {editorId}-insert-link-url, -insert-link-text, -insert-link-new-tab function openInsertLinkModal(state) { if (!state.savedRange || !state.editor.contains(state.savedRange.commonAncestorContainer)) return; if (!isLinkSelectionSafe(state, state.savedRange)) return; @@ -640,9 +636,9 @@ function openInsertLinkModal(state) { modal.instance.show(); } -// ─── Table helpers ──────────────────────────────────────────────────────────── +// TABLE HELPERS -/** Resolves the active table cell from the live selection or the preserved cell. */ +// Resolves the active table cell from the live selection or the preserved cell. function getTableContext(state) { restoreSelection(state); const element = getSelectionElement(state); @@ -657,7 +653,7 @@ function getTableContext(state) { return { table, row, cell }; } -/** Moves the selection into a specific table cell. */ +// Moves the selection into a specific table cell. function selectTableCell(state, cell) { if (!cell || !state.editor.contains(cell)) { state.activeTableCell = null; @@ -673,7 +669,7 @@ function selectTableCell(state, cell) { state.activeTableCell = cell; } -/** Repairs empty or incomplete rows so table actions always work with a rectangular grid. */ +// Repairs empty or incomplete rows so table actions always work with a rectangular grid. function normalizeTableRows(table) { const rows = Array.from(table.rows); const columnCount = Math.max(0, ...rows.map((r) => r.cells.length)); @@ -791,7 +787,7 @@ function alignTableCell(state, alignment) { rememberSelection(state); } -/** Inserts a bordered data table with a semantic header row. */ +// Inserts a bordered data table with a semantic header row. function insertTable(state, rows, columns) { restoreSelection(state); const range = getSavedEditorRange(state); @@ -832,14 +828,11 @@ function insertTable(state, rows, columns) { notifyEditorChange(state); } -// ─── Table modal ────────────────────────────────────────────────────────────── +// TABLE MODAL -/** - * Opens the table-dimensions modal. - * Modal element ID convention: {editorId}-insert-table-modal - * Form field IDs: {editorId}-insert-table-rows - * {editorId}-insert-table-columns - */ +// Opens the table-dimensions modal. +// Modal ID: {editorId}-insert-table-modal +// Fields: {editorId}-insert-table-rows, -insert-table-columns function openInsertTableModal(state) { const modal = getModal(state, 'insert-table-modal'); if (!modal) return; @@ -878,7 +871,7 @@ function openInsertTableModal(state) { modal.instance.show(); } -// ─── Image helpers ──────────────────────────────────────────────────────────── +// IMAGE HELPERS function normalizeHttpUrl(value) { try { @@ -1143,7 +1136,7 @@ function resetImageModal(state) { clearImageFeedback(state); } -/** Finds an editor image from a click or a saved selection, when available. */ +// Finds an editor image from a click or a saved selection, when available. function getImageForEditing(state) { if (state.activeEditorImage && state.editor.contains(state.activeEditorImage)) return state.activeEditorImage; if (!state.savedRange || !state.editor.contains(state.savedRange.commonAncestorContainer)) return null; @@ -1159,7 +1152,7 @@ function getImageForEditing(state) { return null; } -/** Pre-fills the image dialog from an existing editor image without changing its source. */ +// Pre-fills the image dialog from an existing editor image without changing its source. function loadImageForEditing(state, image) { const figure = image.closest('figure'); const imageWidthValue = Number(image.getAttribute('width')) || image.naturalWidth || image.width || 1; @@ -1206,7 +1199,7 @@ function loadImageForEditing(state, image) { if (submitBtn) submitBtn.disabled = false; } -/** Creates the requested image or figure element and inserts it at the saved editor range. */ +// Creates the requested image or figure element and inserts it at the saved editor range. function insertPreparedImage(state) { const widthInput = el(state, 'image-width'); const heightInput = el(state, 'image-height'); @@ -1302,30 +1295,16 @@ function insertPreparedImage(state) { if (modal) modal.instance.hide(); } -// ─── Image modal ────────────────────────────────────────────────────────────── - -/** - * Opens the image modal in insert or edit mode. - * Modal element ID convention: {editorId}-insert-image-modal - * All image-related element IDs follow the pattern {editorId}-{field-name}, e.g.: - * {editorId}-image-direct-url {editorId}-image-alt-text - * {editorId}-image-upload-file {editorId}-image-width / -image-height - * {editorId}-image-render-type {editorId}-image-alignment - * {editorId}-image-aspect-lock {editorId}-image-responsive - * {editorId}-image-caption {editorId}-image-title-text - * {editorId}-image-decorative {editorId}-image-feedback - * {editorId}-image-preview {editorId}-image-options - * {editorId}-image-caption-group {editorId}-insert-image-submit - * {editorId}-insert-image-title {editorId}-image-url-tab - * {editorId}-upload-image-file {editorId}-validate-image-url - * {editorId}-image-upload-progress {editorId}-image-upload-progress-bar - * {editorId}-image-response-mode {editorId}-image-response-path-group - * {editorId}-image-extension-whitelist {editorId}-image-upload-file-field - * {editorId}-image-upload-endpoint {editorId}-image-response-path - * {editorId}-image-max-size {editorId}-image-size-message - * {editorId}-image-upload-fields {editorId}-image-upload-headers - * {editorId}-insert-image-form - */ +// IMAGE MODAL + +// Opens the image modal in insert or edit mode. +// Modal ID: {editorId}-insert-image-modal +// All image field IDs use the pattern {editorId}-{field-name}, e.g. {editorId}-image-direct-url, +// -image-alt-text, -image-width, -image-height, -image-render-type, -image-alignment, +// -image-aspect-lock, -image-responsive, -image-caption, -image-decorative, -image-feedback, +// -image-preview, -image-options, -insert-image-submit, -insert-image-title, -image-url-tab, +// -upload-image-file, -validate-image-url, -image-upload-progress, -image-response-mode, +// -image-extension-whitelist, -image-upload-endpoint, -image-max-size, -insert-image-form function openInsertImageModal(state) { const modal = getModal(state, 'insert-image-modal'); if (!modal) return; @@ -1419,9 +1398,9 @@ function openInsertImageModal(state) { modal.instance.show(); } -// ─── Print ──────────────────────────────────────────────────────────────────── +// PRINT -/** Uses the native print dialog while temporarily showing only the editor content. */ +// Uses the native print dialog while temporarily showing only the editor content. function printEditorDocument(state) { const editor = state.editor; const editorSurface = editor.closest('section'); @@ -1492,7 +1471,7 @@ function printEditorDocument(state) { } } -// ─── Toolbar click handler ──────────────────────────────────────────────────── +// TOOLBAR CLICK HANDLER function handleToolbarClick(state, event) { const button = event.target.closest('button'); @@ -1559,7 +1538,7 @@ function handleToolbarClick(state, event) { } } -// ─── Exported module API ────────────────────────────────────────────────────── +// EXPORTED MODULE API export function dispose(dotNetHelper, editorId) { const state = getEditorState(editorId); From 832c33c0e16d0a2d721a012d95f51191815ae1e8 Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Mon, 17 Aug 2026 00:36:17 +0530 Subject: [PATCH 20/53] Refactor footer to use dynamic IDs and live updates Refactored the RichTextEditor footer to generate unique element IDs based on the editor's Id property, supporting multiple instances. Replaced static text with spans for character/word counts and context labels, which are now updated in real time via new JavaScript functions (updateFooter, updateFooterCounts, updateFooterContext) on content and selection changes. Footer state is initialized on editor load. --- .../Form/RichTextEditor/RichTextEditor.razor | 10 ++-- blazorbootstrap/Extensions/EnumExtensions.cs | 14 +++-- .../blazor.bootstrap.rich-text-editor.js | 60 +++++++++++++++++++ 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor index ddc4b0274..f19bfd074 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor @@ -153,13 +153,13 @@
- Paragraph - Inter, 14 px - Left aligned + Paragraph + Inter, 14 px + Left aligned
- 1,248 characters - 214 words + +
diff --git a/blazorbootstrap/Extensions/EnumExtensions.cs b/blazorbootstrap/Extensions/EnumExtensions.cs index 22c8443ec..a76bddcf1 100644 --- a/blazorbootstrap/Extensions/EnumExtensions.cs +++ b/blazorbootstrap/Extensions/EnumExtensions.cs @@ -375,7 +375,6 @@ public static IconName ToIconName(this RichTextEditorToolbarItem toolbarItem) => RichTextEditorToolbarItem.Italic => IconName.TypeItalic, RichTextEditorToolbarItem.Underline => IconName.TypeUnderline, RichTextEditorToolbarItem.Strikethrough => IconName.TypeStrikethrough, - RichTextEditorToolbarItem.ClearFormatting => IconName.Eraser, RichTextEditorToolbarItem.AlignLeft => IconName.TextLeft, RichTextEditorToolbarItem.AlignCenter => IconName.TextCenter, RichTextEditorToolbarItem.AlignRight => IconName.TextRight, @@ -384,12 +383,14 @@ public static IconName ToIconName(this RichTextEditorToolbarItem toolbarItem) => RichTextEditorToolbarItem.Outdent => IconName.TextIndentRight, RichTextEditorToolbarItem.OrderedList => IconName.ListOl, RichTextEditorToolbarItem.UnorderedList => IconName.ListUl, - RichTextEditorToolbarItem.Blockquote => IconName.Quote, - RichTextEditorToolbarItem.CodeBlock => IconName.Code, RichTextEditorToolbarItem.Link => IconName.Link45Deg, RichTextEditorToolbarItem.Image => IconName.Image, RichTextEditorToolbarItem.HorizontalRule => IconName.Hr, RichTextEditorToolbarItem.Table => IconName.Table, + RichTextEditorToolbarItem.Blockquote => IconName.Quote, + RichTextEditorToolbarItem.CodeBlock => IconName.Code, + RichTextEditorToolbarItem.ClearFormatting => IconName.Eraser, + RichTextEditorToolbarItem.Fullscreen => IconName.Fullscreen, _ => IconName.Type }; @@ -403,7 +404,6 @@ public static string ToIconLabel(this RichTextEditorToolbarItem toolbarItem) => RichTextEditorToolbarItem.Italic => "Italic", RichTextEditorToolbarItem.Underline => "Underline", RichTextEditorToolbarItem.Strikethrough => "Strikethrough", - RichTextEditorToolbarItem.ClearFormatting => "Clear formatting", RichTextEditorToolbarItem.AlignLeft => "Align left", RichTextEditorToolbarItem.AlignCenter => "Align center", RichTextEditorToolbarItem.AlignRight => "Align right", @@ -412,12 +412,14 @@ public static string ToIconLabel(this RichTextEditorToolbarItem toolbarItem) => RichTextEditorToolbarItem.Outdent => "Outdent", RichTextEditorToolbarItem.OrderedList => "Numbered list", RichTextEditorToolbarItem.UnorderedList => "Bulleted list", - RichTextEditorToolbarItem.Blockquote => "Blockquote", - RichTextEditorToolbarItem.CodeBlock => "Code block", RichTextEditorToolbarItem.Link => "Insert link", RichTextEditorToolbarItem.Image => "Insert image", RichTextEditorToolbarItem.HorizontalRule => "Insert horizontal rule", RichTextEditorToolbarItem.Table => "Insert table", + RichTextEditorToolbarItem.Blockquote => "Blockquote", + RichTextEditorToolbarItem.CodeBlock => "Code block", + RichTextEditorToolbarItem.ClearFormatting => "Clear formatting", + RichTextEditorToolbarItem.Fullscreen => "Fullscreen", _ => Regex.Replace(toolbarItem.ToString(), "([a-z])([A-Z])", "$1 $2") }; diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index 5d9ddf847..512f2d13c 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -82,6 +82,7 @@ function rememberSelection(state) { if (selection && selection.rangeCount && state.editor.contains(selection.anchorNode)) { state.savedRange = selection.getRangeAt(0).cloneRange(); } + updateFooterContext(state); } // Returns focus and the saved selection to the editable area. @@ -163,6 +164,62 @@ function notifyEditorChange(state) { const html = state.editor.innerHTML.replace(/\u200B/g, ''); state.dotNetHelper.invokeMethodAsync('OnEditorValueChangedAsync', html); } + updateFooterCounts(state); +} + +// FOOTER UPDATE + +// Updates the character/word counts and the block/font/alignment context in the footer. +function updateFooter(state) { + updateFooterCounts(state); + updateFooterContext(state); +} + +// Recalculates the character and word counts shown in the footer. +function updateFooterCounts(state) { + const characterCountEl = document.getElementById(state.editorId + '-footer-character-count'); + const wordCountEl = document.getElementById(state.editorId + '-footer-word-count'); + if (!characterCountEl && !wordCountEl) return; + const text = (state.editor.innerText || '').replace(/\u200B/g, '').trim(); + const characters = text.length; + const words = text ? text.split(/\s+/).length : 0; + if (characterCountEl) characterCountEl.textContent = characters.toLocaleString() + ' characters'; + if (wordCountEl) wordCountEl.textContent = words.toLocaleString() + ' words'; +} + +// Updates the block type, font/size, and alignment labels in the footer. +function updateFooterContext(state) { + const blockEl = document.getElementById(state.editorId + '-footer-block'); + const fontEl = document.getElementById(state.editorId + '-footer-font'); + const alignmentEl = document.getElementById(state.editorId + '-footer-alignment'); + if (!blockEl && !fontEl && !alignmentEl) return; + + const element = getSelectionElement(state); + + // Block label + if (blockEl) { + const block = element && element.closest('h1, h2, h3, p, blockquote, pre, li, td, th'); + const blockLabels = { H1: 'Heading 1', H2: 'Heading 2', H3: 'Heading 3', BLOCKQUOTE: 'Block quote', PRE: 'Code block', LI: 'List item', TD: 'Table cell', TH: 'Table header' }; + const label = block ? (blockLabels[block.tagName] || 'Paragraph') : 'Paragraph'; + blockEl.innerHTML = '' + label; + } + + // Font and size label + if (fontEl) { + const fontElement = element && element.closest('[data-rte-font], font[face]'); + const selectedFont = fontElement ? (fontElement.dataset.rteFont || fontElement.getAttribute('face') || 'Inter') : 'Inter'; + const sizeElement = element && element.closest('[data-rte-size], font[size]'); + const selectedSize = sizeElement ? (sizeElement.dataset.rteSize || _fontSizeLabels[sizeElement.getAttribute('size')] || '14 px') : '14 px'; + fontEl.innerHTML = '' + selectedFont + ', ' + selectedSize; + } + + // Alignment label + if (alignmentEl) { + const block = element && element.closest('h1, h2, h3, h4, h5, h6, p, li, td, th, blockquote, pre'); + const alignment = block ? getComputedStyle(block).textAlign : 'left'; + const alignLabel = alignment === 'center' ? 'Centered' : alignment === 'right' ? 'Right aligned' : alignment === 'justify' ? 'Justified' : 'Left aligned'; + alignmentEl.innerHTML = '' + alignLabel; + } } // INLINE FORMATTING @@ -1609,4 +1666,7 @@ export function initialize(dotNetHelper, editorId) { // Send the initial editor value to .NET dotNetHelper.invokeMethodAsync('OnEditorValueChangedAsync', state.editor.innerHTML.replace(/\u200B/g, '')); + + // Populate the footer with the initial document state + updateFooter(state); } \ No newline at end of file From de76081145df71525c04db96a746159f0cbc7cae Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Mon, 17 Aug 2026 09:40:21 +0530 Subject: [PATCH 21/53] Refactor JS command routing; add debug logs Refactor JavaScript to route 'undo', 'redo', and 'print' through executeCommand, simplifying toolbar click handling. Add Console.WriteLine statements for debugging in C# and JS. Use null-forgiving operator on objRef in RichTextEditor.razor.cs to suppress nullable warnings. --- .../Form/RichTextEditor/RichTextEditor.razor.cs | 4 ++-- .../RichTextEditor/RichTextEditorJsInterop.cs | 1 + .../RichTextEditorToolbarButton.razor.cs | 1 + .../wwwroot/blazor.bootstrap.rich-text-editor.js | 16 +++++++--------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs index 5b92e68cf..796aea3d1 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor.cs @@ -28,7 +28,7 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) uploadCancellationTokenSource.Dispose(); if (Id is not null) - await RichTextEditorJsInterop.DisposeAsync(objRef, Id); + await RichTextEditorJsInterop.DisposeAsync(objRef!, Id); objRef?.Dispose(); } @@ -41,7 +41,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender) { objRef ??= DotNetObjectReference.Create(this); - await RichTextEditorJsInterop.InitializeAsync(objRef, Id!); + await RichTextEditorJsInterop.InitializeAsync(objRef!, Id!); lastRenderedValue = Value; } //else if (lastRenderedValue != Value) diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs index 249250fe3..460b37b39 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorJsInterop.cs @@ -35,6 +35,7 @@ public async Task DisposeAsync(object objRef, string editorId) public async Task ExecuteAsync(object objRef, string editorId, string elementId, string command, string value) { + Console.WriteLine($"Executing command: {command} with value: {value} on element: {elementId} in editor: {editorId}"); await SafeInvokeVoidAsync(Execute, objRef, editorId, elementId, command, value); } diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs index 780288c24..56b99ca60 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditorToolbarButton.razor.cs @@ -21,6 +21,7 @@ protected override async Task OnInitializedAsync() private async Task OnClickAsync() { + Console.WriteLine($"Toolbar button clicked: {Id}, Item: {Item}, Disabled: {Disabled}"); if (Disabled) { return; diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index 512f2d13c..a149551bc 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -521,7 +521,13 @@ function selectBlock(state, block) { // Routes toolbar commands to the appropriate inline or block implementation. function executeCommand(state, command, value = null) { - if (['bold', 'italic', 'underline', 'strikeThrough', 'fontName', 'fontSize', 'foreColor', 'hiliteColor'].includes(command)) { + if (command === 'undo') { + restoreEditorHistory(state, state.editorUndoStates, state.editorRedoStates); + } else if (command === 'redo') { + restoreEditorHistory(state, state.editorRedoStates, state.editorUndoStates); + } else if (command === 'print') { + printEditorDocument(state); + } else if (['bold', 'italic', 'underline', 'strikeThrough', 'fontName', 'fontSize', 'foreColor', 'hiliteColor'].includes(command)) { applyInlineCommand(state, command, value); } else if (command === 'justifyLeft') { applyAlignment(state, 'left'); @@ -1535,14 +1541,6 @@ function handleToolbarClick(state, event) { if (!button) return; if (button.dataset.editorCommand) { - if (button.dataset.editorCommand === 'undo') { - restoreEditorHistory(state, state.editorUndoStates, state.editorRedoStates); - return; - } - if (button.dataset.editorCommand === 'redo') { - restoreEditorHistory(state, state.editorRedoStates, state.editorUndoStates); - return; - } executeCommand(state, button.dataset.editorCommand); } else if (button.dataset.editorBlock) { selectBlock(state, button.dataset.editorBlock); From 5d0925380e71d77e962bca6445eb01d33c008de9 Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Mon, 17 Aug 2026 11:00:33 +0530 Subject: [PATCH 22/53] Fix editor ID retrieval in createEditorState function --- blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index a149551bc..d00cdb361 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -11,7 +11,7 @@ function getEditorState(editorId) { } function createEditorState(editorId, dotNetHelper) { - const editor = document.getElementById(editorId); + const editor = document.getElementById(editorId + '-editor'); if (!editor) return null; // The toolbar is expected to be a sibling/ancestor element with role="toolbar" From 063a73732dcf29a1de35969b2b59a492ec9cb42a Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Mon, 17 Aug 2026 11:35:29 +0530 Subject: [PATCH 23/53] Enhance print functionality in editor: add print styles and manage visibility of elements during printing --- .../blazor.bootstrap.rich-text-editor.js | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index d00cdb361..3ea482a46 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -1466,7 +1466,18 @@ function openInsertImageModal(state) { // Uses the native print dialog while temporarily showing only the editor content. function printEditorDocument(state) { const editor = state.editor; + const printScopeClass = 'bb-rte-printing'; + const printDocumentClass = 'bb-rte-print-document'; + let printDocument; + const printStyle = document.createElement('style'); + printStyle.textContent = `@media print { + body.${printScopeClass} * { visibility: hidden !important; } + body.${printScopeClass} .${printDocumentClass}, body.${printScopeClass} .${printDocumentClass} * { visibility: visible !important; } + body.${printScopeClass} .${printDocumentClass} { position: absolute !important; inset: 0 !important; width: auto !important; } + body.${printScopeClass} .${printDocumentClass} table { width: 100% !important; } + }`; const editorSurface = editor.closest('section'); + const toolbar = editorSurface && editorSurface.querySelector('[role="toolbar"]'); const footer = editorSurface && editorSurface.querySelector('.card-footer'); const hiddenElements = [toolbar, footer].filter(Boolean).map((element) => ({ element, wasHidden: element.hidden })); @@ -1485,6 +1496,7 @@ function printEditorDocument(state) { element.classList.remove(...removeClasses); element.classList.add(...addClasses); }; + adjustPrintClasses(editor, ['p-2'], ['p-4']); editor.querySelectorAll('.table-responsive').forEach((e) => adjustPrintClasses(e, [], ['table-responsive'])); editor.querySelectorAll('table').forEach((e) => adjustPrintClasses(e, ['table-sm', 'small', 'text-break'])); @@ -1521,9 +1533,18 @@ function printEditorDocument(state) { if (editorAttributes.contenteditable !== null) editor.setAttribute('contenteditable', editorAttributes.contenteditable); if (editorAttributes.role !== null) editor.setAttribute('role', editorAttributes.role); if (editorAttributes.ariaMultiline !== null) editor.setAttribute('aria-multiline', editorAttributes.ariaMultiline); + document.body.classList.remove(printScopeClass); + printDocument?.remove(); + printStyle.remove(); }; hiddenElements.forEach(({ element }) => { element.hidden = true; }); if (editorSurface) editorSurface.classList.remove(...removedClasses); + printDocument = editor.cloneNode(true); + printDocument.removeAttribute('id'); + printDocument.classList.add(printDocumentClass); + document.body.appendChild(printDocument); + document.head.appendChild(printStyle); + document.body.classList.add(printScopeClass); window.addEventListener('afterprint', restoreEditorView, { once: true }); try { window.focus(); From d17e938304f4fd957bfbedbb8c316c8546838cf0 Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Mon, 17 Aug 2026 16:39:55 +0530 Subject: [PATCH 24/53] Add data attributes for text and highlight color indicators in RichTextEditor --- .../Components/Form/RichTextEditor/RichTextEditor.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor index f19bfd074..95b238d4a 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor @@ -51,7 +51,7 @@
- +
- +
- diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js index 4dd59b54f..e265de9ea 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.rich-text-editor.js @@ -873,15 +873,6 @@ function applyTableStyle(state, style) { rememberSelection(state); } -function alignTableCell(state, alignment) { - const context = getTableContext(state); - if (!context) return; - recordEditorState(state); - context.cell.classList.remove('text-start', 'text-center', 'text-end'); - context.cell.classList.add(alignment); - notifyEditorChange(state); - rememberSelection(state); -} // Inserts a bordered data table with a semantic header row. function insertTable(state, rows, columns) { @@ -1623,8 +1614,6 @@ function handleToolbarClick(state, event) { applyTableAction(state, button.dataset.editorTableAction); } else if (button.dataset.editorTableStyle) { applyTableStyle(state, button.dataset.editorTableStyle); - } else if (button.dataset.editorTableAlign) { - alignTableCell(state, button.dataset.editorTableAlign); } else if (button.dataset.editorAction === 'apply-text-color') { executeCommand(state, 'foreColor', state.selectedTextColor); } else if (button.dataset.editorAction === 'apply-highlight-color') { From 109cecb6f9b843296f8c451845497deaf6f0eb97 Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Mon, 17 Aug 2026 23:49:08 +0530 Subject: [PATCH 31/53] Image updates --- .../Form/RichTextEditor/RichTextEditor.razor | 30 ++- .../RichTextEditor/RichTextEditor.razor.cs | 124 +++++++-- .../RichTextEditor/RichTextEditorJsInterop.cs | 16 +- blazorbootstrap/Extensions/EnumExtensions.cs | 1 + .../blazor.bootstrap.rich-text-editor.js | 247 ++++-------------- 5 files changed, 204 insertions(+), 214 deletions(-) diff --git a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor index 51545a86a..ed0fe6912 100644 --- a/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor +++ b/blazorbootstrap/Components/Form/RichTextEditor/RichTextEditor.razor @@ -192,10 +192,34 @@ - -
- @if (IsToolbarItemVisible(RichTextEditorToolbarItem.Link)) + @if (isLinkModalMounted) { } - @if (IsToolbarItemVisible(RichTextEditorToolbarItem.Image)) + @if (isImageModalMounted) { } - @if (IsToolbarItemVisible(RichTextEditorToolbarItem.Table)) + @if (isTableModalMounted) {