280 lines
10 KiB
Plaintext
280 lines
10 KiB
Plaintext
@inject Microsoft.JSInterop.IJSRuntime JSRuntime
|
|
@using Microsoft.JSInterop
|
|
@implements IDisposable
|
|
|
|
<div class="flex flex-col gap-4 w-full h-full border rounded-lg bg-base-100 p-4 shadow-sm relative transition-all">
|
|
|
|
@if (_showSuccessMessage)
|
|
{
|
|
<div
|
|
class="absolute inset-0 z-50 flex items-center justify-center bg-base-100/90 backdrop-blur-sm rounded-lg animate-fade-in">
|
|
<div class="alert alert-success max-w-sm shadow-lg">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
|
|
viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>Image Cropped Successfully!</span>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Section 1.1: Image Viewer / Cropper Area -->
|
|
<div class="flex-1 relative bg-base-300 rounded-lg overflow-hidden min-h-[400px] flex items-center justify-center">
|
|
@if (_imageDataUrl != null)
|
|
{
|
|
<!-- Unique ID -->
|
|
<img id="@_uniqueId" src="@_imageDataUrl" @onload="OnImageLoaded"
|
|
class="max-w-full max-h-[600px] object-contain block" style="display:block;" />
|
|
}
|
|
else
|
|
{
|
|
<div class="text-base-content/30 flex flex-col items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-2" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
<span>Select an image to view or crop</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<!-- Section 1.2: Controls -->
|
|
<div class="flex flex-wrap gap-4 items-center justify-between border-t border-base-200 pt-4">
|
|
|
|
<div class="flex gap-2 items-center @(_isDisabled ? "opacity-50 pointer-events-none" : "")">
|
|
<span class="font-bold text-sm">Ratio (W:H):</span>
|
|
<div class="join">
|
|
<input type="number" class="join-item input input-bordered input-sm w-16" placeholder="W" min="1"
|
|
@bind="_ratioW" @bind:after="OnRatioInput" />
|
|
<span class="join-item btn btn-sm btn-disabled bg-base-200 border-base-300">:</span>
|
|
<input type="number" class="join-item input input-bordered input-sm w-16" placeholder="H" min="1"
|
|
@bind="_ratioH" @bind:after="OnRatioInput" />
|
|
</div>
|
|
<button class="btn btn-xs btn-ghost" @onclick="ClearRatio" title="Reset to Free">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
<div class="divider divider-horizontal mx-0"></div>
|
|
<!-- Maximize Buttons -->
|
|
<button class="btn btn-xs btn-ghost" @onclick="SetFullWidth" title="Maximize Width">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16" />
|
|
</svg>
|
|
W
|
|
</button>
|
|
<button class="btn btn-xs btn-ghost" @onclick="SetFullHeight" title="Maximize Height">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 4v16M16 4v16" />
|
|
</svg>
|
|
H
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
@if (_isCropping)
|
|
{
|
|
<button class="btn btn-ghost" @onclick="CancelCropMode">Cancel</button>
|
|
<button class="btn btn-primary" @onclick="Save">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
Apply Crop
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<button class="btn btn-warning" @onclick="StartCropping" disabled="@_isDisabled">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
</svg>
|
|
Edit / Crop
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@code {
|
|
[Parameter] public MediaItemModel? Item { get; set; }
|
|
[Parameter] public EventCallback<MediaItemModel> OnCropSaved { get; set; }
|
|
[Parameter] public EventCallback OnCancel { get; set; } // May act as clearing selection
|
|
[Parameter] public double? AspectRatio { get; set; }
|
|
[Parameter] public int? TargetWidth { get; set; }
|
|
[Parameter] public int? TargetHeight { get; set; }
|
|
[Parameter] public string? AiApiKey { get; set; }
|
|
|
|
private string _uniqueId = "cropper-" + Guid.NewGuid().ToString("N");
|
|
// Removed _isOpen as it's always visible
|
|
private bool _isCropping = false;
|
|
private string? _imageDataUrl;
|
|
private MediaJsInterop _interop = default!;
|
|
private DotNetObjectReference<ImageCropper>? _objRef;
|
|
|
|
private double? _ratioW = 16;
|
|
private double? _ratioH = 9;
|
|
private bool _showSuccessMessage = false;
|
|
|
|
private bool _isDisabled => Item == null;
|
|
|
|
protected override void OnInitialized()
|
|
{
|
|
_interop = new MediaJsInterop(JSRuntime);
|
|
_objRef = DotNetObjectReference.Create(this);
|
|
}
|
|
|
|
public async Task OpenAsync(MediaItemModel item)
|
|
{
|
|
// Close existing if open
|
|
try { await _interop.DestroyCropperAsync(); } catch { }
|
|
|
|
Item = item;
|
|
_isCropping = false;
|
|
_imageDataUrl = null;
|
|
_imageLoaded = false;
|
|
StateHasChanged();
|
|
|
|
if (item.IsLocal && item.File != null && string.IsNullOrEmpty(item.PreviewUrl))
|
|
{
|
|
// Try to load preview if not ready
|
|
await LoadPreviewInternal(item);
|
|
}
|
|
|
|
_imageDataUrl = !string.IsNullOrEmpty(item.PreviewUrl) ? item.PreviewUrl : item.Url;
|
|
StateHasChanged();
|
|
}
|
|
|
|
private async Task LoadPreviewInternal(MediaItemModel item)
|
|
{
|
|
try
|
|
{
|
|
var maxFileSize = 10L * 1024 * 1024;
|
|
var buffer = new byte[item.File.Size];
|
|
await item.File.OpenReadStream(maxAllowedSize: maxFileSize).ReadAsync(buffer);
|
|
var base64 = Convert.ToBase64String(buffer);
|
|
item.PreviewUrl = $"data:{item.File.ContentType};base64,{base64}";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"Error reading file: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private bool _imageLoaded = false;
|
|
|
|
private async Task OnImageLoaded()
|
|
{
|
|
_imageLoaded = true;
|
|
// Optional: Auto start crop? User said "select image ... viewed ... then buttons enabled to start cropping IF you want"
|
|
// So we just stay in view mode.
|
|
}
|
|
|
|
private async Task StartCropping()
|
|
{
|
|
if (_isCropping || Item == null) return;
|
|
_isCropping = true;
|
|
StateHasChanged();
|
|
|
|
if (!string.IsNullOrEmpty(_imageDataUrl) && _imageLoaded)
|
|
{
|
|
await InitCropperInternal();
|
|
}
|
|
}
|
|
|
|
private async Task InitCropperInternal()
|
|
{
|
|
await Task.Delay(50);
|
|
|
|
// Use input ratio if set, else Free (NaN)
|
|
double ratio = double.NaN;
|
|
if (_ratioW > 0 && _ratioH > 0)
|
|
{
|
|
ratio = _ratioW.Value / _ratioH.Value;
|
|
}
|
|
|
|
await _interop.InitCropperAsync(_uniqueId, ratio, _objRef!);
|
|
}
|
|
|
|
private async Task OnRatioInput()
|
|
{
|
|
if (_ratioW.HasValue && _ratioW < 1) _ratioW = 1;
|
|
if (_ratioH.HasValue && _ratioH < 1) _ratioH = 1;
|
|
|
|
if (_ratioW > 0 && _ratioH > 0)
|
|
{
|
|
await _interop.SetAspectRatioAsync(_ratioW.Value / _ratioH.Value);
|
|
}
|
|
else
|
|
{
|
|
await _interop.SetAspectRatioAsync(double.NaN);
|
|
}
|
|
}
|
|
|
|
private async Task ClearRatio()
|
|
{
|
|
_ratioW = null;
|
|
_ratioH = null;
|
|
await _interop.SetAspectRatioAsync(double.NaN);
|
|
}
|
|
|
|
private async Task SetFullWidth()
|
|
{
|
|
await _interop.SetFullWidthAsync();
|
|
}
|
|
|
|
private async Task SetFullHeight()
|
|
{
|
|
await _interop.SetFullHeightAsync();
|
|
}
|
|
|
|
private async Task Save()
|
|
{
|
|
var croppedBase64 = await _interop.GetCroppedImageAsync();
|
|
|
|
if (!string.IsNullOrEmpty(croppedBase64) && Item != null)
|
|
{
|
|
Item.PreviewUrl = croppedBase64;
|
|
// If we want to replace the image in the view immediately:
|
|
_imageDataUrl = croppedBase64;
|
|
}
|
|
|
|
await _interop.DestroyCropperAsync();
|
|
_isCropping = false;
|
|
|
|
// Success Feedback
|
|
_showSuccessMessage = true;
|
|
StateHasChanged();
|
|
|
|
await OnCropSaved.InvokeAsync(Item);
|
|
|
|
// Hide message after delay
|
|
await Task.Delay(2000);
|
|
_showSuccessMessage = false;
|
|
StateHasChanged();
|
|
}
|
|
|
|
private async Task CancelCropMode()
|
|
{
|
|
try
|
|
{
|
|
await _interop.DestroyCropperAsync();
|
|
}
|
|
catch { }
|
|
|
|
_isCropping = false;
|
|
StateHasChanged();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_objRef?.Dispose();
|
|
}
|
|
} |