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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user