fix(photos): убрать мигание предпросмотра и задержку сброса правок
- фото и photo.updated несут edit_pending: рендер сохранённых правок ещё идёт - клиент держит предпросмотр, пока серверная версия не загружена и не декодирована, поэтому оригинал не мелькает при подмене - сброс возвратом контролов в значения по умолчанию виден сразу, не через 10 с - Viewer берёт URL из актуального объекта (SSE), fresh остаётся фолбэком - попутно исправлен порядок чтения tags и album_title в ListTrash
This commit is contained in:
@@ -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[]>>({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user