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,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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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[]>>({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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