Files
ds-galo/frontend/src/lib/components/PhotoEditPanel.svelte
T
iu d904b2fe31 fix(web): линия тональной кривой была не видна
Цвета каналов задавались CSS-переменными темы прямо в SVG-атрибутах stroke и
fill, но presentation-атрибуты не понимают var(), а переменные темы хранят
компоненты HSL, а не готовый цвет. В итоге обводка была пустой, и на тёмном
фоне графика кривую не было видно.

Теперь цвет канала идёт через style в формате hsl(var(--text)), а заливка
точек — hsl(var(--bg)).
2026-10-21 01:24:40 +03:00

408 lines
12 KiB
Svelte

<script lang="ts">
import { photosApi } from '$lib/api';
import Icon from './Icon.svelte';
import { edit } from '$lib/stores/edit.svelte';
import {
CHANNEL_COLORS,
CHANNEL_LABELS,
CURVE_CHANNELS,
LIGHT_SLIDERS,
emptyEditParams,
isEmptyEditParams,
normalizeEditParams,
readEditParams,
type CurveChannel,
type CurvePoint,
type EditParams
} from '$lib/edit';
import type { Photo } from '$lib/types';
interface Props {
photo: Photo;
canEdit: boolean;
onUpdated: (photo: Photo) => void;
}
let { photo, canEdit, onUpdated }: Props = $props();
let params = $state<EditParams>(emptyEditParams());
let busy = $state(false);
let error = $state('');
let collapsed = $state(false);
let channel = $state<CurveChannel>('rgb');
const saved = $derived(readEditParams(photo));
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
// Кнопка сброса активна и для сохранённых, и для ещё не отправленных правок.
const hasEdits = $derived(!isEmptyEditParams(saved) || !isEmptyEditParams(params));
// Правки сохранены, но отредактированная версия ещё рендерится.
const rendering = $derived(hasEdits && !photo.edit_preview_url);
// При смене фото параметры берутся из метаданных.
let lastPhotoId = '';
$effect(() => {
const id = photo.id;
if (id === lastPhotoId) return;
lastPhotoId = id;
params = readEditParams(photo);
edit.set(id, params);
error = '';
return () => {
// Фото переключилось: отправляем накопленные правки.
flushSave();
};
});
// Правки уходят на сервер не сразу: пока пользователь крутит ползунки,
// фото перерисовывает браузер, а серверу достаётся уже settled-версия.
const SAVE_DELAY = 10_000;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let pendingId = $state('');
let pendingParams = $state<EditParams | null>(null);
function scheduleSave() {
pendingId = photo.id;
pendingParams = params;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
void flushSave();
}, SAVE_DELAY);
}
async function flushSave() {
if (!saveTimer && !pendingParams) return;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
const id = pendingId;
const next = pendingParams ? normalizeEditParams(pendingParams) : null;
pendingId = '';
pendingParams = null;
if (!id || !next) return;
if (isEmptyEditParams(next)) {
await resetEdit(id);
return;
}
await saveTo(id, next);
}
async function saveTo(id: string, next: EditParams) {
if (busy) return;
busy = true;
error = '';
try {
const res = await photosApi.edit(id, next);
onUpdated(res.photo);
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
function onSliderInput(key: (typeof LIGHT_SLIDERS)[number]['key'], value: number) {
params = { ...params, light: { ...params.light, [key]: value } };
// Живые параметры нужны просмотрщику: он рисует видимую область фото
// с правками, пока сервер не отрендерил версию.
edit.set(photo.id, params);
}
function onSliderCommit() {
if (!canEdit) return;
params = normalizeEditParams(params);
edit.set(photo.id, params);
// На сервер отправляем не сразу: ждём, пока пользователь закончит.
scheduleSave();
}
function setChannel(next: CurveChannel) {
channel = next;
}
function channelPoints(ch: CurveChannel): CurvePoint[] {
return params.light.curve?.[ch] ?? [];
}
function curvePath(ch: CurveChannel): string {
const pts = channelPoints(ch);
if (pts.length < 2) return 'M0,100 L100,0';
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x * 100},${(1 - p.y) * 100}`).join(' ');
}
let dragging = -1;
function pointFromEvent(ev: PointerEvent): CurvePoint {
const svg = (ev.currentTarget as SVGElement).ownerSVGElement ?? (ev.currentTarget as SVGElement);
const rect = svg.getBoundingClientRect();
const x = (ev.clientX - rect.left) / rect.width;
const y = 1 - (ev.clientY - rect.top) / rect.height;
return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) };
}
function onPointDown(ev: PointerEvent, index: number) {
if (!canEdit) return;
ev.preventDefault();
ev.stopPropagation();
dragging = index;
(ev.currentTarget as Element).setPointerCapture?.(ev.pointerId);
}
/** Клик по полю кривой добавляет точку. */
function onCurveDown(ev: PointerEvent) {
if (!canEdit) return;
const target = ev.target as Element | null;
if (target && target.tagName.toLowerCase() === 'circle') return;
const ch = channel;
const pts = [...channelPoints(ch)];
if (pts.length >= 16) return;
const point = pointFromEvent(ev);
let index = pts.findIndex((p) => p.x > point.x);
if (index < 0) index = pts.length;
pts.splice(index, 0, point);
params = {
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
};
edit.set(photo.id, params);
dragging = index;
}
function onPointMove(ev: PointerEvent) {
if (dragging < 0 || !canEdit) return;
const ch = channel;
const pts = [...channelPoints(ch)];
if (dragging >= pts.length) return;
pts[dragging] = pointFromEvent(ev);
params = {
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
};
edit.set(photo.id, params);
}
function onPointUp() {
if (dragging < 0) return;
dragging = -1;
onSliderCommit();
}
/** Точку кривой можно двигать и с клавиатуры. */
function onPointKey(ev: KeyboardEvent, index: number) {
if (!canEdit) return;
const step = ev.shiftKey ? 0.1 : 0.02;
let dx = 0;
let dy = 0;
if (ev.key === 'ArrowLeft') dx = -step;
else if (ev.key === 'ArrowRight') dx = step;
else if (ev.key === 'ArrowUp') dy = step;
else if (ev.key === 'ArrowDown') dy = -step;
else return;
ev.preventDefault();
const ch = channel;
const pts = [...channelPoints(ch)];
if (index >= pts.length) return;
pts[index] = {
x: Math.min(1, Math.max(0, pts[index].x + dx)),
y: Math.min(1, Math.max(0, pts[index].y + dy))
};
params = {
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
};
edit.set(photo.id, params);
onSliderCommit();
}
function resetCurve() {
if (!canEdit) return;
const ch = channel;
params = {
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: undefined } }
};
edit.set(photo.id, params);
onSliderCommit();
}
async function resetEdit(id: string) {
if (busy) return;
busy = true;
error = '';
try {
const res = await photosApi.resetEdit(id);
params = readEditParams(res.photo);
edit.set(id, params);
onUpdated(res.photo);
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
// Сброс доступен, когда у фото есть правки.
async function toggleReset() {
if (!hasEdits) return;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
pendingId = '';
pendingParams = null;
await resetEdit(photo.id);
}
const sliderClass = 'accent-primary h-1 w-full cursor-pointer';
const valueClass = 'text-text-muted text-xs tabular-nums';
function formatValue(value: number): string {
const rounded = Math.round(value * 10) / 10;
if (rounded === 0) return '0';
return rounded > 0 ? `+${rounded}` : `${rounded}`;
}
</script>
<div class="p-4">
<div class="flex items-center justify-between gap-2">
<button
type="button"
class="text-text hover:text-primary flex items-center gap-1 text-sm font-semibold"
aria-expanded={!collapsed}
onclick={() => (collapsed = !collapsed)}
>
<Icon
name="expand_more"
class="size-4 transition-transform {collapsed ? '-rotate-90' : ''}"
/>
Свет
</button>
<button
type="button"
class="border-border text-text-muted hover:text-text inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs disabled:opacity-40"
aria-pressed={hasEdits}
disabled={!canEdit || !hasEdits || busy}
title="Сбросить правки: удалить параметры и отредактированную версию"
onclick={toggleReset}
>
Сброс
</button>
</div>
{#if !collapsed}
<div class="mt-3 space-y-4">
<div class="space-y-3">
{#each LIGHT_SLIDERS as slider (slider.key)}
<div>
<div class="flex items-baseline justify-between gap-2">
<label class="text-text text-xs" for="light-{slider.key}">{slider.label}</label>
<span class={valueClass}>{formatValue(params.light[slider.key])}</span>
</div>
<input
id="light-{slider.key}"
type="range"
class={sliderClass}
min={slider.min}
max={slider.max}
step={slider.step}
value={params.light[slider.key]}
disabled={!canEdit}
oninput={(e) => onSliderInput(slider.key, Number(e.currentTarget.value))}
onchange={onSliderCommit}
/>
</div>
{/each}
</div>
<div class="border-border border-t pt-3">
<div class="flex items-center justify-between gap-2">
<span class="text-text text-xs font-semibold">Кривая</span>
<div class="flex items-center gap-1.5">
{#each CURVE_CHANNELS as ch (ch)}
<button
type="button"
class="size-4 rounded-full border-2 disabled:opacity-40"
style="border-color: {CHANNEL_COLORS[ch]}; background: {ch === channel
? CHANNEL_COLORS[ch]
: 'transparent'}"
aria-label="Канал {CHANNEL_LABELS[ch]}"
title="Канал {CHANNEL_LABELS[ch]}"
aria-pressed={ch === channel}
disabled={!canEdit}
onclick={() => setChannel(ch)}
></button>
{/each}
</div>
</div>
<svg
class="border-border mt-2 block aspect-square w-full touch-none rounded border"
viewBox="0 0 100 100"
role="application"
aria-label="Тоновая кривая"
onpointerdown={onCurveDown}
onpointermove={onPointMove}
onpointerup={onPointUp}
onpointerleave={onPointUp}
>
<rect x="0" y="0" width="100" height="100" class="bg-bg" />
{#each [25, 50, 75] as step (step)}
<line x1={step} y1="0" x2={step} y2="100" class="stroke-border" stroke-width="0.4" />
<line x1="0" y1={step} x2="100" y2={step} class="stroke-border" stroke-width="0.4" />
{/each}
<path
d={curvePath(channel)}
fill="none"
stroke-width="1.4"
style="stroke: {CHANNEL_COLORS[channel]}"
/>
{#each channelPoints(channel) as point, index (index)}
<circle
cx={point.x * 100}
cy={(1 - point.y) * 100}
r="2.4"
class="cursor-grab"
stroke-width="1.2"
style="fill: hsl(var(--bg)); stroke: {CHANNEL_COLORS[channel]}"
role="slider"
tabindex="0"
aria-label="Точка кривой {index + 1}"
aria-valuemin="0"
aria-valuemax="1"
aria-valuenow={point.x}
onpointerdown={(e) => onPointDown(e, index)}
onkeydown={(e) => onPointKey(e, index)}
></circle>
{/each}
</svg>
<div class="mt-2 flex items-center justify-between gap-2">
<span class="text-text-muted text-xs">Точечная кривая · линейная</span>
<button
type="button"
class="border-border text-text-muted hover:text-text rounded-md border px-2 py-1 text-xs disabled:opacity-40"
disabled={!canEdit || !channelPoints(channel).length}
onclick={resetCurve}
>
Сбросить кривую
</button>
</div>
</div>
{#if busy}
<p class="text-text-subtle text-xs">Сохраняем на сервере…</p>
{:else if dirty && !busy}
<p class="text-text-subtle text-xs">
Правки видны сразу, на сервер уйдут через 10 секунд без изменений
</p>
{:else if rendering}
<p class="text-text-subtle text-xs">Рендерим правки на сервере…</p>
{/if}
{#if error}
<p class="text-danger text-xs">{error}</p>
{/if}
</div>
{/if}
</div>