Files

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();
}
}