feat: compare slider UI for photo enhance modal
- Side-by-side before/after comparison with draggable divider - Zoom (scroll wheel) and pan when zoomed in, double-click to reset - Sliders moved to sidebar panel, responsive layout - Original/Result badges on the comparison view - Helper functions: setEnhanceClip, showEnhanceResult, applyEnhanceTransform
This commit is contained in:
+48
-20
@@ -5,6 +5,33 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Журнал — Админ-панель</title>
|
<title>Журнал — Админ-панель</title>
|
||||||
<link rel="stylesheet" href="admin.css">
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
<style>
|
||||||
|
.enhance-modal { max-width: 1100px; width: 94vw; }
|
||||||
|
.enhance-body { display: flex; gap: 16px; align-items: stretch; margin-top: 4px; }
|
||||||
|
.enhance-compare { position: relative; flex: 1 1 auto; min-width: 0; border-radius: 10px; overflow: hidden; background: var(--bg); border: 1px solid var(--border); user-select: none; touch-action: none; cursor: ew-resize; }
|
||||||
|
.enhance-compare.zoomed { cursor: grab; }
|
||||||
|
.enhance-compare.panning { cursor: grabbing; }
|
||||||
|
.enhance-stage { transform-origin: 0 0; will-change: transform; }
|
||||||
|
.enhance-stage > img, .enhance-stage > canvas { display: block; width: 100%; height: 100%; }
|
||||||
|
.enhance-stage > img { height: auto; }
|
||||||
|
.enhance-top { position: absolute; inset: 0; overflow: hidden; }
|
||||||
|
.enhance-top .enhance-stage { position: absolute; inset: 0; }
|
||||||
|
.enhance-divider { position: absolute; top: 0; bottom: 0; left: 50%; width: 32px; transform: translateX(-50%); pointer-events: auto; cursor: ew-resize; }
|
||||||
|
.enhance-divider::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; transform: translateX(-50%); background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.6); pointer-events: none; }
|
||||||
|
.enhance-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #333; display: flex; align-items: center; justify-content: center; font-size: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.4); pointer-events: none; }
|
||||||
|
.enhance-badge { position: absolute; top: 10px; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: .72rem; pointer-events: none; }
|
||||||
|
.enhance-badge.left { left: 10px; }
|
||||||
|
.enhance-badge.right { right: 10px; }
|
||||||
|
.enhance-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: .85rem; background: var(--bg); }
|
||||||
|
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.enhance-sliders .settings-field input[type=range] { width: 100%; }
|
||||||
|
.enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.enhance-body { flex-direction: column; }
|
||||||
|
.enhance-sliders { flex: 1 1 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-page="journal">
|
<body data-page="journal">
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
@@ -162,31 +189,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="enhanceModal">
|
<div class="modal-overlay" id="enhanceModal">
|
||||||
<div class="edit-modal" style="max-width:960px">
|
<div class="edit-modal enhance-modal">
|
||||||
<h3>Улучшение фотографии</h3>
|
<h3>Улучшение фотографии</h3>
|
||||||
<div style="display:flex;gap:16px;flex-wrap:wrap">
|
<div class="enhance-body">
|
||||||
<div style="flex:1;min-width:280px">
|
<div class="enhance-compare" id="enhanceCompare">
|
||||||
<div style="font-size:.8rem;color:var(--muted);margin-bottom:6px">Оригинал</div>
|
<div class="enhance-stage" id="enhanceStage">
|
||||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;overflow:hidden;min-height:120px;display:flex;align-items:center;justify-content:center">
|
<img id="enhanceOrig" alt="Оригинал">
|
||||||
<img id="enhanceOrig" style="max-width:100%;max-height:320px;display:block">
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="enhance-top" id="enhanceTop" style="clip-path:inset(0 0 0 50%)">
|
||||||
|
<div class="enhance-stage" id="enhanceInner">
|
||||||
|
<canvas id="enhanceCanvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="enhance-divider" id="enhanceDivider"><div class="enhance-handle">⇔</div></div>
|
||||||
|
<div class="enhance-badge left">Оригинал</div>
|
||||||
|
<div class="enhance-badge right">Результат</div>
|
||||||
|
<div class="enhance-loading" id="enhanceLoading">Загрузка...</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1;min-width:280px">
|
<div class="enhance-sliders">
|
||||||
<div style="font-size:.8rem;color:var(--muted);margin-bottom:6px">Результат</div>
|
<div class="settings-field"><label>Яркость <span id="enhanceBrightnessVal">100%</span></label><input type="range" id="enhanceBrightness" min="50" max="150" value="100"></div>
|
||||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;overflow:hidden;min-height:120px;display:flex;align-items:center;justify-content:center">
|
<div class="settings-field"><label>Контраст <span id="enhanceContrastVal">100%</span></label><input type="range" id="enhanceContrast" min="50" max="150" value="100"></div>
|
||||||
<canvas id="enhanceCanvas" style="max-width:100%;max-height:320px;display:none"></canvas>
|
<div class="settings-field"><label>Насыщенность <span id="enhanceSaturateVal">100%</span></label><input type="range" id="enhanceSaturate" min="0" max="200" value="100"></div>
|
||||||
<span id="enhanceLoading" style="color:var(--muted);font-size:.8rem;padding:24px">Загрузка...</span>
|
<div class="settings-field"><label>Шумодав <span id="enhanceDenoiseVal">0%</span></label><input type="range" id="enhanceDenoise" min="0" max="100" value="0"></div>
|
||||||
</div>
|
<div class="settings-field"><label>Резкость <span id="enhanceSharpVal">0%</span></label><input type="range" id="enhanceSharp" min="0" max="100" value="0"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;margin-top:14px">
|
<div class="card-foot enhance-foot">
|
||||||
<div class="settings-field"><label>Яркость <span id="enhanceBrightnessVal">100%</span></label><input type="range" id="enhanceBrightness" min="50" max="150" value="100"></div>
|
|
||||||
<div class="settings-field"><label>Контраст <span id="enhanceContrastVal">100%</span></label><input type="range" id="enhanceContrast" min="50" max="150" value="100"></div>
|
|
||||||
<div class="settings-field"><label>Насыщенность <span id="enhanceSaturateVal">100%</span></label><input type="range" id="enhanceSaturate" min="0" max="200" value="100"></div>
|
|
||||||
<div class="settings-field"><label>Шумодав <span id="enhanceDenoiseVal">0%</span></label><input type="range" id="enhanceDenoise" min="0" max="100" value="0"></div>
|
|
||||||
<div class="settings-field"><label>Резкость <span id="enhanceSharpVal">0%</span></label><input type="range" id="enhanceSharp" min="0" max="100" value="0"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:6px">
|
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
|
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
|
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
|
||||||
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
||||||
|
|||||||
+110
-14
@@ -427,6 +427,107 @@ let enhanceEngineLoaded = false;
|
|||||||
let photoAiEnabled = false;
|
let photoAiEnabled = false;
|
||||||
let photoAiPollTimer = null;
|
let photoAiPollTimer = null;
|
||||||
let enhanceAiResultPath = null;
|
let enhanceAiResultPath = null;
|
||||||
|
let enhanceClipPos = 50;
|
||||||
|
|
||||||
|
function setEnhanceClip(p) {
|
||||||
|
enhanceClipPos = Math.min(100, Math.max(0, p));
|
||||||
|
document.getElementById('enhanceTop').style.clipPath = `inset(0 0 0 ${enhanceClipPos}%)`;
|
||||||
|
document.getElementById('enhanceDivider').style.left = enhanceClipPos + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEnhanceResult(show) {
|
||||||
|
document.getElementById('enhanceTop').style.display = show ? '' : 'none';
|
||||||
|
document.getElementById('enhanceLoading').style.display = show ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEnhanceLoadingText(text) {
|
||||||
|
const el = document.getElementById('enhanceLoading');
|
||||||
|
el.style.display = '';
|
||||||
|
el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
let enhanceZoom = 1;
|
||||||
|
let enhancePanX = 0;
|
||||||
|
let enhancePanY = 0;
|
||||||
|
|
||||||
|
function applyEnhanceTransform() {
|
||||||
|
const t = `translate(${enhancePanX}px, ${enhancePanY}px) scale(${enhanceZoom})`;
|
||||||
|
document.getElementById('enhanceStage').style.transform = t;
|
||||||
|
document.getElementById('enhanceInner').style.transform = t;
|
||||||
|
const area = document.getElementById('enhanceCompare');
|
||||||
|
area.classList.toggle('zoomed', enhanceZoom > 1);
|
||||||
|
if (enhanceZoom <= 1) area.classList.remove('panning');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampEnhancePan() {
|
||||||
|
const rect = document.getElementById('enhanceCompare').getBoundingClientRect();
|
||||||
|
const minX = rect.width * (1 - enhanceZoom);
|
||||||
|
const minY = rect.height * (1 - enhanceZoom);
|
||||||
|
enhancePanX = Math.min(0, Math.max(minX, enhancePanX));
|
||||||
|
enhancePanY = Math.min(0, Math.max(minY, enhancePanY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEnhanceZoom() {
|
||||||
|
enhanceZoom = 1;
|
||||||
|
enhancePanX = 0;
|
||||||
|
enhancePanY = 0;
|
||||||
|
applyEnhanceTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
(function initEnhanceDrag() {
|
||||||
|
const area = document.getElementById('enhanceCompare');
|
||||||
|
let dragging = false;
|
||||||
|
let dividerDrag = false;
|
||||||
|
let lastX = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
const move = ev => {
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
setEnhanceClip(((ev.clientX - rect.left) / rect.width) * 100);
|
||||||
|
};
|
||||||
|
area.addEventListener('pointerdown', ev => {
|
||||||
|
dividerDrag = !!ev.target.closest('#enhanceDivider');
|
||||||
|
dragging = true;
|
||||||
|
lastX = ev.clientX;
|
||||||
|
lastY = ev.clientY;
|
||||||
|
area.setPointerCapture(ev.pointerId);
|
||||||
|
if (!dividerDrag && enhanceZoom === 1) move(ev);
|
||||||
|
});
|
||||||
|
area.addEventListener('pointermove', ev => {
|
||||||
|
if (!dragging) return;
|
||||||
|
if (dividerDrag || enhanceZoom === 1) {
|
||||||
|
move(ev);
|
||||||
|
} else {
|
||||||
|
area.classList.add('panning');
|
||||||
|
enhancePanX += ev.clientX - lastX;
|
||||||
|
enhancePanY += ev.clientY - lastY;
|
||||||
|
lastX = ev.clientX;
|
||||||
|
lastY = ev.clientY;
|
||||||
|
clampEnhancePan();
|
||||||
|
applyEnhanceTransform();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const end = () => {
|
||||||
|
dragging = false;
|
||||||
|
area.classList.remove('panning');
|
||||||
|
};
|
||||||
|
area.addEventListener('pointerup', end);
|
||||||
|
area.addEventListener('pointercancel', end);
|
||||||
|
area.addEventListener('wheel', ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
const cx = ev.clientX - rect.left;
|
||||||
|
const cy = ev.clientY - rect.top;
|
||||||
|
const prev = enhanceZoom;
|
||||||
|
const next = Math.min(5, Math.max(1, enhanceZoom * (ev.deltaY < 0 ? 1.15 : 1 / 1.15)));
|
||||||
|
if (next === prev) return;
|
||||||
|
enhanceZoom = next;
|
||||||
|
enhancePanX = cx - (cx - enhancePanX) * (next / prev);
|
||||||
|
enhancePanY = cy - (cy - enhancePanY) * (next / prev);
|
||||||
|
clampEnhancePan();
|
||||||
|
applyEnhanceTransform();
|
||||||
|
}, { passive: false });
|
||||||
|
area.addEventListener('dblclick', resetEnhanceZoom);
|
||||||
|
})();
|
||||||
|
|
||||||
async function loadEnhanceEngine() {
|
async function loadEnhanceEngine() {
|
||||||
if (enhanceEngineLoaded) return;
|
if (enhanceEngineLoaded) return;
|
||||||
@@ -446,8 +547,6 @@ async function runPhotoAi() {
|
|||||||
enhanceSaving = true;
|
enhanceSaving = true;
|
||||||
const btn = document.getElementById('enhanceAiBtn');
|
const btn = document.getElementById('enhanceAiBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const loading = document.getElementById('enhanceLoading');
|
|
||||||
const canvas = document.getElementById('enhanceCanvas');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai`, { method: 'POST', headers: hdr() });
|
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai`, { method: 'POST', headers: hdr() });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -455,9 +554,8 @@ async function runPhotoAi() {
|
|||||||
throw new Error(err.error || 'Ошибка запуска ИИ');
|
throw new Error(err.error || 'Ошибка запуска ИИ');
|
||||||
}
|
}
|
||||||
const { jobId } = await res.json();
|
const { jobId } = await res.json();
|
||||||
canvas.style.display = 'none';
|
showEnhanceResult(false);
|
||||||
loading.style.display = '';
|
setEnhanceLoadingText('ИИ обрабатывает фото... (может занять до минуты)');
|
||||||
loading.textContent = 'ИИ обрабатывает фото... (может занять до минуты)';
|
|
||||||
btn.textContent = '⏳ ИИ...';
|
btn.textContent = '⏳ ИИ...';
|
||||||
const result = await new Promise((resolve, reject) => {
|
const result = await new Promise((resolve, reject) => {
|
||||||
photoAiPollTimer = setInterval(async () => {
|
photoAiPollTimer = setInterval(async () => {
|
||||||
@@ -488,8 +586,7 @@ async function runPhotoAi() {
|
|||||||
aiImg.onerror = () => { showToast('Не удалось загрузить результат ИИ'); };
|
aiImg.onerror = () => { showToast('Не удалось загрузить результат ИИ'); };
|
||||||
aiImg.src = `${API}${result.photo_path}`;
|
aiImg.src = `${API}${result.photo_path}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loading.style.display = 'none';
|
showEnhanceResult(true);
|
||||||
canvas.style.display = 'block';
|
|
||||||
alert(e.message);
|
alert(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
enhanceSaving = false;
|
enhanceSaving = false;
|
||||||
@@ -545,17 +642,17 @@ function openEnhance(id) {
|
|||||||
document.getElementById('enhanceAiBtn').style.display = photoAiEnabled ? '' : 'none';
|
document.getElementById('enhanceAiBtn').style.display = photoAiEnabled ? '' : 'none';
|
||||||
});
|
});
|
||||||
const canvas = document.getElementById('enhanceCanvas');
|
const canvas = document.getElementById('enhanceCanvas');
|
||||||
canvas.style.display = 'none';
|
setEnhanceClip(50);
|
||||||
const loading = document.getElementById('enhanceLoading');
|
resetEnhanceZoom();
|
||||||
loading.style.display = '';
|
showEnhanceResult(false);
|
||||||
loading.textContent = 'Загрузка...';
|
setEnhanceLoadingText('Загрузка...');
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
enhanceSourceImg = img;
|
enhanceSourceImg = img;
|
||||||
renderEnhancePreview();
|
renderEnhancePreview();
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
loading.textContent = 'Не удалось загрузить фото';
|
setEnhanceLoadingText('Не удалось загрузить фото');
|
||||||
};
|
};
|
||||||
img.src = `${API}${mp}`;
|
img.src = `${API}${mp}`;
|
||||||
document.getElementById('enhanceModal').classList.add('open');
|
document.getElementById('enhanceModal').classList.add('open');
|
||||||
@@ -651,8 +748,7 @@ function renderEnhancePreview() {
|
|||||||
canvas.height = h;
|
canvas.height = h;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
drawEnhanced(ctx, img, w, h);
|
drawEnhanced(ctx, img, w, h);
|
||||||
canvas.style.display = 'block';
|
showEnhanceResult(true);
|
||||||
document.getElementById('enhanceLoading').style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleEnhancePreview() {
|
function scheduleEnhancePreview() {
|
||||||
|
|||||||
Reference in New Issue
Block a user