fix(ui): random 3 album covers max in banner, redesigned cropper with drag-to-move and drag-handle resize, image stays static
This commit is contained in:
@@ -25,9 +25,9 @@
|
||||
@* Banner *@
|
||||
<div class="cosplayer-banner @(coverUrls.Count == 0 ? "cosplayer-banner-empty" : "")">
|
||||
@if (coverUrls.Count > 0) {
|
||||
var cols = coverUrls.Count <= 3 ? coverUrls.Count : 3;
|
||||
<div class="cosplayer-banner-grid" style="grid-template-columns: repeat(@cols, 1fr)">
|
||||
@foreach (var url in coverUrls) {
|
||||
var bannerCovers = coverUrls.OrderBy(_ => Random.Shared.Next()).Take(3).ToList();
|
||||
<div class="cosplayer-banner-grid" style="grid-template-columns: repeat(@bannerCovers.Count, 1fr)">
|
||||
@foreach (var url in bannerCovers) {
|
||||
<div class="banner-cell" style="background-image: url('@url')"></div>
|
||||
}
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@
|
||||
var cy = person.ProfileCropY ?? 50;
|
||||
var zoom = person.ProfileCropZoom ?? 1f;
|
||||
<div class="cosplayer-avatar-img"
|
||||
style="background-image: url('@ThumbUrl(person.ProfileAssetId.Value)'); background-position: @cx% @cy%; background-size: calc(100% * @zoom)">
|
||||
style="background-image: url('@ThumbUrl(person.ProfileAssetId.Value)'); background-position: @cx% @cy%; background-size: calc(100% / @zoom)">
|
||||
</div>
|
||||
} else {
|
||||
<i class="bi bi-person-circle cosplayer-avatar-placeholder"></i>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
var cy2 = person.ProfileCropY ?? 50;
|
||||
var zoom2 = person.ProfileCropZoom ?? 1f;
|
||||
<div class="cosplayer-card-img"
|
||||
style="background-image: url('@ThumbUrl(person.ProfileAssetId.Value)'); background-position: @cx2% @cy2%; background-size: calc(100% * @zoom2)"></div>
|
||||
style="background-image: url('@ThumbUrl(person.ProfileAssetId.Value)'); background-position: @cx2% @cy2%; background-size: calc(100% / @zoom2)"></div>
|
||||
<div class="cosplayer-card-fallback" style="display:none">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
</div>
|
||||
|
||||
@@ -12,22 +12,15 @@
|
||||
<button class="btn-close" @onclick="OnCancel"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="cropper-toolbar">
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ZoomOut" title="Zoom out">
|
||||
<i class="bi bi-zoom-out"></i>
|
||||
</button>
|
||||
<span class="cropper-zoom-label">@(CurrentZoom.ToString("F1"))×</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ZoomIn" title="Zoom in">
|
||||
<i class="bi bi-zoom-in"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="cropper-viewport" id="@ContainerId">
|
||||
<img class="cropper-img" alt="Profile picture" />
|
||||
<div class="cropper-overlay"></div>
|
||||
<div class="cropper-square"></div>
|
||||
<div class="cropper-circle"></div>
|
||||
<div class="cropper-circle">
|
||||
<div class="cropper-handle" title="Drag to resize"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mt-2 text-center">Drag the circle to center the face. Use zoom for fine control.</p>
|
||||
<p class="text-muted small mt-2 text-center">Drag the circle to center the face. Drag the handle on the edge to resize.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick="OnCancel">Cancel</button>
|
||||
@@ -78,8 +71,6 @@
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnClosed { get; set; }
|
||||
|
||||
float CurrentZoom => CropZoom ?? 1f;
|
||||
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
string token => loginService.AuthInfo?.Token ?? string.Empty;
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
@@ -101,18 +92,10 @@
|
||||
if (_pendingInit) {
|
||||
_pendingInit = false;
|
||||
await jsRuntime.InvokeVoidAsync("profileCropper.initialize",
|
||||
ContainerId, ImageUrl, CropX ?? 50, CropY ?? 50, CropZoom ?? 1f);
|
||||
ContainerId, ImageUrl, CropX ?? 50, CropY ?? 50, CropZoom ?? 0.4f);
|
||||
}
|
||||
}
|
||||
|
||||
async Task ZoomIn() {
|
||||
await jsRuntime.InvokeVoidAsync("profileCropper.zoom", ContainerId, 0.25);
|
||||
}
|
||||
|
||||
async Task ZoomOut() {
|
||||
await jsRuntime.InvokeVoidAsync("profileCropper.zoom", ContainerId, -0.25);
|
||||
}
|
||||
|
||||
async Task OnSave() {
|
||||
var crop = await jsRuntime.InvokeAsync<CropResult>("profileCropper.getCrop", ContainerId);
|
||||
await jsRuntime.InvokeVoidAsync("profileCropper.destroy", ContainerId);
|
||||
|
||||
@@ -9,125 +9,134 @@ window.profileCropper = {
|
||||
var overlay = container.querySelector('.cropper-overlay');
|
||||
var circle = container.querySelector('.cropper-circle');
|
||||
var square = container.querySelector('.cropper-square');
|
||||
if (!img || !overlay || !circle || !square) return;
|
||||
var handle = container.querySelector('.cropper-handle');
|
||||
if (!img || !overlay || !circle || !square || !handle) return;
|
||||
|
||||
img.onload = function () {
|
||||
var cx = cropX != null ? parseFloat(cropX) : 50;
|
||||
var cy = cropY != null ? parseFloat(cropY) : 50;
|
||||
var zoom = cropZoom != null ? parseFloat(cropZoom) : 1;
|
||||
var zoom = cropZoom != null ? parseFloat(cropZoom) : 0.4;
|
||||
|
||||
window.profileCropper._state[containerId] = {
|
||||
cropX: cx, cropY: cy, zoom: zoom,
|
||||
dragging: false, startX: 0, startY: 0,
|
||||
startCX: cx, startCY: cy,
|
||||
var state = {
|
||||
cropX: cx, cropY: cy, cropZoom: zoom,
|
||||
dragging: false, resizing: false,
|
||||
startX: 0, startY: 0,
|
||||
startCX: cx, startCY: cy, startZoom: zoom,
|
||||
container: container, img: img,
|
||||
overlay: overlay, circle: circle, square: square
|
||||
overlay: overlay, circle: circle, square: square, handle: handle
|
||||
};
|
||||
|
||||
window.profileCropper._state[containerId] = state;
|
||||
window.profileCropper._updateView(containerId);
|
||||
|
||||
// Drag circle body
|
||||
var onStart = function (clientX, clientY) {
|
||||
state.dragging = true;
|
||||
state.startX = clientX;
|
||||
state.startY = clientY;
|
||||
state.startCX = state.cropX;
|
||||
state.startCY = state.cropY;
|
||||
};
|
||||
|
||||
var onMove = function (clientX, clientY) {
|
||||
if (state.dragging) {
|
||||
var rect = container.getBoundingClientRect();
|
||||
var dx = ((clientX - state.startX) / rect.width) * 100;
|
||||
var dy = ((clientY - state.startY) / rect.height) * 100;
|
||||
state.cropX = Math.max(0, Math.min(100, state.startCX + dx));
|
||||
state.cropY = Math.max(0, Math.min(100, state.startCY + dy));
|
||||
window.profileCropper._updateView(containerId);
|
||||
}
|
||||
if (state.resizing) {
|
||||
var rect = container.getBoundingClientRect();
|
||||
var vpPx = rect.width;
|
||||
var centrePx = [(state.cropX / 100) * vpPx, (state.cropY / 100) * vpPx];
|
||||
var dx = clientX - state.startX;
|
||||
var dy = clientY - state.startY;
|
||||
var dist = Math.sqrt(dx * dx + dy * dy);
|
||||
var dir = (clientX - centrePx[0]) * dx + (clientY - centrePx[1]) * dy >= 0 ? 1 : -1;
|
||||
var delta = (dist / vpPx) * dir * 100;
|
||||
state.cropZoom = Math.max(0.1, Math.min(0.9, state.startZoom + delta));
|
||||
window.profileCropper._updateView(containerId);
|
||||
}
|
||||
};
|
||||
|
||||
var onEnd = function () {
|
||||
state.dragging = false;
|
||||
state.resizing = false;
|
||||
};
|
||||
|
||||
// Mouse
|
||||
circle.addEventListener('mousedown', function (e) {
|
||||
window.profileCropper._onStart(containerId, e.clientX, e.clientY);
|
||||
e.preventDefault();
|
||||
});
|
||||
document.addEventListener('mousemove', function (e) {
|
||||
window.profileCropper._onMove(containerId, e.clientX, e.clientY);
|
||||
});
|
||||
document.addEventListener('mouseup', function () {
|
||||
window.profileCropper._onEnd(containerId);
|
||||
});
|
||||
circle.addEventListener('mousedown', function (e) { onStart(e.clientX, e.clientY); e.preventDefault(); });
|
||||
document.addEventListener('mousemove', function (e) { onMove(e.clientX, e.clientY); });
|
||||
document.addEventListener('mouseup', onEnd);
|
||||
|
||||
// Touch
|
||||
circle.addEventListener('touchstart', function (e) {
|
||||
circle.addEventListener('touchstart', function (e) { var t = e.touches[0]; onStart(t.clientX, t.clientY); e.preventDefault(); }, { passive: false });
|
||||
document.addEventListener('touchmove', function (e) { var t = e.touches[0]; onMove(t.clientX, t.clientY); }, { passive: false });
|
||||
document.addEventListener('touchend', onEnd);
|
||||
|
||||
// Resize handle
|
||||
handle.addEventListener('mousedown', function (e) {
|
||||
state.resizing = true;
|
||||
state.startX = e.clientX;
|
||||
state.startY = e.clientY;
|
||||
state.startZoom = state.cropZoom;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
});
|
||||
handle.addEventListener('touchstart', function (e) {
|
||||
var t = e.touches[0];
|
||||
window.profileCropper._onStart(containerId, t.clientX, t.clientY);
|
||||
state.resizing = true;
|
||||
state.startX = t.clientX;
|
||||
state.startY = t.clientY;
|
||||
state.startZoom = state.cropZoom;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchmove', function (e) {
|
||||
var t = e.touches[0];
|
||||
window.profileCropper._onMove(containerId, t.clientX, t.clientY);
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchend', function () {
|
||||
window.profileCropper._onEnd(containerId);
|
||||
});
|
||||
};
|
||||
|
||||
img.onerror = function () {
|
||||
img.style.display = 'none';
|
||||
};
|
||||
|
||||
img.onerror = function () { img.style.display = 'none'; };
|
||||
img.src = imageUrl;
|
||||
},
|
||||
|
||||
_onStart: function (id, clientX, clientY) {
|
||||
var s = this._state[id];
|
||||
if (!s) return;
|
||||
s.dragging = true;
|
||||
s.startX = clientX;
|
||||
s.startY = clientY;
|
||||
s.startCX = s.cropX;
|
||||
s.startCY = s.cropY;
|
||||
},
|
||||
|
||||
_onMove: function (id, clientX, clientY) {
|
||||
var s = this._state[id];
|
||||
if (!s || !s.dragging) return;
|
||||
var rect = s.container.getBoundingClientRect();
|
||||
var dx = ((clientX - s.startX) / rect.width) * 100;
|
||||
var dy = ((clientY - s.startY) / rect.height) * 100;
|
||||
s.cropX = Math.max(0, Math.min(100, s.startCX + dx));
|
||||
s.cropY = Math.max(0, Math.min(100, s.startCY + dy));
|
||||
this._updateView(id);
|
||||
},
|
||||
|
||||
_onEnd: function (id) {
|
||||
var s = this._state[id];
|
||||
if (!s) return;
|
||||
s.dragging = false;
|
||||
},
|
||||
|
||||
_updateView: function (id) {
|
||||
var s = this._state[id];
|
||||
if (!s) return;
|
||||
|
||||
// Image position and zoom
|
||||
s.img.style.transformOrigin = s.cropX + '% ' + s.cropY + '%';
|
||||
s.img.style.transform = 'translate(-' + s.cropX + '%, -' + s.cropY + '%) scale(' + s.zoom + ')';
|
||||
|
||||
// Overlay mask: transparent circle at the crop center
|
||||
var rect = s.container.getBoundingClientRect();
|
||||
var px = (s.cropX / 100) * rect.width;
|
||||
var py = (s.cropY / 100) * rect.height;
|
||||
var radius = Math.min(rect.width, rect.height) * 0.23; // circle relative to container
|
||||
var mask = 'radial-gradient(circle at ' + px + 'px ' + py + 'px, transparent ' + radius + 'px, black ' + radius + 'px)';
|
||||
var vpPx = rect.width;
|
||||
var cxPx = (s.cropX / 100) * vpPx;
|
||||
var cyPx = (s.cropY / 100) * vpPx;
|
||||
var radiusPx = s.cropZoom * vpPx * 0.5;
|
||||
|
||||
// Overlay mask: transparent circle
|
||||
var mask = 'radial-gradient(circle at ' + cxPx + 'px ' + cyPx + 'px, transparent ' + radiusPx + 'px, black ' + radiusPx + 'px)';
|
||||
s.overlay.style.webkitMaskImage = mask;
|
||||
s.overlay.style.maskImage = mask;
|
||||
|
||||
// Square boundary around circle
|
||||
var side = radius * 2 / Math.sqrt(2); // inscribed square in circle
|
||||
var sx = px - side / 2;
|
||||
var sy = py - side / 2;
|
||||
// Square boundary (inscribed square in circle)
|
||||
var side = radiusPx * 2 / Math.sqrt(2);
|
||||
var sx = cxPx - side / 2;
|
||||
var sy = cyPx - side / 2;
|
||||
s.square.style.left = sx + 'px';
|
||||
s.square.style.top = sy + 'px';
|
||||
s.square.style.width = side + 'px';
|
||||
s.square.style.height = side + 'px';
|
||||
},
|
||||
|
||||
zoom: function (containerId, delta) {
|
||||
var s = this._state[containerId];
|
||||
if (!s) return;
|
||||
s.zoom = Math.max(1, Math.min(5, s.zoom + delta));
|
||||
this._updateView(containerId);
|
||||
// Resize handle at right edge of circle
|
||||
var handleX = cxPx + radiusPx;
|
||||
var handleY = cyPx;
|
||||
s.handle.style.left = (handleX - 8) + 'px';
|
||||
s.handle.style.top = (handleY - 8) + 'px';
|
||||
},
|
||||
|
||||
getCrop: function (containerId) {
|
||||
var s = this._state[containerId];
|
||||
if (!s) return { cropX: 50, cropY: 50, cropZoom: 1 };
|
||||
if (!s) return { cropX: 50, cropY: 50, cropZoom: 0.4 };
|
||||
return {
|
||||
cropX: Math.round(s.cropX * 10) / 10,
|
||||
cropY: Math.round(s.cropY * 10) / 10,
|
||||
cropZoom: Math.round(s.zoom * 100) / 100
|
||||
cropZoom: Math.round(s.cropZoom * 100) / 100
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -11922,21 +11922,6 @@ textarea.form-control-lg {
|
||||
}
|
||||
|
||||
/* ── Profile Cropper ── */
|
||||
.cropper-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cropper-zoom-label {
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bs-secondary, #aaa);
|
||||
}
|
||||
|
||||
.cropper-viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -11991,4 +11976,17 @@ textarea.form-control-lg {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.cropper-handle {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid var(--bs-primary, #888);
|
||||
cursor: nwse-resize;
|
||||
z-index: 3;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user