diff --git a/Radzen.Blazor/Common.cs b/Radzen.Blazor/Common.cs
index 1e548976d61..658e680f424 100644
--- a/Radzen.Blazor/Common.cs
+++ b/Radzen.Blazor/Common.cs
@@ -3974,4 +3974,25 @@ public enum FrozenColumnPosition
///
Right
}
+
+ ///
+ /// Supplies information about an image resize event in the RadzenHtmlEditor.
+ ///
+ public class ImageResizeEventArgs
+ {
+ ///
+ /// Gets or sets the source URL of the resized image.
+ ///
+ public string Src { get; set; }
+
+ ///
+ /// Gets or sets the new width of the image.
+ ///
+ public string Width { get; set; }
+
+ ///
+ /// Gets or sets the new height of the image.
+ ///
+ public string Height { get; set; }
+ }
}
diff --git a/Radzen.Blazor/RadzenHtmlEditor.razor.cs b/Radzen.Blazor/RadzenHtmlEditor.razor.cs
index c59363a3f6e..3cc5d58f06d 100644
--- a/Radzen.Blazor/RadzenHtmlEditor.razor.cs
+++ b/Radzen.Blazor/RadzenHtmlEditor.razor.cs
@@ -459,6 +459,12 @@ public override void Dispose()
[Parameter]
public EventCallback UploadComplete { get; set; }
+ ///
+ /// Gets or sets the callback which is invoked when an image is resized.
+ ///
+ /// The image resize callback.
+ [Parameter]
+ public EventCallback ImageResize { get; set; }
internal async Task RaiseUploadComplete(UploadCompleteEventArgs args)
{
@@ -487,5 +493,14 @@ public async Task OnUploadComplete(string response)
await UploadComplete.InvokeAsync(new UploadCompleteEventArgs() { RawResponse = response, JsonResponse = doc });
}
+
+ ///
+ /// Invoked by interop when an image is resized.
+ ///
+ [JSInvokable("OnImageResize")]
+ public async Task OnImageResize(ImageResizeEventArgs data)
+ {
+ await ImageResize.InvokeAsync(data);
+ }
}
}
diff --git a/Radzen.Blazor/RadzenHtmlEditorButtonBase.cs b/Radzen.Blazor/RadzenHtmlEditorButtonBase.cs
index fd46f3a3701..324c4315d2d 100644
--- a/Radzen.Blazor/RadzenHtmlEditorButtonBase.cs
+++ b/Radzen.Blazor/RadzenHtmlEditorButtonBase.cs
@@ -2,6 +2,8 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.JSInterop;
+
namespace Radzen.Blazor
{
@@ -10,6 +12,12 @@ namespace Radzen.Blazor
///
public abstract class RadzenHtmlEditorButtonBase : ComponentBase, IDisposable
{
+ ///
+ /// Gets or sets the IJSRuntime instance.
+ ///
+ [Inject]
+ protected IJSRuntime JsRuntime { get; set; }
+
///
/// The RadzenHtmlEditor component which this tool is part of.
///
@@ -33,6 +41,9 @@ public abstract class RadzenHtmlEditorButtonBase : ComponentBase, IDisposable
///
protected virtual async Task OnClick()
{
+ // Remove resize handles if an image is selected
+ await JsRuntime.InvokeVoidAsync("Radzen.removeImageResizeHandlesForLink", Editor.Element);
+
await Editor.ExecuteCommandAsync(CommandName);
}
diff --git a/Radzen.Blazor/RadzenHtmlEditorImage.razor b/Radzen.Blazor/RadzenHtmlEditorImage.razor
index 48bb1da52a8..c922455b039 100644
--- a/Radzen.Blazor/RadzenHtmlEditorImage.razor
+++ b/Radzen.Blazor/RadzenHtmlEditorImage.razor
@@ -9,11 +9,14 @@
@code {
protected override async Task OnClick()
{
+ // Remove resize handles if an image is selected
+ await JsRuntime.InvokeVoidAsync("Radzen.removeImageResizeHandlesForLink", Editor.Element);
+
await Editor.SaveSelectionAsync();
var uploadHeaders = Editor.UploadHeaders ?? new Dictionary();
- Attributes = await Editor.GetSelectionAttributes("img", new[] {"src", "alt", "width", "height"});
+ Attributes = await Editor.GetSelectionAttributes("img", ["src", "alt", "width", "height"]);
var result = await DialogService.OpenAsync(Title, ds => @
diff --git a/Radzen.Blazor/RadzenHtmlEditorLink.razor b/Radzen.Blazor/RadzenHtmlEditorLink.razor
index 1d88b6a91eb..180e1317782 100644
--- a/Radzen.Blazor/RadzenHtmlEditorLink.razor
+++ b/Radzen.Blazor/RadzenHtmlEditorLink.razor
@@ -9,11 +9,17 @@
@code {
protected override async Task OnClick()
{
+ // Remove resize handles if an image is selected
+ await JsRuntime.InvokeVoidAsync("Radzen.removeImageResizeHandlesForLink", Editor.Element);
+
await Editor.SaveSelectionAsync();
bool blank = false;
- var attributes = await Editor.GetSelectionAttributes("a", new[] {"innerText", "href", "target" });
+ // Check if we have an image selected using JavaScript
+ var isImageSelected = await JsRuntime.InvokeAsync("Radzen.hasSelectedImage", Editor.Element);
+
+ var attributes = await Editor.GetSelectionAttributes("a", ["innerText", "href", "target", "innerHTML"]);
if (attributes.Target == "_blank")
{
@@ -46,18 +52,26 @@
await Editor.RestoreSelectionAsync();
- if (result == true && !String.IsNullOrEmpty(attributes.Href))
+ if (result == true && !string.IsNullOrEmpty(attributes.Href))
{
- var html = new StringBuilder();
- html.AppendFormat(" in a link
+ await JsRuntime.InvokeVoidAsync("Radzen.wrapSelectedImageWithLink", attributes.Href, blank);
}
+ else
+ {
+ var html = new StringBuilder();
+ html.AppendFormat("{0}", string.IsNullOrEmpty(attributes.InnerText) ? attributes.InnerHtml : attributes.InnerText);
+ html.AppendFormat(">{0}", string.IsNullOrEmpty(attributes.InnerText) ? attributes.InnerHtml : attributes.InnerText);
- await Editor.ExecuteCommandAsync("insertHTML", html.ToString());
+ await Editor.ExecuteCommandAsync("insertHTML", html.ToString());
+ }
}
}
}
\ No newline at end of file
diff --git a/Radzen.Blazor/RadzenHtmlEditorUnlink.razor b/Radzen.Blazor/RadzenHtmlEditorUnlink.razor
index 1d9331e10b5..1b8cd7a2e8e 100644
--- a/Radzen.Blazor/RadzenHtmlEditorUnlink.razor
+++ b/Radzen.Blazor/RadzenHtmlEditorUnlink.razor
@@ -1,4 +1,27 @@
@using Radzen.Blazor.Rendering
+@using Microsoft.JSInterop
@inherits RadzenHtmlEditorButtonBase
-
\ No newline at end of file
+
+
+@code {
+ protected override async Task OnClick()
+ {
+ // Remove resize handles if an image is selected
+ await JsRuntime.InvokeVoidAsync("Radzen.removeImageResizeHandlesForLink", Editor.Element);
+
+ // Check if we have an image selected
+ var hasImage = await JsRuntime.InvokeAsync("Radzen.hasSelectedImage", Editor.Element);
+
+ if (hasImage)
+ {
+ // Use custom unlink function for images
+ await JsRuntime.InvokeVoidAsync("Radzen.unlinkSelectedImage", Editor.Element);
+ }
+ else
+ {
+ // Use standard unlink command for text links
+ await Editor.ExecuteCommandAsync(CommandName);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Radzen.Blazor/themes/components/blazor/_editor.scss b/Radzen.Blazor/themes/components/blazor/_editor.scss
index 2b62336589f..d993e7dc2d0 100644
--- a/Radzen.Blazor/themes/components/blazor/_editor.scss
+++ b/Radzen.Blazor/themes/components/blazor/_editor.scss
@@ -35,6 +35,56 @@ $editor-focus-outline-offset: calc(-1 * var(--rz-outline-width)) !default;
background-color: var(--rz-editor-content-background-color);
}
+.rz-html-editor .rz-html-editor-content img.rz-state-selected {
+ position: relative;
+ outline: 2px solid var(--rz-primary);
+ outline-offset: 2px;
+}
+
+.rz-html-editor .rz-html-editor-content {
+ .rz-image-resize-handle {
+ position: absolute;
+ width: 12px;
+ height: 12px;
+ background-color: var(--rz-primary);
+ border: 2px solid var(--rz-on-primary);
+ border-radius: 50%;
+ cursor: pointer;
+ z-index: 1000;
+ pointer-events: all;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2);
+
+ &.rz-resize-nw {
+ top: -6px;
+ left: -6px;
+ cursor: nw-resize;
+ }
+
+ &.rz-resize-ne {
+ top: -6px;
+ right: -6px;
+ cursor: ne-resize;
+ }
+
+ &.rz-resize-sw {
+ bottom: -6px;
+ left: -6px;
+ cursor: sw-resize;
+ }
+
+ &.rz-resize-se {
+ bottom: -6px;
+ right: -6px;
+ cursor: se-resize;
+ }
+ }
+
+ .rz-image-resize-container {
+ position: relative;
+ display: inline-block;
+ }
+}
+
.rz-html-editor-source.rz-textarea {
--rz-input-hover-shadow: none;
--rz-input-border: none;
@@ -62,6 +112,21 @@ $editor-focus-outline-offset: calc(-1 * var(--rz-outline-width)) !default;
> * {
margin: var(--rz-editor-toolbar-item-margin);
}
+
+ .rz-html-editor-colorpicker {
+ .rz-colorpicker {
+ &:not(:disabled):not(.rz-state-disabled):hover {
+ border: none;
+ }
+ }
+
+ .rz-colorpicker-trigger {
+ .rzi {
+ font-size: 1.25rem;
+ height: 1rem;
+ }
+ }
+ }
}
.rz-html-editor-colorpicker {
@@ -209,22 +274,4 @@ $editor-focus-outline-offset: calc(-1 * var(--rz-outline-width)) !default;
.rz-html-editor-separator {
width: 1px;
background-color: var(--rz-editor-separator-background-color);
-}
-
-.rz-html-editor-toolbar {
-
- .rz-html-editor-colorpicker {
- .rz-colorpicker {
- &:not(:disabled):not(.rz-state-disabled):hover {
- border: none;
- }
- }
-
- .rz-colorpicker-trigger {
- .rzi {
- font-size: 1.25rem;
- height: 1rem;
- }
- }
- }
}
\ No newline at end of file
diff --git a/Radzen.Blazor/wwwroot/Radzen.Blazor.js b/Radzen.Blazor/wwwroot/Radzen.Blazor.js
index 16372a09f16..92f9fe2b8d4 100644
--- a/Radzen.Blazor/wwwroot/Radzen.Blazor.js
+++ b/Radzen.Blazor/wwwroot/Radzen.Blazor.js
@@ -1757,8 +1757,8 @@ window.Radzen = {
delete ref.mouseEnterHandler;
ref.removeEventListener('mousemove', ref.mouseMoveHandler);
delete ref.mouseMoveHandler;
- ref.removeEventListener('click', ref.clickHandler);
- delete ref.clickHandler;
+ ref.removeEventListener('click', ref.clickListener);
+ delete ref.clickListener;
this.destroyResizable(ref);
},
destroyGauge: function (ref) {
@@ -1935,17 +1935,38 @@ window.Radzen = {
e.preventDefault();
}
+ // Remove resize handles from all images
+ Radzen.removeImageResizeHandles(ref);
+
for (var img of ref.querySelectorAll('img.rz-state-selected')) {
img.classList.remove('rz-state-selected');
}
-
+
+ // Handle image clicks (including linked images)
if (e.target.matches('img')) {
+ e.preventDefault();
e.target.classList.add('rz-state-selected');
+ Radzen.addImageResizeHandles(e.target, ref, instance);
+
+ // Create a range that selects only the image element, not the wrapper
var range = document.createRange();
range.selectNode(e.target);
getSelection().removeAllRanges();
getSelection().addRange(range);
}
+ // Handle link clicks that contain images
+ else if (e.target.matches('a') && e.target.querySelector('img')) {
+ e.preventDefault();
+ var img = e.target.querySelector('img');
+ img.classList.add('rz-state-selected');
+ Radzen.addImageResizeHandles(img, ref, instance);
+
+ // Create a range that selects only the image element, not the wrapper
+ var range = document.createRange();
+ range.selectNode(img);
+ getSelection().removeAllRanges();
+ getSelection().addRange(range);
+ }
}
}
@@ -1954,6 +1975,8 @@ window.Radzen = {
instance.invokeMethodAsync('OnSelectionChange');
}
};
+
+
ref.pasteListener = function (e) {
var item = e.clipboardData.items[0];
@@ -2023,6 +2046,9 @@ window.Radzen = {
ref.addEventListener('keydown', ref.keydownListener);
ref.addEventListener('click', ref.clickListener);
document.addEventListener('selectionchange', ref.selectionChangeListener);
+
+
+
document.execCommand('styleWithCSS', false, true);
},
saveSelection: function (ref) {
@@ -2066,6 +2092,17 @@ window.Radzen = {
if (img && selector == 'img') {
target = img;
+ } else if (img && selector == 'a') {
+ // If we have a selected image and we're looking for 'a' tags,
+ // check if the image is inside a link or if we should create a link around it
+ var linkElement = img.closest('a');
+ if (linkElement) {
+ target = linkElement;
+ } else {
+ // If no link exists, we'll create one around the image
+ // For now, return empty attributes so the link dialog can work
+ target = img;
+ }
} else if (target) {
if (target.nodeType == 3) {
target = target.parentElement;
@@ -2082,7 +2119,9 @@ window.Radzen = {
return attributes.reduce(function (result, name) {
if (target) {
- result[name] = name == 'innerText' ? target[name] : target.getAttribute(name);
+ var value = name == 'innerText' ? target[name] : target.getAttribute(name);
+ // Ensure all values are strings for JSON serialization
+ result[name] = value != null ? String(value) : null;
}
return result;
}, { innerText: selection.toString(), innerHTML: innerHTML });
@@ -2094,6 +2133,9 @@ window.Radzen = {
ref.removeEventListener('keydown', ref.keydownListener);
ref.removeEventListener('click', ref.clickListener);
document.removeEventListener('selectionchange', ref.selectionChangeListener);
+
+ // Remove image resize handles
+ Radzen.removeImageResizeHandles(ref);
}
},
startDrag: function (ref, instance, handler) {
@@ -2576,5 +2618,261 @@ window.Radzen = {
unregisterScrollListener: function (element) {
document.removeEventListener('scroll', element.scrollHandler, true);
window.removeEventListener('resize', element.scrollHandler, true);
+ },
+ addImageResizeHandles: function (img, container, instance) {
+ // Remove existing handles first
+ Radzen.removeImageResizeHandles(container);
+
+ // Check if the image is inside a link
+ var link = img.closest('a');
+ var targetElement = link || img;
+
+ // Create container for the image and handles
+ var wrapper = document.createElement('div');
+ wrapper.className = 'rz-image-resize-container';
+ wrapper.style.position = 'relative';
+ wrapper.style.display = 'inline-block';
+
+ // Preserve the original display style of the target element
+ var originalDisplay = window.getComputedStyle(targetElement).display;
+ if (originalDisplay === 'block' || originalDisplay === 'flex' || originalDisplay === 'grid') {
+ wrapper.style.display = originalDisplay;
+ }
+
+ // Insert wrapper before the target element
+ targetElement.parentNode.insertBefore(wrapper, targetElement);
+ wrapper.appendChild(targetElement);
+
+ // Ensure the image is selected (not the wrapper)
+ var range = document.createRange();
+ range.selectNode(img);
+ getSelection().removeAllRanges();
+ getSelection().addRange(range);
+
+ // Create resize handles
+ var handles = ['nw', 'ne', 'sw', 'se'];
+ var resizeData = {
+ img: img,
+ wrapper: wrapper,
+ editorContainer: container, // Store reference to the editor container
+ startX: 0,
+ startY: 0,
+ startWidth: 0,
+ startHeight: 0,
+ aspectRatio: 0
+ };
+
+ handles.forEach(function(position) {
+ var handle = document.createElement('div');
+ handle.className = 'rz-image-resize-handle rz-resize-' + position;
+ handle.setAttribute('data-position', position);
+
+ // Add inline styles to ensure visibility
+ handle.style.position = 'absolute';
+ handle.style.width = '12px';
+ handle.style.height = '12px';
+ handle.style.backgroundColor = '#007bff';
+ handle.style.border = '2px solid #ffffff';
+ handle.style.borderRadius = '50%';
+ handle.style.cursor = 'pointer';
+ handle.style.zIndex = '1000';
+ handle.style.pointerEvents = 'all';
+ handle.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
+
+ // Position the handle
+ if (position === 'nw') {
+ handle.style.top = '-6px';
+ handle.style.left = '-6px';
+ handle.style.cursor = 'nw-resize';
+ } else if (position === 'ne') {
+ handle.style.top = '-6px';
+ handle.style.right = '-6px';
+ handle.style.cursor = 'ne-resize';
+ } else if (position === 'sw') {
+ handle.style.bottom = '-6px';
+ handle.style.left = '-6px';
+ handle.style.cursor = 'sw-resize';
+ } else if (position === 'se') {
+ handle.style.bottom = '-6px';
+ handle.style.right = '-6px';
+ handle.style.cursor = 'se-resize';
+ }
+
+ wrapper.appendChild(handle);
+
+ handle.addEventListener('pointerdown', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ Radzen.startImageResize(e, resizeData, instance);
+ });
+ });
+ },
+ removeImageResizeHandles: function (container) {
+ var wrappers = container.querySelectorAll('.rz-image-resize-container');
+ wrappers.forEach(function(wrapper) {
+ // Move the first child (which should be the target element) back to its original position
+ if (wrapper.firstChild) {
+ wrapper.parentNode.insertBefore(wrapper.firstChild, wrapper);
+ wrapper.parentNode.removeChild(wrapper);
+ }
+ });
+ },
+ startImageResize: function (e, data, instance) {
+ var rect = data.img.getBoundingClientRect();
+ data.startX = e.clientX;
+ data.startY = e.clientY;
+ data.startWidth = rect.width;
+ data.startHeight = rect.height;
+ data.aspectRatio = rect.width / rect.height;
+
+ // Store references to the event handlers so we can remove them later
+ data.pointerMoveHandler = function(e) {
+ Radzen.resizeImage(e, data, instance);
+ };
+
+ data.pointerUpHandler = function() {
+ document.removeEventListener('pointermove', data.pointerMoveHandler);
+ document.removeEventListener('pointerup', data.pointerUpHandler);
+ Radzen.finishImageResize(data, instance);
+ };
+
+ // Add event listeners
+ document.addEventListener('pointermove', data.pointerMoveHandler);
+ document.addEventListener('pointerup', data.pointerUpHandler);
+ },
+ resizeImage: function (e, data, instance) {
+ var deltaX = e.clientX - data.startX;
+ var deltaY = e.clientY - data.startY;
+
+ // Calculate new dimensions based on the resize handle position
+ var newWidth = data.startWidth + deltaX;
+ var newHeight = data.startHeight + deltaY;
+
+ // Maintain aspect ratio
+ if (Math.abs(deltaX) > Math.abs(deltaY)) {
+ newHeight = newWidth / data.aspectRatio;
+ } else {
+ newWidth = newHeight * data.aspectRatio;
+ }
+
+ // Apply minimum size constraints
+ newWidth = Math.max(20, newWidth);
+ newHeight = Math.max(20, newHeight);
+
+ // Update image dimensions
+ data.img.style.width = newWidth + 'px';
+ data.img.style.height = newHeight + 'px';
+ },
+ finishImageResize: function (data, instance) {
+ // Get the computed dimensions in pixels
+ var computedWidth = data.img.clientWidth;
+ var computedHeight = data.img.clientHeight;
+
+ // Update the image attributes to match the computed dimensions
+ data.img.setAttribute('width', computedWidth);
+ data.img.setAttribute('height', computedHeight);
+
+ // Keep the style attributes for visual consistency
+ data.img.style.width = computedWidth + 'px';
+ data.img.style.height = computedHeight + 'px';
+
+ // Remove resize handles before notifying Blazor
+ Radzen.removeImageResizeHandles(data.editorContainer);
+
+ // Notify Blazor about the HTML change
+ var editorContainer = data.editorContainer;
+ if (editorContainer && editorContainer.inputListener) {
+ editorContainer.inputListener();
+ }
+
+ // Notify the Blazor component about the image resize event
+ if (instance) {
+ instance.invokeMethodAsync('OnImageResize', {
+ src: data.img.src,
+ width: String(computedWidth),
+ height: String(computedHeight)
+ });
+ }
+ },
+
+ removeImageResizeHandlesForLink: function (container) {
+ // Remove all resize handles and wrappers, leaving just the images
+ var wrappers = container.querySelectorAll('.rz-image-resize-container');
+ wrappers.forEach(function(wrapper) {
+ if (wrapper.firstChild) {
+ wrapper.parentNode.insertBefore(wrapper.firstChild, wrapper);
+ wrapper.parentNode.removeChild(wrapper);
+ }
+ });
+ },
+ hasSelectedImage: function (ref) {
+ var img = ref.querySelector('img.rz-state-selected');
+ return img !== null;
+ },
+ unlinkSelectedImage: function (ref) {
+ var img = ref.querySelector('img.rz-state-selected');
+ if (img) {
+ var link = img.closest('a');
+ if (link) {
+ // Move the image out of the link
+ link.parentNode.insertBefore(img, link);
+ link.parentNode.removeChild(link);
+ }
+
+ // Remove the selected class
+ img.classList.remove('rz-state-selected');
+
+ // Find the editor container and trigger change event
+ var editorContainer = document.querySelector('.rz-html-editor-content');
+ if (editorContainer && editorContainer.inputListener) {
+ editorContainer.inputListener();
+ }
}
+ },
+
+ wrapSelectedImageWithLink: function (href, blank) {
+ // Find the editor container
+ var editorContainer = document.querySelector('.rz-html-editor-content');
+ if (!editorContainer) {
+ return;
+ }
+
+ // Look for selected image in the editor
+ var img = editorContainer.querySelector('img.rz-state-selected');
+ if (!img) {
+ return;
+ }
+
+ // If image is already inside a link, update link
+ var link = img.closest('a');
+ if (link) {
+ link.setAttribute('href', href);
+ if (blank) {
+ link.setAttribute('target', '_blank');
+ } else {
+ link.removeAttribute('target');
+ }
+ return;
+ }
+
+ // Create link and wrap the image
+ var a = document.createElement('a');
+ a.setAttribute('href', href);
+ if (blank) {
+ a.setAttribute('target', '_blank');
+ }
+
+ // Insert link before image and move image inside
+ img.parentNode.insertBefore(a, img);
+ a.appendChild(img);
+
+ // Remove the selected class
+ img.classList.remove('rz-state-selected');
+
+ // Trigger change event to notify Blazor
+ if (editorContainer.inputListener) {
+ editorContainer.inputListener();
+ }
+ }
};
+
diff --git a/RadzenBlazorDemos/Pages/HtmlEditorPage.razor b/RadzenBlazorDemos/Pages/HtmlEditorPage.razor
index 5c3fced5f34..85c18d29acc 100644
--- a/RadzenBlazorDemos/Pages/HtmlEditorPage.razor
+++ b/RadzenBlazorDemos/Pages/HtmlEditorPage.razor
@@ -79,6 +79,7 @@ The Radzen HtmlEditor supports the following tools:
+
Focus