diff --git a/backend/internal/e2e/edit_pending_test.go b/backend/internal/e2e/edit_pending_test.go new file mode 100644 index 0000000..b081b57 --- /dev/null +++ b/backend/internal/e2e/edit_pending_test.go @@ -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) + } +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 7ddccc3..8611242 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -108,24 +108,27 @@ type Photo struct { Caption string `json:"caption"` EXIF map[string]any `json:"exif,omitempty"` EditParams map[string]any `json:"edit_params,omitempty"` - Tags []Tag `json:"tags,omitempty"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` - AlbumTitle string `json:"album_title,omitempty"` - CreatedAt time.Time `json:"created_at"` - Uploader PublicUser `json:"uploader"` - LikesCount int `json:"likes_count"` - CommentsCount int `json:"comments_count"` - LikedByMe bool `json:"liked_by_me"` - ThumbURL string `json:"thumb_url,omitempty"` - MediumURL string `json:"medium_url,omitempty"` - PreviewURL string `json:"preview_url,omitempty"` - PlaybackURL string `json:"playback_url,omitempty"` - OriginalURL string `json:"original_url,omitempty"` - DownloadURL string `json:"download_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"` + // EditPending is true while the server still renders the saved edit + // parameters, so the edit_* URLs do not describe the current edit yet. + EditPending bool `json:"edit_pending"` + Tags []Tag `json:"tags,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + AlbumTitle string `json:"album_title,omitempty"` + CreatedAt time.Time `json:"created_at"` + Uploader PublicUser `json:"uploader"` + LikesCount int `json:"likes_count"` + CommentsCount int `json:"comments_count"` + LikedByMe bool `json:"liked_by_me"` + ThumbURL string `json:"thumb_url,omitempty"` + MediumURL string `json:"medium_url,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` + PlaybackURL string `json:"playback_url,omitempty"` + OriginalURL string `json:"original_url,omitempty"` + DownloadURL string `json:"download_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 { diff --git a/backend/internal/photos/sqlite_store.go b/backend/internal/photos/sqlite_store.go index 8492bc0..3c1e6f8 100644 --- a/backend/internal/photos/sqlite_store.go +++ b/backend/internal/photos/sqlite_store.go @@ -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)) 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 - 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 = ` 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, &takenAt, &lat, &long, &p.Caption, &createdAt, &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 } p.TakenAt = sqliteutil.ParseNullTime(takenAt) @@ -311,7 +312,7 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph &p.Kind, &p.DurationMS, &takenAt, &lat, &long, &p.Caption, &createdAt, &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 } p.TakenAt = sqliteutil.ParseNullTime(takenAt) diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go index 763daf7..04efedb 100644 --- a/backend/internal/photos/store.go +++ b/backend/internal/photos/store.go @@ -47,7 +47,8 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, COALESCE(p.edit_params, '{}'::jsonb)::text, 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 - 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 = ` 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.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &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 errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -228,7 +229,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) &p.Kind, &p.DurationMS, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &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 } setExif(&p, exifText) @@ -348,7 +349,7 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e &p.Kind, &p.DurationMS, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &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 } setExif(&p, exifText) diff --git a/backend/internal/repo/contract/contract.go b/backend/internal/repo/contract/contract.go index d08133a..2040fcc 100644 --- a/backend/internal/repo/contract/contract.go +++ b/backend/internal/repo/contract/contract.go @@ -48,6 +48,7 @@ func Run(t *testing.T, r Repos) { {"ShareLinks", testShareLinks}, {"Social", testSocial}, {"Photos", testPhotos}, + {"EditPending", testEditPending}, {"Objects", testObjects}, {"Clone", testClone}, {"UserAvatar", testUserAvatar}, @@ -490,6 +491,11 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) { 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 { 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 { 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 { 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) { owner := seedUser(t, ctx, r, "Owner") a := seedAlbum(t, ctx, r, owner.ID) diff --git a/frontend/src/lib/components/PhotoEditPanel.svelte b/frontend/src/lib/components/PhotoEditPanel.svelte index ca1191e..412ae9f 100644 --- a/frontend/src/lib/components/PhotoEditPanel.svelte +++ b/frontend/src/lib/components/PhotoEditPanel.svelte @@ -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>({ diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte index c4fcbbf..74a8553 100644 --- a/frontend/src/lib/components/Viewer.svelte +++ b/frontend/src/lib/components/Viewer.svelte @@ -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(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 не нужен — серверную версию грузит сам . + 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 { + 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} > {/if} - {#if clientPreview} + {#if canvasVisible} ; edit_params?: Record; + // 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; diff --git a/frontend/src/lib/viewer-source.test.ts b/frontend/src/lib/viewer-source.test.ts new file mode 100644 index 0000000..c6f3c90 --- /dev/null +++ b/frontend/src/lib/viewer-source.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/viewer-source.ts b/frontend/src/lib/viewer-source.ts new file mode 100644 index 0000000..6a0a337 --- /dev/null +++ b/frontend/src/lib/viewer-source.ts @@ -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 + }; +}