feat(web): вкладка «Редактирование» с инструментом «Свет»

В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая
с выбором канала. Правки сохраняются автоматически, переключатель «Сброс»
удаляет параметры правок и отредактированную версию фото.

Редактирование неразрушающее: параметры хранятся в метаданных фото, а
отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb,
edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется,
скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают
отредактированную версию, когда она отрендерена.
This commit is contained in:
iu
2026-10-20 23:06:24 +03:00
parent afaaaab5f0
commit 0548b1ab5e
32 changed files with 1954 additions and 24 deletions
@@ -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>
+10 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { grid } from '$lib/stores/grid.svelte';
import { photoMediumUrl, photoPreviewUrl, photoThumbUrl } from '$lib/edit';
import { isPanoTag } from '$lib/pano';
import type { Photo } from '$lib/types';
@@ -38,11 +39,15 @@
// Offer every variant the photo actually has; the backend generates the
// medium variant asynchronously, so an incomplete set must still render.
// When the photo is edited, the edited variants are shown.
function srcsetFor(photo: Photo): string {
const parts: string[] = [];
if (photo.thumb_url) parts.push(`${photo.thumb_url} 200w`);
if (photo.medium_url) parts.push(`${photo.medium_url} 800w`);
if (photo.preview_url) parts.push(`${photo.preview_url} 1200w`);
const thumb = photoThumbUrl(photo);
const medium = photoMediumUrl(photo);
const preview = photoPreviewUrl(photo);
if (thumb) parts.push(`${thumb} 200w`);
if (medium) parts.push(`${medium} 800w`);
if (preview) parts.push(`${preview} 1200w`);
return parts.join(', ');
}
@@ -106,9 +111,9 @@
ondragstart={(e) => onDragStart?.(i, e)}
oncontextmenu={(e) => onContextMenu?.(i, e)}
>
{#if photo.thumb_url}
{#if photoThumbUrl(photo)}
<img
src={photo.thumb_url}
src={photoThumbUrl(photo)}
srcset={srcsetFor(photo)}
{sizes}
alt={photo.original_filename}
+48 -1
View File
@@ -5,6 +5,7 @@
import Comments from './Comments.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte';
import PhotoEditPanel from './PhotoEditPanel.svelte';
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albums } from '$lib/stores/albums.svelte';
@@ -28,12 +29,18 @@
const canEdit = $derived(role === 'owner' || role === 'admin');
const canRename = $derived(canEdit || photo.uploader_id === auth.user?.id);
const canDelete = $derived(canEdit || photo.uploader_id === auth.user?.id);
// Правки может вносить и загрузивший фото, если у него есть право загрузки.
const canEditPhoto = $derived(
canEdit || (photo.uploader_id === auth.user?.id && role !== 'viewer')
);
let editingName = $state(false);
let nameDraft = $state('');
let savingName = $state(false);
let nameError = $state('');
let tab = $state<'info' | 'edit'>('info');
let pendingDelete = $state(false);
let deleting = $state(false);
@@ -65,6 +72,7 @@
showAll = false;
tagQuery = '';
tagOpen = false;
tab = 'info';
});
let tagQuery = $state('');
@@ -207,10 +215,47 @@
const navLink =
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
const tabClass =
'text-text-muted hover:text-text flex-1 border-b-2 border-transparent px-3 py-2 text-sm font-medium transition-colors aria-selected:border-primary aria-selected:text-text';
</script>
<div>
<div class="p-4">
<div
class="border-border bg-chrome sticky top-0 z-10 flex border-b"
role="tablist"
aria-label="Разделы фото"
>
<button
type="button"
role="tab"
id="photo-tab-info"
aria-controls="photo-panel-info"
aria-selected={tab === 'info'}
class={tabClass}
onclick={() => (tab = 'info')}
>
Сведения
</button>
<button
type="button"
role="tab"
id="photo-tab-edit"
aria-controls="photo-panel-edit"
aria-selected={tab === 'edit'}
class={tabClass}
onclick={() => (tab = 'edit')}
>
Редактирование
</button>
</div>
{#if tab === 'edit'}
<div id="photo-panel-edit" role="tabpanel" aria-labelledby="photo-tab-edit">
<PhotoEditPanel {photo} canEdit={canEditPhoto} onUpdated={(updated) => onRenamed?.(updated)} />
</div>
{:else}
<div id="photo-panel-info" role="tabpanel" aria-labelledby="photo-tab-info">
<div class="p-4">
{#if editingName}
<form class="relative" onsubmit={saveName}>
<input
@@ -443,6 +488,8 @@
<PanelSection id="photo-comments" title="Комментарии">
<Comments photoId={photo.id} />
</PanelSection>
</div>
{/if}
</div>
{#if settingsOpen}
+15 -2
View File
@@ -9,6 +9,7 @@
import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { viewer } from '$lib/stores/viewer.svelte';
import { photoFullUrl, photoPreviewUrl } from '$lib/edit';
import type { Photo } from '$lib/types';
interface Props {
@@ -55,10 +56,19 @@
let startL = 0;
let startT = 0;
// Отредактированная версия фото показывается вместо исходной.
const previewSrc = $derived(
fresh?.preview_url ?? fresh?.original_url ?? current?.preview_url ?? current?.original_url ?? ''
(fresh ? photoPreviewUrl(fresh) : undefined) ??
(current ? photoPreviewUrl(current) : undefined) ??
fresh?.original_url ??
current?.original_url ??
''
);
const fullSrc = $derived(
(fresh ? photoFullUrl(fresh) : undefined) ??
(current ? photoFullUrl(current) : undefined) ??
''
);
const fullSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
const displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
const pano = $derived(panoTag && !panoFailed);
@@ -79,6 +89,9 @@
$effect(() => {
const id = current?.id;
// Версия фото входит в зависимость: после рендера правок превью
// меняется, и данные нужно перечитать.
current?.edit_preview_url ?? current?.preview_url ?? '';
fresh = null;
zoomed = false;
scale = 1;