fix(photos): убрать мигание предпросмотра и задержку сброса правок
- фото и photo.updated несут edit_pending: рендер сохранённых правок ещё идёт - клиент держит предпросмотр, пока серверная версия не загружена и не декодирована, поэтому оригинал не мелькает при подмене - сброс возвратом контролов в значения по умолчанию виден сразу, не через 10 с - Viewer берёт URL из актуального объекта (SSE), fresh остаётся фолбэком - попутно исправлен порядок чтения tags и album_title в ListTrash
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,9 @@ type Photo struct {
|
||||
Caption string `json:"caption"`
|
||||
EXIF map[string]any `json:"exif,omitempty"`
|
||||
EditParams map[string]any `json:"edit_params,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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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