feat(web): вкладка «Редактирование» с инструментом «Свет»
В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»: ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая с выбором канала. Правки сохраняются автоматически, переключатель «Сброс» удаляет параметры правок и отредактированную версию фото. Редактирование неразрушающее: параметры хранятся в метаданных фото, а отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb, edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется, скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают отредактированную версию, когда она отрендерена.
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
<script lang="ts">
|
||||
import { photosApi } from '$lib/api';
|
||||
import Icon from './Icon.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 hasEdits = $derived(!isEmptyEditParams(saved));
|
||||
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
|
||||
|
||||
// При смене фото параметры берутся из метаданных.
|
||||
let lastPhotoId = '';
|
||||
$effect(() => {
|
||||
const id = photo.id;
|
||||
if (id === lastPhotoId) return;
|
||||
lastPhotoId = id;
|
||||
params = readEditParams(photo);
|
||||
error = '';
|
||||
});
|
||||
|
||||
async function save(next: EditParams) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await photosApi.edit(photo.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 } };
|
||||
}
|
||||
|
||||
function onSliderCommit() {
|
||||
if (!canEdit) return;
|
||||
const next = normalizeEditParams(params);
|
||||
params = next;
|
||||
if (isEmptyEditParams(next)) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
void save(next);
|
||||
}
|
||||
|
||||
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 } }
|
||||
};
|
||||
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 } }
|
||||
};
|
||||
}
|
||||
|
||||
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 } }
|
||||
};
|
||||
onSliderCommit();
|
||||
}
|
||||
|
||||
function resetCurve() {
|
||||
if (!canEdit) return;
|
||||
const ch = channel;
|
||||
params = {
|
||||
...params,
|
||||
light: { ...params.light, curve: { ...params.light.curve, [ch]: undefined } }
|
||||
};
|
||||
onSliderCommit();
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await photosApi.resetEdit(photo.id);
|
||||
params = readEditParams(res.photo);
|
||||
onUpdated(res.photo);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Сброс доступен, когда у фото есть правки.
|
||||
async function toggleReset() {
|
||||
if (!hasEdits) return;
|
||||
await reset();
|
||||
}
|
||||
|
||||
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={CHANNEL_COLORS[channel]} stroke-width="1.2" />
|
||||
{#each channelPoints(channel) as point, index (index)}
|
||||
<circle
|
||||
cx={point.x * 100}
|
||||
cy={(1 - point.y) * 100}
|
||||
r="2.4"
|
||||
class="cursor-grab"
|
||||
fill="var(--bg)"
|
||||
stroke={CHANNEL_COLORS[channel]}
|
||||
stroke-width="1"
|
||||
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>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="text-danger text-xs">{error}</p>
|
||||
{/if}
|
||||
{#if dirty && !busy}
|
||||
<p class="text-text-subtle text-xs">Есть несохранённые изменения</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user