Цвета каналов задавались CSS-переменными темы прямо в SVG-атрибутах stroke и fill, но presentation-атрибуты не понимают var(), а переменные темы хранят компоненты HSL, а не готовый цвет. В итоге обводка была пустой, и на тёмном фоне графика кривую не было видно. Теперь цвет канала идёт через style в формате hsl(var(--text)), а заливка точек — hsl(var(--bg)).
408 lines
12 KiB
Svelte
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>
|