diff --git a/lang/ar/common.php b/lang/ar/common.php index ee82031c..85e9f7f0 100644 --- a/lang/ar/common.php +++ b/lang/ar/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'اسحب لإعادة التموضع', 'crop_save' => 'حفظ', 'crop_cancel' => 'إلغاء', + 'crop_error' => 'تعذّر تحميل هذه الصورة. جرّب ملفًا آخر.', ], 'timezone' => [ diff --git a/lang/de/common.php b/lang/de/common.php index d14036e6..15b2e67e 100644 --- a/lang/de/common.php +++ b/lang/de/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'Zum Verschieben ziehen', 'crop_save' => 'Speichern', 'crop_cancel' => 'Abbrechen', + 'crop_error' => 'Dieses Bild konnte nicht geladen werden. Versuchen Sie eine andere Datei.', ], 'timezone' => [ diff --git a/lang/el/common.php b/lang/el/common.php index 66633824..00f7733a 100644 --- a/lang/el/common.php +++ b/lang/el/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'Σύρετε για επανατοποθέτηση', 'crop_save' => 'Αποθήκευση', 'crop_cancel' => 'Άκυρο', + 'crop_error' => 'Δεν ήταν δυνατή η φόρτωση αυτής της εικόνας. Δοκιμάστε άλλο αρχείο.', ], 'timezone' => [ diff --git a/lang/en/common.php b/lang/en/common.php index 0fbe653f..294d55a6 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'Drag to reposition', 'crop_save' => 'Save', 'crop_cancel' => 'Cancel', + 'crop_error' => 'Couldn\'t load this image. Try another file.', ], 'timezone' => [ diff --git a/lang/es/common.php b/lang/es/common.php index d513ff5b..4947c817 100644 --- a/lang/es/common.php +++ b/lang/es/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'Arrastra para reposicionar', 'crop_save' => 'Guardar', 'crop_cancel' => 'Cancelar', + 'crop_error' => 'No se pudo cargar esta imagen. Prueba con otro archivo.', ], 'timezone' => [ diff --git a/lang/fr/common.php b/lang/fr/common.php index 6c167327..d893ced6 100644 --- a/lang/fr/common.php +++ b/lang/fr/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'Faites glisser pour repositionner', 'crop_save' => 'Enregistrer', 'crop_cancel' => 'Annuler', + 'crop_error' => 'Impossible de charger cette image. Essayez un autre fichier.', ], 'timezone' => [ diff --git a/lang/it/common.php b/lang/it/common.php index 367ce4de..438ee268 100644 --- a/lang/it/common.php +++ b/lang/it/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'Trascina per riposizionare', 'crop_save' => 'Salva', 'crop_cancel' => 'Annulla', + 'crop_error' => 'Impossibile caricare questa immagine. Prova un altro file.', ], 'timezone' => [ diff --git a/lang/ja/common.php b/lang/ja/common.php index 40551cef..43de5380 100644 --- a/lang/ja/common.php +++ b/lang/ja/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'ドラッグして移動', 'crop_save' => '保存', 'crop_cancel' => 'キャンセル', + 'crop_error' => 'この画像を読み込めませんでした。別のファイルをお試しください。', ], 'timezone' => [ diff --git a/lang/ko/common.php b/lang/ko/common.php index 9c434b1c..eaaf3249 100644 --- a/lang/ko/common.php +++ b/lang/ko/common.php @@ -26,6 +26,7 @@ 'crop_hint' => '드래그하여 위치 조정', 'crop_save' => '저장', 'crop_cancel' => '취소', + 'crop_error' => '이 이미지를 불러올 수 없습니다. 다른 파일을 사용해 보세요.', ], 'timezone' => [ diff --git a/lang/nl/common.php b/lang/nl/common.php index 74491bba..5ae09ecb 100644 --- a/lang/nl/common.php +++ b/lang/nl/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'Sleep om te verplaatsen', 'crop_save' => 'Opslaan', 'crop_cancel' => 'Annuleren', + 'crop_error' => 'Kan deze afbeelding niet laden. Probeer een ander bestand.', ], 'timezone' => [ diff --git a/lang/pl/common.php b/lang/pl/common.php index 5d298c0d..30b2470e 100644 --- a/lang/pl/common.php +++ b/lang/pl/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'Przeciągnij, aby zmienić położenie', 'crop_save' => 'Zapisz', 'crop_cancel' => 'Anuluj', + 'crop_error' => 'Nie można załadować tego obrazu. Spróbuj innego pliku.', ], 'timezone' => [ diff --git a/lang/pt-BR/common.php b/lang/pt-BR/common.php index dc059548..0bc91663 100644 --- a/lang/pt-BR/common.php +++ b/lang/pt-BR/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'Arraste para reposicionar', 'crop_save' => 'Salvar', 'crop_cancel' => 'Cancelar', + 'crop_error' => 'Não foi possível carregar esta imagem. Tente outro arquivo.', ], 'timezone' => [ diff --git a/lang/ru/common.php b/lang/ru/common.php index 0895c276..c4720bb6 100644 --- a/lang/ru/common.php +++ b/lang/ru/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'Перетащите, чтобы переместить', 'crop_save' => 'Сохранить', 'crop_cancel' => 'Отмена', + 'crop_error' => 'Не удалось загрузить это изображение. Попробуйте другой файл.', ], 'timezone' => [ diff --git a/lang/tr/common.php b/lang/tr/common.php index 6ea72d0d..eddef71c 100644 --- a/lang/tr/common.php +++ b/lang/tr/common.php @@ -26,6 +26,7 @@ 'crop_hint' => 'Yeniden konumlandırmak için sürükleyin', 'crop_save' => 'Kaydet', 'crop_cancel' => 'İptal', + 'crop_error' => 'Bu görsel yüklenemedi. Başka bir dosya deneyin.', ], 'timezone' => [ diff --git a/lang/zh/common.php b/lang/zh/common.php index 29a7e066..6f6fa51b 100644 --- a/lang/zh/common.php +++ b/lang/zh/common.php @@ -26,6 +26,7 @@ 'crop_hint' => '拖动以重新定位', 'crop_save' => '保存', 'crop_cancel' => '取消', + 'crop_error' => '无法加载此图片。请尝试其他文件。', ], 'timezone' => [ diff --git a/resources/js/components/ImageCropperDialog.vue b/resources/js/components/ImageCropperDialog.vue index a19e7d6e..c16d326f 100644 --- a/resources/js/components/ImageCropperDialog.vue +++ b/resources/js/components/ImageCropperDialog.vue @@ -47,15 +47,25 @@ const natural = ref({ width: 0, height: 0 }); const transform = ref({ scale: 1, x: 0, y: 0 }); const processing = ref(false); const initialized = ref(false); +const imageError = ref(false); let dragPointerId: number | null = null; let dragStart = { pointerX: 0, pointerY: 0, x: 0, y: 0 }; let resizeObserver: ResizeObserver | null = null; +const encodableMimes = ['image/jpeg', 'image/png', 'image/webp']; +const extensions: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }; + const ready = computed(() => viewportSize.value > 0 && natural.value.width > 0); const maskClass = computed(() => (props.shape === 'square' ? 'rounded-lg' : 'rounded-full')); +const outputMime = computed(() => (encodableMimes.includes(props.mimeType) ? props.mimeType : 'image/png')); + +const outputFileName = computed( + () => `${props.fileName.replace(/\.[^./]+$/, '') || 'image'}.${extensions[outputMime.value]}`, +); + const imageStyle = computed(() => ({ width: `${natural.value.width * transform.value.scale}px`, height: `${natural.value.height * transform.value.scale}px`, @@ -94,10 +104,20 @@ const onImageLoad = () => { return; } + if (img.naturalWidth === 0 || img.naturalHeight === 0) { + imageError.value = true; + + return; + } + natural.value = { width: img.naturalWidth, height: img.naturalHeight }; maybeInitialize(); }; +const onImageError = () => { + imageError.value = true; +}; + const onPointerDown = (event: PointerEvent) => { if (!ready.value) { return; @@ -187,10 +207,10 @@ const save = () => { return; } - emit('cropped', new File([blob], props.fileName, { type: props.mimeType })); + emit('cropped', new File([blob], outputFileName.value, { type: outputMime.value })); close(); }, - props.mimeType, + outputMime.value, 0.92, ); }; @@ -201,6 +221,7 @@ watch( if (isOpen) { initialized.value = false; processing.value = false; + imageError.value = false; await nextTick(); measure(); @@ -219,6 +240,7 @@ watch( () => props.src, () => { initialized.value = false; + imageError.value = false; natural.value = { width: 0, height: 0 }; }, ); @@ -244,7 +266,7 @@ onBeforeUnmount(() => resizeObserver?.disconnect()); @wheel="onWheel" > resizeObserver?.disconnect()); class="absolute left-0 top-0 max-w-none" :style="imageStyle" @load="onImageLoad" + @error="onImageError" />
+ {{ $t('common.photo_upload.crop_error') }} +
+
{ if (naturalWidth <= 0 || naturalHeight <= 0) { return 1; @@ -52,7 +54,8 @@ export const zoomTransform = ( naturalHeight: number, viewport: number, ): CropTransform => { - const nextScale = Math.max(transform.scale * factor, coverScale(naturalWidth, naturalHeight, viewport)); + const minScale = coverScale(naturalWidth, naturalHeight, viewport); + const nextScale = Math.min(minScale * MAX_ZOOM, Math.max(transform.scale * factor, minScale)); const center = viewport / 2; const sourceX = (center - transform.x) / transform.scale; const sourceY = (center - transform.y) / transform.scale; diff --git a/tests/Browser/ImageCropperTest.php b/tests/Browser/ImageCropperTest.php index 846c8711..6377707a 100644 --- a/tests/Browser/ImageCropperTest.php +++ b/tests/Browser/ImageCropperTest.php @@ -32,10 +32,11 @@ function selectPhoto(mixed $page): void } /** - * Capture the next multipart request the page sends. The Pest browser server - * does not parse multipart bodies (its file handling is an open TODO), so we - * assert the crop dispatches the right upload rather than that it persists — - * server-side persistence is covered by ProfileUpdateTest. + * Capture the next multipart upload the page sends, keeping the uploaded File so + * the test can decode it. The Pest browser server does not parse multipart + * bodies (its file handling is an open TODO), so we assert the crop dispatches + * a valid image rather than that it persists — persistence is covered by + * ProfileUpdateTest. */ function recordUpload(mixed $page): void { @@ -51,7 +52,14 @@ function recordUpload(mixed $page): void }; XMLHttpRequest.prototype.send = function (body) { if (body instanceof FormData) { - window.__uploadRequest = { method: this.__method, url: this.__url, keys: [...body.keys()] }; + const photo = body.get('photo'); + window.__uploadFile = photo; + window.__uploadRequest = { + method: this.__method, + url: this.__url, + keys: [...body.keys()], + size: photo instanceof File ? photo.size : 0, + }; } return send.apply(this, arguments); }; @@ -59,7 +67,7 @@ function recordUpload(mixed $page): void JS); } -test('cropping a selected photo dispatches a cropped avatar upload', function () { +test('cropping a selected photo dispatches a valid 512x512 avatar upload', function () { $this->actingAs(User::factory()->create()); $page = visit(route('app.profile.edit')); @@ -67,9 +75,6 @@ function recordUpload(mixed $page): void selectPhoto($page); recordUpload($page); - // Auto-waits for the cropper dialog to open and its Save button to become - // enabled — the button only enables once the image has loaded and been - // measured inside the modal, which is exactly what a broken cropper fails. $page->click('@crop-save') ->assertNoJavaScriptErrors(); @@ -78,12 +83,19 @@ function recordUpload(mixed $page): void for (let attempt = 0; attempt < 80 && !window.__uploadRequest; attempt++) { await new Promise((resolve) => setTimeout(resolve, 100)); } - return JSON.stringify(window.__uploadRequest); + if (!window.__uploadRequest) { + return 'null'; + } + const bitmap = await createImageBitmap(window.__uploadFile); + return JSON.stringify({ ...window.__uploadRequest, width: bitmap.width, height: bitmap.height }); })(); JS), true); expect($request)->not->toBeNull() ->and($request['method'])->toBe('POST') - ->and($request['url'])->toContain('/settings/profile/photo') - ->and($request['keys'])->toContain('photo'); + ->and($request['url'])->toContain(route('app.profile.upload-photo', absolute: false)) + ->and($request['keys'])->toContain('photo') + ->and($request['size'])->toBeGreaterThan(0) + ->and($request['width'])->toBe(512) + ->and($request['height'])->toBe(512); }); diff --git a/tests/Browser/ProbeTest.php b/tests/Browser/ProbeTest.php deleted file mode 100644 index a7d32640..00000000 --- a/tests/Browser/ProbeTest.php +++ /dev/null @@ -1,41 +0,0 @@ -create(); - $this->actingAs($user); - $base64 = base64_encode((string) file_get_contents(base_path('tests/fixtures/blue-logo.png'))); - - $page = visit(route('app.profile.edit')); - $page->script(<< { - const findInput = () => document.querySelector('input[type="file"]'); - for (let i = 0; i < 50 && !findInput(); i++) { await new Promise(r => setTimeout(r, 100)); } - const input = findInput(); - const bytes = Uint8Array.from(atob('{$base64}'), (c) => c.charCodeAt(0)); - const file = new File([bytes], 'logo.png', { type: 'image/png' }); - const dt = new DataTransfer(); dt.items.add(file); - input.files = dt.files; - input.dispatchEvent(new Event('change', { bubbles: true })); - })(); - JS); - $page->script(<<<'JS' - (() => { - window.__resp = null; - const oOpen = XMLHttpRequest.prototype.open; - XMLHttpRequest.prototype.open = function (m, u) { this.__u = u; this.addEventListener('loadend', () => { - if (String(this.__u).includes('/photo')) window.__resp = { status: this.status, body: (this.responseText || '').slice(0, 300) }; - }); return oOpen.apply(this, arguments); }; - })(); - JS); - $page->click('@crop-save'); - $info = $page->script(<<<'JS' - (async () => { for (let i = 0; i < 80 && !window.__resp; i++) { await new Promise(r => setTimeout(r, 100)); } return JSON.stringify(window.__resp || 'NO RESPONSE'); })(); - JS); - fwrite(STDERR, "\nUPLOAD_RESP => {$info}\n"); - fwrite(STDERR, 'DB_HAS_PHOTO => '.json_encode($user->fresh()->has_photo)."\n"); - expect(true)->toBeTrue(); -}); diff --git a/tests/BrowserTestCase.php b/tests/BrowserTestCase.php new file mode 100644 index 00000000..479adf35 --- /dev/null +++ b/tests/BrowserTestCase.php @@ -0,0 +1,14 @@ +extend(TestCase::class) ->use(RefreshDatabase::class) - ->in('Feature', 'Unit', 'Browser'); + ->in('Feature', 'Unit'); + +pest()->extend(BrowserTestCase::class) + ->use(RefreshDatabase::class) + ->in('Browser'); /* |-------------------------------------------------------------------------- diff --git a/tests/TestCase.php b/tests/TestCase.php index 09b229fc..388d375c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -17,21 +17,18 @@ abstract class TestCase extends BaseTestCase */ protected $seed = true; + /** + * Whether to fake the Vite manifest. Browser tests drive a real browser and + * need the built assets, so they opt out via BrowserTestCase. + */ + protected bool $fakesVite = true; + protected function setUp(): void { parent::setUp(); - if (! $this->isBrowserTest()) { + if ($this->fakesVite) { $this->withoutVite(); } } - - /** - * Browser tests drive a real browser and need the built Vite assets, so the - * Vite manifest must not be faked away for them. - */ - private function isBrowserTest(): bool - { - return str_contains(static::class, '\\Browser\\'); - } }