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
+109
View File
@@ -0,0 +1,109 @@
package e2e
import (
"context"
"path/filepath"
"testing"
"time"
"galo/internal/albums"
"galo/internal/photos"
)
// TestEditPendingLifecycle covers the render-state flag clients use to decide
// whether the edit_* URLs can be shown: saving edits reports a pending render,
// the worker clears it and publishes the fresh URLs, a second edit keeps the
// previous render (still pending), and a reset clears both the flag and the
// edited version.
func TestEditPendingLifecycle(t *testing.T) {
ctx := context.Background()
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "editpending@test.local", "", "password123", "E")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Pending"})
if err != nil {
t.Fatalf("create album: %v", err)
}
p, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: user.ID, Filename: "p.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload: %v", err)
}
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
w.ProcessOnce(ctx)
first, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{
Light: photos.LightParams{Exposure: 0.5},
})
if err != nil {
t.Fatalf("edit: %v", err)
}
if !first.EditPending {
t.Fatalf("edit response does not report a pending render: %+v", first)
}
if first.EditPreviewURL != "" {
t.Fatalf("first render already exposes edit_preview_url: %q", first.EditPreviewURL)
}
w.ProcessOnce(ctx)
rendered, err := st.photos.Get(ctx, p.ID, user.ID)
if err != nil {
t.Fatalf("get rendered photo: %v", err)
}
if rendered.EditPending {
t.Fatalf("rendered photo still reports edit_pending: %+v", rendered)
}
if rendered.EditPreviewURL == "" {
t.Fatalf("rendered photo has no edit_preview_url: %+v", rendered)
}
firstPreview := rendered.EditPreviewURL
// A second edit is queued while the previous render is still bound: the
// photo stays pending and the old URLs must not be treated as current.
second, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{
Light: photos.LightParams{Exposure: 1.25},
})
if err != nil {
t.Fatalf("second edit: %v", err)
}
if !second.EditPending {
t.Fatalf("second edit does not report a pending render: %+v", second)
}
if second.EditPreviewURL != firstPreview {
t.Fatalf("second edit should keep the previous render while pending: %q want %q",
second.EditPreviewURL, firstPreview)
}
w.ProcessOnce(ctx)
after, err := st.photos.Get(ctx, p.ID, user.ID)
if err != nil {
t.Fatalf("get after second render: %v", err)
}
if after.EditPending {
t.Fatalf("second render still reports edit_pending: %+v", after)
}
if after.EditPreviewURL == "" || after.EditPreviewURL == firstPreview {
t.Fatalf("second render preview url = %q, want a fresh one (old %q)",
after.EditPreviewURL, firstPreview)
}
reset, err := st.photos.ResetEdit(ctx, p.ID, user.ID)
if err != nil {
t.Fatalf("reset: %v", err)
}
if reset.EditPending {
t.Fatalf("reset photo reports edit_pending: %+v", reset)
}
if reset.EditPreviewURL != "" || reset.EditURL != "" {
t.Fatalf("reset photo still exposes edit urls: %+v", reset)
}
if reset.EditParams != nil && len(reset.EditParams) > 0 {
t.Fatalf("reset photo still exposes edit params: %+v", reset.EditParams)
}
}
+21 -18
View File
@@ -108,24 +108,27 @@ type Photo struct {
Caption string `json:"caption"` Caption string `json:"caption"`
EXIF map[string]any `json:"exif,omitempty"` EXIF map[string]any `json:"exif,omitempty"`
EditParams map[string]any `json:"edit_params,omitempty"` EditParams map[string]any `json:"edit_params,omitempty"`
Tags []Tag `json:"tags,omitempty"` // EditPending is true while the server still renders the saved edit
DeletedAt *time.Time `json:"deleted_at,omitempty"` // parameters, so the edit_* URLs do not describe the current edit yet.
AlbumTitle string `json:"album_title,omitempty"` EditPending bool `json:"edit_pending"`
CreatedAt time.Time `json:"created_at"` Tags []Tag `json:"tags,omitempty"`
Uploader PublicUser `json:"uploader"` DeletedAt *time.Time `json:"deleted_at,omitempty"`
LikesCount int `json:"likes_count"` AlbumTitle string `json:"album_title,omitempty"`
CommentsCount int `json:"comments_count"` CreatedAt time.Time `json:"created_at"`
LikedByMe bool `json:"liked_by_me"` Uploader PublicUser `json:"uploader"`
ThumbURL string `json:"thumb_url,omitempty"` LikesCount int `json:"likes_count"`
MediumURL string `json:"medium_url,omitempty"` CommentsCount int `json:"comments_count"`
PreviewURL string `json:"preview_url,omitempty"` LikedByMe bool `json:"liked_by_me"`
PlaybackURL string `json:"playback_url,omitempty"` ThumbURL string `json:"thumb_url,omitempty"`
OriginalURL string `json:"original_url,omitempty"` MediumURL string `json:"medium_url,omitempty"`
DownloadURL string `json:"download_url,omitempty"` PreviewURL string `json:"preview_url,omitempty"`
EditURL string `json:"edit_url,omitempty"` PlaybackURL string `json:"playback_url,omitempty"`
EditThumbURL string `json:"edit_thumb_url,omitempty"` OriginalURL string `json:"original_url,omitempty"`
EditMediumURL string `json:"edit_medium_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
EditPreviewURL string `json:"edit_preview_url,omitempty"` EditURL string `json:"edit_url,omitempty"`
EditThumbURL string `json:"edit_thumb_url,omitempty"`
EditMediumURL string `json:"edit_medium_url,omitempty"`
EditPreviewURL string `json:"edit_preview_url,omitempty"`
} }
type Tag struct { type Tag struct {
+4 -3
View File
@@ -41,7 +41,8 @@ const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
COALESCE((SELECT json_group_array(json_object('id', t.id, 'name', t.name)) COALESCE((SELECT json_group_array(json_object('id', t.id, 'name', t.name))
FROM (SELECT t.id, t.name FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id FROM (SELECT t.id, t.name FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.photo_id = p.id AND t.user_id = $2 WHERE pt.photo_id = p.id AND t.user_id = $2
ORDER BY lower(t.name)) t), '[]')` ORDER BY lower(t.name)) t), '[]'),
EXISTS(SELECT 1 FROM edit_jobs ej WHERE ej.photo_id = p.id)`
const sqlitePhotoJoins = ` const sqlitePhotoJoins = `
LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original' LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original'
@@ -76,7 +77,7 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
&p.Kind, &p.DurationMS, &p.Kind, &p.DurationMS,
&takenAt, &lat, &long, &p.Caption, &createdAt, &takenAt, &lat, &long, &p.Caption, &createdAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &tagsText); err != nil { &p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &tagsText, &p.EditPending); err != nil {
return nil, err return nil, err
} }
p.TakenAt = sqliteutil.ParseNullTime(takenAt) p.TakenAt = sqliteutil.ParseNullTime(takenAt)
@@ -311,7 +312,7 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph
&p.Kind, &p.DurationMS, &p.Kind, &p.DurationMS,
&takenAt, &lat, &long, &p.Caption, &createdAt, &takenAt, &lat, &long, &p.Caption, &createdAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &albumTitle, &tagsText); err != nil { &p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &tagsText, &p.EditPending, &albumTitle); err != nil {
return nil, err return nil, err
} }
p.TakenAt = sqliteutil.ParseNullTime(takenAt) p.TakenAt = sqliteutil.ParseNullTime(takenAt)
+5 -4
View File
@@ -47,7 +47,8 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
COALESCE(p.edit_params, '{}'::jsonb)::text, COALESCE(p.edit_params, '{}'::jsonb)::text,
COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY lower(t.name)) COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY lower(t.name))
FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text` WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text,
EXISTS(SELECT 1 FROM edit_jobs ej WHERE ej.photo_id = p.id)`
const photoJoins = ` const photoJoins = `
LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original' LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original'
@@ -79,7 +80,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
&p.Kind, &p.DurationMS, &p.Kind, &p.DurationMS,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText) &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText, &p.EditPending)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -228,7 +229,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
&p.Kind, &p.DurationMS, &p.Kind, &p.DurationMS,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText); err != nil { &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText, &p.EditPending); err != nil {
return nil, err return nil, err
} }
setExif(&p, exifText) setExif(&p, exifText)
@@ -348,7 +349,7 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
&p.Kind, &p.DurationMS, &p.Kind, &p.DurationMS,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &p.AlbumTitle, &tagsText); err != nil { &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText, &p.EditPending, &p.AlbumTitle); err != nil {
return nil, err return nil, err
} }
setExif(&p, exifText) setExif(&p, exifText)
@@ -48,6 +48,7 @@ func Run(t *testing.T, r Repos) {
{"ShareLinks", testShareLinks}, {"ShareLinks", testShareLinks},
{"Social", testSocial}, {"Social", testSocial},
{"Photos", testPhotos}, {"Photos", testPhotos},
{"EditPending", testEditPending},
{"Objects", testObjects}, {"Objects", testObjects},
{"Clone", testClone}, {"Clone", testClone},
{"UserAvatar", testUserAvatar}, {"UserAvatar", testUserAvatar},
@@ -490,6 +491,11 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
t.Fatalf("cover not cleared: %+v", al.CoverPhotoID) t.Fatalf("cover not cleared: %+v", al.CoverPhotoID)
} }
// Trash rows are read with the same column list as the album list, so the
// tags JSON and the album title must land in their own fields.
if err := r.Photos.AttachTag(ctx, p.ID, tag.ID); err != nil {
t.Fatalf("re-attach tag for trash: %v", err)
}
if err := r.Photos.SetDeleted(ctx, p.ID, true); err != nil { if err := r.Photos.SetDeleted(ctx, p.ID, true); err != nil {
t.Fatalf("soft delete: %v", err) t.Fatalf("soft delete: %v", err)
} }
@@ -500,6 +506,12 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
if err != nil || len(trash) != 1 { if err != nil || len(trash) != 1 {
t.Fatalf("trash: %v len=%d", err, len(trash)) t.Fatalf("trash: %v len=%d", err, len(trash))
} }
if trash[0].AlbumTitle != a.Title {
t.Fatalf("trash album title = %q, want %q", trash[0].AlbumTitle, a.Title)
}
if len(trash[0].Tags) != 1 || trash[0].Tags[0].Name != "Vacation" {
t.Fatalf("trash tags = %+v", trash[0].Tags)
}
if err := r.Photos.RestoreToAlbum(ctx, p.ID, a.ID); err != nil { if err := r.Photos.RestoreToAlbum(ctx, p.ID, a.ID); err != nil {
t.Fatalf("restore: %v", err) t.Fatalf("restore: %v", err)
} }
@@ -563,6 +575,56 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
} }
} }
// testEditPending verifies that reads expose whether an edit render is still
// queued: clients rely on it to tell stale edit URLs from the current ones.
func testEditPending(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
getPending := func() bool {
t.Helper()
got, err := r.Photos.GetByID(ctx, p.ID, owner.ID)
if err != nil {
t.Fatalf("get photo: %v", err)
}
return got.EditPending
}
listPending := func() bool {
t.Helper()
list, err := r.Photos.List(ctx, photos.ListParams{AlbumID: a.ID, Viewer: owner.ID, Limit: 10})
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
return list[0].EditPending
}
if getPending() || listPending() {
t.Fatalf("photo without an edit job reports a pending edit render")
}
if err := r.Photos.EnqueueEditJob(ctx, p.ID); err != nil {
t.Fatalf("enqueue edit job: %v", err)
}
if !getPending() || !listPending() {
t.Fatalf("photo with a queued edit render does not report edit_pending")
}
if err := r.Photos.CompleteEditJob(ctx, p.ID); err != nil {
t.Fatalf("complete edit job: %v", err)
}
if getPending() || listPending() {
t.Fatalf("rendered photo still reports edit_pending")
}
if err := r.Photos.EnqueueEditJob(ctx, p.ID); err != nil {
t.Fatalf("enqueue again: %v", err)
}
if err := r.Photos.DeleteEditJob(ctx, p.ID); err != nil {
t.Fatalf("delete edit job: %v", err)
}
if getPending() {
t.Fatalf("photo reports edit_pending after the job was dropped")
}
}
func testObjects(t *testing.T, ctx context.Context, r Repos) { func testObjects(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner") owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID) a := seedAlbum(t, ctx, r, owner.ID)
@@ -87,8 +87,8 @@
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved)); const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
// Кнопка сброса активна и для сохранённых, и для ещё не отправленных правок. // Кнопка сброса активна и для сохранённых, и для ещё не отправленных правок.
const hasEdits = $derived(!isEmptyEditParams(saved) || !isEmptyEditParams(params)); 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 customPresets = $derived(normalizeCurvePresets(auth.preferences.curve_presets));
const effectiveCurves = $derived<Record<CurveChannel, CurvePoint[]>>({ const effectiveCurves = $derived<Record<CurveChannel, CurvePoint[]>>({
+91 -62
View File
@@ -19,10 +19,9 @@
isEmptyColor, isEmptyColor,
isEmptyEditParams, isEmptyEditParams,
isEmptyLight, isEmptyLight,
photoFullUrl,
photoPreviewUrl,
readEditParams readEditParams
} from '$lib/edit'; } from '$lib/edit';
import { computeViewerSource, editPendingOf } from '$lib/viewer-source';
import type { Photo } from '$lib/types'; import type { Photo } from '$lib/types';
interface Props { interface Props {
@@ -35,6 +34,10 @@
let { photos, index, onClose, onIndexChange }: Props = $props(); let { photos, index, onClose, onIndexChange }: Props = $props();
let fresh = $state<Photo | null>(null); let fresh = $state<Photo | null>(null);
// URL серверного рендера, который уже загружен и декодирован браузером.
// Пока он не готов, показывается предпросмотр — иначе при подмене на
// мгновение мелькает предыдущее изображение (в зуме — оригинал).
let serverImageSrc = $state('');
let touchX = 0; let touchX = 0;
let previewLow = $state(false); let previewLow = $state(false);
let useGL = $state(false); let useGL = $state(false);
@@ -57,7 +60,6 @@
let isFullscreen = $state(false); let isFullscreen = $state(false);
let zoomed = $state(false); let zoomed = $state(false);
let scale = $state(1); let scale = $state(1);
let hiResLoaded = $state(false);
let panoFailed = $state(false); let panoFailed = $state(false);
let panoRef: { reset: () => void } | undefined = $state(); let panoRef: { reset: () => void } | undefined = $state();
@@ -74,57 +76,76 @@
let startL = 0; let startL = 0;
let startT = 0; let startT = 0;
// Отредактированная версия фото показывается вместо исходной. // Варианты фото. Актуальный объект — current: его обновляют SSE-события,
const previewSrc = $derived( // поэтому URL берутся из него, а fresh остаётся фолбэком для страниц без
(fresh ? photoPreviewUrl(fresh) : undefined) ?? // SSE (просмотр по share-ссылке).
(current ? photoPreviewUrl(current) : undefined) ?? const basePreviewSrc = $derived(current?.preview_url || fresh?.preview_url || '');
fresh?.original_url ?? const originalSrc = $derived(current?.original_url || fresh?.original_url || '');
current?.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( const fullSrc = $derived(editFullSrc || originalSrc || '');
(fresh ? photoFullUrl(fresh) : undefined) ?? // Сервер ещё рендерит сохранённые правки: edit-URL не описывают текущую
(current ? photoFullUrl(current) : undefined) ?? // правку. Старый бэкенд поле не присылает — считаем рендер завершённым.
'' const editPending = $derived(current ? editPendingOf(current) : editPendingOf(fresh));
);
const originalSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
// Базовые (неотредактированные) версии: на них браузер рисует предпросмотр.
const basePreviewSrc = $derived(fresh?.preview_url ?? current?.preview_url ?? '');
// Пока сервер не отрендерил текущие правки, видимую область фото рисует // Пока сервер не отрендерил текущие правки, видимую область фото рисует
// сам браузер: правки видны сразу, без ожидания рендера. // сам браузер: правки видны сразу, без ожидания рендера.
const liveParams = $derived( const liveParams = $derived(
edit.photoId === current?.id ? edit.params : readEditParams(current) 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 savedParams = $derived(readEditParams(current));
const pending = $derived( const pending = $derived(
edit.photoId === current?.id && edit.photoId === current?.id &&
JSON.stringify(edit.params) !== JSON.stringify(savedParams) JSON.stringify(edit.params) !== JSON.stringify(savedParams)
); );
const needClientRender = $derived(hasEdits && (pending || !editUrl)); const source = $derived(
const clientPreview = $derived(needClientRender); computeViewerSource({
zoomed,
dirty: pending,
editPending,
localParamsEmpty: isEmptyEditParams(liveParams),
originalUrl: originalSrc,
previewUrl: basePreviewSrc,
editUrl: editFullSrc,
editPreviewUrl: editPreviewSrc
})
);
// Пока браузер рисует правки сам, под ним лежит исходная версия фото. // Переход «предпросмотр → серверная версия». Canvas держится только на
const clientSource = $derived( // время перехода, чтобы не мелькал оригинал; при простом открытии фото с
useGL // готовым рендером canvas не нужен — серверную версию грузит сам <img>.
? zoomed let previewSwapping = $state(false);
? originalSrc || basePreviewSrc let previousUseBase: boolean | null = null;
: basePreviewSrc
: previewLow // Подменяем предпросмотр серверной версией только после загрузки и
? basePreviewSrc // декодирования кадра: пока браузер не готов его показать, остаётся canvas.
: zoomed $effect(() => {
? originalSrc || basePreviewSrc const useBase = source.useBase;
: basePreviewSrc if (previousUseBase === true && !useBase) previewSwapping = true;
); if (useBase) previewSwapping = false;
const displaySrc = $derived( previousUseBase = useBase;
clientPreview });
? clientSource || previewSrc
: zoomed && hiResLoaded && fullSrc $effect(() => {
? fullSrc if (source.useBase || !previewSwapping) return;
: previewSrc 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 panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
const pano = $derived(panoTag && !panoFailed); const pano = $derived(panoTag && !panoFailed);
const panoSrc = $derived(fullSrc || previewSrc); const panoSrc = $derived(fullSrc || previewSrc);
@@ -134,6 +155,24 @@
fresh?.preview_url ?? fresh?.thumb_url ?? current?.preview_url ?? current?.thumb_url ?? '' 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(() => { $effect(() => {
// Пока открыт просмотр, клавиши `+`/`-` относятся к зуму фото. // Пока открыт просмотр, клавиши `+`/`-` относятся к зуму фото.
viewer.open = true; viewer.open = true;
@@ -145,14 +184,20 @@
}; };
}); });
// Фолбэк для страниц без SSE: перечитываем фото при смене версии превью.
// Ответы нумеруются, чтобы устаревший запрос не перебил более новый.
let freshSeq = 0;
$effect(() => { $effect(() => {
const id = current?.id; const id = current?.id;
// Версия фото входит в зависимость: после рендера правок превью // Версия фото входит в зависимость: после рендера правок превью
// меняется, и данные нужно перечитать. // меняется, и данные нужно перечитать.
current?.edit_preview_url ?? current?.preview_url ?? ''; current?.edit_preview_url ?? current?.preview_url ?? '';
if (!id) return; if (!id) return;
const seq = ++freshSeq;
api<{ photo: Photo }>(`/photos/${id}`) api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (fresh = data.photo)) .then((data) => {
if (seq === freshSeq) fresh = data.photo;
})
.catch(() => {}); .catch(() => {});
}); });
@@ -164,30 +209,14 @@
if (id === lastPhotoId) return; if (id === lastPhotoId) return;
lastPhotoId = id; lastPhotoId = id;
fresh = null; fresh = null;
serverImageSrc = '';
previewSwapping = false;
previousUseBase = null;
zoomed = false; zoomed = false;
scale = 1; scale = 1;
hiResLoaded = false;
panoFailed = 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(() => { $effect(() => {
const el = scroller; const el = scroller;
if (!el) return; if (!el) return;
@@ -917,7 +946,7 @@
onclick={onImageClick} onclick={onImageClick}
></button> ></button>
{/if} {/if}
{#if clientPreview} {#if canvasVisible}
<!-- Правки видны сразу: браузер рисует видимую область, пока <!-- Правки видны сразу: браузер рисует видимую область, пока
сервер готовит отредактированную версию фото. --> сервер готовит отредактированную версию фото. -->
<canvas <canvas
+4
View File
@@ -79,6 +79,10 @@ export interface Photo {
caption: string; caption: string;
exif?: Record<string, unknown>; exif?: Record<string, unknown>;
edit_params?: 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[]; tags?: Tag[];
deleted_at?: string | null; deleted_at?: string | null;
album_title?: string; 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
};
}