fix(photos): убрать мигание предпросмотра и задержку сброса правок

- фото и photo.updated несут edit_pending: рендер сохранённых правок ещё идёт
- клиент держит предпросмотр, пока серверная версия не загружена и не
  декодирована, поэтому оригинал не мелькает при подмене
- сброс возвратом контролов в значения по умолчанию виден сразу, не через 10 с
- Viewer берёт URL из актуального объекта (SSE), fresh остаётся фолбэком
- попутно исправлен порядок чтения tags и album_title в ListTrash
This commit is contained in:
iu
2026-10-25 10:02:04 +03:00
parent 1c29f5d961
commit 2b8d329a3e
10 changed files with 495 additions and 89 deletions
@@ -87,8 +87,8 @@
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
// Кнопка сброса активна и для сохранённых, и для ещё не отправленных правок.
const hasEdits = $derived(!isEmptyEditParams(saved) || !isEmptyEditParams(params));
// Правки сохранены, но отредактированная версия ещё рендерится.
const rendering = $derived(hasEdits && !photo.edit_preview_url);
// Правки сохранены, но сервер ещё рендерит отредактированную версию.
const rendering = $derived(photo.edit_pending === true);
const customPresets = $derived(normalizeCurvePresets(auth.preferences.curve_presets));
const effectiveCurves = $derived<Record<CurveChannel, CurvePoint[]>>({
+91 -62
View File
@@ -19,10 +19,9 @@
isEmptyColor,
isEmptyEditParams,
isEmptyLight,
photoFullUrl,
photoPreviewUrl,
readEditParams
} from '$lib/edit';
import { computeViewerSource, editPendingOf } from '$lib/viewer-source';
import type { Photo } from '$lib/types';
interface Props {
@@ -35,6 +34,10 @@
let { photos, index, onClose, onIndexChange }: Props = $props();
let fresh = $state<Photo | null>(null);
// URL серверного рендера, который уже загружен и декодирован браузером.
// Пока он не готов, показывается предпросмотр — иначе при подмене на
// мгновение мелькает предыдущее изображение (в зуме — оригинал).
let serverImageSrc = $state('');
let touchX = 0;
let previewLow = $state(false);
let useGL = $state(false);
@@ -57,7 +60,6 @@
let isFullscreen = $state(false);
let zoomed = $state(false);
let scale = $state(1);
let hiResLoaded = $state(false);
let panoFailed = $state(false);
let panoRef: { reset: () => void } | undefined = $state();
@@ -74,57 +76,76 @@
let startL = 0;
let startT = 0;
// Отредактированная версия фото показывается вместо исходной.
const previewSrc = $derived(
(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 originalSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
// Базовые (неотредактированные) версии: на них браузер рисует предпросмотр.
const basePreviewSrc = $derived(fresh?.preview_url ?? current?.preview_url ?? '');
// Варианты фото. Актуальный объект — current: его обновляют SSE-события,
// поэтому URL берутся из него, а fresh остаётся фолбэком для страниц без
// SSE (просмотр по share-ссылке).
const basePreviewSrc = $derived(current?.preview_url || fresh?.preview_url || '');
const originalSrc = $derived(current?.original_url || fresh?.original_url || '');
const editPreviewSrc = $derived(current?.edit_preview_url || fresh?.edit_preview_url || '');
const editFullSrc = $derived(current?.edit_url || fresh?.edit_url || '');
const previewSrc = $derived(editPreviewSrc || basePreviewSrc || originalSrc);
const fullSrc = $derived(editFullSrc || originalSrc || '');
// Сервер ещё рендерит сохранённые правки: edit-URL не описывают текущую
// правку. Старый бэкенд поле не присылает — считаем рендер завершённым.
const editPending = $derived(current ? editPendingOf(current) : editPendingOf(fresh));
// Пока сервер не отрендерил текущие правки, видимую область фото рисует
// сам браузер: правки видны сразу, без ожидания рендера.
const liveParams = $derived(
edit.photoId === current?.id ? edit.params : readEditParams(current)
);
const hasEdits = $derived(!isEmptyEditParams(liveParams));
const editUrl = $derived(fresh?.edit_url ?? current?.edit_url ?? '');
const savedParams = $derived(readEditParams(current));
const pending = $derived(
edit.photoId === current?.id &&
JSON.stringify(edit.params) !== JSON.stringify(savedParams)
);
const needClientRender = $derived(hasEdits && (pending || !editUrl));
const clientPreview = $derived(needClientRender);
const source = $derived(
computeViewerSource({
zoomed,
dirty: pending,
editPending,
localParamsEmpty: isEmptyEditParams(liveParams),
originalUrl: originalSrc,
previewUrl: basePreviewSrc,
editUrl: editFullSrc,
editPreviewUrl: editPreviewSrc
})
);
// Пока браузер рисует правки сам, под ним лежит исходная версия фото.
const clientSource = $derived(
useGL
? zoomed
? originalSrc || basePreviewSrc
: basePreviewSrc
: previewLow
? basePreviewSrc
: zoomed
? originalSrc || basePreviewSrc
: basePreviewSrc
);
const displaySrc = $derived(
clientPreview
? clientSource || previewSrc
: zoomed && hiResLoaded && fullSrc
? fullSrc
: previewSrc
// Переход «предпросмотр → серверная версия». Canvas держится только на
// время перехода, чтобы не мелькал оригинал; при простом открытии фото с
// готовым рендером canvas не нужен — серверную версию грузит сам <img>.
let previewSwapping = $state(false);
let previousUseBase: boolean | null = null;
// Подменяем предпросмотр серверной версией только после загрузки и
// декодирования кадра: пока браузер не готов его показать, остаётся canvas.
$effect(() => {
const useBase = source.useBase;
if (previousUseBase === true && !useBase) previewSwapping = true;
if (useBase) previewSwapping = false;
previousUseBase = useBase;
});
$effect(() => {
if (source.useBase || !previewSwapping) return;
const url = source.serverSrc;
if (!url || serverImageSrc === url) return;
let alive = true;
void decodeImage(url).then((ok) => {
if (alive && ok) serverImageSrc = url;
});
return () => {
alive = false;
};
});
const serverImageReady = $derived(
!previewSwapping || (source.serverSrc !== '' && serverImageSrc === source.serverSrc)
);
const clientPreview = $derived(source.useBase || !serverImageReady);
const canvasVisible = $derived(source.drawCanvas && clientPreview);
const displaySrc = $derived(clientPreview ? source.baseSrc : source.serverSrc);
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
const pano = $derived(panoTag && !panoFailed);
const panoSrc = $derived(fullSrc || previewSrc);
@@ -134,6 +155,24 @@
fresh?.preview_url ?? fresh?.thumb_url ?? current?.preview_url ?? current?.thumb_url ?? ''
);
/** Загружает и декодирует изображение; false — загрузить не удалось. */
async function decodeImage(url: string): Promise<boolean> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
if (typeof img.decode !== 'function') {
resolve(true);
return;
}
img.decode()
.then(() => resolve(true))
.catch(() => resolve(true));
};
img.onerror = () => resolve(false);
img.src = url;
});
}
$effect(() => {
// Пока открыт просмотр, клавиши `+`/`-` относятся к зуму фото.
viewer.open = true;
@@ -145,14 +184,20 @@
};
});
// Фолбэк для страниц без SSE: перечитываем фото при смене версии превью.
// Ответы нумеруются, чтобы устаревший запрос не перебил более новый.
let freshSeq = 0;
$effect(() => {
const id = current?.id;
// Версия фото входит в зависимость: после рендера правок превью
// меняется, и данные нужно перечитать.
current?.edit_preview_url ?? current?.preview_url ?? '';
if (!id) return;
const seq = ++freshSeq;
api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (fresh = data.photo))
.then((data) => {
if (seq === freshSeq) fresh = data.photo;
})
.catch(() => {});
});
@@ -164,30 +209,14 @@
if (id === lastPhotoId) return;
lastPhotoId = id;
fresh = null;
serverImageSrc = '';
previewSwapping = false;
previousUseBase = null;
zoomed = false;
scale = 1;
hiResLoaded = false;
panoFailed = false;
});
// In zoom mode, preload the full-resolution original and swap it in once loaded.
// Во время правок full-res не грузим: превью рисуется из base preview.
$effect(() => {
if (!zoomed || hiResLoaded || clientPreview) return;
const url = fullSrc;
if (!url) return;
const im = new Image();
let alive = true;
im.onload = () => {
if (alive) hiResLoaded = true;
};
im.src = url;
return () => {
alive = false;
im.onload = null;
};
});
$effect(() => {
const el = scroller;
if (!el) return;
@@ -917,7 +946,7 @@
onclick={onImageClick}
></button>
{/if}
{#if clientPreview}
{#if canvasVisible}
<!-- Правки видны сразу: браузер рисует видимую область, пока
сервер готовит отредактированную версию фото. -->
<canvas
+4
View File
@@ -79,6 +79,10 @@ export interface Photo {
caption: string;
exif?: Record<string, unknown>;
edit_params?: Record<string, unknown>;
// edit_pending is true while the server renders the saved edit parameters,
// so the edit_* URLs do not describe the current edit yet. Older backends
// omit the field (treated as false).
edit_pending?: boolean;
tags?: Tag[];
deleted_at?: string | null;
album_title?: string;
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import type { Photo } from './types';
import { computeViewerSource, editPendingOf } from './viewer-source';
const BASE = {
originalUrl: '/orig.jpg',
previewUrl: '/preview.jpg',
editUrl: '/edit.jpg',
editPreviewUrl: '/edit-preview.jpg'
};
describe('computeViewerSource', () => {
it('shows the server render when it matches the saved edits', () => {
const src = computeViewerSource({
zoomed: false,
dirty: false,
editPending: false,
localParamsEmpty: false,
...BASE
});
expect(src.useBase).toBe(false);
expect(src.serverSrc).toBe('/edit-preview.jpg');
expect(src.baseSrc).toBe('/preview.jpg');
});
it('uses the full-size variants in zoom', () => {
const src = computeViewerSource({
zoomed: true,
dirty: false,
editPending: false,
localParamsEmpty: false,
...BASE
});
expect(src.serverSrc).toBe('/edit.jpg');
expect(src.baseSrc).toBe('/orig.jpg');
});
it('keeps the client preview while local edits are unsaved', () => {
const src = computeViewerSource({
zoomed: true,
dirty: true,
editPending: false,
localParamsEmpty: false,
...BASE
});
expect(src.useBase).toBe(true);
expect(src.drawCanvas).toBe(true);
expect(src.baseSrc).toBe('/orig.jpg');
});
it('shows the base version immediately when the edits are reset to defaults', () => {
const src = computeViewerSource({
zoomed: true,
dirty: true,
editPending: false,
localParamsEmpty: true,
...BASE
});
expect(src.useBase).toBe(true);
expect(src.drawCanvas).toBe(false);
expect(src.baseSrc).toBe('/orig.jpg');
});
it('ignores a stale server render while the saved edits are still rendering', () => {
const src = computeViewerSource({
zoomed: false,
dirty: false,
editPending: true,
localParamsEmpty: false,
...BASE
});
expect(src.useBase).toBe(true);
expect(src.drawCanvas).toBe(true);
});
it('keeps the client preview when a second edit is pending over an old render', () => {
const src = computeViewerSource({
zoomed: true,
dirty: false,
editPending: true,
localParamsEmpty: false,
...BASE
});
expect(src.useBase).toBe(true);
expect(src.drawCanvas).toBe(true);
expect(src.baseSrc).toBe('/orig.jpg');
});
it('falls back to the base version when there is no edit render', () => {
const src = computeViewerSource({
zoomed: false,
dirty: false,
editPending: false,
localParamsEmpty: true,
originalUrl: '/orig.jpg',
previewUrl: '/preview.jpg'
});
expect(src.useBase).toBe(true);
expect(src.drawCanvas).toBe(false);
expect(src.serverSrc).toBe('/preview.jpg');
});
it('prefers the full-size base when zoomed without a preview', () => {
const src = computeViewerSource({
zoomed: true,
dirty: false,
editPending: false,
localParamsEmpty: true,
originalUrl: '/orig.jpg'
});
expect(src.baseSrc).toBe('/orig.jpg');
expect(src.serverSrc).toBe('/orig.jpg');
});
it('treats the old backend without edit_pending as a finished render', () => {
expect(editPendingOf(undefined)).toBe(false);
expect(editPendingOf({ id: 'p' } as Photo)).toBe(false);
expect(editPendingOf({ id: 'p', edit_pending: true } as Photo)).toBe(true);
});
});
+77
View File
@@ -0,0 +1,77 @@
import type { Photo } from './types';
/**
* Выбор изображения для просмотрщика. Модуль намеренно не использует руны
* Svelte: правила подмены предпросмотра серверной версией можно проверить
* обычными unit-тестами.
*
* Состояния:
* - локальные правки ещё не сохранены (`dirty`) или сервер ещё рендерит
* сохранённые правки (`editPending`) — показывать нельзя серверный рендер,
* он не соответствует параметрам; показываем базовую версию, поверх неё
* рисуется клиентский предпросмотр;
* - локальные правки пусты (сброс возвратом контролов) — сразу базовую версию
* без предпросмотра, не дожидаясь отложенного сохранения;
* - серверный рендер соответствует сохранённым правкам — показываем его, но
* только после загрузки и декодирования (решает компонент).
*/
export interface ViewerSourceInput {
/** Пользователь увеличил фото: показываем полноразмерную версию. */
zoomed: boolean;
/** Локальные параметры отличаются от сохранённых на сервере. */
dirty: boolean;
/** Сервер ещё рендерит сохранённые правки (Photo.edit_pending). */
editPending: boolean;
/** Локальные параметры правок пусты (значения по умолчанию). */
localParamsEmpty: boolean;
originalUrl?: string;
previewUrl?: string;
editUrl?: string;
editPreviewUrl?: string;
}
export interface ViewerSource {
/** Показывать базовую (неотредактированную) версию вместо серверного рендера. */
useBase: boolean;
/** Рисовать клиентский предпросмотр поверх базовой версии. */
drawCanvas: boolean;
/** URL базовой версии — подложка под предпросмотр. */
baseSrc: string;
/** URL серверной версии, который можно показать после загрузки. */
serverSrc: string;
}
/** Первый непустой URL из списка. */
function pick(urls: (string | undefined)[]): string {
for (const url of urls) {
if (url) return url;
}
return '';
}
/**
* Признак незавершённого рендера правок, устойчивый к старому бэкенду: поле
* `edit_pending` может отсутствовать, тогда рендер считается завершённым.
*/
export function editPendingOf(photo: Photo | null | undefined): boolean {
return photo?.edit_pending === true;
}
/** Вычисляет источник изображения для просмотрщика. */
export function computeViewerSource(input: ViewerSourceInput): ViewerSource {
const baseSrc = input.zoomed
? pick([input.originalUrl, input.previewUrl])
: pick([input.previewUrl, input.originalUrl]);
const serverSrc = input.zoomed
? pick([input.editUrl, input.editPreviewUrl])
: pick([input.editPreviewUrl, input.editUrl]);
const useBase = input.dirty || input.editPending || !serverSrc;
return {
useBase,
// Пустые локальные правки — это исходный вид: предпросмотр не нужен,
// базовая версия уже показывает оригинал.
drawCanvas: !input.localParamsEmpty,
baseSrc,
serverSrc: serverSrc || baseSrc
};
}